public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

View 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;
}
}

View 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
View 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>;
}

View 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
View 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
View 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
View 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
};
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}
}
}

View 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
View 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";

View 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];
}
}

View 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 });

View 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);
}
};
}

View 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;
}
}

View File

@@ -0,0 +1,11 @@
export class Permission<Name extends string = string> {
constructor(public name: Name) {
this.name = name;
}
toJSON() {
return {
name: this.name
};
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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);
});
}

View 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>;
});

View 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
View File

@@ -0,0 +1,4 @@
export interface Serializable<Class, Json extends object = object> {
toJSON(): Json;
fromJSON(json: Json): Class;
}

View 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;
}
}

View 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";
}
}

View 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);
}

View 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 };

View 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";

View 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);
}

View 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();
}
}

View 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;
}

View 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 "";
}

View 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;
});
}

View 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;
});
}

View 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
}

View 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
View 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>;

View File

@@ -0,0 +1,4 @@
// generates v4
export function uuid(): string {
return crypto.randomUUID();
}

View File

@@ -0,0 +1,6 @@
import { XMLParser } from "fast-xml-parser";
export function xmlToObject(xml: string) {
const parser = new XMLParser();
return parser.parse(xml);
}