mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
public commit
This commit is contained in:
95
app/src/Api.ts
Normal file
95
app/src/Api.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { AuthApi } from "auth/api/AuthApi";
|
||||
import { DataApi } from "data/api/DataApi";
|
||||
import { decodeJwt } from "jose";
|
||||
import { MediaApi } from "media/api/MediaApi";
|
||||
import { SystemApi } from "modules/SystemApi";
|
||||
|
||||
export type ApiOptions = {
|
||||
host: string;
|
||||
token?: string;
|
||||
tokenStorage?: "localStorage";
|
||||
localStorage?: {
|
||||
key?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class Api {
|
||||
private token?: string;
|
||||
private user?: object;
|
||||
private verified = false;
|
||||
|
||||
public system!: SystemApi;
|
||||
public data!: DataApi;
|
||||
public auth!: AuthApi;
|
||||
public media!: MediaApi;
|
||||
|
||||
constructor(private readonly options: ApiOptions) {
|
||||
if (options.token) {
|
||||
this.updateToken(options.token);
|
||||
} else {
|
||||
this.extractToken();
|
||||
}
|
||||
|
||||
this.buildApis();
|
||||
}
|
||||
|
||||
private extractToken() {
|
||||
if (this.options.tokenStorage === "localStorage") {
|
||||
const key = this.options.localStorage?.key ?? "auth";
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (raw) {
|
||||
const { token } = JSON.parse(raw);
|
||||
this.token = token;
|
||||
this.user = decodeJwt(token) as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateToken(token?: string, rebuild?: boolean) {
|
||||
this.token = token;
|
||||
this.user = token ? (decodeJwt(token) as any) : undefined;
|
||||
|
||||
if (this.options.tokenStorage === "localStorage") {
|
||||
const key = this.options.localStorage?.key ?? "auth";
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem(key, JSON.stringify({ token }));
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (rebuild) this.buildApis();
|
||||
}
|
||||
|
||||
markAuthVerified(verfied: boolean) {
|
||||
this.verified = verfied;
|
||||
return this;
|
||||
}
|
||||
|
||||
getAuthState() {
|
||||
if (!this.token) return;
|
||||
|
||||
return {
|
||||
token: this.token,
|
||||
user: this.user,
|
||||
verified: this.verified
|
||||
};
|
||||
}
|
||||
|
||||
private buildApis() {
|
||||
const baseParams = {
|
||||
host: this.options.host,
|
||||
token: this.token
|
||||
};
|
||||
|
||||
this.system = new SystemApi(baseParams);
|
||||
this.data = new DataApi(baseParams);
|
||||
this.auth = new AuthApi({
|
||||
...baseParams,
|
||||
onTokenUpdate: (token) => this.updateToken(token, true)
|
||||
});
|
||||
this.media = new MediaApi(baseParams);
|
||||
}
|
||||
}
|
||||
142
app/src/App.ts
Normal file
142
app/src/App.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Event } from "core/events";
|
||||
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
||||
import {
|
||||
type InitialModuleConfigs,
|
||||
ModuleManager,
|
||||
type ModuleManagerOptions,
|
||||
type Modules
|
||||
} from "modules/ModuleManager";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import { SystemController } from "modules/server/SystemController";
|
||||
|
||||
export type AppPlugin<DB> = (app: App<DB>) => void;
|
||||
|
||||
export class AppConfigUpdatedEvent extends Event<{ app: App }> {
|
||||
static override slug = "app-config-updated";
|
||||
}
|
||||
export class AppBuiltEvent extends Event<{ app: App }> {
|
||||
static override slug = "app-built";
|
||||
}
|
||||
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const;
|
||||
|
||||
export type CreateAppConfig = {
|
||||
connection:
|
||||
| Connection
|
||||
| {
|
||||
type: "libsql";
|
||||
config: LibSqlCredentials;
|
||||
};
|
||||
initialConfig?: InitialModuleConfigs;
|
||||
plugins?: AppPlugin<any>[];
|
||||
options?: ModuleManagerOptions;
|
||||
};
|
||||
|
||||
export type AppConfig = InitialModuleConfigs;
|
||||
|
||||
export class App<DB = any> {
|
||||
modules: ModuleManager;
|
||||
static readonly Events = AppEvents;
|
||||
|
||||
constructor(
|
||||
private connection: Connection,
|
||||
_initialConfig?: InitialModuleConfigs,
|
||||
private plugins: AppPlugin<DB>[] = [],
|
||||
moduleManagerOptions?: ModuleManagerOptions
|
||||
) {
|
||||
this.modules = new ModuleManager(connection, {
|
||||
...moduleManagerOptions,
|
||||
initial: _initialConfig,
|
||||
onUpdated: async (key, config) => {
|
||||
//console.log("[APP] config updated", key, config);
|
||||
await this.build({ sync: true, save: true });
|
||||
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
||||
}
|
||||
});
|
||||
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||
}
|
||||
|
||||
static create(config: CreateAppConfig) {
|
||||
let connection: Connection | undefined = undefined;
|
||||
|
||||
if (config.connection instanceof Connection) {
|
||||
connection = config.connection;
|
||||
} else if (typeof config.connection === "object") {
|
||||
switch (config.connection.type) {
|
||||
case "libsql":
|
||||
connection = new LibsqlConnection(config.connection.config);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!connection) {
|
||||
throw new Error("Invalid connection");
|
||||
}
|
||||
|
||||
return new App(connection, config.initialConfig, config.plugins, config.options);
|
||||
}
|
||||
|
||||
get emgr() {
|
||||
return this.modules.ctx().emgr;
|
||||
}
|
||||
|
||||
async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) {
|
||||
//console.log("building");
|
||||
await this.modules.build();
|
||||
|
||||
if (options?.sync) {
|
||||
const syncResult = await this.module.data.em
|
||||
.schema()
|
||||
.sync({ force: true, drop: options.drop });
|
||||
//console.log("syncing", syncResult);
|
||||
}
|
||||
|
||||
// load system controller
|
||||
this.modules.ctx().guard.registerPermissions(Object.values(SystemPermissions));
|
||||
this.modules.server.route("/api/system", new SystemController(this).getController());
|
||||
|
||||
// load plugins
|
||||
if (this.plugins.length > 0) {
|
||||
this.plugins.forEach((plugin) => plugin(this));
|
||||
}
|
||||
|
||||
//console.log("emitting built", options);
|
||||
await this.emgr.emit(new AppBuiltEvent({ app: this }));
|
||||
|
||||
// not found on any not registered api route
|
||||
this.modules.server.all("/api/*", async (c) => c.notFound());
|
||||
|
||||
if (options?.save) {
|
||||
await this.modules.save();
|
||||
}
|
||||
}
|
||||
|
||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
||||
return this.modules.get(module).schema();
|
||||
}
|
||||
|
||||
get fetch(): any {
|
||||
return this.modules.server.fetch;
|
||||
}
|
||||
|
||||
get module() {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, module: keyof Modules) => {
|
||||
return this.modules.get(module);
|
||||
}
|
||||
}
|
||||
) as Modules;
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return this.modules.getSchema();
|
||||
}
|
||||
|
||||
version() {
|
||||
return this.modules.version();
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
return this.modules.toJSON(secrets);
|
||||
}
|
||||
}
|
||||
33
app/src/adapter/bun/bun.adapter.ts
Normal file
33
app/src/adapter/bun/bun.adapter.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import { serveStatic } from "hono/bun";
|
||||
|
||||
let app: App;
|
||||
export function serve(config: CreateAppConfig, distPath?: string) {
|
||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||
|
||||
return async (req: Request) => {
|
||||
if (!app) {
|
||||
app = App.create(config);
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
async () => {
|
||||
app.modules.server.get(
|
||||
"/assets/*",
|
||||
serveStatic({
|
||||
root
|
||||
})
|
||||
);
|
||||
app.module?.server?.setAdminHtml(await readFile(root + "/index.html", "utf-8"));
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
}
|
||||
|
||||
return app.modules.server.fetch(req);
|
||||
};
|
||||
}
|
||||
1
app/src/adapter/bun/index.ts
Normal file
1
app/src/adapter/bun/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./bun.adapter";
|
||||
267
app/src/adapter/cloudflare/cloudflare-workers.adapter.ts
Normal file
267
app/src/adapter/cloudflare/cloudflare-workers.adapter.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/cloudflare-workers";
|
||||
import type { BkndConfig, CfBkndModeCache } from "../index";
|
||||
|
||||
// @ts-ignore
|
||||
//import manifest from "__STATIC_CONTENT_MANIFEST";
|
||||
|
||||
import _html from "../../static/index.html";
|
||||
|
||||
type Context = {
|
||||
request: Request;
|
||||
env: any;
|
||||
ctx: ExecutionContext;
|
||||
manifest: any;
|
||||
html: string;
|
||||
};
|
||||
|
||||
export function serve(_config: BkndConfig, manifest?: string, overrideHtml?: string) {
|
||||
const html = overrideHtml ?? _html;
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (manifest) {
|
||||
const pathname = url.pathname.slice(1);
|
||||
const assetManifest = JSON.parse(manifest);
|
||||
if (pathname && pathname in assetManifest) {
|
||||
const hono = new Hono();
|
||||
|
||||
hono.all("*", async (c, next) => {
|
||||
const res = await serveStatic({
|
||||
path: `./${pathname}`,
|
||||
manifest,
|
||||
onNotFound: (path) => console.log("not found", path)
|
||||
})(c as any, next);
|
||||
if (res instanceof Response) {
|
||||
const ttl = pathname.startsWith("assets/")
|
||||
? 60 * 60 * 24 * 365 // 1 year
|
||||
: 60 * 5; // 5 minutes
|
||||
res.headers.set("Cache-Control", `public, max-age=${ttl}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
return c.notFound();
|
||||
});
|
||||
|
||||
return hono.fetch(request, env);
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
..._config,
|
||||
setAdminHtml: _config.setAdminHtml ?? !!manifest
|
||||
};
|
||||
const context = { request, env, ctx, manifest, html };
|
||||
const mode = config.cloudflare?.mode?.(env);
|
||||
|
||||
if (!mode) {
|
||||
console.log("serving fresh...");
|
||||
const app = await getFresh(config, context);
|
||||
return app.fetch(request, env);
|
||||
} else if ("cache" in mode) {
|
||||
console.log("serving cached...");
|
||||
const app = await getCached(config as any, context);
|
||||
return app.fetch(request, env);
|
||||
} else if ("durableObject" in mode) {
|
||||
console.log("serving durable...");
|
||||
|
||||
if (config.onBuilt) {
|
||||
console.log("onBuilt() is not supported with DurableObject mode");
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
const durable = mode.durableObject;
|
||||
const id = durable.idFromName(mode.key);
|
||||
const stub = durable.get(id) as unknown as DurableBkndApp;
|
||||
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
|
||||
const res = await stub.fire(request, {
|
||||
config: create_config,
|
||||
html,
|
||||
keepAliveSeconds: mode.keepAliveSeconds,
|
||||
setAdminHtml: config.setAdminHtml
|
||||
});
|
||||
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-TTDO", String(performance.now() - start));
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getFresh(config: BkndConfig, { env, html }: Context) {
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
const app = App.create(create_config);
|
||||
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async ({ params: { app } }) => {
|
||||
config.onBuilt!(app);
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
}
|
||||
|
||||
await app.build();
|
||||
|
||||
if (config?.setAdminHtml !== false) {
|
||||
app.module.server.setAdminHtml(html);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function getCached(
|
||||
config: BkndConfig & { cloudflare: { mode: CfBkndModeCache } },
|
||||
{ env, html, ctx }: Context
|
||||
) {
|
||||
const { cache, key } = config.cloudflare.mode(env) as ReturnType<CfBkndModeCache>;
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
|
||||
const cachedConfig = await cache.get(key);
|
||||
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
|
||||
|
||||
const app = App.create({ ...create_config, initialConfig });
|
||||
|
||||
async function saveConfig(__config: any) {
|
||||
ctx.waitUntil(cache.put(key, JSON.stringify(__config)));
|
||||
}
|
||||
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async ({ params: { app } }) => {
|
||||
app.module.server.client.get("/__bknd/cache", async (c) => {
|
||||
await cache.delete(key);
|
||||
return c.json({ message: "Cache cleared" });
|
||||
});
|
||||
|
||||
config.onBuilt!(app);
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
}
|
||||
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppConfigUpdatedEvent,
|
||||
async ({ params: { app } }) => {
|
||||
saveConfig(app.toJSON(true));
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
if (!cachedConfig) {
|
||||
saveConfig(app.toJSON(true));
|
||||
}
|
||||
|
||||
//addAssetsRoute(app, manifest);
|
||||
if (config?.setAdminHtml !== false) {
|
||||
app.module.server.setAdminHtml(html);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export class DurableBkndApp extends DurableObject {
|
||||
protected id = Math.random().toString(36).slice(2);
|
||||
protected app?: App;
|
||||
protected interval?: any;
|
||||
|
||||
async fire(
|
||||
request: Request,
|
||||
options: {
|
||||
config: CreateAppConfig;
|
||||
html: string;
|
||||
keepAliveSeconds?: number;
|
||||
setAdminHtml?: boolean;
|
||||
}
|
||||
) {
|
||||
let buildtime = 0;
|
||||
if (!this.app) {
|
||||
const start = performance.now();
|
||||
const config = options.config;
|
||||
|
||||
// change protocol to websocket if libsql
|
||||
if ("type" in config.connection && config.connection.type === "libsql") {
|
||||
config.connection.config.protocol = "wss";
|
||||
}
|
||||
|
||||
this.app = App.create(config);
|
||||
this.app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async ({ params: { app } }) => {
|
||||
app.modules.server.get("/__do", async (c) => {
|
||||
// @ts-ignore
|
||||
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
||||
return c.json({
|
||||
id: this.id,
|
||||
keepAlive: options?.keepAliveSeconds,
|
||||
colo: context.colo
|
||||
});
|
||||
});
|
||||
|
||||
if (options?.setAdminHtml !== false) {
|
||||
app.module.server.setAdminHtml(options.html);
|
||||
}
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await this.app.build();
|
||||
|
||||
buildtime = performance.now() - start;
|
||||
}
|
||||
|
||||
if (options?.keepAliveSeconds) {
|
||||
this.keepAlive(options.keepAliveSeconds);
|
||||
}
|
||||
|
||||
console.log("id", this.id);
|
||||
const res = await this.app!.fetch(request);
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-BuildTime", buildtime.toString());
|
||||
headers.set("X-DO-ID", this.id);
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
protected keepAlive(seconds: number) {
|
||||
console.log("keep alive for", seconds);
|
||||
if (this.interval) {
|
||||
console.log("clearing, there is a new");
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
this.interval = setInterval(() => {
|
||||
i += 1;
|
||||
//console.log("keep-alive", i);
|
||||
if (i === seconds) {
|
||||
console.log("cleared");
|
||||
clearInterval(this.interval);
|
||||
|
||||
// ping every 30 seconds
|
||||
} else if (i % 30 === 0) {
|
||||
console.log("ping");
|
||||
this.app?.modules.ctx().connection.ping();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
1
app/src/adapter/cloudflare/index.ts
Normal file
1
app/src/adapter/cloudflare/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./cloudflare-workers.adapter";
|
||||
36
app/src/adapter/index.ts
Normal file
36
app/src/adapter/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { App, CreateAppConfig } from "bknd";
|
||||
|
||||
export type CfBkndModeCache<Env = any> = (env: Env) => {
|
||||
cache: KVNamespace;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type CfBkndModeDurableObject<Env = any> = (env: Env) => {
|
||||
durableObject: DurableObjectNamespace;
|
||||
key: string;
|
||||
keepAliveSeconds?: number;
|
||||
};
|
||||
|
||||
export type CloudflareBkndConfig<Env = any> = {
|
||||
mode?: CfBkndModeCache | CfBkndModeDurableObject;
|
||||
forceHttps?: boolean;
|
||||
};
|
||||
|
||||
export type BkndConfig<Env = any> = {
|
||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
setAdminHtml?: boolean;
|
||||
server?: {
|
||||
port?: number;
|
||||
platform?: "node" | "bun";
|
||||
};
|
||||
cloudflare?: CloudflareBkndConfig<Env>;
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
};
|
||||
|
||||
export type BkndConfigJson = {
|
||||
app: CreateAppConfig;
|
||||
setAdminHtml?: boolean;
|
||||
server?: {
|
||||
port?: number;
|
||||
};
|
||||
};
|
||||
1
app/src/adapter/nextjs/index.ts
Normal file
1
app/src/adapter/nextjs/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./nextjs.adapter";
|
||||
25
app/src/adapter/nextjs/nextjs.adapter.ts
Normal file
25
app/src/adapter/nextjs/nextjs.adapter.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import { isDebug } from "bknd/core";
|
||||
|
||||
function getCleanRequest(req: Request) {
|
||||
// clean search params from "route" attribute
|
||||
const url = new URL(req.url);
|
||||
url.searchParams.delete("route");
|
||||
return new Request(url.toString(), {
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body: req.body
|
||||
});
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export function serve(config: CreateAppConfig) {
|
||||
return async (req: Request) => {
|
||||
if (!app || isDebug()) {
|
||||
app = App.create(config);
|
||||
await app.build();
|
||||
}
|
||||
const request = getCleanRequest(req);
|
||||
return app.fetch(request, process.env);
|
||||
};
|
||||
}
|
||||
1
app/src/adapter/remix/index.ts
Normal file
1
app/src/adapter/remix/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./remix.adapter";
|
||||
12
app/src/adapter/remix/remix.adapter.ts
Normal file
12
app/src/adapter/remix/remix.adapter.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { App, type CreateAppConfig } from "../../App";
|
||||
|
||||
let app: App;
|
||||
export function serve(config: CreateAppConfig) {
|
||||
return async (args: { request: Request }) => {
|
||||
if (!app) {
|
||||
app = App.create(config);
|
||||
await app.build();
|
||||
}
|
||||
return app.fetch(args.request);
|
||||
};
|
||||
}
|
||||
1
app/src/adapter/vite/index.ts
Normal file
1
app/src/adapter/vite/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./vite.adapter";
|
||||
82
app/src/adapter/vite/vite.adapter.ts
Normal file
82
app/src/adapter/vite/vite.adapter.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { App } from "../../App";
|
||||
import type { BkndConfig } from "../index";
|
||||
|
||||
async function getHtml() {
|
||||
return readFile("index.html", "utf8");
|
||||
}
|
||||
function addViteScripts(html: string) {
|
||||
return html.replace(
|
||||
"<head>",
|
||||
`<script type="module">
|
||||
import RefreshRuntime from "/@react-refresh"
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
<script type="module" src="/@vite/client"></script>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
function createApp(config: BkndConfig, env: any) {
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
return App.create(create_config);
|
||||
}
|
||||
|
||||
function setAppBuildListener(app: App, config: BkndConfig, html: string) {
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
async () => {
|
||||
await config.onBuilt?.(app);
|
||||
app.module.server.setAdminHtml(html);
|
||||
app.module.server.client.get("/assets/!*", serveStatic({ root: "./" }));
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
}
|
||||
|
||||
export async function serveFresh(config: BkndConfig, _html?: string) {
|
||||
let html = _html;
|
||||
if (!html) {
|
||||
html = await getHtml();
|
||||
}
|
||||
|
||||
html = addViteScripts(html);
|
||||
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
const app = createApp(config, env);
|
||||
|
||||
setAppBuildListener(app, config, html);
|
||||
await app.build();
|
||||
|
||||
//console.log("routes", app.module.server.client.routes);
|
||||
return app.fetch(request, env, ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export async function serveCached(config: BkndConfig, _html?: string) {
|
||||
let html = _html;
|
||||
if (!html) {
|
||||
html = await getHtml();
|
||||
}
|
||||
|
||||
html = addViteScripts(html);
|
||||
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
if (!app) {
|
||||
app = createApp(config, env);
|
||||
setAppBuildListener(app, config, html);
|
||||
await app.build();
|
||||
}
|
||||
|
||||
return app.fetch(request, env, ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
269
app/src/auth/AppAuth.ts
Normal file
269
app/src/auth/AppAuth.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
|
||||
import { Exception } from "core";
|
||||
import { Const, StringRecord, Type, transformObject } from "core/utils";
|
||||
import {
|
||||
type Entity,
|
||||
EntityIndex,
|
||||
type EntityManager,
|
||||
EnumField,
|
||||
type Field,
|
||||
type Mutator
|
||||
} from "data";
|
||||
import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
|
||||
import { cloneDeep, mergeWith, omit, pick } from "lodash-es";
|
||||
import { Module } from "modules/Module";
|
||||
import { AuthController } from "./api/AuthController";
|
||||
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
|
||||
|
||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||
declare global {
|
||||
interface DB {
|
||||
users: UserFieldSchema;
|
||||
}
|
||||
}
|
||||
|
||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
private _authenticator?: Authenticator;
|
||||
cache: Record<string, any> = {};
|
||||
|
||||
override async build() {
|
||||
if (!this.config.enabled) {
|
||||
this.setBuilt();
|
||||
return;
|
||||
}
|
||||
|
||||
// register roles
|
||||
const roles = transformObject(this.config.roles ?? {}, (role, name) => {
|
||||
//console.log("role", role, name);
|
||||
return Role.create({ name, ...role });
|
||||
});
|
||||
this.ctx.guard.setRoles(Object.values(roles));
|
||||
this.ctx.guard.setConfig(this.config.guard ?? {});
|
||||
|
||||
// build strategies
|
||||
const strategies = transformObject(this.config.strategies ?? {}, (strategy, name) => {
|
||||
try {
|
||||
return new STRATEGIES[strategy.type].cls(strategy.config as any);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Could not build strategy ${String(name)} with config ${JSON.stringify(strategy.config)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const { fields, ...jwt } = this.config.jwt;
|
||||
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
|
||||
jwt
|
||||
});
|
||||
|
||||
this.registerEntities();
|
||||
super.setBuilt();
|
||||
|
||||
const controller = new AuthController(this);
|
||||
//this.ctx.server.use(controller.getMiddleware);
|
||||
this.ctx.server.route(this.config.basepath, controller.getController());
|
||||
}
|
||||
|
||||
getMiddleware() {
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new AuthController(this).getMiddleware;
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return authConfigSchema;
|
||||
}
|
||||
|
||||
get authenticator(): Authenticator {
|
||||
this.throwIfNotBuilt();
|
||||
return this._authenticator!;
|
||||
}
|
||||
|
||||
get em(): EntityManager<DB> {
|
||||
return this.ctx.em as any;
|
||||
}
|
||||
|
||||
private async resolveUser(
|
||||
action: AuthAction,
|
||||
strategy: Strategy,
|
||||
identifier: string,
|
||||
profile: ProfileExchange
|
||||
): Promise<any> {
|
||||
console.log("***** AppAuth:resolveUser", {
|
||||
action,
|
||||
strategy: strategy.getName(),
|
||||
identifier,
|
||||
profile
|
||||
});
|
||||
|
||||
const fields = this.getUsersEntity()
|
||||
.getFillableFields("create")
|
||||
.map((f) => f.name);
|
||||
const filteredProfile = Object.fromEntries(
|
||||
Object.entries(profile).filter(([key]) => fields.includes(key))
|
||||
);
|
||||
|
||||
switch (action) {
|
||||
case "login":
|
||||
return this.login(strategy, identifier, filteredProfile);
|
||||
case "register":
|
||||
return this.register(strategy, identifier, filteredProfile);
|
||||
}
|
||||
}
|
||||
|
||||
private filterUserData(user: any) {
|
||||
console.log(
|
||||
"--filterUserData",
|
||||
user,
|
||||
this.config.jwt.fields,
|
||||
pick(user, this.config.jwt.fields)
|
||||
);
|
||||
return pick(user, this.config.jwt.fields);
|
||||
}
|
||||
|
||||
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
||||
console.log("--- trying to login", { strategy: strategy.getName(), identifier, profile });
|
||||
if (!("email" in profile)) {
|
||||
throw new Exception("Profile must have email");
|
||||
}
|
||||
if (typeof identifier !== "string" || identifier.length === 0) {
|
||||
throw new Exception("Identifier must be a string");
|
||||
}
|
||||
|
||||
const users = this.getUsersEntity();
|
||||
this.toggleStrategyValueVisibility(true);
|
||||
const result = await this.em.repo(users).findOne({ email: profile.email! });
|
||||
this.toggleStrategyValueVisibility(false);
|
||||
if (!result.data) {
|
||||
throw new Exception("User not found", 404);
|
||||
}
|
||||
console.log("---login data", result.data, result);
|
||||
|
||||
// compare strategy and identifier
|
||||
console.log("strategy comparison", result.data.strategy, strategy.getName());
|
||||
if (result.data.strategy !== strategy.getName()) {
|
||||
console.log("!!! User registered with different strategy");
|
||||
throw new Exception("User registered with different strategy");
|
||||
}
|
||||
|
||||
console.log("identifier comparison", result.data.strategy_value, identifier);
|
||||
if (result.data.strategy_value !== identifier) {
|
||||
console.log("!!! Invalid credentials");
|
||||
throw new Exception("Invalid credentials");
|
||||
}
|
||||
|
||||
return this.filterUserData(result.data);
|
||||
}
|
||||
|
||||
private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
||||
if (!("email" in profile)) {
|
||||
throw new Exception("Profile must have an email");
|
||||
}
|
||||
if (typeof identifier !== "string" || identifier.length === 0) {
|
||||
throw new Exception("Identifier must be a string");
|
||||
}
|
||||
|
||||
const users = this.getUsersEntity();
|
||||
const { data } = await this.em.repo(users).findOne({ email: profile.email! });
|
||||
if (data) {
|
||||
throw new Exception("User already exists");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...profile,
|
||||
strategy: strategy.getName(),
|
||||
strategy_value: identifier
|
||||
};
|
||||
|
||||
const mutator = this.em.mutator(users);
|
||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||
this.toggleStrategyValueVisibility(true);
|
||||
const createResult = await mutator.insertOne(payload);
|
||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||
this.toggleStrategyValueVisibility(false);
|
||||
if (!createResult.data) {
|
||||
throw new Error("Could not create user");
|
||||
}
|
||||
|
||||
return this.filterUserData(createResult.data);
|
||||
}
|
||||
|
||||
private toggleStrategyValueVisibility(visible: boolean) {
|
||||
const field = this.getUsersEntity().field("strategy_value")!;
|
||||
|
||||
field.config.hidden = !visible;
|
||||
field.config.fillable = visible;
|
||||
// @todo: think about a PasswordField that automatically hashes on save?
|
||||
}
|
||||
|
||||
getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> {
|
||||
const entity_name = this.config.entity_name;
|
||||
if (forceCreate || !this.em.hasEntity(entity_name)) {
|
||||
return entity(entity_name as "users", AppAuth.usersFields, undefined, "system");
|
||||
}
|
||||
|
||||
return this.em.entity(entity_name) as any;
|
||||
}
|
||||
|
||||
static usersFields = {
|
||||
email: text().required(),
|
||||
strategy: text({ fillable: ["create"], hidden: ["form"] }).required(),
|
||||
strategy_value: text({
|
||||
fillable: ["create"],
|
||||
hidden: ["read", "table", "update", "form"]
|
||||
}).required(),
|
||||
role: text()
|
||||
};
|
||||
|
||||
registerEntities() {
|
||||
const users = this.getUsersEntity();
|
||||
|
||||
if (!this.em.hasEntity(users.name)) {
|
||||
this.em.addEntity(users);
|
||||
} else {
|
||||
// if exists, check all fields required are there
|
||||
// @todo: add to context: "needs sync" flag
|
||||
const _entity = this.getUsersEntity(true);
|
||||
for (const field of _entity.fields) {
|
||||
const _field = users.field(field.name);
|
||||
if (!_field) {
|
||||
users.addField(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const indices = [
|
||||
new EntityIndex(users, [users.field("email")!], true),
|
||||
new EntityIndex(users, [users.field("strategy")!]),
|
||||
new EntityIndex(users, [users.field("strategy_value")!])
|
||||
];
|
||||
indices.forEach((index) => {
|
||||
if (!this.em.hasIndex(index)) {
|
||||
this.em.addIndex(index);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const roles = Object.keys(this.config.roles ?? {});
|
||||
const field = make("role", enumm({ enum: roles }));
|
||||
this.em.entity(users.name).__experimental_replaceField("role", field);
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const strategies = Object.keys(this.config.strategies ?? {});
|
||||
const field = make("strategy", enumm({ enum: strategies }));
|
||||
this.em.entity(users.name).__experimental_replaceField("strategy", field);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
override toJSON(secrets?: boolean): AppAuthSchema {
|
||||
if (!this.config.enabled) {
|
||||
return this.configDefault;
|
||||
}
|
||||
|
||||
// fixes freezed config object
|
||||
return mergeWith({ ...this.config }, this.authenticator.toJSON(secrets));
|
||||
}
|
||||
}
|
||||
41
app/src/auth/api/AuthApi.ts
Normal file
41
app/src/auth/api/AuthApi.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema";
|
||||
import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator";
|
||||
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
|
||||
|
||||
export type AuthApiOptions = BaseModuleApiOptions & {
|
||||
onTokenUpdate?: (token: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
protected override getDefaultOptions(): Partial<AuthApiOptions> {
|
||||
return {
|
||||
basepath: "/api/auth"
|
||||
};
|
||||
}
|
||||
|
||||
async loginWithPassword(input: any) {
|
||||
const res = await this.post<AuthResponse>(["password", "login"], input);
|
||||
if (res.res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async registerWithPassword(input: any) {
|
||||
const res = await this.post<AuthResponse>(["password", "register"], input);
|
||||
if (res.res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async me() {
|
||||
return this.get<{ user: SafeUser | null }>(["me"]);
|
||||
}
|
||||
|
||||
async strategies() {
|
||||
return this.get<{ strategies: AppAuthSchema["strategies"] }>(["strategies"]);
|
||||
}
|
||||
|
||||
async logout() {}
|
||||
}
|
||||
57
app/src/auth/api/AuthController.ts
Normal file
57
app/src/auth/api/AuthController.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { AppAuth } from "auth";
|
||||
import type { ClassController } from "core";
|
||||
import { Hono, type MiddlewareHandler } from "hono";
|
||||
|
||||
export class AuthController implements ClassController {
|
||||
constructor(private auth: AppAuth) {}
|
||||
|
||||
getMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
// @todo: consider adding app name to the payload, because user is not refetched
|
||||
|
||||
//try {
|
||||
if (c.req.raw.headers.has("Authorization")) {
|
||||
const bearerHeader = String(c.req.header("Authorization"));
|
||||
const token = bearerHeader.replace("Bearer ", "");
|
||||
const verified = await this.auth.authenticator.verify(token);
|
||||
|
||||
// @todo: don't extract user from token, but from the database or cache
|
||||
this.auth.ctx.guard.setUserContext(this.auth.authenticator.getUser());
|
||||
/*console.log("jwt verified?", {
|
||||
verified,
|
||||
auth: this.auth.authenticator.isUserLoggedIn()
|
||||
});*/
|
||||
} else {
|
||||
this.auth.authenticator.__setUserNull();
|
||||
}
|
||||
/* } catch (e) {
|
||||
this.auth.authenticator.__setUserNull();
|
||||
}*/
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
getController(): Hono<any> {
|
||||
const hono = new Hono();
|
||||
const strategies = this.auth.authenticator.getStrategies();
|
||||
//console.log("strategies", strategies);
|
||||
|
||||
for (const [name, strategy] of Object.entries(strategies)) {
|
||||
//console.log("registering", name, "at", `/${name}`);
|
||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||
}
|
||||
|
||||
hono.get("/me", async (c) => {
|
||||
if (this.auth.authenticator.isUserLoggedIn()) {
|
||||
return c.json({ user: await this.auth.authenticator.getUser() });
|
||||
}
|
||||
|
||||
return c.json({ user: null }, 403);
|
||||
});
|
||||
|
||||
hono.get("/strategies", async (c) => {
|
||||
return c.json({ strategies: this.auth.toJSON(false).strategies });
|
||||
});
|
||||
|
||||
return hono;
|
||||
}
|
||||
}
|
||||
85
app/src/auth/auth-schema.ts
Normal file
85
app/src/auth/auth-schema.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { jwtConfig } from "auth/authenticate/Authenticator";
|
||||
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
|
||||
|
||||
export const Strategies = {
|
||||
password: {
|
||||
cls: PasswordStrategy,
|
||||
schema: PasswordStrategy.prototype.getSchema()
|
||||
},
|
||||
oauth: {
|
||||
cls: OAuthStrategy,
|
||||
schema: OAuthStrategy.prototype.getSchema()
|
||||
},
|
||||
custom_oauth: {
|
||||
cls: CustomOAuthStrategy,
|
||||
schema: CustomOAuthStrategy.prototype.getSchema()
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const STRATEGIES = Strategies;
|
||||
const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
||||
return Type.Object(
|
||||
{
|
||||
type: Type.Const(name, { default: name, readOnly: true }),
|
||||
config: strategy.schema
|
||||
},
|
||||
{
|
||||
title: name,
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
});
|
||||
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
||||
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
||||
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
||||
|
||||
const guardConfigSchema = Type.Object({
|
||||
enabled: Type.Optional(Type.Boolean({ default: false }))
|
||||
});
|
||||
export const guardRoleSchema = Type.Object(
|
||||
{
|
||||
permissions: Type.Optional(Type.Array(Type.String())),
|
||||
is_default: Type.Optional(Type.Boolean()),
|
||||
implicit_allow: Type.Optional(Type.Boolean())
|
||||
},
|
||||
{ additionalProperties: false }
|
||||
);
|
||||
|
||||
export const authConfigSchema = Type.Object(
|
||||
{
|
||||
enabled: Type.Boolean({ default: false }),
|
||||
basepath: Type.String({ default: "/api/auth" }),
|
||||
entity_name: Type.String({ default: "users" }),
|
||||
jwt: Type.Composite(
|
||||
[
|
||||
jwtConfig,
|
||||
Type.Object({
|
||||
fields: Type.Array(Type.String(), { default: ["id", "email", "role"] })
|
||||
})
|
||||
],
|
||||
{ default: {}, additionalProperties: false }
|
||||
),
|
||||
strategies: Type.Optional(
|
||||
StringRecord(strategiesSchema, {
|
||||
title: "Strategies",
|
||||
default: {
|
||||
password: {
|
||||
type: "password",
|
||||
config: {
|
||||
hashing: "sha256"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
),
|
||||
guard: Type.Optional(guardConfigSchema),
|
||||
roles: Type.Optional(StringRecord(guardRoleSchema, { default: {} }))
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type AppAuthSchema = Static<typeof authConfigSchema>;
|
||||
190
app/src/auth/authenticate/Authenticator.ts
Normal file
190
app/src/auth/authenticate/Authenticator.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { type Static, type TSchema, Type, parse, randomString, transformObject } from "core/utils";
|
||||
import type { Hono } from "hono";
|
||||
import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose";
|
||||
|
||||
type Input = any; // workaround
|
||||
|
||||
// @todo: add schema to interface to ensure proper inference
|
||||
export interface Strategy {
|
||||
getController: (auth: Authenticator) => Hono<any>;
|
||||
getType: () => string;
|
||||
getMode: () => "form" | "external";
|
||||
getName: () => string;
|
||||
toJSON: (secrets?: boolean) => any;
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type ProfileExchange = {
|
||||
email?: string;
|
||||
username?: string;
|
||||
sub?: string;
|
||||
password?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type SafeUser = Omit<User, "password">;
|
||||
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
|
||||
export type AuthResponse = { user: SafeUser; token: string };
|
||||
|
||||
export interface UserPool<Fields = "id" | "email" | "username"> {
|
||||
findBy: (prop: Fields, value: string | number) => Promise<User | undefined>;
|
||||
create: (user: CreateUser) => Promise<User | undefined>;
|
||||
}
|
||||
|
||||
export const jwtConfig = Type.Object(
|
||||
{
|
||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
||||
secret: Type.String({ default: "secret" }),
|
||||
alg: Type.Optional(Type.String({ enum: ["HS256"], default: "HS256" })),
|
||||
expiresIn: Type.Optional(Type.String()),
|
||||
issuer: Type.Optional(Type.String())
|
||||
},
|
||||
{
|
||||
default: {},
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
export const authenticatorConfig = Type.Object({
|
||||
jwt: jwtConfig
|
||||
});
|
||||
|
||||
type AuthConfig = Static<typeof authenticatorConfig>;
|
||||
export type AuthAction = "login" | "register";
|
||||
export type AuthUserResolver = (
|
||||
action: AuthAction,
|
||||
strategy: Strategy,
|
||||
identifier: string,
|
||||
profile: ProfileExchange
|
||||
) => Promise<SafeUser | undefined>;
|
||||
|
||||
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
||||
private readonly strategies: Strategies;
|
||||
private readonly config: AuthConfig;
|
||||
private _user: SafeUser | undefined;
|
||||
private readonly userResolver: AuthUserResolver;
|
||||
|
||||
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
|
||||
this.userResolver = userResolver ?? (async (a, s, i, p) => p as any);
|
||||
this.strategies = strategies as Strategies;
|
||||
this.config = parse(authenticatorConfig, config ?? {});
|
||||
|
||||
/*const secret = String(this.config.jwt.secret);
|
||||
if (secret === "secret" || secret.length === 0) {
|
||||
this.config.jwt.secret = randomString(64, true);
|
||||
}*/
|
||||
}
|
||||
|
||||
async resolve(
|
||||
action: AuthAction,
|
||||
strategy: Strategy,
|
||||
identifier: string,
|
||||
profile: ProfileExchange
|
||||
) {
|
||||
//console.log("resolve", { action, strategy: strategy.getName(), profile });
|
||||
const user = await this.userResolver(action, strategy, identifier, profile);
|
||||
|
||||
if (user) {
|
||||
return {
|
||||
user,
|
||||
token: await this.jwt(user)
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("User could not be resolved");
|
||||
}
|
||||
|
||||
getStrategies(): Strategies {
|
||||
return this.strategies;
|
||||
}
|
||||
|
||||
isUserLoggedIn(): boolean {
|
||||
return this._user !== undefined;
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this._user;
|
||||
}
|
||||
|
||||
// @todo: determine what to do exactly
|
||||
__setUserNull() {
|
||||
this._user = undefined;
|
||||
}
|
||||
|
||||
strategy<
|
||||
StrategyName extends keyof Strategies,
|
||||
Strat extends Strategy = Strategies[StrategyName]
|
||||
>(strategy: StrategyName): Strat {
|
||||
try {
|
||||
return this.strategies[strategy] as unknown as Strat;
|
||||
} catch (e) {
|
||||
throw new Error(`Strategy "${String(strategy)}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async jwt(user: Omit<User, "password">): Promise<string> {
|
||||
const prohibited = ["password"];
|
||||
for (const prop of prohibited) {
|
||||
if (prop in user) {
|
||||
throw new Error(`Property "${prop}" is prohibited`);
|
||||
}
|
||||
}
|
||||
|
||||
const jwt = new SignJWT(user)
|
||||
.setProtectedHeader({ alg: this.config.jwt?.alg ?? "HS256" })
|
||||
.setIssuedAt();
|
||||
|
||||
if (this.config.jwt?.issuer) {
|
||||
jwt.setIssuer(this.config.jwt.issuer);
|
||||
}
|
||||
|
||||
if (this.config.jwt?.expiresIn) {
|
||||
jwt.setExpirationTime(this.config.jwt.expiresIn);
|
||||
}
|
||||
|
||||
return jwt.sign(new TextEncoder().encode(this.config.jwt?.secret ?? ""));
|
||||
}
|
||||
|
||||
async verify(jwt: string): Promise<boolean> {
|
||||
const options: JWTVerifyOptions = {
|
||||
algorithms: [this.config.jwt?.alg ?? "HS256"]
|
||||
};
|
||||
|
||||
if (this.config.jwt?.issuer) {
|
||||
options.issuer = this.config.jwt.issuer;
|
||||
}
|
||||
|
||||
if (this.config.jwt?.expiresIn) {
|
||||
options.maxTokenAge = this.config.jwt.expiresIn;
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify<User>(
|
||||
jwt,
|
||||
new TextEncoder().encode(this.config.jwt?.secret ?? ""),
|
||||
options
|
||||
);
|
||||
this._user = payload;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._user = undefined;
|
||||
//console.error(e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
return {
|
||||
...this.config,
|
||||
jwt: secrets ? this.config.jwt : undefined,
|
||||
strategies: transformObject(this.getStrategies(), (s) => s.toJSON(secrets))
|
||||
};
|
||||
}
|
||||
}
|
||||
98
app/src/auth/authenticate/strategies/PasswordStrategy.ts
Normal file
98
app/src/auth/authenticate/strategies/PasswordStrategy.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Authenticator, Strategy } from "auth";
|
||||
import { type Static, StringEnum, Type, parse } from "core/utils";
|
||||
import { hash } from "core/utils";
|
||||
import { Hono } from "hono";
|
||||
|
||||
type LoginSchema = { username: string; password: string } | { email: string; password: string };
|
||||
type RegisterSchema = { email: string; password: string; [key: string]: any };
|
||||
|
||||
const schema = Type.Object({
|
||||
hashing: StringEnum(["plain", "sha256" /*, "bcrypt"*/] as const, { default: "sha256" })
|
||||
});
|
||||
|
||||
export type PasswordStrategyOptions = Static<typeof schema>;
|
||||
/*export type PasswordStrategyOptions2 = {
|
||||
hashing?: "plain" | "bcrypt" | "sha256";
|
||||
};*/
|
||||
|
||||
export class PasswordStrategy implements Strategy {
|
||||
private options: PasswordStrategyOptions;
|
||||
|
||||
constructor(options: Partial<PasswordStrategyOptions> = {}) {
|
||||
this.options = parse(schema, options);
|
||||
}
|
||||
|
||||
async hash(password: string) {
|
||||
switch (this.options.hashing) {
|
||||
case "sha256":
|
||||
return hash.sha256(password);
|
||||
default:
|
||||
return password;
|
||||
}
|
||||
}
|
||||
|
||||
async login(input: LoginSchema) {
|
||||
if (!("email" in input) || !("password" in input)) {
|
||||
throw new Error("Invalid input: Email and password must be provided");
|
||||
}
|
||||
|
||||
const hashedPassword = await this.hash(input.password);
|
||||
return { ...input, password: hashedPassword };
|
||||
}
|
||||
|
||||
async register(input: RegisterSchema) {
|
||||
if (!input.email || !input.password) {
|
||||
throw new Error("Invalid input: Email and password must be provided");
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
password: await this.hash(input.password)
|
||||
};
|
||||
}
|
||||
|
||||
getController(authenticator: Authenticator): Hono<any> {
|
||||
const hono = new Hono();
|
||||
|
||||
return hono
|
||||
.post("/login", async (c) => {
|
||||
const body = (await c.req.json()) ?? {};
|
||||
|
||||
const payload = await this.login(body);
|
||||
const data = await authenticator.resolve("login", this, payload.password, payload);
|
||||
|
||||
return c.json(data);
|
||||
})
|
||||
.post("/register", async (c) => {
|
||||
const body = (await c.req.json()) ?? {};
|
||||
|
||||
const payload = await this.register(body);
|
||||
const data = await authenticator.resolve("register", this, payload.password, payload);
|
||||
|
||||
return c.json(data);
|
||||
});
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
getType() {
|
||||
return "password";
|
||||
}
|
||||
|
||||
getMode() {
|
||||
return "form" as const;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "password" as const;
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
return {
|
||||
type: this.getType(),
|
||||
config: secrets ? this.options : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
13
app/src/auth/authenticate/strategies/index.ts
Normal file
13
app/src/auth/authenticate/strategies/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { CustomOAuthStrategy } from "auth/authenticate/strategies/oauth/CustomOAuthStrategy";
|
||||
import { PasswordStrategy, type PasswordStrategyOptions } from "./PasswordStrategy";
|
||||
import { OAuthCallbackException, OAuthStrategy } from "./oauth/OAuthStrategy";
|
||||
|
||||
export * as issuers from "./oauth/issuers";
|
||||
|
||||
export {
|
||||
PasswordStrategy,
|
||||
type PasswordStrategyOptions,
|
||||
OAuthStrategy,
|
||||
OAuthCallbackException,
|
||||
CustomOAuthStrategy
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type Static, StringEnum, Type } from "core/utils";
|
||||
import type * as oauth from "oauth4webapi";
|
||||
import { OAuthStrategy } from "./OAuthStrategy";
|
||||
|
||||
type SupportedTypes = "oauth2" | "oidc";
|
||||
|
||||
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
|
||||
|
||||
const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
|
||||
const oauthSchemaCustom = Type.Object(
|
||||
{
|
||||
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
|
||||
name: Type.String(),
|
||||
client: Type.Object(
|
||||
{
|
||||
client_id: Type.String(),
|
||||
client_secret: Type.String(),
|
||||
token_endpoint_auth_method: StringEnum(["client_secret_basic"])
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
),
|
||||
as: Type.Object(
|
||||
{
|
||||
issuer: Type.String(),
|
||||
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
|
||||
scopes_supported: Type.Optional(Type.Array(Type.String())),
|
||||
scope_separator: Type.Optional(Type.String({ default: " " })),
|
||||
authorization_endpoint: Type.Optional(UrlString),
|
||||
token_endpoint: Type.Optional(UrlString),
|
||||
userinfo_endpoint: Type.Optional(UrlString)
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
)
|
||||
// @todo: profile mapping
|
||||
},
|
||||
{ title: "Custom OAuth", additionalProperties: false }
|
||||
);
|
||||
|
||||
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
|
||||
|
||||
export type UserProfile = {
|
||||
sub: string;
|
||||
email: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type IssuerConfig<UserInfo = any> = {
|
||||
type: SupportedTypes;
|
||||
client: RequireKeys<oauth.Client, "token_endpoint_auth_method">;
|
||||
as: oauth.AuthorizationServer & {
|
||||
scope_separator?: string;
|
||||
};
|
||||
profile: (
|
||||
info: UserInfo,
|
||||
config: Omit<IssuerConfig, "profile">,
|
||||
tokenResponse: any
|
||||
) => Promise<UserProfile>;
|
||||
};
|
||||
|
||||
export class CustomOAuthStrategy extends OAuthStrategy {
|
||||
override getIssuerConfig(): IssuerConfig {
|
||||
return { ...this.config, profile: async (info) => info } as any;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
override getSchema() {
|
||||
return oauthSchemaCustom;
|
||||
}
|
||||
|
||||
override getType() {
|
||||
return "custom_oauth";
|
||||
}
|
||||
}
|
||||
431
app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts
Normal file
431
app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import type { AuthAction, Authenticator, Strategy } from "auth";
|
||||
import { Exception } from "core";
|
||||
import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
|
||||
import { type Context, Hono } from "hono";
|
||||
import { getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import * as oauth from "oauth4webapi";
|
||||
import * as issuers from "./issuers";
|
||||
|
||||
type ConfiguredIssuers = keyof typeof issuers;
|
||||
type SupportedTypes = "oauth2" | "oidc";
|
||||
|
||||
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
|
||||
|
||||
const schemaProvided = Type.Object(
|
||||
{
|
||||
//type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
|
||||
name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]),
|
||||
client: Type.Object(
|
||||
{
|
||||
client_id: Type.String(),
|
||||
client_secret: Type.String()
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
)
|
||||
},
|
||||
{ title: "OAuth" }
|
||||
);
|
||||
type ProvidedOAuthConfig = Static<typeof schemaProvided>;
|
||||
|
||||
export type CustomOAuthConfig = {
|
||||
type: SupportedTypes;
|
||||
name: string;
|
||||
} & IssuerConfig & {
|
||||
client: RequireKeys<
|
||||
oauth.Client,
|
||||
"client_id" | "client_secret" | "token_endpoint_auth_method"
|
||||
>;
|
||||
};
|
||||
|
||||
type OAuthConfig = ProvidedOAuthConfig | CustomOAuthConfig;
|
||||
|
||||
export type UserProfile = {
|
||||
sub: string;
|
||||
email: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type IssuerConfig<UserInfo = any> = {
|
||||
type: SupportedTypes;
|
||||
client: RequireKeys<oauth.Client, "token_endpoint_auth_method">;
|
||||
as: oauth.AuthorizationServer & {
|
||||
scope_separator?: string;
|
||||
};
|
||||
profile: (
|
||||
info: UserInfo,
|
||||
config: Omit<IssuerConfig, "profile">,
|
||||
tokenResponse: any
|
||||
) => Promise<UserProfile>;
|
||||
};
|
||||
|
||||
export class OAuthCallbackException extends Exception {
|
||||
override name = "OAuthCallbackException";
|
||||
|
||||
constructor(
|
||||
public error: any,
|
||||
public step: string
|
||||
) {
|
||||
super("OAuthCallbackException on " + step);
|
||||
}
|
||||
}
|
||||
|
||||
export class OAuthStrategy implements Strategy {
|
||||
constructor(private _config: OAuthConfig) {}
|
||||
|
||||
get config() {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
getIssuerConfig(): IssuerConfig {
|
||||
return issuers[this.config.name];
|
||||
}
|
||||
|
||||
async getConfig(): Promise<
|
||||
IssuerConfig & {
|
||||
client: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
};
|
||||
}
|
||||
> {
|
||||
const info = this.getIssuerConfig();
|
||||
|
||||
if (info.type === "oidc") {
|
||||
const issuer = new URL(info.as.issuer);
|
||||
const request = await oauth.discoveryRequest(issuer);
|
||||
info.as = await oauth.processDiscoveryResponse(issuer, request);
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
type: info.type,
|
||||
client: {
|
||||
...info.client,
|
||||
...this._config.client
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async getCodeChallenge(as: oauth.AuthorizationServer, state: string, method: "S256" = "S256") {
|
||||
const challenge_supported = as.code_challenge_methods_supported?.includes(method);
|
||||
let challenge: string | undefined;
|
||||
let challenge_method: string | undefined;
|
||||
if (challenge_supported) {
|
||||
challenge = await oauth.calculatePKCECodeChallenge(state);
|
||||
challenge_method = method;
|
||||
}
|
||||
|
||||
return { challenge_supported, challenge, challenge_method };
|
||||
}
|
||||
|
||||
async request(options: { redirect_uri: string; state: string; scopes?: string[] }): Promise<{
|
||||
url: string;
|
||||
endpoint: string;
|
||||
params: Record<string, string>;
|
||||
}> {
|
||||
const { client, as } = await this.getConfig();
|
||||
|
||||
const { challenge_supported, challenge, challenge_method } = await this.getCodeChallenge(
|
||||
as,
|
||||
options.state
|
||||
);
|
||||
|
||||
if (!as.authorization_endpoint) {
|
||||
throw new Error("authorization_endpoint is not provided");
|
||||
}
|
||||
|
||||
const scopes = options.scopes ?? as.scopes_supported;
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||
throw new Error("No scopes provided");
|
||||
}
|
||||
|
||||
if (scopes.every((scope) => !as.scopes_supported?.includes(scope))) {
|
||||
throw new Error("Invalid scopes provided");
|
||||
}
|
||||
|
||||
const endpoint = as.authorization_endpoint!;
|
||||
const params: any = {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: options.redirect_uri,
|
||||
response_type: "code",
|
||||
scope: scopes.join(as.scope_separator ?? " ")
|
||||
};
|
||||
if (challenge_supported) {
|
||||
params.code_challenge = challenge;
|
||||
params.code_challenge_method = challenge_method;
|
||||
} else {
|
||||
params.nonce = options.state;
|
||||
}
|
||||
|
||||
return {
|
||||
url: new URL(endpoint) + "?" + new URLSearchParams(params).toString(),
|
||||
endpoint,
|
||||
params
|
||||
};
|
||||
}
|
||||
|
||||
private async oidc(
|
||||
callbackParams: URL | URLSearchParams,
|
||||
options: { redirect_uri: string; state: string; scopes?: string[] }
|
||||
) {
|
||||
const config = await this.getConfig();
|
||||
const { client, as, type } = config;
|
||||
//console.log("config", config);
|
||||
//console.log("callbackParams", callbackParams, options);
|
||||
const parameters = oauth.validateAuthResponse(
|
||||
as,
|
||||
client, // no client_secret required
|
||||
callbackParams,
|
||||
oauth.expectNoState
|
||||
);
|
||||
if (oauth.isOAuth2Error(parameters)) {
|
||||
//console.log("callback.error", parameters);
|
||||
throw new OAuthCallbackException(parameters, "validateAuthResponse");
|
||||
}
|
||||
/*console.log(
|
||||
"callback.parameters",
|
||||
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
|
||||
);*/
|
||||
const response = await oauth.authorizationCodeGrantRequest(
|
||||
as,
|
||||
client,
|
||||
parameters,
|
||||
options.redirect_uri,
|
||||
options.state
|
||||
);
|
||||
//console.log("callback.response", response);
|
||||
|
||||
const challenges = oauth.parseWwwAuthenticateChallenges(response);
|
||||
if (challenges) {
|
||||
for (const challenge of challenges) {
|
||||
//console.log("callback.challenge", challenge);
|
||||
}
|
||||
// @todo: Handle www-authenticate challenges as needed
|
||||
throw new OAuthCallbackException(challenges, "www-authenticate");
|
||||
}
|
||||
|
||||
const { challenge_supported, challenge } = await this.getCodeChallenge(as, options.state);
|
||||
|
||||
const expectedNonce = challenge_supported ? undefined : challenge;
|
||||
const result = await oauth.processAuthorizationCodeOpenIDResponse(
|
||||
as,
|
||||
client,
|
||||
response,
|
||||
expectedNonce
|
||||
);
|
||||
if (oauth.isOAuth2Error(result)) {
|
||||
//console.log("callback.error", result);
|
||||
// @todo: Handle OAuth 2.0 response body error
|
||||
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
|
||||
}
|
||||
|
||||
//console.log("callback.result", result);
|
||||
|
||||
const claims = oauth.getValidatedIdTokenClaims(result);
|
||||
//console.log("callback.IDTokenClaims", claims);
|
||||
|
||||
const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!);
|
||||
|
||||
const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest);
|
||||
//console.log("callback.resultUser", resultUser);
|
||||
|
||||
return await config.profile(resultUser, config, claims); // @todo: check claims
|
||||
}
|
||||
|
||||
private async oauth2(
|
||||
callbackParams: URL | URLSearchParams,
|
||||
options: { redirect_uri: string; state: string; scopes?: string[] }
|
||||
) {
|
||||
const config = await this.getConfig();
|
||||
const { client, type, as, profile } = config;
|
||||
console.log("config", { client, as, type });
|
||||
console.log("callbackParams", callbackParams, options);
|
||||
const parameters = oauth.validateAuthResponse(
|
||||
as,
|
||||
client, // no client_secret required
|
||||
callbackParams,
|
||||
oauth.expectNoState
|
||||
);
|
||||
if (oauth.isOAuth2Error(parameters)) {
|
||||
console.log("callback.error", parameters);
|
||||
throw new OAuthCallbackException(parameters, "validateAuthResponse");
|
||||
}
|
||||
console.log(
|
||||
"callback.parameters",
|
||||
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2)
|
||||
);
|
||||
const response = await oauth.authorizationCodeGrantRequest(
|
||||
as,
|
||||
client,
|
||||
parameters,
|
||||
options.redirect_uri,
|
||||
options.state
|
||||
);
|
||||
|
||||
const challenges = oauth.parseWwwAuthenticateChallenges(response);
|
||||
if (challenges) {
|
||||
for (const challenge of challenges) {
|
||||
//console.log("callback.challenge", challenge);
|
||||
}
|
||||
// @todo: Handle www-authenticate challenges as needed
|
||||
throw new OAuthCallbackException(challenges, "www-authenticate");
|
||||
}
|
||||
|
||||
// slack does not return valid "token_type"...
|
||||
const copy = response.clone();
|
||||
let result: any = {};
|
||||
try {
|
||||
result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response);
|
||||
if (oauth.isOAuth2Error(result)) {
|
||||
console.log("error", result);
|
||||
throw new Error(); // Handle OAuth 2.0 response body error
|
||||
}
|
||||
} catch (e) {
|
||||
result = (await copy.json()) as any;
|
||||
console.log("failed", result);
|
||||
}
|
||||
|
||||
const res2 = await oauth.userInfoRequest(as, client, result.access_token!);
|
||||
const user = await res2.json();
|
||||
console.log("res2", res2, user);
|
||||
|
||||
console.log("result", result);
|
||||
return await config.profile(user, config, result);
|
||||
}
|
||||
|
||||
async callback(
|
||||
callbackParams: URL | URLSearchParams,
|
||||
options: { redirect_uri: string; state: string; scopes?: string[] }
|
||||
): Promise<UserProfile> {
|
||||
const type = this.getIssuerConfig().type;
|
||||
|
||||
console.log("type", type);
|
||||
switch (type) {
|
||||
case "oidc":
|
||||
return await this.oidc(callbackParams, options);
|
||||
case "oauth2":
|
||||
return await this.oauth2(callbackParams, options);
|
||||
default:
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
}
|
||||
|
||||
getController(auth: Authenticator): Hono<any> {
|
||||
const hono = new Hono();
|
||||
const secret = "secret";
|
||||
const cookie_name = "_challenge";
|
||||
|
||||
const setState = async (
|
||||
c: Context,
|
||||
config: { state: string; action: AuthAction; redirect?: string }
|
||||
): Promise<void> => {
|
||||
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 60 * 5 // 5 minutes
|
||||
});
|
||||
};
|
||||
|
||||
const getState = async (
|
||||
c: Context
|
||||
): Promise<{ state: string; action: AuthAction; redirect?: string }> => {
|
||||
const state = await getSignedCookie(c, secret, cookie_name);
|
||||
try {
|
||||
return JSON.parse(state as string);
|
||||
} catch (e) {
|
||||
throw new Error("Invalid state");
|
||||
}
|
||||
};
|
||||
|
||||
hono.get("/callback", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const params = new URLSearchParams(url.search);
|
||||
|
||||
const state = await getState(c);
|
||||
console.log("url", url);
|
||||
|
||||
const profile = await this.callback(params, {
|
||||
redirect_uri: url.origin + url.pathname,
|
||||
state: state.state
|
||||
});
|
||||
|
||||
const { user, token } = await auth.resolve(state.action, this, profile.sub, profile);
|
||||
console.log("******** RESOLVED ********", { user, token });
|
||||
|
||||
if (state.redirect) {
|
||||
console.log("redirect to", state.redirect + "?token=" + token);
|
||||
return c.redirect(state.redirect + "?token=" + token);
|
||||
}
|
||||
|
||||
return c.json({ user, token });
|
||||
});
|
||||
|
||||
hono.get("/:action", async (c) => {
|
||||
const action = c.req.param("action") as AuthAction;
|
||||
if (!["login", "register"].includes(action)) {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
const url = new URL(c.req.url);
|
||||
const path = url.pathname.replace(`/${action}`, "");
|
||||
const redirect_uri = url.origin + path + "/callback";
|
||||
const q_redirect = (c.req.query("redirect") as string) ?? undefined;
|
||||
|
||||
const state = await oauth.generateRandomCodeVerifier();
|
||||
const response = await this.request({
|
||||
redirect_uri,
|
||||
state
|
||||
});
|
||||
//console.log("_state", state);
|
||||
|
||||
await setState(c, { state, action, redirect: q_redirect });
|
||||
|
||||
if (c.req.header("Accept") === "application/json") {
|
||||
return c.json({
|
||||
url: response.url,
|
||||
redirect_uri,
|
||||
challenge: state,
|
||||
params: response.params
|
||||
});
|
||||
}
|
||||
|
||||
//return c.text(response.url);
|
||||
console.log("--redirecting to", response.url);
|
||||
|
||||
return c.redirect(response.url);
|
||||
});
|
||||
|
||||
return hono;
|
||||
}
|
||||
|
||||
getType() {
|
||||
return "oauth";
|
||||
}
|
||||
|
||||
getMode() {
|
||||
return "external" as const;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.config.name;
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return schemaProvided;
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
|
||||
|
||||
return {
|
||||
type: this.getType(),
|
||||
config: {
|
||||
type: this.getIssuerConfig().type,
|
||||
...config
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
63
app/src/auth/authenticate/strategies/oauth/issuers/github.ts
Normal file
63
app/src/auth/authenticate/strategies/oauth/issuers/github.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { IssuerConfig } from "../OAuthStrategy";
|
||||
|
||||
type GithubUserInfo = {
|
||||
id: number;
|
||||
sub: string;
|
||||
name: string;
|
||||
email: null;
|
||||
avatar_url: string;
|
||||
};
|
||||
|
||||
type GithubUserEmailResponse = {
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
visibility: string;
|
||||
}[];
|
||||
|
||||
export const github: IssuerConfig<GithubUserInfo> = {
|
||||
type: "oauth2",
|
||||
client: {
|
||||
token_endpoint_auth_method: "client_secret_basic",
|
||||
},
|
||||
as: {
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
issuer: "https://github.com",
|
||||
scopes_supported: ["read:user", "user:email"],
|
||||
scope_separator: " ",
|
||||
authorization_endpoint: "https://github.com/login/oauth/authorize",
|
||||
token_endpoint: "https://github.com/login/oauth/access_token",
|
||||
userinfo_endpoint: "https://api.github.com/user",
|
||||
},
|
||||
profile: async (
|
||||
info: GithubUserInfo,
|
||||
config: Omit<IssuerConfig, "profile">,
|
||||
tokenResponse: any,
|
||||
) => {
|
||||
console.log("github info", info, config, tokenResponse);
|
||||
|
||||
try {
|
||||
const res = await fetch("https://api.github.com/user/emails", {
|
||||
headers: {
|
||||
"User-Agent": "bknd", // this is mandatory... *smh*
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${tokenResponse.access_token}`,
|
||||
},
|
||||
});
|
||||
const data = (await res.json()) as GithubUserEmailResponse;
|
||||
console.log("data", data);
|
||||
const email = data.find((e: any) => e.primary)?.email;
|
||||
if (!email) {
|
||||
throw new Error("No primary email found");
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
sub: String(info.id),
|
||||
email: email,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error("Couldn't retrive github email");
|
||||
}
|
||||
},
|
||||
};
|
||||
29
app/src/auth/authenticate/strategies/oauth/issuers/google.ts
Normal file
29
app/src/auth/authenticate/strategies/oauth/issuers/google.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { IssuerConfig } from "../OAuthStrategy";
|
||||
|
||||
type GoogleUserInfo = {
|
||||
sub: string;
|
||||
name: string;
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
picture: string;
|
||||
email: string;
|
||||
email_verified: boolean;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export const google: IssuerConfig<GoogleUserInfo> = {
|
||||
type: "oidc",
|
||||
client: {
|
||||
token_endpoint_auth_method: "client_secret_basic",
|
||||
},
|
||||
as: {
|
||||
issuer: "https://accounts.google.com",
|
||||
},
|
||||
profile: async (info) => {
|
||||
return {
|
||||
...info,
|
||||
sub: info.sub,
|
||||
email: info.email,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { google } from "./google";
|
||||
export { github } from "./github";
|
||||
160
app/src/auth/authorize/Guard.ts
Normal file
160
app/src/auth/authorize/Guard.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Exception, Permission } from "core";
|
||||
import { type Static, Type, objectTransform } from "core/utils";
|
||||
import { Role } from "./Role";
|
||||
|
||||
export type GuardUserContext = {
|
||||
role: string | null | undefined;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type GuardConfig = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export class Guard {
|
||||
permissions: Permission[];
|
||||
user?: GuardUserContext;
|
||||
roles?: Role[];
|
||||
config?: GuardConfig;
|
||||
|
||||
constructor(permissions: Permission[] = [], roles: Role[] = [], config?: GuardConfig) {
|
||||
this.permissions = permissions;
|
||||
this.roles = roles;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
static create(
|
||||
permissionNames: string[],
|
||||
roles?: Record<
|
||||
string,
|
||||
{
|
||||
permissions?: string[];
|
||||
is_default?: boolean;
|
||||
implicit_allow?: boolean;
|
||||
}
|
||||
>,
|
||||
config?: GuardConfig
|
||||
) {
|
||||
const _roles = roles
|
||||
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
|
||||
return Role.createWithPermissionNames(name, permissions, is_default, implicit_allow);
|
||||
})
|
||||
: {};
|
||||
const _permissions = permissionNames.map((name) => new Permission(name));
|
||||
return new Guard(_permissions, Object.values(_roles), config);
|
||||
}
|
||||
|
||||
getPermissionNames(): string[] {
|
||||
return this.permissions.map((permission) => permission.name);
|
||||
}
|
||||
|
||||
getPermissions(): Permission[] {
|
||||
return this.permissions;
|
||||
}
|
||||
|
||||
permissionExists(permissionName: string): boolean {
|
||||
return !!this.permissions.find((p) => p.name === permissionName);
|
||||
}
|
||||
|
||||
setRoles(roles: Role[]) {
|
||||
this.roles = roles;
|
||||
return this;
|
||||
}
|
||||
|
||||
getRoles() {
|
||||
return this.roles;
|
||||
}
|
||||
|
||||
setConfig(config: Partial<GuardConfig>) {
|
||||
this.config = { ...this.config, ...config };
|
||||
return this;
|
||||
}
|
||||
|
||||
registerPermission(permission: Permission) {
|
||||
if (this.permissions.find((p) => p.name === permission.name)) {
|
||||
throw new Error(`Permission ${permission.name} already exists`);
|
||||
}
|
||||
|
||||
this.permissions.push(permission);
|
||||
return this;
|
||||
}
|
||||
|
||||
registerPermissions(permissions: Permission[]) {
|
||||
for (const permission of permissions) {
|
||||
this.registerPermission(permission);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
setUserContext(user: GuardUserContext | undefined) {
|
||||
this.user = user;
|
||||
return this;
|
||||
}
|
||||
|
||||
getUserRole(): Role | undefined {
|
||||
if (this.user && typeof this.user.role === "string") {
|
||||
const role = this.roles?.find((role) => role.name === this.user?.role);
|
||||
if (role) {
|
||||
console.log("guard: role found", this.user.role);
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("guard: role not found", this.user, this.user?.role);
|
||||
return this.getDefaultRole();
|
||||
}
|
||||
|
||||
getDefaultRole(): Role | undefined {
|
||||
return this.roles?.find((role) => role.is_default);
|
||||
}
|
||||
|
||||
hasPermission(permission: Permission): boolean;
|
||||
hasPermission(name: string): boolean;
|
||||
hasPermission(permissionOrName: Permission | string): boolean {
|
||||
if (this.config?.enabled !== true) {
|
||||
//console.log("guard not enabled, allowing");
|
||||
return true;
|
||||
}
|
||||
|
||||
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
|
||||
const exists = this.permissionExists(name);
|
||||
if (!exists) {
|
||||
throw new Error(`Permission ${name} does not exist`);
|
||||
}
|
||||
|
||||
const role = this.getUserRole();
|
||||
|
||||
if (!role) {
|
||||
console.log("guard: role not found, denying");
|
||||
return false;
|
||||
} else if (role.implicit_allow === true) {
|
||||
console.log("guard: role implicit allow, allowing");
|
||||
return true;
|
||||
}
|
||||
|
||||
const rolePermission = role.permissions.find(
|
||||
(rolePermission) => rolePermission.permission.name === name
|
||||
);
|
||||
|
||||
console.log("guard: rolePermission, allowing?", {
|
||||
permission: name,
|
||||
role: role.name,
|
||||
allowing: !!rolePermission
|
||||
});
|
||||
return !!rolePermission;
|
||||
}
|
||||
|
||||
granted(permission: Permission | string): boolean {
|
||||
return this.hasPermission(permission as any);
|
||||
}
|
||||
|
||||
throwUnlessGranted(permission: Permission | string) {
|
||||
if (!this.granted(permission)) {
|
||||
throw new Exception(
|
||||
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
||||
403
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
app/src/auth/authorize/Role.ts
Normal file
45
app/src/auth/authorize/Role.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Permission } from "core";
|
||||
|
||||
export class RolePermission {
|
||||
constructor(
|
||||
public permission: Permission,
|
||||
public config?: any
|
||||
) {}
|
||||
}
|
||||
|
||||
export class Role {
|
||||
constructor(
|
||||
public name: string,
|
||||
public permissions: RolePermission[] = [],
|
||||
public is_default: boolean = false,
|
||||
public implicit_allow: boolean = false
|
||||
) {}
|
||||
|
||||
static createWithPermissionNames(
|
||||
name: string,
|
||||
permissionNames: string[],
|
||||
is_default: boolean = false,
|
||||
implicit_allow: boolean = false
|
||||
) {
|
||||
return new Role(
|
||||
name,
|
||||
permissionNames.map((name) => new RolePermission(new Permission(name))),
|
||||
is_default,
|
||||
implicit_allow
|
||||
);
|
||||
}
|
||||
|
||||
static create(config: {
|
||||
name: string;
|
||||
permissions?: string[];
|
||||
is_default?: boolean;
|
||||
implicit_allow?: boolean;
|
||||
}) {
|
||||
return new Role(
|
||||
config.name,
|
||||
config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [],
|
||||
config.is_default,
|
||||
config.implicit_allow
|
||||
);
|
||||
}
|
||||
}
|
||||
28
app/src/auth/errors.ts
Normal file
28
app/src/auth/errors.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Exception } from "core";
|
||||
|
||||
export class UserExistsException extends Exception {
|
||||
override name = "UserExistsException";
|
||||
override code = 422;
|
||||
|
||||
constructor() {
|
||||
super("User already exists");
|
||||
}
|
||||
}
|
||||
|
||||
export class UserNotFoundException extends Exception {
|
||||
override name = "UserNotFoundException";
|
||||
override code = 404;
|
||||
|
||||
constructor() {
|
||||
super("User not found");
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidCredentialsException extends Exception {
|
||||
override name = "InvalidCredentialsException";
|
||||
override code = 401;
|
||||
|
||||
constructor() {
|
||||
super("Invalid credentials");
|
||||
}
|
||||
}
|
||||
21
app/src/auth/index.ts
Normal file
21
app/src/auth/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors";
|
||||
export { sha256 } from "./utils/hash";
|
||||
export {
|
||||
type ProfileExchange,
|
||||
type Strategy,
|
||||
type User,
|
||||
type SafeUser,
|
||||
type CreateUser,
|
||||
type AuthResponse,
|
||||
type UserPool,
|
||||
type AuthAction,
|
||||
type AuthUserResolver,
|
||||
Authenticator,
|
||||
authenticatorConfig,
|
||||
jwtConfig
|
||||
} from "./authenticate/Authenticator";
|
||||
|
||||
export { AppAuth, type UserFieldSchema } from "./AppAuth";
|
||||
|
||||
export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard";
|
||||
export { Role } from "./authorize/Role";
|
||||
13
app/src/auth/utils/hash.ts
Normal file
13
app/src/auth/utils/hash.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// @deprecated: moved to @bknd/core
|
||||
export async function sha256(password: string, salt?: string) {
|
||||
// 1. Convert password to Uint8Array
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode((salt ?? "") + password);
|
||||
|
||||
// 2. Hash the data using SHA-256
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
|
||||
// 3. Convert hash to hex string for easier display
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
12
app/src/cli/commands/config.ts
Normal file
12
app/src/cli/commands/config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDefaultConfig } from "modules/ModuleManager";
|
||||
import type { CliCommand } from "../types";
|
||||
|
||||
export const config: CliCommand = (program) => {
|
||||
program
|
||||
.command("config")
|
||||
.description("get default config")
|
||||
.option("--pretty", "pretty print")
|
||||
.action((options) => {
|
||||
console.log(getDefaultConfig(options.pretty));
|
||||
});
|
||||
};
|
||||
20
app/src/cli/commands/debug.ts
Normal file
20
app/src/cli/commands/debug.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import { getDistPath, getRelativeDistPath, getRootPath } from "cli/utils/sys";
|
||||
import type { CliCommand } from "../types";
|
||||
|
||||
export const debug: CliCommand = (program) => {
|
||||
program
|
||||
.command("debug")
|
||||
.description("debug path resolution")
|
||||
.action(() => {
|
||||
console.log("paths", {
|
||||
rootpath: getRootPath(),
|
||||
distPath: getDistPath(),
|
||||
relativeDistPath: getRelativeDistPath(),
|
||||
cwd: process.cwd(),
|
||||
dir: path.dirname(url.fileURLToPath(import.meta.url)),
|
||||
resolvedPkg: path.resolve(getRootPath(), "package.json")
|
||||
});
|
||||
});
|
||||
};
|
||||
5
app/src/cli/commands/index.ts
Normal file
5
app/src/cli/commands/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { config } from "./config";
|
||||
export { schema } from "./schema";
|
||||
export { run } from "./run";
|
||||
export { debug } from "./debug";
|
||||
export { user } from "./user";
|
||||
1
app/src/cli/commands/run/index.ts
Normal file
1
app/src/cli/commands/run/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./run";
|
||||
96
app/src/cli/commands/run/platform.ts
Normal file
96
app/src/cli/commands/run/platform.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ServeStaticOptions } from "@hono/node-server/serve-static";
|
||||
import { type Config, createClient } from "@libsql/client/node";
|
||||
import { Connection, LibsqlConnection, SqliteLocalConnection } from "data";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { fileExists, getDistPath, getRelativeDistPath } from "../../utils/sys";
|
||||
|
||||
export const PLATFORMS = ["node", "bun"] as const;
|
||||
export type Platform = (typeof PLATFORMS)[number];
|
||||
|
||||
export async function serveStatic(server: Platform): Promise<MiddlewareHandler> {
|
||||
switch (server) {
|
||||
case "node": {
|
||||
const m = await import("@hono/node-server/serve-static");
|
||||
return m.serveStatic({
|
||||
// somehow different for node
|
||||
root: getRelativeDistPath() + "/static"
|
||||
});
|
||||
}
|
||||
case "bun": {
|
||||
const m = await import("hono/bun");
|
||||
return m.serveStatic({
|
||||
root: path.resolve(getRelativeDistPath(), "static")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function attachServeStatic(app: any, platform: Platform) {
|
||||
app.module.server.client.get("/assets/*", await serveStatic(platform));
|
||||
}
|
||||
|
||||
export async function startServer(server: Platform, app: any, options: { port: number }) {
|
||||
const port = options.port;
|
||||
console.log("running on", server, port);
|
||||
switch (server) {
|
||||
case "node": {
|
||||
// https://github.com/honojs/node-server/blob/main/src/response.ts#L88
|
||||
const serve = await import("@hono/node-server").then((m) => m.serve);
|
||||
serve({
|
||||
fetch: (req) => app.fetch(req),
|
||||
port
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "bun": {
|
||||
Bun.serve({
|
||||
fetch: (req) => app.fetch(req),
|
||||
port
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Server listening on", "http://localhost:" + port);
|
||||
}
|
||||
|
||||
export async function getHtml() {
|
||||
return await readFile(path.resolve(getDistPath(), "static/index.html"), "utf-8");
|
||||
}
|
||||
|
||||
export function getConnection(connectionOrConfig?: Connection | Config): Connection {
|
||||
if (connectionOrConfig) {
|
||||
if (connectionOrConfig instanceof Connection) {
|
||||
return connectionOrConfig;
|
||||
}
|
||||
|
||||
if ("url" in connectionOrConfig) {
|
||||
return new LibsqlConnection(createClient(connectionOrConfig));
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Using in-memory database");
|
||||
return new LibsqlConnection(createClient({ url: ":memory:" }));
|
||||
//return new SqliteLocalConnection(new Database(":memory:"));
|
||||
}
|
||||
|
||||
export async function getConfigPath(filePath?: string) {
|
||||
if (filePath) {
|
||||
const config_path = path.resolve(process.cwd(), filePath);
|
||||
if (await fileExists(config_path)) {
|
||||
return config_path;
|
||||
}
|
||||
}
|
||||
|
||||
const paths = ["./bknd.config", "./bknd.config.ts", "./bknd.config.js"];
|
||||
for (const p of paths) {
|
||||
const _p = path.resolve(process.cwd(), p);
|
||||
if (await fileExists(_p)) {
|
||||
return _p;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
115
app/src/cli/commands/run/run.ts
Normal file
115
app/src/cli/commands/run/run.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Config } from "@libsql/client/node";
|
||||
import { App } from "App";
|
||||
import type { BkndConfig } from "adapter";
|
||||
import { Option } from "commander";
|
||||
import type { Connection } from "data";
|
||||
import type { CliCommand } from "../../types";
|
||||
import {
|
||||
PLATFORMS,
|
||||
type Platform,
|
||||
attachServeStatic,
|
||||
getConfigPath,
|
||||
getConnection,
|
||||
getHtml,
|
||||
startServer
|
||||
} from "./platform";
|
||||
|
||||
const isBun = typeof Bun !== "undefined";
|
||||
|
||||
export const run: CliCommand = (program) => {
|
||||
program
|
||||
.command("run")
|
||||
.addOption(
|
||||
new Option("-p, --port <port>", "port to run on")
|
||||
.env("PORT")
|
||||
.default(1337)
|
||||
.argParser((v) => Number.parseInt(v))
|
||||
)
|
||||
.addOption(new Option("-c, --config <config>", "config file"))
|
||||
.addOption(
|
||||
new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts(
|
||||
"config"
|
||||
)
|
||||
)
|
||||
.addOption(new Option("--db-token <db>", "database token").conflicts("config"))
|
||||
.addOption(
|
||||
new Option("--server <server>", "server type")
|
||||
.choices(PLATFORMS)
|
||||
.default(isBun ? "bun" : "node")
|
||||
)
|
||||
.action(action);
|
||||
};
|
||||
|
||||
type MakeAppConfig = {
|
||||
connection: Connection;
|
||||
server?: { platform?: Platform };
|
||||
setAdminHtml?: boolean;
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
};
|
||||
|
||||
async function makeApp(config: MakeAppConfig) {
|
||||
const html = await getHtml();
|
||||
const app = new App(config.connection);
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
async () => {
|
||||
await attachServeStatic(app, config.server?.platform ?? "node");
|
||||
app.module.server.setAdminHtml(html);
|
||||
|
||||
if (config.onBuilt) {
|
||||
await config.onBuilt(app);
|
||||
}
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function makeConfigApp(config: BkndConfig, platform?: Platform) {
|
||||
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
|
||||
const html = await getHtml();
|
||||
const app = App.create(appConfig);
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
async () => {
|
||||
await attachServeStatic(app, platform ?? "node");
|
||||
app.module.server.setAdminHtml(html);
|
||||
|
||||
if (config.onBuilt) {
|
||||
await config.onBuilt(app);
|
||||
}
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
return app;
|
||||
}
|
||||
|
||||
async function action(options: {
|
||||
port: number;
|
||||
config?: string;
|
||||
dbUrl?: string;
|
||||
dbToken?: string;
|
||||
server: Platform;
|
||||
}) {
|
||||
const configFilePath = await getConfigPath(options.config);
|
||||
|
||||
let app: App;
|
||||
if (options.dbUrl || !configFilePath) {
|
||||
const connection = getConnection(
|
||||
options.dbUrl ? { url: options.dbUrl, authToken: options.dbToken } : undefined
|
||||
);
|
||||
app = await makeApp({ connection, server: { platform: options.server } });
|
||||
} else {
|
||||
console.log("Using config from:", configFilePath);
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
|
||||
app = await makeConfigApp(config, options.server);
|
||||
}
|
||||
|
||||
await startServer(options.server, app, { port: options.port });
|
||||
}
|
||||
12
app/src/cli/commands/schema.ts
Normal file
12
app/src/cli/commands/schema.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDefaultSchema } from "modules/ModuleManager";
|
||||
import type { CliCommand } from "../types";
|
||||
|
||||
export const schema: CliCommand = (program) => {
|
||||
program
|
||||
.command("schema")
|
||||
.description("get schema")
|
||||
.option("--pretty", "pretty print")
|
||||
.action((options) => {
|
||||
console.log(getDefaultSchema(options.pretty));
|
||||
});
|
||||
};
|
||||
144
app/src/cli/commands/user.ts
Normal file
144
app/src/cli/commands/user.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { password as $password, text as $text } from "@clack/prompts";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import type { App, BkndConfig } from "bknd";
|
||||
import { makeConfigApp } from "cli/commands/run";
|
||||
import { getConfigPath } from "cli/commands/run/platform";
|
||||
import type { CliCommand } from "cli/types";
|
||||
import { Argument } from "commander";
|
||||
|
||||
export const user: CliCommand = (program) => {
|
||||
program
|
||||
.command("user")
|
||||
.description("create and update user (auth)")
|
||||
.addArgument(new Argument("<action>", "action to perform").choices(["create", "update"]))
|
||||
.action(action);
|
||||
};
|
||||
|
||||
async function action(action: "create" | "update", options: any) {
|
||||
const configFilePath = await getConfigPath();
|
||||
if (!configFilePath) {
|
||||
console.error("config file not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
|
||||
const app = await makeConfigApp(config, options.server);
|
||||
|
||||
switch (action) {
|
||||
case "create":
|
||||
await create(app, options);
|
||||
break;
|
||||
case "update":
|
||||
await update(app, options);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(app: App, options: any) {
|
||||
const config = app.module.auth.toJSON(true);
|
||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||
const users_entity = config.entity_name;
|
||||
|
||||
const email = await $text({
|
||||
message: "Enter email",
|
||||
validate: (v) => {
|
||||
if (!v.includes("@")) {
|
||||
return "Invalid email";
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const password = await $password({
|
||||
message: "Enter password",
|
||||
validate: (v) => {
|
||||
if (v.length < 3) {
|
||||
return "Invalid password";
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof email !== "string" || typeof password !== "string") {
|
||||
console.log("Cancelled");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const mutator = app.modules.ctx().em.mutator(users_entity);
|
||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||
const res = await mutator.insertOne({
|
||||
email,
|
||||
strategy: "password",
|
||||
strategy_value: await strategy.hash(password as string)
|
||||
});
|
||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||
|
||||
console.log("Created:", res.data);
|
||||
} catch (e) {
|
||||
console.error("Error");
|
||||
}
|
||||
}
|
||||
|
||||
async function update(app: App, options: any) {
|
||||
const config = app.module.auth.toJSON(true);
|
||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||
const users_entity = config.entity_name;
|
||||
const em = app.modules.ctx().em;
|
||||
|
||||
const email = (await $text({
|
||||
message: "Which user? Enter email",
|
||||
validate: (v) => {
|
||||
if (!v.includes("@")) {
|
||||
return "Invalid email";
|
||||
}
|
||||
return;
|
||||
}
|
||||
})) as string;
|
||||
if (typeof email !== "string") {
|
||||
console.log("Cancelled");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { data: user } = await em.repository(users_entity).findOne({ email });
|
||||
if (!user) {
|
||||
console.log("User not found");
|
||||
process.exit(0);
|
||||
}
|
||||
console.log("User found:", user);
|
||||
|
||||
const password = await $password({
|
||||
message: "New Password?",
|
||||
validate: (v) => {
|
||||
if (v.length < 3) {
|
||||
return "Invalid password";
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (typeof password !== "string") {
|
||||
console.log("Cancelled");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
function togglePw(visible: boolean) {
|
||||
const field = em.entity(users_entity).field("strategy_value")!;
|
||||
|
||||
field.config.hidden = !visible;
|
||||
field.config.fillable = visible;
|
||||
}
|
||||
togglePw(true);
|
||||
await app.modules
|
||||
.ctx()
|
||||
.em.mutator(users_entity)
|
||||
.updateOne(user.id, {
|
||||
strategy_value: await strategy.hash(password as string)
|
||||
});
|
||||
togglePw(false);
|
||||
|
||||
console.log("Updated:", user);
|
||||
} catch (e) {
|
||||
console.error("Error", e);
|
||||
}
|
||||
}
|
||||
22
app/src/cli/index.ts
Normal file
22
app/src/cli/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from "commander";
|
||||
import * as commands from "./commands";
|
||||
import { getVersion } from "./utils/sys";
|
||||
const program = new Command();
|
||||
|
||||
export async function main() {
|
||||
program
|
||||
.name("bknd")
|
||||
.description("bknd cli")
|
||||
.version(await getVersion());
|
||||
|
||||
// register commands
|
||||
for (const command of Object.values(commands)) {
|
||||
command(program);
|
||||
}
|
||||
|
||||
program.parse();
|
||||
}
|
||||
|
||||
main().then(null).catch(console.error);
|
||||
3
app/src/cli/types.d.ts
vendored
Normal file
3
app/src/cli/types.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
export type CliCommand = (program: Command) => void;
|
||||
40
app/src/cli/utils/sys.ts
Normal file
40
app/src/cli/utils/sys.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
export function getRootPath() {
|
||||
const _path = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
// because of "src", local needs one more level up
|
||||
return path.resolve(_path, process.env.LOCAL ? "../../../" : "../../");
|
||||
}
|
||||
|
||||
export function getDistPath() {
|
||||
return path.resolve(getRootPath(), "dist");
|
||||
}
|
||||
|
||||
export function getRelativeDistPath() {
|
||||
return path.relative(process.cwd(), getDistPath());
|
||||
}
|
||||
|
||||
export async function getVersion() {
|
||||
try {
|
||||
const resolved = path.resolve(getRootPath(), "package.json");
|
||||
const pkg = await readFile(resolved, "utf-8");
|
||||
if (pkg) {
|
||||
return JSON.parse(pkg).version ?? "preview";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to resolve version");
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export async function fileExists(filePath: string) {
|
||||
try {
|
||||
await readFile(path.resolve(process.cwd(), filePath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
127
app/src/core/cache/adapters/CloudflareKvCache.ts
vendored
Normal file
127
app/src/core/cache/adapters/CloudflareKvCache.ts
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { ICacheItem, ICachePool } from "../cache-interface";
|
||||
|
||||
export class CloudflareKVCachePool<Data = any> implements ICachePool<Data> {
|
||||
constructor(private namespace: KVNamespace) {}
|
||||
|
||||
supports = () => ({
|
||||
metadata: true,
|
||||
clear: false,
|
||||
});
|
||||
|
||||
async get(key: string): Promise<ICacheItem<Data>> {
|
||||
const result = await this.namespace.getWithMetadata<any>(key);
|
||||
const hit = result.value !== null && typeof result.value !== "undefined";
|
||||
// Assuming metadata is not supported directly;
|
||||
// you may adjust if Cloudflare KV supports it in future.
|
||||
return new CloudflareKVCacheItem(key, result.value ?? undefined, hit, result.metadata) as any;
|
||||
}
|
||||
|
||||
async getMany(keys: string[] = []): Promise<Map<string, ICacheItem<Data>>> {
|
||||
const items = new Map<string, ICacheItem<Data>>();
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const item = await this.get(key);
|
||||
items.set(key, item);
|
||||
}),
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
const data = await this.namespace.get(key);
|
||||
return data !== null;
|
||||
}
|
||||
|
||||
async clear(): Promise<boolean> {
|
||||
// Cloudflare KV does not support clearing all keys in one operation
|
||||
return false;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
await this.namespace.delete(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteMany(keys: string[]): Promise<boolean> {
|
||||
const results = await Promise.all(keys.map((key) => this.delete(key)));
|
||||
return results.every((result) => result);
|
||||
}
|
||||
|
||||
async save(item: CloudflareKVCacheItem<Data>): Promise<boolean> {
|
||||
await this.namespace.put(item.key(), (await item.value()) as string, {
|
||||
expirationTtl: item._expirationTtl,
|
||||
metadata: item.metadata(),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
value: any,
|
||||
options?: { ttl?: number; expiresAt?: Date; metadata?: Record<string, string> },
|
||||
): Promise<boolean> {
|
||||
const item = new CloudflareKVCacheItem(key, value, true, options?.metadata);
|
||||
|
||||
if (options?.expiresAt) item.expiresAt(options.expiresAt);
|
||||
if (options?.ttl) item.expiresAfter(options.ttl);
|
||||
|
||||
return await this.save(item);
|
||||
}
|
||||
}
|
||||
|
||||
export class CloudflareKVCacheItem<Data = any> implements ICacheItem<Data> {
|
||||
_expirationTtl: number | undefined;
|
||||
|
||||
constructor(
|
||||
private _key: string,
|
||||
private data: Data | undefined,
|
||||
private _hit: boolean = false,
|
||||
private _metadata: Record<string, string> = {},
|
||||
) {}
|
||||
|
||||
key(): string {
|
||||
return this._key;
|
||||
}
|
||||
|
||||
value(): Data | undefined {
|
||||
if (this.data) {
|
||||
try {
|
||||
return JSON.parse(this.data as string);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return this.data ?? undefined;
|
||||
}
|
||||
|
||||
metadata(): Record<string, string> {
|
||||
return this._metadata;
|
||||
}
|
||||
|
||||
hit(): boolean {
|
||||
return this._hit;
|
||||
}
|
||||
|
||||
set(value: Data, metadata: Record<string, string> = {}): this {
|
||||
this.data = value;
|
||||
this._metadata = metadata;
|
||||
return this;
|
||||
}
|
||||
|
||||
expiresAt(expiration: Date | null): this {
|
||||
// Cloudflare KV does not support specific date expiration; calculate ttl instead.
|
||||
if (expiration) {
|
||||
const now = new Date();
|
||||
const ttl = (expiration.getTime() - now.getTime()) / 1000;
|
||||
return this.expiresAfter(Math.max(0, Math.floor(ttl)));
|
||||
}
|
||||
return this.expiresAfter(null);
|
||||
}
|
||||
|
||||
expiresAfter(time: number | null): this {
|
||||
// Dummy implementation as Cloudflare KV requires setting expiration during PUT operation.
|
||||
// This method will be effectively implemented in the Cache Pool save methods.
|
||||
this._expirationTtl = time ?? undefined;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
139
app/src/core/cache/adapters/MemoryCache.ts
vendored
Normal file
139
app/src/core/cache/adapters/MemoryCache.ts
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { ICacheItem, ICachePool } from "../cache-interface";
|
||||
|
||||
export class MemoryCache<Data = any> implements ICachePool<Data> {
|
||||
private cache: Map<string, MemoryCacheItem<Data>> = new Map();
|
||||
private maxSize?: number;
|
||||
|
||||
constructor(options?: { maxSize?: number }) {
|
||||
this.maxSize = options?.maxSize;
|
||||
}
|
||||
|
||||
supports = () => ({
|
||||
metadata: true,
|
||||
clear: true
|
||||
});
|
||||
|
||||
async get(key: string): Promise<MemoryCacheItem<Data>> {
|
||||
if (!this.cache.has(key)) {
|
||||
// use undefined to denote a miss initially
|
||||
return new MemoryCacheItem<Data>(key, undefined!);
|
||||
}
|
||||
return this.cache.get(key)!;
|
||||
}
|
||||
|
||||
async getMany(keys: string[] = []): Promise<Map<string, MemoryCacheItem<Data>>> {
|
||||
const items = new Map<string, MemoryCacheItem<Data>>();
|
||||
for (const key of keys) {
|
||||
items.set(key, await this.get(key));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return this.cache.has(key) && this.cache.get(key)!.hit();
|
||||
}
|
||||
|
||||
async clear(): Promise<boolean> {
|
||||
this.cache.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
|
||||
async deleteMany(keys: string[]): Promise<boolean> {
|
||||
let success = true;
|
||||
for (const key of keys) {
|
||||
if (!this.delete(key)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
async save(item: MemoryCacheItem<Data>): Promise<boolean> {
|
||||
this.checkSizeAndPurge();
|
||||
this.cache.set(item.key(), item);
|
||||
return true;
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
value: Data,
|
||||
options: { expiresAt?: Date; ttl?: number; metadata?: Record<string, string> } = {}
|
||||
): Promise<boolean> {
|
||||
const item = await this.get(key);
|
||||
item.set(value, options.metadata || {});
|
||||
if (options.expiresAt) {
|
||||
item.expiresAt(options.expiresAt);
|
||||
} else if (typeof options.ttl === "number") {
|
||||
item.expiresAfter(options.ttl);
|
||||
}
|
||||
return this.save(item);
|
||||
}
|
||||
|
||||
private checkSizeAndPurge(): void {
|
||||
if (!this.maxSize) return;
|
||||
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
// Implement logic to purge items, e.g., LRU (Least Recently Used)
|
||||
// For simplicity, clear the oldest item inserted
|
||||
const keyToDelete = this.cache.keys().next().value;
|
||||
this.cache.delete(keyToDelete!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MemoryCacheItem<Data = any> implements ICacheItem<Data> {
|
||||
private _key: string;
|
||||
private _value: Data | undefined;
|
||||
private expiration: Date | null = null;
|
||||
private _metadata: Record<string, string> = {};
|
||||
|
||||
constructor(key: string, value: Data, metadata: Record<string, string> = {}) {
|
||||
this._key = key;
|
||||
this.set(value, metadata);
|
||||
}
|
||||
|
||||
key(): string {
|
||||
return this._key;
|
||||
}
|
||||
|
||||
metadata(): Record<string, string> {
|
||||
return this._metadata;
|
||||
}
|
||||
|
||||
value(): Data | undefined {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
hit(): boolean {
|
||||
if (this.expiration !== null && new Date() > this.expiration) {
|
||||
return false;
|
||||
}
|
||||
return this.value() !== undefined;
|
||||
}
|
||||
|
||||
set(value: Data, metadata: Record<string, string> = {}): this {
|
||||
this._value = value;
|
||||
this._metadata = metadata;
|
||||
return this;
|
||||
}
|
||||
|
||||
expiresAt(expiration: Date | null): this {
|
||||
this.expiration = expiration;
|
||||
return this;
|
||||
}
|
||||
|
||||
expiresAfter(time: number | null): this {
|
||||
if (typeof time === "number") {
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setSeconds(expirationDate.getSeconds() + time);
|
||||
this.expiration = expirationDate;
|
||||
} else {
|
||||
this.expiration = null;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
178
app/src/core/cache/cache-interface.ts
vendored
Normal file
178
app/src/core/cache/cache-interface.ts
vendored
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* CacheItem defines an interface for interacting with objects inside a cache.
|
||||
* based on https://www.php-fig.org/psr/psr-6/
|
||||
*/
|
||||
export interface ICacheItem<Data = any> {
|
||||
/**
|
||||
* Returns the key for the current cache item.
|
||||
*
|
||||
* The key is loaded by the Implementing Library, but should be available to
|
||||
* the higher level callers when needed.
|
||||
*
|
||||
* @returns The key string for this cache item.
|
||||
*/
|
||||
key(): string;
|
||||
|
||||
/**
|
||||
* Retrieves the value of the item from the cache associated with this object's key.
|
||||
*
|
||||
* The value returned must be identical to the value originally stored by set().
|
||||
*
|
||||
* If isHit() returns false, this method MUST return null. Note that null
|
||||
* is a legitimate cached value, so the isHit() method SHOULD be used to
|
||||
* differentiate between "null value was found" and "no value was found."
|
||||
*
|
||||
* @returns The value corresponding to this cache item's key, or undefined if not found.
|
||||
*/
|
||||
value(): Data | undefined;
|
||||
|
||||
/**
|
||||
* Retrieves the metadata of the item from the cache associated with this object's key.
|
||||
*/
|
||||
metadata(): Record<string, string>;
|
||||
|
||||
/**
|
||||
* Confirms if the cache item lookup resulted in a cache hit.
|
||||
*
|
||||
* Note: This method MUST NOT have a race condition between calling isHit()
|
||||
* and calling get().
|
||||
*
|
||||
* @returns True if the request resulted in a cache hit. False otherwise.
|
||||
*/
|
||||
hit(): boolean;
|
||||
|
||||
/**
|
||||
* Sets the value represented by this cache item.
|
||||
*
|
||||
* The value argument may be any item that can be serialized by PHP,
|
||||
* although the method of serialization is left up to the Implementing
|
||||
* Library.
|
||||
*
|
||||
* @param value The serializable value to be stored.
|
||||
* @param metadata The metadata to be associated with the item.
|
||||
* @returns The invoked object.
|
||||
*/
|
||||
set(value: Data, metadata?: Record<string, string>): this;
|
||||
|
||||
/**
|
||||
* Sets the expiration time for this cache item.
|
||||
*
|
||||
* @param expiration The point in time after which the item MUST be considered expired.
|
||||
* If null is passed explicitly, a default value MAY be used. If none is set,
|
||||
* the value should be stored permanently or for as long as the
|
||||
* implementation allows.
|
||||
* @returns The called object.
|
||||
*/
|
||||
expiresAt(expiration: Date | null): this;
|
||||
|
||||
/**
|
||||
* Sets the expiration time for this cache item.
|
||||
*
|
||||
* @param time The period of time from the present after which the item MUST be considered
|
||||
* expired. An integer parameter is understood to be the time in seconds until
|
||||
* expiration. If null is passed explicitly, a default value MAY be used.
|
||||
* If none is set, the value should be stored permanently or for as long as the
|
||||
* implementation allows.
|
||||
* @returns The called object.
|
||||
*/
|
||||
expiresAfter(time: number | null): this;
|
||||
}
|
||||
|
||||
/**
|
||||
* CachePool generates CacheItem objects.
|
||||
* based on https://www.php-fig.org/psr/psr-6/
|
||||
*/
|
||||
export interface ICachePool<Data = any> {
|
||||
supports(): {
|
||||
metadata: boolean;
|
||||
clear: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a Cache Item representing the specified key.
|
||||
* This method must always return a CacheItemInterface object, even in case of
|
||||
* a cache miss. It MUST NOT return null.
|
||||
*
|
||||
* @param key The key for which to return the corresponding Cache Item.
|
||||
* @throws Error If the key string is not a legal value an Error MUST be thrown.
|
||||
* @returns The corresponding Cache Item.
|
||||
*/
|
||||
get(key: string): Promise<ICacheItem<Data>>;
|
||||
|
||||
/**
|
||||
* Returns a traversable set of cache items.
|
||||
*
|
||||
* @param keys An indexed array of keys of items to retrieve.
|
||||
* @throws Error If any of the keys in keys are not a legal value an Error MUST be thrown.
|
||||
* @returns A traversable collection of Cache Items keyed by the cache keys of
|
||||
* each item. A Cache item will be returned for each key, even if that
|
||||
* key is not found. However, if no keys are specified then an empty
|
||||
* traversable MUST be returned instead.
|
||||
*/
|
||||
getMany(keys?: string[]): Promise<Map<string, ICacheItem<Data>>>;
|
||||
|
||||
/**
|
||||
* Confirms if the cache contains specified cache item.
|
||||
*
|
||||
* Note: This method MAY avoid retrieving the cached value for performance reasons.
|
||||
* This could result in a race condition with CacheItemInterface.get(). To avoid
|
||||
* such situation use CacheItemInterface.isHit() instead.
|
||||
*
|
||||
* @param key The key for which to check existence.
|
||||
* @throws Error If the key string is not a legal value an Error MUST be thrown.
|
||||
* @returns True if item exists in the cache, false otherwise.
|
||||
*/
|
||||
has(key: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Deletes all items in the pool.
|
||||
* @returns True if the pool was successfully cleared. False if there was an error.
|
||||
*/
|
||||
clear(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Removes the item from the pool.
|
||||
*
|
||||
* @param key The key to delete.
|
||||
* @throws Error If the key string is not a legal value an Error MUST be thrown.
|
||||
* @returns True if the item was successfully removed. False if there was an error.
|
||||
*/
|
||||
delete(key: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Removes multiple items from the pool.
|
||||
*
|
||||
* @param keys An array of keys that should be removed from the pool.
|
||||
* @throws Error If any of the keys in keys are not a legal value an Error MUST be thrown.
|
||||
* @returns True if the items were successfully removed. False if there was an error.
|
||||
*/
|
||||
deleteMany(keys: string[]): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Persists a cache item immediately.
|
||||
*
|
||||
* @param item The cache item to save.
|
||||
* @returns True if the item was successfully persisted. False if there was an error.
|
||||
*/
|
||||
save(item: ICacheItem<Data>): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Persists any deferred cache items.
|
||||
* @returns True if all not-yet-saved items were successfully saved or there were none. False otherwise.
|
||||
*/
|
||||
put(
|
||||
key: string,
|
||||
value: any,
|
||||
options?: { expiresAt?: Date; metadata?: Record<string, string> },
|
||||
): Promise<boolean>;
|
||||
put(
|
||||
key: string,
|
||||
value: any,
|
||||
options?: { ttl?: number; metadata?: Record<string, string> },
|
||||
): Promise<boolean>;
|
||||
put(
|
||||
key: string,
|
||||
value: any,
|
||||
options?: ({ ttl?: number } | { expiresAt?: Date }) & { metadata?: Record<string, string> },
|
||||
): Promise<boolean>;
|
||||
}
|
||||
96
app/src/core/clients/aws/AwsClient.ts
Normal file
96
app/src/core/clients/aws/AwsClient.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { AwsClient as Aws4fetchClient } from "aws4fetch";
|
||||
import { objectKeysPascalToKebab } from "../../utils/objects";
|
||||
import { xmlToObject } from "../../utils/xml";
|
||||
|
||||
type Aws4fetchClientConfig = ConstructorParameters<typeof Aws4fetchClient>[0];
|
||||
type AwsClientConfig = {
|
||||
responseType?: "xml" | "json";
|
||||
responseKeysToUpper?: boolean;
|
||||
convertParams?: "pascalToKebab";
|
||||
};
|
||||
|
||||
export class AwsClient extends Aws4fetchClient {
|
||||
readonly #options: AwsClientConfig;
|
||||
|
||||
constructor(aws4fetchConfig: Aws4fetchClientConfig, options?: AwsClientConfig) {
|
||||
super(aws4fetchConfig);
|
||||
this.#options = options ?? {
|
||||
responseType: "json",
|
||||
};
|
||||
}
|
||||
|
||||
protected convertParams(params: Record<string, any>): Record<string, any> {
|
||||
switch (this.#options.convertParams) {
|
||||
case "pascalToKebab":
|
||||
return objectKeysPascalToKebab(params);
|
||||
default:
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
getUrl(path: string = "/", searchParamsObj: Record<string, any> = {}): string {
|
||||
//console.log("super:getUrl", path, searchParamsObj);
|
||||
const url = new URL(path);
|
||||
const converted = this.convertParams(searchParamsObj);
|
||||
Object.entries(converted).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value as any);
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
protected updateKeysRecursively(obj: any, direction: "toUpperCase" | "toLowerCase") {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => this.updateKeysRecursively(item, direction));
|
||||
}
|
||||
|
||||
if (typeof obj === "object") {
|
||||
return Object.keys(obj).reduce(
|
||||
(acc, key) => {
|
||||
// only if key doesn't have any whitespaces
|
||||
let newKey = key;
|
||||
if (key.indexOf(" ") === -1) {
|
||||
newKey = key.charAt(0)[direction]() + key.slice(1);
|
||||
}
|
||||
acc[newKey] = this.updateKeysRecursively(obj[key], direction);
|
||||
return acc;
|
||||
},
|
||||
{} as { [key: string]: any },
|
||||
);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
async fetchJson<T extends Record<string, any>>(
|
||||
input: RequestInfo,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await this.fetch(input, init);
|
||||
|
||||
if (this.#options.responseType === "xml") {
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(body);
|
||||
}
|
||||
|
||||
const raw = await response.text();
|
||||
//console.log("raw", raw);
|
||||
//console.log(JSON.stringify(xmlToObject(raw), null, 2));
|
||||
return xmlToObject(raw) as T;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json<{ message: string }>();
|
||||
throw new Error(body.message);
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as T;
|
||||
if (this.#options.responseKeysToUpper) {
|
||||
return this.updateKeysRecursively(raw, "toUpperCase");
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
12
app/src/core/config.ts
Normal file
12
app/src/core/config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* These are package global defaults.
|
||||
*/
|
||||
import type { Generated } from "kysely";
|
||||
|
||||
export type PrimaryFieldType = number | Generated<number>;
|
||||
|
||||
export const config = {
|
||||
data: {
|
||||
default_primary_field: "id"
|
||||
}
|
||||
} as const;
|
||||
27
app/src/core/env.ts
Normal file
27
app/src/core/env.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
type TURSO_DB = {
|
||||
url: string;
|
||||
authToken: string;
|
||||
};
|
||||
|
||||
export type Env = {
|
||||
__STATIC_CONTENT: Fetcher;
|
||||
ENVIRONMENT: string;
|
||||
CACHE: KVNamespace;
|
||||
|
||||
// db
|
||||
DB_DATA: TURSO_DB;
|
||||
DB_SCHEMA: TURSO_DB;
|
||||
|
||||
// storage
|
||||
STORAGE: { access_key: string; secret_access_key: string; url: string };
|
||||
BUCKET: R2Bucket;
|
||||
};
|
||||
|
||||
export function isDebug(): boolean {
|
||||
try {
|
||||
// @ts-expect-error - this is a global variable in dev
|
||||
return __isDev === "1" || __isDev === 1;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
37
app/src/core/errors.ts
Normal file
37
app/src/core/errors.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export class Exception extends Error {
|
||||
code = 400;
|
||||
override name = "Exception";
|
||||
|
||||
constructor(message: string, code?: number) {
|
||||
super(message);
|
||||
if (code) {
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.message,
|
||||
type: this.name
|
||||
//message: this.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BkndError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public details?: Record<string, any>,
|
||||
public type?: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type ?? "unknown",
|
||||
message: this.message,
|
||||
details: this.details
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/src/core/events/Event.ts
Normal file
21
app/src/core/events/Event.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export abstract class Event<Params = any> {
|
||||
/**
|
||||
* Unique event slug
|
||||
* Must be static, because registering events is done by class
|
||||
*/
|
||||
static slug: string = "untitled-event";
|
||||
params: Params;
|
||||
|
||||
constructor(params: Params) {
|
||||
this.params = params;
|
||||
}
|
||||
}
|
||||
|
||||
// @todo: current workaround: potentially there is none and that's the way
|
||||
export class NoParamEvent extends Event<null> {
|
||||
static override slug: string = "noparam-event";
|
||||
|
||||
constructor() {
|
||||
super(null);
|
||||
}
|
||||
}
|
||||
22
app/src/core/events/EventListener.ts
Normal file
22
app/src/core/events/EventListener.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Event } from "./Event";
|
||||
import type { EventClass } from "./EventManager";
|
||||
|
||||
export const ListenerModes = ["sync", "async"] as const;
|
||||
export type ListenerMode = (typeof ListenerModes)[number];
|
||||
|
||||
export type ListenerHandler<E extends Event = Event> = (
|
||||
event: E,
|
||||
slug: string,
|
||||
) => Promise<void> | void;
|
||||
|
||||
export class EventListener<E extends Event = Event> {
|
||||
mode: ListenerMode = "async";
|
||||
event: EventClass;
|
||||
handler: ListenerHandler<E>;
|
||||
|
||||
constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") {
|
||||
this.event = event;
|
||||
this.handler = handler;
|
||||
this.mode = mode;
|
||||
}
|
||||
}
|
||||
151
app/src/core/events/EventManager.ts
Normal file
151
app/src/core/events/EventManager.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { Event } from "./Event";
|
||||
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
|
||||
|
||||
export interface EmitsEvents {
|
||||
emgr: EventManager;
|
||||
}
|
||||
|
||||
export type EventClass = {
|
||||
new (params: any): Event;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export class EventManager<
|
||||
RegisteredEvents extends Record<string, EventClass> = Record<string, EventClass>
|
||||
> {
|
||||
protected events: EventClass[] = [];
|
||||
protected listeners: EventListener[] = [];
|
||||
|
||||
constructor(events?: RegisteredEvents, listeners?: EventListener[]) {
|
||||
if (events) {
|
||||
this.registerEvents(events);
|
||||
}
|
||||
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
this.addListener(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearEvents() {
|
||||
this.events = [];
|
||||
return this;
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.clearEvents();
|
||||
this.listeners = [];
|
||||
return this;
|
||||
}
|
||||
|
||||
get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } {
|
||||
// proxy class to access events
|
||||
return new Proxy(this, {
|
||||
get: (_, prop: string) => {
|
||||
return this.events.find((e) => e.slug === prop);
|
||||
}
|
||||
}) as any;
|
||||
}
|
||||
|
||||
eventExists(slug: string): boolean;
|
||||
eventExists(event: EventClass | Event): boolean;
|
||||
eventExists(eventOrSlug: EventClass | Event | string): boolean {
|
||||
let slug: string;
|
||||
|
||||
if (typeof eventOrSlug === "string") {
|
||||
slug = eventOrSlug;
|
||||
} else {
|
||||
// @ts-expect-error
|
||||
slug = eventOrSlug.constructor?.slug ?? eventOrSlug.slug;
|
||||
/*eventOrSlug instanceof Event
|
||||
? // @ts-expect-error slug is static
|
||||
eventOrSlug.constructor.slug
|
||||
: eventOrSlug.slug;*/
|
||||
}
|
||||
|
||||
return !!this.events.find((e) => slug === e.slug);
|
||||
}
|
||||
|
||||
protected throwIfEventNotRegistered(event: EventClass) {
|
||||
if (!this.eventExists(event)) {
|
||||
throw new Error(`Event "${event.slug}" not registered`);
|
||||
}
|
||||
}
|
||||
|
||||
registerEvent(event: EventClass, silent: boolean = false) {
|
||||
if (this.eventExists(event)) {
|
||||
if (silent) {
|
||||
return this;
|
||||
}
|
||||
|
||||
throw new Error(`Event "${event.name}" already registered.`);
|
||||
}
|
||||
|
||||
this.events.push(event);
|
||||
return this;
|
||||
}
|
||||
|
||||
registerEvents(eventObjects: Record<string, EventClass>): this;
|
||||
registerEvents(eventArray: EventClass[]): this;
|
||||
registerEvents(objectOrArray: Record<string, EventClass> | EventClass[]): this {
|
||||
const events =
|
||||
typeof objectOrArray === "object" ? Object.values(objectOrArray) : objectOrArray;
|
||||
events.forEach((event) => this.registerEvent(event, true));
|
||||
return this;
|
||||
}
|
||||
|
||||
addListener(listener: EventListener) {
|
||||
this.throwIfEventNotRegistered(listener.event);
|
||||
|
||||
this.listeners.push(listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
onEvent<ActualEvent extends EventClass, Instance extends InstanceType<ActualEvent>>(
|
||||
event: ActualEvent,
|
||||
handler: ListenerHandler<Instance>,
|
||||
mode: ListenerMode = "async"
|
||||
) {
|
||||
this.throwIfEventNotRegistered(event);
|
||||
|
||||
const listener = new EventListener(event, handler, mode);
|
||||
this.addListener(listener as any);
|
||||
}
|
||||
|
||||
on<Params = any>(
|
||||
slug: string,
|
||||
handler: ListenerHandler<Event<Params>>,
|
||||
mode: ListenerMode = "async"
|
||||
) {
|
||||
const event = this.events.find((e) => e.slug === slug);
|
||||
if (!event) {
|
||||
throw new Error(`Event "${slug}" not registered`);
|
||||
}
|
||||
|
||||
this.onEvent(event, handler, mode);
|
||||
}
|
||||
|
||||
onAny(handler: ListenerHandler<Event<unknown>>, mode: ListenerMode = "async") {
|
||||
this.events.forEach((event) => this.onEvent(event, handler, mode));
|
||||
}
|
||||
|
||||
async emit(event: Event) {
|
||||
// @ts-expect-error slug is static
|
||||
const slug = event.constructor.slug;
|
||||
if (!this.eventExists(event)) {
|
||||
throw new Error(`Event "${slug}" not registered`);
|
||||
}
|
||||
|
||||
const listeners = this.listeners.filter((listener) => listener.event.slug === slug);
|
||||
//console.log("---!-- emitting", slug, listeners.length);
|
||||
|
||||
for (const listener of listeners) {
|
||||
if (listener.mode === "sync") {
|
||||
await listener.handler(event, listener.event.slug);
|
||||
} else {
|
||||
listener.handler(event, listener.event.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
app/src/core/events/index.ts
Normal file
8
app/src/core/events/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { Event, NoParamEvent } from "./Event";
|
||||
export {
|
||||
EventListener,
|
||||
ListenerModes,
|
||||
type ListenerMode,
|
||||
type ListenerHandler,
|
||||
} from "./EventListener";
|
||||
export { EventManager, type EmitsEvents, type EventClass } from "./EventManager";
|
||||
28
app/src/core/index.ts
Normal file
28
app/src/core/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export { Endpoint, type RequestResponse, type Middleware } from "./server/Endpoint";
|
||||
export { zValidator } from "./server/lib/zValidator";
|
||||
export { tbValidator } from "./server/lib/tbValidator";
|
||||
export { Exception, BkndError } from "./errors";
|
||||
export { isDebug } from "./env";
|
||||
export { type PrimaryFieldType, config } from "./config";
|
||||
export { AwsClient } from "./clients/aws/AwsClient";
|
||||
export {
|
||||
SimpleRenderer,
|
||||
type TemplateObject,
|
||||
type TemplateTypes,
|
||||
type SimpleRendererOptions
|
||||
} from "./template/SimpleRenderer";
|
||||
export { Controller, type ClassController } from "./server/Controller";
|
||||
export { SchemaObject } from "./object/SchemaObject";
|
||||
export { DebugLogger } from "./utils/DebugLogger";
|
||||
export { Permission } from "./security/Permission";
|
||||
export {
|
||||
exp,
|
||||
makeValidator,
|
||||
type FilterQuery,
|
||||
type Primitive,
|
||||
isPrimitive,
|
||||
type TExpression,
|
||||
type BooleanLike,
|
||||
isBooleanLike
|
||||
} from "./object/query/query";
|
||||
export { Registry, type Constructor } from "./registry/Registry";
|
||||
199
app/src/core/object/SchemaObject.ts
Normal file
199
app/src/core/object/SchemaObject.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { cloneDeep, get, has, mergeWith, omit, set } from "lodash-es";
|
||||
import {
|
||||
Default,
|
||||
type Static,
|
||||
type TObject,
|
||||
getFullPathKeys,
|
||||
mark,
|
||||
parse,
|
||||
stripMark
|
||||
} from "../utils";
|
||||
|
||||
export type SchemaObjectOptions<Schema extends TObject> = {
|
||||
onUpdate?: (config: Static<Schema>) => void | Promise<void>;
|
||||
restrictPaths?: string[];
|
||||
overwritePaths?: (RegExp | string)[];
|
||||
forceParse?: boolean;
|
||||
};
|
||||
|
||||
export class SchemaObject<Schema extends TObject> {
|
||||
private readonly _default: Partial<Static<Schema>>;
|
||||
private _value: Static<Schema>;
|
||||
private _config: Static<Schema>;
|
||||
private _restriction_bypass: boolean = false;
|
||||
|
||||
constructor(
|
||||
private _schema: Schema,
|
||||
initial?: Partial<Static<Schema>>,
|
||||
private options?: SchemaObjectOptions<Schema>
|
||||
) {
|
||||
this._default = Default(_schema, {} as any) as any;
|
||||
this._value = initial
|
||||
? parse(_schema, cloneDeep(initial as any), {
|
||||
forceParse: this.isForceParse(),
|
||||
skipMark: this.isForceParse()
|
||||
})
|
||||
: this._default;
|
||||
this._config = Object.freeze(this._value);
|
||||
}
|
||||
|
||||
protected isForceParse(): boolean {
|
||||
return this.options?.forceParse ?? true;
|
||||
}
|
||||
|
||||
default(): Static<Schema> {
|
||||
return this._default;
|
||||
}
|
||||
|
||||
get(options?: { stripMark?: boolean }): Static<Schema> {
|
||||
if (options?.stripMark) {
|
||||
return stripMark(this._config);
|
||||
}
|
||||
|
||||
return this._config;
|
||||
}
|
||||
|
||||
async set(config: Static<Schema>, noEmit?: boolean): Promise<Static<Schema>> {
|
||||
const valid = parse(this._schema, cloneDeep(config) as any, {
|
||||
forceParse: true,
|
||||
skipMark: this.isForceParse()
|
||||
});
|
||||
this._value = valid;
|
||||
this._config = Object.freeze(valid);
|
||||
|
||||
if (noEmit !== true) {
|
||||
await this.options?.onUpdate?.(this._config);
|
||||
}
|
||||
|
||||
return this._config;
|
||||
}
|
||||
|
||||
bypass() {
|
||||
this._restriction_bypass = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
throwIfRestricted(object: object): void;
|
||||
throwIfRestricted(path: string): void;
|
||||
throwIfRestricted(pathOrObject: string | object): void {
|
||||
// only bypass once
|
||||
if (this._restriction_bypass) {
|
||||
this._restriction_bypass = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = this.options?.restrictPaths ?? [];
|
||||
if (Array.isArray(paths) && paths.length > 0) {
|
||||
for (const path of paths) {
|
||||
const restricted =
|
||||
typeof pathOrObject === "string"
|
||||
? pathOrObject.startsWith(path)
|
||||
: has(pathOrObject, path);
|
||||
|
||||
if (restricted) {
|
||||
throw new Error(`Path "${path}" is restricted`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async patch(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
||||
const current = cloneDeep(this._config);
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
|
||||
|
||||
this.throwIfRestricted(partial);
|
||||
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
|
||||
|
||||
// overwrite arrays and primitives, only deep merge objects
|
||||
// @ts-ignore
|
||||
const config = mergeWith(current, partial, (objValue, srcValue) => {
|
||||
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
|
||||
return srcValue;
|
||||
}
|
||||
});
|
||||
|
||||
//console.log("overwritePaths", this.options?.overwritePaths);
|
||||
if (this.options?.overwritePaths) {
|
||||
const keys = getFullPathKeys(value).map((k) => path + "." + k);
|
||||
const overwritePaths = keys.filter((k) => {
|
||||
return this.options?.overwritePaths?.some((p) => {
|
||||
if (typeof p === "string") {
|
||||
return k === p;
|
||||
} else {
|
||||
return p.test(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
//console.log("overwritePaths", keys, overwritePaths);
|
||||
|
||||
if (overwritePaths.length > 0) {
|
||||
// filter out less specific paths (but only if more than 1)
|
||||
const specific =
|
||||
overwritePaths.length > 1
|
||||
? overwritePaths.filter((k) =>
|
||||
overwritePaths.some((k2) => {
|
||||
console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k));
|
||||
return k2 !== k && k2.startsWith(k);
|
||||
})
|
||||
)
|
||||
: overwritePaths;
|
||||
//console.log("specific", specific);
|
||||
|
||||
for (const p of specific) {
|
||||
set(config, p, get(partial, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//console.log("patch", { path, value, partial, config, current });
|
||||
|
||||
const newConfig = await this.set(config);
|
||||
return [partial, newConfig];
|
||||
}
|
||||
|
||||
async overwrite(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
||||
const current = cloneDeep(this._config);
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
|
||||
|
||||
this.throwIfRestricted(partial);
|
||||
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
|
||||
|
||||
// overwrite arrays and primitives, only deep merge objects
|
||||
// @ts-ignore
|
||||
const config = set(current, path, value);
|
||||
|
||||
//console.log("overwrite", { path, value, partial, config, current });
|
||||
|
||||
const newConfig = await this.set(config);
|
||||
return [partial, newConfig];
|
||||
}
|
||||
|
||||
has(path: string): boolean {
|
||||
const p = path.split(".");
|
||||
if (p.length > 1) {
|
||||
const parent = p.slice(0, -1).join(".");
|
||||
if (!has(this._config, parent)) {
|
||||
console.log("parent", parent, JSON.stringify(this._config, null, 2));
|
||||
throw new Error(`Parent path "${parent}" does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
return has(this._config, path);
|
||||
}
|
||||
|
||||
async remove(path: string): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
||||
this.throwIfRestricted(path);
|
||||
|
||||
if (!this.has(path)) {
|
||||
throw new Error(`Path "${path}" does not exist`);
|
||||
}
|
||||
|
||||
const current = cloneDeep(this._config);
|
||||
const removed = get(current, path) as Partial<Static<Schema>>;
|
||||
const config = omit(current, path);
|
||||
const newConfig = await this.set(config);
|
||||
return [removed, newConfig];
|
||||
}
|
||||
}
|
||||
96
app/src/core/object/query/object-query.ts
Normal file
96
app/src/core/object/query/object-query.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { type FilterQuery, type Primitive, exp, isPrimitive, makeValidator } from "./query";
|
||||
|
||||
const expressions = [
|
||||
exp(
|
||||
"$eq",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(e, a) => e === a
|
||||
),
|
||||
exp(
|
||||
"$ne",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(e, a) => e !== a
|
||||
),
|
||||
exp(
|
||||
"$like",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(e, a) => {
|
||||
switch (typeof a) {
|
||||
case "string":
|
||||
return (a as string).includes(e as string);
|
||||
case "number":
|
||||
return (a as number) === Number(e);
|
||||
case "boolean":
|
||||
return (a as boolean) === Boolean(e);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
),
|
||||
exp(
|
||||
"$regex",
|
||||
(v: RegExp | string) => (v instanceof RegExp ? true : typeof v === "string"),
|
||||
(e: any, a: any) => {
|
||||
if (e instanceof RegExp) {
|
||||
return e.test(a);
|
||||
}
|
||||
if (typeof e === "string") {
|
||||
const regex = new RegExp(e);
|
||||
return regex.test(a);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
),
|
||||
exp(
|
||||
"$isnull",
|
||||
(v: boolean | 1 | 0) => true,
|
||||
(e, a) => (e ? a === null : a !== null)
|
||||
),
|
||||
exp(
|
||||
"$notnull",
|
||||
(v: boolean | 1 | 0) => true,
|
||||
(e, a) => (e ? a !== null : a === null)
|
||||
),
|
||||
exp(
|
||||
"$in",
|
||||
(v: (string | number)[]) => Array.isArray(v),
|
||||
(e: any, a: any) => e.includes(a)
|
||||
),
|
||||
exp(
|
||||
"$notin",
|
||||
(v: (string | number)[]) => Array.isArray(v),
|
||||
(e: any, a: any) => !e.includes(a)
|
||||
),
|
||||
exp(
|
||||
"$gt",
|
||||
(v: number) => typeof v === "number",
|
||||
(e: any, a: any) => a > e
|
||||
),
|
||||
exp(
|
||||
"$gte",
|
||||
(v: number) => typeof v === "number",
|
||||
(e: any, a: any) => a >= e
|
||||
),
|
||||
exp(
|
||||
"$lt",
|
||||
(v: number) => typeof v === "number",
|
||||
(e: any, a: any) => a < e
|
||||
),
|
||||
exp(
|
||||
"$lte",
|
||||
(v: number) => typeof v === "number",
|
||||
(e: any, a: any) => a <= e
|
||||
),
|
||||
exp(
|
||||
"$between",
|
||||
(v: [number, number]) =>
|
||||
Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === "number"),
|
||||
(e: any, a: any) => e[0] <= a && a <= e[1]
|
||||
)
|
||||
];
|
||||
|
||||
export type ObjectQuery = FilterQuery<typeof expressions>;
|
||||
const validator = makeValidator(expressions);
|
||||
export const convert = (query: ObjectQuery) => validator.convert(query);
|
||||
export const validate = (query: ObjectQuery, object: Record<string, any>) =>
|
||||
validator.validate(query, { object, convert: true });
|
||||
209
app/src/core/object/query/query.ts
Normal file
209
app/src/core/object/query/query.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
export type Primitive = string | number | boolean;
|
||||
export function isPrimitive(value: any): value is Primitive {
|
||||
return ["string", "number", "boolean"].includes(typeof value);
|
||||
}
|
||||
export type BooleanLike = boolean | 0 | 1;
|
||||
export function isBooleanLike(value: any): value is boolean {
|
||||
return [true, false, 0, 1].includes(value);
|
||||
}
|
||||
|
||||
export class Expression<Key, Expect = unknown, CTX = any> {
|
||||
expect!: Expect;
|
||||
|
||||
constructor(
|
||||
public key: Key,
|
||||
public valid: (v: Expect) => boolean,
|
||||
public validate: (e: any, a: any, ctx: CTX) => any
|
||||
) {}
|
||||
}
|
||||
export type TExpression<Key, Expect = unknown, CTX = any> = Expression<Key, Expect, CTX>;
|
||||
|
||||
export function exp<const Key, const Expect, CTX = any>(
|
||||
key: Key,
|
||||
valid: (v: Expect) => boolean,
|
||||
validate: (e: Expect, a: unknown, ctx: CTX) => any
|
||||
): Expression<Key, Expect, CTX> {
|
||||
return new Expression(key, valid, validate);
|
||||
}
|
||||
|
||||
type Expressions = Expression<any, any>[];
|
||||
type ExpressionMap<Exps extends Expressions> = {
|
||||
[K in Exps[number]["key"]]: Extract<Exps[number], { key: K }> extends Expression<K, infer E>
|
||||
? E
|
||||
: never;
|
||||
};
|
||||
type ExpressionCondition<Exps extends Expressions> = {
|
||||
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
|
||||
}[keyof ExpressionMap<Exps>];
|
||||
|
||||
function getExpression<Exps extends Expressions>(
|
||||
expressions: Exps,
|
||||
key: string
|
||||
): Expression<any, any> {
|
||||
const exp = expressions.find((e) => e.key === key);
|
||||
if (!exp) throw new Error(`Expression does not exist: "${key}"`);
|
||||
return exp as any;
|
||||
}
|
||||
|
||||
type LiteralExpressionCondition<Exps extends Expressions> = {
|
||||
[key: string]: Primitive | ExpressionCondition<Exps>;
|
||||
};
|
||||
|
||||
const OperandOr = "$or";
|
||||
type OperandCondition<Exps extends Expressions> = {
|
||||
[OperandOr]?: LiteralExpressionCondition<Exps> | ExpressionCondition<Exps>;
|
||||
};
|
||||
|
||||
export type FilterQuery<Exps extends Expressions> =
|
||||
| LiteralExpressionCondition<Exps>
|
||||
| OperandCondition<Exps>;
|
||||
|
||||
function _convert<Exps extends Expressions>(
|
||||
$query: FilterQuery<Exps>,
|
||||
expressions: Exps,
|
||||
path: string[] = []
|
||||
): FilterQuery<Exps> {
|
||||
//console.log("-----------------");
|
||||
const ExpressionConditionKeys = expressions.map((e) => e.key);
|
||||
const keys = Object.keys($query);
|
||||
const operands = [OperandOr] as const;
|
||||
const newQuery: FilterQuery<Exps> = {};
|
||||
|
||||
if (keys.some((k) => k.startsWith("$") && !operands.includes(k as any))) {
|
||||
throw new Error(`Invalid key '${keys}'. Keys must not start with '$'.`);
|
||||
}
|
||||
|
||||
if (path.length > 0 && keys.some((k) => operands.includes(k as any))) {
|
||||
throw new Error(`Operand ${OperandOr} can only appear at the top level.`);
|
||||
}
|
||||
|
||||
function validate(key: string, value: any, path: string[] = []) {
|
||||
const exp = getExpression(expressions, key as any);
|
||||
if (exp.valid(value) === false) {
|
||||
throw new Error(`Invalid value at "${[...path, key].join(".")}": ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries($query)) {
|
||||
// if $or, convert each value
|
||||
if (key === "$or") {
|
||||
newQuery.$or = _convert(value, expressions, [...path, key]);
|
||||
|
||||
// if primitive, assume $eq
|
||||
} else if (isPrimitive(value)) {
|
||||
validate("$eq", value, path);
|
||||
newQuery[key] = { $eq: value };
|
||||
|
||||
// if object, check for expressions
|
||||
} else if (typeof value === "object") {
|
||||
// when object is given, check if all keys are expressions
|
||||
const invalid = Object.keys(value).filter(
|
||||
(f) => !ExpressionConditionKeys.includes(f as any)
|
||||
);
|
||||
if (invalid.length === 0) {
|
||||
newQuery[key] = {};
|
||||
// validate each expression
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
validate(k, v, [...path, key]);
|
||||
newQuery[key][k] = v;
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expressions.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newQuery;
|
||||
}
|
||||
|
||||
type ValidationResults = { $and: any[]; $or: any[]; keys: Set<string> };
|
||||
type BuildOptions = {
|
||||
object?: any;
|
||||
exp_ctx?: any;
|
||||
convert?: boolean;
|
||||
value_is_kv?: boolean;
|
||||
};
|
||||
function _build<Exps extends Expressions>(
|
||||
_query: FilterQuery<Exps>,
|
||||
expressions: Exps,
|
||||
options: BuildOptions
|
||||
): ValidationResults {
|
||||
const $query = options.convert ? _convert<Exps>(_query, expressions) : _query;
|
||||
|
||||
//console.log("-----------------", { $query });
|
||||
//const keys = Object.keys($query);
|
||||
const result: ValidationResults = {
|
||||
$and: [],
|
||||
$or: [],
|
||||
keys: new Set<string>()
|
||||
};
|
||||
|
||||
const { $or, ...$and } = $query;
|
||||
|
||||
function __validate($op: string, expected: any, actual: any, path: string[] = []) {
|
||||
const exp = getExpression(expressions, $op as any);
|
||||
if (!exp) {
|
||||
throw new Error(`Expression does not exist: "${$op}"`);
|
||||
}
|
||||
if (!exp.valid(expected)) {
|
||||
throw new Error(`Invalid expected value at "${[...path, $op].join(".")}": ${expected}`);
|
||||
}
|
||||
//console.log("found exp", { key: exp.key, expected, actual });
|
||||
return exp.validate(expected, actual, options.exp_ctx);
|
||||
}
|
||||
|
||||
// check $and
|
||||
//console.log("$and entries", Object.entries($and));
|
||||
for (const [key, value] of Object.entries($and)) {
|
||||
//console.log("$op/$v", Object.entries(value));
|
||||
for (const [$op, $v] of Object.entries(value)) {
|
||||
const objValue = options.value_is_kv ? key : options.object[key];
|
||||
//console.log("--check $and", { key, value, objValue, v_i_kv: options.value_is_kv });
|
||||
//console.log("validate", { $op, $v, objValue, key });
|
||||
result.$and.push(__validate($op, $v, objValue, [key]));
|
||||
result.keys.add(key);
|
||||
}
|
||||
//console.log("-", { key, value });
|
||||
}
|
||||
|
||||
// check $or
|
||||
for (const [key, value] of Object.entries($or ?? {})) {
|
||||
const objValue = options.value_is_kv ? key : options.object[key];
|
||||
|
||||
for (const [$op, $v] of Object.entries(value)) {
|
||||
//console.log("validate", { $op, $v, objValue });
|
||||
result.$or.push(__validate($op, $v, objValue, [key]));
|
||||
result.keys.add(key);
|
||||
}
|
||||
//console.log("-", { key, value });
|
||||
}
|
||||
|
||||
//console.log("matches", matches);
|
||||
return result;
|
||||
}
|
||||
|
||||
function _validate(results: ValidationResults): boolean {
|
||||
const matches: { $and?: boolean; $or?: boolean } = {
|
||||
$and: undefined,
|
||||
$or: undefined
|
||||
};
|
||||
|
||||
matches.$and = results.$and.every((r) => Boolean(r));
|
||||
matches.$or = results.$or.some((r) => Boolean(r));
|
||||
|
||||
return !!matches.$and || !!matches.$or;
|
||||
}
|
||||
|
||||
export function makeValidator<Exps extends Expressions>(expressions: Exps) {
|
||||
return {
|
||||
convert: (query: FilterQuery<Exps>) => _convert(query, expressions),
|
||||
build: (query: FilterQuery<Exps>, options: BuildOptions) =>
|
||||
_build(query, expressions, options),
|
||||
validate: (query: FilterQuery<Exps>, options: BuildOptions) => {
|
||||
const fns = _build(query, expressions, options);
|
||||
return _validate(fns);
|
||||
}
|
||||
};
|
||||
}
|
||||
30
app/src/core/registry/Registry.ts
Normal file
30
app/src/core/registry/Registry.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type Constructor<T> = new (...args: any[]) => T;
|
||||
export class Registry<Item, Items extends Record<string, object> = Record<string, object>> {
|
||||
private is_set: boolean = false;
|
||||
private items: Items = {} as Items;
|
||||
|
||||
set<Actual extends Record<string, object>>(items: Actual) {
|
||||
if (this.is_set) {
|
||||
throw new Error("Registry is already set");
|
||||
}
|
||||
// @ts-ignore
|
||||
this.items = items;
|
||||
this.is_set = true;
|
||||
|
||||
return this as unknown as Registry<Item, Actual>;
|
||||
}
|
||||
|
||||
add(name: string, item: Item) {
|
||||
// @ts-ignore
|
||||
this.items[name] = item;
|
||||
return this;
|
||||
}
|
||||
|
||||
get<Name extends keyof Items>(name: Name): Items[Name] {
|
||||
return this.items[name];
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.items;
|
||||
}
|
||||
}
|
||||
11
app/src/core/security/Permission.ts
Normal file
11
app/src/core/security/Permission.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class Permission<Name extends string = string> {
|
||||
constructor(public name: Name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name
|
||||
};
|
||||
}
|
||||
}
|
||||
29
app/src/core/server/ContextHelper.ts
Normal file
29
app/src/core/server/ContextHelper.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Context } from "hono";
|
||||
|
||||
export class ContextHelper {
|
||||
constructor(protected c: Context) {}
|
||||
|
||||
contentTypeMime(): string {
|
||||
const contentType = this.c.res.headers.get("Content-Type");
|
||||
if (contentType) {
|
||||
return String(contentType.split(";")[0]);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
isHtml(): boolean {
|
||||
return this.contentTypeMime() === "text/html";
|
||||
}
|
||||
|
||||
url(): URL {
|
||||
return new URL(this.c.req.url);
|
||||
}
|
||||
|
||||
headersObject() {
|
||||
const headers = {};
|
||||
for (const [k, v] of this.c.res.headers.entries()) {
|
||||
headers[k] = v;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
155
app/src/core/server/Controller.ts
Normal file
155
app/src/core/server/Controller.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Hono, type MiddlewareHandler, type ValidationTargets } from "hono";
|
||||
import type { H } from "hono/types";
|
||||
import { safelyParseObjectValues } from "../utils";
|
||||
import type { Endpoint, Middleware } from "./Endpoint";
|
||||
import { zValidator } from "./lib/zValidator";
|
||||
|
||||
type RouteProxy<Endpoints> = {
|
||||
[K in keyof Endpoints]: Endpoints[K];
|
||||
};
|
||||
|
||||
export interface ClassController {
|
||||
getController: () => Hono<any, any, any>;
|
||||
getMiddleware?: MiddlewareHandler<any, any, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export class Controller<
|
||||
Endpoints extends Record<string, Endpoint> = Record<string, Endpoint>,
|
||||
Middlewares extends Record<string, Middleware> = Record<string, Middleware>
|
||||
> {
|
||||
protected endpoints: Endpoints = {} as Endpoints;
|
||||
protected middlewares: Middlewares = {} as Middlewares;
|
||||
|
||||
public prefix: string = "/";
|
||||
public routes: RouteProxy<Endpoints>;
|
||||
|
||||
constructor(
|
||||
prefix: string = "/",
|
||||
endpoints: Endpoints = {} as Endpoints,
|
||||
middlewares: Middlewares = {} as Middlewares
|
||||
) {
|
||||
this.prefix = prefix;
|
||||
this.endpoints = endpoints;
|
||||
this.middlewares = middlewares;
|
||||
|
||||
this.routes = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, name: string) => {
|
||||
return this.endpoints[name];
|
||||
}
|
||||
}
|
||||
) as RouteProxy<Endpoints>;
|
||||
}
|
||||
|
||||
add<Name extends string, E extends Endpoint>(
|
||||
this: Controller<Endpoints>,
|
||||
name: Name,
|
||||
endpoint: E
|
||||
): Controller<Endpoints & Record<Name, E>> {
|
||||
const newEndpoints = {
|
||||
...this.endpoints,
|
||||
[name]: endpoint
|
||||
} as Endpoints & Record<Name, E>;
|
||||
const newController: Controller<Endpoints & Record<Name, E>> = new Controller<
|
||||
Endpoints & Record<Name, E>
|
||||
>();
|
||||
newController.endpoints = newEndpoints;
|
||||
newController.middlewares = this.middlewares;
|
||||
return newController;
|
||||
}
|
||||
|
||||
get<Name extends keyof Endpoints>(name: Name): Endpoints[Name] {
|
||||
return this.endpoints[name];
|
||||
}
|
||||
|
||||
honoify(_hono: Hono = new Hono()) {
|
||||
const hono = _hono.basePath(this.prefix);
|
||||
|
||||
// apply middlewares
|
||||
for (const m_name in this.middlewares) {
|
||||
const middleware = this.middlewares[m_name];
|
||||
|
||||
if (typeof middleware === "function") {
|
||||
//if (isDebug()) console.log("+++ appyling middleware", m_name, middleware);
|
||||
hono.use(middleware);
|
||||
}
|
||||
}
|
||||
|
||||
// apply endpoints
|
||||
for (const name in this.endpoints) {
|
||||
const endpoint = this.endpoints[name];
|
||||
if (!endpoint) continue;
|
||||
|
||||
const handlers: H[] = [];
|
||||
|
||||
const supportedValidations: Array<keyof ValidationTargets> = ["param", "query", "json"];
|
||||
|
||||
// if validations are present, add them to the handlers
|
||||
for (const validation of supportedValidations) {
|
||||
if (endpoint.validation[validation]) {
|
||||
handlers.push(async (c, next) => {
|
||||
// @todo: potentially add "strict" to all schemas?
|
||||
const res = await zValidator(
|
||||
validation,
|
||||
endpoint.validation[validation] as any,
|
||||
(target, value, c) => {
|
||||
if (["query", "param"].includes(target)) {
|
||||
return safelyParseObjectValues(value);
|
||||
}
|
||||
//console.log("preprocess", target, value, c.req.raw.url);
|
||||
return value;
|
||||
}
|
||||
)(c, next);
|
||||
|
||||
if (res instanceof Response && res.status === 400) {
|
||||
const error = await res.json();
|
||||
return c.json(
|
||||
{
|
||||
error: "Validation error",
|
||||
target: validation,
|
||||
message: error
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// add actual handler
|
||||
handlers.push(endpoint.toHandler());
|
||||
|
||||
const method = endpoint.method.toLowerCase() as
|
||||
| "get"
|
||||
| "post"
|
||||
| "put"
|
||||
| "delete"
|
||||
| "patch";
|
||||
|
||||
//if (isDebug()) console.log("--- adding", method, endpoint.path);
|
||||
hono[method](endpoint.path, ...handlers);
|
||||
}
|
||||
|
||||
return hono;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const endpoints: any = {};
|
||||
for (const name in this.endpoints) {
|
||||
const endpoint = this.endpoints[name];
|
||||
if (!endpoint) continue;
|
||||
|
||||
endpoints[name] = {
|
||||
method: endpoint.method,
|
||||
path: (this.prefix + endpoint.path).replace("//", "/")
|
||||
};
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
147
app/src/core/server/Endpoint.ts
Normal file
147
app/src/core/server/Endpoint.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Context, MiddlewareHandler, Next, ValidationTargets } from "hono";
|
||||
import type { Handler } from "hono/types";
|
||||
import { encodeSearch, replaceUrlParam } from "../utils";
|
||||
import type { Prettify } from "../utils";
|
||||
|
||||
type ZodSchema = { [key: string]: any };
|
||||
|
||||
type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
type Validation<P, Q, B> = {
|
||||
[K in keyof ValidationTargets]?: any;
|
||||
} & {
|
||||
param?: P extends ZodSchema ? P : undefined;
|
||||
query?: Q extends ZodSchema ? Q : undefined;
|
||||
json?: B extends ZodSchema ? B : undefined;
|
||||
};
|
||||
|
||||
type ValidationInput<P, Q, B> = {
|
||||
param?: P extends ZodSchema ? P["_input"] : undefined;
|
||||
query?: Q extends ZodSchema ? Q["_input"] : undefined;
|
||||
json?: B extends ZodSchema ? B["_input"] : undefined;
|
||||
};
|
||||
|
||||
type HonoEnv = any;
|
||||
|
||||
export type Middleware = MiddlewareHandler<any, any, any>;
|
||||
|
||||
type HandlerFunction<P extends string, R> = (c: Context<HonoEnv, P, any>, next: Next) => R;
|
||||
export type RequestResponse<R> = {
|
||||
status: number;
|
||||
ok: boolean;
|
||||
response: Awaited<R>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export class Endpoint<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
> {
|
||||
constructor(
|
||||
readonly method: Method,
|
||||
readonly path: Path,
|
||||
readonly handler: HandlerFunction<Path, R>,
|
||||
readonly validation: Validation<P, Q, B> = {}
|
||||
) {}
|
||||
|
||||
// @todo: typing is not ideal
|
||||
async $request(
|
||||
args?: ValidationInput<P, Q, B>,
|
||||
baseUrl: string = "http://localhost:28623"
|
||||
): Promise<Prettify<RequestResponse<R>>> {
|
||||
let path = this.path as string;
|
||||
if (args?.param) {
|
||||
path = replaceUrlParam(path, args.param);
|
||||
}
|
||||
|
||||
if (args?.query) {
|
||||
path += "?" + encodeSearch(args.query);
|
||||
}
|
||||
|
||||
const url = [baseUrl, path].join("").replace(/\/$/, "");
|
||||
const options: RequestInit = {
|
||||
method: this.method,
|
||||
headers: {} as any
|
||||
};
|
||||
|
||||
if (!["GET", "HEAD"].includes(this.method)) {
|
||||
if (args?.json) {
|
||||
options.body = JSON.stringify(args.json);
|
||||
options.headers!["Content-Type"] = "application/json";
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, options);
|
||||
return {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
response: (await res.json()) as any
|
||||
};
|
||||
}
|
||||
|
||||
toHandler(): Handler {
|
||||
return async (c, next) => {
|
||||
const res = await this.handler(c, next);
|
||||
//console.log("toHandler:isResponse", res instanceof Response);
|
||||
//return res;
|
||||
if (res instanceof Response) {
|
||||
return res;
|
||||
}
|
||||
return c.json(res as any) as unknown as Handler;
|
||||
};
|
||||
}
|
||||
|
||||
static get<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
|
||||
return new Endpoint<Path, P, Q, B, R>("GET", path, handler, validation);
|
||||
}
|
||||
|
||||
static post<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
|
||||
return new Endpoint<Path, P, Q, B, R>("POST", path, handler, validation);
|
||||
}
|
||||
|
||||
static patch<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
|
||||
return new Endpoint<Path, P, Q, B, R>("PATCH", path, handler, validation);
|
||||
}
|
||||
|
||||
static put<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
|
||||
return new Endpoint<Path, P, Q, B, R>("PUT", path, handler, validation);
|
||||
}
|
||||
|
||||
static delete<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
|
||||
return new Endpoint<Path, P, Q, B, R>("DELETE", path, handler, validation);
|
||||
}
|
||||
}
|
||||
37
app/src/core/server/lib/tbValidator.ts
Normal file
37
app/src/core/server/lib/tbValidator.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { StaticDecode, TSchema } from "@sinclair/typebox";
|
||||
import { Value, type ValueError } from "@sinclair/typebox/value";
|
||||
import type { Context, Env, MiddlewareHandler, ValidationTargets } from "hono";
|
||||
import { validator } from "hono/validator";
|
||||
|
||||
type Hook<T, E extends Env, P extends string> = (
|
||||
result: { success: true; data: T } | { success: false; errors: ValueError[] },
|
||||
c: Context<E, P>
|
||||
) => Response | Promise<Response> | void;
|
||||
|
||||
export function tbValidator<
|
||||
T extends TSchema,
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
V extends { in: { [K in Target]: StaticDecode<T> }; out: { [K in Target]: StaticDecode<T> } }
|
||||
>(target: Target, schema: T, hook?: Hook<StaticDecode<T>, E, P>): MiddlewareHandler<E, P, V> {
|
||||
// Compile the provided schema once rather than per validation. This could be optimized further using a shared schema
|
||||
// compilation pool similar to the Fastify implementation.
|
||||
|
||||
// @ts-expect-error not typed well
|
||||
return validator(target, (data, c) => {
|
||||
if (Value.Check(schema, data)) {
|
||||
// always decode
|
||||
const decoded = Value.Decode(schema, data);
|
||||
|
||||
if (hook) {
|
||||
const hookResult = hook({ success: true, data: decoded }, c);
|
||||
if (hookResult instanceof Response || hookResult instanceof Promise) {
|
||||
return hookResult;
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
return c.json({ success: false, errors: [...Value.Errors(schema, data)] }, 400);
|
||||
});
|
||||
}
|
||||
75
app/src/core/server/lib/zValidator.ts
Normal file
75
app/src/core/server/lib/zValidator.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
Context,
|
||||
Env,
|
||||
Input,
|
||||
MiddlewareHandler,
|
||||
TypedResponse,
|
||||
ValidationTargets,
|
||||
} from "hono";
|
||||
import { validator } from "hono/validator";
|
||||
import type { ZodError, ZodSchema, z } from "zod";
|
||||
|
||||
export type Hook<T, E extends Env, P extends string, O = {}> = (
|
||||
result: { success: true; data: T } | { success: false; error: ZodError; data: T },
|
||||
c: Context<E, P>,
|
||||
) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
|
||||
|
||||
type HasUndefined<T> = undefined extends T ? true : false;
|
||||
|
||||
export const zValidator = <
|
||||
T extends ZodSchema,
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
In = z.input<T>,
|
||||
Out = z.output<T>,
|
||||
I extends Input = {
|
||||
in: HasUndefined<In> extends true
|
||||
? {
|
||||
[K in Target]?: K extends "json"
|
||||
? In
|
||||
: HasUndefined<keyof ValidationTargets[K]> extends true
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] };
|
||||
}
|
||||
: {
|
||||
[K in Target]: K extends "json"
|
||||
? In
|
||||
: HasUndefined<keyof ValidationTargets[K]> extends true
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] };
|
||||
};
|
||||
out: { [K in Target]: Out };
|
||||
},
|
||||
V extends I = I,
|
||||
>(
|
||||
target: Target,
|
||||
schema: T,
|
||||
preprocess?: (target: string, value: In, c: Context<E, P>) => V, // <-- added
|
||||
hook?: Hook<z.infer<T>, E, P>,
|
||||
): MiddlewareHandler<E, P, V> =>
|
||||
// @ts-expect-error not typed well
|
||||
validator(target, async (value, c) => {
|
||||
// added: preprocess value first if given
|
||||
const _value = preprocess ? preprocess(target, value, c) : (value as any);
|
||||
const result = await schema.safeParseAsync(_value);
|
||||
|
||||
if (hook) {
|
||||
const hookResult = await hook({ data: value, ...result }, c);
|
||||
if (hookResult) {
|
||||
if (hookResult instanceof Response) {
|
||||
return hookResult;
|
||||
}
|
||||
|
||||
if ("response" in hookResult) {
|
||||
return hookResult.response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return c.json(result, 400);
|
||||
}
|
||||
|
||||
return result.data as z.infer<T>;
|
||||
});
|
||||
96
app/src/core/template/SimpleRenderer.ts
Normal file
96
app/src/core/template/SimpleRenderer.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Liquid, LiquidError } from "liquidjs";
|
||||
import type { RenderOptions } from "liquidjs/dist/liquid-options";
|
||||
import { BkndError } from "../errors";
|
||||
|
||||
export type TemplateObject = Record<string, string | Record<string, string>>;
|
||||
export type TemplateTypes = string | TemplateObject;
|
||||
|
||||
export type SimpleRendererOptions = RenderOptions & {
|
||||
renderKeys?: boolean;
|
||||
};
|
||||
|
||||
export class SimpleRenderer {
|
||||
private engine = new Liquid();
|
||||
|
||||
constructor(
|
||||
private variables: Record<string, any> = {},
|
||||
private options: SimpleRendererOptions = {}
|
||||
) {}
|
||||
|
||||
another() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
static hasMarkup(template: string | object): boolean {
|
||||
//console.log("has markup?", template);
|
||||
let flat: string = "";
|
||||
|
||||
if (Array.isArray(template) || typeof template === "object") {
|
||||
// only plain arrays and objects
|
||||
if (!["Array", "Object"].includes(template.constructor.name)) return false;
|
||||
|
||||
flat = JSON.stringify(template);
|
||||
} else {
|
||||
flat = String(template);
|
||||
}
|
||||
|
||||
//console.log("** flat", flat);
|
||||
|
||||
const checks = ["{{", "{%", "{#", "{:"];
|
||||
const hasMarkup = checks.some((check) => flat.includes(check));
|
||||
//console.log("--has markup?", hasMarkup);
|
||||
return hasMarkup;
|
||||
}
|
||||
|
||||
async render<Given extends TemplateTypes>(template: Given): Promise<Given> {
|
||||
try {
|
||||
if (typeof template === "string") {
|
||||
return (await this.renderString(template)) as unknown as Given;
|
||||
} else if (Array.isArray(template)) {
|
||||
return (await Promise.all(
|
||||
template.map((item) => this.render(item))
|
||||
)) as unknown as Given;
|
||||
} else if (typeof template === "object") {
|
||||
return (await this.renderObject(template)) as unknown as Given;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof LiquidError) {
|
||||
const details = {
|
||||
name: e.name,
|
||||
token: {
|
||||
kind: e.token.kind,
|
||||
input: e.token.input,
|
||||
begin: e.token.begin,
|
||||
end: e.token.end
|
||||
}
|
||||
};
|
||||
|
||||
throw new BkndError(e.message, details, "liquid");
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw new Error("Invalid template type");
|
||||
}
|
||||
|
||||
async renderString(template: string): Promise<string> {
|
||||
//console.log("*** renderString", template, this.variables);
|
||||
return this.engine.parseAndRender(template, this.variables, this.options);
|
||||
}
|
||||
|
||||
async renderObject(template: TemplateObject): Promise<TemplateObject> {
|
||||
const result: TemplateObject = {};
|
||||
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
let resultKey = key;
|
||||
if (this.options.renderKeys) {
|
||||
resultKey = await this.renderString(key);
|
||||
}
|
||||
|
||||
result[resultKey] = await this.render(value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
4
app/src/core/types.ts
Normal file
4
app/src/core/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Serializable<Class, Json extends object = object> {
|
||||
toJSON(): Json;
|
||||
fromJSON(json: Json): Class;
|
||||
}
|
||||
36
app/src/core/utils/DebugLogger.ts
Normal file
36
app/src/core/utils/DebugLogger.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export class DebugLogger {
|
||||
public _context: string[] = [];
|
||||
_enabled: boolean = true;
|
||||
private readonly id = Math.random().toString(36).substr(2, 9);
|
||||
private last: number = 0;
|
||||
|
||||
constructor(enabled: boolean = true) {
|
||||
this._enabled = enabled;
|
||||
}
|
||||
|
||||
context(context: string) {
|
||||
//console.log("[ settings context ]", context, this._context);
|
||||
this._context.push(context);
|
||||
return this;
|
||||
}
|
||||
|
||||
clear() {
|
||||
//console.log("[ clear context ]", this._context.pop(), this._context);
|
||||
this._context.pop();
|
||||
return this;
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
if (!this._enabled) return this;
|
||||
|
||||
const now = performance.now();
|
||||
const time = Number.parseInt(String(now - this.last));
|
||||
const indents = " ".repeat(this._context.length);
|
||||
const context =
|
||||
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
|
||||
console.log(indents, context, time, ...args);
|
||||
|
||||
this.last = now;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
20
app/src/core/utils/browser.ts
Normal file
20
app/src/core/utils/browser.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type TBrowser = "Opera" | "Edge" | "Chrome" | "Safari" | "Firefox" | "IE" | "unknown";
|
||||
export function getBrowser(): TBrowser {
|
||||
if ((navigator.userAgent.indexOf("Opera") || navigator.userAgent.indexOf("OPR")) !== -1) {
|
||||
return "Opera";
|
||||
} else if (navigator.userAgent.indexOf("Edg") !== -1) {
|
||||
return "Edge";
|
||||
} else if (navigator.userAgent.indexOf("Chrome") !== -1) {
|
||||
return "Chrome";
|
||||
} else if (navigator.userAgent.indexOf("Safari") !== -1) {
|
||||
return "Safari";
|
||||
} else if (navigator.userAgent.indexOf("Firefox") !== -1) {
|
||||
return "Firefox";
|
||||
// @ts-ignore
|
||||
} else if (navigator.userAgent.indexOf("MSIE") !== -1 || !!document.documentMode === true) {
|
||||
//IF IE > 10
|
||||
return "IE";
|
||||
} else {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
29
app/src/core/utils/crypto.ts
Normal file
29
app/src/core/utils/crypto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export const HashAlgorithms = ["SHA-1", "SHA-256", "SHA-384", "SHA-512"] as const;
|
||||
export type HashAlgorithm = (typeof HashAlgorithms)[number];
|
||||
export async function digest(alg: HashAlgorithm, input: string, salt?: string, pepper?: string) {
|
||||
if (!HashAlgorithms.includes(alg)) {
|
||||
throw new Error(`Invalid hash algorithm: ${alg}`);
|
||||
}
|
||||
|
||||
// convert to Uint8Array
|
||||
const data = new TextEncoder().encode((salt ?? "") + input + (pepper ?? ""));
|
||||
|
||||
// hash to alg
|
||||
const hashBuffer = await crypto.subtle.digest(alg, data);
|
||||
|
||||
// convert hash to hex string
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export const hash = {
|
||||
sha256: async (input: string, salt?: string, pepper?: string) =>
|
||||
digest("SHA-256", input, salt, pepper),
|
||||
sha1: async (input: string, salt?: string, pepper?: string) =>
|
||||
digest("SHA-1", input, salt, pepper)
|
||||
};
|
||||
|
||||
export async function checksum(s: any) {
|
||||
const o = typeof s === "string" ? s : JSON.stringify(s);
|
||||
return await digest("SHA-1", o);
|
||||
}
|
||||
14
app/src/core/utils/dates.ts
Normal file
14
app/src/core/utils/dates.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import dayjs from "dayjs";
|
||||
import weekOfYear from "dayjs/plugin/weekOfYear.js";
|
||||
|
||||
declare module "dayjs" {
|
||||
interface Dayjs {
|
||||
week(): number;
|
||||
|
||||
week(value: number): dayjs.Dayjs;
|
||||
}
|
||||
}
|
||||
|
||||
dayjs.extend(weekOfYear);
|
||||
|
||||
export { dayjs };
|
||||
13
app/src/core/utils/index.ts
Normal file
13
app/src/core/utils/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export * from "./browser";
|
||||
export * from "./objects";
|
||||
export * from "./strings";
|
||||
export * from "./perf";
|
||||
export * from "./reqres";
|
||||
export * from "./xml";
|
||||
export type { Prettify, PrettifyRec } from "./types";
|
||||
export * from "./typebox";
|
||||
export * from "./dates";
|
||||
export * from "./crypto";
|
||||
export * from "./uuid";
|
||||
export { FromSchema } from "./typebox/from-schema";
|
||||
export * from "./test";
|
||||
198
app/src/core/utils/objects.ts
Normal file
198
app/src/core/utils/objects.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { pascalToKebab } from "./strings";
|
||||
|
||||
export function _jsonp(obj: any, space = 2): string {
|
||||
return JSON.stringify(obj, null, space);
|
||||
}
|
||||
|
||||
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
acc[key] = JSON.parse(value);
|
||||
} catch (error) {
|
||||
// @ts-ignore
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as T);
|
||||
}
|
||||
|
||||
export function keepChanged<T extends object>(origin: T, updated: T): Partial<T> {
|
||||
return Object.keys(updated).reduce(
|
||||
(acc, key) => {
|
||||
if (updated[key] !== origin[key]) {
|
||||
acc[key] = updated[key];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Partial<T>
|
||||
);
|
||||
}
|
||||
|
||||
export function objectKeysPascalToKebab(obj: any, ignoreKeys: string[] = []): any {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => objectKeysPascalToKebab(item, ignoreKeys));
|
||||
}
|
||||
|
||||
return Object.keys(obj).reduce(
|
||||
(acc, key) => {
|
||||
const kebabKey = ignoreKeys.includes(key) ? key : pascalToKebab(key);
|
||||
acc[kebabKey] = objectKeysPascalToKebab(obj[key], ignoreKeys);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
}
|
||||
|
||||
export function filterKeys<Object extends { [key: string]: any }>(
|
||||
obj: Object,
|
||||
keysToFilter: string[]
|
||||
): Object {
|
||||
const result = {} as Object;
|
||||
|
||||
for (const key in obj) {
|
||||
const shouldFilter = keysToFilter.some((filterKey) => key.includes(filterKey));
|
||||
if (!shouldFilter) {
|
||||
if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||
result[key] = filterKeys(obj[key], keysToFilter);
|
||||
} else {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function transformObject<T extends Record<string, any>, U>(
|
||||
object: T,
|
||||
transform: (value: T[keyof T], key: keyof T) => U | undefined
|
||||
): { [K in keyof T]: U } {
|
||||
return Object.entries(object).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const t = transform(value, key as keyof T);
|
||||
if (typeof t !== "undefined") {
|
||||
acc[key as keyof T] = t;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as { [K in keyof T]: U }
|
||||
);
|
||||
}
|
||||
export const objectTransform = transformObject;
|
||||
|
||||
export function objectEach<T extends Record<string, any>, U>(
|
||||
object: T,
|
||||
each: (value: T[keyof T], key: keyof T) => U
|
||||
): void {
|
||||
Object.entries(object).forEach(
|
||||
([key, value]) => {
|
||||
each(value, key);
|
||||
},
|
||||
{} as { [K in keyof T]: U }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple object check.
|
||||
* @param item
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isObject(item) {
|
||||
return item && typeof item === "object" && !Array.isArray(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects.
|
||||
* @param target
|
||||
* @param ...sources
|
||||
*/
|
||||
export function mergeDeep(target, ...sources) {
|
||||
if (!sources.length) return target;
|
||||
const source = sources.shift();
|
||||
|
||||
if (isObject(target) && isObject(source)) {
|
||||
for (const key in source) {
|
||||
if (isObject(source[key])) {
|
||||
if (!target[key]) Object.assign(target, { [key]: {} });
|
||||
mergeDeep(target[key], source[key]);
|
||||
} else {
|
||||
Object.assign(target, { [key]: source[key] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeDeep(target, ...sources);
|
||||
}
|
||||
|
||||
export function getFullPathKeys(obj: any, parentPath: string = ""): string[] {
|
||||
let keys: string[] = [];
|
||||
|
||||
for (const key in obj) {
|
||||
const fullPath = parentPath ? `${parentPath}.${key}` : key;
|
||||
keys.push(fullPath);
|
||||
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
keys = keys.concat(getFullPathKeys(obj[key], fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function flattenObject(obj: any, parentKey = "", result: any = {}): any {
|
||||
for (const key in obj) {
|
||||
if (key in obj) {
|
||||
const newKey = parentKey ? `${parentKey}.${key}` : key;
|
||||
if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||
flattenObject(obj[key], newKey, result);
|
||||
} else if (Array.isArray(obj[key])) {
|
||||
obj[key].forEach((item, index) => {
|
||||
const arrayKey = `${newKey}.${index}`;
|
||||
if (typeof item === "object" && item !== null) {
|
||||
flattenObject(item, arrayKey, result);
|
||||
} else {
|
||||
result[arrayKey] = item;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
result[newKey] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function objectDepth(object: object): number {
|
||||
let level = 1;
|
||||
for (const key in object) {
|
||||
if (typeof object[key] === "object") {
|
||||
const depth = objectDepth(object[key]) + 1;
|
||||
level = Math.max(depth, level);
|
||||
}
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj): Obj {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
if (value && Array.isArray(value) && value.some((v) => typeof v === "object")) {
|
||||
const nested = value.map(objectCleanEmpty);
|
||||
if (nested.length > 0) {
|
||||
acc[key] = nested;
|
||||
}
|
||||
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const nested = objectCleanEmpty(value);
|
||||
if (Object.keys(nested).length > 0) {
|
||||
acc[key] = nested;
|
||||
}
|
||||
} else if (value !== "" && value !== null && value !== undefined) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as any);
|
||||
}
|
||||
60
app/src/core/utils/perf.ts
Normal file
60
app/src/core/utils/perf.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export class Perf {
|
||||
private marks: { mark: string; time: number }[] = [];
|
||||
private startTime: number;
|
||||
private endTime: number | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.startTime = performance.now();
|
||||
}
|
||||
|
||||
static start(): Perf {
|
||||
return new Perf();
|
||||
}
|
||||
|
||||
mark(markName: string): void {
|
||||
if (this.endTime !== null) {
|
||||
throw new Error("Cannot add marks after perf measurement has been closed.");
|
||||
}
|
||||
|
||||
const currentTime = performance.now();
|
||||
const lastMarkTime =
|
||||
this.marks.length > 0 ? this.marks[this.marks.length - 1]!.time : this.startTime;
|
||||
const elapsedTimeSinceLastMark = currentTime - lastMarkTime;
|
||||
|
||||
this.marks.push({ mark: markName, time: elapsedTimeSinceLastMark });
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.endTime !== null) {
|
||||
throw new Error("Perf measurement has already been closed.");
|
||||
}
|
||||
this.endTime = performance.now();
|
||||
}
|
||||
|
||||
result(): { total: number; marks: { mark: string; time: number }[] } {
|
||||
if (this.endTime === null) {
|
||||
throw new Error("Perf measurement has not been closed yet.");
|
||||
}
|
||||
|
||||
const totalTime = this.endTime - this.startTime;
|
||||
return {
|
||||
total: Number.parseFloat(totalTime.toFixed(2)),
|
||||
marks: this.marks.map((mark) => ({
|
||||
mark: mark.mark,
|
||||
time: Number.parseFloat(mark.time.toFixed(2)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
static async execute(fn: () => Promise<any>, times: number = 1): Promise<any> {
|
||||
const perf = Perf.start();
|
||||
|
||||
for (let i = 0; i < times; i++) {
|
||||
await fn();
|
||||
perf.mark(`iteration-${i}`);
|
||||
}
|
||||
|
||||
perf.close();
|
||||
return perf.result();
|
||||
}
|
||||
}
|
||||
84
app/src/core/utils/reqres.ts
Normal file
84
app/src/core/utils/reqres.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export function headersToObject(headers: Headers): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
return { ...Object.fromEntries(headers.entries()) };
|
||||
}
|
||||
|
||||
export function pickHeaders(headers: Headers, keys: string[]): Record<string, string> {
|
||||
const obj = headersToObject(headers);
|
||||
const res = {};
|
||||
for (const key of keys) {
|
||||
if (obj[key]) {
|
||||
res[key] = obj[key];
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const replaceUrlParam = (urlString: string, params: Record<string, string>) => {
|
||||
let newString = urlString;
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
const reg = new RegExp(`/:${k}(?:{[^/]+})?`);
|
||||
newString = newString.replace(reg, `/${v}`);
|
||||
}
|
||||
return newString;
|
||||
};
|
||||
|
||||
export function encodeSearch(obj, options?: { prefix?: string; encode?: boolean }) {
|
||||
let str = "";
|
||||
function _encode(str) {
|
||||
return options?.encode ? encodeURIComponent(str) : str;
|
||||
}
|
||||
|
||||
for (const k in obj) {
|
||||
let tmp = obj[k];
|
||||
if (tmp !== void 0) {
|
||||
if (Array.isArray(tmp)) {
|
||||
for (let i = 0; i < tmp.length; i++) {
|
||||
if (str.length > 0) str += "&";
|
||||
str += `${_encode(k)}=${_encode(tmp[i])}`;
|
||||
}
|
||||
} else {
|
||||
if (typeof tmp === "object") {
|
||||
tmp = JSON.stringify(tmp);
|
||||
}
|
||||
|
||||
if (str.length > 0) str += "&";
|
||||
str += `${_encode(k)}=${_encode(tmp)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (options?.prefix || "") + str;
|
||||
}
|
||||
|
||||
export function decodeSearch(str) {
|
||||
function toValue(mix) {
|
||||
if (!mix) return "";
|
||||
const str = decodeURIComponent(mix);
|
||||
if (str === "false") return false;
|
||||
if (str === "true") return true;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
return +str * 0 === 0 ? +str : str;
|
||||
}
|
||||
}
|
||||
|
||||
let tmp: any;
|
||||
let k: string;
|
||||
const out = {};
|
||||
const arr = str.split("&");
|
||||
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
|
||||
while ((tmp = arr.shift())) {
|
||||
tmp = tmp.split("=");
|
||||
k = tmp.shift();
|
||||
if (out[k] !== void 0) {
|
||||
out[k] = [].concat(out[k], toValue(tmp.shift()));
|
||||
} else {
|
||||
out[k] = toValue(tmp.shift());
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
9
app/src/core/utils/sql.ts
Normal file
9
app/src/core/utils/sql.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { isDebug } from "../env";
|
||||
|
||||
export async function formatSql(sql: string): Promise<string> {
|
||||
if (isDebug()) {
|
||||
const { format } = await import("sql-formatter");
|
||||
return format(sql);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
62
app/src/core/utils/strings.ts
Normal file
62
app/src/core/utils/strings.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export function objectToKeyValueArray<T extends Record<string, any>>(obj: T) {
|
||||
return Object.keys(obj).map((key) => ({ key, value: obj[key as keyof T] }));
|
||||
}
|
||||
|
||||
export function ucFirst(str: string): string {
|
||||
if (!str || str.length === 0) return str;
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
export function ucFirstAll(str: string, split: string = " "): string {
|
||||
if (!str || str.length === 0) return str;
|
||||
return str
|
||||
.split(split)
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join(split);
|
||||
}
|
||||
|
||||
export function ucFirstAllSnakeToPascalWithSpaces(str: string, split: string = " "): string {
|
||||
return ucFirstAll(snakeToPascalWithSpaces(str), split);
|
||||
}
|
||||
|
||||
export function randomString(length: number, includeSpecial = false): string {
|
||||
const base = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const special = "!@#$%^&*()_+{}:\"<>?|[];',./`~";
|
||||
const chars = base + (includeSpecial ? special : "");
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string from snake_case to PascalCase with spaces
|
||||
* Example: `snake_to_pascal` -> `Snake To Pascal`
|
||||
*
|
||||
* @param str
|
||||
*/
|
||||
export function snakeToPascalWithSpaces(str: string): string {
|
||||
if (!str || str.length === 0) return str;
|
||||
|
||||
return str
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function pascalToKebab(pascalStr: string): string {
|
||||
return pascalStr.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace simple mustache like {placeholders} in a string
|
||||
*
|
||||
* @param str
|
||||
* @param vars
|
||||
*/
|
||||
export function replaceSimplePlaceholders(str: string, vars: Record<string, any>): string {
|
||||
return str.replace(/\{\$(\w+)\}/g, (match, key) => {
|
||||
return key in vars ? vars[key] : match;
|
||||
});
|
||||
}
|
||||
18
app/src/core/utils/test.ts
Normal file
18
app/src/core/utils/test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
type ConsoleSeverity = "log" | "warn" | "error";
|
||||
const _oldConsoles = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error
|
||||
};
|
||||
|
||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
|
||||
severities.forEach((severity) => {
|
||||
console[severity] = () => null;
|
||||
});
|
||||
}
|
||||
|
||||
export function enableConsoleLog() {
|
||||
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
|
||||
console[severity as ConsoleSeverity] = fn;
|
||||
});
|
||||
}
|
||||
268
app/src/core/utils/typebox/from-schema.ts
Normal file
268
app/src/core/utils/typebox/from-schema.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/*--------------------------------------------------------------------------
|
||||
|
||||
@sinclair/typebox/prototypes
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017-2024 Haydn Paterson (sinclair) <haydn.developer@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
---------------------------------------------------------------------------*/
|
||||
|
||||
import * as Type from "@sinclair/typebox";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Schematics
|
||||
// ------------------------------------------------------------------
|
||||
const IsExact = (value: unknown, expect: unknown) => value === expect;
|
||||
const IsSValue = (value: unknown): value is SValue =>
|
||||
Type.ValueGuard.IsString(value) ||
|
||||
Type.ValueGuard.IsNumber(value) ||
|
||||
Type.ValueGuard.IsBoolean(value);
|
||||
const IsSEnum = (value: unknown): value is SEnum =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
Type.ValueGuard.IsArray(value.enum) &&
|
||||
value.enum.every((value) => IsSValue(value));
|
||||
const IsSAllOf = (value: unknown): value is SAllOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.allOf);
|
||||
const IsSAnyOf = (value: unknown): value is SAnyOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.anyOf);
|
||||
const IsSOneOf = (value: unknown): value is SOneOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.oneOf);
|
||||
const IsSTuple = (value: unknown): value is STuple =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
IsExact(value.type, "array") &&
|
||||
Type.ValueGuard.IsArray(value.items);
|
||||
const IsSArray = (value: unknown): value is SArray =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
IsExact(value.type, "array") &&
|
||||
!Type.ValueGuard.IsArray(value.items) &&
|
||||
Type.ValueGuard.IsObject(value.items);
|
||||
const IsSConst = (value: unknown): value is SConst =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsObject(value["const"]);
|
||||
const IsSString = (value: unknown): value is SString =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "string");
|
||||
const IsSNumber = (value: unknown): value is SNumber =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "number");
|
||||
const IsSInteger = (value: unknown): value is SInteger =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "integer");
|
||||
const IsSBoolean = (value: unknown): value is SBoolean =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "boolean");
|
||||
const IsSNull = (value: unknown): value is SBoolean =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "null");
|
||||
const IsSProperties = (value: unknown): value is SProperties => Type.ValueGuard.IsObject(value);
|
||||
// prettier-ignore
|
||||
const IsSObject = (value: unknown): value is SObject => Type.ValueGuard.IsObject(value) && IsExact(value.type, 'object') && IsSProperties(value.properties) && (value.required === undefined || Type.ValueGuard.IsArray(value.required) && value.required.every((value: unknown) => Type.ValueGuard.IsString(value)))
|
||||
type SValue = string | number | boolean;
|
||||
type SEnum = Readonly<{ enum: readonly SValue[] }>;
|
||||
type SAllOf = Readonly<{ allOf: readonly unknown[] }>;
|
||||
type SAnyOf = Readonly<{ anyOf: readonly unknown[] }>;
|
||||
type SOneOf = Readonly<{ oneOf: readonly unknown[] }>;
|
||||
type SProperties = Record<PropertyKey, unknown>;
|
||||
type SObject = Readonly<{ type: "object"; properties: SProperties; required?: readonly string[] }>;
|
||||
type STuple = Readonly<{ type: "array"; items: readonly unknown[] }>;
|
||||
type SArray = Readonly<{ type: "array"; items: unknown }>;
|
||||
type SConst = Readonly<{ const: SValue }>;
|
||||
type SString = Readonly<{ type: "string" }>;
|
||||
type SNumber = Readonly<{ type: "number" }>;
|
||||
type SInteger = Readonly<{ type: "integer" }>;
|
||||
type SBoolean = Readonly<{ type: "boolean" }>;
|
||||
type SNull = Readonly<{ type: "null" }>;
|
||||
// ------------------------------------------------------------------
|
||||
// FromRest
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromRest<T extends readonly unknown[], Acc extends Type.TSchema[] = []> = (
|
||||
T extends readonly [infer L extends unknown, ...infer R extends unknown[]]
|
||||
? TFromSchema<L> extends infer S extends Type.TSchema
|
||||
? TFromRest<R, [...Acc, S]>
|
||||
: TFromRest<R, [...Acc]>
|
||||
: Acc
|
||||
)
|
||||
function FromRest<T extends readonly unknown[]>(T: T): TFromRest<T> {
|
||||
return T.map((L) => FromSchema(L)) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// FromEnumRest
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromEnumRest<T extends readonly SValue[], Acc extends Type.TSchema[] = []> = (
|
||||
T extends readonly [infer L extends SValue, ...infer R extends SValue[]]
|
||||
? TFromEnumRest<R, [...Acc, Type.TLiteral<L>]>
|
||||
: Acc
|
||||
)
|
||||
function FromEnumRest<T extends readonly SValue[]>(T: T): TFromEnumRest<T> {
|
||||
return T.map((L) => Type.Literal(L)) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// AllOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromAllOf<T extends SAllOf> = (
|
||||
TFromRest<T['allOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TIntersectEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromAllOf<T extends SAllOf>(T: T): TFromAllOf<T> {
|
||||
return Type.IntersectEvaluated(FromRest(T.allOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// AnyOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromAnyOf<T extends SAnyOf> = (
|
||||
TFromRest<T['anyOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromAnyOf<T extends SAnyOf>(T: T): TFromAnyOf<T> {
|
||||
return Type.UnionEvaluated(FromRest(T.anyOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// OneOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromOneOf<T extends SOneOf> = (
|
||||
TFromRest<T['oneOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromOneOf<T extends SOneOf>(T: T): TFromOneOf<T> {
|
||||
return Type.UnionEvaluated(FromRest(T.oneOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Enum
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromEnum<T extends SEnum> = (
|
||||
TFromEnumRest<T['enum']> extends infer Elements extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Elements>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromEnum<T extends SEnum>(T: T): TFromEnum<T> {
|
||||
return Type.UnionEvaluated(FromEnumRest(T.enum));
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Tuple
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromTuple<T extends STuple> = (
|
||||
TFromRest<T['items']> extends infer Elements extends Type.TSchema[]
|
||||
? Type.TTuple<Elements>
|
||||
: Type.TTuple<[]>
|
||||
)
|
||||
// prettier-ignore
|
||||
function FromTuple<T extends STuple>(T: T): TFromTuple<T> {
|
||||
return Type.Tuple(FromRest(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Array
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromArray<T extends SArray> = (
|
||||
TFromSchema<T['items']> extends infer Items extends Type.TSchema
|
||||
? Type.TArray<Items>
|
||||
: Type.TArray<Type.TUnknown>
|
||||
)
|
||||
// prettier-ignore
|
||||
function FromArray<T extends SArray>(T: T): TFromArray<T> {
|
||||
return Type.Array(FromSchema(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Const
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromConst<T extends SConst> = (
|
||||
Type.Ensure<Type.TLiteral<T['const']>>
|
||||
)
|
||||
function FromConst<T extends SConst>(T: T) {
|
||||
return Type.Literal(T.const, T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Object
|
||||
// ------------------------------------------------------------------
|
||||
type TFromPropertiesIsOptional<
|
||||
K extends PropertyKey,
|
||||
R extends string | unknown,
|
||||
> = unknown extends R ? true : K extends R ? false : true;
|
||||
// prettier-ignore
|
||||
type TFromProperties<T extends SProperties, R extends string | unknown> = Type.Evaluate<{
|
||||
-readonly [K in keyof T]: TFromPropertiesIsOptional<K, R> extends true
|
||||
? Type.TOptional<TFromSchema<T[K]>>
|
||||
: TFromSchema<T[K]>
|
||||
}>
|
||||
// prettier-ignore
|
||||
type TFromObject<T extends SObject> = (
|
||||
TFromProperties<T['properties'], Exclude<T['required'], undefined>[number]> extends infer Properties extends Type.TProperties
|
||||
? Type.TObject<Properties>
|
||||
: Type.TObject<{}>
|
||||
)
|
||||
function FromObject<T extends SObject>(T: T): TFromObject<T> {
|
||||
const properties = globalThis.Object.getOwnPropertyNames(T.properties).reduce((Acc, K) => {
|
||||
return {
|
||||
...Acc,
|
||||
[K]:
|
||||
T.required && T.required.includes(K)
|
||||
? FromSchema(T.properties[K])
|
||||
: Type.Optional(FromSchema(T.properties[K])),
|
||||
};
|
||||
}, {} as Type.TProperties);
|
||||
return Type.Object(properties, T) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// FromSchema
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
export type TFromSchema<T> = (
|
||||
T extends SAllOf ? TFromAllOf<T> :
|
||||
T extends SAnyOf ? TFromAnyOf<T> :
|
||||
T extends SOneOf ? TFromOneOf<T> :
|
||||
T extends SEnum ? TFromEnum<T> :
|
||||
T extends SObject ? TFromObject<T> :
|
||||
T extends STuple ? TFromTuple<T> :
|
||||
T extends SArray ? TFromArray<T> :
|
||||
T extends SConst ? TFromConst<T> :
|
||||
T extends SString ? Type.TString :
|
||||
T extends SNumber ? Type.TNumber :
|
||||
T extends SInteger ? Type.TInteger :
|
||||
T extends SBoolean ? Type.TBoolean :
|
||||
T extends SNull ? Type.TNull :
|
||||
Type.TUnknown
|
||||
)
|
||||
/** Parses a TypeBox type from raw JsonSchema */
|
||||
export function FromSchema<T>(T: T): TFromSchema<T> {
|
||||
// prettier-ignore
|
||||
return (
|
||||
IsSAllOf(T) ? FromAllOf(T) :
|
||||
IsSAnyOf(T) ? FromAnyOf(T) :
|
||||
IsSOneOf(T) ? FromOneOf(T) :
|
||||
IsSEnum(T) ? FromEnum(T) :
|
||||
IsSObject(T) ? FromObject(T) :
|
||||
IsSTuple(T) ? FromTuple(T) :
|
||||
IsSArray(T) ? FromArray(T) :
|
||||
IsSConst(T) ? FromConst(T) :
|
||||
IsSString(T) ? Type.String(T) :
|
||||
IsSNumber(T) ? Type.Number(T) :
|
||||
IsSInteger(T) ? Type.Integer(T) :
|
||||
IsSBoolean(T) ? Type.Boolean(T) :
|
||||
IsSNull(T) ? Type.Null(T) :
|
||||
Type.Unknown(T || {})
|
||||
) as never
|
||||
}
|
||||
206
app/src/core/utils/typebox/index.ts
Normal file
206
app/src/core/utils/typebox/index.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Kind,
|
||||
type ObjectOptions,
|
||||
type SchemaOptions,
|
||||
type Static,
|
||||
type StaticDecode,
|
||||
type StringOptions,
|
||||
type TLiteral,
|
||||
type TLiteralValue,
|
||||
type TObject,
|
||||
type TRecord,
|
||||
type TSchema,
|
||||
type TString,
|
||||
Type,
|
||||
TypeRegistry
|
||||
} from "@sinclair/typebox";
|
||||
import {
|
||||
DefaultErrorFunction,
|
||||
Errors,
|
||||
SetErrorFunction,
|
||||
type ValueErrorIterator
|
||||
} from "@sinclair/typebox/errors";
|
||||
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
|
||||
export type RecursivePartial<T> = {
|
||||
[P in keyof T]?: T[P] extends (infer U)[]
|
||||
? RecursivePartial<U>[]
|
||||
: T[P] extends object | undefined
|
||||
? RecursivePartial<T[P]>
|
||||
: T[P];
|
||||
};
|
||||
|
||||
type ParseOptions = {
|
||||
useDefaults?: boolean;
|
||||
decode?: boolean;
|
||||
onError?: (errors: ValueErrorIterator) => void;
|
||||
forceParse?: boolean;
|
||||
skipMark?: boolean;
|
||||
};
|
||||
|
||||
const validationSymbol = Symbol("tb-parse-validation");
|
||||
|
||||
export class TypeInvalidError extends Error {
|
||||
errors: ValueError[];
|
||||
constructor(
|
||||
public schema: TSchema,
|
||||
public data: unknown,
|
||||
message?: string
|
||||
) {
|
||||
//console.warn("errored schema", JSON.stringify(schema, null, 2));
|
||||
super(message ?? `Invalid: ${JSON.stringify(data)}`);
|
||||
this.errors = [...Errors(schema, data)];
|
||||
}
|
||||
|
||||
first() {
|
||||
return this.errors[0]!;
|
||||
}
|
||||
|
||||
firstToString() {
|
||||
const first = this.first();
|
||||
return `${first.message} at "${first.path}"`;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
message: this.message,
|
||||
schema: this.schema,
|
||||
data: this.data,
|
||||
errors: this.errors
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function stripMark(obj: any) {
|
||||
const newObj = cloneDeep(obj);
|
||||
mark(newObj, false);
|
||||
return newObj;
|
||||
}
|
||||
|
||||
export function mark(obj: any, validated = true) {
|
||||
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
|
||||
if (validated) {
|
||||
obj[validationSymbol] = true;
|
||||
} else {
|
||||
delete obj[validationSymbol];
|
||||
}
|
||||
for (const key in obj) {
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
mark(obj[key], validated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parse<Schema extends TSchema = TSchema>(
|
||||
schema: Schema,
|
||||
data: RecursivePartial<Static<Schema>>,
|
||||
options?: ParseOptions
|
||||
): Static<Schema> {
|
||||
if (!options?.forceParse && typeof data === "object" && validationSymbol in data) {
|
||||
if (options?.useDefaults === false) {
|
||||
return data as Static<typeof schema>;
|
||||
}
|
||||
|
||||
// this is important as defaults are expected
|
||||
return Default(schema, data as any) as Static<Schema>;
|
||||
}
|
||||
|
||||
const parsed = options?.useDefaults === false ? data : Default(schema, data);
|
||||
|
||||
if (Check(schema, parsed)) {
|
||||
options?.skipMark !== true && mark(parsed, true);
|
||||
return parsed as Static<typeof schema>;
|
||||
} else if (options?.onError) {
|
||||
options.onError(Errors(schema, data));
|
||||
} else {
|
||||
throw new TypeInvalidError(schema, data);
|
||||
}
|
||||
|
||||
// @todo: check this
|
||||
return undefined as any;
|
||||
}
|
||||
|
||||
export function parseDecode<Schema extends TSchema = TSchema>(
|
||||
schema: Schema,
|
||||
data: RecursivePartial<StaticDecode<Schema>>
|
||||
): StaticDecode<Schema> {
|
||||
//console.log("parseDecode", schema, data);
|
||||
const parsed = Default(schema, data);
|
||||
|
||||
if (Check(schema, parsed)) {
|
||||
return parsed as StaticDecode<typeof schema>;
|
||||
}
|
||||
//console.log("errors", ...Errors(schema, data));
|
||||
|
||||
throw new TypeInvalidError(schema, data);
|
||||
}
|
||||
|
||||
export function strictParse<Schema extends TSchema = TSchema>(
|
||||
schema: Schema,
|
||||
data: Static<Schema>,
|
||||
options?: ParseOptions
|
||||
): Static<Schema> {
|
||||
return parse(schema, data as any, options);
|
||||
}
|
||||
|
||||
export function registerCustomTypeboxKinds(registry: typeof TypeRegistry) {
|
||||
registry.Set("StringEnum", (schema: any, value: any) => {
|
||||
return typeof value === "string" && schema.enum.includes(value);
|
||||
});
|
||||
}
|
||||
registerCustomTypeboxKinds(TypeRegistry);
|
||||
|
||||
export const StringEnum = <const T extends readonly string[]>(values: T, options?: StringOptions) =>
|
||||
Type.Unsafe<T[number]>({
|
||||
[Kind]: "StringEnum",
|
||||
type: "string",
|
||||
enum: values,
|
||||
...options
|
||||
});
|
||||
|
||||
// key value record compatible with RJSF and typebox inference
|
||||
// acting like a Record, but using an Object with additionalProperties
|
||||
export const StringRecord = <T extends TSchema>(properties: T, options?: ObjectOptions) =>
|
||||
Type.Object({}, { ...options, additionalProperties: properties }) as unknown as TRecord<
|
||||
TString,
|
||||
typeof properties
|
||||
>;
|
||||
|
||||
// fixed value that only be what is given + prefilled
|
||||
export const Const = <T extends TLiteralValue = TLiteralValue>(value: T, options?: SchemaOptions) =>
|
||||
Type.Literal(value, { ...options, default: value, const: value, readOnly: true }) as TLiteral<T>;
|
||||
|
||||
export const StringIdentifier = Type.String({
|
||||
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
minLength: 2,
|
||||
maxLength: 150
|
||||
});
|
||||
|
||||
SetErrorFunction((error) => {
|
||||
if (error?.schema?.errorMessage) {
|
||||
return error.schema.errorMessage;
|
||||
}
|
||||
|
||||
if (error?.schema?.[Kind] === "StringEnum") {
|
||||
return `Expected: ${error.schema.enum.map((e) => `"${e}"`).join(", ")}`;
|
||||
}
|
||||
|
||||
return DefaultErrorFunction(error);
|
||||
});
|
||||
|
||||
export {
|
||||
Type,
|
||||
type Static,
|
||||
type StaticDecode,
|
||||
type TSchema,
|
||||
Kind,
|
||||
type TObject,
|
||||
type ValueError,
|
||||
type SchemaOptions,
|
||||
Value,
|
||||
Default,
|
||||
Errors,
|
||||
Check
|
||||
};
|
||||
8
app/src/core/utils/types.d.ts
vendored
Normal file
8
app/src/core/utils/types.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & NonNullable<unknown>;
|
||||
|
||||
// prettify recursively
|
||||
export type PrettifyRec<T> = {
|
||||
[K in keyof T]: T[K] extends object ? Prettify<T[K]> : T[K];
|
||||
} & NonNullable<unknown>;
|
||||
4
app/src/core/utils/uuid.ts
Normal file
4
app/src/core/utils/uuid.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// generates v4
|
||||
export function uuid(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
6
app/src/core/utils/xml.ts
Normal file
6
app/src/core/utils/xml.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
|
||||
export function xmlToObject(xml: string) {
|
||||
const parser = new XMLParser();
|
||||
return parser.parse(xml);
|
||||
}
|
||||
122
app/src/data/AppData.ts
Normal file
122
app/src/data/AppData.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { transformObject } from "core/utils";
|
||||
import { DataPermissions, Entity, EntityIndex, type EntityManager, type Field } from "data";
|
||||
import { Module } from "modules/Module";
|
||||
import { DataController } from "./api/DataController";
|
||||
import {
|
||||
type AppDataConfig,
|
||||
FIELDS,
|
||||
RELATIONS,
|
||||
type TAppDataEntity,
|
||||
type TAppDataRelation,
|
||||
dataConfigSchema
|
||||
} from "./data-schema";
|
||||
|
||||
export class AppData<DB> extends Module<typeof dataConfigSchema> {
|
||||
static constructEntity(name: string, entityConfig: TAppDataEntity) {
|
||||
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
|
||||
const { type } = fieldConfig;
|
||||
if (!(type in FIELDS)) {
|
||||
throw new Error(`Field type "${type}" not found`);
|
||||
}
|
||||
|
||||
const { field } = FIELDS[type as any];
|
||||
const returnal = new field(name, fieldConfig.config) as Field;
|
||||
return returnal;
|
||||
});
|
||||
|
||||
// @todo: entity must be migrated to typebox
|
||||
return new Entity(
|
||||
name,
|
||||
Object.values(fields),
|
||||
entityConfig.config as any,
|
||||
entityConfig.type as any
|
||||
);
|
||||
}
|
||||
|
||||
static constructRelation(
|
||||
relationConfig: TAppDataRelation,
|
||||
resolver: (name: Entity | string) => Entity
|
||||
) {
|
||||
return new RELATIONS[relationConfig.type].cls(
|
||||
resolver(relationConfig.source),
|
||||
resolver(relationConfig.target),
|
||||
relationConfig.config
|
||||
);
|
||||
}
|
||||
|
||||
override async build() {
|
||||
const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => {
|
||||
return AppData.constructEntity(name, entityConfig);
|
||||
});
|
||||
|
||||
const _entity = (_e: Entity | string): Entity => {
|
||||
const name = typeof _e === "string" ? _e : _e.name;
|
||||
const entity = entities[name];
|
||||
if (entity) return entity;
|
||||
throw new Error(`Entity "${name}" not found`);
|
||||
};
|
||||
|
||||
const relations = transformObject(this.config.relations ?? {}, (relation) =>
|
||||
AppData.constructRelation(relation, _entity)
|
||||
);
|
||||
|
||||
const indices = transformObject(this.config.indices ?? {}, (index, name) => {
|
||||
const entity = _entity(index.entity)!;
|
||||
const fields = index.fields.map((f) => entity.field(f)!);
|
||||
return new EntityIndex(entity, fields, index.unique, name);
|
||||
});
|
||||
|
||||
for (const entity of Object.values(entities)) {
|
||||
this.ctx.em.addEntity(entity);
|
||||
}
|
||||
|
||||
for (const relation of Object.values(relations)) {
|
||||
this.ctx.em.addRelation(relation);
|
||||
}
|
||||
|
||||
for (const index of Object.values(indices)) {
|
||||
this.ctx.em.addIndex(index);
|
||||
}
|
||||
|
||||
this.ctx.server.route(
|
||||
this.basepath,
|
||||
new DataController(this.ctx, this.config).getController()
|
||||
);
|
||||
this.ctx.guard.registerPermissions(Object.values(DataPermissions));
|
||||
|
||||
this.setBuilt();
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return dataConfigSchema;
|
||||
}
|
||||
|
||||
get em(): EntityManager<DB> {
|
||||
this.throwIfNotBuilt();
|
||||
return this.ctx.em;
|
||||
}
|
||||
|
||||
private get basepath() {
|
||||
return this.config.basepath ?? "/api/data";
|
||||
}
|
||||
|
||||
override getOverwritePaths() {
|
||||
return [
|
||||
/^entities\..*\.config$/,
|
||||
/^entities\..*\.fields\..*\.config$/
|
||||
///^entities\..*\.fields\..*\.config\.schema$/
|
||||
];
|
||||
}
|
||||
|
||||
/*registerController(server: AppServer) {
|
||||
console.log("adding data controller to", this.basepath);
|
||||
server.add(this.basepath, new DataController(this.em));
|
||||
}*/
|
||||
|
||||
override toJSON(secrets?: boolean): AppDataConfig {
|
||||
return {
|
||||
...this.config,
|
||||
...this.em.toJSON()
|
||||
};
|
||||
}
|
||||
}
|
||||
63
app/src/data/api/DataApi.ts
Normal file
63
app/src/data/api/DataApi.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { EntityData, RepoQuery, RepositoryResponse } from "data";
|
||||
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
|
||||
|
||||
export type DataApiOptions = BaseModuleApiOptions & {
|
||||
defaultQuery?: Partial<RepoQuery>;
|
||||
};
|
||||
|
||||
export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
protected override getDefaultOptions(): Partial<DataApiOptions> {
|
||||
return {
|
||||
basepath: "/api/data",
|
||||
defaultQuery: {
|
||||
limit: 10
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async readOne(
|
||||
entity: string,
|
||||
id: PrimaryFieldType,
|
||||
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
|
||||
) {
|
||||
return this.get<RepositoryResponse<EntityData>>([entity, id], query);
|
||||
}
|
||||
|
||||
async readMany(entity: string, query: Partial<RepoQuery> = {}) {
|
||||
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
|
||||
[entity],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
}
|
||||
|
||||
async readManyByReference(
|
||||
entity: string,
|
||||
id: PrimaryFieldType,
|
||||
reference: string,
|
||||
query: Partial<RepoQuery> = {}
|
||||
) {
|
||||
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
|
||||
[entity, id, reference],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
}
|
||||
|
||||
async createOne(entity: string, input: EntityData) {
|
||||
return this.post<RepositoryResponse<EntityData>>([entity], input);
|
||||
}
|
||||
|
||||
async updateOne(entity: string, id: PrimaryFieldType, input: EntityData) {
|
||||
return this.patch<RepositoryResponse<EntityData>>([entity, id], input);
|
||||
}
|
||||
|
||||
async deleteOne(entity: string, id: PrimaryFieldType) {
|
||||
return this.delete<RepositoryResponse<EntityData>>([entity, id]);
|
||||
}
|
||||
|
||||
async count(entity: string, where: RepoQuery["where"] = {}) {
|
||||
return this.post<RepositoryResponse<{ entity: string; count: number }>>(
|
||||
[entity, "fn", "count"],
|
||||
where
|
||||
);
|
||||
}
|
||||
}
|
||||
384
app/src/data/api/DataController.ts
Normal file
384
app/src/data/api/DataController.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { type ClassController, isDebug, tbValidator as tb } from "core";
|
||||
import { Type, objectCleanEmpty, objectTransform } from "core/utils";
|
||||
import {
|
||||
DataPermissions,
|
||||
type EntityData,
|
||||
type EntityManager,
|
||||
FieldClassMap,
|
||||
type MutatorResponse,
|
||||
PrimaryField,
|
||||
type RepoQuery,
|
||||
type RepositoryResponse,
|
||||
TextField,
|
||||
querySchema
|
||||
} from "data";
|
||||
import { Hono } from "hono";
|
||||
import type { Handler } from "hono/types";
|
||||
import type { ModuleBuildContext } from "modules";
|
||||
import { AppData } from "../AppData";
|
||||
import { type AppDataConfig, FIELDS } from "../data-schema";
|
||||
|
||||
export class DataController implements ClassController {
|
||||
constructor(
|
||||
private readonly ctx: ModuleBuildContext,
|
||||
private readonly config: AppDataConfig
|
||||
) {
|
||||
/*console.log(
|
||||
"data controller",
|
||||
this.em.entities.map((e) => e.name)
|
||||
);*/
|
||||
}
|
||||
|
||||
get em(): EntityManager<any> {
|
||||
return this.ctx.em;
|
||||
}
|
||||
|
||||
get guard() {
|
||||
return this.ctx.guard;
|
||||
}
|
||||
|
||||
repoResult<T extends RepositoryResponse<any> = RepositoryResponse>(
|
||||
res: T
|
||||
): Pick<T, "meta" | "data"> {
|
||||
let meta: Partial<RepositoryResponse["meta"]> = {};
|
||||
|
||||
if ("meta" in res) {
|
||||
const { query, ...rest } = res.meta;
|
||||
meta = rest;
|
||||
if (isDebug()) meta.query = query;
|
||||
}
|
||||
|
||||
const template = { data: res.data, meta };
|
||||
|
||||
// @todo: this works but it breaks in FE (need to improve DataTable)
|
||||
//return objectCleanEmpty(template) as any;
|
||||
// filter empty
|
||||
return Object.fromEntries(
|
||||
Object.entries(template).filter(([_, v]) => typeof v !== "undefined" && v !== null)
|
||||
) as any;
|
||||
}
|
||||
|
||||
mutatorResult(res: MutatorResponse | MutatorResponse<EntityData>) {
|
||||
const template = { data: res.data };
|
||||
|
||||
// filter empty
|
||||
//return objectCleanEmpty(template);
|
||||
return Object.fromEntries(Object.entries(template).filter(([_, v]) => v !== undefined));
|
||||
}
|
||||
|
||||
entityExists(entity: string) {
|
||||
try {
|
||||
return !!this.em.entity(entity);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getController(): Hono<any> {
|
||||
const hono = new Hono();
|
||||
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);
|
||||
|
||||
// @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;
|
||||
}
|
||||
|
||||
// add timing
|
||||
/*hono.use("*", async (c, next) => {
|
||||
startTime(c, "data");
|
||||
await next();
|
||||
endTime(c, "data");
|
||||
});*/
|
||||
|
||||
// info
|
||||
hono.get(
|
||||
"/",
|
||||
handler("data info", (c) => {
|
||||
// sample implementation
|
||||
return c.json(this.em.toJSON());
|
||||
})
|
||||
);
|
||||
|
||||
// sync endpoint
|
||||
hono.get("/sync", async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.databaseSync);
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
/**
|
||||
* Function endpoints
|
||||
*/
|
||||
hono
|
||||
// fn: count
|
||||
.post(
|
||||
"/:entity/fn/count",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
const where = c.req.json() as any;
|
||||
const result = await this.em.repository(entity).count(where);
|
||||
return c.json({ entity, count: result.count });
|
||||
}
|
||||
)
|
||||
// fn: exists
|
||||
.post(
|
||||
"/:entity/fn/exists",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
const where = c.req.json() as any;
|
||||
const result = await this.em.repository(entity).exists(where);
|
||||
return c.json({ entity, exists: result.exists });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Read endpoints
|
||||
*/
|
||||
hono
|
||||
// read entity schema
|
||||
.get("/schema.json", async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
const url = new URL(c.req.url);
|
||||
const $id = `${url.origin}${this.config.basepath}/schema.json`;
|
||||
const schemas = Object.fromEntries(
|
||||
this.em.entities.map((e) => [
|
||||
e.name,
|
||||
{
|
||||
$ref: `schemas/${e.name}`
|
||||
}
|
||||
])
|
||||
);
|
||||
return c.json({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id,
|
||||
properties: schemas
|
||||
});
|
||||
})
|
||||
// read schema
|
||||
.get(
|
||||
"/schemas/:entity",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
console.log("not found", entity, definedEntities);
|
||||
return c.notFound();
|
||||
}
|
||||
const _entity = this.em.entity(entity);
|
||||
const schema = _entity.toSchema();
|
||||
const url = new URL(c.req.url);
|
||||
const base = `${url.origin}${this.config.basepath}`;
|
||||
const $id = `${base}/schemas/${entity}`;
|
||||
return c.json({
|
||||
$schema: `${base}/schema.json`,
|
||||
$id,
|
||||
title: _entity.label,
|
||||
$comment: _entity.config.description,
|
||||
...schema
|
||||
});
|
||||
}
|
||||
)
|
||||
// read many
|
||||
.get(
|
||||
"/:entity",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("query", querySchema),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
console.log("not found", entity, definedEntities);
|
||||
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 });
|
||||
}
|
||||
)
|
||||
|
||||
// read one
|
||||
.get(
|
||||
"/:entity/:id",
|
||||
tb(
|
||||
"param",
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
id: tbNumber
|
||||
})
|
||||
),
|
||||
tb("query", querySchema),
|
||||
/*zValidator("param", z.object({ entity: z.string(), id: z.coerce.number() })),
|
||||
zValidator("query", repoQuerySchema),*/
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
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 many by reference
|
||||
.get(
|
||||
"/:entity/:id/:reference",
|
||||
tb(
|
||||
"param",
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
id: tbNumber,
|
||||
reference: Type.String()
|
||||
})
|
||||
),
|
||||
tb("query", querySchema),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity, id, reference } = 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)
|
||||
.findManyByReference(Number(id), reference, options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
}
|
||||
)
|
||||
// func query
|
||||
.post(
|
||||
"/:entity/query",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
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 });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Mutation endpoints
|
||||
*/
|
||||
// insert one
|
||||
hono
|
||||
.post("/:entity", tb("param", Type.Object({ entity: Type.String() })), async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityCreate);
|
||||
|
||||
const { entity } = 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).insertOne(body);
|
||||
|
||||
return c.json(this.mutatorResult(result), 201);
|
||||
})
|
||||
// update one
|
||||
.patch(
|
||||
"/:entity/:id",
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityUpdate);
|
||||
|
||||
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));
|
||||
}
|
||||
)
|
||||
// delete one
|
||||
.delete(
|
||||
"/:entity/:id",
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
|
||||
|
||||
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));
|
||||
}
|
||||
)
|
||||
|
||||
// delete many
|
||||
.delete(
|
||||
"/:entity",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema.properties.where),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
|
||||
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
const where = c.req.valid("json") as RepoQuery["where"];
|
||||
console.log("where", where);
|
||||
|
||||
const result = await this.em.mutator(entity).deleteMany(where);
|
||||
|
||||
return c.json(this.mutatorResult(result));
|
||||
}
|
||||
);
|
||||
|
||||
return hono;
|
||||
}
|
||||
}
|
||||
97
app/src/data/connection/Connection.ts
Normal file
97
app/src/data/connection/Connection.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type AliasableExpression,
|
||||
type DatabaseIntrospector,
|
||||
type Expression,
|
||||
type Kysely,
|
||||
type KyselyPlugin,
|
||||
type RawBuilder,
|
||||
type SelectQueryBuilder,
|
||||
type SelectQueryNode,
|
||||
type Simplify,
|
||||
sql
|
||||
} from "kysely";
|
||||
|
||||
export type QB = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
export type IndexMetadata = {
|
||||
name: string;
|
||||
table: string;
|
||||
isUnique: boolean;
|
||||
columns: { name: string; order: number }[];
|
||||
};
|
||||
|
||||
export interface ConnectionIntrospector extends DatabaseIntrospector {
|
||||
getIndices(tbl_name?: string): Promise<IndexMetadata[]>;
|
||||
}
|
||||
|
||||
export interface SelectQueryBuilderExpression<O> extends AliasableExpression<O> {
|
||||
get isSelectQueryBuilder(): true;
|
||||
toOperationNode(): SelectQueryNode;
|
||||
}
|
||||
|
||||
export type DbFunctions = {
|
||||
jsonObjectFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O> | null>;
|
||||
jsonArrayFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O>[]>;
|
||||
jsonBuildObject<O extends Record<string, Expression<unknown>>>(
|
||||
obj: O
|
||||
): RawBuilder<
|
||||
Simplify<{
|
||||
[K in keyof O]: O[K] extends Expression<infer V> ? V : never;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
|
||||
export abstract class Connection {
|
||||
kysely: Kysely<any>;
|
||||
|
||||
constructor(
|
||||
kysely: Kysely<any>,
|
||||
public fn: Partial<DbFunctions> = {},
|
||||
protected plugins: KyselyPlugin[] = []
|
||||
) {
|
||||
this.kysely = kysely;
|
||||
}
|
||||
|
||||
getIntrospector(): ConnectionIntrospector {
|
||||
return this.kysely.introspection as ConnectionIntrospector;
|
||||
}
|
||||
|
||||
supportsBatching(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
supportsIndices(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
const res = await sql`SELECT 1`.execute(this.kysely);
|
||||
return res.rows.length > 0;
|
||||
}
|
||||
|
||||
protected async batch<Queries extends QB[]>(
|
||||
queries: [...Queries]
|
||||
): Promise<{
|
||||
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
|
||||
}> {
|
||||
throw new Error("Batching not supported");
|
||||
}
|
||||
|
||||
async batchQuery<Queries extends QB[]>(
|
||||
queries: [...Queries]
|
||||
): Promise<{
|
||||
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
|
||||
}> {
|
||||
// bypass if no client support
|
||||
if (!this.supportsBatching()) {
|
||||
const data: any = [];
|
||||
for (const q of queries) {
|
||||
const result = await q.execute();
|
||||
data.push(result);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return await this.batch(queries);
|
||||
}
|
||||
}
|
||||
100
app/src/data/connection/LibsqlConnection.ts
Normal file
100
app/src/data/connection/LibsqlConnection.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { type Client, type InStatement, createClient } from "@libsql/client/web";
|
||||
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
||||
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin, sql } from "kysely";
|
||||
import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin";
|
||||
import { KyselyPluginRunner } from "../plugins/KyselyPluginRunner";
|
||||
import type { QB } from "./Connection";
|
||||
import { SqliteConnection } from "./SqliteConnection";
|
||||
import { SqliteIntrospector } from "./SqliteIntrospector";
|
||||
|
||||
export const LIBSQL_PROTOCOLS = ["wss", "https", "libsql"] as const;
|
||||
export type LibSqlCredentials = {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
protocol?: (typeof LIBSQL_PROTOCOLS)[number];
|
||||
};
|
||||
|
||||
class CustomLibsqlDialect extends LibsqlDialect {
|
||||
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
|
||||
return new SqliteIntrospector(db, {
|
||||
excludeTables: ["libsql_wasm_func_table"]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class LibsqlConnection extends SqliteConnection {
|
||||
private client: Client;
|
||||
|
||||
constructor(client: Client);
|
||||
constructor(credentials: LibSqlCredentials);
|
||||
constructor(clientOrCredentials: Client | LibSqlCredentials) {
|
||||
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()];
|
||||
let client: Client;
|
||||
if ("url" in clientOrCredentials) {
|
||||
let { url, authToken, protocol } = clientOrCredentials;
|
||||
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
|
||||
console.log("changing protocol to", protocol);
|
||||
const [, rest] = url.split("://");
|
||||
url = `${protocol}://${rest}`;
|
||||
}
|
||||
|
||||
//console.log("using", url, { protocol });
|
||||
|
||||
client = createClient({ url, authToken });
|
||||
} else {
|
||||
//console.log("-- client provided");
|
||||
client = clientOrCredentials;
|
||||
}
|
||||
|
||||
const kysely = new Kysely({
|
||||
// @ts-expect-error libsql has type issues
|
||||
dialect: new CustomLibsqlDialect({ client }),
|
||||
plugins
|
||||
//log: ["query"],
|
||||
});
|
||||
|
||||
super(kysely, {}, plugins);
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
override supportsBatching(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
override supportsIndices(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
getClient(): Client {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
protected override async batch<Queries extends QB[]>(
|
||||
queries: [...Queries]
|
||||
): Promise<{
|
||||
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
|
||||
}> {
|
||||
const stms: InStatement[] = queries.map((q) => {
|
||||
const compiled = q.compile();
|
||||
//console.log("compiled", compiled.sql, compiled.parameters);
|
||||
return {
|
||||
sql: compiled.sql,
|
||||
args: compiled.parameters as any[]
|
||||
};
|
||||
});
|
||||
|
||||
const res = await this.client.batch(stms);
|
||||
|
||||
// let it run through plugins
|
||||
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
|
||||
|
||||
const data: any = [];
|
||||
for (const r of res) {
|
||||
const rows = await kyselyPlugins.transformResultRows(r.rows);
|
||||
data.push(rows);
|
||||
}
|
||||
//console.log("data", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
22
app/src/data/connection/SqliteConnection.ts
Normal file
22
app/src/data/connection/SqliteConnection.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Kysely, KyselyPlugin } from "kysely";
|
||||
import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { Connection, type DbFunctions } from "./Connection";
|
||||
|
||||
export class SqliteConnection extends Connection {
|
||||
constructor(kysely: Kysely<any>, fn: Partial<DbFunctions> = {}, plugins: KyselyPlugin[] = []) {
|
||||
super(
|
||||
kysely,
|
||||
{
|
||||
...fn,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
jsonBuildObject
|
||||
},
|
||||
plugins
|
||||
);
|
||||
}
|
||||
|
||||
override supportsIndices(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
164
app/src/data/connection/SqliteIntrospector.ts
Normal file
164
app/src/data/connection/SqliteIntrospector.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type {
|
||||
DatabaseIntrospector,
|
||||
DatabaseMetadata,
|
||||
DatabaseMetadataOptions,
|
||||
ExpressionBuilder,
|
||||
Kysely,
|
||||
SchemaMetadata,
|
||||
TableMetadata,
|
||||
} from "kysely";
|
||||
import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql } from "kysely";
|
||||
import type { ConnectionIntrospector, IndexMetadata } from "./Connection";
|
||||
|
||||
export type SqliteIntrospectorConfig = {
|
||||
excludeTables?: string[];
|
||||
};
|
||||
|
||||
export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntrospector {
|
||||
readonly #db: Kysely<any>;
|
||||
readonly _excludeTables: string[] = [];
|
||||
|
||||
constructor(db: Kysely<any>, config: SqliteIntrospectorConfig = {}) {
|
||||
this.#db = db;
|
||||
this._excludeTables = config.excludeTables ?? [];
|
||||
}
|
||||
|
||||
async getSchemas(): Promise<SchemaMetadata[]> {
|
||||
// Sqlite doesn't support schemas.
|
||||
return [];
|
||||
}
|
||||
|
||||
async getIndices(tbl_name?: string): Promise<IndexMetadata[]> {
|
||||
const indices = await this.#db
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "=", "index")
|
||||
.$if(!!tbl_name, (eb) => eb.where("tbl_name", "=", tbl_name))
|
||||
.select("name")
|
||||
.$castTo<{ name: string }>()
|
||||
.execute();
|
||||
|
||||
return Promise.all(indices.map(({ name }) => this.#getIndexMetadata(name)));
|
||||
}
|
||||
|
||||
async #getIndexMetadata(index: string): Promise<IndexMetadata> {
|
||||
const db = this.#db;
|
||||
|
||||
// Get the SQL that was used to create the index.
|
||||
const indexDefinition = await db
|
||||
.selectFrom("sqlite_master")
|
||||
.where("name", "=", index)
|
||||
.select(["sql", "tbl_name", "type"])
|
||||
.$castTo<{ sql: string | undefined; tbl_name: string; type: string }>()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
//console.log("--indexDefinition--", indexDefinition, index);
|
||||
|
||||
// check unique by looking for the word "unique" in the sql
|
||||
const isUnique = indexDefinition.sql?.match(/unique/i) != null;
|
||||
|
||||
const columns = await db
|
||||
.selectFrom(
|
||||
sql<{
|
||||
seqno: number;
|
||||
cid: number;
|
||||
name: string;
|
||||
}>`pragma_index_info(${index})`.as("index_info"),
|
||||
)
|
||||
.select(["seqno", "cid", "name"])
|
||||
.orderBy("cid")
|
||||
.execute();
|
||||
|
||||
return {
|
||||
name: index,
|
||||
table: indexDefinition.tbl_name,
|
||||
isUnique: isUnique,
|
||||
columns: columns.map((col) => ({
|
||||
name: col.name,
|
||||
order: col.seqno,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private excludeTables(tables: string[] = []) {
|
||||
return (eb: ExpressionBuilder<any, any>) => {
|
||||
const and = tables.map((t) => eb("name", "!=", t));
|
||||
return eb.and(and);
|
||||
};
|
||||
}
|
||||
|
||||
async getTables(
|
||||
options: DatabaseMetadataOptions = { withInternalKyselyTables: false },
|
||||
): Promise<TableMetadata[]> {
|
||||
let query = this.#db
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "in", ["table", "view"])
|
||||
.where("name", "not like", "sqlite_%")
|
||||
.select("name")
|
||||
.orderBy("name")
|
||||
.$castTo<{ name: string }>();
|
||||
|
||||
if (!options.withInternalKyselyTables) {
|
||||
query = query.where(
|
||||
this.excludeTables([DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE]),
|
||||
);
|
||||
}
|
||||
if (this._excludeTables.length > 0) {
|
||||
query = query.where(this.excludeTables(this._excludeTables));
|
||||
}
|
||||
|
||||
const tables = await query.execute();
|
||||
return Promise.all(tables.map(({ name }) => this.#getTableMetadata(name)));
|
||||
}
|
||||
|
||||
async getMetadata(options?: DatabaseMetadataOptions): Promise<DatabaseMetadata> {
|
||||
return {
|
||||
tables: await this.getTables(options),
|
||||
};
|
||||
}
|
||||
|
||||
async #getTableMetadata(table: string): Promise<TableMetadata> {
|
||||
const db = this.#db;
|
||||
|
||||
// Get the SQL that was used to create the table.
|
||||
const tableDefinition = await db
|
||||
.selectFrom("sqlite_master")
|
||||
.where("name", "=", table)
|
||||
.select(["sql", "type"])
|
||||
.$castTo<{ sql: string | undefined; type: string }>()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// Try to find the name of the column that has `autoincrement` 🤦
|
||||
const autoIncrementCol = tableDefinition.sql
|
||||
?.split(/[\(\),]/)
|
||||
?.find((it) => it.toLowerCase().includes("autoincrement"))
|
||||
?.trimStart()
|
||||
?.split(/\s+/)?.[0]
|
||||
?.replace(/["`]/g, "");
|
||||
|
||||
const columns = await db
|
||||
.selectFrom(
|
||||
sql<{
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: 0 | 1;
|
||||
dflt_value: any;
|
||||
}>`pragma_table_info(${table})`.as("table_info"),
|
||||
)
|
||||
.select(["name", "type", "notnull", "dflt_value"])
|
||||
.orderBy("cid")
|
||||
.execute();
|
||||
|
||||
return {
|
||||
name: table,
|
||||
isView: tableDefinition.type === "view",
|
||||
columns: columns.map((col) => ({
|
||||
name: col.name,
|
||||
dataType: col.type,
|
||||
isNullable: !col.notnull,
|
||||
isAutoIncrementing: col.name === autoIncrementCol,
|
||||
hasDefaultValue: col.dflt_value != null,
|
||||
comment: undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
31
app/src/data/connection/SqliteLocalConnection.ts
Normal file
31
app/src/data/connection/SqliteLocalConnection.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { DatabaseIntrospector, SqliteDatabase } from "kysely";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { DeserializeJsonValuesPlugin } from "../plugins/DeserializeJsonValuesPlugin";
|
||||
import { SqliteConnection } from "./SqliteConnection";
|
||||
import { SqliteIntrospector } from "./SqliteIntrospector";
|
||||
|
||||
class CustomSqliteDialect extends SqliteDialect {
|
||||
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
|
||||
return new SqliteIntrospector(db, {
|
||||
excludeTables: ["test_table"]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteLocalConnection extends SqliteConnection {
|
||||
constructor(private database: SqliteDatabase) {
|
||||
const plugins = [new DeserializeJsonValuesPlugin()];
|
||||
const kysely = new Kysely({
|
||||
dialect: new CustomSqliteDialect({ database }),
|
||||
plugins
|
||||
//log: ["query"],
|
||||
});
|
||||
|
||||
super(kysely);
|
||||
this.plugins = plugins;
|
||||
}
|
||||
|
||||
override supportsIndices(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
83
app/src/data/data-schema.ts
Normal file
83
app/src/data/data-schema.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
|
||||
import {
|
||||
FieldClassMap,
|
||||
RelationClassMap,
|
||||
RelationFieldClassMap,
|
||||
entityConfigSchema,
|
||||
entityTypes
|
||||
} from "data";
|
||||
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
|
||||
|
||||
export const FIELDS = {
|
||||
...FieldClassMap,
|
||||
...RelationFieldClassMap,
|
||||
media: { schema: mediaFieldConfigSchema, field: MediaField }
|
||||
};
|
||||
export type FieldType = keyof typeof FIELDS;
|
||||
|
||||
export const RELATIONS = RelationClassMap;
|
||||
|
||||
export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => {
|
||||
return Type.Object(
|
||||
{
|
||||
type: Type.Const(name, { default: name, readOnly: true }),
|
||||
config: Type.Optional(field.schema)
|
||||
},
|
||||
{
|
||||
title: name
|
||||
}
|
||||
);
|
||||
});
|
||||
export const fieldsSchema = Type.Union(Object.values(fieldsSchemaObject));
|
||||
export const entityFields = StringRecord(fieldsSchema);
|
||||
export type TAppDataField = Static<typeof fieldsSchema>;
|
||||
export type TAppDataEntityFields = Static<typeof entityFields>;
|
||||
|
||||
export const entitiesSchema = Type.Object({
|
||||
//name: Type.String(),
|
||||
type: Type.Optional(Type.String({ enum: entityTypes, default: "regular", readOnly: true })),
|
||||
config: Type.Optional(entityConfigSchema),
|
||||
fields: Type.Optional(entityFields)
|
||||
});
|
||||
export type TAppDataEntity = Static<typeof entitiesSchema>;
|
||||
|
||||
export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => {
|
||||
return Type.Object(
|
||||
{
|
||||
type: Type.Const(name, { default: name, readOnly: true }),
|
||||
source: Type.String(),
|
||||
target: Type.String(),
|
||||
config: Type.Optional(relationClass.schema)
|
||||
},
|
||||
{
|
||||
title: name
|
||||
}
|
||||
);
|
||||
});
|
||||
export type TAppDataRelation = Static<(typeof relationsSchema)[number]>;
|
||||
|
||||
export const indicesSchema = Type.Object(
|
||||
{
|
||||
entity: Type.String(),
|
||||
fields: Type.Array(Type.String(), { minItems: 1 }),
|
||||
//name: Type.Optional(Type.String()),
|
||||
unique: Type.Optional(Type.Boolean({ default: false }))
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export const dataConfigSchema = Type.Object(
|
||||
{
|
||||
basepath: Type.Optional(Type.String({ default: "/api/data" })),
|
||||
entities: Type.Optional(StringRecord(entitiesSchema, { default: {} })),
|
||||
relations: Type.Optional(StringRecord(Type.Union(relationsSchema), { default: {} })),
|
||||
indices: Type.Optional(StringRecord(indicesSchema, { default: {} }))
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type AppDataConfig = Static<typeof dataConfigSchema>;
|
||||
238
app/src/data/entities/Entity.ts
Normal file
238
app/src/data/entities/Entity.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { config } from "core";
|
||||
import {
|
||||
type Static,
|
||||
StringEnum,
|
||||
Type,
|
||||
parse,
|
||||
snakeToPascalWithSpaces,
|
||||
transformObject
|
||||
} from "core/utils";
|
||||
import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields";
|
||||
|
||||
// @todo: entity must be migrated to typebox
|
||||
export const entityConfigSchema = Type.Object(
|
||||
{
|
||||
name: Type.Optional(Type.String()),
|
||||
name_singular: Type.Optional(Type.String()),
|
||||
description: Type.Optional(Type.String()),
|
||||
sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })),
|
||||
sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" }))
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type EntityConfig = Static<typeof entityConfigSchema>;
|
||||
|
||||
export type EntityData = Record<string, any>;
|
||||
export type EntityJSON = ReturnType<Entity["toJSON"]>;
|
||||
|
||||
/**
|
||||
* regular: normal defined entity
|
||||
* system: generated by the system, e.g. "users" from auth
|
||||
* generated: result of a relation, e.g. many-to-many relation's connection entity
|
||||
*/
|
||||
export const entityTypes = ["regular", "system", "generated"] as const;
|
||||
export type TEntityType = (typeof entityTypes)[number];
|
||||
|
||||
/**
|
||||
* @todo: add check for adding fields (primary and relation not allowed)
|
||||
* @todo: add option to disallow api deletes (or api actions in general)
|
||||
*/
|
||||
export class Entity<
|
||||
EntityName extends string = string,
|
||||
Fields extends Record<string, Field<any, any, any>> = Record<string, Field<any, any, any>>
|
||||
> {
|
||||
readonly #_name!: EntityName;
|
||||
readonly #_fields!: Fields; // only for types
|
||||
|
||||
readonly name: string;
|
||||
readonly fields: Field[];
|
||||
readonly config: EntityConfig;
|
||||
protected data: EntityData[] | undefined;
|
||||
readonly type: TEntityType = "regular";
|
||||
|
||||
constructor(name: string, fields?: Field[], config?: EntityConfig, type?: TEntityType) {
|
||||
if (typeof name !== "string" || name.length === 0) {
|
||||
throw new Error("Entity name must be a non-empty string");
|
||||
}
|
||||
|
||||
this.name = name;
|
||||
this.config = parse(entityConfigSchema, config || {}) as EntityConfig;
|
||||
|
||||
// add id field if not given
|
||||
// @todo: add test
|
||||
const primary_count = fields?.filter((field) => field instanceof PrimaryField).length ?? 0;
|
||||
if (primary_count > 1) {
|
||||
throw new Error(`Entity "${name}" has more than one primary field`);
|
||||
}
|
||||
this.fields = primary_count === 1 ? [] : [new PrimaryField()];
|
||||
|
||||
if (fields) {
|
||||
fields.forEach((field) => this.addField(field));
|
||||
}
|
||||
|
||||
if (type) this.type = type;
|
||||
}
|
||||
|
||||
static create(args: {
|
||||
name: string;
|
||||
fields?: Field[];
|
||||
config?: EntityConfig;
|
||||
type?: TEntityType;
|
||||
}) {
|
||||
return new Entity(args.name, args.fields, args.config, args.type);
|
||||
}
|
||||
|
||||
// @todo: add test
|
||||
getType(): TEntityType {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
getSelect(alias?: string, context?: TActionContext | TRenderContext): string[] {
|
||||
return this.getFields()
|
||||
.filter((field) => !field.isHidden(context ?? "read"))
|
||||
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
|
||||
}
|
||||
|
||||
getDefaultSort() {
|
||||
return {
|
||||
by: this.config.sort_field,
|
||||
dir: this.config.sort_dir
|
||||
};
|
||||
}
|
||||
|
||||
getAliasedSelectFrom(
|
||||
select: string[],
|
||||
_alias?: string,
|
||||
context?: TActionContext | TRenderContext
|
||||
): string[] {
|
||||
const alias = _alias ?? this.name;
|
||||
return this.getFields()
|
||||
.filter(
|
||||
(field) =>
|
||||
!field.isVirtual() &&
|
||||
!field.isHidden(context ?? "read") &&
|
||||
select.includes(field.name)
|
||||
)
|
||||
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
|
||||
}
|
||||
|
||||
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
|
||||
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
|
||||
}
|
||||
|
||||
getRequiredFields(): Field[] {
|
||||
return this.getFields().filter((field) => field.isRequired());
|
||||
}
|
||||
|
||||
getDefaultObject(): EntityData {
|
||||
return this.getFields().reduce((acc, field) => {
|
||||
if (field.hasDefault()) {
|
||||
acc[field.name] = field.getDefault();
|
||||
}
|
||||
return acc;
|
||||
}, {} as EntityData);
|
||||
}
|
||||
|
||||
getField(name: string): Field | undefined {
|
||||
return this.fields.find((field) => field.name === name);
|
||||
}
|
||||
|
||||
__experimental_replaceField(name: string, field: Field) {
|
||||
const index = this.fields.findIndex((f) => f.name === name);
|
||||
if (index === -1) {
|
||||
throw new Error(`Field "${name}" not found on entity "${this.name}"`);
|
||||
}
|
||||
|
||||
this.fields[index] = field;
|
||||
}
|
||||
|
||||
getPrimaryField(): PrimaryField {
|
||||
return this.fields[0] as PrimaryField;
|
||||
}
|
||||
|
||||
id(): PrimaryField {
|
||||
return this.getPrimaryField();
|
||||
}
|
||||
|
||||
get label(): string {
|
||||
return snakeToPascalWithSpaces(this.config.name ?? this.name);
|
||||
}
|
||||
|
||||
field(name: string): Field | undefined {
|
||||
return this.getField(name);
|
||||
}
|
||||
|
||||
getFields(include_virtual: boolean = false): Field[] {
|
||||
if (include_virtual) return this.fields;
|
||||
return this.fields.filter((f) => !f.isVirtual());
|
||||
}
|
||||
|
||||
addField(field: Field) {
|
||||
const existing = this.getField(field.name);
|
||||
// make unique name check
|
||||
if (existing) {
|
||||
// @todo: for now adding a graceful method
|
||||
if (JSON.stringify(existing) === JSON.stringify(field)) {
|
||||
/*console.warn(
|
||||
`Field "${field.name}" already exists on entity "${this.name}", but it's the same, so skipping.`,
|
||||
);*/
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Field "${field.name}" already exists on entity "${this.name}"`);
|
||||
}
|
||||
|
||||
this.fields.push(field);
|
||||
}
|
||||
|
||||
__setData(data: EntityData[]) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
isValidData(data: EntityData, context: TActionContext, explain?: boolean): boolean {
|
||||
const fields = this.getFillableFields(context, false);
|
||||
//const fields = this.fields;
|
||||
//console.log("data", data);
|
||||
for (const field of fields) {
|
||||
if (!field.isValid(data[field.name], context)) {
|
||||
console.log("Entity.isValidData:invalid", context, field.name, data[field.name]);
|
||||
if (explain) {
|
||||
throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
toSchema(clean?: boolean): object {
|
||||
const fields = Object.fromEntries(this.fields.map((field) => [field.name, field]));
|
||||
const schema = Type.Object(
|
||||
transformObject(fields, (field) => ({
|
||||
title: field.config.label,
|
||||
$comment: field.config.description,
|
||||
$field: field.type,
|
||||
readOnly: !field.isFillable("update") ? true : undefined,
|
||||
writeOnly: !field.isFillable("create") ? true : undefined,
|
||||
...field.toJsonSchema()
|
||||
}))
|
||||
);
|
||||
|
||||
return clean ? JSON.parse(JSON.stringify(schema)) : schema;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
//name: this.name,
|
||||
type: this.type,
|
||||
//fields: transformObject(this.fields, (field) => field.toJSON()),
|
||||
fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])),
|
||||
config: this.config
|
||||
};
|
||||
}
|
||||
}
|
||||
266
app/src/data/entities/EntityManager.ts
Normal file
266
app/src/data/entities/EntityManager.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { EventManager } from "core/events";
|
||||
import { sql } from "kysely";
|
||||
import { Connection } from "../connection/Connection";
|
||||
import {
|
||||
EntityNotDefinedException,
|
||||
TransformRetrieveFailedException,
|
||||
UnableToConnectException
|
||||
} from "../errors";
|
||||
import { MutatorEvents, RepositoryEvents } from "../events";
|
||||
import type { EntityIndex } from "../fields/indices/EntityIndex";
|
||||
import type { EntityRelation } from "../relations";
|
||||
import { RelationAccessor } from "../relations/RelationAccessor";
|
||||
import { SchemaManager } from "../schema/SchemaManager";
|
||||
import { Entity } from "./Entity";
|
||||
import { type EntityData, Mutator, Repository } from "./index";
|
||||
|
||||
export class EntityManager<DB> {
|
||||
connection: Connection;
|
||||
|
||||
private _entities: Entity[] = [];
|
||||
private _relations: EntityRelation[] = [];
|
||||
private _indices: EntityIndex[] = [];
|
||||
private _schema?: SchemaManager;
|
||||
readonly emgr: EventManager<typeof EntityManager.Events>;
|
||||
static readonly Events = { ...MutatorEvents, ...RepositoryEvents };
|
||||
|
||||
constructor(
|
||||
entities: Entity[],
|
||||
connection: Connection,
|
||||
relations: EntityRelation[] = [],
|
||||
indices: EntityIndex[] = [],
|
||||
emgr?: EventManager<any>
|
||||
) {
|
||||
// add entities & relations
|
||||
entities.forEach((entity) => this.addEntity(entity));
|
||||
relations.forEach((relation) => this.addRelation(relation));
|
||||
indices.forEach((index) => this.addIndex(index));
|
||||
|
||||
if (!(connection instanceof Connection)) {
|
||||
throw new UnableToConnectException("");
|
||||
}
|
||||
|
||||
this.connection = connection;
|
||||
this.emgr = emgr ?? new EventManager();
|
||||
//console.log("registering events", EntityManager.Events);
|
||||
this.emgr.registerEvents(EntityManager.Events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forks the EntityManager without the EventManager.
|
||||
* This is useful when used inside an event handler.
|
||||
*/
|
||||
fork(): EntityManager<DB> {
|
||||
return new EntityManager(this._entities, this.connection, this._relations, this._indices);
|
||||
}
|
||||
|
||||
get entities(): Entity[] {
|
||||
return this._entities;
|
||||
}
|
||||
|
||||
get relations(): RelationAccessor {
|
||||
return new RelationAccessor(this._relations);
|
||||
}
|
||||
|
||||
get indices(): EntityIndex[] {
|
||||
return this._indices;
|
||||
}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
const res = await sql`SELECT 1`.execute(this.connection.kysely);
|
||||
return res.rows.length > 0;
|
||||
}
|
||||
|
||||
addEntity(entity: Entity) {
|
||||
const existing = this.entities.find((e) => e.name === entity.name);
|
||||
// check if already exists by name
|
||||
if (existing) {
|
||||
// @todo: for now adding a graceful method
|
||||
if (JSON.stringify(existing) === JSON.stringify(entity)) {
|
||||
//console.warn(`Entity "${entity.name}" already exists, but it's the same, so skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Entity "${entity.name}" already exists`);
|
||||
}
|
||||
|
||||
this.entities.push(entity);
|
||||
}
|
||||
|
||||
entity(name: string): Entity {
|
||||
const entity = this.entities.find((e) => e.name === name);
|
||||
if (!entity) {
|
||||
throw new EntityNotDefinedException(name);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
hasEntity(entity: string): boolean;
|
||||
hasEntity(entity: Entity): boolean;
|
||||
hasEntity(nameOrEntity: string | Entity): boolean {
|
||||
const name = typeof nameOrEntity === "string" ? nameOrEntity : nameOrEntity.name;
|
||||
return this.entities.some((e) => e.name === name);
|
||||
}
|
||||
|
||||
hasIndex(index: string): boolean;
|
||||
hasIndex(index: EntityIndex): boolean;
|
||||
hasIndex(nameOrIndex: string | EntityIndex): boolean {
|
||||
const name = typeof nameOrIndex === "string" ? nameOrIndex : nameOrIndex.name;
|
||||
return this.indices.some((e) => e.name === name);
|
||||
}
|
||||
|
||||
addRelation(relation: EntityRelation) {
|
||||
// check if entities are registered
|
||||
if (!this.entity(relation.source.entity.name) || !this.entity(relation.target.entity.name)) {
|
||||
throw new Error("Relation source or target entity not found");
|
||||
}
|
||||
|
||||
// @todo: potentially add name to relation in order to have multiple
|
||||
const found = this._relations.find((r) => {
|
||||
const equalSourceTarget =
|
||||
r.source.entity.name === relation.source.entity.name &&
|
||||
r.target.entity.name === relation.target.entity.name;
|
||||
const equalReferences =
|
||||
r.source.reference === relation.source.reference &&
|
||||
r.target.reference === relation.target.reference;
|
||||
|
||||
return (
|
||||
//r.type === relation.type && // ignore type for now
|
||||
equalSourceTarget && equalReferences
|
||||
);
|
||||
});
|
||||
|
||||
if (found) {
|
||||
throw new Error(
|
||||
`Relation "${relation.type}" between "${relation.source.entity.name}" ` +
|
||||
`and "${relation.target.entity.name}" already exists`
|
||||
);
|
||||
}
|
||||
|
||||
this._relations.push(relation);
|
||||
relation.initialize(this);
|
||||
}
|
||||
|
||||
relationsOf(entity_name: string): EntityRelation[] {
|
||||
return this.relations.relationsOf(this.entity(entity_name));
|
||||
}
|
||||
|
||||
relationOf(entity_name: string, reference: string): EntityRelation | undefined {
|
||||
return this.relations.relationOf(this.entity(entity_name), reference);
|
||||
}
|
||||
|
||||
hasRelations(entity_name: string): boolean {
|
||||
return this.relations.hasRelations(this.entity(entity_name));
|
||||
}
|
||||
|
||||
relatedEntitiesOf(entity_name: string): Entity[] {
|
||||
return this.relations.relatedEntitiesOf(this.entity(entity_name));
|
||||
}
|
||||
|
||||
relationReferencesOf(entity_name: string): string[] {
|
||||
return this.relations.relationReferencesOf(this.entity(entity_name));
|
||||
}
|
||||
|
||||
repository(_entity: Entity | string) {
|
||||
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
|
||||
return new Repository(this, entity, this.emgr);
|
||||
}
|
||||
|
||||
repo<E extends Entity>(
|
||||
_entity: E
|
||||
): Repository<
|
||||
DB,
|
||||
E extends Entity<infer Name> ? (Name extends keyof DB ? Name : never) : never
|
||||
> {
|
||||
return new Repository(this, _entity, this.emgr);
|
||||
}
|
||||
|
||||
_repo<TB extends keyof DB>(_entity: TB): Repository<DB, TB> {
|
||||
const entity = this.entity(_entity as any);
|
||||
return new Repository(this, entity, this.emgr);
|
||||
}
|
||||
|
||||
mutator(_entity: Entity | string) {
|
||||
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
|
||||
return new Mutator(this, entity, this.emgr);
|
||||
}
|
||||
|
||||
addIndex(index: EntityIndex, force = false) {
|
||||
// check if already exists by name
|
||||
if (this.indices.find((e) => e.name === index.name)) {
|
||||
if (force) {
|
||||
throw new Error(`Index "${index.name}" already exists`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._indices.push(index);
|
||||
}
|
||||
|
||||
getIndicesOf(_entity: Entity | string): EntityIndex[] {
|
||||
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
|
||||
return this.indices.filter((index) => index.entity.name === entity.name);
|
||||
}
|
||||
|
||||
schema() {
|
||||
if (!this._schema) {
|
||||
this._schema = new SchemaManager(this);
|
||||
}
|
||||
|
||||
return this._schema;
|
||||
}
|
||||
|
||||
// @todo: centralize and add tests
|
||||
hydrate(entity_name: string, _data: EntityData[]) {
|
||||
const entity = this.entity(entity_name);
|
||||
const data: EntityData[] = [];
|
||||
|
||||
for (const row of _data) {
|
||||
for (let [key, value] of Object.entries(row)) {
|
||||
const field = entity.getField(key);
|
||||
|
||||
if (!field || field.isVirtual()) {
|
||||
// if relation, use related entity to hydrate
|
||||
const relation = this.relationOf(entity_name, key);
|
||||
if (relation) {
|
||||
if (!value) continue;
|
||||
|
||||
value = relation.hydrate(key, Array.isArray(value) ? value : [value], this);
|
||||
row[key] = value;
|
||||
continue;
|
||||
} else if (field?.isVirtual()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Field "${key}" not found on entity "${entity.name}"`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (value === null && field.hasDefault()) {
|
||||
row[key] = field.getDefault();
|
||||
}
|
||||
|
||||
row[key] = field.transformRetrieve(value as any);
|
||||
} catch (e: any) {
|
||||
throw new TransformRetrieveFailedException(
|
||||
`"${field.type}" field "${key}" on entity "${entity.name}": ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
entities: Object.fromEntries(this.entities.map((e) => [e.name, e.toJSON()])),
|
||||
relations: Object.fromEntries(this.relations.all.map((r) => [r.getName(), r.toJSON()])),
|
||||
//relations: this.relations.all.map((r) => r.toJSON()),
|
||||
indices: Object.fromEntries(this.indices.map((i) => [i.name, i.toJSON()]))
|
||||
};
|
||||
}
|
||||
}
|
||||
270
app/src/data/entities/Mutator.ts
Normal file
270
app/src/data/entities/Mutator.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
|
||||
import { type TActionContext, WhereBuilder } from "..";
|
||||
import type { Entity, EntityData, EntityManager } from "../entities";
|
||||
import { InvalidSearchParamsException } from "../errors";
|
||||
import { MutatorEvents } from "../events";
|
||||
import { RelationMutator } from "../relations";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
|
||||
type MutatorQB =
|
||||
| InsertQueryBuilder<any, any, any>
|
||||
| UpdateQueryBuilder<any, any, any, any>
|
||||
| DeleteQueryBuilder<any, any, any>;
|
||||
|
||||
type MutatorUpdateOrDelete =
|
||||
| UpdateQueryBuilder<any, any, any, any>
|
||||
| DeleteQueryBuilder<any, any, any>;
|
||||
|
||||
export type MutatorResponse<T = EntityData[]> = {
|
||||
entity: Entity;
|
||||
sql: string;
|
||||
parameters: any[];
|
||||
result: EntityData[];
|
||||
data: T;
|
||||
};
|
||||
|
||||
export class Mutator<DB> implements EmitsEvents {
|
||||
em: EntityManager<DB>;
|
||||
entity: Entity;
|
||||
static readonly Events = MutatorEvents;
|
||||
emgr: EventManager<typeof MutatorEvents>;
|
||||
|
||||
// @todo: current hacky workaround to disable creation of system entities
|
||||
__unstable_disable_system_entity_creation = true;
|
||||
__unstable_toggleSystemEntityCreation(value: boolean) {
|
||||
this.__unstable_disable_system_entity_creation = value;
|
||||
}
|
||||
|
||||
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
|
||||
this.em = em;
|
||||
this.entity = entity;
|
||||
this.emgr = emgr ?? new EventManager(MutatorEvents);
|
||||
}
|
||||
|
||||
private get conn() {
|
||||
return this.em.connection.kysely;
|
||||
}
|
||||
|
||||
async getValidatedData(data: EntityData, context: TActionContext): Promise<EntityData> {
|
||||
const entity = this.entity;
|
||||
if (!context) {
|
||||
throw new Error("Context must be provided for validation");
|
||||
}
|
||||
|
||||
const keys = Object.keys(data);
|
||||
const validatedData: EntityData = {};
|
||||
|
||||
// get relational references/keys
|
||||
const relationMutator = new RelationMutator(entity, this.em);
|
||||
const relational_keys = relationMutator.getRelationalKeys();
|
||||
|
||||
for (const key of keys) {
|
||||
if (relational_keys.includes(key)) {
|
||||
const result = await relationMutator.persistRelation(key, data[key]);
|
||||
|
||||
// if relation field (include key and value in validatedData)
|
||||
if (Array.isArray(result)) {
|
||||
//console.log("--- (instructions)", result);
|
||||
const [relation_key, relation_value] = result;
|
||||
validatedData[relation_key] = relation_value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = entity.getField(key);
|
||||
if (!field) {
|
||||
throw new Error(
|
||||
`Field "${key}" not found on entity "${entity.name}". Fields: ${entity
|
||||
.getFillableFields()
|
||||
.map((f) => f.name)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// we should never get here, but just to be sure (why?)
|
||||
if (!field.isFillable(context)) {
|
||||
throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`);
|
||||
}
|
||||
|
||||
validatedData[key] = await field.transformPersist(data[key], this.em, context);
|
||||
}
|
||||
|
||||
if (Object.keys(validatedData).length === 0) {
|
||||
throw new Error(`No data left to update "${entity.name}"`);
|
||||
}
|
||||
|
||||
return validatedData;
|
||||
}
|
||||
|
||||
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
|
||||
const entity = this.entity;
|
||||
const { sql, parameters } = qb.compile();
|
||||
//console.log("mutatoar:exec", sql, parameters);
|
||||
const result = await qb.execute();
|
||||
|
||||
const data = this.em.hydrate(entity.name, result) as EntityData[];
|
||||
|
||||
return {
|
||||
entity,
|
||||
sql,
|
||||
parameters: [...parameters],
|
||||
result: result,
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
protected async single(qb: MutatorQB): Promise<MutatorResponse<EntityData>> {
|
||||
const { data, ...response } = await this.many(qb);
|
||||
return { ...response, data: data[0]! };
|
||||
}
|
||||
|
||||
async insertOne(data: EntityData): Promise<MutatorResponse<EntityData>> {
|
||||
const entity = this.entity;
|
||||
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
|
||||
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
|
||||
}
|
||||
|
||||
// @todo: establish the original order from "data"
|
||||
const validatedData = {
|
||||
...entity.getDefaultObject(),
|
||||
...(await this.getValidatedData(data, "create"))
|
||||
};
|
||||
|
||||
await this.emgr.emit(new Mutator.Events.MutatorInsertBefore({ entity, data: validatedData }));
|
||||
|
||||
// check if required fields are present
|
||||
const required = entity.getRequiredFields();
|
||||
for (const field of required) {
|
||||
if (
|
||||
typeof validatedData[field.name] === "undefined" ||
|
||||
validatedData[field.name] === null
|
||||
) {
|
||||
throw new Error(`Field "${field.name}" is required`);
|
||||
}
|
||||
}
|
||||
|
||||
const query = this.conn
|
||||
.insertInto(entity.name)
|
||||
.values(validatedData)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
const res = await this.single(query);
|
||||
|
||||
await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data }));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async updateOne(id: PrimaryFieldType, data: EntityData): Promise<MutatorResponse<EntityData>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for update");
|
||||
}
|
||||
|
||||
const validatedData = await this.getValidatedData(data, "update");
|
||||
|
||||
await this.emgr.emit(
|
||||
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData })
|
||||
);
|
||||
|
||||
const query = this.conn
|
||||
.updateTable(entity.name)
|
||||
.set(validatedData)
|
||||
.where(entity.id().name, "=", id)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
const res = await this.single(query);
|
||||
|
||||
await this.emgr.emit(
|
||||
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<EntityData>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for deletion");
|
||||
}
|
||||
|
||||
await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id }));
|
||||
|
||||
const query = this.conn
|
||||
.deleteFrom(entity.name)
|
||||
.where(entity.id().name, "=", id)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
const res = await this.single(query);
|
||||
|
||||
await this.emgr.emit(
|
||||
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private getValidOptions(options?: Partial<RepoQuery>): Partial<RepoQuery> {
|
||||
const entity = this.entity;
|
||||
const validated: Partial<RepoQuery> = {};
|
||||
|
||||
if (options?.where) {
|
||||
// @todo: add tests for aliased fields in where
|
||||
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
|
||||
return typeof entity.getField(field) === "undefined";
|
||||
});
|
||||
|
||||
if (invalid.length > 0) {
|
||||
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`);
|
||||
}
|
||||
|
||||
validated.where = options.where;
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
private appendWhere<QB extends MutatorUpdateOrDelete>(qb: QB, _where?: RepoQuery["where"]): QB {
|
||||
const entity = this.entity;
|
||||
|
||||
const alias = entity.name;
|
||||
const aliased = (field: string) => `${alias}.${field}`;
|
||||
|
||||
// add where if present
|
||||
if (_where) {
|
||||
// @todo: add tests for aliased fields in where
|
||||
const invalid = WhereBuilder.getPropertyNames(_where).filter((field) => {
|
||||
return typeof entity.getField(field) === "undefined";
|
||||
});
|
||||
|
||||
if (invalid.length > 0) {
|
||||
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`);
|
||||
}
|
||||
|
||||
return WhereBuilder.addClause(qb, _where);
|
||||
}
|
||||
|
||||
return qb;
|
||||
}
|
||||
|
||||
// @todo: decide whether entries should be deleted all at once or one by one (for events)
|
||||
async deleteMany(where?: RepoQuery["where"]): Promise<MutatorResponse<EntityData>> {
|
||||
const entity = this.entity;
|
||||
|
||||
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
|
||||
entity.getSelect()
|
||||
);
|
||||
|
||||
//await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id }));
|
||||
|
||||
const res = await this.many(qb);
|
||||
|
||||
/*await this.emgr.emit(
|
||||
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
|
||||
);*/
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
6
app/src/data/entities/index.ts
Normal file
6
app/src/data/entities/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./Entity";
|
||||
export * from "./EntityManager";
|
||||
export * from "./Mutator";
|
||||
export * from "./query/Repository";
|
||||
export * from "./query/WhereBuilder";
|
||||
export * from "./query/WithBuilder";
|
||||
51
app/src/data/entities/query/JoinBuilder.ts
Normal file
51
app/src/data/entities/query/JoinBuilder.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ManyToManyRelation, ManyToOneRelation } from "../../relations";
|
||||
import type { Entity } from "../Entity";
|
||||
import type { EntityManager } from "../EntityManager";
|
||||
import type { RepositoryQB } from "./Repository";
|
||||
|
||||
export class JoinBuilder {
|
||||
private static buildClause(
|
||||
em: EntityManager<any>,
|
||||
qb: RepositoryQB,
|
||||
entity: Entity,
|
||||
withString: string,
|
||||
) {
|
||||
const relation = em.relationOf(entity.name, withString);
|
||||
if (!relation) {
|
||||
throw new Error(`Relation "${withString}" not found`);
|
||||
}
|
||||
|
||||
return relation.buildJoin(entity, qb, withString);
|
||||
}
|
||||
|
||||
// @todo: returns multiple on manytomany (edit: so?)
|
||||
static getJoinedEntityNames(em: EntityManager<any>, entity: Entity, joins: string[]): string[] {
|
||||
return joins.flatMap((join) => {
|
||||
const relation = em.relationOf(entity.name, join);
|
||||
if (!relation) {
|
||||
throw new Error(`Relation "${join}" not found`);
|
||||
}
|
||||
|
||||
const other = relation.other(entity);
|
||||
|
||||
if (relation instanceof ManyToOneRelation) {
|
||||
return [other.entity.name];
|
||||
} else if (relation instanceof ManyToManyRelation) {
|
||||
return [other.entity.name, relation.connectionEntity.name];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
static addClause(em: EntityManager<any>, qb: RepositoryQB, entity: Entity, joins: string[]) {
|
||||
if (joins.length === 0) return qb;
|
||||
|
||||
let newQb = qb;
|
||||
for (const entry of joins) {
|
||||
newQb = JoinBuilder.buildClause(em, newQb, entity, entry);
|
||||
}
|
||||
|
||||
return newQb;
|
||||
}
|
||||
}
|
||||
407
app/src/data/entities/query/Repository.ts
Normal file
407
app/src/data/entities/query/Repository.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import { type SelectQueryBuilder, sql } from "kysely";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { InvalidSearchParamsException } from "../../errors";
|
||||
import { MutatorEvents, RepositoryEvents, RepositoryFindManyBefore } from "../../events";
|
||||
import { type RepoQuery, defaultQuerySchema } from "../../server/data-query-impl";
|
||||
import {
|
||||
type Entity,
|
||||
type EntityData,
|
||||
type EntityManager,
|
||||
WhereBuilder,
|
||||
WithBuilder
|
||||
} from "../index";
|
||||
import { JoinBuilder } from "./JoinBuilder";
|
||||
|
||||
export type RepositoryQB = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
export type RepositoryRawResponse = {
|
||||
sql: string;
|
||||
parameters: any[];
|
||||
result: EntityData[];
|
||||
};
|
||||
export type RepositoryResponse<T = EntityData[]> = RepositoryRawResponse & {
|
||||
entity: Entity;
|
||||
data: T;
|
||||
meta: {
|
||||
total: number;
|
||||
count: number;
|
||||
items: number;
|
||||
time?: number;
|
||||
query?: {
|
||||
sql: string;
|
||||
parameters: readonly any[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type RepositoryCountResponse = RepositoryRawResponse & {
|
||||
count: number;
|
||||
};
|
||||
export type RepositoryExistsResponse = RepositoryRawResponse & {
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEvents {
|
||||
em: EntityManager<DB>;
|
||||
entity: Entity;
|
||||
static readonly Events = RepositoryEvents;
|
||||
emgr: EventManager<typeof Repository.Events>;
|
||||
|
||||
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
|
||||
this.em = em;
|
||||
this.entity = entity;
|
||||
this.emgr = emgr ?? new EventManager(MutatorEvents);
|
||||
}
|
||||
|
||||
private cloneFor(entity: Entity) {
|
||||
return new Repository(this.em, entity, this.emgr);
|
||||
}
|
||||
|
||||
private get conn() {
|
||||
return this.em.connection.kysely;
|
||||
}
|
||||
|
||||
private getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
|
||||
const entity = this.entity;
|
||||
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
|
||||
const validated = {
|
||||
...cloneDeep(defaultQuerySchema),
|
||||
sort: entity.getDefaultSort(),
|
||||
select: entity.getSelect()
|
||||
};
|
||||
//console.log("validated", validated);
|
||||
|
||||
if (!options) return validated;
|
||||
|
||||
if (options.sort) {
|
||||
if (!validated.select.includes(options.sort.by)) {
|
||||
throw new InvalidSearchParamsException(`Invalid sort field "${options.sort.by}"`);
|
||||
}
|
||||
if (!["asc", "desc"].includes(options.sort.dir)) {
|
||||
throw new InvalidSearchParamsException(`Invalid sort direction "${options.sort.dir}"`);
|
||||
}
|
||||
|
||||
validated.sort = options.sort;
|
||||
}
|
||||
|
||||
if (options.select && options.select.length > 0) {
|
||||
const invalid = options.select.filter((field) => !validated.select.includes(field));
|
||||
|
||||
if (invalid.length > 0) {
|
||||
throw new InvalidSearchParamsException(
|
||||
`Invalid select field(s): ${invalid.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
validated.select = options.select;
|
||||
}
|
||||
|
||||
if (options.with && options.with.length > 0) {
|
||||
for (const entry of options.with) {
|
||||
const related = this.em.relationOf(entity.name, entry);
|
||||
if (!related) {
|
||||
throw new InvalidSearchParamsException(
|
||||
`WITH: "${entry}" is not a relation of "${entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
validated.with.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.join && options.join.length > 0) {
|
||||
for (const entry of options.join) {
|
||||
const related = this.em.relationOf(entity.name, entry);
|
||||
if (!related) {
|
||||
throw new InvalidSearchParamsException(
|
||||
`JOIN: "${entry}" is not a relation of "${entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
validated.join.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.where) {
|
||||
// @todo: auto-alias base entity when using joins! otherwise "id" is ambiguous
|
||||
const aliases = [entity.name];
|
||||
if (validated.join.length > 0) {
|
||||
aliases.push(...JoinBuilder.getJoinedEntityNames(this.em, entity, validated.join));
|
||||
}
|
||||
|
||||
// @todo: add tests for aliased fields in where
|
||||
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
|
||||
if (field.includes(".")) {
|
||||
const [alias, prop] = field.split(".") as [string, string];
|
||||
if (!aliases.includes(alias)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !this.em.entity(alias).getField(prop);
|
||||
}
|
||||
|
||||
return typeof entity.getField(field) === "undefined";
|
||||
});
|
||||
|
||||
if (invalid.length > 0) {
|
||||
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`);
|
||||
}
|
||||
|
||||
validated.where = options.where;
|
||||
}
|
||||
|
||||
// pass unfiltered
|
||||
if (options.limit) validated.limit = options.limit;
|
||||
if (options.offset) validated.offset = options.offset;
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
|
||||
const entity = this.entity;
|
||||
const compiled = qb.compile();
|
||||
/*const { sql, parameters } = qb.compile();
|
||||
console.log("many", sql, parameters);*/
|
||||
|
||||
const start = performance.now();
|
||||
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
|
||||
const countQuery = qb
|
||||
.clearSelect()
|
||||
.select(selector())
|
||||
.clearLimit()
|
||||
.clearOffset()
|
||||
.clearGroupBy()
|
||||
.clearOrderBy();
|
||||
const totalQuery = this.conn.selectFrom(entity.name).select(selector("total"));
|
||||
|
||||
try {
|
||||
const [_count, _total, result] = await this.em.connection.batchQuery([
|
||||
countQuery,
|
||||
totalQuery,
|
||||
qb
|
||||
]);
|
||||
//console.log("result", { _count, _total });
|
||||
|
||||
const time = Number.parseFloat((performance.now() - start).toFixed(2));
|
||||
const data = this.em.hydrate(entity.name, result);
|
||||
|
||||
return {
|
||||
entity,
|
||||
sql: compiled.sql,
|
||||
parameters: [...compiled.parameters],
|
||||
result,
|
||||
data,
|
||||
meta: {
|
||||
total: _total[0]?.total ?? 0,
|
||||
count: _count[0]?.count ?? 0, // @todo: better graceful method
|
||||
items: result.length,
|
||||
time,
|
||||
query: { sql: compiled.sql, parameters: compiled.parameters }
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("many error", e, compiled);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
protected async single(
|
||||
qb: RepositoryQB,
|
||||
options: RepoQuery
|
||||
): Promise<RepositoryResponse<EntityData>> {
|
||||
await this.emgr.emit(
|
||||
new Repository.Events.RepositoryFindOneBefore({ entity: this.entity, options })
|
||||
);
|
||||
|
||||
const { data, ...response } = await this.performQuery(qb);
|
||||
|
||||
await this.emgr.emit(
|
||||
new Repository.Events.RepositoryFindOneAfter({
|
||||
entity: this.entity,
|
||||
options,
|
||||
data: data[0]!
|
||||
})
|
||||
);
|
||||
|
||||
return { ...response, data: data[0]! };
|
||||
}
|
||||
|
||||
private buildQuery(
|
||||
_options?: Partial<RepoQuery>,
|
||||
exclude_options: (keyof RepoQuery)[] = []
|
||||
): { qb: RepositoryQB; options: RepoQuery } {
|
||||
const entity = this.entity;
|
||||
const options = this.getValidOptions(_options);
|
||||
|
||||
const alias = entity.name;
|
||||
const aliased = (field: string) => `${alias}.${field}`;
|
||||
let qb = this.conn
|
||||
.selectFrom(entity.name)
|
||||
.select(entity.getAliasedSelectFrom(options.select, alias));
|
||||
|
||||
//console.log("build query options", options);
|
||||
if (!exclude_options.includes("with") && options.with) {
|
||||
qb = WithBuilder.addClause(this.em, qb, entity, options.with);
|
||||
}
|
||||
|
||||
if (!exclude_options.includes("join") && options.join) {
|
||||
qb = JoinBuilder.addClause(this.em, qb, entity, options.join);
|
||||
}
|
||||
|
||||
// add where if present
|
||||
if (!exclude_options.includes("where") && options.where) {
|
||||
qb = WhereBuilder.addClause(qb, options.where);
|
||||
}
|
||||
|
||||
if (!exclude_options.includes("limit")) qb = qb.limit(options.limit);
|
||||
if (!exclude_options.includes("offset")) qb = qb.offset(options.offset);
|
||||
|
||||
// sorting
|
||||
if (!exclude_options.includes("sort")) {
|
||||
qb = qb.orderBy(aliased(options.sort.by), options.sort.dir);
|
||||
}
|
||||
|
||||
return { qb, options };
|
||||
}
|
||||
|
||||
async findId(
|
||||
id: PrimaryFieldType,
|
||||
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
|
||||
): Promise<RepositoryResponse<DB[TB]>> {
|
||||
const { qb, options } = this.buildQuery(
|
||||
{
|
||||
..._options,
|
||||
where: { [this.entity.getPrimaryField().name]: id },
|
||||
limit: 1
|
||||
},
|
||||
["offset", "sort"]
|
||||
);
|
||||
|
||||
return this.single(qb, options) as any;
|
||||
}
|
||||
|
||||
async findOne(
|
||||
where: RepoQuery["where"],
|
||||
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
|
||||
): Promise<RepositoryResponse<DB[TB] | undefined>> {
|
||||
const { qb, options } = this.buildQuery(
|
||||
{
|
||||
..._options,
|
||||
where,
|
||||
limit: 1
|
||||
},
|
||||
["offset", "sort"]
|
||||
);
|
||||
|
||||
return this.single(qb, options) as any;
|
||||
}
|
||||
|
||||
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<DB[TB][]>> {
|
||||
const { qb, options } = this.buildQuery(_options);
|
||||
//console.log("findMany:options", options);
|
||||
|
||||
await this.emgr.emit(
|
||||
new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options })
|
||||
);
|
||||
|
||||
const res = await this.performQuery(qb);
|
||||
|
||||
await this.emgr.emit(
|
||||
new Repository.Events.RepositoryFindManyAfter({
|
||||
entity: this.entity,
|
||||
options,
|
||||
data: res.data
|
||||
})
|
||||
);
|
||||
|
||||
return res as any;
|
||||
}
|
||||
|
||||
// @todo: add unit tests, specially for many to many
|
||||
async findManyByReference(
|
||||
id: PrimaryFieldType,
|
||||
reference: string,
|
||||
_options?: Partial<Omit<RepoQuery, "limit" | "offset">>
|
||||
): Promise<RepositoryResponse<EntityData>> {
|
||||
const entity = this.entity;
|
||||
const listable_relations = this.em.relations.listableRelationsOf(entity);
|
||||
const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
|
||||
|
||||
if (!relation) {
|
||||
throw new Error(
|
||||
`Relation "${reference}" not found or not listable on entity "${entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
const newEntity = relation.other(entity).entity;
|
||||
const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference);
|
||||
if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) {
|
||||
throw new Error(
|
||||
`Invalid reference query for "${reference}" on entity "${newEntity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
const findManyOptions = {
|
||||
..._options,
|
||||
...refQueryOptions,
|
||||
where: {
|
||||
...refQueryOptions.where,
|
||||
..._options?.where
|
||||
}
|
||||
};
|
||||
|
||||
//console.log("findManyOptions", newEntity.name, findManyOptions);
|
||||
return this.cloneFor(newEntity).findMany(findManyOptions);
|
||||
}
|
||||
|
||||
async count(where?: RepoQuery["where"]): Promise<RepositoryCountResponse> {
|
||||
const entity = this.entity;
|
||||
const options = this.getValidOptions({ where });
|
||||
|
||||
const selector = this.conn.fn.count<number>(sql`*`).as("count");
|
||||
let qb = this.conn.selectFrom(entity.name).select(selector);
|
||||
|
||||
// add where if present
|
||||
if (options.where) {
|
||||
qb = WhereBuilder.addClause(qb, options.where);
|
||||
}
|
||||
|
||||
const compiled = qb.compile();
|
||||
const result = await qb.execute();
|
||||
|
||||
return {
|
||||
sql: compiled.sql,
|
||||
parameters: [...compiled.parameters],
|
||||
result,
|
||||
count: result[0]?.count ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
async exists(where: Required<RepoQuery["where"]>): Promise<RepositoryExistsResponse> {
|
||||
const entity = this.entity;
|
||||
const options = this.getValidOptions({ where });
|
||||
|
||||
const selector = this.conn.fn.count<number>(sql`*`).as("count");
|
||||
let qb = this.conn.selectFrom(entity.name).select(selector);
|
||||
|
||||
// add mandatory where
|
||||
qb = WhereBuilder.addClause(qb, options.where);
|
||||
|
||||
// we only need 1
|
||||
qb = qb.limit(1);
|
||||
|
||||
const compiled = qb.compile();
|
||||
//console.log("exists query", compiled.sql, compiled.parameters);
|
||||
const result = await qb.execute();
|
||||
//console.log("result", result);
|
||||
|
||||
return {
|
||||
sql: compiled.sql,
|
||||
parameters: [...compiled.parameters],
|
||||
result,
|
||||
exists: result[0]!.count > 0
|
||||
};
|
||||
}
|
||||
}
|
||||
132
app/src/data/entities/query/WhereBuilder.ts
Normal file
132
app/src/data/entities/query/WhereBuilder.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
type BooleanLike,
|
||||
type FilterQuery,
|
||||
type Primitive,
|
||||
type TExpression,
|
||||
exp,
|
||||
isBooleanLike,
|
||||
isPrimitive,
|
||||
makeValidator
|
||||
} from "core";
|
||||
import type {
|
||||
DeleteQueryBuilder,
|
||||
ExpressionBuilder,
|
||||
ExpressionWrapper,
|
||||
SelectQueryBuilder,
|
||||
UpdateQueryBuilder
|
||||
} from "kysely";
|
||||
import type { RepositoryQB } from "./Repository";
|
||||
|
||||
type Builder = ExpressionBuilder<any, any>;
|
||||
type Wrapper = ExpressionWrapper<any, any, any>;
|
||||
type WhereQb =
|
||||
| SelectQueryBuilder<any, any, any>
|
||||
| UpdateQueryBuilder<any, any, any, any>
|
||||
| DeleteQueryBuilder<any, any, any>;
|
||||
|
||||
function key(e: unknown): string {
|
||||
if (typeof e !== "string") {
|
||||
throw new Error(`Invalid key: ${e}`);
|
||||
}
|
||||
return e as string;
|
||||
}
|
||||
|
||||
const expressions: TExpression<any, any, any>[] = [
|
||||
exp(
|
||||
"$eq",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "=", v)
|
||||
),
|
||||
exp(
|
||||
"$ne",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "!=", v)
|
||||
),
|
||||
exp(
|
||||
"$gt",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), ">", v)
|
||||
),
|
||||
exp(
|
||||
"$gte",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), ">=", v)
|
||||
),
|
||||
exp(
|
||||
"$lt",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "<", v)
|
||||
),
|
||||
exp(
|
||||
"$lte",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "<=", v)
|
||||
),
|
||||
exp(
|
||||
"$isnull",
|
||||
(v: BooleanLike) => isBooleanLike(v),
|
||||
(v, k, eb: Builder) => eb(key(k), v ? "is" : "is not", null)
|
||||
),
|
||||
exp(
|
||||
"$in",
|
||||
(v: any[]) => Array.isArray(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "in", v)
|
||||
),
|
||||
exp(
|
||||
"$notin",
|
||||
(v: any[]) => Array.isArray(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "not in", v)
|
||||
),
|
||||
exp(
|
||||
"$between",
|
||||
(v: [number, number]) => Array.isArray(v) && v.length === 2,
|
||||
(v, k, eb: Builder) => eb.between(key(k), v[0], v[1])
|
||||
),
|
||||
exp(
|
||||
"$like",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "like", String(v).replace(/\*/g, "%"))
|
||||
)
|
||||
];
|
||||
|
||||
export type WhereQuery = FilterQuery<typeof expressions>;
|
||||
|
||||
const validator = makeValidator(expressions);
|
||||
|
||||
export class WhereBuilder {
|
||||
static addClause<QB extends WhereQb>(qb: QB, query: WhereQuery) {
|
||||
if (Object.keys(query).length === 0) {
|
||||
return qb;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return qb.where((eb) => {
|
||||
const fns = validator.build(query, {
|
||||
value_is_kv: true,
|
||||
exp_ctx: eb,
|
||||
convert: true
|
||||
});
|
||||
|
||||
if (fns.$or.length > 0 && fns.$and.length > 0) {
|
||||
return eb.and(fns.$and).or(eb.and(fns.$or));
|
||||
} else if (fns.$or.length > 0) {
|
||||
return eb.or(fns.$or);
|
||||
}
|
||||
|
||||
return eb.and(fns.$and);
|
||||
});
|
||||
}
|
||||
|
||||
static convert(query: WhereQuery): WhereQuery {
|
||||
return validator.convert(query);
|
||||
}
|
||||
|
||||
static getPropertyNames(query: WhereQuery): string[] {
|
||||
const { keys } = validator.build(query, {
|
||||
value_is_kv: true,
|
||||
exp_ctx: () => null,
|
||||
convert: true
|
||||
});
|
||||
return Array.from(keys);
|
||||
}
|
||||
}
|
||||
42
app/src/data/entities/query/WithBuilder.ts
Normal file
42
app/src/data/entities/query/WithBuilder.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Entity, EntityManager, RepositoryQB } from "../../entities";
|
||||
|
||||
export class WithBuilder {
|
||||
private static buildClause(
|
||||
em: EntityManager<any>,
|
||||
qb: RepositoryQB,
|
||||
entity: Entity,
|
||||
withString: string
|
||||
) {
|
||||
const relation = em.relationOf(entity.name, withString);
|
||||
if (!relation) {
|
||||
throw new Error(`Relation "${withString}" not found`);
|
||||
}
|
||||
|
||||
const cardinality = relation.ref(withString).cardinality;
|
||||
//console.log("with--builder", { entity: entity.name, withString, cardinality });
|
||||
|
||||
const fns = em.connection.fn;
|
||||
const jsonFrom = cardinality === 1 ? fns.jsonObjectFrom : fns.jsonArrayFrom;
|
||||
|
||||
if (!jsonFrom) {
|
||||
throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom");
|
||||
}
|
||||
|
||||
try {
|
||||
return relation.buildWith(entity, qb, jsonFrom, withString);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not build "with" relation "${withString}": ${(e as any).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
static addClause(em: EntityManager<any>, qb: RepositoryQB, entity: Entity, withs: string[]) {
|
||||
if (withs.length === 0) return qb;
|
||||
|
||||
let newQb = qb;
|
||||
for (const entry of withs) {
|
||||
newQb = WithBuilder.buildClause(em, newQb, entity, entry);
|
||||
}
|
||||
|
||||
return newQb;
|
||||
}
|
||||
}
|
||||
77
app/src/data/errors.ts
Normal file
77
app/src/data/errors.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Exception } from "core";
|
||||
import type { TypeInvalidError } from "core/utils";
|
||||
import type { Entity } from "./entities";
|
||||
import type { Field } from "./fields";
|
||||
|
||||
export class UnableToConnectException extends Exception {
|
||||
override name = "UnableToConnectException";
|
||||
override code = 500;
|
||||
}
|
||||
|
||||
export class InvalidSearchParamsException extends Exception {
|
||||
override name = "InvalidSearchParamsException";
|
||||
override code = 422;
|
||||
}
|
||||
|
||||
export class TransformRetrieveFailedException extends Exception {
|
||||
override name = "TransformRetrieveFailedException";
|
||||
override code = 422;
|
||||
}
|
||||
|
||||
export class TransformPersistFailedException extends Exception {
|
||||
override name = "TransformPersistFailedException";
|
||||
override code = 422;
|
||||
|
||||
static invalidType(property: string, expected: string, given: any) {
|
||||
const givenValue = typeof given === "object" ? JSON.stringify(given) : given;
|
||||
const message =
|
||||
`Property "${property}" must be of type "${expected}", ` +
|
||||
`"${givenValue}" of type "${typeof given}" given.`;
|
||||
return new TransformPersistFailedException(message);
|
||||
}
|
||||
|
||||
static required(property: string) {
|
||||
return new TransformPersistFailedException(`Property "${property}" is required`);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidFieldConfigException extends Exception {
|
||||
override name = "InvalidFieldConfigException";
|
||||
override code = 400;
|
||||
|
||||
constructor(
|
||||
field: Field<any, any, any>,
|
||||
public given: any,
|
||||
error: TypeInvalidError
|
||||
) {
|
||||
console.error("InvalidFieldConfigException", {
|
||||
given,
|
||||
error: error.firstToString()
|
||||
});
|
||||
super(`Invalid Field config given for field "${field.name}": ${error.firstToString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityNotDefinedException extends Exception {
|
||||
override name = "EntityNotDefinedException";
|
||||
override code = 400;
|
||||
|
||||
constructor(entity?: Entity | string) {
|
||||
if (!entity) {
|
||||
super("Cannot find an entity that is undefined");
|
||||
} else {
|
||||
super(`Entity "${typeof entity !== "string" ? entity.name : entity}" not defined`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityNotFoundException extends Exception {
|
||||
override name = "EntityNotFoundException";
|
||||
override code = 404;
|
||||
|
||||
constructor(entity: Entity | string, id: any) {
|
||||
super(
|
||||
`Entity "${typeof entity !== "string" ? entity.name : entity}" with id "${id}" not found`
|
||||
);
|
||||
}
|
||||
}
|
||||
74
app/src/data/events/index.ts
Normal file
74
app/src/data/events/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { Event } from "core/events";
|
||||
import type { Entity, EntityData } from "../entities";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
|
||||
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }> {
|
||||
static override slug = "mutator-insert-before";
|
||||
}
|
||||
export class MutatorInsertAfter extends Event<{ entity: Entity; data: EntityData }> {
|
||||
static override slug = "mutator-insert-after";
|
||||
}
|
||||
export class MutatorUpdateBefore extends Event<{
|
||||
entity: Entity;
|
||||
entityId: PrimaryFieldType;
|
||||
data: EntityData;
|
||||
}> {
|
||||
static override slug = "mutator-update-before";
|
||||
}
|
||||
export class MutatorUpdateAfter extends Event<{
|
||||
entity: Entity;
|
||||
entityId: PrimaryFieldType;
|
||||
data: EntityData;
|
||||
}> {
|
||||
static override slug = "mutator-update-after";
|
||||
}
|
||||
export class MutatorDeleteBefore extends Event<{ entity: Entity; entityId: PrimaryFieldType }> {
|
||||
static override slug = "mutator-delete-before";
|
||||
}
|
||||
export class MutatorDeleteAfter extends Event<{
|
||||
entity: Entity;
|
||||
entityId: PrimaryFieldType;
|
||||
data: EntityData;
|
||||
}> {
|
||||
static override slug = "mutator-delete-after";
|
||||
}
|
||||
|
||||
export const MutatorEvents = {
|
||||
MutatorInsertBefore,
|
||||
MutatorInsertAfter,
|
||||
MutatorUpdateBefore,
|
||||
MutatorUpdateAfter,
|
||||
MutatorDeleteBefore,
|
||||
MutatorDeleteAfter
|
||||
};
|
||||
|
||||
export class RepositoryFindOneBefore extends Event<{ entity: Entity; options: RepoQuery }> {
|
||||
static override slug = "repository-find-one-before";
|
||||
}
|
||||
export class RepositoryFindOneAfter extends Event<{
|
||||
entity: Entity;
|
||||
options: RepoQuery;
|
||||
data: EntityData;
|
||||
}> {
|
||||
static override slug = "repository-find-one-after";
|
||||
}
|
||||
|
||||
export class RepositoryFindManyBefore extends Event<{ entity: Entity; options: RepoQuery }> {
|
||||
static override slug = "repository-find-many-before";
|
||||
static another = "one";
|
||||
}
|
||||
export class RepositoryFindManyAfter extends Event<{
|
||||
entity: Entity;
|
||||
options: RepoQuery;
|
||||
data: EntityData;
|
||||
}> {
|
||||
static override slug = "repository-find-many-after";
|
||||
}
|
||||
|
||||
export const RepositoryEvents = {
|
||||
RepositoryFindOneBefore,
|
||||
RepositoryFindOneAfter,
|
||||
RepositoryFindManyBefore,
|
||||
RepositoryFindManyAfter
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user