prefixed data entity endpoints with /entity

This commit is contained in:
dswbx
2025-02-18 09:12:12 +01:00
parent bd362607ae
commit 3e6d381239
14 changed files with 147 additions and 73 deletions

View File

@@ -17,11 +17,11 @@ describe("DataApi", () => {
const get = api.readMany("a".repeat(300), { select: ["id", "name"] }); const get = api.readMany("a".repeat(300), { select: ["id", "name"] });
expect(get.request.method).toBe("GET"); 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"] }); const post = api.readMany("a".repeat(1000), { select: ["id", "name"] });
expect(post.request.method).toBe("POST"); 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 () => { it("returns result", async () => {
@@ -39,7 +39,7 @@ describe("DataApi", () => {
const app = controller.getController(); 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(); const { data } = await res.json();
expect(data.length).toEqual(3); expect(data.length).toEqual(3);
} }

View File

@@ -116,7 +116,7 @@ describe("[data] DataController", async () => {
//console.log("app.routes", app.routes); //console.log("app.routes", app.routes);
// create users // create users
for await (const _user of fixtures.users) { for await (const _user of fixtures.users) {
const res = await app.request("/users", { const res = await app.request("/entity/users", {
method: "POST", method: "POST",
body: JSON.stringify(_user) body: JSON.stringify(_user)
}); });
@@ -131,7 +131,7 @@ describe("[data] DataController", async () => {
// create posts // create posts
for await (const _post of fixtures.posts) { for await (const _post of fixtures.posts) {
const res = await app.request("/posts", { const res = await app.request("/entity/posts", {
method: "POST", method: "POST",
body: JSON.stringify(_post) body: JSON.stringify(_post)
}); });
@@ -145,7 +145,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity (read many)", 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; const data = (await res.json()) as RepositoryResponse;
expect(data.meta.total).toBe(3); expect(data.meta.total).toBe(3);
@@ -156,7 +156,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity/query (func query)", async () => { test("/:entity/query (func query)", async () => {
const res = await app.request("/users/query", { const res = await app.request("/entity/users/query", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
@@ -175,7 +175,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity (read many, paginated)", 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; const data = (await res.json()) as RepositoryResponse;
expect(data.meta.total).toBe(3); expect(data.meta.total).toBe(3);
@@ -186,7 +186,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity/:id (read one)", 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<EntityData>; const data = (await res.json()) as RepositoryResponse<EntityData>;
console.log("data", data); console.log("data", data);
@@ -197,7 +197,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity (update one)", async () => { test("/:entity (update one)", async () => {
const res = await app.request("/users/3", { const res = await app.request("/entity/users/3", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ name: "new name" }) body: JSON.stringify({ name: "new name" })
}); });
@@ -208,7 +208,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity/:id/:reference (read references)", 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; const data = (await res.json()) as RepositoryResponse;
console.log("data", data); console.log("data", data);
@@ -220,14 +220,14 @@ describe("[data] DataController", async () => {
}); });
test("/:entity/:id (delete one)", async () => { test("/:entity/:id (delete one)", async () => {
const res = await app.request("/posts/2", { const res = await app.request("/entity/posts/2", {
method: "DELETE" method: "DELETE"
}); });
const { data } = (await res.json()) as RepositoryResponse<EntityData>; const { data } = (await res.json()) as RepositoryResponse<EntityData>;
expect(data).toEqual({ id: 2, ...fixtures.posts[1] }); expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
// verify // verify
const res2 = await app.request("/posts"); const res2 = await app.request("/entity/posts");
const data2 = (await res2.json()) as RepositoryResponse; const data2 = (await res2.json()) as RepositoryResponse;
expect(data2.meta.total).toBe(1); expect(data2.meta.total).toBe(1);
}); });

View File

@@ -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");
});
});

View File

@@ -22,6 +22,7 @@ export type ApiOptions = {
key?: string; key?: string;
localStorage?: boolean; localStorage?: boolean;
fetcher?: typeof fetch; fetcher?: typeof fetch;
verbose?: boolean;
verified?: boolean; verified?: boolean;
} & ( } & (
| { | {
@@ -196,7 +197,8 @@ export class Api {
host: this.baseUrl, host: this.baseUrl,
token: this.token, token: this.token,
headers: this.options.headers, headers: this.options.headers,
token_transport: this.token_transport token_transport: this.token_transport,
verbose: this.options.verbose
}); });
} }

View File

@@ -132,6 +132,6 @@ export class AuthController extends Controller {
return c.json({ strategies, basepath }); return c.json({ strategies, basepath });
}); });
return hono; return hono.all("*", (c) => c.notFound());
} }
} }

View File

@@ -23,7 +23,10 @@ export class DataApi extends ModuleApi<DataApiOptions> {
id: PrimaryFieldType, id: PrimaryFieldType,
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {} query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {}
) { ) {
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query); return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
["entity", entity as any, id],
query
);
} }
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>( readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
@@ -33,13 +36,13 @@ export class DataApi extends ModuleApi<DataApiOptions> {
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">; type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
const input = query ?? this.options.defaultQuery; const input = query ?? this.options.defaultQuery;
const req = this.get<T>([entity as any], input); const req = this.get<T>(["entity", entity as any], input);
if (req.request.url.length <= this.options.queryLengthLimit) { if (req.request.url.length <= this.options.queryLengthLimit) {
return req; return req;
} }
return this.post<T>([entity as any, "query"], input); return this.post<T>(["entity", entity as any, "query"], input);
} }
readManyByReference< readManyByReference<
@@ -48,7 +51,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
Data = R extends keyof DB ? DB[R] : EntityData Data = R extends keyof DB ? DB[R] : EntityData
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) { >(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>( return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any, id, reference], ["entity", entity as any, id, reference],
query ?? this.options.defaultQuery query ?? this.options.defaultQuery
); );
} }
@@ -57,7 +60,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
entity: E, entity: E,
input: Omit<Data, "id"> input: Omit<Data, "id">
) { ) {
return this.post<RepositoryResponse<Data>>([entity as any], input); return this.post<RepositoryResponse<Data>>(["entity", entity as any], input);
} }
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>( updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
@@ -65,19 +68,19 @@ export class DataApi extends ModuleApi<DataApiOptions> {
id: PrimaryFieldType, id: PrimaryFieldType,
input: Partial<Omit<Data, "id">> input: Partial<Omit<Data, "id">>
) { ) {
return this.patch<RepositoryResponse<Data>>([entity as any, id], input); return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input);
} }
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>( deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
id: PrimaryFieldType id: PrimaryFieldType
) { ) {
return this.delete<RepositoryResponse<Data>>([entity as any, id]); return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]);
} }
count<E extends keyof DB | string>(entity: E, where: RepoQuery["where"] = {}) { count<E extends keyof DB | string>(entity: E, where: RepoQuery["where"] = {}) {
return this.post<RepositoryResponse<{ entity: E; count: number }>>( return this.post<RepositoryResponse<{ entity: E; count: number }>>(
[entity as any, "fn", "count"], ["entity", entity as any, "fn", "count"],
where where
); );
} }

View File

@@ -109,44 +109,7 @@ export class DataController extends Controller {
}); });
/** /**
* Function endpoints * Schema 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 hono
// read entity schema // read entity schema
@@ -197,7 +160,64 @@ export class DataController extends Controller {
...schema ...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 // read many
.get( .get(
"/:entity", "/:entity",

View File

@@ -214,6 +214,6 @@ export class MediaController extends Controller {
} }
); );
return hono; return hono.all("*", (c) => c.notFound());
} }
} }

View File

@@ -9,6 +9,7 @@ export type BaseModuleApiOptions = {
token?: string; token?: string;
headers?: Headers; headers?: Headers;
token_transport?: "header" | "cookie" | "none"; token_transport?: "header" | "cookie" | "none";
verbose?: boolean;
}; };
/** @deprecated */ /** @deprecated */
@@ -107,7 +108,8 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
}); });
return new FetchPromise(request, { return new FetchPromise(request, {
fetcher: this.fetcher fetcher: this.fetcher,
verbose: this.options.verbose
}); });
} }
@@ -219,15 +221,35 @@ export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
public request: Request, public request: Request,
protected options?: { protected options?: {
fetcher?: typeof fetch; fetcher?: typeof fetch;
verbose?: boolean;
} }
) {} ) {}
get verbose() {
return this.options?.verbose ?? false;
}
async execute(): Promise<ResponseObject<T>> { async execute(): Promise<ResponseObject<T>> {
// delay in dev environment // delay in dev environment
isDebug() && (await new Promise((resolve) => setTimeout(resolve, 200))); isDebug() && (await new Promise((resolve) => setTimeout(resolve, 200)));
const fetcher = this.options?.fetcher ?? fetch; 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); 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 resBody: any;
let resData: any; let resData: any;
@@ -242,6 +264,7 @@ export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
} else { } else {
resBody = res.body; resBody = res.body;
} }
console.groupEnd();
return createResponseProxy<T>(res, resBody, resData); return createResponseProxy<T>(res, resBody, resData);
} }

View File

@@ -556,7 +556,9 @@ export class ModuleManager {
await this.options?.onFirstBoot?.(); await this.options?.onFirstBoot?.();
} }
mutateConfigSafe<Module extends keyof Modules>(name: Module) { mutateConfigSafe<Module extends keyof Modules>(
name: Module
): Pick<ReturnType<Modules[Module]["schema"]>, "set" | "patch" | "overwrite" | "remove"> {
const module = this.modules[name]; const module = this.modules[name];
const copy = structuredClone(this.configs()); const copy = structuredClone(this.configs());

View File

@@ -142,14 +142,13 @@ export class SystemController extends Controller {
const value = await c.req.json(); const value = await c.req.json();
const path = c.req.param("path") as string; const path = c.req.param("path") as string;
const moduleConfig = this.app.mutateConfig(module); if (this.app.modules.get(module).schema().has(path)) {
if (moduleConfig.has(path)) {
return c.json({ success: false, path, error: "Path already exists" }, { status: 400 }); return c.json({ success: false, path, error: "Path already exists" }, { status: 400 });
} }
console.log("-- add", module, path, value); console.log("-- add", module, path, value);
return await handleConfigUpdateResponse(c, async () => { return await handleConfigUpdateResponse(c, async () => {
await moduleConfig.patch(path, value); await this.app.mutateConfig(module).patch(path, value);
return { return {
success: true, success: true,
module, module,
@@ -283,6 +282,6 @@ export class SystemController extends Controller {
return c.json(generateOpenAPI(config)); return c.json(generateOpenAPI(config));
}); });
return hono; return hono.all("*", (c) => c.notFound());
} }
} }

View File

@@ -127,7 +127,7 @@ function dataRoutes(config: ModuleConfigs): { paths: OAS.Document["paths"] } {
const tags = ["data"]; const tags = ["data"];
const paths: OAS.PathsObject = { const paths: OAS.PathsObject = {
"/{entity}": { "/entity/{entity}": {
get: { get: {
summary: "List entities", summary: "List entities",
parameters: [params.entity], parameters: [params.entity],
@@ -148,7 +148,7 @@ function dataRoutes(config: ModuleConfigs): { paths: OAS.Document["paths"] } {
tags tags
} }
}, },
"/{entity}/{id}": { "/entity/{entity}/{id}": {
get: { get: {
summary: "Get entity", summary: "Get entity",
parameters: [params.entity, params.entityId], parameters: [params.entity, params.entityId],

View File

@@ -1,4 +1,5 @@
import { Api, type ApiOptions, type TApiUser } from "Api"; import { Api, type ApiOptions, type TApiUser } from "Api";
import { isDebug } from "core";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
const ClientContext = createContext<{ baseUrl: string; api: Api }>({ 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 }); //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 ( return (
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}> <ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>

View File

@@ -91,7 +91,7 @@
"tags": ["system"] "tags": ["system"]
} }
}, },
"/api/data/{entity}": { "/api/data/entity/{entity}": {
"get": { "get": {
"summary": "List entities", "summary": "List entities",
"parameters": [ "parameters": [
@@ -155,7 +155,7 @@
"tags": ["data"] "tags": ["data"]
} }
}, },
"/api/data/{entity}/{id}": { "/api/data/entity/{entity}/{id}": {
"get": { "get": {
"summary": "Get entity", "summary": "Get entity",
"parameters": [ "parameters": [