Files
clean-architecture-frontend…/src/presentation/routes/route-codecs.ts
T

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;
}