feat(housekeeping): dispatch canonical domain routes
This commit is contained in:
1 parent
2970dff563
commit
e5c230ba35
13 files changed
+680
-82
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type CanonicalHousekeepingHref,
|
||||
type HousekeepingDomainManifest,
|
||||
} from "../contracts";
|
||||
import { createHousekeepingRegistry } from "../registry";
|
||||
import { matchHousekeepingRoute } from "./match-route";
|
||||
|
||||
const requirement = anyCapability(PERMS.USERS_VIEW);
|
||||
|
||||
function peopleManifest(
|
||||
routes: HousekeepingDomainManifest["routes"],
|
||||
): HousekeepingDomainManifest {
|
||||
return {
|
||||
id: "people",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase/people",
|
||||
capability: requirement,
|
||||
routes,
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function route(
|
||||
id: string,
|
||||
href: CanonicalHousekeepingHref,
|
||||
): HousekeepingDomainManifest["routes"][number] {
|
||||
return {
|
||||
id,
|
||||
labelKey: `pages.housekeeping.routes.${id}`,
|
||||
href,
|
||||
capability: requirement,
|
||||
};
|
||||
}
|
||||
|
||||
const registry = createHousekeepingRegistry([
|
||||
peopleManifest([
|
||||
route("people.user-detail", "/ase/people/users/:id"),
|
||||
route("people.user-create", "/ase/people/users/new"),
|
||||
route("people.users", "/ase/people/users"),
|
||||
route("people.ticket-note", "/ase/people/tickets/:ticketId/notes/:noteId"),
|
||||
route("people.literal-tool", "/ase/people/tools/a+b[1]"),
|
||||
]),
|
||||
]);
|
||||
|
||||
describe("matchHousekeepingRoute", () => {
|
||||
it("matches a static route without parameters", () => {
|
||||
expect(matchHousekeepingRoute(registry, "/ase/people/users")).toEqual({
|
||||
routeId: "people.users",
|
||||
domain: "people",
|
||||
params: {},
|
||||
canonicalHref: "/ase/people/users",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a static route over a dynamic route with the same depth", () => {
|
||||
expect(matchHousekeepingRoute(registry, "/ase/people/users/new")).toEqual({
|
||||
routeId: "people.user-create",
|
||||
domain: "people",
|
||||
params: {},
|
||||
canonicalHref: "/ase/people/users/new",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches a dynamic segment and decodes its parameter", () => {
|
||||
expect(
|
||||
matchHousekeepingRoute(registry, "/ase/people/users/Ada%20Lovelace"),
|
||||
).toEqual({
|
||||
routeId: "people.user-detail",
|
||||
domain: "people",
|
||||
params: { id: "Ada Lovelace" },
|
||||
canonicalHref: "/ase/people/users/Ada%20Lovelace",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches nested dynamic segments independently", () => {
|
||||
expect(
|
||||
matchHousekeepingRoute(
|
||||
registry,
|
||||
"/ase/people/tickets/42/notes/follow-up",
|
||||
),
|
||||
).toEqual({
|
||||
routeId: "people.ticket-note",
|
||||
domain: "people",
|
||||
params: { ticketId: "42", noteId: "follow-up" },
|
||||
canonicalHref: "/ase/people/tickets/42/notes/follow-up",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats regular-expression metacharacters as literal segment text", () => {
|
||||
expect(
|
||||
matchHousekeepingRoute(registry, "/ase/people/tools/a+b[1]"),
|
||||
)?.toMatchObject({ routeId: "people.literal-tool" });
|
||||
expect(
|
||||
matchHousekeepingRoute(registry, "/ase/people/tools/ab1"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/ase/people/unknown",
|
||||
"/ase/economy/users",
|
||||
"/ase/system/users/Ada",
|
||||
])("returns null for unknown or cross-domain path %s", (path) => {
|
||||
expect(matchHousekeepingRoute(registry, path)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"ase/people/users",
|
||||
"/ase//people/users",
|
||||
"/ase/people/users/",
|
||||
"/ase/people/users?tab=active",
|
||||
"/ase/people/users/%",
|
||||
"/ase/people/users/%2Fsystem",
|
||||
"/ase/people/users/..",
|
||||
"/ase\\people\\users",
|
||||
])("fails closed for malformed canonical path %s", (path) => {
|
||||
expect(matchHousekeepingRoute(registry, path)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { HousekeepingDomainId } from "../../migration/types";
|
||||
import type { CanonicalHousekeepingHref } from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
|
||||
export interface HousekeepingRouteMatch {
|
||||
routeId: string;
|
||||
domain: HousekeepingDomainId;
|
||||
params: Readonly<Record<string, string>>;
|
||||
canonicalHref: CanonicalHousekeepingHref;
|
||||
}
|
||||
|
||||
interface ParsedCanonicalPath {
|
||||
rawSegments: readonly string[];
|
||||
decodedSegments: readonly string[];
|
||||
}
|
||||
|
||||
interface RouteCandidate {
|
||||
routeId: string;
|
||||
domain: HousekeepingDomainId;
|
||||
patternSegments: readonly string[];
|
||||
dynamicSegments: number;
|
||||
}
|
||||
|
||||
export function matchHousekeepingRoute(
|
||||
registry: HousekeepingRegistry,
|
||||
canonicalPath: string,
|
||||
): HousekeepingRouteMatch | null {
|
||||
const path = parseCanonicalPath(canonicalPath);
|
||||
if (!path) return null;
|
||||
|
||||
const candidates = registry.domains
|
||||
.flatMap((domain) =>
|
||||
domain.routes
|
||||
.filter((route) =>
|
||||
routeBelongsToDomain(route.href, domain.canonicalHref),
|
||||
)
|
||||
.map((route) => {
|
||||
const patternSegments = route.href.slice(1).split("/");
|
||||
|
||||
return {
|
||||
routeId: route.id,
|
||||
domain: domain.id,
|
||||
patternSegments,
|
||||
dynamicSegments: patternSegments.filter((segment) =>
|
||||
segment.startsWith(":"),
|
||||
).length,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.sort((left, right) => left.dynamicSegments - right.dynamicSegments);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const params = matchSegments(candidate, path);
|
||||
if (!params) continue;
|
||||
|
||||
return {
|
||||
routeId: candidate.routeId,
|
||||
domain: candidate.domain,
|
||||
params: Object.freeze(params),
|
||||
canonicalHref: canonicalPath as CanonicalHousekeepingHref,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCanonicalPath(canonicalPath: string): ParsedCanonicalPath | null {
|
||||
if (
|
||||
canonicalPath !== canonicalPath.trim() ||
|
||||
!canonicalPath.startsWith("/ase") ||
|
||||
canonicalPath.includes("?") ||
|
||||
canonicalPath.includes("#") ||
|
||||
canonicalPath.includes("\\") ||
|
||||
canonicalPath.endsWith("/")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSegments = canonicalPath.slice(1).split("/");
|
||||
if (rawSegments[0] !== "ase" || rawSegments.some((segment) => !segment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decodedSegments: string[] = [];
|
||||
for (const segment of rawSegments) {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(segment);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!decoded ||
|
||||
decoded === "." ||
|
||||
decoded === ".." ||
|
||||
decoded.includes("/") ||
|
||||
decoded.includes("\\")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
decodedSegments.push(decoded);
|
||||
}
|
||||
|
||||
return { rawSegments, decodedSegments };
|
||||
}
|
||||
|
||||
function routeBelongsToDomain(
|
||||
routeHref: CanonicalHousekeepingHref,
|
||||
domainHref: CanonicalHousekeepingHref,
|
||||
): boolean {
|
||||
return (
|
||||
routeHref === domainHref ||
|
||||
(domainHref !== "/ase" && routeHref.startsWith(`${domainHref}/`)) ||
|
||||
(domainHref === "/ase" && routeHref === "/ase")
|
||||
);
|
||||
}
|
||||
|
||||
function matchSegments(
|
||||
candidate: RouteCandidate,
|
||||
path: ParsedCanonicalPath,
|
||||
): Record<string, string> | null {
|
||||
if (candidate.patternSegments.length !== path.rawSegments.length) return null;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [index, patternSegment] of candidate.patternSegments.entries()) {
|
||||
if (!patternSegment.startsWith(":")) {
|
||||
if (patternSegment !== path.rawSegments[index]) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const parameterName = patternSegment.slice(1);
|
||||
if (!parameterName || parameterName in params) return null;
|
||||
params[parameterName] = path.decodedSegments[index] ?? "";
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
Reference in new issue
Block a user