Files
bknd/app/src/data/api/DataController.ts
dswbx a298b65abf Release 0.16 (#196)
* initial refactor

* fixes

* test secrets extraction

* updated lock

* fix secret schema

* updated schemas, fixed tests, skipping flow tests for now

* added validator for rjsf, hook form via standard schema

* removed @sinclair/typebox

* remove unneeded vite dep

* fix jsonv literal on Field.tsx

* fix schema import path

* fix schema modals

* fix schema modals

* fix json field form, replaced auth form

* initial waku

* finalize waku example

* fix jsonv-ts version

* fix schema updates with falsy values

* fix media api to respect options' init, improve types

* checking media controller test

* checking media controller test

* checking media controller test

* clean up mediacontroller test

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials` (#214)

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials`

* fix server test

* fix data api (updated jsonv-ts)

* enhance cloudflare image optimization plugin with new options and explain endpoint (#215)

* feat: add ability to serve static by using dynamic imports (#197)

* feat: add ability to serve static by using dynamic imports

* serveStaticViaImport: make manifest optional

* serveStaticViaImport: add error log

* refactor/imports (#217)

* refactored core and core/utils imports

* refactored core and core/utils imports

* refactored media imports

* refactored auth imports

* refactored data imports

* updated package json exports, fixed mm config

* fix tests

* feat/deno (#219)

* update bun version

* fix module manager's em reference

* add basic deno example

* finalize

* docs: fumadocs migration (#185)

* feat(docs): initialize documentation structure with Fumadocs

* feat(docs): remove home route and move /docs route to /route

* feat(docs): add redirect to /start page

* feat(docs): migrate Getting Started chapters

* feat(docs): migrate Usage and Extending chapters

* feat(callout): add CalloutCaution, CalloutDanger, CalloutInfo, and CalloutPositive

* feat(layout): add Discord and GitHub links to documentation layout

* feat(docs): add integration chapters draft

* feat(docs): add modules chapters draft

* refactor(mdx-components): remove unused Icon import

* refactor(StackBlitz): enhance type safety by using unknown instead of any

* refactor(layout): update navigation mode to 'top' in layout configuration

* feat(docs): add @iconify/react package

* docs(mdx-components): add Icon component to MDX components list

* feat(docs): update Next.js integration guide

* feat(docs): update React Router integration guide

* feat(docs): update Astro integration guide

* feat(docs): update Vite integration guide

* fix(docs): update package manager initialization commands

* feat(docs): migrate Modules chapters

* chore(docs): update package.json with new devDependencies

* feat(docs): migrate Integration Runtimes chapters

* feat(docs): update Database usage chapter

* feat(docs): restructure documentation paths

* chore(docs): clean up unused imports and files in documentation

* style(layout): revert navigation mode to previous state

* fix(docs): routing for documentation structure

* feat(openapi): add API documentation generation from OpenAPI schema

* feat(docs): add icons to documentation pages

* chore(dependencies): remove unused content-collections packages

* fix(types): fix type error for attachFile in source.ts

* feat(redirects): update root redirect destination to '/start'

* feat(search): add static search functionality

* chore(dependencies): update fumadocs-core and fumadocs-ui to latest versions

* feat(search): add Powered by Orama link

* feat(generate-openapi): add error handling for missing OpenAPI schema

* feat(scripts): add OpenAPI generation to build process

* feat(config): enable dynamic redirects and rewrites in development mode

* feat(layout): add GitHub token support for improved API rate limits

* feat(redirects): add 301 redirects for cloudflare pages

* feat(docs): add Vercel redirects configuration

* feat(config): enable standalone output for development environment

* chore(layout): adjust layout settings

* refactor(package): clean up ajv dependency versions

* feat(docs): add twoslash support

* refactor(layout): update DocsLayout import and navigation configuration

* chore(layout): clean up layout.tsx by commenting out GithubInfo

* fix(Search): add locale to search initialization

* chore(package): update fumadocs and orama to latest versions

* docs: add menu items descriptions

* feat(layout): add GitHub URL to the layout component

* feat(docs): add AutoTypeTable component to MDX components

* feat(app): implement AutoTypeTable rendering for AppEvents type

* docs(layout): switch callouts back to default components

* fix(config): use __filename and __dirname for module paths

* docs: add note about node.js 22 requirement

* feat(styles): add custom color variables for light and dark themes

* docs: add S3 setup instructions for media module

* docs: fix typos and indentation in media module docs

* docs: add local media adapter example for Node.js

* docs(media): add S3/R2 URL format examples and fix typo

* docs: add cross-links to initial config and seeding sections

* indent numbered lists content, clarified media serve locations

* fix mediacontroller tests

* feat(layout): add AnimatedGridPattern component for dynamic background

* style(layout): configure fancy ToC style ('clerk')

* fix(AnimatedGridPattern): correct strokeDasharray type

* docs: actualize docs

* feat: add favicon

* style(cloudflare): format code examples

* feat(layout): add Github and Discord footer icons

* feat(footer): add SVG social media icons for GitHub and Discord

* docs: adjusted auto type table, added llm functions

* added static deployment to cloudflare workers

* docs: change cf redirects to proxy *.mdx instead of redirecting

---------

Co-authored-by: dswbx <dennis.senn@gmx.ch>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>

* build: improve build script

* add missing exports, fix EntityTypescript imports

* media: Dropzone: add programmatic upload, additional events, loading state

* schema object: disable extended defaults to allow empty config values

* Feat/new docs deploy (#224)

* test

* try fixing pm

* try fixing pm

* fix docs on imports, export events correctly

---------

Co-authored-by: Tim Seriakov <59409712+timseriakov@users.noreply.github.com>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>
2025-08-01 15:55:59 +02:00

520 lines
16 KiB
TypeScript

import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller";
import { jsc, s, describeRoute, schemaToSpec, omitKeys } from "bknd/utils";
import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema";
import type { EntityManager, EntityData } from "data/entities";
import * as DataPermissions from "data/permissions";
import { repoQuery, type RepoQuery } from "data/server/query";
export class DataController extends Controller {
constructor(
private readonly ctx: ModuleBuildContext,
private readonly config: AppDataConfig,
) {
super();
}
get em(): EntityManager<any> {
return this.ctx.em;
}
get guard() {
return this.ctx.guard;
}
entityExists(entity: string) {
try {
return !!this.em.entity(entity);
} catch (e) {
return false;
}
}
override getController() {
const { permission, auth } = this.middlewares;
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: sample implementation how to augment handler with additional info
function handler<HH extends Handler>(name: string, h: HH): any {
const func = h;
// @ts-ignore
func.description = name;
return func;
}
// info
hono.get(
"/",
describeRoute({
summary: "Retrieve data configuration",
tags: ["data"],
}),
handler("data info", (c) => {
// sample implementation
return c.json(this.em.toJSON());
}),
);
// sync endpoint
hono.get(
"/sync",
permission(DataPermissions.databaseSync),
describeRoute({
summary: "Sync database schema",
tags: ["data"],
}),
jsc(
"query",
s
.object({
force: s.boolean(),
drop: s.boolean(),
})
.partial(),
),
async (c) => {
const { force, drop } = c.req.valid("query");
//console.log("force", force);
const tables = await this.em.schema().introspect();
//console.log("tables", tables);
const changes = await this.em.schema().sync({
force,
drop,
});
return c.json({ tables: tables.map((t) => t.name), changes });
},
);
/**
* Schema endpoints
*/
// read entity schema
hono.get(
"/schema.json",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Retrieve data schema",
tags: ["data"],
}),
async (c) => {
const $id = `${this.config.basepath}/schema.json`;
const schemas = Object.fromEntries(
this.em.entities.map((e) => [
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
hono.get(
"/schemas/:entity/:context?",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Retrieve entity schema",
tags: ["data"],
}),
jsc(
"param",
s.object({
entity: entitiesEnum,
context: s.string({ enum: ["create", "update"], default: "create" }).optional(),
}),
),
async (c) => {
const { entity, context } = c.req.param();
if (!this.entityExists(entity)) {
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
hono.route("/entity", this.getEntityRoutes());
/**
* Info endpoints
*/
hono.get(
"/info/:entity",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Retrieve entity info",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const _entity = this.em.entity(entity);
const fields = _entity.fields.map((f) => f.name);
const $rels = (r: any) =>
r.map((r: any) => ({
entity: r.other(_entity).entity.name,
ref: r.other(_entity).reference,
}));
return c.json({
name: _entity.name,
fields,
relations: {
all: $rels(this.em.relations.relationsOf(_entity)),
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
target: $rels(this.em.relations.targetRelationsOf(_entity)),
},
});
},
);
return hono;
}
private getEntityRoutes() {
const { permission } = this.middlewares;
const hono = this.create();
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: make dynamic based on entity
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as number | string });
/**
* Function endpoints
*/
// fn: count
hono.post(
"/:entity/fn/count",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Count entities",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).count(where);
return c.json({ entity, ...result.data });
},
);
// fn: exists
hono.post(
"/:entity/fn/exists",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Check if entity exists",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).exists(where);
return c.json({ entity, ...result.data });
},
);
/**
* Read endpoints
*/
// read many
const saveRepoQuery = s
.object({
...omitKeys(repoQuery.properties, ["with"]),
sort: s.string({ default: "id" }),
select: s.array(s.string()),
join: s.array(s.string()),
})
.partial();
const saveRepoQueryParams = (pick: string[] = Object.keys(repoQuery.properties)) => [
...(schemaToSpec(saveRepoQuery, "query").parameters?.filter(
// @ts-ignore
(p) => pick.includes(p.name),
) as any),
];
hono.get(
"/:entity",
describeRoute({
summary: "Read many",
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findMany(options);
return c.json(result, { status: result.data ? 200 : 404 });
},
);
// read one
hono.get(
"/:entity/:id",
describeRoute({
summary: "Read one",
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
jsc(
"param",
s.object({
entity: entitiesEnum,
id: idType,
}),
),
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findId(id, options);
return c.json(result, { status: result.data ? 200 : 404 });
},
);
// read many by reference
hono.get(
"/:entity/:id/:reference",
describeRoute({
summary: "Read many by reference",
parameters: saveRepoQueryParams(),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
jsc(
"param",
s.object({
entity: entitiesEnum,
id: idType,
reference: s.string(),
}),
),
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity, id, reference } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em
.repository(entity)
.findManyByReference(id, reference, options);
return c.json(result, { status: result.data ? 200 : 404 });
},
);
// func query
const fnQuery = s
.object({
...saveRepoQuery.properties,
with: s.object({}),
})
.partial();
hono.post(
"/:entity/query",
describeRoute({
summary: "Query entities",
requestBody: {
content: {
"application/json": {
schema: fnQuery.toJSON(),
example: fnQuery.template({ withOptional: true }),
},
},
},
tags: ["data"],
}),
permission(DataPermissions.entityRead),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const options = c.req.valid("json") as RepoQuery;
const result = await this.em.repository(entity).findMany(options);
return c.json(result, { status: result.data ? 200 : 404 });
},
);
/**
* Mutation endpoints
*/
// insert one or many
hono.post(
"/:entity",
describeRoute({
summary: "Insert one or many",
tags: ["data"],
}),
permission(DataPermissions.entityCreate),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const body = (await c.req.json()) as EntityData | EntityData[];
if (Array.isArray(body)) {
const result = await this.em.mutator(entity).insertMany(body);
return c.json(result, 201);
}
const result = await this.em.mutator(entity).insertOne(body);
return c.json(result, 201);
},
);
// update many
hono.patch(
"/:entity",
describeRoute({
summary: "Update many",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
jsc("param", s.object({ entity: entitiesEnum })),
jsc(
"json",
s.object({
update: s.object({}),
where: repoQuery.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 {
update: EntityData;
where: RepoQuery["where"];
};
const result = await this.em.mutator(entity).updateWhere(update, where);
return c.json(result);
},
);
// update one
hono.patch(
"/:entity/:id",
describeRoute({
summary: "Update one",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
jsc("json", s.object({})),
async (c) => {
const { entity, id } = c.req.valid("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(id, body);
return c.json(result);
},
);
// delete one
hono.delete(
"/:entity/:id",
describeRoute({
summary: "Delete one",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
async (c) => {
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const result = await this.em.mutator(entity).deleteOne(id);
return c.json(result);
},
);
// delete many
hono.delete(
"/:entity",
describeRoute({
summary: "Delete many",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const where = (await c.req.json()) as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(result);
},
);
return hono;
}
}