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

View File

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

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

View File

@@ -132,6 +132,6 @@ export class AuthController extends Controller {
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,
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>(
@@ -33,13 +36,13 @@ export class DataApi extends ModuleApi<DataApiOptions> {
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
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) {
return req;
}
return this.post<T>([entity as any, "query"], input);
return this.post<T>(["entity", entity as any, "query"], input);
}
readManyByReference<
@@ -48,7 +51,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
Data = R extends keyof DB ? DB[R] : EntityData
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
return this.get<Pick<RepositoryResponse<Data[]>, "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<DataApiOptions> {
entity: E,
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>(
@@ -65,19 +68,19 @@ export class DataApi extends ModuleApi<DataApiOptions> {
id: PrimaryFieldType,
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>(
entity: E,
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"] = {}) {
return this.post<RepositoryResponse<{ entity: E; count: number }>>(
[entity as any, "fn", "count"],
["entity", entity as any, "fn", "count"],
where
);
}

View File

@@ -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",

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;
headers?: Headers;
token_transport?: "header" | "cookie" | "none";
verbose?: boolean;
};
/** @deprecated */
@@ -107,7 +108,8 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
});
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,
protected options?: {
fetcher?: typeof fetch;
verbose?: boolean;
}
) {}
get verbose() {
return this.options?.verbose ?? false;
}
async execute(): Promise<ResponseObject<T>> {
// 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<T = ApiResponse<any>> implements Promise<T> {
} else {
resBody = res.body;
}
console.groupEnd();
return createResponseProxy<T>(res, resBody, resData);
}

View File

@@ -556,7 +556,9 @@ export class ModuleManager {
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 copy = structuredClone(this.configs());

View File

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

View File

@@ -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],

View File

@@ -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 (
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>