UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

698 lines (646 loc) 21.6 kB
const QUERY_EXTENSION_PREFIX = "~beignet-query:v1:"; const EMPTY_ARRAY_EXTENSION = `${QUERY_EXTENSION_PREFIX}{"kind":"empty-preserving","value":[]}`; const EMPTY_OBJECT_EXTENSION = `${QUERY_EXTENSION_PREFIX}{"kind":"empty-preserving","value":{}}`; const DATE_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?([Zz]|([+-])(\d{2}):(\d{2}))$/; const NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/; const UNSAFE_QUERY_NAMES = new Set(["__proto__", "constructor", "prototype"]); /** Behavior when a query collection is empty. */ export type QueryEmptyBehavior = "omit" | "preserve"; /** Scalar query parameter transport supported by OpenAPI form encoding. */ export type QueryScalarTransport = | { readonly kind: "string" } | { readonly kind: "number" } | { readonly kind: "integer" } | { readonly kind: "boolean" } | { readonly kind: "date-time" } | { readonly kind: "date" }; /** Repeated scalar query parameter transport. */ export type QueryArrayTransport< TItem extends QueryScalarTransport = QueryScalarTransport, > = { readonly kind: "array"; readonly item: TItem; readonly empty: QueryEmptyBehavior; }; /** Fields supported inside one flat OpenAPI deepObject parameter. */ export type QueryDeepObjectFields = Readonly< Record<string, QueryScalarTransport> >; /** Flat OpenAPI deepObject query parameter transport. */ export type QueryDeepObjectTransport< TFields extends QueryDeepObjectFields = QueryDeepObjectFields, > = { readonly kind: "deep-object"; readonly fields: TFields; readonly empty: QueryEmptyBehavior; }; /** Transport for one top-level query parameter. */ export type QueryFieldTransport = | QueryScalarTransport | QueryArrayTransport | QueryDeepObjectTransport; /** Query field declarations keyed by their HTTP parameter names. */ export type QueryTransportFields = Readonly< Record<string, QueryFieldTransport> >; /** * Deterministic HTTP transport for a contract query schema. * * The same declaration drives server decoding, typed-client encoding, and * OpenAPI parameter serialization. */ export type QueryTransport< TFields extends QueryTransportFields = QueryTransportFields, > = { readonly kind: "query-transport"; readonly fields: TFields; }; /** One deterministic query transport failure. */ export type QueryTransportIssue = { readonly message: string; readonly path: readonly (string | number)[]; }; /** Raised when an HTTP query cannot be encoded or decoded as declared. */ export class QueryTransportError extends Error { readonly issues: readonly QueryTransportIssue[]; constructor(issue: QueryTransportIssue) { super(issue.message); this.name = "QueryTransportError"; this.issues = [issue]; } } /** Logical value encoded by one query field transport. */ export type QueryFieldTransportInput<TField extends QueryFieldTransport> = TField extends { readonly kind: "string" | "date-time" } ? string : TField extends { readonly kind: "number" | "integer" } ? number : TField extends { readonly kind: "boolean" } ? boolean : TField extends { readonly kind: "date" } ? Date : TField extends QueryArrayTransport<infer TItem> ? readonly QueryFieldTransportInput<TItem>[] : TField extends QueryDeepObjectTransport<infer TFields> ? { [K in keyof TFields]?: QueryFieldTransportInput<TFields[K]>; } : never; /** Logical values encoded by a query transport declaration. */ export type QueryTransportInput<TTransport extends QueryTransport> = { [K in keyof TTransport["fields"]]?: QueryFieldTransportInput< TTransport["fields"][K] >; }; type QueryKeys<T> = Extract<keyof T, string>; type DeclaredQueryTransportFields<TTransport extends QueryTransport> = TTransport extends QueryTransport<infer TFields> ? TFields : never; type QueryFieldMatches<TInput, TField extends QueryFieldTransport> = TField extends QueryArrayTransport<infer TItem> ? Exclude<TInput, undefined> extends readonly (infer TEntry)[] ? QueryFieldMatches<TEntry, TItem> : false : TField extends QueryDeepObjectTransport<infer TFields> ? Exclude<TInput, undefined> extends object ? Exclude< QueryKeys<Exclude<TInput, undefined>>, keyof TFields > extends never ? Exclude< keyof TFields, QueryKeys<Exclude<TInput, undefined>> > extends never ? false extends { [K in keyof TFields]: K extends keyof Exclude<TInput, undefined> ? QueryFieldMatches<Exclude<TInput, undefined>[K], TFields[K]> : false; }[keyof TFields] ? false : true : false : false : false : Exclude<TInput, undefined> extends QueryFieldTransportInput<TField> ? true : false; type QueryTransportMatchesInput< TInput, TTransport extends QueryTransport, > = unknown extends TInput ? true : TInput extends object ? keyof DeclaredQueryTransportFields<TTransport> extends never ? QueryKeys<TInput> extends never ? true : TInput[QueryKeys<TInput>] extends never ? true : false : Exclude< QueryKeys<TInput>, keyof DeclaredQueryTransportFields<TTransport> > extends never ? Exclude< keyof DeclaredQueryTransportFields<TTransport>, QueryKeys<TInput> > extends never ? false extends { [K in keyof DeclaredQueryTransportFields<TTransport>]: K extends keyof TInput ? QueryFieldMatches< TInput[K], DeclaredQueryTransportFields<TTransport>[K] > : false; }[keyof DeclaredQueryTransportFields<TTransport>] ? false : true : false : false : false; /** * Type-level contract used by `.query(...)` to keep schema input fields and * transport declarations aligned. */ export type QueryTransportCompatibility< TInput, TTransport extends QueryTransport, > = QueryTransportMatchesInput<TInput, TTransport> extends true ? unknown : { readonly __beignetQueryTransportMismatch: "Query transport fields and value kinds must match the Standard Schema input type."; }; type EmptyOptions = { /** * Empty collections are omitted by default because standard OpenAPI query * serialization cannot distinguish them from absent parameters. `preserve` * opts into Beignet's versioned typed-client extension. */ empty?: QueryEmptyBehavior; }; /** Built-in, OpenAPI-inspectable query parameter transports. */ export const query = { string(): QueryScalarTransport & { readonly kind: "string" } { return Object.freeze({ kind: "string" }); }, number(): QueryScalarTransport & { readonly kind: "number" } { return Object.freeze({ kind: "number" }); }, integer(): QueryScalarTransport & { readonly kind: "integer" } { return Object.freeze({ kind: "integer" }); }, boolean(): QueryScalarTransport & { readonly kind: "boolean" } { return Object.freeze({ kind: "boolean" }); }, dateTime(): QueryScalarTransport & { readonly kind: "date-time" } { return Object.freeze({ kind: "date-time" }); }, date(): QueryScalarTransport & { readonly kind: "date" } { return Object.freeze({ kind: "date" }); }, array<const TItem extends QueryScalarTransport>( item: TItem, options: EmptyOptions = {}, ): QueryArrayTransport<TItem> { return Object.freeze({ kind: "array", item, empty: options.empty ?? "omit", }); }, deepObject<const TFields extends QueryDeepObjectFields>( fields: TFields, options: EmptyOptions = {}, ): QueryDeepObjectTransport<TFields> { assertFieldNames(fields, "deepObject"); return Object.freeze({ kind: "deep-object", fields: copyFields(fields), empty: options.empty ?? "omit", }); }, } as const; /** Define a reusable deterministic query transport. */ export function defineQueryTransport< const TFields extends QueryTransportFields, >(fields: TFields): QueryTransport<TFields> { assertFieldNames(fields, "query"); return Object.freeze({ kind: "query-transport", fields: copyFields(fields), }); } function copyFields<TFields extends QueryTransportFields>( fields: TFields, ): TFields { return Object.freeze({ ...fields }) as TFields; } function assertFieldNames( fields: Readonly<Record<string, unknown>>, location: string, ): void { for (const name of Object.keys(fields)) { if ( name.length === 0 || name.includes("[") || name.includes("]") || UNSAFE_QUERY_NAMES.has(name) ) { throw new Error( `Invalid ${location} transport field "${name}". Query transport field names must be non-empty, must not contain brackets, and must not use prototype-related names.`, ); } } } function queryRecord(): Record<string, unknown> { return Object.create(null) as Record<string, unknown>; } function issue(path: readonly (string | number)[], message: string): never { throw new QueryTransportError({ path, message }); } function isLeapYear(year: number): boolean { return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); } function daysInMonth(year: number, month: number): number { if (month === 2) return isLeapYear(year) ? 29 : 28; return [4, 6, 9, 11].includes(month) ? 30 : 31; } function isDateTime(value: string): boolean { const match = DATE_TIME_PATTERN.exec(value); if (!match) return false; const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); const hour = Number(match[4]); const minute = Number(match[5]); const second = Number(match[6]); const offsetHour = match[9] === undefined ? 0 : Number(match[9]); const offsetMinute = match[10] === undefined ? 0 : Number(match[10]); const componentsAreValid = month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month) && hour <= 23 && minute <= 59 && second <= 60 && offsetHour <= 23 && offsetMinute <= 59; if (!componentsAreValid) return false; if (second < 60) return Number.isFinite(Date.parse(value)); const precedingSecond = value.replace( /^(\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:)60/, (_match, prefix: string) => `${prefix}59`, ); const parsed = Date.parse(precedingSecond); if (!Number.isFinite(parsed)) return false; const utc = new Date(parsed); return ( utc.getUTCHours() === 23 && utc.getUTCMinutes() === 59 && utc.getUTCSeconds() === 59 && ((utc.getUTCMonth() === 5 && utc.getUTCDate() === 30) || (utc.getUTCMonth() === 11 && utc.getUTCDate() === 31)) ); } function decodeScalar( transport: QueryScalarTransport, value: string, path: readonly (string | number)[], ): unknown { switch (transport.kind) { case "string": return value; case "number": { if (!NUMBER_PATTERN.test(value)) { return issue(path, "Expected a finite number query value."); } const decoded = Number(value); return Number.isFinite(decoded) ? decoded : issue(path, "Expected a finite number query value."); } case "integer": { if (!NUMBER_PATTERN.test(value)) { return issue(path, "Expected an integer query value."); } const decoded = Number(value); return Number.isSafeInteger(decoded) ? decoded : issue(path, "Expected a safe integer query value."); } case "boolean": if (value === "true") return true; if (value === "false") return false; return issue(path, 'Expected a boolean query value ("true" or "false").'); case "date-time": return isDateTime(value) ? value : issue(path, "Expected an RFC 3339 date-time query value."); case "date": { if (!isDateTime(value)) { return issue(path, "Expected an RFC 3339 date-time query value."); } const decoded = new Date(value); return Number.isFinite(decoded.getTime()) ? decoded : issue( path, "Expected an RFC 3339 date-time representable by JavaScript Date.", ); } } } function encodeScalar( transport: QueryScalarTransport, value: unknown, path: readonly (string | number)[], ): string { switch (transport.kind) { case "string": return typeof value === "string" ? value : issue(path, "Expected a string query value."); case "number": return typeof value === "number" && Number.isFinite(value) ? String(value) : issue(path, "Expected a finite number query value."); case "integer": return typeof value === "number" && Number.isSafeInteger(value) ? String(value) : issue(path, "Expected a safe integer query value."); case "boolean": return typeof value === "boolean" ? String(value) : issue(path, "Expected a boolean query value."); case "date-time": return typeof value === "string" && isDateTime(value) ? value : issue(path, "Expected an RFC 3339 date-time query value."); case "date": return value instanceof Date && Number.isFinite(value.getTime()) ? value.toISOString() : issue(path, "Expected a valid Date query value."); } } function oneValue( values: readonly string[], path: readonly (string | number)[], ): string { if (values.length !== 1) { return issue(path, "Expected exactly one query value."); } return values[0] as string; } function rawQuery(searchParams: URLSearchParams): Record<string, unknown> { const result = queryRecord(); for (const name of new Set(searchParams.keys())) { const values = searchParams.getAll(name); result[name] = values.length === 1 ? values[0] : values; } return result; } function encodeArrayItem( field: QueryArrayTransport, value: unknown, path: readonly (string | number)[], ): string { const encoded = encodeScalar(field.item, value, path); if ( field.empty === "preserve" && field.item.kind === "string" && encoded.startsWith(QUERY_EXTENSION_PREFIX) ) { return `${QUERY_EXTENSION_PREFIX}${JSON.stringify({ kind: "literal", value: encoded, })}`; } return encoded; } function decodeArrayItem( field: QueryArrayTransport, value: string, path: readonly (string | number)[], ): unknown { if ( field.empty === "preserve" && field.item.kind === "string" && value.startsWith(QUERY_EXTENSION_PREFIX) ) { try { const extension = JSON.parse( value.slice(QUERY_EXTENSION_PREFIX.length), ) as unknown; if ( extension && typeof extension === "object" && "kind" in extension && extension.kind === "literal" && "value" in extension && typeof extension.value === "string" ) { return extension.value; } } catch { // Unknown extension-looking strings remain ordinary string values. } } return decodeScalar(field.item, value, path); } /** Decode URL query values exactly once according to a contract transport. */ export function decodeQueryTransport( transport: QueryTransport, searchParams: URLSearchParams, ): Record<string, unknown> { const decoded = rawQuery(searchParams); for (const [name, field] of Object.entries(transport.fields)) { const values = searchParams.getAll(name); if (field.kind === "deep-object") { const direct = values.length > 0; const prefix = `${name}[`; const nestedNames = [...new Set(searchParams.keys())].filter((key) => key.startsWith(prefix), ); if (direct && nestedNames.length > 0) { issue([name], "Query parameter conflicts with its deepObject fields."); } if (direct) { if ( field.empty === "preserve" && values.length === 1 && values[0] === EMPTY_OBJECT_EXTENSION ) { decoded[name] = {}; continue; } issue([name], "Expected a deepObject query parameter."); } if (nestedNames.length === 0) continue; const object = queryRecord(); for (const nestedName of nestedNames) { const suffix = nestedName.slice(prefix.length); if (!suffix.endsWith("]") || suffix.slice(0, -1).includes("[")) { issue([name], "Expected a flat deepObject query parameter."); } const childName = suffix.slice(0, -1); const child = field.fields[childName]; if (!child) { issue( [name, childName], "Query transport does not declare this deepObject field.", ); } object[childName] = decodeScalar( child, oneValue(searchParams.getAll(nestedName), [name, childName]), [name, childName], ); delete decoded[nestedName]; } decoded[name] = object; continue; } if (values.length === 0) continue; if (field.kind === "array") { if ( field.empty === "preserve" && values.length === 1 && values[0] === EMPTY_ARRAY_EXTENSION ) { decoded[name] = []; } else { decoded[name] = values.map((value, index) => decodeArrayItem(field, value, [name, index]), ); } continue; } decoded[name] = decodeScalar(field, oneValue(values, [name]), [name]); } return Object.fromEntries(Object.entries(decoded)); } function isPlainObject(value: unknown): value is Record<string, unknown> { if (typeof value !== "object" || value === null || Array.isArray(value)) { return false; } const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } /** Encode typed query input according to the same contract transport. */ export function encodeQueryTransport( transport: QueryTransport, value: unknown, ): URLSearchParams { if (!isPlainObject(value)) { issue([], "Expected query input to be an object."); } const params = new URLSearchParams(); for (const name of Object.keys(value)) { if (value[name] !== undefined && !transport.fields[name]) { issue([name], "Query transport does not declare this field."); } } for (const [name, field] of Object.entries(transport.fields)) { const fieldValue = value[name]; if (fieldValue === undefined) continue; if (fieldValue === null) { issue( [name], "Null query values are not representable; omit the parameter instead.", ); } if (field.kind === "array") { if (!Array.isArray(fieldValue)) { issue([name], "Expected an array query value."); } if (fieldValue.length === 0) { if (field.empty === "preserve") { params.append(name, EMPTY_ARRAY_EXTENSION); } continue; } fieldValue.forEach((entry, index) => { params.append(name, encodeArrayItem(field, entry, [name, index])); }); continue; } if (field.kind === "deep-object") { if (!isPlainObject(fieldValue)) { issue([name], "Expected an object query value."); } for (const childName of Object.keys(fieldValue)) { if (fieldValue[childName] !== undefined && !field.fields[childName]) { issue( [name, childName], "Query transport does not declare this deepObject field.", ); } } const entries = Object.entries(fieldValue).filter( ([, entry]) => entry !== undefined, ); if (entries.length === 0) { if (field.empty === "preserve") { params.append(name, EMPTY_OBJECT_EXTENSION); } continue; } for (const [childName, entry] of entries) { const child = field.fields[childName]; if (!child) continue; params.append( `${name}[${childName}]`, encodeScalar(child, entry, [name, childName]), ); } continue; } params.append(name, encodeScalar(field, fieldValue, [name])); } return params; } /** OpenAPI schema keywords implied by a query field transport. */ export function queryTransportSchema( field: QueryFieldTransport, ): Record<string, unknown> { switch (field.kind) { case "string": return { type: "string" }; case "number": return { type: "number" }; case "integer": return { type: "integer", minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER, }; case "boolean": return { type: "boolean" }; case "date-time": case "date": return { type: "string", format: "date-time" }; case "array": return { type: "array", items: queryTransportSchema(field.item), ...(field.empty === "preserve" ? { "x-beignet-empty-query": "v1" } : {}), }; case "deep-object": return { type: "object", properties: Object.fromEntries( Object.entries(field.fields).map(([name, child]) => [ name, queryTransportSchema(child), ]), ), additionalProperties: false, ...(field.empty === "preserve" ? { "x-beignet-empty-query": "v1" } : {}), }; } }