diff --git a/app/build.ts b/app/build.ts index 56dc1dc..58827d2 100644 --- a/app/build.ts +++ b/app/build.ts @@ -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", diff --git a/app/package.json b/app/package.json index e5dbc49..b07743d 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 1597888..57cfd4c 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -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) => { - return c.json(create.schema); - }); + 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,42 +109,54 @@ 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.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); + return c.json({ user }); + } - hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); - this.registerStrategyActions(strategy, hono); - } + return c.json({ user: null }, 403); + }, + ); - hono.get("/me", auth(), async (c) => { - const claims = c.get("auth")?.user; - if (claims) { - const { data: user } = await this.userRepo.findId(claims.id); - return c.json({ user }); - } + 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 }); + } - return c.json({ user: null }, 403); - }); + const referer = c.req.header("referer"); + if (referer) { + return c.redirect(referer); + } - hono.get("/logout", auth(), async (c) => { - await this.auth.authenticator.logout(c); - if (this.auth.authenticator.isJsonRequest(c)) { - return c.json({ ok: true }); - } - - const referer = c.req.header("referer"); - if (referer) { - return c.redirect(referer); - } - - return c.redirect("/"); - }); + 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()); } } diff --git a/app/src/core/env.ts b/app/src/core/env.ts index 6d4f0ba..7771073 100644 --- a/app/src/core/env.ts +++ b/app/src/core/env.ts @@ -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: { diff --git a/app/src/core/index.ts b/app/src/core/index.ts index a0f572d..9ff5370 100644 --- a/app/src/core/index.ts +++ b/app/src/core/index.ts @@ -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"; diff --git a/app/src/core/object/schema/index.ts b/app/src/core/object/schema/index.ts index 28ac2fe..97f8066 100644 --- a/app/src/core/object/schema/index.ts +++ b/app/src/core/object/schema/index.ts @@ -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 = (schema: S): S => { + const json = schema.toJSON(); + return s.fromSchema(json) as S; }; export function parse( @@ -28,7 +37,7 @@ export function parse( v: unknown, opts: ParseOptions = {}, ): s.StaticCoerced { - 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, diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 793b5e4..8c3ab45 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -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(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,49 +94,75 @@ 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"; - //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 }); - }); + 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); + 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), 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, - }); - }); + 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), - 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,30 +191,39 @@ export class DataController extends Controller { /** * Info endpoints */ - hono.get("/info/:entity", 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, - })); + 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 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.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)); diff --git a/app/src/data/server/query.ts b/app/src/data/server/query.ts index fc0c4b3..f5c73dd 100644 --- a/app/src/data/server/query.ts +++ b/app/src/data/server/query.ts @@ -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 = (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; 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 = (self: s.TSchema): s.TSchemaInOut => // 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, diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 4783be9..cdcbe5a 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -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,90 +26,165 @@ 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) => { - const files = await this.getStorageAdapter().listObjects(); - return c.json(files); - }); + 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) => { - const { filename } = c.req.param(); - if (!filename) { - throw new Error("No file name provided"); - } + 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 })); - const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers); + 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); - headers.set("Cache-Control", "public, max-age=31536000, immutable"); + const headers = new Headers(res.headers); + headers.set("Cache-Control", "public, max-age=31536000, immutable"); - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers, - }); - }); + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers, + }); + }, + ); // delete a file by name - hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => { - const { filename } = c.req.param(); - if (!filename) { - throw new Error("No file name provided"); - } - await this.getStorage().deleteFile(filename); + 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"); + } + await this.getStorage().deleteFile(filename); - return c.json({ message: "File deleted" }); - }); + return c.json({ message: "File deleted" }); + }, + ); const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY; if (isDebug()) { - hono.post("/inspect", async (c) => { - const file = await getFileFromContext(c); - return c.json({ - type: file?.type, - name: file?.name, - size: file?.size, - }); - }); + 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) => { - const reqname = c.req.param("filename"); + 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); - if (!body) { - return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST); - } - if (body.size > maxSize) { - return c.json( - { error: `Max size (${maxSize} bytes) exceeded` }, - HttpStatus.PAYLOAD_TOO_LARGE, - ); - } + const body = await getFileFromContext(c); + if (!body) { + return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST); + } + if (body.size > maxSize) { + return c.json( + { error: `Max size (${maxSize} bytes) exceeded` }, + HttpStatus.PAYLOAD_TOO_LARGE, + ); + } - const filename = reqname ?? getRandomizedFilename(body as File); - const res = await this.getStorage().uploadFile(body, filename); + const filename = reqname ?? getRandomizedFilename(body as File); + const res = await this.getStorage().uploadFile(body, filename); - return c.json(res, HttpStatus.CREATED); - }); + 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"); diff --git a/app/src/modules/Controller.ts b/app/src/modules/Controller.ts index 134019a..610337d 100644 --- a/app/src/modules/Controller.ts +++ b/app/src/modules/Controller.ts @@ -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) { + const entities = em.entities.map((e) => e.name); + return entities.length > 0 ? s.string({ enum: entities }) : s.string(); + } } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 7ade0e8..511876a 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -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 = { 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) => { - // @ts-expect-error "fetch" is private - return c.json(await this.app.modules.fetch()); - }); + 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; this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c); @@ -293,25 +292,44 @@ 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) => - c.json({ - version: c.get("app")?.version(), - runtime: getRuntimeKey(), - timezone: { - name: getTimezone(), - offset: getTimezoneOffset(), - local: datetimeStringLocal(), - utc: datetimeStringUTC(), + hono.get( + "/info", + describeRoute({ + summary: "Get the server info", + tags: ["system"], + }), + (c) => + c.json({ + version: c.get("app")?.version(), + runtime: getRuntimeKey(), + timezone: { + name: getTimezone(), + offset: getTimezoneOffset(), + local: datetimeStringLocal(), + utc: datetimeStringUTC(), + }, + }), + ); + + hono.get( + "/openapi.json", + openAPISpecs(this.ctx.server, { + info: { + title: "bknd API", + version: getVersion(), }, }), ); - - hono.get("/openapi.json", async (c) => { - const config = getDefaultConfig(); - return c.json(generateOpenAPI(config)); - }); + hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" })); return hono.all("*", (c) => c.notFound()); } diff --git a/app/src/ui/hooks/use-search.ts b/app/src/ui/hooks/use-search.ts index c2ce8a2..08e118d 100644 --- a/app/src/ui/hooks/use-search.ts +++ b/app/src/ui/hooks/use-search.ts @@ -10,12 +10,11 @@ export function useSearch( ) { const searchString = useWouterSearch(); const [location, navigate] = useLocation(); - let value = (defaultValue ? parse(schema, defaultValue as any) : {}) as s.StaticCoerced; - - 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; // @todo: add option to set multiple keys at once function set>( diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index 866535e..54a9753 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -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) { diff --git a/app/vite.config.ts b/app/vite.config.ts index 1983c95..1e28989 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -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", diff --git a/bun.lock b/bun.lock index f6f5d32..9744812 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="],