129 lines
4.0 KiB
TypeScript
129 lines
4.0 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import { getRoute } from "../../contracts/routes.js";
|
|
import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
|
|
|
|
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
|
|
|
|
const emptyCodec = z.object({}).strict();
|
|
const notFoundSplatCodec = z.object({ "*": z.string().optional() }).strict();
|
|
const sampleResourceListQuery = z
|
|
.object({
|
|
cursor: z.string().min(1).optional(),
|
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
tags: z
|
|
.preprocess(
|
|
(value) =>
|
|
value === undefined
|
|
? undefined
|
|
: Array.isArray(value)
|
|
? value
|
|
: [value],
|
|
z.array(z.string().trim().min(1)),
|
|
)
|
|
.optional(),
|
|
})
|
|
.strict();
|
|
|
|
const codecs = {
|
|
none: emptyCodec,
|
|
NotFoundSplat: notFoundSplatCodec,
|
|
SampleResourceListQuery: sampleResourceListQuery,
|
|
} as const;
|
|
|
|
export type ParsedRouteInput = Readonly<{
|
|
routeId: RouteId;
|
|
params: Readonly<Record<string, unknown>>;
|
|
search: Readonly<Record<string, unknown>>;
|
|
}>;
|
|
|
|
export type RouteInputResult =
|
|
| Readonly<{ success: true; data: ParsedRouteInput }>
|
|
| Readonly<{
|
|
success: false;
|
|
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
|
|
}>;
|
|
|
|
export function parseRouteInput(
|
|
routeId: RouteId,
|
|
rawParams: Readonly<Record<string, string | undefined>>,
|
|
rawSearch: URLSearchParams,
|
|
): RouteInputResult {
|
|
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
|
|
const params = codecs[runtime.paramsCodec].safeParse(rawParams);
|
|
if (!params.success) {
|
|
return { success: false, code: "ROUTE_PARAMS_INVALID" };
|
|
}
|
|
const search = codecs[runtime.searchCodec].safeParse(
|
|
searchRecord(rawSearch),
|
|
);
|
|
if (!search.success) {
|
|
return { success: false, code: "ROUTE_SEARCH_INVALID" };
|
|
}
|
|
const parsedParams: Record<string, unknown> = { ...params.data };
|
|
const parsedSearch: Record<string, unknown> = { ...search.data };
|
|
return {
|
|
success: true,
|
|
data: Object.freeze({
|
|
routeId,
|
|
params: Object.freeze(parsedParams),
|
|
search: Object.freeze(parsedSearch),
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function buildRouteUrl(
|
|
routeId: RouteId,
|
|
input: Readonly<{
|
|
params?: Readonly<Record<string, unknown>>;
|
|
search?: Readonly<Record<string, unknown>>;
|
|
}> = {},
|
|
): string {
|
|
const definition = getRoute(routeId);
|
|
if (definition.path === "*") {
|
|
throw new TypeError("The not-found route cannot build a canonical URL");
|
|
}
|
|
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
|
|
const params = codecs[runtime.paramsCodec].parse(input.params ?? {});
|
|
const search = codecs[runtime.searchCodec].parse(input.search ?? {});
|
|
const parsedParams: Record<string, unknown> = { ...params };
|
|
const parsedSearch: Record<string, unknown> = { ...search };
|
|
let path = definition.path;
|
|
path = path.replace(
|
|
/:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g,
|
|
(_token, colonName: string | undefined, braceName: string | undefined) => {
|
|
const name = colonName ?? braceName ?? "";
|
|
const value = parsedParams[name];
|
|
if (typeof value !== "string" && typeof value !== "number") {
|
|
throw new TypeError(`Missing route path parameter: ${name}`);
|
|
}
|
|
return encodeURIComponent(String(value));
|
|
},
|
|
);
|
|
const query = new URLSearchParams();
|
|
for (const key of Object.keys(parsedSearch).sort((left, right) =>
|
|
left.localeCompare(right),
|
|
)) {
|
|
const value = parsedSearch[key];
|
|
if (value === undefined || value === null) continue;
|
|
for (const item of Array.isArray(value) ? value : [value]) {
|
|
query.append(key, String(item));
|
|
}
|
|
}
|
|
const serialized = query.toString();
|
|
return serialized ? `${path}?${serialized}` : path;
|
|
}
|
|
|
|
function searchRecord(
|
|
search: URLSearchParams,
|
|
): Readonly<Record<string, string | readonly string[]>> {
|
|
const result: Record<string, string | readonly string[]> = {};
|
|
for (const key of [...new Set(search.keys())].sort((left, right) =>
|
|
left.localeCompare(right),
|
|
)) {
|
|
const values = search.getAll(key);
|
|
result[key] = values.length === 1 ? values[0] : values;
|
|
}
|
|
return result;
|
|
}
|