diff --git a/app/__test__/api/DataApi.spec.ts b/app/__test__/api/DataApi.spec.ts index dbbe35d..c3c997a 100644 --- a/app/__test__/api/DataApi.spec.ts +++ b/app/__test__/api/DataApi.spec.ts @@ -17,11 +17,11 @@ describe("DataApi", () => { const get = api.readMany("a".repeat(300), { select: ["id", "name"] }); expect(get.request.method).toBe("GET"); - expect(new URL(get.request.url).pathname).toBe(`/api/data/${"a".repeat(300)}`); + expect(new URL(get.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(300)}`); const post = api.readMany("a".repeat(1000), { select: ["id", "name"] }); expect(post.request.method).toBe("POST"); - expect(new URL(post.request.url).pathname).toBe(`/api/data/${"a".repeat(1000)}/query`); + expect(new URL(post.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(1000)}/query`); }); it("returns result", async () => { @@ -39,7 +39,7 @@ describe("DataApi", () => { const app = controller.getController(); { - const res = (await app.request("/posts")) as Response; + const res = (await app.request("/entity/posts")) as Response; const { data } = await res.json(); expect(data.length).toEqual(3); } diff --git a/app/__test__/data/DataController.spec.ts b/app/__test__/data/DataController.spec.ts index 42ded5f..cec2022 100644 --- a/app/__test__/data/DataController.spec.ts +++ b/app/__test__/data/DataController.spec.ts @@ -116,7 +116,7 @@ describe("[data] DataController", async () => { //console.log("app.routes", app.routes); // create users for await (const _user of fixtures.users) { - const res = await app.request("/users", { + const res = await app.request("/entity/users", { method: "POST", body: JSON.stringify(_user) }); @@ -131,7 +131,7 @@ describe("[data] DataController", async () => { // create posts for await (const _post of fixtures.posts) { - const res = await app.request("/posts", { + const res = await app.request("/entity/posts", { method: "POST", body: JSON.stringify(_post) }); @@ -145,7 +145,7 @@ describe("[data] DataController", async () => { }); test("/:entity (read many)", async () => { - const res = await app.request("/users"); + const res = await app.request("/entity/users"); const data = (await res.json()) as RepositoryResponse; expect(data.meta.total).toBe(3); @@ -156,7 +156,7 @@ describe("[data] DataController", async () => { }); test("/:entity/query (func query)", async () => { - const res = await app.request("/users/query", { + const res = await app.request("/entity/users/query", { method: "POST", headers: { "Content-Type": "application/json" @@ -175,7 +175,7 @@ describe("[data] DataController", async () => { }); test("/:entity (read many, paginated)", async () => { - const res = await app.request("/users?limit=1&offset=2"); + const res = await app.request("/entity/users?limit=1&offset=2"); const data = (await res.json()) as RepositoryResponse; expect(data.meta.total).toBe(3); @@ -186,7 +186,7 @@ describe("[data] DataController", async () => { }); test("/:entity/:id (read one)", async () => { - const res = await app.request("/users/3"); + const res = await app.request("/entity/users/3"); const data = (await res.json()) as RepositoryResponse; console.log("data", data); @@ -197,7 +197,7 @@ describe("[data] DataController", async () => { }); test("/:entity (update one)", async () => { - const res = await app.request("/users/3", { + const res = await app.request("/entity/users/3", { method: "PATCH", body: JSON.stringify({ name: "new name" }) }); @@ -208,7 +208,7 @@ describe("[data] DataController", async () => { }); test("/:entity/:id/:reference (read references)", async () => { - const res = await app.request("/users/1/posts"); + const res = await app.request("/entity/users/1/posts"); const data = (await res.json()) as RepositoryResponse; console.log("data", data); @@ -220,14 +220,14 @@ describe("[data] DataController", async () => { }); test("/:entity/:id (delete one)", async () => { - const res = await app.request("/posts/2", { + const res = await app.request("/entity/posts/2", { method: "DELETE" }); const { data } = (await res.json()) as RepositoryResponse; expect(data).toEqual({ id: 2, ...fixtures.posts[1] }); // verify - const res2 = await app.request("/posts"); + const res2 = await app.request("/entity/posts"); const data2 = (await res2.json()) as RepositoryResponse; expect(data2.meta.total).toBe(1); }); diff --git a/app/__test__/integration/config.integration.test.ts b/app/__test__/integration/config.integration.test.ts new file mode 100644 index 0000000..6eef035 --- /dev/null +++ b/app/__test__/integration/config.integration.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { createApp } from "../../src"; +import { Api } from "../../src/Api"; + +describe("integration config", () => { + it("should create an entity", async () => { + const app = createApp(); + await app.build(); + const api = new Api({ + host: "http://localhost", + fetcher: app.server.request as typeof fetch + }); + + // create entity + await api.system.addConfig("data", "entities.posts", { + name: "posts", + config: { sort_field: "id", sort_dir: "asc" }, + fields: { id: { type: "primary", name: "id" }, asdf: { type: "text" } }, + type: "regular" + }); + + expect(app.em.entities.map((e) => e.name)).toContain("posts"); + }); +}); diff --git a/app/src/Api.ts b/app/src/Api.ts index f0946f0..2e35cee 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -22,6 +22,7 @@ export type ApiOptions = { key?: string; localStorage?: boolean; fetcher?: typeof fetch; + verbose?: boolean; verified?: boolean; } & ( | { @@ -196,7 +197,8 @@ export class Api { host: this.baseUrl, token: this.token, headers: this.options.headers, - token_transport: this.token_transport + token_transport: this.token_transport, + verbose: this.options.verbose }); } diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index aef4da0..3687395 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -132,6 +132,6 @@ export class AuthController extends Controller { return c.json({ strategies, basepath }); }); - return hono; + return hono.all("*", (c) => c.notFound()); } } diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index e444092..2861403 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -23,7 +23,10 @@ export class DataApi extends ModuleApi { id: PrimaryFieldType, query: Omit = {} ) { - return this.get, "meta" | "data">>([entity as any, id], query); + return this.get, "meta" | "data">>( + ["entity", entity as any, id], + query + ); } readMany( @@ -33,13 +36,13 @@ export class DataApi extends ModuleApi { type T = Pick, "meta" | "data">; const input = query ?? this.options.defaultQuery; - const req = this.get([entity as any], input); + const req = this.get(["entity", entity as any], input); if (req.request.url.length <= this.options.queryLengthLimit) { return req; } - return this.post([entity as any, "query"], input); + return this.post(["entity", entity as any, "query"], input); } readManyByReference< @@ -48,7 +51,7 @@ export class DataApi extends ModuleApi { Data = R extends keyof DB ? DB[R] : EntityData >(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) { return this.get, "meta" | "data">>( - [entity as any, id, reference], + ["entity", entity as any, id, reference], query ?? this.options.defaultQuery ); } @@ -57,7 +60,7 @@ export class DataApi extends ModuleApi { entity: E, input: Omit ) { - return this.post>([entity as any], input); + return this.post>(["entity", entity as any], input); } updateOne( @@ -65,19 +68,19 @@ export class DataApi extends ModuleApi { id: PrimaryFieldType, input: Partial> ) { - return this.patch>([entity as any, id], input); + return this.patch>(["entity", entity as any, id], input); } deleteOne( entity: E, id: PrimaryFieldType ) { - return this.delete>([entity as any, id]); + return this.delete>(["entity", entity as any, id]); } count(entity: E, where: RepoQuery["where"] = {}) { return this.post>( - [entity as any, "fn", "count"], + ["entity", entity as any, "fn", "count"], where ); } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index ff27a75..c7eea9b 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -109,44 +109,7 @@ export class DataController extends Controller { }); /** - * Function endpoints - */ - hono - // fn: count - .post( - "/:entity/fn/count", - permission(DataPermissions.entityRead), - tb("param", Type.Object({ entity: Type.String() })), - async (c) => { - const { entity } = c.req.valid("param"); - if (!this.entityExists(entity)) { - return c.notFound(); - } - - const where = c.req.json() as any; - const result = await this.em.repository(entity).count(where); - return c.json({ entity, count: result.count }); - } - ) - // fn: exists - .post( - "/:entity/fn/exists", - permission(DataPermissions.entityRead), - tb("param", Type.Object({ entity: Type.String() })), - async (c) => { - const { entity } = c.req.valid("param"); - if (!this.entityExists(entity)) { - return c.notFound(); - } - - const where = c.req.json() as any; - const result = await this.em.repository(entity).exists(where); - return c.json({ entity, exists: result.exists }); - } - ); - - /** - * Read endpoints + * Schema endpoints */ hono // read entity schema @@ -197,7 +160,64 @@ export class DataController extends Controller { ...schema }); } + ); + + // entity endpoints + hono.route("/entity", this.getEntityRoutes()); + + return hono.all("*", (c) => c.notFound()); + } + + private getEntityRoutes() { + const { permission } = this.middlewares; + const hono = this.create(); + + const definedEntities = this.em.entities.map((e) => e.name); + const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" })) + .Decode(Number.parseInt) + .Encode(String); + + /** + * Function endpoints + */ + hono + // fn: count + .post( + "/:entity/fn/count", + permission(DataPermissions.entityRead), + tb("param", Type.Object({ entity: Type.String() })), + async (c) => { + const { entity } = c.req.valid("param"); + if (!this.entityExists(entity)) { + return c.notFound(); + } + + const where = c.req.json() as any; + const result = await this.em.repository(entity).count(where); + return c.json({ entity, count: result.count }); + } ) + // fn: exists + .post( + "/:entity/fn/exists", + permission(DataPermissions.entityRead), + tb("param", Type.Object({ entity: Type.String() })), + async (c) => { + const { entity } = c.req.valid("param"); + if (!this.entityExists(entity)) { + return c.notFound(); + } + + const where = c.req.json() as any; + const result = await this.em.repository(entity).exists(where); + return c.json({ entity, exists: result.exists }); + } + ); + + /** + * Read endpoints + */ + hono // read many .get( "/:entity", diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index fc70cce..4194b2b 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -214,6 +214,6 @@ export class MediaController extends Controller { } ); - return hono; + return hono.all("*", (c) => c.notFound()); } } diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 27ad838..f493382 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -9,6 +9,7 @@ export type BaseModuleApiOptions = { token?: string; headers?: Headers; token_transport?: "header" | "cookie" | "none"; + verbose?: boolean; }; /** @deprecated */ @@ -107,7 +108,8 @@ export abstract class ModuleApi> implements Promise { public request: Request, protected options?: { fetcher?: typeof fetch; + verbose?: boolean; } ) {} + get verbose() { + return this.options?.verbose ?? false; + } + async execute(): Promise> { // delay in dev environment isDebug() && (await new Promise((resolve) => setTimeout(resolve, 200))); const fetcher = this.options?.fetcher ?? fetch; + if (this.verbose) { + console.log("[FetchPromise] Request", { + method: this.request.method, + url: this.request.url + }); + } + const res = await fetcher(this.request); + if (this.verbose) { + console.log("[FetchPromise] Response", { + res: res, + ok: res.ok, + status: res.status + }); + } + let resBody: any; let resData: any; @@ -242,6 +264,7 @@ export class FetchPromise> implements Promise { } else { resBody = res.body; } + console.groupEnd(); return createResponseProxy(res, resBody, resData); } diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index e251167..c323484 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -556,7 +556,9 @@ export class ModuleManager { await this.options?.onFirstBoot?.(); } - mutateConfigSafe(name: Module) { + mutateConfigSafe( + name: Module + ): Pick, "set" | "patch" | "overwrite" | "remove"> { const module = this.modules[name]; const copy = structuredClone(this.configs()); diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index ebcec9b..79b9064 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -142,14 +142,13 @@ export class SystemController extends Controller { const value = await c.req.json(); const path = c.req.param("path") as string; - const moduleConfig = this.app.mutateConfig(module); - if (moduleConfig.has(path)) { + if (this.app.modules.get(module).schema().has(path)) { return c.json({ success: false, path, error: "Path already exists" }, { status: 400 }); } console.log("-- add", module, path, value); return await handleConfigUpdateResponse(c, async () => { - await moduleConfig.patch(path, value); + await this.app.mutateConfig(module).patch(path, value); return { success: true, module, @@ -283,6 +282,6 @@ export class SystemController extends Controller { return c.json(generateOpenAPI(config)); }); - return hono; + return hono.all("*", (c) => c.notFound()); } } diff --git a/app/src/modules/server/openapi.ts b/app/src/modules/server/openapi.ts index 8db915d..7e8f56d 100644 --- a/app/src/modules/server/openapi.ts +++ b/app/src/modules/server/openapi.ts @@ -127,7 +127,7 @@ function dataRoutes(config: ModuleConfigs): { paths: OAS.Document["paths"] } { const tags = ["data"]; const paths: OAS.PathsObject = { - "/{entity}": { + "/entity/{entity}": { get: { summary: "List entities", parameters: [params.entity], @@ -148,7 +148,7 @@ function dataRoutes(config: ModuleConfigs): { paths: OAS.Document["paths"] } { tags } }, - "/{entity}/{id}": { + "/entity/{entity}/{id}": { get: { summary: "Get entity", parameters: [params.entity, params.entityId], diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index fc7fb3a..c81e5cb 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -1,4 +1,5 @@ import { Api, type ApiOptions, type TApiUser } from "Api"; +import { isDebug } from "core"; import { createContext, useContext } from "react"; const ClientContext = createContext<{ baseUrl: string; api: Api }>({ @@ -31,7 +32,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) } //console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user }); - const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user }); + const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user, verbose: isDebug() }); return ( diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index 9ed7e5b..5db48d8 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -91,7 +91,7 @@ "tags": ["system"] } }, - "/api/data/{entity}": { + "/api/data/entity/{entity}": { "get": { "summary": "List entities", "parameters": [ @@ -155,7 +155,7 @@ "tags": ["data"] } }, - "/api/data/{entity}/{id}": { + "/api/data/entity/{entity}/{id}": { "get": { "summary": "Get entity", "parameters": [