Merge pull request #99 from bknd-io/feat/data-api-bulk-update

feat/data-api-bulk-update
This commit is contained in:
dswbx
2025-02-26 19:55:22 +01:00
committed by GitHub
15 changed files with 291 additions and 246 deletions

View File

@@ -3,8 +3,7 @@ import { tbValidator as tb } from "core";
import { Type, TypeInvalidError, parse, transformObject } from "core/utils"; import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
import { DataPermissions } from "data"; import { DataPermissions } from "data";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { Controller } from "modules/Controller"; import { Controller, type ServerEnv } from "modules/Controller";
import type { ServerEnv } from "modules/Module";
export type AuthActionResponse = { export type AuthActionResponse = {
success: boolean; success: boolean;

View File

@@ -13,7 +13,7 @@ import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt"; import { sign, verify } from "hono/jwt";
import type { CookieOptions } from "hono/utils/cookie"; import type { CookieOptions } from "hono/utils/cookie";
import type { ServerEnv } from "modules/Module"; import type { ServerEnv } from "modules/Controller";
type Input = any; // workaround type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0]; export type JWTPayload = Parameters<typeof sign>[0];

View File

@@ -1,7 +1,7 @@
import { Exception, Permission } from "core"; import { Exception, Permission } from "core";
import { objectTransform } from "core/utils"; import { objectTransform } from "core/utils";
import type { Context } from "hono"; import type { Context } from "hono";
import type { ServerEnv } from "modules/Module"; import type { ServerEnv } from "modules/Controller";
import { Role } from "./Role"; import { Role } from "./Role";
export type GuardUserContext = { export type GuardUserContext = {

View File

@@ -2,7 +2,7 @@ import type { Permission } from "core";
import { patternMatch } from "core/utils"; import { patternMatch } from "core/utils";
import type { Context } from "hono"; import type { Context } from "hono";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import type { ServerEnv } from "modules/Module"; import type { ServerEnv } from "modules/Controller";
function getPath(reqOrCtx: Request | Context) { function getPath(reqOrCtx: Request | Context) {
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw; const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;

View File

@@ -111,56 +111,56 @@ export class DataController extends Controller {
/** /**
* Schema endpoints * Schema endpoints
*/ */
hono // read entity schema
// read entity schema hono.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
.get("/schema.json", permission(DataPermissions.entityRead), async (c) => { const $id = `${this.config.basepath}/schema.json`;
const $id = `${this.config.basepath}/schema.json`; const schemas = Object.fromEntries(
const schemas = Object.fromEntries( this.em.entities.map((e) => [
this.em.entities.map((e) => [ e.name,
e.name, {
{ $ref: `${this.config.basepath}/schemas/${e.name}`
$ref: `${this.config.basepath}/schemas/${e.name}`
}
])
);
return c.json({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id,
properties: schemas
});
})
// read schema
.get(
"/schemas/:entity/:context?",
permission(DataPermissions.entityRead),
tb(
"param",
Type.Object({
entity: Type.String(),
context: Type.Optional(StringEnum(["create", "update"]))
})
),
async (c) => {
//console.log("request", c.req.raw);
const { entity, context } = c.req.param();
if (!this.entityExists(entity)) {
console.warn("not found:", entity, definedEntities);
return c.notFound();
} }
const _entity = this.em.entity(entity); ])
const schema = _entity.toSchema({ context } as any);
const url = new URL(c.req.url);
const base = `${url.origin}${this.config.basepath}`;
const $id = `${this.config.basepath}/schemas/${entity}`;
return c.json({
$schema: `${base}/schema.json`,
$id,
title: _entity.label,
$comment: _entity.config.description,
...schema
});
}
); );
return c.json({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id,
properties: schemas
});
});
// read schema
hono.get(
"/schemas/:entity/:context?",
permission(DataPermissions.entityRead),
tb(
"param",
Type.Object({
entity: Type.String(),
context: Type.Optional(StringEnum(["create", "update"]))
})
),
async (c) => {
//console.log("request", c.req.raw);
const { entity, context } = c.req.param();
if (!this.entityExists(entity)) {
console.warn("not found:", entity, definedEntities);
return this.notFound(c);
}
const _entity = this.em.entity(entity);
const schema = _entity.toSchema({ context } as any);
const url = new URL(c.req.url);
const base = `${url.origin}${this.config.basepath}`;
const $id = `${this.config.basepath}/schemas/${entity}`;
return c.json({
$schema: `${base}/schema.json`,
$id,
title: _entity.label,
$comment: _entity.config.description,
...schema
});
}
);
// entity endpoints // entity endpoints
hono.route("/entity", this.getEntityRoutes()); hono.route("/entity", this.getEntityRoutes());
@@ -171,7 +171,7 @@ export class DataController extends Controller {
hono.get("/info/:entity", async (c) => { hono.get("/info/:entity", async (c) => {
const { entity } = c.req.param(); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return this.notFound(c);
} }
const _entity = this.em.entity(entity); const _entity = this.em.entity(entity);
const fields = _entity.fields.map((f) => f.name); const fields = _entity.fields.map((f) => f.name);
@@ -208,203 +208,232 @@ export class DataController extends Controller {
/** /**
* Function endpoints * Function endpoints
*/ */
hono // fn: count
// fn: count hono.post(
.post( "/:entity/fn/count",
"/:entity/fn/count", permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead), tb("param", Type.Object({ entity: Type.String() })),
tb("param", Type.Object({ entity: Type.String() })), async (c) => {
async (c) => { const { entity } = c.req.valid("param");
const { entity } = c.req.valid("param"); if (!this.entityExists(entity)) {
if (!this.entityExists(entity)) { return this.notFound(c);
return c.notFound();
}
const where = (await 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 where = (await c.req.json()) as any;
const result = await this.em.repository(entity).exists(where); const result = await this.em.repository(entity).count(where);
return c.json({ entity, exists: result.exists }); return c.json({ entity, count: result.count });
}
);
// fn: exists
hono.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 this.notFound(c);
} }
);
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 * Read endpoints
*/ */
hono // read many
// read many hono.get(
.get( "/:entity",
"/:entity", permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead), tb("param", Type.Object({ entity: Type.String() })),
tb("param", Type.Object({ entity: Type.String() })), tb("query", querySchema),
tb("query", querySchema), async (c) => {
async (c) => { //console.log("request", c.req.raw);
//console.log("request", c.req.raw); const { entity } = c.req.param();
const { entity } = c.req.param(); if (!this.entityExists(entity)) {
if (!this.entityExists(entity)) { console.warn("not found:", entity, definedEntities);
console.warn("not found:", entity, definedEntities); return this.notFound(c);
return c.notFound();
}
const options = c.req.valid("query") as RepoQuery;
//console.log("before", this.ctx.emgr.Events);
const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
} }
) const options = c.req.valid("query") as RepoQuery;
//console.log("before", this.ctx.emgr.Events);
const result = await this.em.repository(entity).findMany(options);
// read one return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
.get( }
"/:entity/:id", );
permission(DataPermissions.entityRead),
tb(
"param",
Type.Object({
entity: Type.String(),
id: tbNumber
})
),
tb("query", querySchema),
async (c) => {
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findId(Number(id), options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); // read one
hono.get(
"/:entity/:id",
permission(DataPermissions.entityRead),
tb(
"param",
Type.Object({
entity: Type.String(),
id: tbNumber
})
),
tb("query", querySchema),
async (c) => {
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
} }
) const options = c.req.valid("query") as RepoQuery;
// read many by reference const result = await this.em.repository(entity).findId(Number(id), options);
.get(
"/:entity/:id/:reference",
permission(DataPermissions.entityRead),
tb(
"param",
Type.Object({
entity: Type.String(),
id: tbNumber,
reference: Type.String()
})
),
tb("query", querySchema),
async (c) => {
const { entity, id, reference } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const options = c.req.valid("query") as RepoQuery; return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
const result = await this.em }
.repository(entity) );
.findManyByReference(Number(id), reference, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); // read many by reference
hono.get(
"/:entity/:id/:reference",
permission(DataPermissions.entityRead),
tb(
"param",
Type.Object({
entity: Type.String(),
id: tbNumber,
reference: Type.String()
})
),
tb("query", querySchema),
async (c) => {
const { entity, id, reference } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
} }
)
// func query
.post(
"/:entity/query",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema),
async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const options = (await c.req.valid("json")) as RepoQuery;
//console.log("options", options);
const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); const options = c.req.valid("query") as RepoQuery;
const result = await this.em
.repository(entity)
.findManyByReference(Number(id), reference, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
}
);
// func query
hono.post(
"/:entity/query",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema),
async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
} }
); const options = (await c.req.valid("json")) as RepoQuery;
//console.log("options", options);
const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
}
);
/** /**
* Mutation endpoints * Mutation endpoints
*/ */
// insert one // insert one
hono hono.post(
.post( "/:entity",
"/:entity", permission(DataPermissions.entityCreate),
permission(DataPermissions.entityCreate), tb("param", Type.Object({ entity: Type.String() })),
tb("param", Type.Object({ entity: Type.String() })), async (c) => {
async (c) => { const { entity } = c.req.param();
const { entity } = c.req.param(); if (!this.entityExists(entity)) {
if (!this.entityExists(entity)) { return this.notFound(c);
return c.notFound();
}
const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).insertOne(body);
return c.json(this.mutatorResult(result), 201);
} }
) const body = (await c.req.json()) as EntityData;
// update one const result = await this.em.mutator(entity).insertOne(body);
.patch(
"/:entity/:id",
permission(DataPermissions.entityUpdate),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => {
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).updateOne(Number(id), body);
return c.json(this.mutatorResult(result)); return c.json(this.mutatorResult(result), 201);
}
);
// update many
hono.patch(
"/:entity",
permission(DataPermissions.entityUpdate),
tb("param", Type.Object({ entity: Type.String() })),
tb(
"json",
Type.Object({
update: Type.Object({}),
where: querySchema.properties.where
})
),
async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
} }
) const { update, where } = (await c.req.json()) as {
// delete one update: EntityData;
.delete( where: RepoQuery["where"];
"/:entity/:id", };
permission(DataPermissions.entityDelete), const result = await this.em.mutator(entity).updateWhere(update, where);
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => {
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const result = await this.em.mutator(entity).deleteOne(Number(id));
return c.json(this.mutatorResult(result)); return c.json(this.mutatorResult(result));
}
);
// update one
hono.patch(
"/:entity/:id",
permission(DataPermissions.entityUpdate),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => {
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
} }
) const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).updateOne(Number(id), body);
// delete many return c.json(this.mutatorResult(result));
.delete( }
"/:entity", );
permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema.properties.where),
async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const where = c.req.valid("json") as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result)); // delete one
hono.delete(
"/:entity/:id",
permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => {
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
} }
); const result = await this.em.mutator(entity).deleteOne(Number(id));
return c.json(this.mutatorResult(result));
}
);
// delete many
hono.delete(
"/:entity",
permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema.properties.where),
async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const where = c.req.valid("json") as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result));
}
);
return hono; return hono;
} }

View File

@@ -1,7 +1,21 @@
import { Hono } from "hono"; import type { App } from "App";
import type { ServerEnv } from "modules/Module"; import { type Context, Hono } from "hono";
import * as middlewares from "modules/middlewares"; import * as middlewares from "modules/middlewares";
export type ServerEnv = {
Variables: {
app: App;
// to prevent resolving auth multiple times
auth?: {
resolved: boolean;
registered: boolean;
skip: boolean;
user?: { id: any; role?: string; [key: string]: any };
};
html?: string;
};
};
export class Controller { export class Controller {
protected middlewares = middlewares; protected middlewares = middlewares;
@@ -16,4 +30,19 @@ export class Controller {
getController(): Hono<ServerEnv> { getController(): Hono<ServerEnv> {
return this.create(); return this.create();
} }
protected isJsonRequest(c: Context<ServerEnv>) {
return (
c.req.header("Content-Type") === "application/json" ||
c.req.header("Accept") === "application/json"
);
}
protected notFound(c: Context<ServerEnv>) {
if (this.isJsonRequest(c)) {
return c.json({ error: "Not found" }, 404);
}
return c.notFound();
}
} }

View File

@@ -1,4 +1,3 @@
import type { App } from "App";
import type { Guard } from "auth"; import type { Guard } from "auth";
import { type DebugLogger, SchemaObject } from "core"; import { type DebugLogger, SchemaObject } from "core";
import type { EventManager } from "core/events"; import type { EventManager } from "core/events";
@@ -15,20 +14,7 @@ import {
import { Entity } from "data"; import { Entity } from "data";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import type { ServerEnv } from "modules/Controller";
export type ServerEnv = {
Variables: {
app: App;
// to prevent resolving auth multiple times
auth?: {
resolved: boolean;
registered: boolean;
skip: boolean;
user?: { id: any; role?: string; [key: string]: any };
};
html?: string;
};
};
export type ModuleBuildContext = { export type ModuleBuildContext = {
connection: Connection; connection: Connection;

View File

@@ -32,7 +32,8 @@ import { AppAuth } from "../auth/AppAuth";
import { AppData } from "../data/AppData"; import { AppData } from "../data/AppData";
import { AppFlows } from "../flows/AppFlows"; import { AppFlows } from "../flows/AppFlows";
import { AppMedia } from "../media/AppMedia"; import { AppMedia } from "../media/AppMedia";
import { Module, type ModuleBuildContext, type ServerEnv } from "./Module"; import type { ServerEnv } from "./Controller";
import { Module, type ModuleBuildContext } from "./Module";
export type { ModuleBuildContext }; export type { ModuleBuildContext };

View File

@@ -0,0 +1 @@
export { auth, permission } from "auth/middlewares";

View File

@@ -1,4 +1,4 @@
--- ---
title: 'Create Entity' title: 'Create Entity'
openapi: 'POST /api/data/{entity}' openapi: 'POST /api/data/entity/{entity}'
--- ---

View File

@@ -1,4 +1,4 @@
--- ---
title: 'Delete Entity' title: 'Delete Entity'
openapi: 'DELETE /api/data/{entity}/{id}' openapi: 'DELETE /api/data/entity/{entity}/{id}'
--- ---

View File

@@ -1,4 +1,4 @@
--- ---
title: 'Get Entity' title: 'Get Entity'
openapi: 'GET /api/data/{entity}/{id}' openapi: 'GET /api/data/entity/{entity}/{id}'
--- ---

View File

@@ -1,4 +1,4 @@
--- ---
title: 'List Entity' title: 'List Entity'
openapi: 'GET /api/data/{entity}' openapi: 'GET /api/data/entity/{entity}'
--- ---

View File

@@ -1,4 +1,4 @@
--- ---
title: 'Update Entity' title: 'Update Entity'
openapi: 'PATCH /api/data/{entity}/{id}' openapi: 'PATCH /api/data/entity/{entity}/{id}'
--- ---

View File

@@ -13,7 +13,7 @@ run on your toaster (probably).
</Note> </Note>
## Preview ## Preview
**bknd** is so lightweight that it fully runs inside StackBlitz. Take a look at the preview below: Here is a preview of **bknd** in StackBlitz:
<Stackblitz {...examples.adminRich} /> <Stackblitz {...examples.adminRich} />
<Accordion title="What's going on?" icon="lightbulb"> <Accordion title="What's going on?" icon="lightbulb">