diff --git a/CHANGES.md b/CHANGES.md index 7fe388fd0..0a0a20c57 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -51,7 +51,13 @@ To be released. deliberately exclude raw URLs, query strings, and identifier values to keep cardinality bounded. [[#316], [#736], [#757]] + - Replaced Fedify's internal federation routing with + *@fedify/uri-template* for stricter RFC 6570 URI Template expansion and + matching. The deprecated `Router` export from *@fedify/fedify* remains + available for compatibility. [[#418]] + [#316]: https://github.com/fedify-dev/fedify/issues/316 +[#418]: https://github.com/fedify-dev/fedify/issues/418 [#619]: https://github.com/fedify-dev/fedify/issues/619 [#735]: https://github.com/fedify-dev/fedify/issues/735 [#736]: https://github.com/fedify-dev/fedify/issues/736 @@ -72,6 +78,15 @@ To be released. - Added a `meterProvider` option to `createFederation()` so mock contexts can expose a test OpenTelemetry meter provider. [[#316], [#619], [#755]] +### @fedify/uri-template + + - Added *@fedify/uri-template*, a dependency-free RFC 6570 URI Template + implementation for expansion, variable extraction, and round-trip route + matching. This package replaces Fedify's previous direct use of + *url-template* and *uri-template-router*. [[#418], [#758] by ChanHaeng Lee] + +[#758]: https://github.com/fedify-dev/fedify/pull/758 + ### @fedify/amqp - Added `AmqpMessageQueue.getDepth()` for reporting queued, ready, and diff --git a/cspell.json b/cspell.json index c7516d114..25fb5937a 100644 --- a/cspell.json +++ b/cspell.json @@ -121,6 +121,11 @@ "urlpattern", "uuidv7", "valueparser", + "varspec", + "varname", + "varnames", + "varchar", + "varchars", "Vinxi", "vitepress", "vtsls", diff --git a/deno.json b/deno.json index cc061b9fd..639eecf75 100644 --- a/deno.json +++ b/deno.json @@ -25,6 +25,7 @@ "./packages/sqlite", "./packages/sveltekit", "./packages/testing", + "./packages/uri-template", "./packages/vocab", "./packages/vocab-runtime", "./packages/vocab-tools", diff --git a/deno.lock b/deno.lock index 6d41fb003..d528ef3c0 100644 --- a/deno.lock +++ b/deno.lock @@ -3448,7 +3448,8 @@ ] }, "@ungap/structured-clone@1.3.0": { - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "deprecated": true }, "@vercel/nft@1.5.0_rollup@4.60.1": { "integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==", @@ -9443,9 +9444,7 @@ "npm:json-canon@^1.0.1", "npm:jsonld@9", "npm:pkijs@^3.3.3", - "npm:structured-field-values@^2.0.4", - "npm:uri-template-router@1", - "npm:url-template@^3.1.1" + "npm:structured-field-values@^2.0.4" ], "packageJson": { "dependencies": [ @@ -9457,8 +9456,6 @@ "npm:miniflare@^4.20250523.0", "npm:structured-field-values@^2.0.4", "npm:tsx@^4.19.4", - "npm:uri-template-router@1", - "npm:url-template@^3.1.1", "npm:wrangler@^4.17.0" ] } diff --git a/packages/fedify/README.md b/packages/fedify/README.md index 9f6a9cdd9..8c17b11a4 100644 --- a/packages/fedify/README.md +++ b/packages/fedify/README.md @@ -123,6 +123,7 @@ Here is the list of packages: | [@fedify/sqlite](/packages/sqlite/) | [JSR][jsr:@fedify/sqlite] | [npm][npm:@fedify/sqlite] | SQLite driver | | [@fedify/sveltekit](/packages/sveltekit/) | [JSR][jsr:@fedify/sveltekit] | [npm][npm:@fedify/sveltekit] | SvelteKit integration | | [@fedify/testing](/packages/testing/) | [JSR][jsr:@fedify/testing] | [npm][npm:@fedify/testing] | Testing utilities | +| [@fedify/uri-template](/packages/uri-template/) | [JSR][jsr:@fedify/uri-template] | [npm][npm:@fedify/uri-template] | RFC 6570 URI Template library | | [@fedify/vocab](/packages/vocab/) | [JSR][jsr:@fedify/vocab] | [npm][npm:@fedify/vocab] | Activity Vocabulary library | | [@fedify/vocab-runtime](/packages/vocab-runtime/) | [JSR][jsr:@fedify/vocab-runtime] | [npm][npm:@fedify/vocab-runtime] | Runtime library for code-generated vocab | | [@fedify/vocab-tools](/packages/vocab-tools/) | [JSR][jsr:@fedify/vocab-tools] | [npm][npm:@fedify/vocab-tools] | Code generation tools for Activity Vocab | @@ -176,6 +177,8 @@ Here is the list of packages: [npm:@fedify/sveltekit]: https://www.npmjs.com/package/@fedify/sveltekit [jsr:@fedify/testing]: https://jsr.io/@fedify/testing [npm:@fedify/testing]: https://www.npmjs.com/package/@fedify/testing +[jsr:@fedify/uri-template]: https://jsr.io/@fedify/uri-template +[npm:@fedify/uri-template]: https://www.npmjs.com/package/@fedify/uri-template [jsr:@fedify/vocab]: https://jsr.io/@fedify/vocab [npm:@fedify/vocab]: https://www.npmjs.com/package/@fedify/vocab [jsr:@fedify/vocab-runtime]: https://jsr.io/@fedify/vocab-runtime diff --git a/packages/fedify/deno.json b/packages/fedify/deno.json index daf267c6b..29c2199c3 100644 --- a/packages/fedify/deno.json +++ b/packages/fedify/deno.json @@ -23,9 +23,7 @@ "json-canon": "npm:json-canon@^1.0.1", "jsonld": "npm:jsonld@^9.0.0", "pkijs": "npm:pkijs@^3.3.3", - "structured-field-values": "npm:structured-field-values@^2.0.4", - "uri-template-router": "npm:uri-template-router@^1.0.0", - "url-template": "npm:url-template@^3.1.1" + "structured-field-values": "npm:structured-field-values@^2.0.4" }, "exclude": [ ".test-report.xml", diff --git a/packages/fedify/package.json b/packages/fedify/package.json index 4883c51e7..27f1d8af3 100644 --- a/packages/fedify/package.json +++ b/packages/fedify/package.json @@ -139,6 +139,7 @@ } }, "dependencies": { + "@fedify/uri-template": "workspace:*", "@fedify/vocab": "workspace:*", "@fedify/vocab-runtime": "workspace:*", "@fedify/webfinger": "workspace:*", @@ -153,8 +154,6 @@ "json-canon": "^1.0.1", "jsonld": "^9.0.0", "structured-field-values": "^2.0.4", - "uri-template-router": "^1.0.0", - "url-template": "^3.1.1", "urlpattern-polyfill": "catalog:" }, "devDependencies": { diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts index 9d691c40d..d8f762931 100644 --- a/packages/fedify/src/federation/builder.test.ts +++ b/packages/fedify/src/federation/builder.test.ts @@ -1,4 +1,5 @@ import { test } from "@fedify/fixture"; +import { RouterError } from "@fedify/uri-template"; import { Activity, Note, Person } from "@fedify/vocab"; import { assertEquals, assertExists, assertThrows } from "@std/assert"; import type { Protocol } from "../nodeinfo/types.ts"; @@ -13,7 +14,6 @@ import type { } from "./callback.ts"; import { MemoryKvStore } from "./kv.ts"; import type { FederationImpl } from "./middleware.ts"; -import { RouterError } from "./router.ts"; test("FederationBuilder", async (t) => { await t.step( @@ -211,6 +211,27 @@ test("FederationBuilder", async (t) => { ), RouterError, ); + assertThrows( + () => + builderAfterInvalid.setOutboxListeners( + "/users/{identifier:3}/outbox" as `${string}{identifier}${string}`, + ), + RouterError, + ); + assertThrows( + () => + builderAfterInvalid.setOutboxListeners( + "/users/{identifier*}/outbox" as `${string}{identifier}${string}`, + ), + RouterError, + ); + assertThrows( + () => + builderAfterInvalid.setOutboxListeners( + "/users/{identifier,identifier}/outbox" as `${string}{identifier}${string}`, + ), + RouterError, + ); builderAfterInvalid.setOutboxListeners("/users/{identifier}/outbox"); const builder2 = createFederationBuilder(); @@ -231,6 +252,18 @@ test("FederationBuilder", async (t) => { RouterError, ); + const builder3a = createFederationBuilder(); + assertThrows( + () => builder3a.setOutboxListeners("/users{;identifier}/outbox"), + RouterError, + ); + + const builder3b = createFederationBuilder(); + assertThrows( + () => builder3b.setOutboxListeners("/users{.identifier}/outbox"), + RouterError, + ); + const builder4 = createFederationBuilder(); assertThrows( () => @@ -240,6 +273,16 @@ test("FederationBuilder", async (t) => { ), RouterError, ); + + const builder5 = createFederationBuilder(); + assertThrows( + () => + builder5.setOutboxDispatcher( + "/users/{identifier:3}/outbox" as `${string}{identifier}${string}`, + () => ({ items: [] }), + ), + RouterError, + ); }); await t.step("should pass build options correctly", async () => { diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index da5cf9845..c013ab174 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -1,3 +1,10 @@ +import { + assertPath, + isExpression, + type Path, + Router, + RouterError, +} from "@fedify/uri-template"; import type { Activity, Actor, @@ -8,9 +15,10 @@ import type { } from "@fedify/vocab"; import { getTypeId, Tombstone } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; -import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import type { Tracer } from "@opentelemetry/api"; +import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import metadata from "../../deno.json" with { type: "json" }; +import { ActivityListenerSet } from "./activity-listener.ts"; import type { ActorAliasMapper, ActorDispatcher, @@ -41,7 +49,6 @@ import type { OutboxContext, RequestContext, } from "./context.ts"; -import { ActivityListenerSet } from "./activity-listener.ts"; import type { ActorCallbackSetters, CollectionCallbackSetters, @@ -61,7 +68,6 @@ import type { CollectionCallbacks, CustomCollectionCallbacks, } from "./handler.ts"; -import { Router, RouterError } from "./router.ts"; export const ACTOR_ALIAS_PREFIX = "actorAlias:"; @@ -69,24 +75,25 @@ function validateSingleIdentifierVariablePath( path: string, errorMessage: string, ): void { - const operatorMatches = globalThis.Array.from( - path.matchAll(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g), - ); - if ( - operatorMatches.length !== 1 || - operatorMatches[0]?.[2] !== "identifier" - ) { + assertPath(path); + const pattern = Router.compile(path); + if (pattern.variables.size !== 1 || !pattern.variables.has("identifier")) { throw new RouterError(errorMessage); } - if ( - operatorMatches.some((match) => - ["?", "&", "#"].includes(match[1]) && match[2] === "identifier" - ) - ) { + const expressions = pattern.template.tokens + .filter(isExpression) + .filter((token) => token.vars.some(({ name }) => name === "identifier")); + + if (expressions.length !== 1 || expressions[0].vars.length !== 1) { throw new RouterError(errorMessage); } - const variables = new Router().add(path, "outbox"); - if (variables.size !== 1 || !variables.has("identifier")) { + + const { operator, vars: [varSpec] } = expressions[0]; + if ( + !(operator === "" || operator === "/") || + varSpec.explode || + varSpec.prefix != null + ) { throw new RouterError(errorMessage); } } @@ -251,15 +258,11 @@ export class FederationBuilderImpl if (this.router.has("actor")) { throw new RouterError("Actor dispatcher already set."); } - const variables = this.router.add(path, "actor"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for actor dispatcher must have one variable: {identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for actor dispatcher must have one variable: {identifier}", + ); + this.router.add(path as Path, "actor"); const callbacks: ActorCallbacks = { dispatcher: async (context, identifier) => { const actor = await this._getTracer().startActiveSpan( @@ -524,7 +527,7 @@ export class FederationBuilderImpl callbacks.aliasMapper = mapper; return setters; }, - mapActorAlias: (path: `/${string}`, identifier: string) => { + mapActorAlias: (path: Path, identifier: string) => { if (identifier === "") { throw new RouterError("Identifier cannot be empty."); } @@ -533,7 +536,7 @@ export class FederationBuilderImpl `Actor alias for "${identifier}" already set.`, ); } - const variables = new Router().add(path, "temp"); + const variables = Router.variables(path); if (variables.size > 0) { throw new RouterError( "Path for actor alias must have no variables.", @@ -563,12 +566,13 @@ export class FederationBuilderImpl if (this.router.has("nodeInfo")) { throw new RouterError("NodeInfo dispatcher already set."); } - const variables = this.router.add(path, "nodeInfo"); + const variables = Router.variables(path as Path); if (variables.size !== 0) { throw new RouterError( "Path for NodeInfo dispatcher must have no variables.", ); } + this.router.add(path as Path, "nodeInfo"); this.nodeInfoDispatcher = dispatcher; } @@ -624,12 +628,13 @@ export class FederationBuilderImpl if (this.router.has(routeName)) { throw new RouterError(`Object dispatcher for ${cls.name} already set.`); } - const variables = this.router.add(path, routeName); + const variables = Router.variables(path as Path); if (variables.size < 1) { throw new RouterError( "Path for object dispatcher must have at least one variable.", ); } + this.router.add(path as Path, routeName); const callbacks: ObjectCallbacks = { dispatcher: (ctx, values) => { const tracer = this._getTracer(); @@ -711,15 +716,11 @@ export class FederationBuilderImpl ); } } else { - const variables = this.router.add(path, "inbox"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for inbox dispatcher must have one variable: {identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for inbox dispatcher must have one variable: {identifier}", + ); + this.router.add(path as Path, "inbox"); this.inboxPath = path; } const callbacks: CollectionCallbacks< @@ -793,7 +794,7 @@ export class FederationBuilderImpl path, "Path for outbox dispatcher must have one variable: {identifier}", ); - this.router.add(path, "outbox"); + this.router.add(path as Path, "outbox"); this.outboxPath = path; } const callbacks: CollectionCallbacks< @@ -857,7 +858,7 @@ export class FederationBuilderImpl outboxPath, "Path for outbox must have one variable: {identifier}", ); - this.router.add(outboxPath, "outbox"); + this.router.add(outboxPath as Path, "outbox"); this.outboxPath = outboxPath; } const listeners = this.outboxListeners = new ActivityListenerSet< @@ -904,16 +905,12 @@ export class FederationBuilderImpl if (this.router.has("following")) { throw new RouterError("Following collection dispatcher already set."); } - const variables = this.router.add(path, "following"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for following collection dispatcher must have one variable: " + - "{identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for following collection dispatcher must have one variable: " + + "{identifier}", + ); + this.router.add(path as Path, "following"); const callbacks: CollectionCallbacks< Actor | URL, RequestContext, @@ -970,16 +967,12 @@ export class FederationBuilderImpl if (this.router.has("followers")) { throw new RouterError("Followers collection dispatcher already set."); } - const variables = this.router.add(path, "followers"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for followers collection dispatcher must have one variable: " + - "{identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for followers collection dispatcher must have one variable: " + + "{identifier}", + ); + this.router.add(path as Path, "followers"); const callbacks: CollectionCallbacks< Recipient, Context, @@ -1032,16 +1025,12 @@ export class FederationBuilderImpl if (this.router.has("liked")) { throw new RouterError("Liked collection dispatcher already set."); } - const variables = this.router.add(path, "liked"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for liked collection dispatcher must have one variable: " + - "{identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for liked collection dispatcher must have one variable: " + + "{identifier}", + ); + this.router.add(path as Path, "liked"); const callbacks: CollectionCallbacks< Like, RequestContext, @@ -1102,16 +1091,12 @@ export class FederationBuilderImpl if (this.router.has("featured")) { throw new RouterError("Featured collection dispatcher already set."); } - const variables = this.router.add(path, "featured"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for featured collection dispatcher must have one variable: " + - "{identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for featured collection dispatcher must have one variable: " + + "{identifier}", + ); + this.router.add(path as Path, "featured"); const callbacks: CollectionCallbacks< Object, RequestContext, @@ -1172,16 +1157,12 @@ export class FederationBuilderImpl if (this.router.has("featuredTags")) { throw new RouterError("Featured tags collection dispatcher already set."); } - const variables = this.router.add(path, "featuredTags"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for featured tags collection dispatcher must have one " + - "variable: {identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for featured tags collection dispatcher must have one " + + "variable: {identifier}", + ); + this.router.add(path as Path, "featuredTags"); const callbacks: CollectionCallbacks< Hashtag, RequestContext, @@ -1240,24 +1221,21 @@ export class FederationBuilderImpl ); } } else { - const variables = this.router.add(inboxPath, "inbox"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for inbox must have one variable: {identifier}", - ); - } + validateSingleIdentifierVariablePath( + inboxPath, + "Path for inbox must have one variable: {identifier}", + ); + this.router.add(inboxPath as Path, "inbox"); this.inboxPath = inboxPath; } if (sharedInboxPath != null) { - const siVars = this.router.add(sharedInboxPath, "sharedInbox"); + const siVars = Router.variables(sharedInboxPath as Path); if (siVars.size !== 0) { throw new RouterError( "Path for shared inbox must have no variables.", ); } + this.router.add(sharedInboxPath as Path, "sharedInbox"); } const listeners = this.inboxListeners = new ActivityListenerSet< InboxContext @@ -1533,12 +1511,13 @@ export class FederationBuilderImpl ); } - const variables = this.router.add(path, routeName); + const variables = Router.variables(path as Path); if (variables.size < 1) { throw new RouterError( "Path for collection dispatcher must have at least one variable.", ); } + this.router.add(path as Path, routeName); const callbacks: CustomCollectionCallbacks< TObject, diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 7de8556cc..8d0ac5cb0 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -4,6 +4,7 @@ import { mockDocumentLoader, test, } from "@fedify/fixture"; +import { RouterError } from "@fedify/uri-template"; import { configure, type LogRecord, reset } from "@logtape/logtape"; import * as vocab from "@fedify/vocab"; import { getTypeId, lookupObject } from "@fedify/vocab"; @@ -58,7 +59,6 @@ import { } from "./middleware.ts"; import type { MessageQueue } from "./mq.ts"; import type { InboxMessage, Message, OutboxMessage } from "./queue.ts"; -import { RouterError } from "./router.ts"; type IsEqual = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index f29e3a84c..693fd41c0 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -1,3 +1,4 @@ +import { type Path, RouterError } from "@fedify/uri-template"; import type { Actor, Collection, @@ -67,7 +68,7 @@ import { getKeyOwner, type GetKeyOwnerOptions } from "../sig/owner.ts"; import { hasProofLike, signObject, verifyObject } from "../sig/proof.ts"; import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; import { kvCache } from "../utils/kv-cache.ts"; -import { FederationBuilderImpl } from "./builder.ts"; +import { ACTOR_ALIAS_PREFIX, FederationBuilderImpl } from "./builder.ts"; import type { OutboxErrorHandler } from "./callback.ts"; import { buildCollectionSynchronizationHeader } from "./collection.ts"; import type { @@ -118,9 +119,6 @@ import type { SenderKeyJwkPair, } from "./queue.ts"; import { createExponentialBackoffPolicy, type RetryPolicy } from "./retry.ts"; -import { RouterError } from "./router.ts"; -import { ACTOR_ALIAS_PREFIX } from "./builder.ts"; - import { extractInboxes, sendActivity, @@ -1483,7 +1481,7 @@ export class FederationImpl onNotAcceptable ??= notAcceptable; onUnauthorized ??= unauthorized; const url = new URL(request.url); - const route = this.router.route(url.pathname); + const route = this.router.route(url.pathname as Path); if (route == null) { metricState.endpoint = "not_found"; return await onNotFound(request); @@ -2090,7 +2088,7 @@ export class ContextImpl implements Context { if (uri.origin !== this.origin && uri.origin !== this.canonicalOrigin) { return null; } - const route = this.federation.router.route(uri.pathname); + const route = this.federation.router.route(uri.pathname as Path); if (route == null) return null; else if (route.name === "sharedInbox") { return { diff --git a/packages/fedify/src/federation/router.ts b/packages/fedify/src/federation/router.ts index b1d542ff9..cff88b726 100644 --- a/packages/fedify/src/federation/router.ts +++ b/packages/fedify/src/federation/router.ts @@ -1,94 +1,71 @@ -// @ts-ignore TS7016 -import { cloneDeep } from "es-toolkit"; -import { Router as InnerRouter } from "uri-template-router"; -import { parseTemplate, type Template } from "url-template"; +import type { + RouterOptions as _RouterOptions, + RouterRouteResult as _RouterRouteResult, +} from "@fedify/uri-template"; +import { + assertPath, + Router as _Router, + RouterError as _RouterError, +} from "@fedify/uri-template"; +import { getLogger } from "@logtape/logtape"; + +const logger = getLogger(["fedify", "federation", "router", "deprecated"]); /** * Options for the {@link Router}. * @since 0.12.0 + * @deprecated Import `RouterOptions` from `@fedify/uri-template` instead. */ -export interface RouterOptions { - /** - * Whether to ignore trailing slashes when matching paths. - */ - trailingSlashInsensitive?: boolean; -} +export interface RouterOptions extends _RouterOptions {} /** * The result of {@link Router.route} method. * @since 1.3.0 + * @deprecated Import `RouterRouteResult` from `@fedify/uri-template` instead. */ -export interface RouterRouteResult { - /** - * The matched route name. - */ - name: string; - - /** - * The URL template of the matched route. - */ - template: string; - - /** - * The values extracted from the URL. - */ - values: Record; -} - -function cloneInnerRouter(router: InnerRouter): InnerRouter { - const clone = new InnerRouter(); - clone.nid = router.nid; - clone.fsm = cloneDeep(router.fsm); - clone.routeSet = new Set(router.routeSet); - clone.templateRouteMap = new Map(router.templateRouteMap); - clone.valueRouteMap = new Map(router.valueRouteMap); - clone.hierarchy = cloneDeep(router.hierarchy); - return clone; -} +export interface RouterRouteResult extends _RouterRouteResult {} /** * URL router and constructor based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). + * + * @deprecated Import `Router` from `@fedify/uri-template` instead. This class + * remains only for compatibility with older Fedify code. The + * `@fedify/uri-template` router is the replacement implementation + * and should be used directly in new code. */ export class Router { - #router: InnerRouter; - #templates: Record; - #templateStrings: Record; - - /** - * Whether to ignore trailing slashes when matching paths. - * @since 1.6.0 - */ - trailingSlashInsensitive: boolean; - + #router: _Router; /** * Create a new {@link Router}. * @param options Options for the router. + * @deprecated Use `new Router(options)` from `@fedify/uri-template` + * instead. */ - constructor(options: RouterOptions = {}) { - this.#router = new InnerRouter(); - this.#templates = {}; - this.#templateStrings = {}; - this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false; + constructor(options?: _RouterOptions) { + this.#router = convertRouterError(() => new _Router(options)); } + /** + * Clones this router. + * @deprecated Use `Router` from `@fedify/uri-template` instead. + */ clone(): Router { - const clone = new Router({ - trailingSlashInsensitive: this.trailingSlashInsensitive, + return convertRouterError(() => { + const clone = new Router(); + clone.#router = this.#router.clone(); + return clone; }); - clone.#router = cloneInnerRouter(this.#router); - clone.#templates = { ...this.#templates }; - clone.#templateStrings = { ...this.#templateStrings }; - return clone; } /** * Checks if a path name exists in the router. * @param name The name of the path. * @returns `true` if the path name exists, otherwise `false`. + * @deprecated Use `Router` from `@fedify/uri-template` instead. */ has(name: string): boolean { - return name in this.#templates; + return convertRouterError(() => this.#router.has(name)); } /** @@ -96,15 +73,23 @@ export class Router { * @param template The path pattern. * @param name The name of the path. * @returns The names of the variables in the path pattern. + * @deprecated Use `Router` from `@fedify/uri-template` instead. In this + * compatibility class, `add()` both registers the route and + * returns the variables in the path pattern. In + * `@fedify/uri-template`, these two responsibilities are split: + * `router.add(template, name)` registers the route and returns + * `void`, while the pure static method + * `Router.variables(template)` returns the variable names. To + * migrate, call `Router.variables(template)` when variables are + * needed, then call `router.add(template, name)` to register the + * route. */ add(template: string, name: string): Set { - if (!template.startsWith("/")) { - throw new RouterError("Path must start with a slash."); - } - const rule = this.#router.addTemplate(template, {}, name); - this.#templates[name] = parseTemplate(template); - this.#templateStrings[name] = template; - return new Set(rule.variables.map((v: { varname: string }) => v.varname)); + return convertRouterError(() => { + assertPath(template); + this.#router.add(template, name); + return _Router.variables(template); + }); } /** @@ -112,20 +97,13 @@ export class Router { * @param url The URL to resolve. * @returns The name of the path and its values, if any match. Otherwise, * `null`. + * @deprecated Use `Router` from `@fedify/uri-template` instead. */ route(url: string): RouterRouteResult | null { - let match = this.#router.resolveURI(url); - if (match == null) { - if (!this.trailingSlashInsensitive) return null; - url = url.endsWith("/") ? url.replace(/\/+$/, "") : `${url}/`; - match = this.#router.resolveURI(url); - if (match == null) return null; - } - return { - name: match.matchValue, - template: this.#templateStrings[match.matchValue], - values: match.params, - }; + return convertRouterError(() => { + assertPath(url); + return this.#router.route(url); + }); } /** @@ -133,25 +111,43 @@ export class Router { * @param name The name of the path. * @param values The values to expand the path with. * @returns The URL/path, if the name exists. Otherwise, `null`. + * @deprecated Use `Router` from `@fedify/uri-template` instead. */ build(name: string, values: Record): string | null { - if (name in this.#templates) { - return this.#templates[name].expand(values); - } - return null; + return convertRouterError(() => this.#router.build(name, values)); } } /** * An error thrown by the {@link Router}. + * @deprecated Import `RouterError` from `@fedify/uri-template` instead. */ -export class RouterError extends Error { +export class RouterError extends _RouterError { /** * Create a new {@link RouterError}. * @param message The error message. + * @deprecated Import `RouterError` from `@fedify/uri-template` instead. */ constructor(message: string) { super(message); - this.name = "RouterError"; + logger.warn( + "The `RouterError` class from `@fedify/fedify` is deprecated." + + " Please use `Router` from `@fedify/uri-template` instead.", + ); + } +} + +function convertRouterError(func: () => T): T { + try { + logger.warn( + "The `Router` class from `@fedify/fedify` is deprecated." + + " Please use `Router` from `@fedify/uri-template` instead.", + ); + return func(); + } catch (error) { + if (error instanceof _RouterError) { + throw new RouterError(error.message); + } + throw error; } } diff --git a/packages/fedify/src/nodeinfo/handler.ts b/packages/fedify/src/nodeinfo/handler.ts index 89d4e068d..3bd001a09 100644 --- a/packages/fedify/src/nodeinfo/handler.ts +++ b/packages/fedify/src/nodeinfo/handler.ts @@ -1,7 +1,7 @@ import type { Link, ResourceDescriptor } from "@fedify/webfinger"; +import { RouterError } from "@fedify/uri-template"; import type { NodeInfoDispatcher } from "../federation/callback.ts"; import type { RequestContext } from "../federation/context.ts"; -import { RouterError } from "../federation/router.ts"; import { nodeInfoToJson } from "./types.ts"; /** diff --git a/packages/fedify/src/testing/context.ts b/packages/fedify/src/testing/context.ts index 8787d11ce..0f9781d14 100644 --- a/packages/fedify/src/testing/context.ts +++ b/packages/fedify/src/testing/context.ts @@ -1,4 +1,5 @@ import { mockDocumentLoader } from "@fedify/fixture"; +import { RouterError } from "@fedify/uri-template"; import { lookupObject as globalLookupObject, traverseCollection as globalTraverseCollection, @@ -12,7 +13,6 @@ import type { RequestContext, } from "../federation/context.ts"; import type { Federation } from "../federation/federation.ts"; -import { RouterError } from "../federation/router.ts"; export function createContext( values: Partial> & { diff --git a/packages/uri-template/README.md b/packages/uri-template/README.md new file mode 100644 index 000000000..02e54d9d3 --- /dev/null +++ b/packages/uri-template/README.md @@ -0,0 +1,148 @@ + + +@fedify/uri-template: Round-trip RFC 6570 URI Template library +============================================================== + +[![JSR][JSR badge]][JSR] +[![npm][npm badge]][npm] + +This package provides an [RFC 6570] URI Template implementation that performs +both expansion and pattern matching with round-trip verification. It is part of +the [Fedify] framework but can be used independently. + +[JSR badge]: https://jsr.io/badges/@fedify/uri-template +[JSR]: https://jsr.io/@fedify/uri-template +[npm badge]: https://img.shields.io/npm/v/@fedify/uri-template?logo=npm +[npm]: https://www.npmjs.com/package/@fedify/uri-template +[RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570 +[Fedify]: https://fedify.dev/ + + +Why `@fedify/uri-template`? +--------------------------- + +Fedify previously relied on two independent third-party implementations: +[url-template] for URI Template expansion and [uri-template-router] for +route matching. `@fedify/uri-template` replaces both with one strict RFC 6570 +parser and one expansion/matching model. + +[url-template]: https://www.npmjs.com/package/url-template +[uri-template-router]: https://www.npmjs.com/package/uri-template-router + +### Why replacing [url-template] with `Template`? + +[url-template] describes itself as an RFC 6570 implementation, but its behavior +is not strict enough for Fedify's URI routing and round-trip matching needs. +The test in *old/url-template.test.ts* records the differences against +`npm:url-template@^3.1.1`. + +The important failures are: + + - It double-encodes pct-encoded triplets in variable names when named + operators emit the variable name. For example, `{?abc%20def}` expands to + `?abc%2520def=spaced` instead of `?abc%20def=spaced`. [RFC 6570 §2.3] + allows `pct-encoded` inside `varname` and treats it as part of the + variable name. [RFC 6570 §3.2.8] emits the variable name as a literal + string, and [RFC 6570 §2.1] permits `pct-encoded` literals. Therefore + `%20` and `%41` must be preserved, not encoded again as `%2520` and + `%2541`. + - It accepts malformed templates instead of reporting syntax errors. + [RFC 6570 §2] requires expressions to be delimited by matching braces, + [RFC 6570 §2.1] excludes raw braces, control characters, spaces, raw `%` + outside a pct-encoded triplet, and other forbidden literal characters, and + [RFC 6570 §3] says grammar errors should indicate their location and type + to the invoking application. `@fedify/uri-template` reports these cases as + typed errors. + - It applies prefix modifiers to composite values such as lists and + associative arrays. [RFC 6570 §2.4.1] states that prefix modifiers are not + applicable to variables with composite values, so `{list:3}`, `{keys:3}`, + and `{count:2}` must fail. + +`Template` was written as a new implementation instead of wrapping +[url-template] because Fedify needs strict RFC 6570 expansion, typed syntax +errors, and round-trip-checked matching behavior. Applications that need a +looser parser can opt in explicitly: `strict: false` passes parse and expansion +errors to `report` without throwing, and a custom `report` function can allow +all errors or throw only for selected error classes. + +[RFC 6570 §2.3]: https://datatracker.ietf.org/doc/html/rfc6570#section-2.3 +[RFC 6570 §3.2.8]: https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8 +[RFC 6570 §2.1]: https://datatracker.ietf.org/doc/html/rfc6570#section-2.1 +[RFC 6570 §2]: https://datatracker.ietf.org/doc/html/rfc6570#section-2 +[RFC 6570 §3]: https://datatracker.ietf.org/doc/html/rfc6570#section-3 +[RFC 6570 §2.4.1]: https://datatracker.ietf.org/doc/html/rfc6570#section-2.4.1 + +### Why replacing [uri-template-router] with `Router`? + +The previous router shape combined two independent third-party +implementations: [url-template] for building URLs and [uri-template-router] for +matching URLs. *old/uri-template-router.test.ts* defines that old shape as +closely as possible so the differences are visible under the same route API. + +The important differences are: + + - Build, match, and variable extraction all use the same strict RFC 6570 + parser. The previous router expanded with [url-template] but matched with + [uri-template-router], so a value could be encoded by one implementation + and decoded by another with different rules. + - Route matches are round-trip checked. A candidate route is accepted only + when the recovered values expand back to the exact input URI. This rejects + matches that look plausible after decoding but cannot reproduce the + original URI. By default, matching is exact against the input URI. + `trailingSlashInsensitive` can be enabled to use a looser path lookup for + trailing slash differences before applying the same round-trip check. + - Pct-encoded triplets are preserved where RFC 6570 treats them as syntax. + Literal triplets, pct-encoded variable names, and named query parameters + such as `{?abc%20def}` remain `%20` instead of becoming `%2520`. + - Reserved expansion values keep their encoded form when that is what the + URI contained. Under the previous matching path, `/files/a%2Fb` could be + reported as `a/b`, `/files/%30%23` as `0#`, and pct-encoded UTF-8 octets as + Unicode text. Those values do not round-trip to the original URI under the + same template. + - Path templates are validated by `Router.compile()` before registration. + The new router supports ordinary slash-prefixed paths and the leading path + expansion form used by Fedify routes, such as `{/identifier}/inbox`. + - `Router.variables()` and `Router.compile()` expose variable extraction + without mutating a router. The legacy `Router.add()` returned variables as + a side effect of registering the route. + - Candidate lookup uses a prefix trie keyed by the initial literal prefix of + each route. Candidates are ordered deterministically by literal length, + initial literal prefix length, variable count, and insertion order before + the round-trip matcher runs. + - Cloning and route replacement do not depend on copying private mutable + state from [uri-template-router]. The router stores compiled templates and + active route entries directly, which keeps the implementation independent + and dependency-free at runtime. + +The old implementation differences can be checked by running the compatibility +tests in *old/url-template.test.ts* and *old/uri-template-router.test.ts*. +These tests intentionally run Fedify's expected behavior against the old +libraries, so the failing cases show the gaps: + +~~~~ bash +deno task test:old +~~~~ + + +Features +-------- + + - Full RFC 6570 expansion for all expression types + (`{var}`, `{+var}`, `{#var}`, `{.var}`, `{/var}`, `{;var}`, `{?var}`, + `{&var}`) + - Round-trip pattern matching that mirrors expansion: when `match(uri)` + returns values, `expand(values) === uri` + - Strict TypeScript types with no `any` in the public surface + - Zero runtime dependencies + + +Installation +------------ + +~~~~ bash +deno add jsr:@fedify/uri-template # Deno +npm add @fedify/uri-template # npm +pnpm add @fedify/uri-template # pnpm +yarn add @fedify/uri-template # Yarn +bun add @fedify/uri-template # Bun +~~~~ diff --git a/packages/uri-template/deno.json b/packages/uri-template/deno.json new file mode 100644 index 000000000..b1a87213c --- /dev/null +++ b/packages/uri-template/deno.json @@ -0,0 +1,33 @@ +{ + "name": "@fedify/uri-template", + "version": "2.3.0", + "license": "MIT", + "exports": { + ".": "./src/mod.ts" + }, + "description": "RFC 6570 URI Template expansion and round-trip pattern matching for Fedify", + "author": { + "name": "Chanhaeng Lee", + "email": "2chanhaeng@gmail.com", + "url": "https://chomu.dev" + }, + "imports": {}, + "exclude": [ + "dist", + "node_modules" + ], + "publish": { + "exclude": [ + "**/*.bench.ts", + "**/*.test.ts", + "src/tests/", + "summary.txt", + "tsdown.config.ts" + ] + }, + "tasks": { + "check": "deno fmt --check && deno lint && deno check", + "test": "deno test --allow-env", + "test:old": "OLD=true deno test --allow-env old/" + } +} diff --git a/packages/uri-template/old/uri-template-router.test.ts b/packages/uri-template/old/uri-template-router.test.ts new file mode 100644 index 000000000..2c82dfb98 --- /dev/null +++ b/packages/uri-template/old/uri-template-router.test.ts @@ -0,0 +1,233 @@ +// deno-lint-ignore-file no-import-prefix +import { test } from "@fedify/fixture"; +import { cloneDeep } from "es-toolkit"; +import { Router as InnerRouter } from "npm:uri-template-router@^1.0.0"; +import { + parseTemplate, + type Template as UrlTemplate, +} from "npm:url-template@^3.1.1"; +import { + createRouterAddTest, + createRouterBuildTest, + createRouterCloneTest, + createRouterRouteTest, + createRouterVariablesTest, + routerBuildTestSuites, + routerCloneTestSuites, + routerRouteDefinitions, + routerRouteTestSuites, + routerVariablesCases, +} from "../src/tests/mod.ts"; +import type { Path } from "../src/types.ts"; + +/** + * Known failures for npm:uri-template-router@^1.0.0, checked with + * `deno task test:old`. These pct-encoding gaps are the main routing + * correctness issues that motivated the @fedify/uri-template Router + * implementation. + * + * RFC 6570 treats pct-encoded triplets as valid literal and varname syntax. + * It also distinguishes reserved characters from their pct-encoded forms + * under reserved expansion (`+` and `#` allow sets). The previous Router + * loses that distinction when matching reserved expansions: `/files/a%2Fb` + * becomes `a/b`, `/files/%30%23` becomes `0#`, and UTF-8 pct-encoded octets + * are decoded to Unicode characters. That makes route results fail to + * round-trip the actual URI template value. + * + * The companion `url-template` expander has the opposite problem for named + * variables: pct-encoded triplets in varnames such as `{?abc%20def}`, + * `{;%41}`, and `{&abc%20def}` are double-encoded as `%2520` or `%2541` + * when building URIs. The new Router uses the same strict RFC 6570 parser + * for building, matching, and variable extraction, so pct-encoded variable + * names and reserved expansion values are preserved instead of decoded or + * encoded a second time. + * + * The same compatibility run also records route-shape differences that matter + * for Fedify routes. The previous router rejects leading path expansion + * templates such as `{/identifier}/inbox` when they partially overlap with + * slash-prefixed routes, and it misses optional form-style query matches such + * as `/search{?q,page}` with only one query variable present. + */ +export interface RouterOptions { + trailingSlashInsensitive?: boolean; +} + +export interface RouterRouteResult { + name: string; + template: Path; + values: Record; +} + +export interface RouterPathPattern { + readonly path: Path; + readonly template: UrlTemplate; + readonly variables: ReadonlySet; +} + +interface InnerRouteMatch { + readonly matchValue: string; + readonly params: Record; +} + +function cloneInnerRouter(router: InnerRouter): InnerRouter { + const clone = new InnerRouter(); + clone.nid = router.nid; + clone.fsm = cloneDeep(router.fsm); + clone.routeSet = new Set(router.routeSet); + clone.templateRouteMap = new Map(router.templateRouteMap); + clone.valueRouteMap = new Map(router.valueRouteMap); + clone.hierarchy = cloneDeep(router.hierarchy); + return clone; +} + +export class Router { + #router: InnerRouter; + #templates: Record; + #templateStrings: Record; + + trailingSlashInsensitive: boolean; + + constructor(options: RouterOptions = {}) { + this.#router = new InnerRouter(); + this.#templates = {}; + this.#templateStrings = {}; + this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false; + } + + clone(): Router { + const clone = new Router({ + trailingSlashInsensitive: this.trailingSlashInsensitive, + }); + clone.#router = cloneInnerRouter(this.#router); + clone.#templates = { ...this.#templates }; + clone.#templateStrings = { ...this.#templateStrings }; + return clone; + } + + static compile(path: Path): RouterPathPattern { + const router = new InnerRouter(); + const rule = router.addTemplate(path, {}, "temp"); + return { + path, + template: parseTemplate(path), + variables: new Set( + rule.variables.map((v: { varname: string }) => v.varname), + ), + }; + } + + static variables(path: Path): Set { + return new Set(Router.compile(path).variables); + } + + has(name: string): boolean { + return name in this.#templates; + } + + add(template: Path, name: string): void { + this.#router.addTemplate(template, {}, name); + this.#templates[name] = parseTemplate(template); + this.#templateStrings[name] = template as Path; + } + + route(url: Path): RouterRouteResult | null { + let match = this.#router.resolveURI(url) as InnerRouteMatch | null; + if (match == null) { + if (!this.trailingSlashInsensitive) return null; + const retryUrl = toggleTrailingSlash(url); + if (retryUrl == null) return null; + match = this.#router.resolveURI(retryUrl) as InnerRouteMatch | null; + if (match == null) return null; + } + const values = toRouteValues(match.params); + if (values == null) return null; + + return { + name: match.matchValue, + template: this.#templateStrings[match.matchValue], + values, + }; + } + + build(name: string, values: Record): Path | null { + if (name in this.#templates) { + return this.#templates[name].expand(values) as Path; + } + return null; + } +} + +const isPath = (path: string): path is Path => + path.startsWith("/") || /^\{\/[^}]+\}\//.test(path); + +const toggleTrailingSlash = (path: Path): Path | null => { + if (!path.endsWith("/")) return `${path}/`; + + const trimmed = path.replace(/\/+$/, ""); + return isPath(trimmed) ? trimmed : null; +}; + +const toRouteValues = ( + params: Record, +): Record | null => { + const values: Record = {}; + + for (const [key, value] of Object.entries(params)) { + if (typeof value !== "string") return null; + values[key] = value; + } + + return values; +}; + +export class RouterError extends Error { + constructor(message: string) { + super(message); + this.name = "RouterError"; + } +} + +const isOldTest = Deno.env.get("OLD") === "true"; + +const runAddCases = createRouterAddTest(Router); +test( + "Router.add()", + { ignore: !isOldTest }, + runAddCases(routerRouteDefinitions), +); + +const runVariablesCases = createRouterVariablesTest(Router); +test( + "Router.variables()", + { ignore: !isOldTest }, + runVariablesCases(routerVariablesCases), +); + +const runCloneCases = createRouterCloneTest(Router); +test( + "Router.clone()", + { ignore: !isOldTest }, + runCloneCases(routerCloneTestSuites), +); + +const runRouteCases = createRouterRouteTest(Router); +for ( + const { name, options, routeDefinitions, cases } of routerRouteTestSuites +) { + test( + `Router.route(): ${name}`, + { ignore: !isOldTest }, + runRouteCases(routeDefinitions, options)(cases), + ); +} + +const runBuildCases = createRouterBuildTest(Router); +for ( + const { name, options, routeDefinitions, cases } of routerBuildTestSuites +) { + test( + `Router.build(): ${name}`, + { ignore: !isOldTest }, + runBuildCases(routeDefinitions, options)(cases), + ); +} diff --git a/packages/uri-template/old/url-template.test.ts b/packages/uri-template/old/url-template.test.ts new file mode 100644 index 000000000..0e5851a17 --- /dev/null +++ b/packages/uri-template/old/url-template.test.ts @@ -0,0 +1,102 @@ +import { test } from "@fedify/fixture"; +// deno-lint-ignore no-import-prefix +import { parseTemplate } from "npm:url-template@^3.1.1"; +import { + createFixedTemplateTest, + createTemplateHardTest, + createTemplatePairTest, + createWrongTemplateTest, + fixedTestSuites, + hardTestSuites, + pairTestSuites, + wrongTestSuites, +} from "../src/tests/mod.ts"; + +/** + * Known failures for npm:url-template@^3.1.1, checked with + * `deno task test:old`. + * These are the compatibility gaps that motivated the strict + * @fedify/uri-template implementation. + * + * Expected-error cases that throw a different npm error are intentionally + * excluded. In the current run, none of the failing expected-error cases fell + * into that category; npm:url-template accepted and expanded them instead. + * + * RFC 6570 grounds for the expected behavior: + * + * - Section 2 defines a URI Template as zero or more literals or + * expressions, and each expression is delimited by a matching pair of + * braces. Section 3.2 also states that expressions cannot be nested. + * - Section 2.1 excludes CTL, SP, DQUOTE, "'", raw "%" outside a + * pct-encoded triplet, "<", ">", "\", "^", "`", "{", "|", and "}" from + * literals. + * - Section 2.3 defines `varname` as `varchar *( ["."] varchar )`, where + * `varchar` includes `pct-encoded`, and says pct-encoded triplets in a + * varname are essential parts of the variable name and are not decoded. + * - Section 3.2.8 says query expansion appends the variable name encoded as + * if it were a literal string. Since Section 2.1 permits `pct-encoded` in + * literals, a pct-encoded triplet in a variable name must be preserved and + * must not be encoded again. + * - Section 2.4.1 says prefix modifiers are not applicable to variables + * that have composite values. + * - Section 3 says grammar errors SHOULD indicate the location and type of + * error to the invoking application. @fedify/uri-template reports these + * cases as typed errors; npm:url-template silently returns a best-effort + * expansion for the cases below. + * + * Successful cases with different output: + * + * - `{?abc%20def}` expands to `?abc%2520def=spaced`; expected + * `?abc%20def=spaced`. + * - `{?%41}` expands to `?%2541=encoded-A`; expected `?%41=encoded-A`. + * + * Invalid templates accepted by npm:url-template: + * + * - `wrongTestSuites`: all 75 negative parser cases are accepted: + * Brackets not matched (9/9), Duplicated brackets (8/8), Wrong position + * of level 4 modifier (10/10), Wrong prefix modifier (10/10), Invalid + * characters in literals (21/21), and Invalid characters in expression + * (17/17). + * - `hardTestSuites` with `success: false`: all 10 negative cases are + * accepted: `%7Bvar}`, `{var%7D`, `}%7B%7D`, `%7B}`, + * `{var%7B%7D`, `{list:3}`, `{keys:3}`, `{?list:6}`, `{/keys:4}`, + * and `{count:2}`. + */ +class Template { + expand; + constructor(template: string) { + const { expand } = parseTemplate(template); + this.expand = expand; + } + match = (_: string) => null; +} + +const isOldTest = Deno.env.get("OLD") === "true"; + +const runPairCases = createTemplatePairTest(Template); +test( + "old expand: examples", + { ignore: !isOldTest }, + runPairCases(pairTestSuites), +); + +const runFixedCases = createFixedTemplateTest(Template); +test( + "old expand: fixed templates", + { ignore: !isOldTest }, + runFixedCases(fixedTestSuites), +); + +const runWrongCases = createWrongTemplateTest(Template); +test( + "old parse: invalid templates", + { ignore: !isOldTest }, + runWrongCases(wrongTestSuites), +); + +const runHardCases = createTemplateHardTest(Template); +test( + "old expand: hard cases", + { ignore: !isOldTest }, + runHardCases(hardTestSuites), +); diff --git a/packages/uri-template/package.json b/packages/uri-template/package.json new file mode 100644 index 000000000..ac2aed05a --- /dev/null +++ b/packages/uri-template/package.json @@ -0,0 +1,69 @@ +{ + "name": "@fedify/uri-template", + "version": "2.3.0", + "homepage": "https://fedify.dev/", + "repository": { + "type": "git", + "url": "git+https://github.com/fedify-dev/fedify.git", + "directory": "packages/uri-template" + }, + "bugs": { + "url": "https://github.com/fedify-dev/fedify/issues" + }, + "funding": [ + "https://opencollective.com/fedify", + "https://github.com/sponsors/dahlia" + ], + "engines": { + "deno": ">=2.0.0", + "node": ">=22.0.0", + "bun": ">=1.1.0" + }, + "description": "RFC 6570 URI Template expansion and round-trip pattern matching", + "type": "module", + "main": "./dist/mod.cjs", + "module": "./dist/mod.js", + "types": "./dist/mod.d.ts", + "exports": { + ".": { + "require": { + "types": "./dist/mod.d.cts", + "default": "./dist/mod.cjs" + }, + "import": { + "types": "./dist/mod.d.ts", + "default": "./dist/mod.js" + } + }, + "./package.json": "./package.json" + }, + "scripts": { + "build:self": "tsdown", + "build": "pnpm --filter @fedify/uri-template... run build:self", + "prepack": "pnpm build", + "prepublish": "pnpm build", + "test:bun": "bun test src/**/*.test.ts", + "test": "node --experimental-transform-types --test src/**/*.test.ts" + }, + "keywords": [ + "Fedify", + "URI Template", + "RFC 6570", + "ActivityPub", + "Fediverse" + ], + "author": { + "name": "Chanhaeng Lee", + "email": "2chanhaeng@gmail.com", + "url": "https://chomu.dev/" + }, + "license": "MIT", + "devDependencies": { + "@fedify/fixture": "workspace:^", + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "dependencies": {}, + "sideEffects": false +} diff --git a/packages/uri-template/src/const.ts b/packages/uri-template/src/const.ts new file mode 100644 index 000000000..959597fcc --- /dev/null +++ b/packages/uri-template/src/const.ts @@ -0,0 +1,78 @@ +/** + * Expansion behavior for a URI Template operator. + * + * Used by the expansion module to apply RFC 6570's `first`, `sep`, `named`, + * `ifemp`, and allowed-character rules uniformly. + */ +export interface OperatorSpec { + /** Prefix emitted before the first defined value in the expression. */ + first: string; + /** Separator emitted between defined values or exploded members. */ + sep: string; + /** Whether the expansion emits variable names or associative keys. */ + named: boolean; + /** Suffix emitted after a name when the corresponding value is empty. */ + ifEmpty: string; + /** Whether reserved characters and pct-encoded triplets pass through. */ + allowReserved: boolean; +} + +/** + * Operators implemented by this package, including `""` for simple string + * expansion with no explicit operator. + */ +export const OPERATORS = ["", "+", ".", "/", ";", "?", "&", "#"] as const; + +/** + * Union of supported URI Template operators. + */ +export type Operator = typeof OPERATORS[number]; + +/** + * RFC 6570 operator behavior table used during expansion. + * This table is from RFC 6570 Appendix A. + * + * | | `NUL` | `+` | `.` | `/` | `;` | `?` | `&` | `#` | + * | ----- | ------- | ------- | ------- | ------- | ------ | ------ | ------ | ------- | + * | first | `""` | `""` | `"."` | `"/"` | `";"` | `"?"` | `"&"` | `"#"` | + * | sep | `","` | `","` | `"."` | `"/"` | `";"` | `"&"` | `"&"` | `","` | + * | named | `false` | `false` | `false` | `false` | `true` | `true` | `true` | `false` | + * | ifemp | `""` | `""` | `""` | `""` | `""` | `"="` | `"="` | `""` | + * | allow | `U` | `U+R` | `U` | `U` | `U` | `U` | `U` | `U+R` | + */ +export const operatorSpecs: Record = { + "": { first: "", sep: ",", named: false, ifEmpty: "", allowReserved: false }, + "+": { first: "", sep: ",", named: false, ifEmpty: "", allowReserved: true }, + ".": { + first: ".", + sep: ".", + named: false, + ifEmpty: "", + allowReserved: false, + }, + "/": { + first: "/", + sep: "/", + named: false, + ifEmpty: "", + allowReserved: false, + }, + ";": { first: ";", sep: ";", named: true, ifEmpty: "", allowReserved: false }, + "?": { + first: "?", + sep: "&", + named: true, + ifEmpty: "=", + allowReserved: false, + }, + "&": { + first: "&", + sep: "&", + named: true, + ifEmpty: "=", + allowReserved: false, + }, + "#": { first: "#", sep: ",", named: false, ifEmpty: "", allowReserved: true }, +}; + +// cspell: ignore ifemp diff --git a/packages/uri-template/src/mod.ts b/packages/uri-template/src/mod.ts new file mode 100644 index 000000000..e5b79cd4f --- /dev/null +++ b/packages/uri-template/src/mod.ts @@ -0,0 +1,47 @@ +/** + * [RFC 6570] URI Template expansion and round-trip pattern matching. + * + * [RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570 + * + * @module + */ + +export { Router, RouterError, RouteTemplatePathError } from "./router/mod.ts"; +export type { + RouterOptions, + RouterPathPattern, + RouterRoute, + RouterRouteResult, +} from "./router/mod.ts"; +export { + EmptyExpressionError, + EmptyVarNameError, + InvalidLiteralError, + InvalidPrefixError, + InvalidVarNameError, + InvalidVarSpecError, + NestedOpeningBraceError, + PrefixModifierNotApplicableError, + ReservedOperatorError, + StrayClosingBraceError, + Template, + TemplateExpansionError, + TemplateParseError, + TrailingCommaError, + UnclosedExpressionError, + UnexpectedCharacterError, + UnknownOperatorError, +} from "./template/mod.ts"; +export type { + AssociativeValue, + ExpandContext, + ExpandValue, + Operator, + Path, + PrimitiveValue, + Reporter, + TemplateOptions, + Token, + VarSpec, +} from "./types.ts"; +export { assertPath, isExpression, isPath } from "./utils.ts"; diff --git a/packages/uri-template/src/router/errors.ts b/packages/uri-template/src/router/errors.ts new file mode 100644 index 000000000..e945e5b83 --- /dev/null +++ b/packages/uri-template/src/router/errors.ts @@ -0,0 +1,27 @@ +/** + * Common base class for router-level errors. + */ +export class RouterError extends Error { + /** + * @param message Human-readable summary. + */ + constructor(message: string) { + super(message); + this.name = "RouterError"; + } +} + +/** + * Raised when a route template is not a path template. + */ +export class RouteTemplatePathError extends RouterError { + constructor( + /** + * The route template that failed validation. + */ + public readonly template: string, + ) { + super("Path must start with a slash or a path expansion."); + this.name = "RouteTemplatePathError"; + } +} diff --git a/packages/uri-template/src/router/mod.ts b/packages/uri-template/src/router/mod.ts new file mode 100644 index 000000000..a0e828a15 --- /dev/null +++ b/packages/uri-template/src/router/mod.ts @@ -0,0 +1,8 @@ +export * from "./errors.ts"; +export { default as Router } from "./router.ts"; +export type { + RouterOptions, + RouterPathPattern, + RouterRoute, + RouterRouteResult, +} from "./router.ts"; diff --git a/packages/uri-template/src/router/node.ts b/packages/uri-template/src/router/node.ts new file mode 100644 index 000000000..9cd7833a5 --- /dev/null +++ b/packages/uri-template/src/router/node.ts @@ -0,0 +1,85 @@ +import { + compareRouteEntries, + mergeRouteEntries, + type PrioritizedRouteEntry, +} from "./priority.ts"; + +/** + * Trie node used by the router index. + */ +export default class Node { + readonly #children = new Map>(); + readonly #entries: TEntry[] = []; + #candidates: readonly TEntry[] = []; + + get entries(): readonly TEntry[] { + return this.#entries; + } + + get candidates(): readonly TEntry[] { + return this.#candidates; + } + + child = (key: string): Node | undefined => this.#children.get(key); + + childOrInsert = (key: string): Node => { + const existing = this.#children.get(key); + if (existing != null) return existing; + + const inserted = new Node(); + this.#children.set(key, inserted); + return inserted; + }; + + insert = (entry: TEntry): void => { + this.#entries.splice(this.#insertionIndex(entry), 0, entry); + }; + + remove = (entry: TEntry): void => { + const index = this.#entries.indexOf(entry); + if (index < 0) return; + this.#entries.splice(index, 1); + }; + + insertAll = (entries: TEntry[]): void => { + if (entries.length === 0) return; + if (entries.length === 1) { + this.insert(entries[0]); + return; + } + + entries.sort(compareRouteEntries); + const merged = mergeRouteEntries(this.#entries, entries); + + this.#entries.length = 0; + for (const entry of merged) this.#entries.push(entry); + }; + + rebuildCandidates = (parentCandidates: readonly TEntry[]): void => { + this.#candidates = mergeRouteEntries(parentCandidates, this.#entries); + + for (const child of this.#children.values()) { + child.rebuildCandidates(this.#candidates); + } + }; + + #insertionIndex(entry: TEntry): number { + let low = 0; + let high = this.#entries.length; + + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (this.#compare(this.#entries[middle], entry) <= 0) { + low = middle + 1; + } else { + high = middle; + } + } + + return low; + } + + #compare(left: TEntry, right: TEntry): number { + return compareRouteEntries(left, right); + } +} diff --git a/packages/uri-template/src/router/priority.ts b/packages/uri-template/src/router/priority.ts new file mode 100644 index 000000000..91a8bcb16 --- /dev/null +++ b/packages/uri-template/src/router/priority.ts @@ -0,0 +1,40 @@ +export interface PrioritizedRouteEntry { + readonly index: number; + readonly initialLiteralPrefix: string; + readonly literalLength: number; + readonly variableCount: number; +} + +export const compareRouteEntries = ( + left: PrioritizedRouteEntry, + right: PrioritizedRouteEntry, +): number => + right.literalLength - left.literalLength || + right.initialLiteralPrefix.length - left.initialLiteralPrefix.length || + left.variableCount - right.variableCount || + left.index - right.index; + +export const mergeRouteEntries = ( + left: readonly TEntry[], + right: readonly TEntry[], +): readonly TEntry[] => { + if (left.length < 1) return right; + if (right.length < 1) return left; + + const merged: TEntry[] = []; + let leftIndex = 0; + let rightIndex = 0; + + while (leftIndex < left.length && rightIndex < right.length) { + if (compareRouteEntries(left[leftIndex], right[rightIndex]) <= 0) { + merged.push(left[leftIndex++]); + } else { + merged.push(right[rightIndex++]); + } + } + + while (leftIndex < left.length) merged.push(left[leftIndex++]); + while (rightIndex < right.length) merged.push(right[rightIndex++]); + + return merged; +}; diff --git a/packages/uri-template/src/router/router.bench.ts b/packages/uri-template/src/router/router.bench.ts new file mode 100644 index 000000000..5d94c9db6 --- /dev/null +++ b/packages/uri-template/src/router/router.bench.ts @@ -0,0 +1,67 @@ +import { + createDeepPrefixRouterTest, + createDynamicRoutesTest, + createInactiveEntriesTest, + createRouterBuildPathsBench, + createRouterCompileAndAddBench, + createRouterFirstRouteAfterBuildBench, + createRouterRouteHitsBench, + createRouterRouteMissesBench, + createRoutesPressureTest, + routerBuildCases, + routerHitPaths, + routerMissPaths, + routerRouteDefinitions, +} from "../tests/mod.ts"; +import Router from "./router.ts"; + +const runCompileAndAddRoutes = createRouterCompileAndAddBench(Router); +Deno.bench( + "Router: compile and add routes", + runCompileAndAddRoutes(routerRouteDefinitions, "actor"), +); + +const runRouteHits = createRouterRouteHitsBench(Router); +Deno.bench( + "Router: route mixed hits", + runRouteHits(routerRouteDefinitions, routerHitPaths), +); + +const runRouteMisses = createRouterRouteMissesBench(Router); +Deno.bench( + "Router: route misses", + runRouteMisses(routerRouteDefinitions, routerMissPaths), +); + +const runBuildPaths = createRouterBuildPathsBench(Router); +Deno.bench( + "Router: build paths", + runBuildPaths(routerRouteDefinitions, routerBuildCases), +); + +const runFirstRouteAfterBuild = createRouterFirstRouteAfterBuildBench(Router); +for ( + const scenario of [ + createRoutesPressureTest(), + createDeepPrefixRouterTest(), + createDynamicRoutesTest(), + createInactiveEntriesTest(), + ] +) { + Deno.bench( + `Router: ${scenario.name}: compile and add routes`, + runCompileAndAddRoutes(scenario.routeDefinitions, scenario.routeName), + ); + Deno.bench( + `Router: ${scenario.name}: first route after build`, + runFirstRouteAfterBuild(scenario.routeDefinitions, scenario.hitPaths[0]), + ); + Deno.bench( + `Router: ${scenario.name}: route hits`, + runRouteHits(scenario.routeDefinitions, scenario.hitPaths), + ); + Deno.bench( + `Router: ${scenario.name}: route misses`, + runRouteMisses(scenario.routeDefinitions, scenario.missPaths), + ); +} diff --git a/packages/uri-template/src/router/router.test.ts b/packages/uri-template/src/router/router.test.ts new file mode 100644 index 000000000..c1102175a --- /dev/null +++ b/packages/uri-template/src/router/router.test.ts @@ -0,0 +1,322 @@ +import { test } from "@fedify/fixture"; +import { deepEqual, equal } from "node:assert/strict"; +import { + createRouterAddTest, + createRouterBuildTest, + createRouterCloneTest, + createRouterCompileErrorTest, + createRouterRouteTest, + createRouterVariablesTest, + routerBuildTestSuites, + routerCloneTestSuites, + routerCompileErrorCases, + routerRouteDefinitions, + routerRouteTestSuites, + routerVariablesCases, +} from "../tests/mod.ts"; +import type { Path } from "../types.ts"; +import Router, { type RouterRoute } from "./router.ts"; + +const runAddCases = createRouterAddTest(Router); +test("Router.add()", runAddCases(routerRouteDefinitions)); + +const runCompileErrorCases = createRouterCompileErrorTest(Router); +test( + "Router.compile() rejects invalid templates", + runCompileErrorCases(routerCompileErrorCases), +); + +const runVariablesCases = createRouterVariablesTest(Router); +test("Router.variables()", runVariablesCases(routerVariablesCases)); + +const runCloneCases = createRouterCloneTest(Router); +test("Router.clone()", runCloneCases(routerCloneTestSuites)); + +const runRouteCases = createRouterRouteTest(Router); +for ( + const { name, options, routeDefinitions, cases } of routerRouteTestSuites +) { + test( + `Router.route(): ${name}`, + runRouteCases(routeDefinitions, options)(cases), + ); +} + +const runBuildCases = createRouterBuildTest(Router); +for ( + const { name, options, routeDefinitions, cases } of routerBuildTestSuites +) { + test( + `Router.build(): ${name}`, + runBuildCases(routeDefinitions, options)(cases), + ); +} + +const sampleRoutes: readonly RouterRoute[] = [ + ["/users/{id}", "user"] as const, + ["/posts/{id}", "post"] as const, + ["/users/{id}/posts/{postId}", "userPost"] as const, +]; + +test("Router#register() registers all routes in one call", async (t) => { + const router = new Router(); + router.register(sampleRoutes); + + await t.step("registers every name", () => { + equal(router.has("user"), true); + equal(router.has("post"), true); + equal(router.has("userPost"), true); + }); + + await t.step("matches routes equivalently to repeated add()", () => { + deepEqual(router.route("/users/42" as Path), { + name: "user", + template: "/users/{id}", + values: { id: "42" }, + }); + deepEqual(router.route("/users/42/posts/7" as Path), { + name: "userPost", + template: "/users/{id}/posts/{postId}", + values: { id: "42", postId: "7" }, + }); + }); + + await t.step("preserves insertion order against later add()", () => { + const reference = new Router(); + for (const [path, name] of sampleRoutes) { + reference.add(path, name); + } + deepEqual( + router.route("/users/42" as Path), + reference.route("/users/42" as Path), + ); + }); + + await t.step("accepts non-array iterables", () => { + function* iter(): Generator { + for (const route of sampleRoutes) yield route; + } + const fromGenerator = new Router(); + fromGenerator.register(iter()); + equal(fromGenerator.has("userPost"), true); + }); +}); + +test("Router accepts pre-parsed RouterPathPattern", async (t) => { + const pattern = Router.compile("/items/{id}" as Path); + + await t.step("via add()", () => { + const router = new Router(); + router.add(pattern, "item"); + equal(router.has("item"), true); + deepEqual(router.route("/items/9" as Path), { + name: "item", + template: "/items/{id}", + values: { id: "9" }, + }); + }); + + await t.step("via register()", () => { + const router = new Router(); + router.register([[pattern, "item"]]); + equal(router.has("item"), true); + }); + + await t.step("via constructor", () => { + const router = new Router([[pattern, "item"]]); + equal(router.has("item"), true); + }); + + await t.step("via Router.from()", () => { + const router = Router.from([[pattern, "item"]]); + equal(router.has("item"), true); + }); +}); + +test("Router trailing slash retry accepts empty root path", () => { + const router = new Router([["", "root"]], { + trailingSlashInsensitive: true, + }); + + deepEqual(router.route("/"), { + name: "root", + template: "", + values: {}, + }); +}); + +test("Router constructor argument variants", async (t) => { + await t.step("no arguments builds an empty router", () => { + const router = new Router(); + equal(router.has("user"), false); + equal(router.trailingSlashInsensitive, false); + }); + + await t.step("options only", () => { + const router = new Router({ trailingSlashInsensitive: true }); + equal(router.trailingSlashInsensitive, true); + equal(router.has("user"), false); + }); + + await t.step("routes only", () => { + const router = new Router(sampleRoutes); + equal(router.has("user"), true); + equal(router.trailingSlashInsensitive, false); + }); + + await t.step("routes and options together", () => { + const router = new Router(sampleRoutes, { + trailingSlashInsensitive: true, + }); + equal(router.has("user"), true); + equal(router.trailingSlashInsensitive, true); + }); +}); + +test("Router treats re-registration as replacement", async (t) => { + await t.step("add() replaces a previous add() with the same name", () => { + const router = new Router(); + router.add("/old/{id}" as Path, "user"); + router.add("/new/{id}" as Path, "user"); + + equal(router.route("/old/1" as Path), null); + deepEqual(router.route("/new/1" as Path), { + name: "user", + template: "/new/{id}", + values: { id: "1" }, + }); + equal(router.build("user", { id: "1" }), "/new/1"); + }); + + await t.step("register() replaces previously registered names", () => { + const router = new Router(); + router.register([ + ["/a/{id}" as Path, "user"], + ["/b/{id}" as Path, "post"], + ]); + router.register([ + ["/c/{id}" as Path, "user"], + ["/d/{id}" as Path, "post"], + ]); + + equal(router.route("/a/1" as Path), null); + equal(router.route("/b/1" as Path), null); + equal(router.route("/c/1" as Path)?.name, "user"); + equal(router.route("/d/1" as Path)?.name, "post"); + }); + + await t.step("register() de-duplicates names within a single call", () => { + const router = new Router(); + router.register([ + ["/a/{id}" as Path, "user"], + ["/b/{id}" as Path, "user"], + ]); + + equal(router.route("/a/1" as Path), null); + deepEqual(router.route("/b/1" as Path), { + name: "user", + template: "/b/{id}", + values: { id: "1" }, + }); + }); + + await t.step("constructor de-duplicates names in the input iterable", () => { + const router = new Router([ + ["/v1/{id}" as Path, "user"], + ["/v2/{id}" as Path, "user"], + ]); + + equal(router.route("/v1/1" as Path), null); + equal(router.route("/v2/1" as Path)?.name, "user"); + }); + + await t.step("only the latest survives repeated re-registration", () => { + const router = new Router(); + for (let i = 0; i < 50; i++) { + router.add(`/v${i}/{id}` as Path, "user"); + } + + equal(router.route("/v0/1" as Path), null); + equal(router.route("/v25/1" as Path), null); + deepEqual(router.route("/v49/1" as Path), { + name: "user", + template: "/v49/{id}", + values: { id: "1" }, + }); + }); + + await t.step( + "mixed add() / register() preserves replacement semantics", + () => { + const router = new Router(); + router.add("/old-a/{id}" as Path, "a"); + router.add("/old-b/{id}" as Path, "b"); + router.register([ + ["/new-a/{id}" as Path, "a"], + ["/new-b/{id}" as Path, "b"], + ]); + + equal(router.route("/old-a/1" as Path), null); + equal(router.route("/old-b/1" as Path), null); + equal(router.route("/new-a/1" as Path)?.name, "a"); + equal(router.route("/new-b/1" as Path)?.name, "b"); + }, + ); + + await t.step("sibling routes survive re-registration of another name", () => { + const router = new Router(); + router.register([ + ["/users/{id}" as Path, "user"], + ["/posts/{id}" as Path, "post"], + ]); + router.add("/people/{id}" as Path, "user"); + + equal(router.route("/users/1" as Path), null); + equal(router.route("/people/1" as Path)?.name, "user"); + equal(router.route("/posts/9" as Path)?.name, "post"); + }); + + await t.step( + "clone() after re-registration reflects only active routes", + () => { + const router = new Router(); + router.add("/old/{id}" as Path, "user"); + router.add("/new/{id}" as Path, "user"); + + const cloned = router.clone(); + equal(cloned.has("user"), true); + equal(cloned.route("/old/1" as Path), null); + deepEqual(cloned.route("/new/1" as Path), { + name: "user", + template: "/new/{id}", + values: { id: "1" }, + }); + }, + ); +}); + +test("Router.from() mirrors the constructor", async (t) => { + await t.step("no arguments", () => { + const router = Router.from(); + equal(router.has("user"), false); + equal(router.trailingSlashInsensitive, false); + }); + + await t.step("options only", () => { + const router = Router.from({ trailingSlashInsensitive: true }); + equal(router.trailingSlashInsensitive, true); + }); + + await t.step("routes only", () => { + const router = Router.from(sampleRoutes); + equal(router.has("post"), true); + }); + + await t.step("routes and options together", () => { + const router = Router.from(sampleRoutes, { + trailingSlashInsensitive: true, + }); + equal(router.has("post"), true); + equal(router.trailingSlashInsensitive, true); + }); +}); diff --git a/packages/uri-template/src/router/router.ts b/packages/uri-template/src/router/router.ts new file mode 100644 index 000000000..8347dd152 --- /dev/null +++ b/packages/uri-template/src/router/router.ts @@ -0,0 +1,340 @@ +import { Template } from "../template/mod.ts"; +import type { ExpandContext, Path, Token } from "../types.ts"; +import { isExpression, isPath } from "../utils.ts"; +import { RouteTemplatePathError } from "./errors.ts"; +import Trie from "./trie.ts"; + +/** + * Options for the {@link Router}. + */ +export interface RouterOptions { + /** + * Whether to ignore trailing slashes when matching paths. + */ + trailingSlashInsensitive?: boolean; +} + +/** + * The result of {@link Router.route}. + */ +export interface RouterRouteResult { + /** + * The matched route name. + */ + name: string; + + /** + * The URI template of the matched route. + */ + template: Path; + + /** + * The values extracted from the URI. + */ + values: Record; +} + +/** + * Parsed path template ready to be registered in a {@link Router}. + */ +export interface RouterPathPattern { + /** + * The original path template string. + */ + readonly path: Path; + + /** + * Parsed URI Template. + */ + readonly template: Template; + + /** + * Variable names found in the template. + */ + readonly variables: ReadonlySet; +} + +/** + * Route definition accepted by {@link Router#register}, the {@link Router} + * constructor, and {@link Router.from}. The first element is either a path + * template string or a pre-parsed {@link RouterPathPattern} from + * {@link Router.compile}; the second element is the route name. + */ +export type RouterRoute = readonly [ + pathOrPattern: Path | RouterPathPattern, + name: string, +]; + +interface RouteEntry { + readonly index: number; + readonly name: string; + readonly pattern: RouterPathPattern; + readonly initialLiteralPrefix: string; + readonly literalLength: number; + readonly variableCount: number; +} + +/** + * Router that resolves URIs against registered RFC 6570 templates. + */ +export default class Router { + readonly #trie: Trie; + readonly #routesByName: Map; + #nextIndex: number; + + /** + * Whether to ignore trailing slashes when matching paths. + */ + trailingSlashInsensitive: boolean; + + /** + * Create a new {@link Router}. + * + * The first argument may be an iterable of routes, an options object, or + * omitted. When two arguments are passed, they are interpreted as + * `(routes, options)`. + * + * @param routes Routes to register on the new router. + * @param options Options for the router. + */ + constructor(routes: Iterable, options?: RouterOptions); + constructor(options?: RouterOptions); + constructor( + routesOrOptions?: Iterable | RouterOptions, + maybeOptions?: RouterOptions, + ) { + const routes = isRoutesArgument(routesOrOptions) + ? routesOrOptions + : undefined; + const options = isRoutesArgument(routesOrOptions) + ? maybeOptions + : routesOrOptions; + + this.#trie = new Trie(); + this.#routesByName = new Map(); + this.#nextIndex = 0; + this.trailingSlashInsensitive = options?.trailingSlashInsensitive ?? false; + + if (routes != null) this.register(routes); + } + + /** + * Creates a new {@link Router}. Mirrors the constructor argument + * interface and is provided for ergonomic call sites that prefer a + * static factory over `new`. + */ + static from(routes: Iterable, options?: RouterOptions): Router; + static from(options?: RouterOptions): Router; + static from( + routesOrOptions?: Iterable | RouterOptions, + options?: RouterOptions, + ): Router { + return new Router(routesOrOptions as Iterable, options); + } + + clone = (): Router => + new Router( + this.#activeEntries(), + { trailingSlashInsensitive: this.trailingSlashInsensitive }, + ); + + /** + * Compiles a path template without registering it in a router. + * @param path The path pattern. + * @returns A parsed path pattern. + */ + static compile(path: Path): RouterPathPattern { + if (!isPath(path)) { + throw new RouteTemplatePathError(path); + } + + const template = Template.parse(path); + return { + path, + template, + variables: collectVariables(template.tokens), + }; + } + + /** + * Returns the variable names in a path template without registering it. + * @param path The path pattern. + * @returns The names of the variables in the path pattern. + */ + static variables = (path: Path): Set => + new Set(Router.compile(path).variables); + + /** + * Checks if a path name exists in the router. + * @param name The name of the path. + * @returns `true` if the path name exists, otherwise `false`. + */ + has = (name: string): boolean => this.#routesByName.has(name); + + /** + * Adds a new path rule to the router. + * @param pathOrPattern The path template, or a pre-parsed + * {@link RouterPathPattern} produced by + * {@link Router.compile}. + * @param name The name of the path. + */ + add = (pathOrPattern: Path | RouterPathPattern, name: string): void => { + const pattern = resolvePathPattern(pathOrPattern); + const previous = this.#routesByName.get(name); + if (previous != null) this.#trie.remove(previous); + + const entry = createRouteEntry({ index: this.#nextIndex++, name, pattern }); + + this.#routesByName.set(name, entry); + this.#trie.insert(entry); + }; + + /** + * Registers multiple path rules at once. Compared to calling {@link add} + * in a loop, this batches trie insertions into one sorted merge per + * affected node, which lowers the asymptotic cost of bulk registration. + * @param routes Iterable of `[pathOrPattern, name]` pairs to register. + */ + register = (routes: Iterable): void => { + const entries: RouteEntry[] = []; + const pendingByName = new Map(); + + for (const [pathOrPattern, name] of routes) { + const pending = pendingByName.get(name); + if (pending != null) { + const index = entries.indexOf(pending); + if (index >= 0) entries.splice(index, 1); + } else { + const committed = this.#routesByName.get(name); + if (committed != null) this.#trie.remove(committed); + } + + const pattern = resolvePathPattern(pathOrPattern); + const entry = createRouteEntry({ + index: this.#nextIndex++, + name, + pattern, + }); + + this.#routesByName.set(name, entry); + pendingByName.set(name, entry); + entries.push(entry); + } + + this.#trie.insertAll(entries); + }; + + /** + * Resolves a path name and values from a URI, if any match. + * @param url The URI to resolve. + * @returns The name of the path and its values, if any match. Otherwise, + * `null`. + */ + route = (url: Path): RouterRouteResult | null => { + const match = this.#route(url); + if (match != null || !this.trailingSlashInsensitive) return match; + + const retryUrl = toggleTrailingSlash(url); + return retryUrl == null ? null : this.#route(retryUrl); + }; + + #route(url: Path): RouterRouteResult | null { + for (const entry of this.#trie.candidates(url)) { + const context = entry.pattern.template.match(url); + if (context == null) continue; + + const values = toRouteValues(context); + if (values == null) continue; + + return { + name: entry.name, + template: entry.pattern.path, + values, + }; + } + + return null; + } + + /** + * Constructs a URL/path from a path name and values. + * @param name The name of the path. + * @param values The values to expand the path with. + * @returns The URL/path, if the name exists. Otherwise, `null`. + */ + build = (name: string, values: Record): Path | null => + (this.#routesByName.get(name) + ?.pattern.template.expand(values) ?? null) as Path | null; + + #activeEntries = (): RouterRoute[] => + Array.from(this.#routesByName.values()) + .sort((left, right) => left.index - right.index) + .map((entry): RouterRoute => [entry.pattern, entry.name]); +} + +interface CreateRouteEntryOptions { + readonly index: number; + readonly name: string; + readonly pattern: RouterPathPattern; +} + +const createRouteEntry = ({ + index, + name, + pattern, +}: CreateRouteEntryOptions): RouteEntry => ({ + index, + name, + pattern, + initialLiteralPrefix: getInitialLiteralPrefix(pattern.template.tokens), + literalLength: getLiteralLength(pattern.template.tokens), + variableCount: pattern.variables.size, +}); + +const resolvePathPattern = ( + value: Path | RouterPathPattern, +): RouterPathPattern => + typeof value === "string" ? Router.compile(value) : value; + +const isRoutesArgument = ( + value: Iterable | RouterOptions | undefined, +): value is Iterable => + value != null && + typeof value === "object" && + Symbol.iterator in (value as object); + +const toggleTrailingSlash = (path: Path): Path | null => { + if (!path.endsWith("/")) return `${path}/`; + + const trimmed = path.replace(/\/+$/, ""); + return isPath(trimmed) ? trimmed : null; +}; + +const collectVariables = (tokens: readonly Token[]): Set => + new Set( + tokens + .filter(isExpression) + .flatMap((token) => token.vars.map((varSpec) => varSpec.name)), + ); + +const getInitialLiteralPrefix = (tokens: readonly Token[]): string => + tokens[0]?.kind === "literal" ? tokens[0].text : ""; + +const getLiteralLength = (tokens: readonly Token[]): number => + tokens.reduce( + (sum, token) => token.kind === "literal" ? sum + token.text.length : sum, + 0, + ); + +const toRouteValues = ( + context: ExpandContext, +): Record | null => { + const values: Record = {}; + + for (const [key, value] of Object.entries(context)) { + if (typeof value !== "string") return null; + values[key] = value; + } + + return values; +}; diff --git a/packages/uri-template/src/router/trie.ts b/packages/uri-template/src/router/trie.ts new file mode 100644 index 000000000..4eb27320c --- /dev/null +++ b/packages/uri-template/src/router/trie.ts @@ -0,0 +1,88 @@ +import type { Path } from "../types.ts"; +import Node from "./node.ts"; + +interface TrieEntry { + readonly index: number; + readonly initialLiteralPrefix: string; + readonly literalLength: number; + readonly variableCount: number; +} + +/** + * Prefix trie for registered route candidates. + */ +export default class Trie { + readonly #root = new Node(); + #dirty = true; + + insert = (entry: TEntry): void => { + let node = this.#root; + + for (const char of entry.initialLiteralPrefix) { + node = node.childOrInsert(char); + } + + node.insert(entry); + this.#dirty = true; + }; + + remove = (entry: TEntry): void => { + let node = this.#root; + + for (const char of entry.initialLiteralPrefix) { + const child = node.child(char); + if (child == null) return; + node = child; + } + + node.remove(entry); + this.#dirty = true; + }; + + insertAll = (entries: readonly TEntry[]): void => { + if (entries.length === 0) return; + + const buckets = new Map, TEntry[]>(); + + for (const entry of entries) { + let node = this.#root; + for (const char of entry.initialLiteralPrefix) { + node = node.childOrInsert(char); + } + const bucket = buckets.get(node); + if (bucket == null) buckets.set(node, [entry]); + else bucket.push(entry); + } + + for (const [node, bucket] of buckets) { + node.insertAll(bucket); + } + + this.#dirty = true; + }; + + *candidates(path: Path): Generator { + if (this.#dirty) this.#rebuildCandidates(); + + for (const entry of this.#deepestNode(path).candidates) { + yield entry; + } + } + + #deepestNode(path: Path): Node { + let node = this.#root; + + for (const char of path) { + const child = node.child(char); + if (child == null) return node; + node = child; + } + + return node; + } + + #rebuildCandidates = (): void => { + this.#root.rebuildCandidates([]); + this.#dirty = false; + }; +} diff --git a/packages/uri-template/src/template/encoding.ts b/packages/uri-template/src/template/encoding.ts new file mode 100644 index 000000000..96c2f4372 --- /dev/null +++ b/packages/uri-template/src/template/encoding.ts @@ -0,0 +1,214 @@ +const textEncoder = new TextEncoder(); + +const hexDigits = "0123456789ABCDEF"; + +/** + * Returns whether a character is an RFC 3986 hexadecimal digit. + * + * Used by parsers and encoders when recognizing pct-encoded triplets. + */ +export const isHexDigit: (char: string) => boolean = ( + char: string, +): boolean => + some( + between(0x30, 0x39), + between(0x41, 0x46), + between(0x61, 0x66), + )(char.charCodeAt(0)); + +/** + * Returns whether `value[index]` starts a complete pct-encoded triplet. + */ +export const isPctEncodedAt: ( + value: string, + index: number, +) => boolean = (value: string, index: number): boolean => + value[index] === "%" && + index + 2 < value.length && + isHexDigit(value[index + 1]) && + isHexDigit(value[index + 2]); + +/** + * Returns the UTF-16 length of an RFC 6570 `varchar` at `index`, or `0` when + * no varchar starts there. + */ +export function isVarcharAt(value: string, index: number): number { + const char = value[index]; + if (char == null) return 0; + if (some(isAlpha, isDigit, eq("_"))(char)) return 1; + return isPctEncodedAt(value, index) ? 3 : 0; +} + +/** + * Returns the UTF-16 length of an RFC 6570 literal token at `index`, or `0` + * when the character is not valid literal syntax. + */ +export function isLiteralAt(value: string, index: number): number { + if (isPctEncodedAt(value, index)) return 3; + const { char, size } = readCodePoint(value, index); + return isLiteralChar(char) ? size : 0; +} + +/** + * Reads one Unicode code point from a JavaScript string. + */ +export function readCodePoint( + value: string, + index: number, +): { char: string; size: number } { + const codePoint = value.codePointAt(index); + if (codePoint == null) return { char: "", size: 0 }; + const size = codePoint > 0xffff ? 2 : 1; + return { char: value.slice(index, index + size), size }; +} + +/** + * Percent-encodes an expanded variable value according to the operator's + * allowed-character rule. + */ +export const encodeValue: ( + allowReserved: boolean, +) => (value: string) => string = ( + allowReserved: boolean, +): (value: string) => string => +(value: string): string => { + let encoded = ""; + for (let index = 0; index < value.length;) { + if (allowReserved && isPctEncodedAt(value, index)) { + encoded += value.slice(index, index + 3); + index += 3; + continue; + } + + const { char, size } = readCodePoint(value, index); + encoded += some( + isUnreserved, + (char: string): boolean => allowReserved && isReserved(char), + )(char) + ? char + : percentEncode(char); + index += size; + } + return encoded; +}; + +/** + * Percent-encodes a variable name or associative key for named expansions. + */ +export function encodeName(value: string): string { + let encoded = ""; + for (let index = 0; index < value.length;) { + if (isPctEncodedAt(value, index)) { + encoded += value.slice(index, index + 3); + index += 3; + continue; + } + + const { char, size } = readCodePoint(value, index); + encoded += isUnreserved(char) ? char : percentEncode(char); + index += size; + } + return encoded; +} + +/** + * Returns the first `length` RFC 6570 prefix characters without splitting a + * Unicode code point or a pct-encoded triplet. + */ +export function truncateValue(value: string, length: number): string { + let truncated = ""; + let count = 0; + for (let index = 0; index < value.length && count < length; count++) { + if (isPctEncodedAt(value, index)) { + truncated += value.slice(index, index + 3); + index += 3; + continue; + } + + const { char, size } = readCodePoint(value, index); + truncated += char; + index += size; + } + return truncated; +} + +const isAlpha: (char: string) => boolean = (char: string): boolean => + some(between(0x41, 0x5a), between(0x61, 0x7a))(char.charCodeAt(0)); + +const isDigit: (char: string) => boolean = (char: string): boolean => + between(0x30, 0x39)(char.charCodeAt(0)); + +const isUnreserved: (char: string) => boolean = (char: string): boolean => + some( + isAlpha, + isDigit, + (char: string) => "-._~".includes(char), + )(char); + +const isReserved: (char: string) => boolean = (char: string): boolean => + ":/?#[]@!$&'()*+,;=".includes(char); + +const isLiteralChar: (char: string) => boolean = (char: string): boolean => + isLiteralCodePoint(char.codePointAt(0)); + +const isLiteralCodePoint: ( + code: number | undefined, +) => boolean = (code: number | undefined): boolean => + code != null && + some( + eq(0x21), + between(0x23, 0x24), + eq(0x26), + between(0x28, 0x3b), + eq(0x3d), + between(0x3f, 0x5b), + eq(0x5d), + eq(0x5f), + between(0x61, 0x7a), + eq(0x7e), + isUcsChar, + isIPrivate, + )(code); + +function isUcsChar(code: number): boolean { + if (code < 0x10000) { + return some( + between(0xa0, 0xd7ff), + between(0xf900, 0xfdcf), + between(0xfdf0, 0xffef), + )(code); + } + + if (code > 0xefffd) return false; + const offset = code % 0x10000; + return offset <= 0xfffd && (code < 0xe0000 || offset >= 0x1000); +} + +const isIPrivate: (code: number) => boolean = (code: number): boolean => + some( + between(0xe000, 0xf8ff), + between(0xf0000, 0xffffd), + between(0x100000, 0x10fffd), + )(code); + +const percentEncode: (char: string) => string = (char: string): string => + Array.from(textEncoder.encode(char)) + .map((byte) => `%${hexDigits[byte >> 4]}${hexDigits[byte & 0x0f]}`) + .join(""); + +const between: ( + min: number, + max: number, +) => (num: number) => boolean = + (min: number, max: number): (num: number) => boolean => + (num: number): boolean => min <= num && num <= max; + +const eq: (a: T) => (b: T) => boolean = + (a: T): (b: T) => boolean => (b: T): boolean => a === b; + +const some: ( + ...preds: ((arg: T) => boolean)[] +) => (arg: T) => boolean = + (...preds: ((arg: T) => boolean)[]): (arg: T) => boolean => + (arg: T): boolean => preds.some((pred) => pred(arg)); +// cspell: ignore preds diff --git a/packages/uri-template/src/template/errors.ts b/packages/uri-template/src/template/errors.ts new file mode 100644 index 000000000..9bad699a5 --- /dev/null +++ b/packages/uri-template/src/template/errors.ts @@ -0,0 +1,364 @@ +/** + * Errors raised when an RFC 6570 URI template fails to parse or expand. + * + * Parse-time hierarchy: + * + * ~~~~ + * TemplateParseError + * ├── UnclosedExpressionError + * ├── StrayClosingBraceError + * ├── NestedOpeningBraceError + * ├── EmptyExpressionError + * ├── ReservedOperatorError + * ├── UnknownOperatorError + * ├── InvalidLiteralError + * ├── InvalidVarSpecError + * │ ├── EmptyVarNameError + * │ ├── InvalidVarNameError + * │ ├── InvalidPrefixError + * │ └── TrailingCommaError + * └── UnexpectedCharacterError + * ~~~~ + * + * Expansion-time hierarchy: + * + * ~~~~ + * TemplateExpansionError + * └── PrefixModifierNotApplicableError + * ~~~~ + * + * Parse errors carry the original `template` and the 0-based `position` where + * the offending input was located. Expansion errors carry the runtime variable + * name whose value cannot be expanded. + * + * @module + */ + +/** + * Common base class for every parse-time error produced by the RFC 6570 parser. + */ +export class TemplateParseError extends Error { + /** + * @param template The full URI template string that was being parsed. + * @param position 0-based index into `template` where the problem was + * detected. When the offending input spans a range, + * this is the start of that range. + * @param hint Short, actionable instruction for the user. + * @param message Human-readable summary. + */ + constructor( + public readonly template: string, + public readonly position: number, + public readonly hint: string, + message: string, + ) { + super(`${message} (at position ${position}): ${hint}`); + this.name = "TemplateParseError"; + } + throw(): never { + throw this; + } +} + +/** + * Raised when an opening `{` has no matching `}` before the template ends. + * + * Fix: close the expression with `}` or pct-encode the literal `{` as `%7B`. + * RFC 6570 does not define an escape syntax. + */ +export class UnclosedExpressionError extends TemplateParseError { + constructor(template: string, position: number) { + super( + template, + position, + "Add the missing '}' to close the expression, or remove the stray '{'.", + "Unclosed expression: '{' has no matching '}'", + ); + this.name = "UnclosedExpressionError"; + } +} + +/** + * Raised when a `}` appears outside of any expression. + * + * Fix: remove the stray `}` or precede it with a matching `{`. + */ +export class StrayClosingBraceError extends TemplateParseError { + constructor(template: string, position: number) { + super( + template, + position, + "Remove this stray '}' or add a matching '{' before it.", + "Stray '}' outside of any expression", + ); + this.name = "StrayClosingBraceError"; + } +} + +/** + * Raised when a `{` appears inside another expression before that expression + * is closed. RFC 6570 expressions cannot nest. + * + * Fix: close the outer expression with `}` before opening a new one. + */ +export class NestedOpeningBraceError extends TemplateParseError { + constructor(template: string, position: number) { + super( + template, + position, + "RFC 6570 expressions cannot nest. Close the outer expression with " + + "'}' before starting a new one.", + "Nested '{' inside an unclosed expression", + ); + this.name = "NestedOpeningBraceError"; + } +} + +/** + * Raised when a literal section of the template contains a character that is + * outside the RFC 6570 `literals` set: CTL, SP, `"`, `'`, lone `%`, `<`, `>`, + * `\\`, `^`, `` ` ``, `|`. + * + * Fix: pct-encode the offending character or remove it. + */ +export class InvalidLiteralError extends TemplateParseError { + constructor( + template: string, + position: number, + public readonly char: string, + ) { + super( + template, + position, + `Pct-encode '${char}' (e.g. '%${ + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0") + }') or remove it. Literals may not contain CTL, SP, '\"', '\\'', lone ` + + "'%', '<', '>', '\\\\', '^', '`', or '|'.", + `Invalid literal character '${char}'`, + ); + this.name = "InvalidLiteralError"; + } +} + +/** + * Raised for `{}` — an expression that contains neither operator nor varspec. + * + * Fix: insert at least one varname between the braces, e.g. `{var}`. + */ +export class EmptyExpressionError extends TemplateParseError { + constructor(template: string, position: number) { + super( + template, + position, + "Provide at least one varname inside the braces, e.g. '{var}'.", + "Empty expression '{}'", + ); + this.name = "EmptyExpressionError"; + } +} + +/** + * Raised when the operator slot holds one of the characters reserved by + * RFC 6570 §2.2 for future extensions: `=`, `,`, `!`, `@`, `|`. + * + * Fix: drop the reserved operator or replace it with one of the implemented + * operators (`+`, `#`, `.`, `/`, `;`, `?`, `&`). + */ +export class ReservedOperatorError extends TemplateParseError { + constructor( + template: string, + position: number, + public readonly operator: string, + ) { + super( + template, + position, + `Operator '${operator}' is reserved by RFC 6570 §2.2 for future ` + + "extensions. Use one of '+', '#', '.', '/', ';', '?', '&' instead, " + + "or remove the operator.", + `Reserved operator '${operator}'`, + ); + this.name = "ReservedOperatorError"; + } +} + +/** + * Raised when the operator slot holds a character that is neither a defined + * RFC 6570 operator nor part of the varname grammar. + * + * Fix: use one of the implemented operators (`+`, `#`, `.`, `/`, `;`, `?`, + * `&`) or remove the character. + */ +export class UnknownOperatorError extends TemplateParseError { + constructor( + template: string, + position: number, + public readonly operator: string, + ) { + super( + template, + position, + `Replace '${operator}' with one of '+', '#', '.', '/', ';', '?', '&' ` + + "or remove it.", + `Unknown operator '${operator}'`, + ); + this.name = "UnknownOperatorError"; + } +} + +/** + * Common base for malformed varspec errors so users can `instanceof`-filter. + */ +export class InvalidVarSpecError extends TemplateParseError { + constructor( + template: string, + position: number, + hint: string, + message: string, + public readonly varSpec: string, + ) { + super(template, position, hint, message); + this.name = "InvalidVarSpecError"; + } +} + +/** + * Raised when a varspec contains no varname (e.g. `{,foo}` or `{foo,}`). + */ +export class EmptyVarNameError extends InvalidVarSpecError { + constructor(template: string, position: number) { + super( + template, + position, + "Remove the stray comma or insert a varname before/after it.", + "Empty varname in variable-list", + "", + ); + this.name = "EmptyVarNameError"; + } +} + +/** + * Raised when a varname contains characters outside the RFC 6570 varchar set + * (`ALPHA / DIGIT / "_" / pct-encoded`, optionally separated by `.`). + */ +export class InvalidVarNameError extends InvalidVarSpecError { + constructor( + template: string, + position: number, + varSpec: string, + public readonly offendingChar: string, + ) { + super( + template, + position, + "Varnames may only contain ALPHA, DIGIT, '_', '.', or pct-encoded " + + `triplets. Replace '${offendingChar}' or pct-encode it.`, + `Invalid character '${offendingChar}' in varname`, + varSpec, + ); + this.name = "InvalidVarNameError"; + } +} + +/** + * Raised when a prefix modifier (`:N`) is malformed: missing digits, leading + * zero, or `N` outside the range `1..9999`. + */ +export class InvalidPrefixError extends InvalidVarSpecError { + constructor( + template: string, + position: number, + varSpec: string, + public readonly prefix: string, + ) { + super( + template, + position, + "Prefix modifiers must be ':N' where N is a positive integer in " + + "1..9999 with no leading zero (e.g. ':3').", + `Invalid prefix modifier ':${prefix}'`, + varSpec, + ); + this.name = "InvalidPrefixError"; + } +} + +/** + * Raised when a varspec ends with a trailing comma followed by `}` or end of + * variable-list (e.g. `{a,b,}`). + */ +export class TrailingCommaError extends InvalidVarSpecError { + constructor(template: string, position: number) { + super( + template, + position, + "Remove the trailing comma, or add a varspec after it.", + "Trailing ',' in variable-list", + "", + ); + this.name = "TrailingCommaError"; + } +} + +/** + * Raised when an unexpected character appears between a varspec and the next + * separator (`,` or `}`), e.g. `{a b}` or `{a:3x}`. + */ +export class UnexpectedCharacterError extends TemplateParseError { + constructor( + template: string, + position: number, + public readonly char: string, + ) { + super( + template, + position, + "Expected ',' or '}' here. Remove the unexpected character or " + + "pct-encode it if it belongs in the varname.", + `Unexpected character '${char}' in expression`, + ); + this.name = "UnexpectedCharacterError"; + } +} + +/** + * Common base class for runtime expansion errors. + */ +export class TemplateExpansionError extends Error { + /** + * @param variableName The variable whose resolved value cannot be expanded. + * @param hint Short, actionable instruction for the user. + * @param message Human-readable summary. + */ + constructor( + public readonly variableName: string, + public readonly hint: string, + message: string, + ) { + super(`${message} for '${variableName}': ${hint}`); + this.name = "TemplateExpansionError"; + } +} + +/** + * Raised when a prefix modifier is applied to a composite value. + * + * RFC 6570 §2.4.1 defines prefix modifiers for string values only; lists and + * associative arrays must use normal or explode expansion instead. + */ +export class PrefixModifierNotApplicableError extends TemplateExpansionError { + constructor( + variableName: string, + public readonly prefix: number, + public readonly valueType: "list" | "associative", + ) { + super( + variableName, + "Remove the prefix modifier from this varspec, or provide a string " + + "value instead of a composite value.", + `Prefix modifier ':${prefix}' is not applicable to ${valueType} values`, + ); + this.name = "PrefixModifierNotApplicableError"; + } +} diff --git a/packages/uri-template/src/template/expand.ts b/packages/uri-template/src/template/expand.ts new file mode 100644 index 000000000..4e7d61aaa --- /dev/null +++ b/packages/uri-template/src/template/expand.ts @@ -0,0 +1,170 @@ +import { operatorSpecs } from "../const.ts"; +import type { + AssociativeValue, + ExpandContext, + OperatorSpec, + PrimitiveValue, + TemplateOptions, + Token, + VarSpec, +} from "../types.ts"; +import { encodeName, encodeValue, truncateValue } from "./encoding.ts"; +import { PrefixModifierNotApplicableError } from "./errors.ts"; + +/** + * Expands one parsed URI Template expression against the supplied variable + * context using the operator behavior table from RFC 6570. + */ +export default function expand( + tokens: readonly Token[], + context: ExpandContext, + options: TemplateOptions, +): string { + return tokens.map((token) => + token.kind === "literal" + ? token.text + : expandExpressions(token.vars, token.operator, context, options) + ).join(""); +} + +function expandExpressions( + vars: VarSpec[], + operator: keyof typeof operatorSpecs, + context: ExpandContext, + options: TemplateOptions, +): string { + const spec = operatorSpecs[operator]; + const parts = vars.flatMap((varSpec) => + expandValue(varSpec, context[varSpec.name], spec, options) + ); + return parts.length < 1 ? "" : `${spec.first}${parts.join(spec.sep)}`; +} + +function expandValue( + varSpec: VarSpec, + value: ExpandContext[string], + spec: OperatorSpec, + options: TemplateOptions, +): string[] { + if (value == null) return []; + if (isPrimitiveList(value)) { + const encoded = encodeListMembers(value, spec.allowReserved); + if (encoded.length < 1) return []; + if (!reportPrefixModifierError(varSpec, "list", options)) return []; + return expandList(varSpec, encoded, spec); + } + if (isAssociative(value)) { + const pairs = encodeAssociativePairs(value, spec.allowReserved); + if (pairs.length < 1) return []; + if (!reportPrefixModifierError(varSpec, "associative", options)) return []; + return expandAssociative(varSpec, pairs, spec); + } + return expandPrimitive(varSpec, value, spec); +} + +function expandPrimitive( + varSpec: VarSpec, + value: Exclude, + spec: OperatorSpec, +): string[] { + const text = String(value); + const prefixed = varSpec.prefix == null + ? text + : truncateValue(text, varSpec.prefix); + const encoded = encodeValue(spec.allowReserved)(prefixed); + if (!spec.named) return [encoded]; + + const name = encodeName(varSpec.name); + return [expandNamedPair(name, encoded, spec)]; +} + +function expandList( + varSpec: VarSpec, + encoded: readonly string[], + spec: OperatorSpec, +): string[] { + const name = encodeName(varSpec.name); + if (varSpec.explode) { + return spec.named + ? encoded.map((item) => expandNamedPair(name, item, spec)) + : [...encoded]; + } + + const joined = encoded.join(","); + return spec.named ? [expandNamedPair(name, joined, spec)] : [joined]; +} + +function expandAssociative( + varSpec: VarSpec, + pairs: readonly (readonly [key: string, value: string])[], + spec: OperatorSpec, +): string[] { + if (varSpec.explode) { + return pairs.map(([key, item]) => expandNamedPair(key, item, spec)); + } + + const item = pairs.flat(1).join(","); + if (!spec.named) return [item]; + + const key = encodeName(varSpec.name); + return [expandNamedPair(key, item, spec)]; +} + +const expandNamedPair = ( + key: string, + item: string, + spec: OperatorSpec, +): string => item === "" ? `${key}${spec.ifEmpty}` : `${key}=${item}`; + +const encodeListMembers = ( + value: readonly PrimitiveValue[], + allowReserved: boolean, +): string[] => + value + .filter((item) => item != null) + .map(String) + .map(encodeValue(allowReserved)); + +const encodeAssociativePairs = ( + value: AssociativeValue, + allowReserved: boolean, +): (readonly [key: string, value: string])[] => + Object.entries(value) + .map(([key, item]) => [key, normalizePairValue(item) as string] as const) + .filter(([, normalized]) => normalized != null) + .map((kv) => kv.map(encodeValue(allowReserved)) as [string, string]); + +function normalizePairValue( + value: PrimitiveValue | readonly PrimitiveValue[], +): string | null { + if (value == null) return null; + if (!Array.isArray(value)) return String(value); + + const items = value.filter((item) => item != null).map(String); + return items.length < 1 ? null : items.join(","); +} + +const isAssociative = ( + value: ExpandContext[string], +): value is AssociativeValue => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isPrimitiveList = ( + value: ExpandContext[string], +): value is readonly PrimitiveValue[] => Array.isArray(value); + +function reportPrefixModifierError( + varSpec: VarSpec, + valueType: "list" | "associative", + { report, strict }: TemplateOptions, +): boolean { + if (varSpec.prefix == null) return true; + const error = new PrefixModifierNotApplicableError( + varSpec.name, + varSpec.prefix, + valueType, + ); + report(error); + if (strict) throw error; + return false; +} diff --git a/packages/uri-template/src/template/expression.ts b/packages/uri-template/src/template/expression.ts new file mode 100644 index 000000000..d09f0a48d --- /dev/null +++ b/packages/uri-template/src/template/expression.ts @@ -0,0 +1,182 @@ +import { OPERATORS } from "../const.ts"; +import type { Operator, Token, VarSpec } from "../types.ts"; +import { isVarcharAt } from "./encoding.ts"; +import { + EmptyExpressionError, + EmptyVarNameError, + InvalidPrefixError, + InvalidVarNameError, + ReservedOperatorError, + TrailingCommaError, + UnexpectedCharacterError, + UnknownOperatorError, +} from "./errors.ts"; + +const reservedOperators = ["=", ",", "!", "@", "|"] as const; + +/** + * Parses the content between `{` and `}` into one expression token. + * + * The tokenizer supplies the original template and offset so errors can point + * at the original source string. + */ +export default function parseExpression( + source: string, + template: string, + position: number, +): Token { + const raiseExpressionError = (error: Error): never => { + throw error; + }; + + if (source.length < 1) { + return raiseExpressionError(new EmptyExpressionError(template, position)); + } + + const first = source[0]; + if (isReservedOperator(first)) { + return raiseExpressionError( + new ReservedOperatorError(template, position + 1, first), + ); + } + if (!isOperator(first) && isVarcharAt(source, 0) < 1) { + return raiseExpressionError( + new UnknownOperatorError(template, position + 1, first), + ); + } + + const operator: Operator = isOperator(first) ? first : ""; + const varListStart = operator === "" ? 0 : 1; + const vars = parseVarList(source, template, position + 1, varListStart); + return { + kind: "expression", + operator, + vars, + }; +} + +function parseVarList( + source: string, + template: string, + offset: number, + start: number, +): VarSpec[] { + if (start >= source.length) { + throw new EmptyVarNameError(template, offset + start); + } + + const vars: VarSpec[] = []; + for (let index = start; index < source.length;) { + if (source[index] === ",") { + throw index === source.length - 1 + ? new TrailingCommaError(template, offset + index) + : new EmptyVarNameError(template, offset + index); + } + + const varStart = index; + const nameEnd = readVarNameEnd(source, index); + if (nameEnd === varStart) { + throw new EmptyVarNameError(template, offset + index); + } + + const name = source.slice(varStart, nameEnd); + index = nameEnd; + + const modifier = readModifier(source, template, offset, index, name); + index = modifier.index; + + if (index < source.length && source[index] !== ",") { + throw modifier.used + ? new UnexpectedCharacterError(template, offset + index, source[index]) + : new InvalidVarNameError( + template, + offset + index, + name, + source[index], + ); + } + + vars.push({ + name, + explode: modifier.explode, + ...(modifier.prefix == null ? {} : { prefix: modifier.prefix }), + }); + + if (index < source.length) { + index++; + if (index >= source.length) { + throw new TrailingCommaError(template, offset + index - 1); + } + } + } + + return vars; +} + +function readVarNameEnd(source: string, start: number): number { + let index = start; + let expectVarchar = true; + while (index < source.length) { + const varcharLength = isVarcharAt(source, index); + if (varcharLength > 0) { + index += varcharLength; + expectVarchar = false; + continue; + } + if (source[index] !== ".") break; + if (expectVarchar || isVarcharAt(source, index + 1) < 1) break; + index++; + expectVarchar = true; + } + return index; +} + +function readModifier( + source: string, + template: string, + offset: number, + index: number, + varSpec: string, +): { + readonly explode: boolean; + readonly index: number; + readonly prefix?: number; + readonly used: boolean; +} { + if (source[index] === "*") { + return { explode: true, index: index + 1, used: true }; + } + + if (source[index] !== ":") { + return { explode: false, index, used: false }; + } + + const digitsStart = index + 1; + let digitsEnd = digitsStart; + while (digitsEnd < source.length && isDigit(source[digitsEnd])) digitsEnd++; + + const prefix = source.slice(digitsStart, digitsEnd); + if (!/^[1-9][0-9]{0,3}$/.test(prefix)) { + throw new InvalidPrefixError(template, offset + index, varSpec, prefix); + } + + return { + explode: false, + index: digitsEnd, + prefix: Number(prefix), + used: true, + }; +} + +function isOperator(char: string): char is Operator { + return (OPERATORS as readonly string[]).includes(char) && char !== ""; +} + +function isReservedOperator(char: string): boolean { + return (reservedOperators as readonly string[]).includes(char); +} + +function isDigit(char: string): boolean { + const code = char.charCodeAt(0); + return code >= 0x30 && code <= 0x39; +} diff --git a/packages/uri-template/src/template/match.ts b/packages/uri-template/src/template/match.ts new file mode 100644 index 000000000..20c650164 --- /dev/null +++ b/packages/uri-template/src/template/match.ts @@ -0,0 +1,750 @@ +import { operatorSpecs } from "../const.ts"; +import type { + AssociativeValue, + ExpandContext, + ExpandValue, + OperatorSpec, + TemplateOptions, + Token, + VarSpec, +} from "../types.ts"; +import { encodeName, isVarcharAt, truncateValue } from "./encoding.ts"; +import expand from "./expand.ts"; + +/** + * Matches a URI against a parsed URI template and extracts variable bindings. + * + * The inverse of {@link expand}: given the token stream produced by parsing a + * template and a concrete URI, recovers an {@link ExpandContext} such that + * re-expanding the template with that context reproduces the original URI. + * + * @param tokens The parsed template tokens to match against. + * @param uri The concrete URI to decompose. + * @param options Template options shared with the expansion side. + * @returns The recovered variable context, or `null` if the URI does not match + * the template under any interpretation. + */ +export default function match( + tokens: readonly Token[], + uri: string, + options: TemplateOptions, +): ExpandContext | null { + return matchTokens(tokens, uri, options, 0, 0, {}); +} + +interface Binding { + readonly prefix?: number; + readonly value: ExpandValue; +} + +type Bindings = Record; + +interface NamedPart { + readonly name: string; + readonly value: string; +} + +interface ConsumedParts { + readonly bindings: Bindings; + readonly index: number; +} + +/** + * Walks the token stream and the URI in lockstep, backtracking over every + * candidate decomposition until one survives roundtrip verification. + * + * Literal tokens advance deterministically; expression tokens fan out across + * all viable end positions and value interpretations. When the token stream is + * exhausted, the accumulated bindings are accepted only if re-expanding the + * template with them yields the original URI exactly — this is what filters + * out the spurious interpretations that the permissive parsing stage admits. + * + * @returns The first surviving context, or `null` if every branch fails. + */ +function matchTokens( + tokens: readonly Token[], + uri: string, + options: TemplateOptions, + tokenIndex: number, + uriIndex: number, + bindings: Bindings, +): ExpandContext | null { + if (tokenIndex >= tokens.length) { + if (uriIndex !== uri.length) return null; + const context = toExpandContext(bindings); + return expand(tokens, context, options) === uri ? context : null; + } + + const token = tokens[tokenIndex]; + if (token.kind === "literal") { + return uri.startsWith(token.text, uriIndex) + ? matchTokens( + tokens, + uri, + options, + tokenIndex + 1, + uriIndex + token.text.length, + bindings, + ) + : null; + } + + for (const end of expressionEnds(tokens, uri, tokenIndex, uriIndex)) { + const expression = uri.slice(uriIndex, end); + for ( + const expressionBindings of matchExpression( + token.vars, + token.operator, + expression, + ) + ) { + const merged = mergeBindings(bindings, expressionBindings); + if (merged == null) continue; + const result = matchTokens( + tokens, + uri, + options, + tokenIndex + 1, + end, + merged, + ); + if (result != null) return result; + } + } + + return null; +} + +/** + * Generates candidate end positions in the URI for the expression token at + * `tokenIndex`, using the next non-empty literal token as a search anchor. + * + * If no following literal exists the expression may run to the end of the URI, + * so every position from the URI end back to `uriIndex` is yielded (longest + * first, biasing the search toward greedy matches). Otherwise only the offsets + * where the next literal appears are returned, which prunes the search space + * dramatically in templates with structural separators. + */ +function* expressionEnds( + tokens: readonly Token[], + uri: string, + tokenIndex: number, + uriIndex: number, +): Generator { + const nextLiteral = tokens + .slice(tokenIndex + 1) + .find((token) => token.kind === "literal" && token.text !== ""); + + if (nextLiteral == null || nextLiteral.kind !== "literal") { + yield* range(uri.length, uriIndex); + return; + } + + for ( + let index = uri.indexOf(nextLiteral.text, uriIndex); + index >= 0; + index = uri.indexOf(nextLiteral.text, index + 1) + ) { + yield index; + } +} + +function* range(from: number, to: number): Generator { + for (let value = from; value >= to; value--) yield value; +} + +/** + * Decomposes a single expression substring into every plausible binding set. + * + * Validates the operator's leading sigil (`?`, `#`, `/`, etc.), strips it, and + * dispatches to the named or unnamed parser based on the operator spec. The + * empty-expression case is delegated to {@link matchEmptyExpression}, which + * decides when an empty expression substring may be read back as an + * empty-string binding rather than as no binding at all. + */ +function* matchExpression( + vars: readonly VarSpec[], + operator: keyof typeof operatorSpecs, + expression: string, +): Generator { + if (expression === "") { + yield* matchEmptyExpression(vars, operator); + return; + } + + const spec = operatorSpecs[operator]; + if (!expression.startsWith(spec.first)) return; + + const body = expression.slice(spec.first.length); + yield* (spec.named + ? matchNamedExpression(vars, spec, body) + : matchUnnamedExpression(vars, spec, body)); +} + +const matchEmptyExpression = ( + vars: readonly VarSpec[], + operator: keyof typeof operatorSpecs, +): Bindings[] => { + if ((operator === "" || operator === "+") && vars.length === 1) { + return [bindValue(vars[0], "")]; + } + return [{}]; +}; + +const matchUnnamedExpression = ( + vars: readonly VarSpec[], + spec: OperatorSpec, + body: string, +): Generator => + matchUnnamedFrom(vars, spec, split(body, spec.sep), 0, 0); + +/** + * Distributes the separator-split parts of an unnamed expression across the + * remaining variables via backtracking. + * + * For each variable, every contiguous slice of parts that respects the + * `minLength`/`maxLength` budget is tried as that variable's value, and the + * variable may also be skipped entirely (consuming zero parts) to handle + * undefined variables in the template. Surviving combinations are yielded for + * the caller to filter. + */ +function* matchUnnamedFrom( + vars: readonly VarSpec[], + spec: OperatorSpec, + parts: readonly string[], + varIndex: number, + partIndex: number, +): Generator { + if (varIndex >= vars.length) { + if (partIndex >= parts.length) yield {}; + return; + } + + const varSpec = vars[varIndex]; + for ( + const consumed of consumeUnnamed(varSpec, spec, parts, partIndex) + ) { + for ( + const rest of matchUnnamedFrom( + vars, + spec, + parts, + varIndex + 1, + consumed.index, + ) + ) { + const merged = mergeBindings(consumed.bindings, rest); + if (merged != null) yield merged; + } + } + + yield* matchUnnamedFrom(vars, spec, parts, varIndex + 1, partIndex); +} + +/** + * Enumerates every (binding, next-part-index) pair produced by letting one + * unnamed variable consume any number of remaining parts. + */ +function* consumeUnnamed( + varSpec: VarSpec, + spec: OperatorSpec, + parts: readonly string[], + partIndex: number, +): Generator { + if (partIndex >= parts.length) return; + + const maxLength = parts.length - partIndex; + for (let length = 1; length <= maxLength; length++) { + const slice = parts.slice(partIndex, partIndex + length); + for (const bindings of parseUnnamedValue(varSpec, spec, slice)) { + yield { bindings, index: partIndex + length }; + } + } +} + +/** + * Yields every binding interpretation of a slice assigned to one unnamed + * variable: scalar, comma-list, associative, and (for explode) an + * exploded list or associative reading of the same parts. + * + * Prefix-bound variables collapse to the scalar reading only. + */ +function* parseUnnamedValue( + varSpec: VarSpec, + spec: OperatorSpec, + parts: readonly string[], +): Generator { + const joined = parts.join(spec.sep); + const nonExploded = parseNonExplodedValue(varSpec, spec, joined); + if (varSpec.prefix != null) { + yield* nonExploded; + return; + } + + if (varSpec.explode && parts.length > 0) { + yield* parseExplodedUnnamed(varSpec, spec, parts); + } + yield* nonExploded; +} + +function* parseExplodedUnnamed( + varSpec: VarSpec, + spec: OperatorSpec, + parts: readonly string[], +): Generator { + const decodedList = decodeValues(parts, spec.allowReserved); + if (decodedList == null) return; + + const object = parseExplodedAssociative(parts, spec); + if (object != null) yield bindValue(varSpec, object); + yield bindValue(varSpec, decodedList); +} + +const parseExplodedAssociative = ( + parts: readonly string[], + spec: OperatorSpec, +): AssociativeValue | null => + parseExplodedAssociativeBody(parts.join(spec.sep), spec); + +function parseExplodedAssociativeBody( + body: string, + spec: OperatorSpec, +): AssociativeValue | null { + const entries: [string, string][] = []; + + for (let index = 0; index < body.length;) { + const equals = body.indexOf("=", index); + if (equals < 0) return null; + + const valueStart = equals + 1; + const valueEnd = findExplodedPairBoundary(body, valueStart, spec.sep); + const key = decodeValue(body.slice(index, equals), spec.allowReserved); + const value = decodeValue( + body.slice(valueStart, valueEnd), + spec.allowReserved, + ); + if (key == null || value == null) return null; + + entries.push([key, value]); + index = valueEnd + spec.sep.length; + } + + return entries.length < 1 ? null : Object.fromEntries(entries); +} + +function findExplodedPairBoundary( + body: string, + start: number, + separator: string, +): number { + for (let index = start; index < body.length; index++) { + if (isExplodedPairBoundary(body, index, separator)) return index; + } + return body.length; +} + +const isExplodedPairBoundary = ( + body: string, + index: number, + separator: string, +): boolean => { + if (!body.startsWith(separator, index)) return false; + + const keyStart = index + separator.length; + const keyEnd = readPairKeyEnd(body, keyStart); + return keyEnd > keyStart && body[keyEnd] === "="; +}; + +function readPairKeyEnd(body: string, start: number): number { + let index = start; + let expectVarchar = true; + while (index < body.length) { + const varcharLength = isVarcharAt(body, index); + if (varcharLength > 0) { + index += varcharLength; + expectVarchar = false; + continue; + } + if (body[index] !== ".") break; + if (expectVarchar || isVarcharAt(body, index + 1) < 1) break; + index++; + expectVarchar = true; + } + return index; +} + +/** + * Yields candidate readings of a single value string under non-exploded + * encoding: scalar, comma-separated list, and (when an even element count + * permits) comma-separated associative array. Some candidates will not + * round-trip — for instance a comma-bearing scalar gets re-encoded with + * `%2C` on expansion — and are filtered out by the roundtrip check in + * {@link matchTokens}. + * + * The yielded bindings are ordered from most structured to least, so the + * surrounding backtracking tries the richer interpretations before the scalar + * one. + */ +function* parseNonExplodedValue( + varSpec: VarSpec, + spec: OperatorSpec, + value: string, +): Generator { + const primitive = decodeValue(value, spec.allowReserved); + if (primitive == null) return; + + const primitiveBinding = bindValue(varSpec, primitive); + if (varSpec.prefix != null) { + yield primitiveBinding; + return; + } + + const commaParts = split(value, ","); + if (commaParts.length < 2) { + yield primitiveBinding; + return; + } + + const decodedList = decodeValues(commaParts, spec.allowReserved); + if (decodedList == null) { + yield primitiveBinding; + return; + } + + const associative = commaParts.length % 2 === 0 + ? parseAssociative(commaParts, spec.allowReserved) + : null; + + if (associative != null) yield bindValue(varSpec, associative); + yield bindValue(varSpec, decodedList); + yield primitiveBinding; +} + +const matchNamedExpression = ( + vars: readonly VarSpec[], + spec: OperatorSpec, + body: string, +): Generator => + matchNamedFrom(vars, spec, split(body, spec.sep).map(splitNamedPart), 0, 0); + +/** + * Named-expression counterpart of {@link matchUnnamedFrom}: backtracks over + * `name=value` parts assigning them to declared variables. + * + * Like the unnamed variant, each variable may either consume a contiguous run + * of parts (via {@link consumeNamed}) or be skipped entirely so that variables + * absent from the URI remain unbound. + */ +function* matchNamedFrom( + vars: readonly VarSpec[], + spec: OperatorSpec, + parts: readonly NamedPart[], + varIndex: number, + partIndex: number, +): Generator { + if (varIndex >= vars.length) { + if (partIndex >= parts.length) yield {}; + return; + } + + const varSpec = vars[varIndex]; + for ( + const consumed of consumeNamed(varSpec, spec, parts, partIndex, vars) + ) { + for ( + const rest of matchNamedFrom( + vars, + spec, + parts, + varIndex + 1, + consumed.index, + ) + ) { + const merged = mergeBindings(consumed.bindings, rest); + if (merged != null) yield merged; + } + } + + yield* matchNamedFrom(vars, spec, parts, varIndex + 1, partIndex); +} + +function* consumeNamed( + varSpec: VarSpec, + spec: OperatorSpec, + parts: readonly NamedPart[], + partIndex: number, + vars: readonly VarSpec[], +): Generator { + if (partIndex >= parts.length) return; + + if (varSpec.explode && varSpec.prefix == null) { + yield* consumeExplodedNamed(varSpec, spec, parts, partIndex, vars); + return; + } + + yield* consumeNamedValue(varSpec, spec, parts, partIndex); +} + +function* consumeNamedValue( + varSpec: VarSpec, + spec: OperatorSpec, + parts: readonly NamedPart[], + partIndex: number, +): Generator { + const part = parts[partIndex]; + if (part.name !== encodeName(varSpec.name)) return; + + for (const bindings of parseNonExplodedValue(varSpec, spec, part.value)) { + yield { bindings, index: partIndex + 1 }; + } +} + +function* consumeExplodedNamed( + varSpec: VarSpec, + spec: OperatorSpec, + parts: readonly NamedPart[], + partIndex: number, + vars: readonly VarSpec[], +): Generator { + yield* consumeNamedList(varSpec, spec, parts, partIndex); + yield* consumeNamedAssociative(varSpec, spec, parts, partIndex, vars); +} + +/** + * Reads consecutive parts that share the variable's name, decoding each as a + * list element. Used for the explode-as-list interpretation of a named + * variable. + */ +function* consumeNamedList( + varSpec: VarSpec, + spec: OperatorSpec, + parts: readonly NamedPart[], + partIndex: number, +): Generator { + const name = encodeName(varSpec.name); + const values = [...namedListValues(name, spec, parts, partIndex)]; + + if (values.length > 0) { + yield { + bindings: bindValue(varSpec, values), + index: partIndex + values.length, + }; + } +} + +function* namedListValues( + name: string, + spec: OperatorSpec, + parts: readonly NamedPart[], + partIndex: number, +): Generator { + for ( + let index = partIndex; + index < parts.length && parts[index].name === name; + index++ + ) { + const value = decodeValue(parts[index].value, spec.allowReserved); + if (value == null) return; + yield value; + } +} + +/** + * Reads consecutive parts as `key=value` entries of an associative array under + * one named exploded variable. + * + * After the first part, stops as soon as a part's name matches any declared + * variable so that those parts remain available for their own variables. The + * first part is always consumed regardless of name to bootstrap the + * association. + */ +function* consumeNamedAssociative( + varSpec: VarSpec, + spec: OperatorSpec, + parts: readonly NamedPart[], + partIndex: number, + vars: readonly VarSpec[], +): Generator { + const reservedNames = new Set(vars.map((item) => encodeName(item.name))); + const entries = [ + ...namedAssociativeEntries(spec, parts, partIndex, reservedNames), + ]; + + if (entries.length > 0) { + yield { + bindings: bindValue(varSpec, Object.fromEntries(entries)), + index: partIndex + entries.length, + }; + } +} + +function* namedAssociativeEntries( + spec: OperatorSpec, + parts: readonly NamedPart[], + partIndex: number, + reservedNames: ReadonlySet, +): Generator { + for (let index = partIndex; index < parts.length; index++) { + const part = parts[index]; + if (index > partIndex && reservedNames.has(part.name)) return; + const key = decodeValue(part.name, spec.allowReserved); + const value = decodeValue(part.value, spec.allowReserved); + if (key == null || value == null) return; + yield [key, value]; + } +} + +function splitNamedPart(part: string): NamedPart { + const equals = part.indexOf("="); + return equals < 0 + ? { name: part, value: "" } + : { name: part.slice(0, equals), value: part.slice(equals + 1) }; +} + +function parseAssociative( + parts: readonly string[], + allowReserved: boolean, +): AssociativeValue | null { + const entries: [string, string][] = []; + for (let index = 0; index < parts.length; index += 2) { + const key = decodeValue(parts[index], allowReserved); + const value = decodeValue(parts[index + 1], allowReserved); + if (key == null || value == null) return null; + entries.push([key, value]); + } + return Object.fromEntries(entries); +} + +function decodeValues( + values: readonly string[], + allowReserved: boolean, +): string[] | null { + const decoded: string[] = []; + for (const value of values) { + const item = decodeValue(value, allowReserved); + if (item == null) return null; + decoded.push(item); + } + return decoded; +} + +function decodeValue(value: string, allowReserved: boolean): string | null { + if (allowReserved) return value; + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +const bindValue = ( + { name, prefix }: VarSpec, + value: ExpandValue, +): Bindings => ({ [name]: { value, prefix } }); + +/** + * Combines two binding sets, returning `null` if any shared variable receives + * incompatible values across them. + * + * Equality of values and prefix-aware compatibility (one binding being a + * truncation of the other) are both delegated to {@link mergeBinding}. + */ +function mergeBindings(left: Bindings, right: Bindings): Bindings | null { + const merged: Bindings = { ...left }; + for (const [name, binding] of Object.entries(right)) { + const existing = merged[name]; + if (existing == null) { + merged[name] = binding; + continue; + } + + const next = mergeBinding(existing, binding); + if (next == null) return null; + merged[name] = next; + } + return merged; +} + +const mergeBinding = (left: Binding, right: Binding): Binding | null => + isPrefixBinding(left) || isPrefixBinding(right) + ? mergePrefixBinding(left, right) + : equalExpandValue(left.value, right.value) + ? left + : null; + +/** + * Reconciles two bindings when at least one carries a prefix limit, by + * checking that the truncation of the longer (or unrestricted) value matches + * the shorter prefixed value. + * + * Returns the binding that carries the more complete information, or `null` + * when no consistent reading exists or non-string values are involved. + */ +function mergePrefixBinding(left: Binding, right: Binding): Binding | null { + const leftValue = primitiveString(left.value); + const rightValue = primitiveString(right.value); + if (leftValue == null || rightValue == null) return null; + + const isLeftPrefix = isPrefixBinding(left); + const isRightPrefix = isPrefixBinding(right); + if (!isLeftPrefix && !isRightPrefix) return null; + + if (isLeftPrefix && !isRightPrefix) { + return truncateValue(rightValue, left.prefix) === leftValue ? right : null; + } + + if (!isLeftPrefix && isRightPrefix) { + return truncateValue(leftValue, right.prefix) === rightValue ? left : null; + } + + const leftPrefix = left.prefix!; + const rightPrefix = right.prefix!; + if (leftPrefix <= rightPrefix) { + return truncateValue(rightValue, leftPrefix) === leftValue ? right : null; + } + return truncateValue(leftValue, rightPrefix) === rightValue ? left : null; +} + +const isPrefixBinding = ( + binding: Binding, +): binding is Binding & { readonly prefix: number } => binding.prefix != null; + +const primitiveString = (value: ExpandValue): string | null => + typeof value === "string" ? value : null; + +function equalExpandValue(left: ExpandValue, right: ExpandValue): boolean { + if (Array.isArray(left)) { + return Array.isArray(right) && + left.length === right.length && + left.every((item, index) => item === right[index]); + } + + if (isAssociative(left)) { + return isAssociative(right) && + equalEntries(Object.entries(left), Object.entries(right)); + } + + return left === right; +} + +const equalEntries = ( + left: readonly (readonly [string, unknown])[], + right: readonly (readonly [string, unknown])[], +): boolean => + left.length === right.length && + left.every(([key, value], index) => { + const [rightKey, rightValue] = right[index]; + return key === rightKey && value === rightValue; + }); + +const isAssociative = (value: ExpandValue): value is AssociativeValue => + typeof value === "object" && value !== null && !Array.isArray(value); + +const toExpandContext = (bindings: Bindings): ExpandContext => + Object.fromEntries( + Object.entries(bindings) + .map(([key, binding]) => [key, binding.value]), + ); + +const split = (value: string, separator: string): string[] => + separator === "" ? [value] : value.split(separator); diff --git a/packages/uri-template/src/template/mod.ts b/packages/uri-template/src/template/mod.ts new file mode 100644 index 000000000..876612910 --- /dev/null +++ b/packages/uri-template/src/template/mod.ts @@ -0,0 +1,2 @@ +export * from "./errors.ts"; +export { default as Template } from "./template.ts"; diff --git a/packages/uri-template/src/template/template.bench.ts b/packages/uri-template/src/template/template.bench.ts new file mode 100644 index 000000000..f47744fc3 --- /dev/null +++ b/packages/uri-template/src/template/template.bench.ts @@ -0,0 +1,39 @@ +import { test } from "@fedify/fixture"; +import { + createMatchBench, + createMatchBenchTestCases, + createTemplatePairTest, + pairTestSuites, +} from "../tests/mod.ts"; +import Template from "./template.ts"; + +Deno.bench("Template (expand)", (b) => { + const runPairCases = createTemplatePairTest(Template); + b.start(); + for (const _ of Array(10000)) { + test("expand: examples", runPairCases(pairTestSuites)); + } + b.end(); +}); + +const matchBench = createMatchBench(Template); +Deno.bench( + "Template (match) — 5-var unnamed, 8 parts", + (b) => { + const bench = matchBench("/items/{a,b,c}/end"); + b.start(); + bench(Array(1000).fill("/items/p1,p2,p3,p4,p5,p6,p7,p8,p9,p0,p11,p12/end")); + b.end(); + }, +); + +Deno.bench( + "Template (match) — 728 paths test", + (b) => { + const bench = matchBench("{/paths*}"); + const cases = createMatchBenchTestCases(); + b.start(); + bench(cases); + b.end(); + }, +); diff --git a/packages/uri-template/src/template/template.test.ts b/packages/uri-template/src/template/template.test.ts new file mode 100644 index 000000000..1ea634637 --- /dev/null +++ b/packages/uri-template/src/template/template.test.ts @@ -0,0 +1,131 @@ +import { test } from "@fedify/fixture"; +import { deepEqual, equal, ok, throws } from "node:assert/strict"; +import { + createFixedTemplateMatchTest, + createFixedTemplateTest, + createMatchOnlyTest, + createTemplateHardTest, + createTemplateMatchHardTest, + createTemplateMatchTest, + createTemplatePairTest, + createWrongTemplateTest, + fixedTestSuites, + hardTestSuites, + matchTestSuites, + pairTestSuites, + wrongTestSuites, +} from "../tests/mod.ts"; +import { + EmptyExpressionError, + InvalidLiteralError, + InvalidPrefixError, + PrefixModifierNotApplicableError, + ReservedOperatorError, + UnclosedExpressionError, +} from "./errors.ts"; +import Template from "./template.ts"; + +const runPairCases = createTemplatePairTest(Template); +test("expand: examples", runPairCases(pairTestSuites)); + +const runFixedCases = createFixedTemplateTest(Template); +test("expand: fixed templates", runFixedCases(fixedTestSuites)); + +const runWrongCases = createWrongTemplateTest(Template); +test("parse: invalid templates", runWrongCases(wrongTestSuites)); + +const runHardCases = createTemplateHardTest(Template); +test("expand: hard cases", runHardCases(hardTestSuites)); + +const runMatchCases = createTemplateMatchTest(Template); +test("match: examples", runMatchCases(pairTestSuites)); + +const runFixedMatchCases = createFixedTemplateMatchTest(Template); +test("match: fixed templates", runFixedMatchCases(fixedTestSuites)); + +const runHardMatchCases = createTemplateMatchHardTest(Template); +for (const { name, cases } of hardTestSuites) { + test(`match: ${name}`, runHardMatchCases(cases)); +} + +const runMatchOnlyCases = createMatchOnlyTest(Template); +test("match-only", runMatchOnlyCases(matchTestSuites)); + +test("throws parse errors in strict mode", () => { + throws(() => new Template("{var"), UnclosedExpressionError); + throws(() => new Template("bad literal"), InvalidLiteralError); + throws(() => new Template("{=var}"), ReservedOperatorError); + throws(() => new Template("{var:0}"), InvalidPrefixError); +}); + +test("reports expression parse errors once in strict mode", () => { + const errors: Error[] = []; + + throws( + () => new Template("{}", { report: (error: Error) => errors.push(error) }), + EmptyExpressionError, + ); + equal(errors.length, 1); + equal(errors[0] instanceof EmptyExpressionError, true); +}); + +test("reports parse errors without throwing in non-strict mode", () => { + const errors: Error[] = []; + const template = new Template("{=bad}/{ok}", { + strict: false, + report: (error: Error) => errors.push(error), + }); + + equal(template.expand({ ok: "value" }), "{=bad}/value"); + equal(errors.length, 1); + equal(errors[0] instanceof ReservedOperatorError, true); +}); + +test("reports expansion errors without throwing in non-strict mode", () => { + const errors: Error[] = []; + const template = new Template("{list:3}/{ok}", { + strict: false, + report: (error: Error) => errors.push(error), + }); + + equal(template.expand({ list: ["red"], ok: "value" }), "/value"); + equal(errors.length, 1); + equal(errors[0] instanceof PrefixModifierNotApplicableError, true); +}); + +test("parses reusable template instances", () => { + const template = Template.parse("/mapper{?address*}"); + equal( + template.expand({ address: { city: "Newport Beach", state: "CA" } }), + "/mapper?city=Newport%20Beach&state=CA", + ); + deepEqual(template.tokens, [ + { kind: "literal", text: "/mapper" }, + { + kind: "expression", + operator: "?", + vars: [{ name: "address", explode: true }], + }, + ]); +}); + +// Regression for the `consumeUnnamed` minLength bug: when an unnamed expression +// has more separated parts than variables, the matcher must let the *current* +// variable absorb fewer parts than the naive `parts - remainingVars` formula +// allows. With `{x:5,y}` against `abc,def,ghi` the only round-trippable +// binding has x consume one part (so prefix:5 truncation does not corrupt the +// joined string); under the buggy minLength formula the matcher only reaches +// the fallback `x undefined, y absorbs everything` decomposition, leaving +// `m.x` undefined. +test("Template#match — unnamed minLength must allow current var to consume one part", () => { + const template = new Template("{x:5,y}"); + const m = template.match("abc,def,ghi"); + + ok(m != null, "matcher returned null for a round-trippable URI"); + equal(template.expand(m), "abc,def,ghi"); + equal( + m.x, + "abc", + "matcher should reach the binding with x consuming one part", + ); +}); diff --git a/packages/uri-template/src/template/template.ts b/packages/uri-template/src/template/template.ts new file mode 100644 index 000000000..36a0bd75e --- /dev/null +++ b/packages/uri-template/src/template/template.ts @@ -0,0 +1,94 @@ +import { getLogger } from "@logtape/logtape"; +import type { + ExpandContext, + Reporter, + TemplateOptions, + Token, +} from "../types.ts"; +import expand from "./expand.ts"; +import match from "./match.ts"; +import tokenize from "./token.ts"; + +/** + * Parsed RFC 6570 URI Template that can be expanded repeatedly. + * + * This class owns tokenization and delegates expression expansion to the + * expansion module. + */ +export default class Template { + readonly #tokens: Token[]; + readonly #fullOptions: TemplateOptions; + + constructor( + /** + * URI template string to parse. See [RFC 6570] for syntax details. + * + * [RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570 + */ + public readonly uriTemplate: string, + /** + * Options for parsing the template. By default, `strict` is `true` and + * `report` logs errors using the default logger. If `strict` is `true`, the + * first error encountered while parsing or expanding will be automatically + * thrown after being reported. If `strict` is `false`, errors will be + * reported but none will be thrown unless the `report` function itself + * throws. The rest of the part remains as literal text. + */ + readonly options: Partial = {}, + ) { + this.#fullOptions = fillOptions(options); + this.#tokens = tokenize(uriTemplate, this.#fullOptions); + } + + /** + * Parses a URI Template using default strict parsing options. + */ + static parse( + uriTemplate: string, + options: Partial = {}, + ): Template { + return new Template(uriTemplate, options); + } + + /** + * Parsed token stream for diagnostics and router integration. + */ + get tokens(): readonly Token[] { + return this.#tokens; + } + + /** + * Expands this template against a variable context. + */ + expand: (context: ExpandContext) => string = ( + context: ExpandContext, + ): string => expand(this.#tokens, context, this.#fullOptions); + + /** + * Matches a URI against this template, returning the variable context if the + * URI matches or `null` if it does not. + */ + match: (uri: string) => ExpandContext | null = ( + uri: string, + ): ExpandContext | null => match(this.#tokens, uri, this.#fullOptions); + + toString = (): string => this.uriTemplate; +} + +const logger = getLogger(["fedify", "uri-template", "template"]); + +const defaultReporter: Reporter = (error: Error) => logger.error(error); + +const fillOptions = ( + { strict, report }: Partial, +): TemplateOptions => { + report ??= defaultReporter; + strict ??= true; + report = strict ? strictWrapper(report) : report; + return { strict, report }; +}; + +const strictWrapper = (reporter: Reporter) => (error: Error): never => { + reporter(error); + throw error; +}; diff --git a/packages/uri-template/src/template/token.ts b/packages/uri-template/src/template/token.ts new file mode 100644 index 000000000..63f4d1465 --- /dev/null +++ b/packages/uri-template/src/template/token.ts @@ -0,0 +1,83 @@ +import type { TemplateOptions, Token } from "../types.ts"; +import { isLiteralAt, readCodePoint } from "./encoding.ts"; +import { + InvalidLiteralError, + NestedOpeningBraceError, + StrayClosingBraceError, + UnclosedExpressionError, +} from "./errors.ts"; +import parseExpression from "./expression.ts"; + +/** + * Splits a URI Template source string into literal and expression tokens. + * + * This module validates RFC 6570 literal syntax and delegates expression + * parsing to the expression parser. + */ +export default function tokenize( + template: string, + options: TemplateOptions, +): Token[] { + const { report } = options; + const tokens: Token[] = []; + const appendLiteral = (text: string): void => { + const previous = tokens.at(-1); + if (previous?.kind === "literal") { + previous.text += text; + } else { + tokens.push({ kind: "literal", text }); + } + }; + + for (let index = 0; index < template.length;) { + const char = template[index]; + + if (char === "{") { + const closeIndex = template.indexOf("}", index + 1); + if (closeIndex < 0) { + report(new UnclosedExpressionError(template, index)); + appendLiteral(template.slice(index)); + break; + } + + const nestedIndex = template.indexOf("{", index + 1); + if (nestedIndex >= 0 && nestedIndex < closeIndex) { + report(new NestedOpeningBraceError(template, nestedIndex)); + appendLiteral(template.slice(index, closeIndex + 1)); + index = closeIndex + 1; + continue; + } + + const expression = template.slice(index + 1, closeIndex); + try { + tokens.push(parseExpression(expression, template, index)); + } catch (error) { + report(error instanceof Error ? error : new Error(String(error))); + appendLiteral(template.slice(index, closeIndex + 1)); + } + index = closeIndex + 1; + continue; + } + + if (char === "}") { + report(new StrayClosingBraceError(template, index)); + appendLiteral(char); + index++; + continue; + } + + const literalLength = isLiteralAt(template, index); + if (literalLength > 0) { + appendLiteral(template.slice(index, index + literalLength)); + index += literalLength; + continue; + } + + const { char: invalidChar, size } = readCodePoint(template, index); + report(new InvalidLiteralError(template, index, invalidChar)); + appendLiteral(invalidChar); + index += size; + } + + return tokens; +} diff --git a/packages/uri-template/src/tests/assert.ts b/packages/uri-template/src/tests/assert.ts new file mode 100644 index 000000000..a9f6ea706 --- /dev/null +++ b/packages/uri-template/src/tests/assert.ts @@ -0,0 +1,357 @@ +import * as ROUTER_ERROR_CLASSES from "../router/errors.ts"; +import * as ERROR_CLASSES from "../template/errors.ts"; +import type { Path } from "../types.ts"; +import type { + RouterBuildCase, + RouterBuildTestSuite, + RouterCloneTestSuite, + RouterCompileErrorCase, + RouterRouteDefinition, + RouterRouteTestSuite, + RouterVariablesCase, +} from "./router.ts"; +import type { + HardTestSuite, + MatchTestSuite, + PairTestSuite, + WrongTestSuite, +} from "./template.ts"; + +export function assertPairTestSuite( + suites: unknown, +): asserts suites is readonly PairTestSuite[] { + validateSuites(suites, validatePairCase); +} + +export function assertWrongTestSuite( + suites: unknown, +): asserts suites is readonly WrongTestSuite[] { + validateSuites(suites, validateWrongCase); +} + +export function assertHardTestSuite( + suites: unknown, +): asserts suites is readonly HardTestSuite[] { + validateSuites(suites, validateHardCase); +} + +export function assertMatchTestSuite( + suites: unknown, +): asserts suites is readonly MatchTestSuite[] { + validateSuites(suites, validateMatchCase); +} + +export function assertRouterRouteDefinitions( + definitions: unknown, +): asserts definitions is readonly RouterRouteDefinition[] { + assertArray(definitions, "router route definitions"); + for (const definition of definitions) validateRouteDefinition(definition); +} + +export function assertRouterPaths( + paths: unknown, +): asserts paths is readonly Path[] { + assertArray(paths, "router paths"); + for (const path of paths) assertPath(path, "router path"); +} + +export function assertRouterBuildCases( + cases: unknown, +): asserts cases is readonly RouterBuildCase[] { + assertArray(cases, "router build cases"); + for (const c of cases) validateRouterBuildCase(c); +} + +export function assertRouterRouteTestSuites( + suites: unknown, +): asserts suites is readonly RouterRouteTestSuite[] { + assertArray(suites, "router route test suites"); + for (const suite of suites) validateRouterRouteTestSuite(suite); +} + +export function assertRouterBuildTestSuites( + suites: unknown, +): asserts suites is readonly RouterBuildTestSuite[] { + assertArray(suites, "router build test suites"); + for (const suite of suites) validateRouterBuildTestSuite(suite); +} + +export function assertRouterVariablesCases( + cases: unknown, +): asserts cases is readonly RouterVariablesCase[] { + assertArray(cases, "router variables cases"); + for (const c of cases) validateRouterVariablesCase(c); +} + +export function assertRouterCompileErrorCases( + cases: unknown, +): asserts cases is readonly RouterCompileErrorCase[] { + assertArray(cases, "router compile error cases"); + for (const c of cases) validateRouterCompileErrorCase(c); +} + +export function assertRouterCloneTestSuites( + suites: unknown, +): asserts suites is readonly RouterCloneTestSuite[] { + assertArray(suites, "router clone test suites"); + for (const suite of suites) validateRouterCloneTestSuite(suite); +} + +function validateSuites( + suites: unknown, + validateCase: (c: unknown) => void, +): void { + assertArray(suites, "suites"); + for (const suite of suites) { + assertObject(suite, "suite"); + assertString(suite.name, "suite.name"); + assertArray(suite.cases, "suite.cases"); + for (const c of suite.cases) validateCase(c); + } +} + +function validatePairCase(c: unknown): void { + if ( + !Array.isArray(c) || c.length !== 2 || + typeof c[0] !== "string" || typeof c[1] !== "string" + ) { + throw new TypeError( + "each case must be a [template: string, expanded: string] tuple", + ); + } +} + +function validateWrongCase(c: unknown): void { + assertObject(c, "case"); + assertString(c.name, "case.name"); + assertString(c.template, "case.template"); + assertErrorName(c.expected, "case.expected"); +} + +function validateHardCase(c: unknown): void { + assertObject(c, "case"); + assertString(c.name, "case.name"); + assertString(c.template, "case.template"); + assertString(c.expected, "case.expected"); + assertBoolean(c.success, "case.success"); + if (c.reason !== undefined) assertString(c.reason, "case.reason"); + if (!c.success) assertErrorName(c.expected, "case.expected"); +} + +function validateMatchCase(c: unknown): void { + assertObject(c, "case"); + assertString(c.name, "case.name"); + assertString(c.template, "case.template"); + assertString(c.uri, "case.uri"); + if (c.expected !== null) assertObject(c.expected, "case.expected"); + if (c.reason !== undefined) assertString(c.reason, "case.reason"); +} + +function validateRouteDefinition(value: unknown): void { + if ( + !Array.isArray(value) || value.length !== 2 || + typeof value[1] !== "string" + ) { + throw new TypeError( + "each route definition must be a [path: string, name: string] tuple", + ); + } + + assertPath(value[0], "router route definition path"); +} + +function validateRouterBuildCase(value: unknown): void { + if ( + !Array.isArray(value) || value.length !== 2 || + typeof value[0] !== "string" + ) { + throw new TypeError( + "each router build case must be a [name: string, values: object] tuple", + ); + } + + assertStringRecord(value[1], "router build case values"); +} + +function validateRouterRouteTestSuite(value: unknown): void { + assertObject(value, "router route test suite"); + assertString(value.name, "router route test suite.name"); + if (value.options !== undefined) { + validateRouterOptions(value.options, "router route test suite.options"); + } + assertRouterRouteDefinitions(value.routeDefinitions); + assertArray(value.cases, "router route test suite.cases"); + for (const c of value.cases) validateRouterRouteCase(c); +} + +function validateRouterBuildTestSuite(value: unknown): void { + assertObject(value, "router build test suite"); + assertString(value.name, "router build test suite.name"); + if (value.options !== undefined) { + validateRouterOptions(value.options, "router build test suite.options"); + } + assertRouterRouteDefinitions(value.routeDefinitions); + assertArray(value.cases, "router build test suite.cases"); + for (const c of value.cases) validateRouterBuildTestCase(c); +} + +function validateRouterCloneTestSuite(value: unknown): void { + assertObject(value, "router clone test suite"); + assertString(value.name, "router clone test suite.name"); + if (value.options !== undefined) { + validateRouterOptions(value.options, "router clone test suite.options"); + } + assertRouterRouteDefinitions(value.routeDefinitions); + assertRouterRouteDefinitions(value.clonedRouteDefinitions); + assertArray( + value.originalRouteCases, + "router clone test suite.originalRouteCases", + ); + for (const c of value.originalRouteCases) validateRouterRouteCase(c); + assertArray( + value.clonedRouteCases, + "router clone test suite.clonedRouteCases", + ); + for (const c of value.clonedRouteCases) validateRouterRouteCase(c); +} + +function validateRouterRouteCase(value: unknown): void { + assertObject(value, "router route case"); + assertString(value.name, "router route case.name"); + assertPath(value.path, "router route case.path"); + validateRouterRouteResult(value.expected, "router route case.expected"); +} + +function validateRouterBuildTestCase(value: unknown): void { + assertObject(value, "router build test case"); + assertString(value.name, "router build test case.name"); + assertString(value.routeName, "router build test case.routeName"); + assertStringRecord(value.values, "router build test case.values"); + assertStringOrNull(value.expected, "router build test case.expected"); +} + +function validateRouterVariablesCase(value: unknown): void { + assertObject(value, "router variables case"); + assertString(value.name, "router variables case.name"); + assertPath(value.path, "router variables case.path"); + assertStringArray(value.expected, "router variables case.expected"); +} + +function validateRouterCompileErrorCase(value: unknown): void { + assertObject(value, "router compile error case"); + assertString(value.name, "router compile error case.name"); + assertString(value.path, "router compile error case.path"); + assertStringArray(value.expected, "router compile error case.expected"); + for (const errorName of value.expected) { + assertErrorName(errorName, "router compile error case.expected"); + } +} + +function validateRouterOptions(value: unknown, label: string): void { + assertObject(value, label); + if (value.trailingSlashInsensitive !== undefined) { + assertBoolean( + value.trailingSlashInsensitive, + `${label}.trailingSlashInsensitive`, + ); + } +} + +function validateRouterRouteResult(value: unknown, label: string): void { + if (value === null) return; + + assertObject(value, label); + assertString(value.name, `${label}.name`); + assertPath(value.template, `${label}.template`); + assertStringRecord(value.values, `${label}.values`); +} + +function assertString( + value: unknown, + label: string, +): asserts value is string { + if (typeof value !== "string") { + throw new TypeError(`${label} must be a string`); + } +} + +function assertBoolean( + value: unknown, + label: string, +): asserts value is boolean { + if (typeof value !== "boolean") { + throw new TypeError(`${label} must be a boolean`); + } +} + +function assertObject( + value: unknown, + label: string, +): asserts value is Record { + if (typeof value !== "object" || value === null) { + throw new TypeError(`${label} must be an object`); + } +} + +function assertArray( + value: unknown, + label: string, +): asserts value is unknown[] { + if (!Array.isArray(value)) { + throw new TypeError(`${label} must be an array`); + } +} + +function assertStringArray( + value: unknown, + label: string, +): asserts value is string[] { + assertArray(value, label); + for (const item of value) assertString(item, label); +} + +function assertPath( + value: unknown, + label: string, +): asserts value is Path { + assertString(value, label); + if (value !== "" && !value.startsWith("/") && !/^\{\/[^}]+\}/.test(value)) { + throw new TypeError(`${label} must be a router path`); + } +} + +function assertStringRecord( + value: unknown, + label: string, +): asserts value is Record { + assertObject(value, label); + for (const [key, item] of Object.entries(value)) { + assertString(item, `${label}.${key}`); + } +} + +function assertStringOrNull( + value: unknown, + label: string, +): asserts value is string | null { + if (value !== null) assertString(value, label); +} + +const ERROR_NAMES: ReadonlySet = new Set([ + ...Object.keys(ERROR_CLASSES), + ...Object.keys(ROUTER_ERROR_CLASSES), +]); + +function assertErrorName( + value: unknown, + label: string, +): asserts value is string { + assertString(value, label); + if (!ERROR_NAMES.has(value)) { + throw new TypeError( + `${label} must be one of [${[...ERROR_NAMES].join(", ")}], got ${ + JSON.stringify(value) + }`, + ); + } +} diff --git a/packages/uri-template/src/tests/json/references/fixed.json b/packages/uri-template/src/tests/json/references/fixed.json new file mode 100644 index 000000000..9a95a231d --- /dev/null +++ b/packages/uri-template/src/tests/json/references/fixed.json @@ -0,0 +1,48 @@ +[ + { + "name": "Section 1.1 — Introductory Examples (Form-style with Undef Cases)", + "template": "http://www.example.com/foo{?query,number}", + "cases": [ + { + "name": "query=mycelium, number=100", + "context": { + "query": "mycelium", + "number": 100 + }, + "expected": "http://www.example.com/foo?query=mycelium&number=100" + }, + { + "name": "query undefined, number=100", + "context": { + "query": null, + "number": 100 + }, + "expected": "http://www.example.com/foo?number=100" + }, + { + "name": "query and number both undefined", + "context": { + "query": null, + "number": null + }, + "expected": "http://www.example.com/foo" + } + ] + }, + { + "name": "Section 2.4.2 — Composite Address Example", + "template": "/mapper{?address*}", + "cases": [ + { + "name": "address with city and state", + "context": { + "address": { + "city": "Newport Beach", + "state": "CA" + } + }, + "expected": "/mapper?city=Newport%20Beach&state=CA" + } + ] + } +] diff --git a/packages/uri-template/src/tests/json/references/pairs.json b/packages/uri-template/src/tests/json/references/pairs.json new file mode 100644 index 000000000..6e0e18ab8 --- /dev/null +++ b/packages/uri-template/src/tests/json/references/pairs.json @@ -0,0 +1,829 @@ +[ + { + "name": "Section 1.2 — Level 1 (Simple String Expansion)", + "cases": [ + [ + "{var}", + "value" + ], + [ + "{hello}", + "Hello%20World%21" + ] + ] + }, + { + "name": "Section 1.2 — Level 2 (Reserved + Fragment Expansion)", + "cases": [ + [ + "{+var}", + "value" + ], + [ + "{+hello}", + "Hello%20World!" + ], + [ + "{+path}/here", + "/foo/bar/here" + ], + [ + "here?ref={+path}", + "here?ref=/foo/bar" + ], + [ + "X{#var}", + "X#value" + ], + [ + "X{#hello}", + "X#Hello%20World!" + ] + ] + }, + { + "name": "Section 1.2 — Level 3 (Multiple Variables, More Operators)", + "cases": [ + [ + "map?{x,y}", + "map?1024,768" + ], + [ + "{x,hello,y}", + "1024,Hello%20World%21,768" + ], + [ + "{+x,hello,y}", + "1024,Hello%20World!,768" + ], + [ + "{+path,x}/here", + "/foo/bar,1024/here" + ], + [ + "{#x,hello,y}", + "#1024,Hello%20World!,768" + ], + [ + "{#path,x}/here", + "#/foo/bar,1024/here" + ], + [ + "X{.var}", + "X.value" + ], + [ + "X{.x,y}", + "X.1024.768" + ], + [ + "{/var}", + "/value" + ], + [ + "{/var,x}/here", + "/value/1024/here" + ], + [ + "{;x,y}", + ";x=1024;y=768" + ], + [ + "{;x,y,empty}", + ";x=1024;y=768;empty" + ], + [ + "{?x,y}", + "?x=1024&y=768" + ], + [ + "{?x,y,empty}", + "?x=1024&y=768&empty=" + ], + [ + "?fixed=yes{&x}", + "?fixed=yes&x=1024" + ], + [ + "{&x,y,empty}", + "&x=1024&y=768&empty=" + ] + ] + }, + { + "name": "Section 1.2 — Level 4 (Value Modifiers)", + "cases": [ + [ + "{var:3}", + "val" + ], + [ + "{var:30}", + "value" + ], + [ + "{list}", + "red,green,blue" + ], + [ + "{list*}", + "red,green,blue" + ], + [ + "{keys}", + "semi,%3B,dot,.,comma,%2C" + ], + [ + "{keys*}", + "semi=%3B,dot=.,comma=%2C" + ], + [ + "{+path:6}/here", + "/foo/b/here" + ], + [ + "{+list}", + "red,green,blue" + ], + [ + "{+list*}", + "red,green,blue" + ], + [ + "{+keys}", + "semi,;,dot,.,comma,," + ], + [ + "{+keys*}", + "semi=;,dot=.,comma=," + ], + [ + "{#path:6}/here", + "#/foo/b/here" + ], + [ + "{#list}", + "#red,green,blue" + ], + [ + "{#list*}", + "#red,green,blue" + ], + [ + "{#keys}", + "#semi,;,dot,.,comma,," + ], + [ + "{#keys*}", + "#semi=;,dot=.,comma=," + ], + [ + "X{.var:3}", + "X.val" + ], + [ + "X{.list}", + "X.red,green,blue" + ], + [ + "X{.list*}", + "X.red.green.blue" + ], + [ + "X{.keys}", + "X.semi,%3B,dot,.,comma,%2C" + ], + [ + "X{.keys*}", + "X.semi=%3B.dot=..comma=%2C" + ], + [ + "{/var:1,var}", + "/v/value" + ], + [ + "{/list}", + "/red,green,blue" + ], + [ + "{/list*}", + "/red/green/blue" + ], + [ + "{/list*,path:4}", + "/red/green/blue/%2Ffoo" + ], + [ + "{/keys}", + "/semi,%3B,dot,.,comma,%2C" + ], + [ + "{/keys*}", + "/semi=%3B/dot=./comma=%2C" + ], + [ + "{;hello:5}", + ";hello=Hello" + ], + [ + "{;list}", + ";list=red,green,blue" + ], + [ + "{;list*}", + ";list=red;list=green;list=blue" + ], + [ + "{;keys}", + ";keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{;keys*}", + ";semi=%3B;dot=.;comma=%2C" + ], + [ + "{?var:3}", + "?var=val" + ], + [ + "{?list}", + "?list=red,green,blue" + ], + [ + "{?list*}", + "?list=red&list=green&list=blue" + ], + [ + "{?keys}", + "?keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{?keys*}", + "?semi=%3B&dot=.&comma=%2C" + ], + [ + "{&var:3}", + "&var=val" + ], + [ + "{&list}", + "&list=red,green,blue" + ], + [ + "{&list*}", + "&list=red&list=green&list=blue" + ], + [ + "{&keys}", + "&keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{&keys*}", + "&semi=%3B&dot=.&comma=%2C" + ] + ] + }, + { + "name": "Section 2.4.1 — Prefix Modifier", + "cases": [ + [ + "{var}", + "value" + ], + [ + "{var:20}", + "value" + ], + [ + "{var:3}", + "val" + ], + [ + "{semi}", + "%3B" + ], + [ + "{semi:2}", + "%3B" + ] + ] + }, + { + "name": "Section 2.4.2 — Composite (Explode) Values", + "cases": [ + [ + "find{?year*}", + "find?year=1965&year=2000&year=2012" + ], + [ + "www{.dom*}", + "www.example.com" + ] + ] + }, + { + "name": "Section 3.2.1 — Variable Expansion (List with Various Operators)", + "cases": [ + [ + "{count}", + "one,two,three" + ], + [ + "{count*}", + "one,two,three" + ], + [ + "{/count}", + "/one,two,three" + ], + [ + "{/count*}", + "/one/two/three" + ], + [ + "{;count}", + ";count=one,two,three" + ], + [ + "{;count*}", + ";count=one;count=two;count=three" + ], + [ + "{?count}", + "?count=one,two,three" + ], + [ + "{?count*}", + "?count=one&count=two&count=three" + ], + [ + "{&count*}", + "&count=one&count=two&count=three" + ] + ] + }, + { + "name": "Section 3.2.2 — Simple String Expansion: {var}", + "cases": [ + [ + "{var}", + "value" + ], + [ + "{hello}", + "Hello%20World%21" + ], + [ + "{half}", + "50%25" + ], + [ + "O{empty}X", + "OX" + ], + [ + "O{undef}X", + "OX" + ], + [ + "{x,y}", + "1024,768" + ], + [ + "{x,hello,y}", + "1024,Hello%20World%21,768" + ], + [ + "?{x,empty}", + "?1024," + ], + [ + "?{x,undef}", + "?1024" + ], + [ + "?{undef,y}", + "?768" + ], + [ + "{var:3}", + "val" + ], + [ + "{var:30}", + "value" + ], + [ + "{list}", + "red,green,blue" + ], + [ + "{list*}", + "red,green,blue" + ], + [ + "{keys}", + "semi,%3B,dot,.,comma,%2C" + ], + [ + "{keys*}", + "semi=%3B,dot=.,comma=%2C" + ] + ] + }, + { + "name": "Section 3.2.3 — Reserved Expansion: {+var}", + "cases": [ + [ + "{+var}", + "value" + ], + [ + "{+hello}", + "Hello%20World!" + ], + [ + "{+half}", + "50%25" + ], + [ + "{base}index", + "http%3A%2F%2Fexample.com%2Fhome%2Findex" + ], + [ + "{+base}index", + "http://example.com/home/index" + ], + [ + "O{+empty}X", + "OX" + ], + [ + "O{+undef}X", + "OX" + ], + [ + "{+path}/here", + "/foo/bar/here" + ], + [ + "here?ref={+path}", + "here?ref=/foo/bar" + ], + [ + "up{+path}{var}/here", + "up/foo/barvalue/here" + ], + [ + "{+x,hello,y}", + "1024,Hello%20World!,768" + ], + [ + "{+path,x}/here", + "/foo/bar,1024/here" + ], + [ + "{+path:6}/here", + "/foo/b/here" + ], + [ + "{+list}", + "red,green,blue" + ], + [ + "{+list*}", + "red,green,blue" + ], + [ + "{+keys}", + "semi,;,dot,.,comma,," + ], + [ + "{+keys*}", + "semi=;,dot=.,comma=," + ] + ] + }, + { + "name": "Section 3.2.4 — Fragment Expansion: {#var}", + "cases": [ + [ + "{#var}", + "#value" + ], + [ + "{#hello}", + "#Hello%20World!" + ], + [ + "{#half}", + "#50%25" + ], + [ + "foo{#empty}", + "foo#" + ], + [ + "foo{#undef}", + "foo" + ], + [ + "{#x,hello,y}", + "#1024,Hello%20World!,768" + ], + [ + "{#path,x}/here", + "#/foo/bar,1024/here" + ], + [ + "{#path:6}/here", + "#/foo/b/here" + ], + [ + "{#list}", + "#red,green,blue" + ], + [ + "{#list*}", + "#red,green,blue" + ], + [ + "{#keys}", + "#semi,;,dot,.,comma,," + ], + [ + "{#keys*}", + "#semi=;,dot=.,comma=," + ] + ] + }, + { + "name": "Section 3.2.5 — Label Expansion with Dot-Prefix: {.var}", + "cases": [ + [ + "{.who}", + ".fred" + ], + [ + "{.who,who}", + ".fred.fred" + ], + [ + "{.half,who}", + ".50%25.fred" + ], + [ + "www{.dom*}", + "www.example.com" + ], + [ + "X{.var}", + "X.value" + ], + [ + "X{.empty}", + "X." + ], + [ + "X{.undef}", + "X" + ], + [ + "X{.var:3}", + "X.val" + ], + [ + "X{.list}", + "X.red,green,blue" + ], + [ + "X{.list*}", + "X.red.green.blue" + ], + [ + "X{.keys}", + "X.semi,%3B,dot,.,comma,%2C" + ], + [ + "X{.keys*}", + "X.semi=%3B.dot=..comma=%2C" + ], + [ + "X{.empty_keys}", + "X" + ], + [ + "X{.empty_keys*}", + "X" + ] + ] + }, + { + "name": "Section 3.2.6 — Path Segment Expansion: {/var}", + "cases": [ + [ + "{/who}", + "/fred" + ], + [ + "{/who,who}", + "/fred/fred" + ], + [ + "{/half,who}", + "/50%25/fred" + ], + [ + "{/who,dub}", + "/fred/me%2Ftoo" + ], + [ + "{/var}", + "/value" + ], + [ + "{/var,empty}", + "/value/" + ], + [ + "{/var,undef}", + "/value" + ], + [ + "{/var,x}/here", + "/value/1024/here" + ], + [ + "{/var:1,var}", + "/v/value" + ], + [ + "{/list}", + "/red,green,blue" + ], + [ + "{/list*}", + "/red/green/blue" + ], + [ + "{/list*,path:4}", + "/red/green/blue/%2Ffoo" + ], + [ + "{/keys}", + "/semi,%3B,dot,.,comma,%2C" + ], + [ + "{/keys*}", + "/semi=%3B/dot=./comma=%2C" + ] + ] + }, + { + "name": "Section 3.2.7 — Path-Style Parameter Expansion: {;var}", + "cases": [ + [ + "{;who}", + ";who=fred" + ], + [ + "{;half}", + ";half=50%25" + ], + [ + "{;empty}", + ";empty" + ], + [ + "{;v,empty,who}", + ";v=6;empty;who=fred" + ], + [ + "{;v,bar,who}", + ";v=6;who=fred" + ], + [ + "{;x,y}", + ";x=1024;y=768" + ], + [ + "{;x,y,empty}", + ";x=1024;y=768;empty" + ], + [ + "{;x,y,undef}", + ";x=1024;y=768" + ], + [ + "{;hello:5}", + ";hello=Hello" + ], + [ + "{;list}", + ";list=red,green,blue" + ], + [ + "{;list*}", + ";list=red;list=green;list=blue" + ], + [ + "{;keys}", + ";keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{;keys*}", + ";semi=%3B;dot=.;comma=%2C" + ] + ] + }, + { + "name": "Section 3.2.8 — Form-Style Query Expansion: {?var}", + "cases": [ + [ + "{?who}", + "?who=fred" + ], + [ + "{?half}", + "?half=50%25" + ], + [ + "{?x,y}", + "?x=1024&y=768" + ], + [ + "{?x,y,empty}", + "?x=1024&y=768&empty=" + ], + [ + "{?x,y,undef}", + "?x=1024&y=768" + ], + [ + "{?var:3}", + "?var=val" + ], + [ + "{?list}", + "?list=red,green,blue" + ], + [ + "{?list*}", + "?list=red&list=green&list=blue" + ], + [ + "{?keys}", + "?keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{?keys*}", + "?semi=%3B&dot=.&comma=%2C" + ] + ] + }, + { + "name": "Section 3.2.9 — Form-Style Query Continuation: {&var}", + "cases": [ + [ + "{&who}", + "&who=fred" + ], + [ + "{&half}", + "&half=50%25" + ], + [ + "?fixed=yes{&x}", + "?fixed=yes&x=1024" + ], + [ + "{&x,y,empty}", + "&x=1024&y=768&empty=" + ], + [ + "{&x,y,undef}", + "&x=1024&y=768" + ], + [ + "{&var:3}", + "&var=val" + ], + [ + "{&list}", + "&list=red,green,blue" + ], + [ + "{&list*}", + "&list=red&list=green&list=blue" + ], + [ + "{&keys}", + "&keys=semi,%3B,dot,.,comma,%2C" + ], + [ + "{&keys*}", + "&semi=%3B&dot=.&comma=%2C" + ] + ] + } +] diff --git a/packages/uri-template/src/tests/json/references/vars.json b/packages/uri-template/src/tests/json/references/vars.json new file mode 100644 index 000000000..8535b6808 --- /dev/null +++ b/packages/uri-template/src/tests/json/references/vars.json @@ -0,0 +1,58 @@ +{ + "count": [ + "one", + "two", + "three" + ], + "dom": [ + "example", + "com" + ], + "dub": "me/too", + "hello": "Hello World!", + "half": "50%", + "var": "value", + "who": "fred", + "base": "http://example.com/home/", + "path": "/foo/bar", + "list": [ + "red", + "green", + "blue" + ], + "keys": { + "semi": ";", + "dot": ".", + "comma": "," + }, + "v": "6", + "x": "1024", + "y": "768", + "empty": "", + "empty_keys": {}, + "undef": null, + "semi": ";", + "year": [ + "1965", + "2000", + "2012" + ], + "empty_list": [], + "unicode": "페디파이", + "abc%20def": "spaced", + "%2Bvar": "plus", + "%7B": "open-brace", + "%41": "encoded-A", + "foo.bar": "dotted", + "123": "numeric", + "sparse": [ + "one", + null, + "three" + ], + "nullable_keys": { + "a": "1", + "b": null, + "c": "3" + } +} diff --git a/packages/uri-template/src/tests/json/router/build-cases.json b/packages/uri-template/src/tests/json/router/build-cases.json new file mode 100644 index 000000000..b5f5668f2 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/build-cases.json @@ -0,0 +1,100 @@ +[ + [ + "actor", + { + "identifier": "alice" + } + ], + [ + "inbox", + { + "identifier": "alice" + } + ], + [ + "outbox", + { + "identifier": "alice" + } + ], + [ + "followers", + { + "identifier": "alice" + } + ], + [ + "following", + { + "identifier": "alice" + } + ], + [ + "collection", + { + "identifier": "alice", + "collection": "bookmarks" + } + ], + [ + "note", + { + "identifier": "alice", + "statusId": "2026050501" + } + ], + [ + "activity", + { + "identifier": "alice", + "statusId": "2026050501" + } + ], + [ + "replies", + { + "identifier": "alice", + "statusId": "2026050501" + } + ], + [ + "media", + { + "identifier": "alice", + "mediaId": "avatar" + } + ], + [ + "object", + { + "type": "note", + "id": "2026050501" + } + ], + [ + "collectionItem", + { + "collection": "public", + "itemId": "2026050501" + } + ], + [ + "tag", + { + "tag": "activitypub" + } + ], + [ + "search", + { + "q": "fedify", + "page": "2" + } + ], + [ + "file", + { + "path": "images/avatar/original.png" + } + ] +] diff --git a/packages/uri-template/src/tests/json/router/build-suites.json b/packages/uri-template/src/tests/json/router/build-suites.json new file mode 100644 index 000000000..15b4cd174 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/build-suites.json @@ -0,0 +1,182 @@ +[ + { + "name": "default routes", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/users/{name}/posts/{postId}", + "post" + ] + ], + "cases": [ + { + "name": "builds user path", + "routeName": "user", + "values": { + "name": "alice" + }, + "expected": "/users/alice" + }, + { + "name": "builds post path", + "routeName": "post", + "values": { + "name": "alice", + "postId": "123" + }, + "expected": "/users/alice/posts/123" + }, + { + "name": "returns null for missing route name", + "routeName": "missing", + "values": {}, + "expected": null + } + ] + }, + { + "name": "leading path expansion templates", + "routeDefinitions": [ + [ + "{/identifier}/inbox", + "inbox" + ] + ], + "cases": [ + { + "name": "builds leading path expansion", + "routeName": "inbox", + "values": { + "identifier": "alice" + }, + "expected": "/alice/inbox" + } + ] + }, + { + "name": "template matching symmetry", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/files/{+path}", + "file" + ], + [ + "/actors{/identifier}", + "actor" + ] + ], + "cases": [ + { + "name": "escapes slash in simple variable", + "routeName": "user", + "values": { + "name": "a/b" + }, + "expected": "/users/a%2Fb" + }, + { + "name": "preserves slash in reserved expansion", + "routeName": "file", + "values": { + "path": "a/b" + }, + "expected": "/files/a/b" + }, + { + "name": "preserves pct-encoded slash in reserved expansion", + "routeName": "file", + "values": { + "path": "a%2Fb" + }, + "expected": "/files/a%2Fb" + }, + { + "name": "preserves pct-encoded reserved characters in reserved expansion", + "routeName": "file", + "values": { + "path": "%30%23" + }, + "expected": "/files/%30%23" + }, + { + "name": "preserves pct-encoded UTF-8 sequence in reserved expansion", + "routeName": "file", + "values": { + "path": "%E3%81%82" + }, + "expected": "/files/%E3%81%82" + }, + { + "name": "builds path expansion operator", + "routeName": "actor", + "values": { + "identifier": "alice" + }, + "expected": "/actors/alice" + } + ] + }, + { + "name": "pct-encoded query variable names", + "routeDefinitions": [ + [ + "/encoded-query{?abc%20def}", + "encodedQuery" + ], + [ + "/encoded-letters{?%41,%42}", + "encodedLetters" + ], + [ + "/matrix{;abc%20def}", + "matrix" + ], + [ + "/continuation?fixed=true{&abc%20def}", + "continuation" + ] + ], + "cases": [ + { + "name": "preserves pct-encoded triplet in query variable name", + "routeName": "encodedQuery", + "values": { + "abc%20def": "spaced" + }, + "expected": "/encoded-query?abc%20def=spaced" + }, + { + "name": "preserves pct-encoded query variable names", + "routeName": "encodedLetters", + "values": { + "%41": "a", + "%42": "b" + }, + "expected": "/encoded-letters?%41=a&%42=b" + }, + { + "name": "preserves pct-encoded matrix variable name", + "routeName": "matrix", + "values": { + "abc%20def": "spaced" + }, + "expected": "/matrix;abc%20def=spaced" + }, + { + "name": "preserves pct-encoded continuation variable name", + "routeName": "continuation", + "values": { + "abc%20def": "spaced" + }, + "expected": "/continuation?fixed=true&abc%20def=spaced" + } + ] + } +] diff --git a/packages/uri-template/src/tests/json/router/clone-suites.json b/packages/uri-template/src/tests/json/router/clone-suites.json new file mode 100644 index 000000000..b07b3b837 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/clone-suites.json @@ -0,0 +1,41 @@ +[ + { + "name": "clone keeps existing routes and isolates new routes", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/users/{name}/posts/{postId}", + "post" + ] + ], + "clonedRouteDefinitions": [ + [ + "/users/{name}/friends", + "friends" + ] + ], + "originalRouteCases": [ + { + "name": "original does not match cloned route", + "path": "/users/alice/friends", + "expected": null + } + ], + "clonedRouteCases": [ + { + "name": "clone matches cloned route", + "path": "/users/alice/friends", + "expected": { + "name": "friends", + "template": "/users/{name}/friends", + "values": { + "name": "alice" + } + } + } + ] + } +] diff --git a/packages/uri-template/src/tests/json/router/compile-error-cases.json b/packages/uri-template/src/tests/json/router/compile-error-cases.json new file mode 100644 index 000000000..895bd3572 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/compile-error-cases.json @@ -0,0 +1,18 @@ +[ + { + "name": "relative path template", + "path": "foo", + "expected": [ + "RouterError", + "RouteTemplatePathError" + ] + }, + { + "name": "invalid URI template literal", + "path": "/bad path", + "expected": [ + "TemplateParseError", + "InvalidLiteralError" + ] + } +] diff --git a/packages/uri-template/src/tests/json/router/hit-paths.json b/packages/uri-template/src/tests/json/router/hit-paths.json new file mode 100644 index 000000000..1cbe6c1a7 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/hit-paths.json @@ -0,0 +1,27 @@ +[ + "/.well-known/webfinger", + "/.well-known/nodeinfo", + "/users/alice", + "/users/alice/inbox", + "/users/alice/outbox", + "/users/alice/followers", + "/users/alice/following", + "/users/alice/liked", + "/users/alice/collections/featured", + "/users/alice/collections/tags", + "/users/alice/collections/bookmarks", + "/users/alice/statuses/2026050501", + "/users/alice/statuses/2026050501/activity", + "/users/alice/statuses/2026050501/replies", + "/users/alice/media/avatar", + "/objects/note/2026050501", + "/collections/public/2026050501", + "/tags/activitypub", + "/search?q=fedify&page=2", + "/files/images/avatar/original.png", + "/bob/inbox", + "/bob/outbox", + "/bob/followers", + "/bob/following", + "/bob/collections/articles" +] diff --git a/packages/uri-template/src/tests/json/router/miss-paths.json b/packages/uri-template/src/tests/json/router/miss-paths.json new file mode 100644 index 000000000..78e565bf8 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/miss-paths.json @@ -0,0 +1,15 @@ +[ + "/.well-known/host-meta", + "/users", + "/users/alice/statuses", + "/users/alice/statuses/2026050501/context", + "/users/alice/media", + "/objects/note", + "/collections/public", + "/tags", + "/search", + "/files", + "/bob", + "/bob/liked", + "/bob/collections" +] diff --git a/packages/uri-template/src/tests/json/router/route-definitions.json b/packages/uri-template/src/tests/json/router/route-definitions.json new file mode 100644 index 000000000..1b80c1f67 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/route-definitions.json @@ -0,0 +1,102 @@ +[ + [ + "/.well-known/webfinger", + "webfinger" + ], + [ + "/.well-known/nodeinfo", + "nodeInfoJrd" + ], + [ + "/users/{identifier}", + "actor" + ], + [ + "/users/{identifier}/inbox", + "inbox" + ], + [ + "/users/{identifier}/outbox", + "outbox" + ], + [ + "/users/{identifier}/following", + "following" + ], + [ + "/users/{identifier}/followers", + "followers" + ], + [ + "/users/{identifier}/liked", + "liked" + ], + [ + "/users/{identifier}/collections/featured", + "featured" + ], + [ + "/users/{identifier}/collections/tags", + "featuredTags" + ], + [ + "/users/{identifier}/collections/{collection}", + "collection" + ], + [ + "/users/{identifier}/statuses/{statusId}", + "note" + ], + [ + "/users/{identifier}/statuses/{statusId}/activity", + "activity" + ], + [ + "/users/{identifier}/statuses/{statusId}/replies", + "replies" + ], + [ + "/users/{identifier}/media/{mediaId}", + "media" + ], + [ + "/objects/{type}/{id}", + "object" + ], + [ + "/collections/{collection}/{itemId}", + "collectionItem" + ], + [ + "/tags/{tag}", + "tag" + ], + [ + "/search{?q,page}", + "search" + ], + [ + "/files/{+path}", + "file" + ], + [ + "{/identifier}/inbox", + "rootInbox" + ], + [ + "{/identifier}/outbox", + "rootOutbox" + ], + [ + "{/identifier}/followers", + "rootFollowers" + ], + [ + "{/identifier}/following", + "rootFollowing" + ], + [ + "{/identifier}/collections/{collection}", + "rootCollection" + ] +] diff --git a/packages/uri-template/src/tests/json/router/route-suites.json b/packages/uri-template/src/tests/json/router/route-suites.json new file mode 100644 index 000000000..61d4dfe9c --- /dev/null +++ b/packages/uri-template/src/tests/json/router/route-suites.json @@ -0,0 +1,357 @@ +[ + { + "name": "default trailing slash behavior", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/users/{name}/posts/{postId}", + "post" + ] + ], + "cases": [ + { + "name": "matches user path", + "path": "/users/alice", + "expected": { + "name": "user", + "template": "/users/{name}", + "values": { + "name": "alice" + } + } + }, + { + "name": "rejects user path with trailing slash", + "path": "/users/bob/", + "expected": null + }, + { + "name": "matches post path", + "path": "/users/alice/posts/123", + "expected": { + "name": "post", + "template": "/users/{name}/posts/{postId}", + "values": { + "name": "alice", + "postId": "123" + } + } + }, + { + "name": "rejects post path with trailing slash", + "path": "/users/bob/posts/456/", + "expected": null + } + ] + }, + { + "name": "trailing slash insensitive behavior", + "options": { + "trailingSlashInsensitive": true + }, + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/users/{name}/posts/{postId}/", + "post" + ] + ], + "cases": [ + { + "name": "matches user path without trailing slash", + "path": "/users/alice", + "expected": { + "name": "user", + "template": "/users/{name}", + "values": { + "name": "alice" + } + } + }, + { + "name": "matches user path with trailing slash", + "path": "/users/bob/", + "expected": { + "name": "user", + "template": "/users/{name}", + "values": { + "name": "bob" + } + } + }, + { + "name": "matches post path without trailing slash", + "path": "/users/alice/posts/123", + "expected": { + "name": "post", + "template": "/users/{name}/posts/{postId}/", + "values": { + "name": "alice", + "postId": "123" + } + } + }, + { + "name": "matches post path with trailing slash", + "path": "/users/bob/posts/456/", + "expected": { + "name": "post", + "template": "/users/{name}/posts/{postId}/", + "values": { + "name": "bob", + "postId": "456" + } + } + } + ] + }, + { + "name": "leading path expansion templates", + "routeDefinitions": [ + [ + "{/identifier}/inbox", + "inbox" + ] + ], + "cases": [ + { + "name": "matches expanded leading path segment", + "path": "/alice/inbox", + "expected": { + "name": "inbox", + "template": "{/identifier}/inbox", + "values": { + "identifier": "alice" + } + } + } + ] + }, + { + "name": "literal route priority", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/users/settings", + "settings" + ] + ], + "cases": [ + { + "name": "prefers more specific literal route", + "path": "/users/settings", + "expected": { + "name": "settings", + "template": "/users/settings", + "values": {} + } + } + ] + }, + { + "name": "template matching symmetry", + "routeDefinitions": [ + [ + "/users/{name}", + "user" + ], + [ + "/files/{+path}", + "file" + ], + [ + "/actors{/identifier}", + "actor" + ] + ], + "cases": [ + { + "name": "decodes escaped slash in simple variable", + "path": "/users/a%2Fb", + "expected": { + "name": "user", + "template": "/users/{name}", + "values": { + "name": "a/b" + } + } + }, + { + "name": "rejects raw slash in simple variable", + "path": "/users/a/b", + "expected": null + }, + { + "name": "preserves raw slash in reserved expansion", + "path": "/files/a/b", + "expected": { + "name": "file", + "template": "/files/{+path}", + "values": { + "path": "a/b" + } + } + }, + { + "name": "preserves pct-encoded slash in reserved expansion", + "path": "/files/a%2Fb", + "expected": { + "name": "file", + "template": "/files/{+path}", + "values": { + "path": "a%2Fb" + } + } + }, + { + "name": "preserves pct-encoded reserved characters in reserved expansion", + "path": "/files/%30%23", + "expected": { + "name": "file", + "template": "/files/{+path}", + "values": { + "path": "%30%23" + } + } + }, + { + "name": "preserves pct-encoded UTF-8 sequence in reserved expansion", + "path": "/files/%E3%81%82", + "expected": { + "name": "file", + "template": "/files/{+path}", + "values": { + "path": "%E3%81%82" + } + } + }, + { + "name": "matches path expansion operator", + "path": "/actors/alice", + "expected": { + "name": "actor", + "template": "/actors{/identifier}", + "values": { + "identifier": "alice" + } + } + } + ] + }, + { + "name": "pct-encoded variable names", + "routeDefinitions": [ + [ + "/encoded-query{?%41,%42}", + "encodedQuery" + ], + [ + "/matrix{;abc%20def}", + "matrix" + ], + [ + "/continuation?fixed=true{&abc%20def}", + "continuation" + ] + ], + "cases": [ + { + "name": "matches query expansion with pct-encoded variable names", + "path": "/encoded-query?%41=a&%42=b", + "expected": { + "name": "encodedQuery", + "template": "/encoded-query{?%41,%42}", + "values": { + "%41": "a", + "%42": "b" + } + } + }, + { + "name": "matches matrix expansion with pct-encoded variable name", + "path": "/matrix;abc%20def=spaced%20value", + "expected": { + "name": "matrix", + "template": "/matrix{;abc%20def}", + "values": { + "abc%20def": "spaced value" + } + } + }, + { + "name": "matches continuation expansion with pct-encoded variable name", + "path": "/continuation?fixed=true&abc%20def=spaced%20value", + "expected": { + "name": "continuation", + "template": "/continuation?fixed=true{&abc%20def}", + "values": { + "abc%20def": "spaced value" + } + } + } + ] + }, + { + "name": "query expansion with optional variables", + "routeDefinitions": [ + [ + "/search{?q,page}", + "search" + ] + ], + "cases": [ + { + "name": "matches query expansion with no variables", + "path": "/search", + "expected": { + "name": "search", + "template": "/search{?q,page}", + "values": {} + } + }, + { + "name": "matches first optional query variable", + "path": "/search?q=fedify", + "expected": { + "name": "search", + "template": "/search{?q,page}", + "values": { + "q": "fedify" + } + } + }, + { + "name": "matches later optional query variable without first variable", + "path": "/search?page=2", + "expected": { + "name": "search", + "template": "/search{?q,page}", + "values": { + "page": "2" + } + } + }, + { + "name": "matches unicode query variable value", + "path": "/search?q=%ED%8E%98%EB%94%94%ED%8C%8C%EC%9D%B4&page=42", + "expected": { + "name": "search", + "template": "/search{?q,page}", + "values": { + "page": "42", + "q": "페디파이" + } + } + } + ] + } +] diff --git a/packages/uri-template/src/tests/json/router/variables-cases.json b/packages/uri-template/src/tests/json/router/variables-cases.json new file mode 100644 index 000000000..236ff09e1 --- /dev/null +++ b/packages/uri-template/src/tests/json/router/variables-cases.json @@ -0,0 +1,58 @@ +[ + { + "name": "literal path", + "path": "/users", + "expected": [] + }, + { + "name": "single variable", + "path": "/users/{name}", + "expected": [ + "name" + ] + }, + { + "name": "multiple variables", + "path": "/users/{name}/posts/{postId}", + "expected": [ + "name", + "postId" + ] + }, + { + "name": "leading path expansion", + "path": "{/identifier}/inbox", + "expected": [ + "identifier" + ] + }, + { + "name": "pct-encoded query variable name", + "path": "/encoded-query{?abc%20def}", + "expected": [ + "abc%20def" + ] + }, + { + "name": "pct-encoded query variable names", + "path": "/encoded-query{?%41,%42}", + "expected": [ + "%41", + "%42" + ] + }, + { + "name": "pct-encoded matrix variable name", + "path": "/matrix{;abc%20def}", + "expected": [ + "abc%20def" + ] + }, + { + "name": "pct-encoded continuation variable name", + "path": "/continuation?fixed=true{&abc%20def}", + "expected": [ + "abc%20def" + ] + } +] diff --git a/packages/uri-template/src/tests/json/template/hard.json b/packages/uri-template/src/tests/json/template/hard.json new file mode 100644 index 000000000..14df39a3f --- /dev/null +++ b/packages/uri-template/src/tests/json/template/hard.json @@ -0,0 +1,324 @@ +[ + { + "name": "Pct-encoded triplets in literal text are emitted verbatim", + "cases": [ + { + "name": "Pct-encoded path separators in surrounding literal are preserved as-is", + "template": "%2Fapi%2Fv1%2F{var}", + "expected": "%2Fapi%2Fv1%2Fvalue", + "reason": "Literal text including pct-encoded triplets is emitted verbatim — the parser MUST NOT re-encode `%2F` to `%252F` nor decode it to `/`.", + "success": true + }, + { + "name": "Lowercase hex in a literal pct-encoded triplet is preserved without normalization", + "template": "%2f{var}%2F", + "expected": "%2fvalue%2F", + "reason": "RFC 6570 says hex digits are case-insensitive, but literals are emitted verbatim. The lowercase `%2f` MUST stay lowercase in the output even though the trailing `%2F` is uppercase.", + "success": true + }, + { + "name": "Pct-encoded percent sign in a literal does not become double-encoded", + "template": "%25{var}", + "expected": "%25value", + "reason": "`%25` is the literal pct-encoded form of `%`. Because it appears in literal text (not in an expanded value), it is NOT re-encoded into `%2525`.", + "success": true + }, + { + "name": "Multi-octet UTF-8 sequence written as pct-encoded literals stays untouched", + "template": "%ED%8E%98%EB%94%94%ED%8C%8C%EC%9D%B4{?var}", + "expected": "%ED%8E%98%EB%94%94%ED%8C%8C%EC%9D%B4?var=value", + "reason": "The literal `%ED%8E%98%EB%94%94%ED%8C%8C%EC%9D%B4` (the UTF-8 encoding of `페디파이`) consists of twelve pct-encoded triplets. The parser must accept consecutive pct-encoded literals and emit them unchanged.", + "success": true + }, + { + "name": "Pct-encoded operator-like characters in a literal do not introduce another expression", + "template": "%23section{?var}", + "expected": "%23section?var=value", + "reason": "`%23` is the encoded `#`. It appears in literal text, not in the operator slot immediately after `{`, so it must NOT trigger fragment-style expansion behavior.", + "success": true + }, + { + "name": "Pct-encoded forbidden literal characters appear in literal text", + "template": "%3C%3E%5C%5E%60%7C{var}", + "expected": "%3C%3E%5C%5E%60%7Cvalue", + "reason": "The bare characters `<`, `>`, `\\`, `^`, `` ` ``, `|` are forbidden by RFC 6570 literal syntax, but their pct-encoded forms `%3C`, `%3E`, `%5C`, `%5E`, `%60`, `%7C` are allowed verbatim.", + "success": true + } + ] + }, + { + "name": "Pct-encoded brackets in literals are not delimiters", + "cases": [ + { + "name": "Encoded `{` and `}` surrounding a varname are literal text, no expansion happens", + "template": "%7Bvar%7D", + "expected": "%7Bvar%7D", + "reason": "`%7B`/`%7D` are pct-encoded literals — they look like brackets to a human but are NOT expression delimiters. The whole template is one literal; `var` is never looked up.", + "success": true + }, + { + "name": "Encoded brackets surrounding a real expression form decorative literals", + "template": "%7B{var}%7D", + "expected": "%7Bvalue%7D", + "reason": "Only the unencoded `{`/`}` denote the expression. `%7B` before and `%7D` after are literal text — useful when you actually want braces around an expanded value.", + "success": true + }, + { + "name": "Encoded close-brace inside literal does not terminate a previous open expression", + "template": "{var}%7Dtail", + "expected": "value%7Dtail", + "reason": "After the expression `{var}` closes, the literal `%7Dtail` follows. The encoded `%7D` is ordinary literal text and does not interact with bracket parsing.", + "success": true + }, + { + "name": "Encoded brackets adjacent to real brackets must not be confused", + "template": "%7B%7B{var}%7D%7D", + "expected": "%7B%7Bvalue%7D%7D", + "reason": "Doubled encoded brackets are still literal. The single real expression `{var}` expands; the two encoded `%7B%7B` and `%7D%7D` flank it as decoration.", + "success": true + }, + { + "name": "Encoded brackets between two expressions do not nest or escape anything", + "template": "{var}%7B%7D{hello}", + "expected": "value%7B%7DHello%20World%21", + "reason": "The two expressions are independent and concatenate; the encoded `%7B%7D` literal sits between them like any other literal text.", + "success": true + } + ] + }, + { + "name": "Pct-encoded characters within varnames", + "cases": [ + { + "name": "Varname containing a pct-encoded space is looked up verbatim", + "template": "{abc%20def}", + "expected": "spaced", + "reason": "Per RFC 6570 ABNF, `varchar = ALPHA / DIGIT / \"_\" / pct-encoded`. So `abc%20def` is one valid varname. The lookup uses the pct-encoded form as the key — it is NOT decoded to `abc def`.", + "success": true + }, + { + "name": "Varname starting with a pct-encoded character is not interpreted as an operator", + "template": "{%2Bvar}", + "expected": "plus", + "reason": "The first character of the expression is `%`, which is not an operator. The varname is the entire pct-encoded string `%2Bvar` — it must NOT be confused with the `+` operator (whose pct-encoded form is `%2B`).", + "success": true + }, + { + "name": "Pct-encoded varname is preserved when emitted by a named operator", + "template": "{?abc%20def}", + "expected": "?abc%20def=spaced", + "reason": "Named operators echo the varname using \"the same encoding process as for literals\". A pct-encoded triplet inside the varname is therefore preserved verbatim — neither decoded nor double-encoded.", + "success": true + }, + { + "name": "Varname with an internal dot is one varname, not two joined by `.`", + "template": "{foo.bar}", + "expected": "dotted", + "reason": "ABNF: `varname = varchar *( [\".\"] varchar )`. Dots are part of the varname, so `foo.bar` is the literal lookup key — NOT a path into nested objects.", + "success": true + }, + { + "name": "Varname consisting only of digits is valid", + "template": "{123}", + "expected": "numeric", + "reason": "`varchar` includes DIGIT, so a varname may start with — and consist entirely of — digits.", + "success": true + }, + { + "name": "Pct-encoded varname combined with a normal varname in one expression", + "template": "{abc%20def,var}", + "expected": "spaced,value", + "reason": "Two independent varspecs joined by `,`. The first uses a pct-encoded varname; the lookup is independent of how varnames are written.", + "success": true + }, + { + "name": "Varname that looks like an encoded close-brace is a valid (undefined) varname", + "template": "O{%7D}X", + "expected": "OX", + "reason": "RFC 6570 §2.3: \"A varname MAY contain one or more pct-encoded triplets.\" So `%7D` is a single pct-encoded varchar — and a complete, valid varname. With no `%7D` key defined, the varname is undefined per §3.2.1 and the expression collapses to an empty string.", + "success": true + }, + { + "name": "Varname that looks like an encoded open-brace resolves to its defined value", + "template": "{%7B}", + "expected": "open-brace", + "reason": "Analogous to the undefined `%7D` case: `%7B` is a single pct-encoded varchar and a complete varname. With `%7B` mapped to `open-brace` in the variable set, the expression expands to that defined value — the encoded-bracket appearance does NOT prevent normal lookup.", + "success": true + }, + { + "name": "Varname spelled in pct-encoded form is NOT the same as its decoded counterpart", + "template": "{%41}", + "expected": "encoded-A", + "reason": "RFC 6570 §2.3: \"A varname containing pct-encoded characters is not the same variable as a varname with those same characters decoded.\" So `%41` and `A` are two distinct variable names. The lookup uses `%41` literally and yields `encoded-A`, not whatever `A` might (or might not) resolve to.", + "success": true + }, + { + "name": "Pct-encoded varname under a named operator emits the encoded form as the parameter name", + "template": "{?%41}", + "expected": "?%41=encoded-A", + "reason": "Named-operator output echoes the varname \"using the same encoding process as for literals\". Since `%41` is already a valid pct-encoded literal triplet, it is preserved verbatim — the parameter name in the URI is literally `%41`, not `A`.", + "success": true + } + ] + }, + { + "name": "Composite values with null members", + "cases": [ + { + "name": "Null entries in a list are skipped during expansion", + "template": "{sparse}", + "expected": "one,three", + "reason": "RFC 6570 §3.2.1 expands only defined list member string values. The null at index 1 is treated as undefined and skipped; only `one` and `three` are joined by `,`.", + "success": true + }, + { + "name": "Null entries in a list are skipped under named explode too", + "template": "{?sparse*}", + "expected": "?sparse=one&sparse=three", + "reason": "Null members are excluded from the iteration entirely. Only the two defined members emit `sparse=…` pairs joined by `&`.", + "success": true + }, + { + "name": "Null entries do not produce an empty path segment", + "template": "{/sparse*}", + "expected": "/one/three", + "reason": "Each defined member produces one path segment. The null member is skipped, so the output has TWO segments — NOT three with an empty middle one.", + "success": true + }, + { + "name": "Null values in an associative array drop their key", + "template": "{nullable_keys}", + "expected": "a,1,c,3", + "reason": "The pair `(b, null)` is excluded because its value is undefined. Only `a=1` and `c=3` survive, flattened as `name,value` joined by `,`.", + "success": true + }, + { + "name": "Null values are skipped under named explode of an associative array", + "template": "{?nullable_keys*}", + "expected": "?a=1&c=3", + "reason": "Pairs with undefined values are skipped before the `?`/`&` separator logic runs. The output looks identical to an object that simply did not contain key `b`.", + "success": true + } + ] + }, + { + "name": "Explode modifier on string values is silently ignored", + "cases": [ + { + "name": "Explode on a string under NUL is a no-op", + "template": "{var*}", + "expected": "value", + "reason": "The expansion algorithm dispatches on value type. For string values, the explode flag is irrelevant — the result is identical to `{var}`.", + "success": true + }, + { + "name": "Explode on a string under named operator does not multiply assignments", + "template": "{?var*}", + "expected": "?var=value", + "reason": "Strings have no \"members\" to explode over. The output is the same as `{?var}`. People often expect `?var=v&var=a&var=l&var=u&var=e` — that is wrong.", + "success": true + }, + { + "name": "Explode on an empty string is also a no-op", + "template": "{?empty*}", + "expected": "?empty=", + "reason": "Empty string is a defined string value. The string-branch logic runs and emits `name=` because of `ifemp = =`. The `*` has no effect.", + "success": true + }, + { + "name": "Explode on a path-like string still encodes reserved characters under NUL", + "template": "{path*}", + "expected": "%2Ffoo%2Fbar", + "reason": "Same string-branch — `*` is ignored. Encoding rules of NUL still apply: each `/` becomes `%2F`.", + "success": true + }, + { + "name": "Explode on a string with prefix-length-exceeding value is still a single value", + "template": "{hello*}", + "expected": "Hello%20World%21", + "reason": "`*` is silently ignored on the string `hello`. The result is the same as `{hello}`.", + "success": true + } + ] + }, + { + "name": "Mismatched pct-encoded brackets and stray brackets", + "cases": [ + { + "name": "Bare closing brace in literals is forbidden even when an encoded open brace precedes it", + "template": "%7Bvar}", + "expected": "StrayClosingBraceError", + "reason": "`%7B` is a pct-encoded literal — it does NOT open an expression. The trailing bare `}` is forbidden in literals (allowed character set excludes `}`), so the template is malformed.", + "success": false + }, + { + "name": "Real opening brace closed only by an encoded brace is unclosed", + "template": "{var%7D", + "expected": "UnclosedExpressionError", + "reason": "Inside the expression, `%7D` is a valid pct-encoded varchar — it becomes part of the varname `var%7D`. There is no actual `}` to terminate the expression, so parsing fails.", + "success": false + }, + { + "name": "Stray bare close-brace before any literal text or expression fails", + "template": "}%7B%7D", + "expected": "StrayClosingBraceError", + "reason": "The leading `}` is a bare close-brace in the literals position. RFC 6570 §2.1 forbids `}` from the literals character set regardless of what follows — the trailing encoded `%7B%7D` does not rescue the template.", + "success": false + }, + { + "name": "Encoded open brace followed by stray real close brace fails on the close brace", + "template": "%7B}", + "expected": "StrayClosingBraceError", + "reason": "The literal `%7B` is fine. The following bare `}` is forbidden in literals.", + "success": false + }, + { + "name": "Real opening brace nested inside a varname-shaped sequence is invalid", + "template": "{var%7B%7D", + "expected": "UnclosedExpressionError", + "reason": "The expression body becomes `var%7B%7D` — two pct-encoded characters all valid as varchars — but the expression never sees a literal `}`. Unclosed.", + "success": false + } + ] + }, + { + "name": "Prefix modifier on composite values (RFC 6570 §2.4.1)", + "cases": [ + { + "name": "Prefix on a list is not applicable per spec", + "template": "{list:3}", + "expected": "PrefixModifierNotApplicableError", + "reason": "RFC 6570 §2.4.1 states: \"Prefix modifiers are not applicable to variables that have composite values.\" `list` is composite, so this must fail at expansion time.", + "success": false + }, + { + "name": "Prefix on an associative array is not applicable per spec", + "template": "{keys:3}", + "expected": "PrefixModifierNotApplicableError", + "reason": "Same rule as for lists: prefix length is undefined for object values.", + "success": false + }, + { + "name": "Prefix on a list under named operator also fails", + "template": "{?list:6}", + "expected": "PrefixModifierNotApplicableError", + "reason": "The named operator does not bypass the §2.4.1 restriction. The prefix modifier remains inapplicable regardless of the surrounding operator.", + "success": false + }, + { + "name": "Prefix on a path-segment expansion of a composite still fails", + "template": "{/keys:4}", + "expected": "PrefixModifierNotApplicableError", + "reason": "Path-segment operator does not change the type of the variable. Composite means composite.", + "success": false + }, + { + "name": "Prefix on the canonical `count` list also fails", + "template": "{count:2}", + "expected": "PrefixModifierNotApplicableError", + "reason": "`count` is `[\"one\", \"two\", \"three\"]`. Prefix modifier is invalid for it. The error is at expansion time, not parsing time — the syntax `count:2` is grammatically well-formed.", + "success": false + } + ] + } +] diff --git a/packages/uri-template/src/tests/json/template/match.json b/packages/uri-template/src/tests/json/template/match.json new file mode 100644 index 000000000..e240be9dc --- /dev/null +++ b/packages/uri-template/src/tests/json/template/match.json @@ -0,0 +1,582 @@ +[ + { + "name": "Required literals must be present in the URI", + "cases": [ + { + "name": "Form-style query operator requires the leading `?`", + "template": "{?x}", + "uri": "x=value", + "expected": null, + "reason": "The `?` operator unconditionally emits `?` as its first character when any variable is defined. A URI lacking `?` cannot be the round-trip result of any binding." + }, + { + "name": "Fragment operator requires the leading `#`", + "template": "X{#var}", + "uri": "Xvalue", + "expected": null, + "reason": "Fragment expansion always begins with `#` when the variable is defined. The only no-`#` alternative would have var undefined, which would round-trip to just `X`, not `Xvalue`." + }, + { + "name": "Path-style parameter requires the leading `;`", + "template": "{;who}", + "uri": "who=fred", + "expected": null, + "reason": "The `;` operator unconditionally emits `;` before the parameter. Without that prefix the URI cannot be a valid expansion." + }, + { + "name": "Trailing literal that is not present in the URI causes match to fail", + "template": "{+path}/end", + "uri": "/a/b/c", + "expected": null, + "reason": "The required trailing literal `/end` is not present anywhere in the URI; no slicing of `+path` can produce a valid round-trip." + }, + { + "name": "Surrounding literal at wrong position rejects the URI", + "template": "/api/{id}/profile", + "uri": "/api/123/page", + "expected": null, + "reason": "After consuming `/api/` and the `{id}` expression, the tail must equal `/profile`. `/page` cannot match because simple expansion forbids `/` in `id`." + }, + { + "name": "Pct-encoded literal does not match its decoded form", + "template": "%2F{var}", + "uri": "/value", + "expected": null, + "reason": "Literal text in a template is matched verbatim. The literal `%2F` only matches the three-character sequence `%2F`, not the single byte `/`." + } + ] + }, + { + "name": "Operator allow-sets gate which characters can appear in the captured value", + "cases": [ + { + "name": "Simple expansion does not permit a raw `/` in the captured value", + "template": "{var}", + "uri": "a/b", + "expected": null, + "reason": "Simple expansion percent-encodes `/`. A URI containing a raw `/` cannot be the round-trip result of any binding because expansion would emit `%2F`." + }, + { + "name": "Reserved expansion captures raw `/` as part of the value", + "template": "{+path}", + "uri": "/foo/bar/baz", + "expected": { + "path": "/foo/bar/baz" + }, + "reason": "Reserved expansion (`+`) allows reserved characters unencoded. The whole URI is the value of `path`." + }, + { + "name": "Reserved expansion preserves pct-encoded triplets verbatim", + "template": "{+var}", + "uri": "a%2Fb", + "expected": { + "var": "a%2Fb" + }, + "reason": "Per RFC 6570 §3.2.3, reserved expansion emits already-encoded triplets without re-encoding. Match must retain the encoded form rather than decode it to `a/b`." + }, + { + "name": "Fragment expansion treats already-encoded triplets verbatim", + "template": "{#var}", + "uri": "#a%2Fb", + "expected": { + "var": "a%2Fb" + }, + "reason": "Like `+`, the `#` operator allows reserved + pct-encoding (U+R). The captured value retains its encoded form." + } + ] + }, + { + "name": "Prefix modifier must yield a binding consistent with the truncated value", + "cases": [ + { + "name": "Prefix length equal to the value length is a no-op", + "template": "{var:5}", + "uri": "value", + "expected": { + "var": "value" + }, + "reason": "A 5-char prefix on a 5-char value truncates to the original. Match binds the full string." + }, + { + "name": "Prefix length larger than the value is also a no-op", + "template": "{var:30}", + "uri": "value", + "expected": { + "var": "value" + }, + "reason": "A prefix length larger than the value returns the full value unchanged. Match binds var to the literal URI content." + }, + { + "name": "URI longer than the declared prefix length cannot round-trip", + "template": "{var:3}", + "uri": "value", + "expected": null, + "reason": "Any binding for `var` would expand to its first 3 characters. The URI `value` has length 5, so no binding can round-trip." + }, + { + "name": "Prefix and full reference of the same variable must agree", + "template": "{/var:1,var}", + "uri": "/v/wrong", + "expected": null, + "reason": "First occurrence forces var[:1] === 'v'; second forces var === 'wrong'. truncate('wrong', 1) === 'w' ≠ 'v', so no binding satisfies both." + }, + { + "name": "Encoded prefix segment is consistent with the full encoded segment", + "template": "{/semi:2,semi}", + "uri": "/%3B/%3B", + "expected": { + "semi": ";" + }, + "reason": "semi=';'. truncate(';', 2) === ';'. Both occurrences expand to `%3B`, so the URI matches with semi=';'." + } + ] + }, + { + "name": "Boundary disambiguation across multiple literal occurrences", + "cases": [ + { + "name": "Reserved expansion picks the boundary that lets the trailing literal match the URI tail", + "template": "{+path}/end", + "uri": "/a/b/end/end", + "expected": { + "path": "/a/b/end" + }, + "reason": "`/end` appears twice. Only when `+path` consumes through the first `/end` (binding `path` to `/a/b/end`) does the remaining tail equal the trailing literal." + }, + { + "name": "Simple expansion forbids `/`, eliminating all candidate boundaries", + "template": "{var}/end", + "uri": "value/middle/end", + "expected": null, + "reason": "Simple expansion cannot capture a `/`. Both possible boundaries assign a value containing `/`, so no binding round-trips." + }, + { + "name": "Empty captured value between fixed literals is allowed when ifemp permits it", + "template": "O{empty}X", + "uri": "OX", + "expected": { + "empty": "" + }, + "reason": "Simple expansion of an empty string emits nothing. Match recovers `empty` as the empty string." + } + ] + }, + { + "name": "Named operators: empty values, missing variables, and name mismatches", + "cases": [ + { + "name": "Empty value with `=` separator binds the variable to empty string", + "template": "{?x}", + "uri": "?x=", + "expected": { + "x": "" + }, + "reason": "Form-style query expansion of an empty string emits `name=` per the `ifemp` table entry. Match recovers `x` as the empty string." + }, + { + "name": "Path-style flag form (`;name` without `=`) binds the variable to empty string", + "template": "{;empty}", + "uri": ";empty", + "expected": { + "empty": "" + }, + "reason": "The `;` operator with an empty value emits `;name` (no `=`) per `ifemp`. Match recovers `empty` as the empty string." + }, + { + "name": "Form-style with one of two variables undefined", + "template": "{?x,y}", + "uri": "?y=value", + "expected": { + "y": "value" + }, + "reason": "Undefined variables are skipped during expansion. Match must allow the same: `x` is unbound, `y` is bound to `value`." + }, + { + "name": "Named query with a mismatched parameter name fails to match", + "template": "{?x}", + "uri": "?other=value", + "expected": null, + "reason": "The `?` operator emits the varname literally. `other` is not the varname `x`, so no binding can produce this URI." + }, + { + "name": "Named explode of a list emits the varname per element", + "template": "{?list*}", + "uri": "?list=red&list=green&list=blue", + "expected": { + "list": [ + "red", + "green", + "blue" + ] + }, + "reason": "Per RFC 6570 §3.2.8, an exploded list under `?` emits `list=red&list=green&...`. Repetition of the same name signals list semantics." + } + ] + }, + { + "name": "Composite values: list vs. associative disambiguation", + "cases": [ + { + "name": "Exploded named pairs decode as an associative array", + "template": "{?keys*}", + "uri": "?a=1&b=2", + "expected": { + "keys": { + "a": "1", + "b": "2" + } + }, + "reason": "Each `name=value` part with a unique name decodes as an entry of the associative form. The list interpretation would require all parts to share `name === 'keys'`." + }, + { + "name": "Non-exploded composite under a path segment serializes as comma-separated key,value pairs", + "template": "{/keys}", + "uri": "/semi,%3B,dot,.,comma,%2C", + "expected": { + "keys": { + "semi": ";", + "dot": ".", + "comma": "," + } + }, + "reason": "An even-numbered comma list pairs to a valid associative form whose round-trip equals the URI. The associative interpretation is preferred over list when both round-trip identically." + }, + { + "name": "Exploded path segments decode as a list", + "template": "{/list*}", + "uri": "/red/green/blue", + "expected": { + "list": [ + "red", + "green", + "blue" + ] + }, + "reason": "`{/list*}` distributes list elements as `/`-separated segments. Match recovers the list of three elements." + } + ] + }, + { + "name": "Decoding failures bubble up as match failure", + "cases": [ + { + "name": "Invalid pct-encoded byte sequence cannot be decoded under simple expansion", + "template": "{var}", + "uri": "bad%FFseq", + "expected": null, + "reason": "decodeURIComponent('bad%FFseq') throws because `%FF` is not a valid UTF-8 byte. No binding can produce this URI under simple expansion (which percent-decodes captured values)." + }, + { + "name": "Truncated pct-encoded triplet at end of value", + "template": "{var}", + "uri": "abc%2", + "expected": null, + "reason": "`%2` is an incomplete pct-encoded triplet. decodeURIComponent throws, so no binding can round-trip under simple expansion." + }, + { + "name": "Reserved expansion accepts an otherwise-undecodable triplet verbatim", + "template": "{+var}", + "uri": "bad%FFseq", + "expected": { + "var": "bad%FFseq" + }, + "reason": "The same URI fails under simple expansion because decodeURIComponent rejects the `%FF` byte. Under reserved expansion, captured triplets are emitted verbatim — no decoding is performed — so the binding round-trips." + } + ] + }, + { + "name": "ActivityPub-style URIs combine host and resource paths", + "cases": [ + { + "name": "Actor URI splits host and username across two simple expansions", + "template": "https://{host}/users/{user}", + "uri": "https://example.com/users/alice", + "expected": { + "host": "example.com", + "user": "alice" + }, + "reason": "Simple expansion captures `example.com` for `host` and `alice` for `user`. The intervening literal `/users/` fixes the boundary the first expression must stop at." + }, + { + "name": "Three-level actor object URI binds host, user, and object id", + "template": "https://{host}/users/{user}/notes/{id}", + "uri": "https://example.com/users/alice/notes/abc-123", + "expected": { + "host": "example.com", + "user": "alice", + "id": "abc-123" + }, + "reason": "Each simple expansion is bounded by a surrounding literal. `-` is in the unreserved set, so `abc-123` round-trips for `id` without encoding." + }, + { + "name": "Reserved expansion captures host with port while trailing path remains literal", + "template": "https://{+host}/users/{user}", + "uri": "https://example.com:3000/users/alice", + "expected": { + "host": "example.com:3000", + "user": "alice" + }, + "reason": "Simple expansion would percent-encode `:`. Reserved expansion (`+`) allows reserved characters unencoded, so `host` retains the port suffix verbatim." + }, + { + "name": "Pct-encoded UTF-8 username decodes to its Unicode form", + "template": "https://{host}/users/{user}", + "uri": "https://example.com/users/%ED%8E%98%EB%94%94%ED%8C%8C%EC%9D%B4", + "expected": { + "host": "example.com", + "user": "페디파이" + }, + "reason": "Simple expansion percent-decodes the captured triplets as UTF-8. Re-expanding `페디파이` produces the same byte sequence, so the binding round-trips." + }, + { + "name": "WebFinger discovery URL captures host literal and form-style query parameter", + "template": "https://{host}/.well-known/webfinger{?resource}", + "uri": "https://example.com/.well-known/webfinger?resource=acct%3Aalice%40example.com", + "expected": { + "host": "example.com", + "resource": "acct:alice@example.com" + }, + "reason": "Form-style expansion (`?`) percent-encodes `:` and `@`. The captured value decodes to `acct:alice@example.com`; expanding it re-encodes the same octets to round-trip identically." + }, + { + "name": "Mismatched trailing literal between expressions rejects the URI", + "template": "https://{host}/users/{user}/inbox", + "uri": "https://example.com/users/alice/outbox", + "expected": null, + "reason": "After consuming `host` and `user`, the trailing literal `/inbox` does not appear in the URI. Simple expansion cannot capture `/`, so no boundary lets both literals align." + } + ] + }, + { + "name": "REST resource routes mix path segments and query parameters", + "cases": [ + { + "name": "Versioned API path joins literal with two simple expansions and a path segment", + "template": "/api/v{version}/users{/id}", + "uri": "/api/v1/users/42", + "expected": { + "version": "1", + "id": "42" + }, + "reason": "`{version}` is bounded by the literal `/users`; `{/id}` consumes the final `/42`. Both values are unreserved, so they round-trip without encoding." + }, + { + "name": "Form-style query with two parameters captures both as separate bindings", + "template": "/posts{?page,limit}", + "uri": "/posts?page=2&limit=20", + "expected": { + "page": "2", + "limit": "20" + }, + "reason": "The `?` operator emits `?page=2&limit=20`. Each named part decodes into its declared variable in declaration order." + }, + { + "name": "Path-style segment combined with explode list query", + "template": "/posts{/id}{?include*}", + "uri": "/posts/42?include=author&include=tags", + "expected": { + "id": "42", + "include": [ + "author", + "tags" + ] + }, + "reason": "`{?include*}` distributes a list as repeated `include=…` parameters per RFC 6570 §3.2.8. Match recovers the list of two members; `{/id}` separately captures `42`." + }, + { + "name": "Exploded path segments yield a list across multiple URI segments", + "template": "/files{/path*}", + "uri": "/files/a/b/c", + "expected": { + "path": [ + "a", + "b", + "c" + ] + }, + "reason": "`{/path*}` distributes list members as `/`-separated segments. The string interpretation `path = \"a/b/c\"` would round-trip to `/a%2Fb%2Fc`, so only the list binding is consistent." + }, + { + "name": "Optional query expression is absent and matches with the variable unbound", + "template": "/api/users{/id}{?fields}", + "uri": "/api/users/42", + "expected": { + "id": "42" + }, + "reason": "When all variables in a `{?…}` expression are undefined, the expansion is the empty string. The URI ends right after `{/id}`, so `fields` remains unbound." + }, + { + "name": "Form-style query with an unrecognized parameter name fails to match", + "template": "/api/users{/id}{?fields}", + "uri": "/api/users/42?wrong=value", + "expected": null, + "reason": "`{?fields}` requires the literal name `fields`. The named operator must consume every part of its expression body, so the unknown `wrong` part cannot be discarded." + } + ] + }, + { + "name": "Form-style query continuation appends parameters after a fixed query prefix", + "cases": [ + { + "name": "Continuation operator binds two variables after a literal query prefix", + "template": "?fixed=yes{&x,y}", + "uri": "?fixed=yes&x=1024&y=768", + "expected": { + "x": "1024", + "y": "768" + }, + "reason": "The `&` operator emits `&x=1024&y=768` after the literal `?fixed=yes`. Each named part decodes into its declared variable in order." + }, + { + "name": "Form-style query feeds into a continuation across two expressions", + "template": "/search{?q}{&page}", + "uri": "/search?q=cats&page=2", + "expected": { + "q": "cats", + "page": "2" + }, + "reason": "`{?q}` emits `?q=cats`; `{&page}` emits `&page=2`. Match must split the URI tail at the start of `&page` so each expression sees only its own parts." + }, + { + "name": "Two consecutive continuation expressions split distinct parameters", + "template": "?type=user{&q}{&page}", + "uri": "?type=user&q=alice&page=2", + "expected": { + "q": "alice", + "page": "2" + }, + "reason": "Each `&…` expression declares exactly one named variable, so the only consistent split places `&q=alice` in the first expression and `&page=2` in the second." + }, + { + "name": "Continuation operator with a mismatched parameter name fails", + "template": "/search{?q}{&page}", + "uri": "/search?q=cats&pg=2", + "expected": null, + "reason": "`{&page}` requires the literal name `page`. The shortened `pg` cannot satisfy any binding under the named operator, so no boundary between the two expressions succeeds." + } + ] + }, + { + "name": "Fragment-anchored deep links carry path and fragment in separate expressions", + "cases": [ + { + "name": "Path expression precedes fragment expression", + "template": "/docs/{page}{#anchor}", + "uri": "/docs/intro#getting-started", + "expected": { + "page": "intro", + "anchor": "getting-started" + }, + "reason": "Simple expansion would percent-encode `#`, so `{page}` cannot consume past the fragment delimiter. `{#anchor}` consumes the leading `#` as the fragment operator prefix and binds `anchor` to the value after it." + }, + { + "name": "Section and id captured as path segments before a fragment", + "template": "/{section}/{id}{#anchor}", + "uri": "/posts/42#comments", + "expected": { + "section": "posts", + "id": "42", + "anchor": "comments" + }, + "reason": "Three expressions cooperate: `{section}` and `{id}` are simple expansions bounded by `/` literals; `{#anchor}` consumes the `#` prefix and captures the fragment value after it." + }, + { + "name": "Missing fragment leaves the variable unbound", + "template": "/docs/{page}{#anchor}", + "uri": "/docs/intro", + "expected": { + "page": "intro" + }, + "reason": "An undefined variable under `#` produces no output. `{#anchor}` matches the empty tail of the URI, leaving `anchor` unbound." + }, + { + "name": "Acct URI splits user and host around a literal `@`", + "template": "acct:{user}@{host}", + "uri": "acct:alice@example.com", + "expected": { + "user": "alice", + "host": "example.com" + }, + "reason": "The literal `@` between the two simple expansions disambiguates the boundary. Both captured values contain only unreserved characters, so they round-trip without encoding." + } + ] + }, + { + "name": "Repeated variable names across expressions must agree on a single value", + "cases": [ + { + "name": "Same simple variable appearing twice with the same value succeeds", + "template": "/{lang}/docs/{lang}", + "uri": "/en/docs/en", + "expected": { + "lang": "en" + }, + "reason": "RFC 6570 §3.2.1 requires a variable's value to remain static throughout the expansion. Both occurrences capture `en`, so a single binding `lang = 'en'` satisfies both." + }, + { + "name": "Same simple variable appearing twice with conflicting values fails", + "template": "/{lang}/docs/{lang}", + "uri": "/en/docs/ko", + "expected": null, + "reason": "The first occurrence forces `lang = 'en'` and the second forces `lang = 'ko'`. No single binding can satisfy both, so the merge step rejects the candidate." + }, + { + "name": "Same variable shared between simple and reserved expansions, both consistent", + "template": "/{path}/raw/{+path}", + "uri": "/notes/raw/notes", + "expected": { + "path": "notes" + }, + "reason": "Simple expansion of `notes` is `notes`; reserved expansion of `notes` is also `notes`. Both occurrences agree on `path = 'notes'`." + }, + { + "name": "Same variable shared between simple and reserved expansions diverges on encoding", + "template": "/{path}/raw/{+path}", + "uri": "/foo%2Fbar/raw/foo/bar", + "expected": { + "path": "foo/bar" + }, + "reason": "`{path}` decodes `foo%2Fbar` to `foo/bar`; `{+path}` captures `foo/bar` verbatim. Both occurrences resolve to the same `path = 'foo/bar'`, and re-expanding under each operator reproduces the URI." + }, + { + "name": "Same variable shared between simple and reserved expansions with mismatched payloads fails", + "template": "/{path}/raw/{+path}", + "uri": "/foo/raw/bar", + "expected": null, + "reason": "Simple expansion forces `path = 'foo'`; reserved expansion forces `path = 'bar'`. The two bindings cannot be reconciled, so the URI does not round-trip under any single context." + }, + { + "name": "Variable repeated across path and form-style query with the same value", + "template": "/users/{id}/profile{?id}", + "uri": "/users/42/profile?id=42", + "expected": { + "id": "42" + }, + "reason": "The first `{id}` consumes `42` from the path; the second emits `?id=42` under the `?` operator. Both occurrences agree, so a single binding `id = '42'` satisfies the whole template." + }, + { + "name": "Variable repeated across path and query diverges on the captured value", + "template": "/users/{id}/profile{?id}", + "uri": "/users/42/profile?id=99", + "expected": null, + "reason": "The path occurrence forces `id = '42'` and the query occurrence forces `id = '99'`. RFC 6570 §3.2.1 forbids the variable from taking two values within the same expansion." + }, + { + "name": "Prefix and full reference of the same variable both contribute to the binding", + "template": "/{slug:3}/{slug}", + "uri": "/int/intro", + "expected": { + "slug": "intro" + }, + "reason": "`{slug:3}` truncates `slug` to its first three characters (`int`); `{slug}` emits the full value. The full-value occurrence determines `slug = 'intro'`, and `truncate('intro', 3) === 'int'` confirms the prefix occurrence." + }, + { + "name": "Prefix and full reference of the same variable disagree on the truncation", + "template": "/{slug:3}/{slug}", + "uri": "/abc/intro", + "expected": null, + "reason": "Full reference forces `slug = 'intro'`, but `truncate('intro', 3)` is `int`, not `abc`. The prefix and full bindings cannot agree, so no candidate round-trips." + } + ] + } +] diff --git a/packages/uri-template/src/tests/json/template/wrong.json b/packages/uri-template/src/tests/json/template/wrong.json new file mode 100644 index 000000000..fbac4c69a --- /dev/null +++ b/packages/uri-template/src/tests/json/template/wrong.json @@ -0,0 +1,407 @@ +[ + { + "name": "Brackets not matched", + "cases": [ + { + "template": "{var", + "name": "unclosed opening brace", + "expected": "UnclosedExpressionError" + }, + { + "template": "var}", + "name": "unmatched closing brace after literal", + "expected": "StrayClosingBraceError" + }, + { + "template": "{", + "name": "lone opening brace", + "expected": "UnclosedExpressionError" + }, + { + "template": "}", + "name": "lone closing brace", + "expected": "StrayClosingBraceError" + }, + { + "template": "prefix{var", + "name": "unclosed expression after literal prefix", + "expected": "UnclosedExpressionError" + }, + { + "template": "{a}{b", + "name": "second expression unclosed", + "expected": "UnclosedExpressionError" + }, + { + "template": "{+var:3", + "name": "unclosed expression with operator and prefix modifier", + "expected": "UnclosedExpressionError" + }, + { + "template": "{var,name", + "name": "unclosed expression with multiple varspecs", + "expected": "UnclosedExpressionError" + }, + { + "template": "text{?q,r", + "name": "unclosed query-style expression", + "expected": "UnclosedExpressionError" + } + ] + }, + { + "name": "Duplicated brackets", + "cases": [ + { + "template": "{{var}}", + "name": "nested opening braces", + "expected": "NestedOpeningBraceError" + }, + { + "template": "{var}}", + "name": "extra closing brace after expression", + "expected": "StrayClosingBraceError" + }, + { + "template": "{{var}", + "name": "extra opening brace before expression", + "expected": "NestedOpeningBraceError" + }, + { + "template": "{}}", + "name": "extra closing brace after empty expression", + "expected": "EmptyExpressionError" + }, + { + "template": "{{}", + "name": "nested empty expression", + "expected": "NestedOpeningBraceError" + }, + { + "template": "{a{b}", + "name": "opening brace inside expression", + "expected": "NestedOpeningBraceError" + }, + { + "template": "{a}b}", + "name": "extra closing brace after literal", + "expected": "StrayClosingBraceError" + }, + { + "template": "{{{var}}}", + "name": "triple-nested braces", + "expected": "NestedOpeningBraceError" + } + ] + }, + { + "name": "Wrong position of level4 modifier", + "cases": [ + { + "template": "{*var}", + "name": "explode placed before varname", + "expected": "UnknownOperatorError" + }, + { + "template": "{:3var}", + "name": "prefix placed before varname", + "expected": "UnknownOperatorError" + }, + { + "template": "{var*name}", + "name": "explode placed in the middle of varname", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{var:3:5}", + "name": "two prefix modifiers on a single varspec", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{var**}", + "name": "two explode modifiers on a single varspec", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{var*:3}", + "name": "explode followed by prefix on a single varspec", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{var:3*}", + "name": "prefix followed by explode on a single varspec", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{*}", + "name": "lone explode modifier without varname", + "expected": "UnknownOperatorError" + }, + { + "template": "{:3}", + "name": "lone prefix modifier without varname", + "expected": "UnknownOperatorError" + }, + { + "template": "{a*,b*c}", + "name": "explode placed in the middle of the second varname", + "expected": "UnexpectedCharacterError" + } + ] + }, + { + "name": "Wrong prefix modifier", + "cases": [ + { + "template": "{var:}", + "name": "empty prefix length (max-length requires at least one digit)", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:0}", + "name": "zero prefix length (max-length first digit is %x31-39)", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:01}", + "name": "leading zero in prefix length", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:10000}", + "name": "prefix length must be less than 10000", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:99999}", + "name": "prefix length exceeds max-length digit count", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:abc}", + "name": "non-numeric prefix length", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:-1}", + "name": "negative prefix length", + "expected": "InvalidPrefixError" + }, + { + "template": "{var: 3}", + "name": "space before prefix length", + "expected": "InvalidPrefixError" + }, + { + "template": "{var:3a}", + "name": "non-digit character after digits in prefix length", + "expected": "UnexpectedCharacterError" + }, + { + "template": "{var:1.5}", + "name": "decimal point in prefix length", + "expected": "UnexpectedCharacterError" + } + ] + }, + { + "name": "Invalid characters in literals", + "cases": [ + { + "template": "\\u0000", + "name": "NUL control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u0001", + "name": "SOH control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u0009", + "name": "TAB control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u000A", + "name": "LF control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u000D", + "name": "CR control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u001F", + "name": "US control character (last C0 CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "\\u007F", + "name": "DEL control character (CTL forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": " ", + "name": "space character (SP forbidden in literals)", + "expected": "InvalidLiteralError" + }, + { + "template": "a b", + "name": "embedded space in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "\"", + "name": "double-quote character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "'", + "name": "apostrophe forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "%", + "name": "lone percent sign without pct-encoded triplet", + "expected": "InvalidLiteralError" + }, + { + "template": "%1", + "name": "incomplete pct-encoded sequence", + "expected": "InvalidLiteralError" + }, + { + "template": "%G1", + "name": "invalid first hex digit in pct-encoded sequence", + "expected": "InvalidLiteralError" + }, + { + "template": "%1G", + "name": "invalid second hex digit in pct-encoded sequence", + "expected": "InvalidLiteralError" + }, + { + "template": "<", + "name": "less-than character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": ">", + "name": "greater-than character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "\\", + "name": "backslash character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "^", + "name": "caret character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "`", + "name": "backtick character forbidden in literals", + "expected": "InvalidLiteralError" + }, + { + "template": "|", + "name": "pipe character forbidden in literals", + "expected": "InvalidLiteralError" + } + ] + }, + { + "name": "Invalid characters in expression", + "cases": [ + { + "template": "{}", + "name": "empty expression (variable-list requires at least one varspec)", + "expected": "EmptyExpressionError" + }, + { + "template": "{,var}", + "name": "reserved comma operator before variable-list", + "expected": "ReservedOperatorError" + }, + { + "template": "{var,}", + "name": "empty varname after trailing comma in variable-list", + "expected": "TrailingCommaError" + }, + { + "template": "{var,,name}", + "name": "consecutive commas yielding an empty varspec", + "expected": "EmptyVarNameError" + }, + { + "template": "{var..name}", + "name": "consecutive dots in varname (varname allows at most one dot between varchars)", + "expected": "InvalidVarNameError" + }, + { + "template": "{var.}", + "name": "trailing dot in varname (dot must be followed by a varchar)", + "expected": "InvalidVarNameError" + }, + { + "template": "{var-name}", + "name": "hyphen is not in the varchar set", + "expected": "InvalidVarNameError" + }, + { + "template": "{var name}", + "name": "space is not in the varchar set", + "expected": "InvalidVarNameError" + }, + { + "template": "{var$name}", + "name": "dollar sign is not in the varchar set", + "expected": "InvalidVarNameError" + }, + { + "template": "{var^name}", + "name": "caret is not in the varchar set", + "expected": "InvalidVarNameError" + }, + { + "template": "{var%G1}", + "name": "invalid hex in pct-encoded varchar", + "expected": "InvalidVarNameError" + }, + { + "template": "{=var}", + "name": "operator '=' is reserved (op-reserve, unimplemented)", + "expected": "ReservedOperatorError" + }, + { + "template": "{!var}", + "name": "operator '!' is reserved (op-reserve, unimplemented)", + "expected": "ReservedOperatorError" + }, + { + "template": "{@var}", + "name": "operator '@' is reserved (op-reserve, unimplemented)", + "expected": "ReservedOperatorError" + }, + { + "template": "{|var}", + "name": "operator '|' is reserved (op-reserve, unimplemented)", + "expected": "ReservedOperatorError" + }, + { + "template": "{++var}", + "name": "duplicated operator characters", + "expected": "EmptyVarNameError" + }, + { + "template": "{+#var}", + "name": "two operators in a single expression", + "expected": "EmptyVarNameError" + } + ] + } +] diff --git a/packages/uri-template/src/tests/mod.ts b/packages/uri-template/src/tests/mod.ts new file mode 100644 index 000000000..52d8ff9bc --- /dev/null +++ b/packages/uri-template/src/tests/mod.ts @@ -0,0 +1,162 @@ +// Add runtime validators only when tsc cannot verify a test suite JSON import +// precisely enough to infer the exported type. +import type { Path } from "../types.ts"; +import { + assertHardTestSuite, + assertMatchTestSuite, + assertPairTestSuite, + assertRouterBuildCases, + assertRouterBuildTestSuites, + assertRouterCloneTestSuites, + assertRouterCompileErrorCases, + assertRouterPaths, + assertRouterRouteDefinitions, + assertRouterRouteTestSuites, + assertRouterVariablesCases, + assertWrongTestSuite, +} from "./assert.ts"; +import _fixedTestSuites from "./json/references/fixed.json" with { + type: "json", +}; +import _pairTestSuites from "./json/references/pairs.json" with { + type: "json", +}; +import _routerBuildCases from "./json/router/build-cases.json" with { + type: "json", +}; +import _routerBuildTestSuites from "./json/router/build-suites.json" with { + type: "json", +}; +import _routerCloneTestSuites from "./json/router/clone-suites.json" with { + type: "json", +}; +import _routerCompileErrorCases from "./json/router/compile-error-cases.json" with { + type: "json", +}; +import _routerHitPaths from "./json/router/hit-paths.json" with { + type: "json", +}; +import _routerMissPaths from "./json/router/miss-paths.json" with { + type: "json", +}; +import _routerRouteDefinitions from "./json/router/route-definitions.json" with { + type: "json", +}; +import _routerRouteTestSuites from "./json/router/route-suites.json" with { + type: "json", +}; +import _routerVariablesCases from "./json/router/variables-cases.json" with { + type: "json", +}; +import _hardTestSuites from "./json/template/hard.json" with { + type: "json", +}; +import _matchTestSuites from "./json/template/match.json" with { type: "json" }; +import _wrongTestSuites from "./json/template/wrong.json" with { type: "json" }; +import type { + RouterBuildCase, + RouterBuildTestSuite, + RouterCloneTestSuite, + RouterCompileErrorCase, + RouterRouteDefinition, + RouterRouteTestSuite, + RouterVariablesCase, +} from "./router.ts"; +import type { + FixedTemplateTestSuite, + HardTestSuite, + MatchTestSuite, + PairTestSuite, + WrongTestSuite, +} from "./template.ts"; + +type JsonAssertion = (value: unknown) => asserts value is T; + +const validate = (validate: JsonAssertion, value: unknown): T => { + validate(value); + return value; +}; + +export const pairTestSuites: readonly PairTestSuite[] = validate( + assertPairTestSuite, + _pairTestSuites, +); +export const fixedTestSuites: readonly FixedTemplateTestSuite[] = + _fixedTestSuites; +export const wrongTestSuites: readonly WrongTestSuite[] = validate( + assertWrongTestSuite, + _wrongTestSuites, +); +export const hardTestSuites: readonly HardTestSuite[] = validate( + assertHardTestSuite, + _hardTestSuites, +); +export const matchTestSuites: readonly MatchTestSuite[] = validate( + assertMatchTestSuite, + _matchTestSuites, +); +export const routerRouteDefinitions: readonly RouterRouteDefinition[] = + validate( + assertRouterRouteDefinitions, + _routerRouteDefinitions, + ); +export const routerHitPaths: readonly Path[] = validate( + assertRouterPaths, + _routerHitPaths, +); +export const routerMissPaths: readonly Path[] = validate( + assertRouterPaths, + _routerMissPaths, +); +export const routerBuildCases: readonly RouterBuildCase[] = validate( + assertRouterBuildCases, + _routerBuildCases, +); +export const routerRouteTestSuites: readonly RouterRouteTestSuite[] = validate( + assertRouterRouteTestSuites, + _routerRouteTestSuites, +); +export const routerBuildTestSuites: readonly RouterBuildTestSuite[] = validate( + assertRouterBuildTestSuites, + _routerBuildTestSuites, +); +export const routerVariablesCases: readonly RouterVariablesCase[] = validate( + assertRouterVariablesCases, + _routerVariablesCases, +); +export const routerCompileErrorCases: readonly RouterCompileErrorCase[] = + validate(assertRouterCompileErrorCases, _routerCompileErrorCases); +export const routerCloneTestSuites: readonly RouterCloneTestSuite[] = validate( + assertRouterCloneTestSuites, + _routerCloneTestSuites, +); + +export { + createDeepPrefixRouterTest, + createDynamicRoutesTest, + createInactiveEntriesTest, + createRouterAddTest, + createRouterBuildPathsBench, + createRouterBuildTest, + createRouterCloneTest, + createRouterCompileAndAddBench, + createRouterCompileErrorTest, + createRouterFirstRouteAfterBuildBench, + createRouterRouteHitsBench, + createRouterRouteMissesBench, + createRouterRouteTest, + createRouterVariablesTest, + createRoutesPressureTest, +} from "./router.ts"; +export { + createFixedTemplateMatchTest, + createFixedTemplateTest, + createMatchBench, + createMatchBenchTestCases, + createMatchOnlyTest, + createTemplateHardTest, + createTemplateMatchHardTest, + createTemplateMatchTest, + createTemplatePairTest, + createWrongTemplateTest, +} from "./template.ts"; diff --git a/packages/uri-template/src/tests/router.ts b/packages/uri-template/src/tests/router.ts new file mode 100644 index 000000000..35e678098 --- /dev/null +++ b/packages/uri-template/src/tests/router.ts @@ -0,0 +1,491 @@ +import { deepEqual, equal, throws } from "node:assert/strict"; +import * as ERROR_CLASSES from "../router/errors.ts"; +import type { Path } from "../types.ts"; + +type ErrorName = keyof typeof ERROR_CLASSES; + +interface RouterTestOptions { + readonly trailingSlashInsensitive?: boolean; +} + +interface RouterRouteResult { + readonly name: string; + readonly template: Path; + readonly values: Record; +} + +interface RouterInstance { + add(pattern: Path, name: string): void; + build(name: string, values: Record): Path | null; + has(name: string): boolean; + route(path: Path): RouterRouteResult | null; + clone(): RouterInstance; +} + +interface RouterConstructor { + new (options?: RouterTestOptions): RouterInstance; +} + +interface RouterExtendedConstructor + extends RouterConstructor { + compile(path: Path): TPattern; + variables(path: Path): Set; +} + +export type RouterRouteDefinition = readonly [path: Path, name: string]; + +export type RouterBuildCase = readonly [ + routeName: string, + values: Record, +]; + +export interface RouterRouteCase { + readonly name: string; + readonly path: Path; + readonly expected: RouterRouteResult | null; +} + +export interface RouterRouteTestSuite { + readonly name: string; + readonly options?: RouterTestOptions; + readonly routeDefinitions: readonly RouterRouteDefinition[]; + readonly cases: readonly RouterRouteCase[]; +} + +export interface RouterBuildTestCase { + readonly name: string; + readonly routeName: string; + readonly values: Record; + readonly expected: string | null; +} + +export interface RouterBuildTestSuite { + readonly name: string; + readonly options?: RouterTestOptions; + readonly routeDefinitions: readonly RouterRouteDefinition[]; + readonly cases: readonly RouterBuildTestCase[]; +} + +export interface RouterVariablesCase { + readonly name: string; + readonly path: Path; + readonly expected: readonly string[]; +} + +export interface RouterCompileErrorCase { + readonly name: string; + readonly path: string; + readonly expected: readonly ErrorName[]; +} + +export interface RouterCloneTestSuite { + readonly name: string; + readonly options?: RouterTestOptions; + readonly routeDefinitions: readonly RouterRouteDefinition[]; + readonly clonedRouteDefinitions: readonly RouterRouteDefinition[]; + readonly originalRouteCases: readonly RouterRouteCase[]; + readonly clonedRouteCases: readonly RouterRouteCase[]; +} + +interface RouterMemoryPressureSuite { + readonly name: string; + readonly routeDefinitions: readonly RouterRouteDefinition[]; + readonly hitPaths: readonly Path[]; + readonly missPaths: readonly Path[]; + readonly routeName: string; +} + +let routerBenchSink = 0; + +const consumeRouterBenchValue = (value: number): void => { + routerBenchSink = (routerBenchSink + value) | 0; +}; + +const consumeRouterRoute = (result: RouterRouteResult | null): void => { + consumeRouterBenchValue( + (result?.name.length ?? 0) + (result?.template.length ?? 0), + ); +}; + +const consumeRouterPath = (path: Path | null): void => { + consumeRouterBenchValue(path?.length ?? 0); +}; + +const createRouterDefinitions = ( + count: number, + createDefinition: (index: number) => RouterRouteDefinition, +): readonly RouterRouteDefinition[] => + Array.from({ length: count }, (_, index) => createDefinition(index)); + +const createSampledIndexes = (count: number, sampleCount: number): number[] => + Array.from( + { length: sampleCount }, + (_, index) => Math.floor(index * count / sampleCount), + ); + +const padRouterIndex = (index: number): string => + index.toString().padStart(4, "0"); + +export function createRoutesPressureTest(): RouterMemoryPressureSuite { + const routeDefinitions = createRouterDefinitions( + 512, + (index): RouterRouteDefinition => [ + `/bulk/group-${index % 16}/items/${padRouterIndex(index)}/{id}`, + `bulk${padRouterIndex(index)}`, + ], + ); + const sampledIndexes = createSampledIndexes(512, 16); + + return { + name: "hundreds of routes", + routeDefinitions, + hitPaths: sampledIndexes.map((index) => + `/bulk/group-${index % 16}/items/${padRouterIndex(index)}/value` as Path + ), + missPaths: sampledIndexes.map((index) => + `/bulk/group-${index % 16}/missing/${padRouterIndex(index)}/value` as Path + ), + routeName: `bulk${padRouterIndex(511)}`, + }; +} + +export function createDeepPrefixRouterTest(): RouterMemoryPressureSuite { + const segments = Array.from( + { length: 128 }, + (_, index) => `segment-${padRouterIndex(index)}`, + ); + const routeDefinitions = segments.map(( + _, + index, + ): RouterRouteDefinition => [ + `/deep/${segments.slice(0, index + 1).join("/")}` as Path, + `deep${padRouterIndex(index)}`, + ]); + const deepestPath = `/deep/${segments.join("/")}` as Path; + + return { + name: "deep common prefix", + routeDefinitions, + hitPaths: [deepestPath], + missPaths: [`${deepestPath}/missing` as Path], + routeName: `deep${padRouterIndex(127)}`, + }; +} + +export function createDynamicRoutesTest(): RouterMemoryPressureSuite { + const routeDefinitions = createRouterDefinitions( + 384, + (index): RouterRouteDefinition => [ + `/{tenant}/resource-${padRouterIndex(index)}/{id}`, + `rootDynamic${padRouterIndex(index)}`, + ], + ); + const sampledIndexes = createSampledIndexes(384, 16); + + return { + name: "root-adjacent dynamic routes", + routeDefinitions, + hitPaths: sampledIndexes.map((index) => + `/alice/resource-${padRouterIndex(index)}/value` as Path + ), + missPaths: sampledIndexes.map((index) => + `/alice/missing-${padRouterIndex(index)}/value` as Path + ), + routeName: `rootDynamic${padRouterIndex(383)}`, + }; +} + +export function createInactiveEntriesTest(): RouterMemoryPressureSuite { + return { + name: "inactive entries", + routeDefinitions: createRouterDefinitions( + 512, + (index): RouterRouteDefinition => [ + `/{tenant}/inactive-${padRouterIndex(index)}/{id}`, + "replaced", + ], + ), + hitPaths: [`/alice/inactive-${padRouterIndex(511)}/value`], + missPaths: ["/alice/inactive-missing/value"], + routeName: "replaced", + }; +} + +const createRouterFromDefinitions = ( + Router: RouterExtendedConstructor, + definitions: readonly RouterRouteDefinition[], + options: RouterTestOptions = {}, +): RouterInstance => { + const router = new Router(options); + + for (const [path, name] of definitions) { + router.add(path, name); + } + + return router; +}; + +export function createRouterAddTest( + Router: RouterExtendedConstructor, +): ( + definitions: readonly RouterRouteDefinition[], +) => (t: Deno.TestContext) => Promise { + return ( + definitions: readonly RouterRouteDefinition[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + const router = new Router(); + + for (const [path, name] of definitions) { + await t.step(`${path} as ${name}`, () => { + equal(router.add(path, name), undefined); + equal(router.has(name), true); + }); + } + }; +} + +export function createRouterCompileErrorTest( + Router: RouterExtendedConstructor, +): ( + cases: readonly RouterCompileErrorCase[], +) => (t: Deno.TestContext) => Promise { + return ( + cases: readonly RouterCompileErrorCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, path, expected } of cases) { + await t.step(name, () => { + for (const errorName of expected) { + throws( + () => Router.compile(path as Path), + ERROR_CLASSES[errorName], + ); + } + }); + } + }; +} + +export function createRouterVariablesTest( + Router: RouterExtendedConstructor, +): ( + cases: readonly RouterVariablesCase[], +) => (t: Deno.TestContext) => Promise { + return ( + cases: readonly RouterVariablesCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, path, expected } of cases) { + await t.step( + name, + () => deepEqual(Router.variables(path), new Set(expected)), + ); + } + }; +} + +export function createRouterRouteTest( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + options?: RouterTestOptions, +) => ( + cases: readonly RouterRouteCase[], +) => (t: Deno.TestContext) => Promise { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + options: RouterTestOptions = {}, + ): ( + cases: readonly RouterRouteCase[], + ) => (t: Deno.TestContext) => Promise => { + const router = createRouterFromDefinitions( + Router, + routeDefinitions, + options, + ); + + return ( + cases: readonly RouterRouteCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, path, expected } of cases) { + await t.step( + name, + () => deepEqual(router.route(path), expected), + ); + } + }; + }; +} + +export function createRouterBuildTest( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + options?: RouterTestOptions, +) => ( + cases: readonly RouterBuildTestCase[], +) => (t: Deno.TestContext) => Promise { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + options: RouterTestOptions = {}, + ): ( + cases: readonly RouterBuildTestCase[], + ) => (t: Deno.TestContext) => Promise => { + const router = createRouterFromDefinitions( + Router, + routeDefinitions, + options, + ); + + return ( + cases: readonly RouterBuildTestCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, routeName, values, expected } of cases) { + await t.step( + name, + () => equal(router.build(routeName, values), expected), + ); + } + }; + }; +} + +export function createRouterCloneTest( + Router: RouterConstructor, +): ( + suites: readonly RouterCloneTestSuite[], +) => (t: Deno.TestContext) => Promise { + return ( + suites: readonly RouterCloneTestSuite[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const suite of suites) { + await t.step(suite.name, () => { + const original = new Router(suite.options); + for (const [path, name] of suite.routeDefinitions) { + original.add(path, name); + } + const clone = original.clone(); + + for (const [path, name] of suite.clonedRouteDefinitions) { + clone.add(path, name); + } + for (const [, name] of suite.routeDefinitions) { + equal(original.has(name), true); + equal(clone.has(name), true); + } + for (const [, name] of suite.clonedRouteDefinitions) { + equal(original.has(name), false); + equal(clone.has(name), true); + } + for (const { path, expected } of suite.originalRouteCases) { + deepEqual(original.route(path), expected); + } + for (const { path, expected } of suite.clonedRouteCases) { + deepEqual(clone.route(path), expected); + } + }); + } + }; +} + +export function createRouterCompileAndAddBench( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + routeName: string, +) => () => void { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + routeName: string, + ): () => void => { + const createRouter = (): RouterInstance => + createRouterFromDefinitions(Router, routeDefinitions); + + return (): void => { + for (let count = 0; count < 100; count++) { + const router = createRouter(); + consumeRouterBenchValue(router.has(routeName) ? 1 : 0); + } + }; + }; +} + +export function createRouterFirstRouteAfterBuildBench( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + path: Path, +) => () => void { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + path: Path, + ): () => void => + (): void => { + const router = createRouterFromDefinitions(Router, routeDefinitions); + consumeRouterRoute(router.route(path)); + }; +} + +export function createRouterRouteHitsBench( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + paths: readonly Path[], +) => () => void { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + paths: readonly Path[], + ): () => void => { + const router = createRouterFromDefinitions(Router, routeDefinitions); + + return (): void => { + for (const path of paths) { + consumeRouterRoute(router.route(path)); + } + }; + }; +} + +export function createRouterRouteMissesBench( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + paths: readonly Path[], +) => () => void { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + paths: readonly Path[], + ): () => void => { + const router = createRouterFromDefinitions(Router, routeDefinitions); + + return (): void => { + for (const path of paths) { + consumeRouterRoute(router.route(path)); + } + }; + }; +} + +export function createRouterBuildPathsBench( + Router: RouterExtendedConstructor, +): ( + routeDefinitions: readonly RouterRouteDefinition[], + cases: readonly RouterBuildCase[], +) => () => void { + return ( + routeDefinitions: readonly RouterRouteDefinition[], + cases: readonly RouterBuildCase[], + ): () => void => { + const router = createRouterFromDefinitions(Router, routeDefinitions); + + return (): void => { + for (const [routeName, values] of cases) { + consumeRouterPath(router.build(routeName, values)); + } + }; + }; +} diff --git a/packages/uri-template/src/tests/template.ts b/packages/uri-template/src/tests/template.ts new file mode 100644 index 000000000..b1e0df4b9 --- /dev/null +++ b/packages/uri-template/src/tests/template.ts @@ -0,0 +1,372 @@ +import { deepEqual, equal, ok, throws } from "node:assert/strict"; +import * as ERROR_CLASSES from "../template/errors.ts"; +import type { ExpandContext } from "../types.ts"; +import testVars from "./json/references/vars.json" with { type: "json" }; + +interface TemplateConstructor { + new (template: string): { + expand(context: ExpandContext): string; + match(uri: string): ExpandContext | null; + }; +} + +type PairTestCase = readonly [template: string, expanded: string]; + +export interface PairTestSuite { + name: string; + cases: readonly PairTestCase[]; +} + +export function createTemplatePairTest( + Template: TemplateConstructor, +): ( + suites: readonly PairTestSuite[], + context?: ExpandContext, +) => (t: Deno.TestContext) => Promise { + return ( + suites: readonly PairTestSuite[], + context: ExpandContext = testVars, + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, cases } of suites) { + await t.step(name, async (t) => { + for (const [template, expected] of cases) { + await t.step( + `${template} => ${expected}`, + () => equal(new Template(template).expand(context), expected), + ); + } + }); + } + }; +} + +export function createTemplateMatchTest( + Template: TemplateConstructor, +): ( + suites: readonly PairTestSuite[], +) => (t: Deno.TestContext) => Promise { + return ( + suites: readonly PairTestSuite[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, cases } of suites) { + await t.step(name, async (t) => { + for (const [template, expanded] of cases) { + await t.step( + `${expanded} => ${template}`, + () => { + const instance = new Template(template); + const matched = instance.match(expanded); + ok(matched != null, `match returned null for ${expanded}`); + equal(instance.expand(matched), expanded); + }, + ); + } + }); + } + }; +} + +export interface MatchTestSuite { + name: string; + cases: readonly MatchTestCase[]; +} + +interface MatchTestCase { + name: string; + template: string; + uri: string; + expected: ExpandContext | null; + reason?: string; +} + +export function createMatchOnlyTest( + Template: TemplateConstructor, +): ( + suites: readonly MatchTestSuite[], +) => (t: Deno.TestContext) => Promise { + return ( + suites: readonly MatchTestSuite[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, cases } of suites) { + await t.step(name, async (t) => { + for (const c of cases) { + await t.step(c.name, () => { + const got = new Template(c.template).match(c.uri); + deepEqual(got, c.expected); + }); + } + }); + } + }; +} + +export interface FixedTemplateTestSuite { + name: string; + template: string; + cases: readonly FixedTemplateTestCase[]; +} + +interface FixedTemplateTestCase { + name: string; + context: ExpandContext; + expected: string; +} + +export const createFixedTemplateTest: ( + Template: TemplateConstructor, +) => ( + suites: readonly FixedTemplateTestSuite[], +) => (t: Deno.TestContext) => Promise = ( + Template: TemplateConstructor, +) => { + return ( + suites: readonly FixedTemplateTestSuite[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { template, name, cases } of suites) { + await t.step(name, async (t) => { + const instance = new Template(template); + for (const { name, context, expected } of cases) { + await t.step(name, () => equal(instance.expand(context), expected)); + } + }); + } + }; +}; + +export const createFixedTemplateMatchTest: ( + Template: TemplateConstructor, +) => ( + suites: readonly FixedTemplateTestSuite[], +) => (t: Deno.TestContext) => Promise = ( + Template: TemplateConstructor, +) => { + return ( + suites: readonly FixedTemplateTestSuite[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { template, name, cases } of suites) { + await t.step(name, async (t) => { + const instance = new Template(template); + for (const { name, expected } of cases) { + await t.step(name, () => { + const matched = instance.match(expected); + ok(matched != null, `match returned null for ${expected}`); + equal(instance.expand(matched), expected); + }); + } + }); + } + }; +}; + +type ErrorName = keyof typeof ERROR_CLASSES; + +/** + * A single negative test case asserting that a given URI template MUST cause + * the parser to throw a specific error class. + * + * Unlike {@link PairTestSuite} or {@link FixedTemplateTestSuite}, which assert + * successful expansion, a {@link WrongTemplateTestCase} pins down the exact + * error class that the parser is expected to raise. Pinning the class — rather + * than merely asserting "something throws" — catches regressions where the + * parser still rejects the input but with a less precise diagnostic. + */ +interface WrongTemplateTestCase { + /** + * Human-readable label of the case, used as the test step name. + * Should explain *why* the template is invalid (e.g. + * "unclosed opening brace"), not what the error is. + */ + name: string; + /** + * The URI template string that the parser MUST reject. + */ + template: string; + /** + * The `name` of the error class that the parser MUST throw, taken from + * the concrete classes exported by *src/template/errors.ts* (e.g. + * `"UnclosedExpressionError"`, `"InvalidLiteralError"`). + * + * The runner compares the thrown error's `instanceof` against the class + * resolved from this name; subclasses count as a match. + */ + expected: ErrorName; +} + +export interface WrongTestSuite { + name: string; + cases: readonly WrongTemplateTestCase[]; +} + +export function createWrongTemplateTest( + Template: TemplateConstructor, +): ( + suites: readonly WrongTestSuite[], +) => (t: Deno.TestContext) => Promise { + return ( + suites: readonly WrongTestSuite[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, cases } of suites) { + await t.step(name, async (t) => { + for (const { name, template, expected } of cases) { + await t.step( + `${template} — ${name}`, + () => throws(() => new Template(template), ERROR_CLASSES[expected]), + ); + } + }); + } + }; +} + +/** + * A single hard-mode test case. Each case carries its own template (unlike + * {@link FixedTemplateTestSuite} where the suite shares one template). + * + * The shape of {@link HardTestCase.expected} is determined by + * {@link HardTestCase.success}: + * + * - `success: true` → `expected` is the exact expansion result string. + * - `success: false` → `expected` is the `name` of the error class that the + * parser or expander MUST throw, taken from the concrete classes exported + * by *src/template/errors.ts*. The runner verifies the thrown error via + * `instanceof` against the resolved class. + * + * Use {@link HardTestCase.reason} for the human-readable rationale. + */ +type HardTestCase = HardSuccessCase | HardFailureCase; + +interface HardCaseBase { + /** Human-readable name of the case, used as the test step label. */ + name: string; + /** URI template string to parse and expand. */ + template: string; + /** Optional rationale explaining why this case behaves as specified. */ + reason?: string; +} + +interface HardSuccessCase extends HardCaseBase { + /** The exact string that the template MUST expand to. */ + expected: string; + /** Marks this as a success case. */ + success: true; +} + +interface HardFailureCase extends HardCaseBase { + /** + * The `name` of the error class that the parser or expander MUST throw, + * taken from the concrete classes exported by *src/template/errors.ts* (e.g. + * `"UnclosedExpressionError"`, `"PrefixModifierNotApplicableError"`). + * + * The runner compares the thrown error's `instanceof` against the class + * resolved from this name; subclasses count as a match. + */ + expected: ErrorName; + /** Marks this as a failure case. */ + success: false; +} + +/** + * A suite of {@link HardTestCase}s grouped under a common theme. + * + * Unlike {@link PairTestSuite} (uniform success cases) or + * {@link FixedTemplateTestSuite} (one fixed template, varying contexts), + * a hard-mode suite mixes per-case templates and may include both + * success and failure cases in the same suite. + */ +export interface HardTestSuite { + /** Human-readable name of the suite. */ + name: string; + /** Cases belonging to this suite. */ + cases: readonly HardTestCase[]; +} + +export function createTemplateHardTest( + Template: TemplateConstructor, +): ( + suites: readonly HardTestSuite[], + context?: ExpandContext, +) => (t: Deno.TestContext) => Promise { + return ( + suites: readonly HardTestSuite[], + context: ExpandContext = testVars, + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const { name, cases } of suites) { + await t.step(name, async (t) => { + for (const c of cases) { + await t.step(c.name, () => { + if (c.success) { + equal(new Template(c.template).expand(context), c.expected); + } else { + throws( + () => new Template(c.template).expand(context), + ERROR_CLASSES[c.expected], + ); + } + }); + } + }); + } + }; +} + +export function createTemplateMatchHardTest( + Template: TemplateConstructor, +): ( + cases: readonly HardTestCase[], +) => (t: Deno.TestContext) => Promise { + return ( + cases: readonly HardTestCase[], + ): (t: Deno.TestContext) => Promise => + async (t: Deno.TestContext): Promise => { + for (const c of cases) { + if (!c.success) continue; + await t.step(c.name, () => { + const instance = new Template(c.template); + const matched = instance.match(c.expected); + ok(matched != null, `match returned null for ${c.expected}`); + equal(instance.expand(matched), c.expected); + }); + } + }; +} + +export function createMatchBench( + Template: TemplateConstructor, +): (templateText: string) => (uris: readonly string[]) => void { + return (templateText: string): (uris: readonly string[]) => void => { + const template = new Template(templateText); + return (uris: readonly string[]): void => { + for (const uri of uris) template.match(uri); + }; + }; +} + +const div = (i: number, j: number) => Math.floor(i / j); + +export function createMatchBenchTestCases(): readonly string[] { + let a = "/A"; + const b = ["/A"]; + for (let i = 1; i < 26; i++) { + a += "/" + String.fromCharCode(0x41 + i); + b.push(a); + } + + for (let i = 0; i < 26; i++) { + a += "/" + String.fromCharCode(0x61 + i); + b.push(a); + } + for (let i = 0; i < 676; i++) { + a += "/" + + String.fromCharCode(0x61 + div(i, 26)) + + String.fromCharCode(0x61 + i % 26); + b.push(a); + } + return b; +} diff --git a/packages/uri-template/src/types.ts b/packages/uri-template/src/types.ts new file mode 100644 index 000000000..aa9eaf92b --- /dev/null +++ b/packages/uri-template/src/types.ts @@ -0,0 +1,92 @@ +export type { Operator, OperatorSpec } from "./const.ts"; +import type { Operator } from "./const.ts"; + +/** + * Path-shaped URI Template accepted by the router. + * + * The empty path is accepted so trailing-slash-insensitive routing can retry + * the root path (`/`) as an empty path. + */ +export type Path = "" | `/${string}` | `{/${string}}${string}`; + +/** + * Primitive value accepted by {@link Template.expand}. + */ +export type PrimitiveValue = string | number | boolean | null | undefined; + +/** + * Associative composite value accepted by + * {@link Template.expand}. + * + * Keys are expanded as URI Template associative names. Values may be primitive + * values or primitive lists. + */ +export type AssociativeValue = Record< + string, + PrimitiveValue | readonly PrimitiveValue[] +>; + +/** + * Any value shape accepted for one template variable during expansion. + */ +export type ExpandValue = + | PrimitiveValue + | readonly PrimitiveValue[] + | AssociativeValue; + +/** + * Context object accepted by {@link Template.expand}. + * Each variable resolves to a primitive, an ordered list of primitives, + * or an associative map. + */ +export type ExpandContext = Record; + +/** + * Parsed RFC 6570 variable specification inside an expression. + * + * Produced by the expression parser and consumed by the expansion module. + */ +export interface VarSpec { + /** Variable name to look up in the expansion context. */ + name: string; + /** Whether the varspec uses the Level 4 explode modifier (`*`). */ + explode: boolean; + /** Prefix length from a Level 4 prefix modifier (`:N`), if present. */ + prefix?: number; +} + +/** + * Token produced by parsing a URI Template. + * + * Literal tokens are copied directly. Expression tokens are expanded with a + * context object. + */ +export type Token = + | { kind: "literal"; text: string } + | { kind: "expression"; operator: Operator; vars: VarSpec[] }; + +/** + * Options controlling URI Template parsing and expansion diagnostics. + */ +export interface TemplateOptions { + /** + * If `true`, the first parse or expansion error will be automatically + * thrown after being reported. `true` is the default value. If `false`, + * errors will be reported to by the `report` function, but none will be + * thrown unless the `report` function itself throws. + */ + strict: boolean; + /** + * A function that will be called with any errors encountered while parsing + * or expanding. By default, errors are logged using the default logger. + * In strict mode, they are still thrown after this reporter runs. + * @param error The error that was encountered while parsing or expanding the + * template. + */ + report: Reporter; +} + +/** + * Callback used to report recoverable parse and expansion diagnostics. + */ +export type Reporter = (error: Error) => void; diff --git a/packages/uri-template/src/utils.ts b/packages/uri-template/src/utils.ts new file mode 100644 index 000000000..09c682117 --- /dev/null +++ b/packages/uri-template/src/utils.ts @@ -0,0 +1,45 @@ +import { RouterError } from "./router/errors.ts"; +import Template from "./template/template.ts"; +import type { Path } from "./types.ts"; + +export const isExpression = ( + token: T, +): token is Extract => token.kind === "expression"; + +export const isLiteral = ( + token: T, +): token is Extract => token.kind === "literal"; + +/** + * Returns whether `path` is a path-shaped URI Template accepted by the + * router. + * + * A path is either an empty string, a literal string starting with `/`, or a + * path-expansion expression (`{/var}`). + * Templates that fail to parse — and therefore could never be routed — + * return `false`. + */ +export function isPath(path: string): path is Path { + if (path === "") return true; + + try { + const template = new Template(path); + + const [first] = template.tokens; + if (first == null) return false; + if (isLiteral(first)) return first.text.startsWith("/"); + if (first.operator === "/") return true; + return false; + } catch { + return false; + } +} + +export function assertPath(path: string): asserts path is Path { + if (!isPath(path)) { + throw new RouterError( + `"${path}" is not a router path. It must be empty, start with ` + + "`/`, or start with a expression with slash(`/`) like `{/id}`.", + ); + } +} diff --git a/packages/uri-template/summary.txt b/packages/uri-template/summary.txt new file mode 100644 index 000000000..b8ce0ed8e --- /dev/null +++ b/packages/uri-template/summary.txt @@ -0,0 +1,188 @@ +This is a summary of the URI Template specification. +The full specification is https://datatracker.ietf.org/doc/html/rfc6570. +If there are any discrepancies between this summary and the full specification, +the full specification is authoritative. +Fix this summary if you find any such discrepancies. + +ALPHA = %x41-5A / %x61-7A ; A-Z / a-z +DIGIT = %x30-39 ; 0-9 +HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" + ; case-insensitive + +pct-encoded = "%" HEXDIG HEXDIG +unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" +reserved = gen-delims / sub-delims +gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" +sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + / "*" / "+" / "," / ";" / "=" + +ucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF + / %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD + / %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD + / %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD + / %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD + / %xD0000-DFFFD / %xE1000-EFFFD + +iprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD + +URI-Template = *( literals / expression ) + +literals = %x21 / %x23-24 / %x26 / %x28-3B / %x3D / %x3F-5B + / %x5D / %x5F / %x61-7A / %x7E / ucschar / iprivate + / pct-encoded + ; any Unicode character except: CTL, SP, + ; DQUOTE, "'", "%" (aside from pct-encoded), + ; "<", ">", "\", "^", "`", "{", "|", "}" + +expression = "{" [ operator ] variable-list "}" +operator = op-level2 / op-level3 / op-reserve +op-level2 = "+" / "#" +op-level3 = "." / "/" / ";" / "?" / "&" +op-reserve = "=" / "," / "!" / "@" / "|" + +modifier-level4 = prefix / explode +explode = "*" +prefix = ":" max-length +max-length = %x31-39 0*3DIGIT ; positive integer < 10000 + +The operator characters +- `+`: Reserved character strings; +- `#`: Fragment identifiers prefixed by "#"; +- `.`: Name labels or extensions prefixed by "."; +- `/`: Path segments prefixed by "/"; +- `;`: Path parameter name or name=value pairs prefixed by ";"; +- `?`: Query component beginning with "?" and consisting of name=value pairs separated by "&"; and, + & Continuation of query-style &name=value pairs within a literal query component. + +The operator characters equals ("="), comma (","), exclamation ("!"), at sign ("@"), and pipe ("|") are reserved for future extensions. + +variable-list = varspec *( "," varspec ) +varspec = varname [ modifier-level4 ] +varname = varchar *( ["."] varchar ) +varchar = ALPHA / DIGIT / "_" / pct-encoded + +The normative sections on expansion describe each operator with a +separate expansion process for the sake of descriptive clarity. In +actual implementations, we expect the expressions to be processed +left-to-right using a common algorithm that has only minor variations +in process per operator. This non-normative appendix describes one +such algorithm. + +Initialize an empty result string and its non-error state. + +Scan the template and copy literals to the result string (as in +Section 3.1) until an expression is indicated by a "{", an error is +indicated by the presence of a non-literals character other than "{", +or the template ends. When it ends, return the result string and its +current error or non-error state. + +o If an expression is found, scan the template to the next "}" and + extract the characters in between the braces. + +o If the template ends before a "}", then append the "{" and + extracted characters to the result string and return with an error + status indicating the expression is malformed. + +Examine the first character of the extracted expression for an +operator. + +o If the expression ended (i.e., is "{}"), an operator is found that + is unknown or unimplemented, or the character is not in the + varchar set (Section 2.3), then append "{", the extracted + expression, and "}" to the result string, remember that the result + is in an error state, and then go back to scan the remainder of + the template. + +o If a known and implemented operator is found, store the operator + and skip to the next character to begin the varspec-list. + +o Otherwise, store the operator as NUL (simple string expansion). + +Use the following value table to determine the processing behavior by +expression type operator. The entry for "first" is the string to +append to the result first if any of the expression's variables are +defined. The entry for "sep" is the separator to append to the +result before any second (or subsequent) defined variable expansion. +The entry for "named" is a boolean for whether or not the expansion +includes the variable or key name when no explode modifier is given. +The entry for "ifemp" is a string to append to the name if its +corresponding value is empty. The entry for "allow" indicates what +characters to allow unencoded within the value expansion: (U) means +any character not in the unreserved set will be encoded; (U+R) means +any character not in the union of (unreserved / reserved / pct- +encoding) will be encoded; and, for both cases, each disallowed +character is first encoded as its sequence of octets in UTF-8 and +then each such octet is encoded as a pct-encoded triplet. + +.------------------------------------------------------------------. +| NUL + . / ; ? & # | +|------------------------------------------------------------------| +| first | "" "" "." "/" ";" "?" "&" "#" | +| sep | "," "," "." "/" ";" "&" "&" "," | +| named | false false false false true true true false | +| ifemp | "" "" "" "" "" "=" "=" "" | +| allow | U U+R U U U U U U+R | +`------------------------------------------------------------------' + +With the above table in mind, process the variable-list as follows: + +For each varspec, extract a variable name and optional modifier from +the expression by scanning the variable-list until a character not in +the varname set is found or the end of the expression is reached. + +o If it is the end of the expression and the varname is empty, go back to scan the remainder of the template. + +o If it is not the end of the expression and the last character found indicates a modifier ("*" or ":"), remember that modifier. If it is an explode ("*"), scan the next character. If it is a prefix (":"), continue scanning the next one to four characters for the max-length represented as a decimal integer and then, if it is still not the end of the expression, scan the next character. + +o If it is not the end of the expression and the last character found is not a comma (","), append "{", the stored operator (if any), the scanned varname and modifier, the remaining expression, and "}" to the result string, remember that the result is in an error state, and then go back to scan the remainder of the template. + +Lookup the value for the scanned variable name, and then + +o If the varname is unknown or corresponds to a variable with an undefined value (Section 2.3), then skip to the next varspec. + +o If this is the first defined variable for this expression, append the first string for this expression type to the result string and remember that it has been done. Otherwise, append the sep string to the result string. + +o If this variable's value is a string, then + + * if named is true, append the varname to the result string using the same encoding process as for literals, and + + + if the value is empty, append the ifemp string to the result string and skip to the next varspec; + + + otherwise, append "=" to the result string. + + * if a prefix modifier is present and the prefix length is less than the value string length in number of Unicode characters, append that number of characters from the beginning of the value string to the result string, after pct-encoding any characters that are not in the allow set, while taking care not to split multi-octet or pct-encoded triplet characters that represent a single Unicode code point; + + * otherwise, append the value to the result string after pct- encoding any characters that are not in the allow set. + +o else if no explode modifier is given, then + + * if named is true, append the varname to the result string using the same encoding process as for literals, and + + + if the value is empty, append the ifemp string to the result string and skip to the next varspec; + + + otherwise, append "=" to the result string; and + + * if this variable's value is a list, append each defined list member to the result string, after pct-encoding any characters that are not in the allow set, with a comma (",") appended to the result between each defined list member; + + * if this variable's value is an associative array or any other form of paired (name, value) structure, append each pair with a defined value to the result string as "name,value", after pct- encoding any characters that are not in the allow set, with a comma (",") appended to the result between each defined pair. + +o else if an explode modifier is given, then + + * if named is true, then for each defined list member or array (name, value) pair with a defined value, do: + + + if this is not the first defined member/value, append the sep string to the result string; + + + if this is a list, append the varname to the result string using the same encoding process as for literals; + + + if this is a pair, append the name to the result string using the same encoding process as for literals; + + + if the member/value is empty, append the ifemp string to the result string; otherwise, append "=" and the member/value to the result string, after pct-encoding any member/value characters that are not in the allow set. + + * else if named is false, then + + + if this is a list, append each defined list member to the result string, after pct-encoding any characters that are not in the allow set, with the sep string appended to the result between each defined list member. + + + if this is an array of (name, value) pairs, append each pair with a defined value to the result string as "name=value", after pct-encoding any characters that are not in the allow set, with the sep string appended to the result between each defined pair. + +When the variable-list for this expression is exhausted, go back to +scan the remainder of the template. diff --git a/packages/uri-template/tsdown.config.ts b/packages/uri-template/tsdown.config.ts new file mode 100644 index 000000000..bf33f512d --- /dev/null +++ b/packages/uri-template/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/mod.ts"], + dts: true, + format: ["esm", "cjs"], + platform: "node", + outExtensions({ format }) { + return { + js: format === "cjs" ? ".cjs" : ".js", + dts: format === "cjs" ? ".d.cts" : ".d.ts", + }; + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e959474ee..78a5bde8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1146,6 +1146,9 @@ importers: packages/fedify: dependencies: + '@fedify/uri-template': + specifier: workspace:* + version: link:../uri-template '@fedify/vocab': specifier: workspace:* version: link:../vocab @@ -1188,12 +1191,6 @@ importers: structured-field-values: specifier: ^2.0.4 version: 2.0.4 - uri-template-router: - specifier: ^1.0.0 - version: 1.0.0 - url-template: - specifier: ^3.1.1 - version: 3.1.1 urlpattern-polyfill: specifier: 'catalog:' version: 10.1.0 @@ -1675,6 +1672,21 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/uri-template: + devDependencies: + '@fedify/fixture': + specifier: workspace:^ + version: link:../fixture + '@types/node': + specifier: 'catalog:' + version: 22.19.1 + tsdown: + specifier: 'catalog:' + version: 0.21.6(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/vocab: dependencies: '@fedify/vocab-runtime': @@ -12458,16 +12470,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - uri-template-router@1.0.0: - resolution: {integrity: sha512-WKcL9ZSIEhHE3f5P4Z47Tf0nWbcgV1ISb/OBuF8YKEYi0SQOyTLCzM6B/gAKFWZhRhqA+C/Ks8UXe2qU5W0FVg==} - url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - url-template@3.1.1: - resolution: {integrity: sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} @@ -20099,7 +20104,7 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 eslint: 9.32.0(jiti@2.6.1) - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.7 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.16 @@ -20114,7 +20119,7 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 eslint: 8.57.1 - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.7 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.16 @@ -20129,7 +20134,7 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 eslint: 9.32.0(jiti@2.6.1) - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.7 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.16 @@ -25557,15 +25562,11 @@ snapshots: dependencies: punycode: 2.3.1 - uri-template-router@1.0.0: {} - url-parse@1.5.10: dependencies: querystringify: 2.2.0 requires-port: 1.0.0 - url-template@3.1.1: {} - urlpattern-polyfill@10.1.0: {} utif2@4.1.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b7f5dcda4..fb87a9434 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,6 +26,7 @@ packages: - packages/sqlite - packages/sveltekit - packages/testing +- packages/uri-template - packages/vocab - packages/vocab-runtime - packages/vocab-tools diff --git a/scripts/check_fixture_usage.ts b/scripts/check_fixture_usage.ts index 75496e5eb..7ca5e28ff 100644 --- a/scripts/check_fixture_usage.ts +++ b/scripts/check_fixture_usage.ts @@ -85,7 +85,7 @@ for await ( }) ) { const rel = relative(projectRoot, entry.path); - if (rel.endsWith(".test.ts")) continue; + if (rel.endsWith(".test.ts") || rel.endsWith(".bench.ts")) continue; if (allowed.has(rel)) continue; const content = await Deno.readTextFile(entry.path);