@beignet/core
Version:
Core framework primitives for Beignet
406 lines • 15.5 kB
JavaScript
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