@beignet/core
Version:
Core framework primitives for Beignet
698 lines (646 loc) • 21.6 kB
text/typescript
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" }
: {}),
};
}
}