Controllers: New validation + auto OpenAPI (#173)

* updated controllers to use custom json schema and added auto openapi specs

* fix data routes parsing body

* added schema exports to core

* added swagger link to Admin, switched use-search
This commit is contained in:
dswbx
2025-05-27 09:06:36 +02:00
committed by GitHub
parent 773df544dd
commit db795ec050
15 changed files with 527 additions and 269 deletions

View File

@@ -1,5 +1,6 @@
import { $ } from "bun";
import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" };
const args = process.argv.slice(2);
const watch = args.includes("--watch");
@@ -9,7 +10,7 @@ const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
if (clean) {
console.log("Cleaning dist (w/o static)");
console.info("Cleaning dist (w/o static)");
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
}
@@ -21,11 +22,11 @@ function buildTypes() {
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
console.log("Types built");
console.info("Types built");
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
console.log("Types aliased");
console.info("Types aliased");
types_running = false;
},
});
@@ -47,10 +48,10 @@ if (types && !watch) {
}
function banner(title: string) {
console.log("");
console.log("=".repeat(40));
console.log(title.toUpperCase());
console.log("-".repeat(40));
console.info("");
console.info("=".repeat(40));
console.info(title.toUpperCase());
console.info("-".repeat(40));
}
// collection of always-external packages
@@ -65,6 +66,9 @@ async function buildApi() {
minify,
sourcemap,
watch,
define: {
__version: JSON.stringify(pkg.version),
},
entry: [
"src/index.ts",
"src/core/index.ts",

View File

@@ -49,6 +49,7 @@
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
@@ -64,7 +65,6 @@
"json-schema-form-react": "^0.0.2",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "^0.0.11",
"kysely": "^0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
@@ -99,6 +99,7 @@
"dotenv": "^16.4.7",
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"jsonv-ts": "^0.0.14-alpha.6",
"kysely-d1": "^0.3.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",

View File

@@ -1,11 +1,9 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { tbValidator as tb } from "core";
import { TypeInvalidError, parse, transformObject } from "core/utils";
import { DataPermissions } from "data";
import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { describeRoute, jsc, s } from "core/object/schema";
export type AuthActionResponse = {
success: boolean;
@@ -14,10 +12,6 @@ export type AuthActionResponse = {
errors?: any;
};
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class AuthController extends Controller {
constructor(private auth: AppAuth) {
super();
@@ -56,6 +50,10 @@ export class AuthController extends Controller {
hono.post(
"/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
describeRoute({
summary: "Create a new user",
tags: ["auth"],
}),
async (c) => {
try {
const body = await this.auth.authenticator.getBody(c);
@@ -93,9 +91,16 @@ export class AuthController extends Controller {
}
},
);
hono.get("create/schema.json", async (c) => {
hono.get(
"create/schema.json",
describeRoute({
summary: "Get the schema for creating a user",
tags: ["auth"],
}),
async (c) => {
return c.json(create.schema);
});
},
);
}
mainHono.route(`/${name}/actions`, hono);
@@ -104,16 +109,15 @@ export class AuthController extends Controller {
override getController() {
const { auth } = this.middlewares;
const hono = this.create();
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
hono.get("/me", auth(), async (c) => {
hono.get(
"/me",
describeRoute({
summary: "Get the current user",
tags: ["auth"],
}),
auth(),
async (c) => {
const claims = c.get("auth")?.user;
if (claims) {
const { data: user } = await this.userRepo.findId(claims.id);
@@ -121,9 +125,17 @@ export class AuthController extends Controller {
}
return c.json({ user: null }, 403);
});
},
);
hono.get("/logout", auth(), async (c) => {
hono.get(
"/logout",
describeRoute({
summary: "Logout the current user",
tags: ["auth"],
}),
auth(),
async (c) => {
await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true });
@@ -135,11 +147,16 @@ export class AuthController extends Controller {
}
return c.redirect("/");
});
},
);
hono.get(
"/strategies",
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
describeRoute({
summary: "Get the available authentication strategies",
tags: ["auth"],
}),
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
async (c) => {
const { include_disabled } = c.req.valid("query");
const { strategies, basepath } = this.auth.toJSON(false);
@@ -157,6 +174,15 @@ export class AuthController extends Controller {
},
);
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
return hono.all("*", (c) => c.notFound());
}
}

View File

@@ -13,6 +13,15 @@ export function isDebug(): boolean {
}
}
export function getVersion(): string {
try {
// @ts-expect-error - this is a global variable in dev
return __version;
} catch (e) {
return "0.0.0";
}
}
const envs = {
// used in $console to determine the log level
cli_log_level: {

View File

@@ -26,6 +26,7 @@ export {
} from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry";
export { getFlashMessage } from "./server/flash";
export { s, jsc, describeRoute } from "./object/schema";
export * from "./console";
export * from "./events";

View File

@@ -1,8 +1,11 @@
import { mergeObject } from "core/utils";
export { jsc, type Options, type Hook } from "./validator";
//export { jsc, type Options, type Hook } from "./validator";
import * as s from "jsonv-ts";
export { validator as jsc, type Options } from "jsonv-ts/hono";
export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono";
export { s };
export class InvalidSchemaError extends Error {
@@ -21,6 +24,12 @@ export class InvalidSchemaError extends Error {
export type ParseOptions = {
withDefaults?: boolean;
coerse?: boolean;
clone?: boolean;
};
const cloneSchema = <S extends s.TSchema>(schema: S): S => {
const json = schema.toJSON();
return s.fromSchema(json) as S;
};
export function parse<S extends s.TAnySchema>(
@@ -28,7 +37,7 @@ export function parse<S extends s.TAnySchema>(
v: unknown,
opts: ParseOptions = {},
): s.StaticCoerced<S> {
const schema = _schema as unknown as s.TSchema;
const schema = (opts.clone ? cloneSchema(_schema as any) : _schema) as s.TSchema;
const value = opts.coerse !== false ? schema.coerce(v) : v;
const result = schema.validate(value, {
shortCircuit: true,

View File

@@ -1,6 +1,4 @@
import { $console, isDebug, tbValidator as tb } from "core";
import { StringEnum } from "core/utils";
import * as tbbox from "@sinclair/typebox";
import { $console, isDebug } from "core";
import {
DataPermissions,
type EntityData,
@@ -13,10 +11,10 @@ import {
import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller";
import { jsc, s } from "core/object/schema";
import { jsc, s, describeRoute, schemaToSpec } from "core/object/schema";
import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema";
const { Type } = tbbox;
import { omitKeys } from "core/utils";
export class DataController extends Controller {
constructor(
@@ -72,6 +70,7 @@ export class DataController extends Controller {
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 {
@@ -84,6 +83,10 @@ export class DataController extends Controller {
// info
hono.get(
"/",
describeRoute({
summary: "Retrieve data configuration",
tags: ["data"],
}),
handler("data info", (c) => {
// sample implementation
return c.json(this.em.toJSON());
@@ -91,9 +94,22 @@ export class DataController extends Controller {
);
// sync endpoint
hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => {
const force = c.req.query("force") === "1";
const drop = c.req.query("drop") === "1";
hono.get(
"/sync",
permission(DataPermissions.databaseSync),
describeRoute({
summary: "Sync database schema",
tags: ["data"],
}),
jsc(
"query",
s.partialObject({
force: s.boolean(),
drop: s.boolean(),
}),
),
async (c) => {
const { force, drop } = c.req.valid("query");
//console.log("force", force);
const tables = await this.em.schema().introspect();
//console.log("tables", tables);
@@ -102,13 +118,21 @@ export class DataController extends Controller {
drop,
});
return c.json({ tables: tables.map((t) => t.name), changes });
});
},
);
/**
* Schema endpoints
*/
// read entity schema
hono.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
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) => [
@@ -123,17 +147,22 @@ export class DataController extends Controller {
$id,
properties: schemas,
});
});
},
);
// read schema
hono.get(
"/schemas/:entity/:context?",
permission(DataPermissions.entityRead),
tb(
describeRoute({
summary: "Retrieve entity schema",
tags: ["data"],
}),
jsc(
"param",
Type.Object({
entity: Type.String(),
context: Type.Optional(StringEnum(["create", "update"])),
s.object({
entity: entitiesEnum,
context: s.string({ enum: ["create", "update"], default: "create" }).optional(),
}),
),
async (c) => {
@@ -162,7 +191,15 @@ export class DataController extends Controller {
/**
* Info endpoints
*/
hono.get("/info/:entity", async (c) => {
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);
@@ -185,7 +222,8 @@ export class DataController extends Controller {
target: $rels(this.em.relations.targetRelationsOf(_entity)),
},
});
});
},
);
return hono.all("*", (c) => c.notFound());
}
@@ -194,10 +232,7 @@ export class DataController extends Controller {
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);
const entitiesEnum = this.getEntitiesEnum(this.em);
/**
* Function endpoints
@@ -206,14 +241,19 @@ export class DataController extends Controller {
hono.post(
"/:entity/fn/count",
permission(DataPermissions.entityRead),
jsc("param", s.object({ entity: s.string() })),
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 = (await c.req.json()) as any;
const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).count(where);
return c.json({ entity, count: result.count });
},
@@ -223,14 +263,19 @@ export class DataController extends Controller {
hono.post(
"/:entity/fn/exists",
permission(DataPermissions.entityRead),
jsc("param", s.object({ entity: s.string() })),
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.json() as any;
const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).exists(where);
return c.json({ entity, exists: result.exists });
},
@@ -240,11 +285,29 @@ export class DataController extends Controller {
* Read endpoints
*/
// read many
const saveRepoQuery = s.partialObject({
...omitKeys(repoQuery.properties, ["with"]),
sort: s.string({ default: "id" }),
select: s.array(s.string()),
join: s.array(s.string()),
});
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: s.string() })),
jsc("query", repoQuery),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
@@ -260,15 +323,20 @@ export class DataController extends Controller {
// 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: s.string(),
entity: entitiesEnum,
id: s.string(),
}),
),
jsc("query", repoQuery),
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
@@ -284,16 +352,21 @@ export class DataController extends Controller {
// 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: s.string(),
entity: entitiesEnum,
id: s.string(),
reference: s.string(),
}),
),
jsc("query", repoQuery),
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity, id, reference } = c.req.valid("param");
if (!this.entityExists(entity)) {
@@ -310,17 +383,33 @@ export class DataController extends Controller {
);
// func query
const fnQuery = s.partialObject({
...saveRepoQuery.properties,
with: s.object({}),
});
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: s.string() })),
jsc("json", repoQuery),
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 = (await c.req.valid("json")) as RepoQuery;
const options = (await c.req.json()) as RepoQuery;
const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
@@ -333,10 +422,13 @@ export class DataController extends Controller {
// insert one
hono.post(
"/:entity",
describeRoute({
summary: "Insert one or many",
tags: ["data"],
}),
permission(DataPermissions.entityCreate),
jsc("param", s.object({ entity: s.string() })),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
//tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
@@ -357,8 +449,12 @@ export class DataController extends Controller {
// update many
hono.patch(
"/:entity",
describeRoute({
summary: "Update many",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
jsc("param", s.object({ entity: s.string() })),
jsc("param", s.object({ entity: entitiesEnum })),
jsc(
"json",
s.object({
@@ -384,8 +480,13 @@ export class DataController extends Controller {
// update one
hono.patch(
"/:entity/:id",
describeRoute({
summary: "Update one",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
jsc("param", s.object({ entity: s.string(), id: s.number() })),
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
jsc("json", s.object({})),
async (c) => {
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
@@ -401,8 +502,12 @@ export class DataController extends Controller {
// delete one
hono.delete(
"/:entity/:id",
describeRoute({
summary: "Delete one",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
jsc("param", s.object({ entity: s.string(), id: s.number() })),
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
async (c) => {
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
@@ -417,15 +522,19 @@ export class DataController extends Controller {
// delete many
hono.delete(
"/:entity",
describeRoute({
summary: "Delete many",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
jsc("param", s.object({ entity: s.string() })),
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 RepoQuery["where"];
const where = (await c.req.json()) as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result));

View File

@@ -10,18 +10,6 @@ const stringIdentifier = s.string({
// allow "id", "id,title" but not "id," or "not allowed"
pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
});
const numberOrString = <N extends s.UnionSchema>(c: N = {} as N) =>
s.anyOf([s.number(), s.string()], {
...c,
coerse: function (this: s.TSchema, v): number {
if (typeof v === "string") {
const n = Number.parseInt(v);
if (Number.isNaN(n)) return this.default ?? 10;
return n;
}
return v as number;
},
}) as unknown as s.TSchemaInOut<number | string, number>;
const stringArray = s.anyOf(
[
stringIdentifier,
@@ -75,6 +63,13 @@ const sort = s.anyOf([s.string(), sortSchema], {
// filter
const where = s.anyOf([s.string(), s.object({})], {
default: {},
examples: [
{
attribute: {
$eq: 1,
},
},
],
coerce: (value: unknown) => {
const q = typeof value === "string" ? JSON.parse(value) : value;
return WhereBuilder.convert(q);
@@ -132,8 +127,8 @@ const withSchema = <In, Out = In>(self: s.TSchema): s.TSchemaInOut<In, Out> =>
// REPO QUERY
export const repoQuery = s.recursive((self) =>
s.partialObject({
limit: numberOrString({ default: 10 }),
offset: numberOrString({ default: 0 }),
limit: s.number({ default: 10 }),
offset: s.number({ default: 0 }),
sort,
where,
select: stringArray,

View File

@@ -6,12 +6,7 @@ import { DataPermissions } from "data";
import { Controller } from "modules/Controller";
import type { AppMedia } from "../AppMedia";
import { MediaField } from "../MediaField";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
import { jsc, s, describeRoute } from "core/object/schema";
export class MediaController extends Controller {
constructor(private readonly media: AppMedia) {
@@ -31,22 +26,40 @@ export class MediaController extends Controller {
// @todo: implement range requests
const { auth, permission } = this.middlewares;
const hono = this.create().use(auth());
const entitiesEnum = this.getEntitiesEnum(this.media.em);
// get files list (temporary)
hono.get("/files", permission(MediaPermissions.listFiles), async (c) => {
hono.get(
"/files",
describeRoute({
summary: "Get the list of files",
tags: ["media"],
}),
permission(MediaPermissions.listFiles),
async (c) => {
const files = await this.getStorageAdapter().listObjects();
return c.json(files);
});
},
);
// get file by name
// @todo: implement more aggressive cache? (configurable)
hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => {
hono.get(
"/file/:filename",
describeRoute({
summary: "Get a file by name",
tags: ["media"],
}),
permission(MediaPermissions.readFile),
async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
await this.getStorage().emgr.emit(
new StorageEvents.FileAccessEvent({ name: filename }),
);
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
const headers = new Headers(res.headers);
@@ -57,10 +70,18 @@ export class MediaController extends Controller {
statusText: res.statusText,
headers,
});
});
},
);
// delete a file by name
hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => {
hono.delete(
"/file/:filename",
describeRoute({
summary: "Delete a file by name",
tags: ["media"],
}),
permission(MediaPermissions.deleteFile),
async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
@@ -68,24 +89,64 @@ export class MediaController extends Controller {
await this.getStorage().deleteFile(filename);
return c.json({ message: "File deleted" });
});
},
);
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
if (isDebug()) {
hono.post("/inspect", async (c) => {
hono.post(
"/inspect",
describeRoute({
summary: "Inspect a file",
tags: ["media"],
}),
async (c) => {
const file = await getFileFromContext(c);
return c.json({
type: file?.type,
name: file?.name,
size: file?.size,
});
});
},
);
}
const requestBody = {
content: {
"multipart/form-data": {
schema: {
type: "object",
properties: {
file: {
type: "string",
format: "binary",
},
},
required: ["file"],
},
},
"application/octet-stream": {
schema: {
type: "string",
format: "binary",
},
},
},
} as any;
// upload file
// @todo: add required type for "upload endpoints"
hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => {
hono.post(
"/upload/:filename?",
describeRoute({
summary: "Upload a file",
tags: ["media"],
requestBody,
}),
jsc("param", s.object({ filename: s.string().optional() })),
permission(MediaPermissions.uploadFile),
async (c) => {
const reqname = c.req.param("filename");
const body = await getFileFromContext(c);
@@ -103,18 +164,27 @@ export class MediaController extends Controller {
const res = await this.getStorage().uploadFile(body, filename);
return c.json(res, HttpStatus.CREATED);
});
},
);
// add upload file to entity
// @todo: add required type for "upload endpoints"
hono.post(
"/entity/:entity/:id/:field",
tb(
"query",
Type.Object({
overwrite: Type.Optional(booleanLike),
describeRoute({
summary: "Add a file to an entity",
tags: ["media"],
requestBody,
}),
jsc(
"param",
s.object({
entity: entitiesEnum,
id: s.number(),
field: s.string(),
}),
),
jsc("query", s.object({ overwrite: s.boolean().optional() })),
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
async (c) => {
const entity_name = c.req.param("entity");

View File

@@ -2,6 +2,8 @@ import type { App } from "App";
import { type Context, type Env, Hono } from "hono";
import * as middlewares from "modules/middlewares";
import type { SafeUser } from "auth";
import type { EntityManager } from "data";
import { s } from "core/object/schema";
export type ServerEnv = Env & {
Variables: {
@@ -46,4 +48,9 @@ export class Controller {
return c.notFound();
}
protected getEntitiesEnum(em: EntityManager<any>) {
const entities = em.entities.map((e) => e.name);
return entities.length > 0 ? s.string({ enum: entities }) : s.string();
}
}

View File

@@ -13,9 +13,8 @@ import {
import { getRuntimeKey } from "core/utils";
import type { Context, Hono } from "hono";
import { Controller } from "modules/Controller";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { openAPISpecs } from "jsonv-ts/hono";
import { swaggerUI } from "@hono/swagger-ui";
import {
MODULE_NAMES,
type ModuleConfigs,
@@ -24,12 +23,8 @@ import {
getDefaultConfig,
} from "modules/ModuleManager";
import * as SystemPermissions from "modules/permissions";
import { generateOpenAPI } from "modules/server/openapi";
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
import { jsc, s, describeRoute } from "core/object/schema";
import { getVersion } from "core/env";
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true;
module: Key;
@@ -61,20 +56,27 @@ export class SystemController extends Controller {
hono.use(permission(SystemPermissions.configRead));
hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => {
hono.get(
"/raw",
describeRoute({
summary: "Get the raw config",
tags: ["system"],
}),
permission([SystemPermissions.configReadSecrets]),
async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch());
});
},
);
hono.get(
"/:module?",
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),
tb(
"query",
Type.Object({
secrets: Type.Optional(booleanLike),
describeRoute({
summary: "Get the config for a module",
tags: ["system"],
}),
),
jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })),
jsc("query", s.object({ secrets: s.boolean().optional() })),
async (c) => {
// @todo: allow secrets if authenticated user is admin
const { secrets } = c.req.valid("query");
@@ -119,12 +121,7 @@ export class SystemController extends Controller {
hono.post(
"/set/:module",
permission(SystemPermissions.configWrite),
tb(
"query",
Type.Object({
force: Type.Optional(booleanLike),
}),
),
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
async (c) => {
const module = c.req.param("module") as any;
const { force } = c.req.valid("query");
@@ -230,13 +227,17 @@ export class SystemController extends Controller {
hono.get(
"/schema/:module?",
describeRoute({
summary: "Get the schema for a module",
tags: ["system"],
}),
permission(SystemPermissions.schemaRead),
tb(
jsc(
"query",
Type.Object({
config: Type.Optional(booleanLike),
secrets: Type.Optional(booleanLike),
fresh: Type.Optional(booleanLike),
s.partialObject({
config: s.boolean(),
secrets: s.boolean(),
fresh: s.boolean(),
}),
),
async (c) => {
@@ -274,13 +275,11 @@ export class SystemController extends Controller {
hono.post(
"/build",
tb(
"query",
Type.Object({
sync: Type.Optional(booleanLike),
fetch: Type.Optional(booleanLike),
describeRoute({
summary: "Build the app",
tags: ["system"],
}),
),
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
async (c) => {
const options = c.req.valid("query") as Record<string, boolean>;
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
@@ -293,9 +292,22 @@ export class SystemController extends Controller {
},
);
hono.get("/ping", (c) => c.json({ pong: true }));
hono.get(
"/ping",
describeRoute({
summary: "Ping the server",
tags: ["system"],
}),
(c) => c.json({ pong: true }),
);
hono.get("/info", (c) =>
hono.get(
"/info",
describeRoute({
summary: "Get the server info",
tags: ["system"],
}),
(c) =>
c.json({
version: c.get("app")?.version(),
runtime: getRuntimeKey(),
@@ -308,10 +320,16 @@ export class SystemController extends Controller {
}),
);
hono.get("/openapi.json", async (c) => {
const config = getDefaultConfig();
return c.json(generateOpenAPI(config));
});
hono.get(
"/openapi.json",
openAPISpecs(this.ctx.server, {
info: {
title: "bknd API",
version: getVersion(),
},
}),
);
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
return hono.all("*", (c) => c.notFound());
}

View File

@@ -10,12 +10,11 @@ export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
) {
const searchString = useWouterSearch();
const [location, navigate] = useLocation();
let value = (defaultValue ? parse(schema, defaultValue as any) : {}) as s.StaticCoerced<Schema>;
if (searchString.length > 0) {
value = parse(schema, decodeSearch(searchString));
//console.log("search:decode", value);
}
const initial = searchString.length > 0 ? decodeSearch(searchString) : (defaultValue ?? {});
const value = parse(schema, initial, {
withDefaults: true,
clone: true,
}) as s.StaticCoerced<Schema>;
// @todo: add option to set multiple keys at once
function set<Key extends keyof s.StaticCoerced<Schema>>(

View File

@@ -1,5 +1,5 @@
import { SegmentedControl, Tooltip } from "@mantine/core";
import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
import { IconApi, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
import {
TbDatabase,
TbFingerprint,
@@ -159,6 +159,11 @@ function UserMenu() {
const items: DropdownItem[] = [
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings },
{
label: "OpenAPI",
onClick: () => window.open("/api/system/swagger", "_blank"),
icon: IconApi,
},
];
if (config.auth.enabled) {

View File

@@ -4,11 +4,13 @@ import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { devServerConfig } from "./src/adapter/vite/dev-server-config";
import tailwindcss from "@tailwindcss/vite";
import pkg from "./package.json" with { type: "json" };
// https://vitejs.dev/config/
export default defineConfig({
define: {
__isDev: process.env.NODE_ENV === "production" ? "0" : "1",
__version: JSON.stringify(pkg.version),
},
clearScreen: false,
publicDir: "./src/ui/assets",

View File

@@ -34,6 +34,7 @@
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
@@ -49,7 +50,6 @@
"json-schema-form-react": "^0.0.2",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "^0.0.11",
"kysely": "^0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
@@ -84,6 +84,7 @@
"dotenv": "^16.4.7",
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"jsonv-ts": "^0.0.14-alpha.6",
"kysely-d1": "^0.3.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
@@ -640,6 +641,8 @@
"@hono/node-server": ["@hono/node-server@1.13.8", "", { "peerDependencies": { "hono": "^4" } }, "sha512-fsn8ucecsAXUoVxrUil0m13kOEq4mkX4/4QozCqmY+HpGfKl74OYSn8JcMA8GnG0ClfdRI4/ZSeG7zhFaVg+wg=="],
"@hono/swagger-ui": ["@hono/swagger-ui@0.5.1", "", { "peerDependencies": { "hono": "*" } }, "sha512-XpUCfszLJ9b1rtFdzqOSHfdg9pfBiC2J5piEjuSanYpDDTIwpMz0ciiv5N3WWUaQpz9fEgH8lttQqL41vIFuDA=="],
"@hono/typebox-validator": ["@hono/typebox-validator@0.3.2", "", { "peerDependencies": { "@sinclair/typebox": ">=0.31.15 <1", "hono": ">=3.9.0" } }, "sha512-MIxYk80vtuFnkvbNreMubZ/vLoNCCQivLH8n3vNDY5dFNsZ12BFaZV3FmsLJHGibNMMpmkO6y4w5gNWY4KzSdg=="],
"@hono/vite-dev-server": ["@hono/vite-dev-server@0.19.0", "", { "dependencies": { "@hono/node-server": "^1.12.0", "minimatch": "^9.0.3" }, "peerDependencies": { "hono": "*", "miniflare": "*", "wrangler": "*" }, "optionalPeers": ["miniflare", "wrangler"] }, "sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw=="],
@@ -2518,7 +2521,7 @@
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"jsonv-ts": ["jsonv-ts@0.0.11", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-W5WC6iwQvOuB0gRaAW9jAQKqT56pXjTA7XCjjAXZIM92/VBVNczTmV7iPtClqV1Zpgy4CtzaUsOJj4kWNeB5YQ=="],
"jsonv-ts": ["jsonv-ts@0.0.14-alpha.6", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-pwMpjEbNtyq8Xi6QBXuQ8dOZm7WQAEwvCPu3vVf9b3aU2KRHW+cfTPqO53U01YYdjWSSRkqaTKcLSiYdfwBYRA=="],
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],