@beignet/core
Version:
Core framework primitives for Beignet
119 lines (103 loc) • 3.43 kB
text/typescript
/**
* Parsed path segment from a Beignet route template.
*/
export type PathTemplateSegment =
| { kind: "static"; value: string }
| { kind: "dynamic"; name: string; raw: string };
/**
* Parsed representation of a Beignet route path template.
*/
export interface ParsedPathTemplate {
/**
* Dynamic parameter names in declaration order.
*/
keys: string[];
/**
* Static and dynamic path segments.
*/
segments: PathTemplateSegment[];
/**
* Normalized Beignet path using `:param` dynamic syntax.
*/
normalizedPath: string;
/**
* Route shape used for ambiguity detection, ignoring dynamic parameter names.
*/
shapeKey: string;
/**
* OpenAPI-compatible path using `{param}` syntax.
*/
openApiPath: string;
}
const PARAM_NAME = "[A-Za-z0-9_-]+";
const COLON_PARAM = new RegExp(`^:(${PARAM_NAME})$`);
const BRACKET_PARAM = new RegExp(`^\\[(${PARAM_NAME})\\]$`);
function createPathTemplateError(path: string, segment: string): Error {
return new Error(
`Unsupported path template segment "${segment}" in "${path}". ` +
"Use single-segment params like :id or [id]. Contract catch-all params such as [...path] and partial-segment params are not supported. Expose a platform catch-all route to the Beignet server, but keep individual contracts on concrete paths.",
);
}
function parsePathSegment(path: string, segment: string): PathTemplateSegment {
const colonMatch = segment.match(COLON_PARAM);
if (colonMatch) {
return { kind: "dynamic", name: colonMatch[1], raw: segment };
}
const bracketMatch = segment.match(BRACKET_PARAM);
if (bracketMatch) {
return { kind: "dynamic", name: bracketMatch[1], raw: segment };
}
if (
segment.startsWith(":") ||
segment.startsWith("[") ||
segment.endsWith("]")
) {
throw createPathTemplateError(path, segment);
}
return { kind: "static", value: segment };
}
/**
* Parse a Beignet route path template.
*
* Paths must start with `/`. Dynamic segments may use `:id` or `[id]`.
* Contract catch-all segments and partial dynamic segments are intentionally rejected.
*/
export function parsePathTemplate(path: string): ParsedPathTemplate {
if (!path.startsWith("/")) {
throw new Error(
`Invalid path template "${path}". Paths must start with "/".`,
);
}
const segments = path
.split("/")
.filter(Boolean)
.map((segment) => parsePathSegment(path, segment));
const keys = segments
.filter(
(segment): segment is Extract<PathTemplateSegment, { kind: "dynamic" }> =>
segment.kind === "dynamic",
)
.map((segment) => segment.name);
const duplicateKeys = keys.filter(
(key, index) => keys.indexOf(key) !== index,
);
if (duplicateKeys.length) {
throw new Error(
`Invalid path template "${path}". Path parameter names must be unique; duplicate parameter "${duplicateKeys[0]}" was found.`,
);
}
const normalizedPath = `/${segments
.map((segment) =>
segment.kind === "static" ? segment.value : `:${segment.name}`,
)
.join("/")}`;
const shapeKey = `/${segments
.map((segment) => (segment.kind === "static" ? segment.value : ":"))
.join("/")}`;
const openApiPath = `/${segments
.map((segment) =>
segment.kind === "static" ? segment.value : `{${segment.name}}`,
)
.join("/")}`;
return { keys, segments, normalizedPath, shapeKey, openApiPath };
}