UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

406 lines 15.5 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"]); /** Raised when an HTTP query cannot be encoded or decoded as declared. */ export class QueryTransportError extends Error { issues; constructor(issue) { super(issue.message); this.name = "QueryTransportError"; this.issues = [issue]; } } /** Built-in, OpenAPI-inspectable query parameter transports. */ export const query = { string() { return Object.freeze({ kind: "string" }); }, number() { return Object.freeze({ kind: "number" }); }, integer() { return Object.freeze({ kind: "integer" }); }, boolean() { return Object.freeze({ kind: "boolean" }); }, dateTime() { return Object.freeze({ kind: "date-time" }); }, date() { return Object.freeze({ kind: "date" }); }, array(item, options = {}) { return Object.freeze({ kind: "array", item, empty: options.empty ?? "omit", }); }, deepObject(fields, options = {}) { assertFieldNames(fields, "deepObject"); return Object.freeze({ kind: "deep-object", fields: copyFields(fields), empty: options.empty ?? "omit", }); }, }; /** Define a reusable deterministic query transport. */ export function defineQueryTransport(fields) { assertFieldNames(fields, "query"); return Object.freeze({ kind: "query-transport", fields: copyFields(fields), }); } function copyFields(fields) { return Object.freeze({ ...fields }); } function assertFieldNames(fields, location) { 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() { return Object.create(null); } function issue(path, message) { throw new QueryTransportError({ path, message }); } function isLeapYear(year) { return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); } function daysInMonth(year, month) { if (month === 2) return isLeapYear(year) ? 29 : 28; return [4, 6, 9, 11].includes(month) ? 30 : 31; } function isDateTime(value) { 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) => `${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, value, path) { 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, value, path) { 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, path) { if (values.length !== 1) { return issue(path, "Expected exactly one query value."); } return values[0]; } function rawQuery(searchParams) { 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, value, path) { 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, value, path) { if (field.empty === "preserve" && field.item.kind === "string" && value.startsWith(QUERY_EXTENSION_PREFIX)) { try { const extension = JSON.parse(value.slice(QUERY_EXTENSION_PREFIX.length)); 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, searchParams) { 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) { 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, value) { 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) { 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" } : {}), }; } } //# sourceMappingURL=query-transport.js.map