From ae89297ede035f75ce47936e181c119cfadfd526 Mon Sep 17 00:00:00 2001 From: Hugh Grigg Date: Wed, 2 Sep 2026 12:25:29 +0100 Subject: [PATCH 1/2] feat: simplify public API configuration --- docs/custom-operations/README.md | 9 +- docs/rest-resources/README.md | 75 ++++++++- docs/webhooks/README.md | 11 +- src/api.test.ts | 165 +++++++++++++++--- src/api.ts | 22 ++- src/http/operation-router.ts | 6 +- src/http/path-parameter.ts | 13 ++ src/http/resource-path.ts | 11 +- src/http/rest-resource-operation-defaults.ts | 40 +++++ src/http/rest-resource-operations.ts | 167 ++++++------------- src/http/supplied-rest-resource-operation.ts | 65 ++++++++ src/index.ts | 33 ++-- src/resource.test.ts | 2 +- src/resource.ts | 2 +- src/rest-resource-operation.ts | 22 ++- src/rest-resource.ts | 10 ++ src/unimplemented-route-error.ts | 6 +- src/webhooks.test.ts | 11 +- src/webhooks.ts | 30 ++-- test/github-rest-api-issue-collection.ts | 9 +- test/github-rest-api-issue-identity.ts | 13 -- test/github-rest-api-issue-item.ts | 19 +-- test/github-rest-api-issues.ts | 26 ++- 23 files changed, 524 insertions(+), 243 deletions(-) create mode 100644 src/http/path-parameter.ts create mode 100644 src/http/rest-resource-operation-defaults.ts create mode 100644 src/http/supplied-rest-resource-operation.ts diff --git a/docs/custom-operations/README.md b/docs/custom-operations/README.md index 3311d6e..d4cc811 100644 --- a/docs/custom-operations/README.md +++ b/docs/custom-operations/README.md @@ -36,16 +36,13 @@ Use `resource.operation()` for an action that belongs to a resource, such as archiving a widget: ```ts +import { requirePathParameter } from "@kensio/simnaril"; + const archive = widgets.operation<{ reason: string }, Widget>("archive", { method: "POST", path: "/:id/archive", handle({ input, params, resource }) { - const id = params["id"]; - - if (id === undefined) { - throw new TypeError("The archive route requires an id."); - } - + const id = requirePathParameter(params, "id"); console.log(input.reason); return resource.update(id, { status: "archived" }); }, diff --git a/docs/rest-resources/README.md b/docs/rest-resources/README.md index da543a7..9109d6e 100644 --- a/docs/rest-resources/README.md +++ b/docs/rest-resources/README.md @@ -21,8 +21,8 @@ const widgets = api.resource({ }); ``` -The resource path is an absolute collection path. It cannot contain path -parameters, a query string, or a fragment. +The resource path is an absolute collection path. It may contain named path +parameters. It cannot contain a query string or fragment. The supplied routes are: @@ -59,6 +59,64 @@ widgets.clear(); The same state backs direct method calls and HTTP requests. A change through one interface is visible through the other. +## Nest a resource under another route + +A collection path can name its parent resources. `itemPath` is appended for +the three item operations. + +```ts +import { requirePathParameter } from "@kensio/simnaril"; + +interface Issue { + number: number; + owner: string; + repository: string; + title: string; +} + +const issues = api.resource({ + path: "/repos/:owner/:repository/issues", + itemPath: "/:number", + identify: (issue) => `${issue.owner}/${issue.repository}#${issue.number}`, + locate: (params) => + `${requirePathParameter(params, "owner")}/${requirePathParameter( + params, + "repository", + )}#${requirePathParameter(params, "number")}`, +}); +``` + +This resource supplies collection routes under each repository and item routes +ending in `/:number`. `identify` turns an entity into its state identity. +`locate` turns the matched route parameters back into the same identity. + +`locate` applies to get, update, and delete. Override list or create when the +parent parameters change collection behaviour. Their handlers receive the same +`params` object. + +The default locator reads `params.id`. Define `locate` when an item route uses +another parameter or needs more than one value. `requirePathParameter` returns +a parameter or throws a clear error when the route did not supply it. + +## Leave unsupported operations out + +Set a supplied operation to `false` when the simulated service has no such +route: + +```ts +const widgets = api.resource({ + path: "/widgets", + operations: { + create: false, + update: false, + delete: false, + }, +}); +``` + +The example keeps the list and get routes. Requests to the three omitted routes +throw `UnimplementedRouteError`. + ## Configure creation Pass state options to `api.resource()` along with the HTTP path: @@ -101,6 +159,15 @@ unfinished simulation visible during development. Node's `fetch()` wraps an `UnimplementedRouteError` in a `TypeError`. The original error is available as `error.cause`. +Give the API a name when a simulation contains several services: + +```ts +const api = new SimApi({ name: "GitHub" }); +``` + +An unknown route then reports that it reached `GitHub`. The default name is +`SimApi`. + ## Shape the errors one API answers with A real service has one error envelope across every endpoint. Stripe's is @@ -155,7 +222,9 @@ same. The supplied operation names are `list`, `create`, `get`, `update`, and `delete`. Configured paths for `get`, `update`, and `delete` must include an -`:id` parameter because their default handlers use it as the state identity. +`:id` parameter when the resource uses the default locator. A configured +`locate` function can use any parameters present in the collection and item +paths. Read [Custom operations](../custom-operations/README.md) when a route needs different behavior. diff --git a/docs/webhooks/README.md b/docs/webhooks/README.md index e808ccd..b0fe676 100644 --- a/docs/webhooks/README.md +++ b/docs/webhooks/README.md @@ -89,7 +89,16 @@ console.log(result?.response?.status); console.log(result?.error); ``` -`response` and `error` mean different things, and only one of them is ever set. +Check `delivered` to distinguish the two result shapes. A delivered result has +`response`. A failed result has `error`. + +```ts +if (result?.delivered === true) { + console.log(result.response.status); +} else { + console.error(result?.error); +} +``` | The delivery | Result | | ----------------------------------------- | ---------------------------------------- | diff --git a/src/api.test.ts b/src/api.test.ts index 93a3305..871967c 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -12,6 +12,7 @@ import { import { describe, expectTypeOf, it } from "vitest"; import { + requirePathParameter, SimApi, SimEnvironment, SimResource, @@ -26,24 +27,7 @@ describe("a simulated API", () => { status: "active" | "archived"; } - const pathParameter = ( - params: Readonly>, - name: string, - ): string => { - const value = params[name]; - - if (value === undefined) { - throw new Error(`Missing test path parameter ":${name}".`); - } - - return value; - }; - - const request = ( - method: string, - path: string, - body?: Partial, - ): Request => { + const request = (method: string, path: string, body?: object): Request => { const url = `https://api.example.test${path}`; if (body === undefined) { @@ -235,6 +219,22 @@ describe("a simulated API", () => { assertStringIncludes(error.message, "already exposed at collection path"); }); + it("rejects equivalent parameterised collection paths", () => { + // Given an API with a collection below one named route parameter. + const api = new SimApi(); + api.resource({ path: "/accounts/:account/widgets" }); + + // When another resource changes only the parameter's name. + const error = assertThrowsError(() => { + api.resource({ path: "/accounts/:owner/widgets" }); + }); + + // Then the API rejects the route shape before requests become ambiguous. + assertInstanceOf(error, TypeError); + assertStringIncludes(error.message, "already exposed at collection path"); + assertStringIncludes(error.message, "/accounts/:owner/widgets"); + }); + it("maps missing and duplicate entities to simulated responses", async () => { // Given an API resource containing one entity. const api = new SimApi(); @@ -312,6 +312,93 @@ describe("a simulated API", () => { assertInstanceOf(oldRoute, UnimplementedRouteError); }); + it("locates item state from a parameterised collection path", async () => { + // Given issues stored under repository-scoped identities. + interface Issue { + number: number; + owner: string; + repository: string; + title: string; + } + const api = new SimApi(); + const issues = api.resource({ + identify: (issue) => `${issue.owner}/${issue.repository}#${issue.number}`, + itemPath: "/:number", + locate: (params) => + `${requirePathParameter(params, "owner")}/${requirePathParameter(params, "repository")}#${requirePathParameter(params, "number")}`, + path: "/repos/:owner/:repository/issues", + }); + const issue: Issue = { + number: faker.number.int({ min: 1 }), + owner: faker.internet.username(), + repository: faker.word.noun(), + title: faker.lorem.sentence(), + }; + issues.seed(issue); + const itemPath = `/repos/${issue.owner}/${issue.repository}/issues/${issue.number}`; + + // When the item is read and changed through its nested HTTP route. + const read = await api.handle(request("GET", itemPath)); + const changedTitle = faker.lorem.sentence(); + const updated = await api.handle( + request("PATCH", itemPath, { title: changedTitle }), + ); + + // Then both operations use the resource's route-to-state locator. + assertResponseStatus(read, 200); + assertObjectEquals(await read.json(), issue); + assertResponseStatus(updated, 200); + assertObjectEquals(await updated.json(), { ...issue, title: changedTitle }); + assertIdentical( + issues.locate({ + number: String(issue.number), + owner: issue.owner, + repository: issue.repository, + }), + `${issue.owner}/${issue.repository}#${issue.number}`, + ); + }); + + it("leaves disabled supplied operations unimplemented", async () => { + // Given a read-only resource containing one arranged entity. + const api = new SimApi(); + const widgets = api.resource({ + operations: { + create: false, + delete: false, + update: false, + }, + path: "/widgets", + }); + const widget: Widget = { + id: faker.string.uuid(), + name: faker.commerce.productName(), + status: "active", + }; + widgets.seed(widget); + + // When the resource is read and a caller tries to change it. + const read = await api.handle(request("GET", `/widgets/${widget.id}`)); + const createError = await assertThrowsErrorAsync(() => + api.handle(request("POST", "/widgets", widget)), + ); + const updateError = await assertThrowsErrorAsync(() => + api.handle( + request("PATCH", `/widgets/${widget.id}`, { status: "archived" }), + ), + ); + const deleteError = await assertThrowsErrorAsync(() => + api.handle(request("DELETE", `/widgets/${widget.id}`)), + ); + + // Then reads work and each disabled route fails as unimplemented. + assertResponseStatus(read, 200); + assertObjectEquals(await read.json(), widget); + assertInstanceOf(createError, UnimplementedRouteError); + assertInstanceOf(updateError, UnimplementedRouteError); + assertInstanceOf(deleteError, UnimplementedRouteError); + }); + it("keeps the HTTP pipeline around a semantic operation override", async () => { // Given a create override which uses decoded input and HTTP context. const api = new SimApi(); @@ -382,7 +469,7 @@ describe("a simulated API", () => { path: "/:id/archive", handle({ params, query, resource }) { const status = query.get("confirm") === "yes" ? "archived" : "active"; - return resource.update(pathParameter(params, "id"), { status }); + return resource.update(requirePathParameter(params, "id"), { status }); }, }); const reportOperation = api.operation( @@ -463,7 +550,7 @@ describe("a simulated API", () => { path: "/:id/archive", handle({ params, resource }) { events.push("operation"); - return resource.update(pathParameter(params, "id"), { + return resource.update(requirePathParameter(params, "id"), { status: "archived", }); }, @@ -529,7 +616,9 @@ describe("a simulated API", () => { path: "/:id/archive", handle({ input, params, resource }) { receivedReason = input.reason; - resource.update(pathParameter(params, "id"), { status: "archived" }); + resource.update(requirePathParameter(params, "id"), { + status: "archived", + }); }, }); const reason = faker.lorem.sentence(); @@ -582,6 +671,21 @@ describe("a simulated API", () => { const invalidMethod = assertThrowsError(() => { api.operation("GET REPORT", "/reports", () => new Response()); }); + const relativeItemPath = assertThrowsError(() => { + new SimApi().resource({ + itemPath: ":id", + path: "/widgets", + }); + }); + const relativeSuppliedPath = assertThrowsError(() => { + new SimApi().resource({ + operations: { get: { path: ":id" } }, + path: "/widgets", + }); + }); + const missingPathParameter = assertThrowsError(() => + requirePathParameter({}, "id"), + ); const missingRequiredParameters = ( ["get", "update", "delete"] as const ).map((operationName) => @@ -603,6 +707,9 @@ describe("a simulated API", () => { duplicateParameter, invalidPath, invalidMethod, + relativeItemPath, + relativeSuppliedPath, + missingPathParameter, ...missingRequiredParameters, ]) { assertInstanceOf(error, TypeError); @@ -616,6 +723,12 @@ describe("a simulated API", () => { assertStringIncludes(duplicateParameter.message, "appears more than once"); assertStringIncludes(invalidPath.message, "absolute operation path"); assertStringIncludes(invalidMethod.message, "Expected an HTTP method"); + assertStringIncludes(relativeItemPath.message, "resource-relative"); + assertStringIncludes(relativeSuppliedPath.message, "resource-relative"); + assertIdentical( + missingPathParameter.message, + 'Matched operation has no ":id" path parameter.', + ); for (const error of missingRequiredParameters) { assertStringIncludes(error.message, 'must include path parameter ":id"'); } @@ -653,8 +766,9 @@ describe("a simulated API", () => { }); it("fails loudly for an unimplemented route", async () => { - // Given an API that implements only conventional widget routes. - const api = new SimApi(); + // Given a named API that implements only conventional widget routes. + const apiName = faker.company.name(); + const api = new SimApi({ name: apiName }); api.resource({ path: "/widgets" }); const url = `https://api.example.test/widgets/${faker.string.uuid()}/archive?force=true`; const unknownUrl = "https://api.example.test/status"; @@ -670,17 +784,18 @@ describe("a simulated API", () => { // Then both throw route errors instead of returning simulated 404s. assertInstanceOf(error, UnimplementedRouteError); + assertIdentical(error.apiName, apiName); assertIdentical(error.method, "GET"); assertIdentical(error.url, url); assertIdentical( error.message, - `GET ${url} reached SimApi, but SimApi has no handler for GET ${new URL(url).pathname}.`, + `GET ${url} reached ${apiName}, but ${apiName} has no handler for GET ${new URL(url).pathname}.`, ); assertInstanceOf(unknownError, UnimplementedRouteError); assertIdentical(unknownError.pathname, "/status"); assertIdentical( unknownError.message, - `GET ${unknownUrl} reached SimApi, but SimApi has no handler for GET /status.`, + `GET ${unknownUrl} reached ${apiName}, but ${apiName} has no handler for GET /status.`, ); }); diff --git a/src/api.ts b/src/api.ts index b4afb09..12db36f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -11,8 +11,17 @@ import { attachRestResource, RestResource } from "./rest-resource.js"; import type { RestResourceProps } from "./rest-resource-operation.js"; import { SimResource, type SimResourceProps } from "./resource.js"; +const resourcePathShape = (path: string): string => + path + .split("/") + .map((segment) => (segment.startsWith(":") ? ":" : segment)) + .join("/"); + /** Configures behaviour shared by every resource on one simulated API. */ export interface SimApiProps { + /** The service name used in route errors. `SimApi` when none is given. */ + name?: string; + /** * How request bodies are read, for the resources that read one. * @@ -36,13 +45,15 @@ export interface SimApiResourceProps /** Routes HTTP requests to stateful simulated resources. */ export class SimApi { + readonly name: string; readonly #paths = new Set(); readonly #router: OperationRouter; readonly #decode: RequestDecoder | undefined; constructor(props: SimApiProps = {}) { + this.name = props.name ?? "SimApi"; this.#decode = props.decode; - this.#router = new OperationRouter(props.formatError); + this.#router = new OperationRouter(this.name, props.formatError); } /** Adds middleware around every matched operation in this API. */ @@ -53,10 +64,12 @@ export class SimApi { /** Creates resource state and exposes its conventional HTTP operations. */ resource(props: SimApiResourceProps): RestResource { - const { decode, operations, path, ...stateProps } = props; + const { decode, itemPath, locate, operations, path, ...stateProps } = props; const restProps: RestResourceProps = { path, ...(decode === undefined ? {} : { decode }), + ...(itemPath === undefined ? {} : { itemPath }), + ...(locate === undefined ? {} : { locate }), ...(operations === undefined ? {} : { operations }), }; return this.expose(new SimResource(stateProps), restProps); @@ -68,8 +81,9 @@ export class SimApi { props: RestResourceProps, ): RestResource { validateResourcePath(props.path); + const pathShape = resourcePathShape(props.path); - if (this.#paths.has(props.path)) { + if (this.#paths.has(pathShape)) { throw new TypeError( `A resource is already exposed at collection path "${props.path}".`, ); @@ -79,7 +93,7 @@ export class SimApi { attachRestResource(resource, (operation) => { this.#router.register(operation); }); - this.#paths.add(props.path); + this.#paths.add(pathShape); return resource; } diff --git a/src/http/operation-router.ts b/src/http/operation-router.ts index 59cc73f..4ab4871 100644 --- a/src/http/operation-router.ts +++ b/src/http/operation-router.ts @@ -12,11 +12,13 @@ import type { RouteMatch } from "./route.js"; /** Selects and runs registered operations for one simulated API. */ export class OperationRouter { + readonly #apiName: string; readonly #middleware: HttpMiddleware[] = []; readonly #operations: HttpOperation[] = []; readonly #formatError: ErrorFormatter | undefined; - constructor(formatError?: ErrorFormatter) { + constructor(apiName: string, formatError?: ErrorFormatter) { + this.#apiName = apiName; this.#formatError = formatError; } @@ -50,7 +52,7 @@ export class OperationRouter { } if (selected === undefined) { - throw new UnimplementedRouteError(request); + throw new UnimplementedRouteError(request, this.#apiName); } return this.#run(selected.operation, request, selected.match); diff --git a/src/http/path-parameter.ts b/src/http/path-parameter.ts new file mode 100644 index 0000000..91bd7fd --- /dev/null +++ b/src/http/path-parameter.ts @@ -0,0 +1,13 @@ +/** Returns one matched path parameter or throws when it is absent. */ +export function requirePathParameter( + params: Readonly>, + name: string, +): string { + const value = params[name]; + + if (value === undefined) { + throw new TypeError(`Matched operation has no ":${name}" path parameter.`); + } + + return value; +} diff --git a/src/http/resource-path.ts b/src/http/resource-path.ts index dfc0c92..5eab322 100644 --- a/src/http/resource-path.ts +++ b/src/http/resource-path.ts @@ -1,9 +1,10 @@ /** * Checks that a resource path names a collection and nothing else. * - * A path with a parameter, a query string or a fragment cannot be joined to - * `/:id` to make an item route, and a path URL parsing would rewrite makes the - * routes registered disagree with the string the caller passed. + * A query string or fragment does not belong to a collection path. URL + * normalization can also make the registered route differ from the string the + * caller passed. Named parameters are allowed because collections often belong + * to another resource. * * Anything but a canonical collection path throws a `TypeError` naming what * was received. @@ -17,7 +18,9 @@ export function validateResourcePath(path: string): void { url.pathname !== path || url.search !== "" || url.hash !== "" || - !/^\/[^/:]+(?:\/[^/:]+)*$/u.test(path) + !/^\/(?:[^/:]+|:[A-Za-z_][A-Za-z\d_]*)(?:\/(?:[^/:]+|:[A-Za-z_][A-Za-z\d_]*))*$/u.test( + path, + ) ) { throw new TypeError( `Expected a resource collection path such as "/widgets", received "${path}".`, diff --git a/src/http/rest-resource-operation-defaults.ts b/src/http/rest-resource-operation-defaults.ts new file mode 100644 index 0000000..7f48da5 --- /dev/null +++ b/src/http/rest-resource-operation-defaults.ts @@ -0,0 +1,40 @@ +import type { + RestResourceOperationConfiguration, + RestResourceOperationSetting, +} from "../rest-resource-operation.js"; +import type { HttpOperation } from "./operation.js"; +import { + decodeJson, + decodeNothing, + decodeWhenPresent, + type RequestDecoder, +} from "./request-decoder.js"; + +export const encodeJson = + (status: number) => + (output: unknown): Response => + Response.json(output, { status }); + +export const encodeEmpty = (): Response => + new Response(undefined, { status: 204 }); + +export const bodyDecoder = ( + configuration: RestResourceOperationConfiguration | undefined, + resource: RequestDecoder | undefined, +): RequestDecoder => configuration?.decode ?? resource ?? decodeJson; + +export const emptyDecoder = ( + configuration: RestResourceOperationConfiguration | undefined, +): RequestDecoder => decodeWhenPresent(configuration?.decode ?? decodeNothing); + +export const configuredOperation = ( + setting: RestResourceOperationSetting | undefined, +): RestResourceOperationConfiguration | undefined => + setting === false ? undefined : setting; + +export const presentHttpOperations = ( + ...operations: (HttpOperation | undefined)[] +): HttpOperation[] => + operations.filter( + (operation): operation is HttpOperation => operation !== undefined, + ); diff --git a/src/http/rest-resource-operations.ts b/src/http/rest-resource-operations.ts index a55956b..a099e4b 100644 --- a/src/http/rest-resource-operations.ts +++ b/src/http/rest-resource-operations.ts @@ -1,108 +1,21 @@ import type { RestResource } from "../rest-resource.js"; import type { - RestResourceOperationConfiguration, RestResourceOperations, RestResourceProps, } from "../rest-resource-operation.js"; +import type { HttpMiddleware, HttpOperation } from "./operation.js"; import { - type HttpMiddleware, - type HttpOperation, - SemanticOperation, - type SemanticOperationContext, -} from "./operation.js"; -import { - decodeJson, - decodeNothing, - decodeWhenPresent, - type RequestDecoder, -} from "./request-decoder.js"; -import { compileRoute } from "./route.js"; -import { semanticHttpOperation } from "./semantic-http-operation.js"; - -const encodeJson = - (status: number) => - (output: unknown): Response => - Response.json(output, { status }); - -const encodeEmpty = (): Response => new Response(undefined, { status: 204 }); - -/** - * The decoder for an operation that reads a request body. - * - * A decoder configured on the operation itself always wins. Below that, one - * configured on the resource or on the API applies, and JSON is the default. - */ -const bodyDecoder = ( - configuration: RestResourceOperationConfiguration | undefined, - resource: RequestDecoder | undefined, -): RequestDecoder => configuration?.decode ?? resource ?? decodeJson; - -/** - * The decoder for an operation that is usually given no request body. - * - * `list`, `get` and `delete` inherit no decoder from the resource or the API. - * There would be nothing there for it to read. One configured on the operation - * itself is honoured, for the services that do send a body with a `DELETE`, - * and it runs only when a body actually arrived. A decoder handed a bodyless - * request is how `decodeJson` comes to answer `Unexpected end of JSON input` - * for a `DELETE` the caller sent nothing with. - */ -const emptyDecoder = ( - configuration: RestResourceOperationConfiguration | undefined, -): RequestDecoder => decodeWhenPresent(configuration?.decode ?? decodeNothing); - -const requiredPathParameter = ( - params: Readonly>, - name: string, -): string => { - const value = params[name]; - - if (value === undefined) { - throw new TypeError(`Matched operation has no ":${name}" path parameter.`); - } + bodyDecoder, + configuredOperation, + emptyDecoder, + encodeEmpty, + encodeJson, + presentHttpOperations, +} from "./rest-resource-operation-defaults.js"; +import { suppliedOperation } from "./supplied-rest-resource-operation.js"; - return value; -}; - -interface SuppliedOperationProps { - configuration: RestResourceOperationConfiguration | undefined; - decode: RequestDecoder; - defaultMethod: string; - defaultPath: string; - encode: (output: unknown) => Promise | Response; - handle: ( - context: SemanticOperationContext>, - ) => Promise | TOutput; - resource: RestResource; - resourceMiddleware: readonly HttpMiddleware[]; - requiredParameters?: readonly string[]; -} - -const suppliedOperation = ( - props: SuppliedOperationProps, -): { - http: HttpOperation; - semantic: SemanticOperation>; -} => { - const semantic = new SemanticOperation(props.handle); - const route = compileRoute( - `${props.resource.path}${props.configuration?.path ?? props.defaultPath}`, - props.requiredParameters, - ); - - return { - semantic, - http: semanticHttpOperation({ - decode: props.decode, - encode: props.encode, - method: props.configuration?.method ?? props.defaultMethod, - resource: props.resource, - resourceMiddleware: props.resourceMiddleware, - route, - semantic, - }), - }; -}; +const requiredItemParameters = (props: RestResourceProps): readonly string[] => + props.locate === undefined ? ["id"] : []; /** Builds conventional collection and item operations for one resource. */ export function restResourceOperations( @@ -111,23 +24,32 @@ export function restResourceOperations( resourceMiddleware: readonly HttpMiddleware[] = [], ): { http: HttpOperation[]; semantic: RestResourceOperations } { const configuration = props.operations ?? {}; + const listConfiguration = configuredOperation(configuration.list); + const createConfiguration = configuredOperation(configuration.create); + const getConfiguration = configuredOperation(configuration.get); + const updateConfiguration = configuredOperation(configuration.update); + const deleteConfiguration = configuredOperation(configuration.delete); + const itemPath = props.itemPath ?? "/:id"; + const itemParameters = requiredItemParameters(props); const list = suppliedOperation({ resource, resourceMiddleware, - configuration: configuration.list, + configuration: listConfiguration, defaultMethod: "GET", defaultPath: "", - decode: emptyDecoder(configuration.list), + decode: emptyDecoder(listConfiguration), + enabled: configuration.list !== false, handle: ({ resource: operationResource }) => operationResource.list(), encode: encodeJson(200), }); const create = suppliedOperation, T, T>({ resource, resourceMiddleware, - configuration: configuration.create, + configuration: createConfiguration, defaultMethod: "POST", defaultPath: "", - decode: bodyDecoder(configuration.create, props.decode), + decode: bodyDecoder(createConfiguration, props.decode), + enabled: configuration.create !== false, handle: ({ input, resource: operationResource }) => operationResource.create(input), encode: encodeJson(201), @@ -135,42 +57,51 @@ export function restResourceOperations( const get = suppliedOperation({ resource, resourceMiddleware, - configuration: configuration.get, + configuration: getConfiguration, defaultMethod: "GET", - defaultPath: "/:id", - decode: emptyDecoder(configuration.get), + defaultPath: itemPath, + decode: emptyDecoder(getConfiguration), + enabled: configuration.get !== false, handle: ({ params, resource: operationResource }) => - operationResource.get(requiredPathParameter(params, "id")), + operationResource.get(operationResource.locate(params)), encode: encodeJson(200), - requiredParameters: ["id"], + requiredParameters: itemParameters, }); const update = suppliedOperation, T, T>({ resource, resourceMiddleware, - configuration: configuration.update, + configuration: updateConfiguration, defaultMethod: "PATCH", - defaultPath: "/:id", - decode: bodyDecoder(configuration.update, props.decode), + defaultPath: itemPath, + decode: bodyDecoder(updateConfiguration, props.decode), + enabled: configuration.update !== false, handle: ({ input, params, resource: operationResource }) => - operationResource.update(requiredPathParameter(params, "id"), input), + operationResource.update(operationResource.locate(params), input), encode: encodeJson(200), - requiredParameters: ["id"], + requiredParameters: itemParameters, }); const deleteOperation = suppliedOperation({ resource, resourceMiddleware, - configuration: configuration.delete, + configuration: deleteConfiguration, defaultMethod: "DELETE", - defaultPath: "/:id", - decode: emptyDecoder(configuration.delete), + defaultPath: itemPath, + decode: emptyDecoder(deleteConfiguration), + enabled: configuration.delete !== false, handle: ({ params, resource: operationResource }) => - operationResource.delete(requiredPathParameter(params, "id")), + operationResource.delete(operationResource.locate(params)), encode: encodeEmpty, - requiredParameters: ["id"], + requiredParameters: itemParameters, }); return { - http: [list.http, create.http, get.http, update.http, deleteOperation.http], + http: presentHttpOperations( + list.http, + create.http, + get.http, + update.http, + deleteOperation.http, + ), semantic: { list: list.semantic, create: create.semantic, diff --git a/src/http/supplied-rest-resource-operation.ts b/src/http/supplied-rest-resource-operation.ts new file mode 100644 index 0000000..4ee5743 --- /dev/null +++ b/src/http/supplied-rest-resource-operation.ts @@ -0,0 +1,65 @@ +import type { RestResource } from "../rest-resource.js"; +import type { RestResourceOperationConfiguration } from "../rest-resource-operation.js"; +import { + type HttpMiddleware, + type HttpOperation, + SemanticOperation, + type SemanticOperationContext, +} from "./operation.js"; +import type { RequestDecoder } from "./request-decoder.js"; +import { compileRoute } from "./route.js"; +import { semanticHttpOperation } from "./semantic-http-operation.js"; + +interface SuppliedOperationProps { + configuration: RestResourceOperationConfiguration | undefined; + decode: RequestDecoder; + defaultMethod: string; + defaultPath: string; + enabled: boolean; + encode: (output: unknown) => Promise | Response; + handle: ( + context: SemanticOperationContext>, + ) => Promise | TOutput; + resource: RestResource; + resourceMiddleware: readonly HttpMiddleware[]; + requiredParameters?: readonly string[]; +} + +export const suppliedOperation = ( + props: SuppliedOperationProps, +): { + http?: HttpOperation; + semantic: SemanticOperation>; +} => { + const semantic = new SemanticOperation(props.handle); + + if (!props.enabled) { + return { semantic }; + } + + const relativePath = props.configuration?.path ?? props.defaultPath; + + if (relativePath !== "" && !relativePath.startsWith("/")) { + throw new TypeError( + `Expected a resource-relative operation path such as "/:id", received "${relativePath}".`, + ); + } + + const route = compileRoute( + `${props.resource.path}${relativePath}`, + props.requiredParameters, + ); + + return { + semantic, + http: semanticHttpOperation({ + decode: props.decode, + encode: props.encode, + method: props.configuration?.method ?? props.defaultMethod, + resource: props.resource, + resourceMiddleware: props.resourceMiddleware, + route, + semantic, + }), + }; +}; diff --git a/src/index.ts b/src/index.ts index 830d7ae..61ac4eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,25 +7,28 @@ export { } from "./environment.js"; export { DuplicateEntityError } from "./duplicate-entity-error.js"; export { EntityNotFoundError } from "./entity-not-found-error.js"; -export { - type HttpMiddleware, - type HttpOperationContext, - type RawHttpOperation, - SemanticOperation, - type SemanticOperationContext, - type SemanticOperationOverride, +export type { + HttpMiddleware, + HttpOperationContext, + RawHttpOperation, + SemanticOperationContext, + SemanticOperationOverride, } from "./http/operation.js"; +export { SemanticOperation } from "./http/operation.js"; export { decodeForm } from "./http/decode-form.js"; -export { type ErrorFormatter } from "./http/error-formatter.js"; -export { type RawHttpOperationHandler } from "./http/raw-operation.js"; +export type { ErrorFormatter } from "./http/error-formatter.js"; +export { requirePathParameter } from "./http/path-parameter.js"; +export type { RawHttpOperationHandler } from "./http/raw-operation.js"; export { decodeJson, type RequestDecoder } from "./http/request-decoder.js"; export { RestResource } from "./rest-resource.js"; -export { - type ResourceOperationProps, - type RestResourceOperationConfiguration, - type RestResourceOperationName, - type RestResourceOperations, - type RestResourceProps, +export type { + ResourceLocator, + ResourceOperationProps, + RestResourceOperationConfiguration, + RestResourceOperationName, + RestResourceOperationSetting, + RestResourceOperations, + RestResourceProps, } from "./rest-resource-operation.js"; export { SimResource, type SimResourceProps } from "./resource.js"; export { UnclaimedOriginError } from "./unclaimed-origin-error.js"; diff --git a/src/resource.test.ts b/src/resource.test.ts index 9c10310..b8a6956 100644 --- a/src/resource.test.ts +++ b/src/resource.test.ts @@ -24,7 +24,7 @@ describe("a simulated resource", () => { it("seeds and reads exact entities by their conventional id", () => { // Given a resource with two exact entities arranged in it. - const widgets = new SimResource({}); + const widgets = new SimResource(); const first: Widget = { id: faker.string.uuid(), name: faker.commerce.productName(), diff --git a/src/resource.ts b/src/resource.ts index 4b10944..f55fbf8 100644 --- a/src/resource.ts +++ b/src/resource.ts @@ -27,7 +27,7 @@ export class SimResource { readonly #entities = new Map(); readonly #identify: (entity: T) => string; - constructor(props: SimResourceProps) { + constructor(props: SimResourceProps = {}) { this.name = props.name; this.#createEntity = props.create ?? ((input): T => input as T); this.#identify = props.identify ?? identifyById; diff --git a/src/rest-resource-operation.ts b/src/rest-resource-operation.ts index 1e86e84..aa00fd3 100644 --- a/src/rest-resource-operation.ts +++ b/src/rest-resource-operation.ts @@ -5,19 +5,39 @@ import type { import type { RequestDecoder } from "./http/request-decoder.js"; import type { RestResource } from "./rest-resource.js"; +/** Turns matched route parameters into one resource state identity. */ +export type ResourceLocator = ( + params: Readonly>, +) => string; + /** Changes the route and request decoding of one supplied resource operation. */ export interface RestResourceOperationConfiguration { + /** Replaces the decoder inherited from the resource or API. */ decode?: RequestDecoder; + /** Replaces the supplied operation's conventional HTTP method. */ method?: string; + /** Replaces the path appended to the resource collection path. */ path?: string; } +/** Configures one supplied operation or leaves its route unimplemented. */ +export type RestResourceOperationSetting = + | RestResourceOperationConfiguration + | false; + /** Configures the HTTP path and supplied operations for a simulated resource. */ export interface RestResourceProps { + /** Reads request bodies for operations without their own decoder. */ decode?: RequestDecoder; + /** The path appended for get, update and delete. `/:id` by default. */ + itemPath?: string; + /** Maps item route parameters to state identity. Reads `:id` by default. */ + locate?: ResourceLocator; + /** Moves, decodes or disables the five supplied operations. */ operations?: Partial< - Record + Record >; + /** The absolute collection path, including any parent parameters. */ path: string; } diff --git a/src/rest-resource.ts b/src/rest-resource.ts index eb1f064..48f240a 100644 --- a/src/rest-resource.ts +++ b/src/rest-resource.ts @@ -2,10 +2,12 @@ import type { HttpMiddleware, SemanticOperation } from "./http/operation.js"; import { ResourceOperationRegistry } from "./http/resource-operation-registry.js"; import type { SimResource } from "./resource.js"; import type { + ResourceLocator, ResourceOperationProps, RestResourceOperations, RestResourceProps, } from "./rest-resource-operation.js"; +import { requirePathParameter } from "./http/path-parameter.js"; const attachResource = Symbol("attach REST resource"); @@ -14,15 +16,23 @@ export class RestResource { readonly operations: RestResourceOperations; readonly path: string; readonly state: SimResource; + readonly #locate: ResourceLocator; readonly #operationRegistry: ResourceOperationRegistry; constructor(state: SimResource, props: RestResourceProps) { this.state = state; this.path = props.path; + this.#locate = + props.locate ?? ((params): string => requirePathParameter(params, "id")); this.#operationRegistry = new ResourceOperationRegistry(this, props); this.operations = this.#operationRegistry.operations; } + /** Returns the state identity named by matched route parameters. */ + locate(params: Readonly>): string { + return this.#locate(params); + } + /** Adds middleware around every operation owned by this resource. */ use(middleware: HttpMiddleware): this { this.#operationRegistry.use(middleware); diff --git a/src/unimplemented-route-error.ts b/src/unimplemented-route-error.ts index a60f241..0426abe 100644 --- a/src/unimplemented-route-error.ts +++ b/src/unimplemented-route-error.ts @@ -1,17 +1,19 @@ /** Reports a request for which a simulated API has no operation. */ export class UnimplementedRouteError extends Error { + readonly apiName: string; readonly method: string; readonly pathname: string; readonly url: string; - constructor(request: Request) { + constructor(request: Request, apiName = "SimApi") { const url = new URL(request.url); const method = request.method.toUpperCase(); super( - `${method} ${request.url} reached SimApi, but SimApi has no handler for ${method} ${url.pathname}.`, + `${method} ${request.url} reached ${apiName}, but ${apiName} has no handler for ${method} ${url.pathname}.`, ); this.name = "UnimplementedRouteError"; + this.apiName = apiName; this.method = method; this.pathname = url.pathname; this.url = request.url; diff --git a/src/webhooks.test.ts b/src/webhooks.test.ts index bb8a569..944b191 100644 --- a/src/webhooks.test.ts +++ b/src/webhooks.test.ts @@ -2,9 +2,12 @@ import { faker } from "@faker-js/faker"; import { assertArrayEmpty, assertArrayLength, + assertFalse, assertIdentical, assertInstanceOf, assertObjectEquals, + assertResponseStatus, + assertTrue, assertUndefined, } from "@kensio/smartass"; import { describe, it } from "vitest"; @@ -125,7 +128,8 @@ describe("sending requests outwards", () => { const [result] = await webhooks.flush(); // Then the delivery arrived, and the response says what happened to it. - assertIdentical(result?.response?.status, 400); + assertTrue(result?.delivered); + assertResponseStatus(result.response, 400); assertUndefined(result.error); }); @@ -141,8 +145,9 @@ describe("sending requests outwards", () => { const [result] = await webhooks.flush(); // Then it comes back as a failure naming the unclaimed origin. - assertUndefined(result?.response); - assertInstanceOf(result?.error, TypeError); + assertFalse(result?.delivered); + assertUndefined(result.response); + assertInstanceOf(result.error, TypeError); assertInstanceOf(result.error.cause, UnclaimedOriginError); }); diff --git a/src/webhooks.ts b/src/webhooks.ts index 561d0a8..3790ee8 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -26,17 +26,21 @@ export interface WebhookDelivery { } /** What one endpoint did with one delivery. */ -export interface WebhookDeliveryResult { - readonly delivery: WebhookDelivery; - /** - * What stopped the request from arriving, or nothing when it arrived. - * - * An endpoint that answered 400 or 500 arrived. That is a `response`. - */ - readonly error: unknown; - /** The endpoint's answer, whatever its status, or nothing when it never arrived. */ - readonly response: Response | undefined; -} +export type WebhookDeliveryResult = + | { + readonly delivered: true; + readonly delivery: WebhookDelivery; + readonly error: undefined; + /** The endpoint's answer, whatever its status. */ + readonly response: Response; + } + | { + readonly delivered: false; + readonly delivery: WebhookDelivery; + /** What stopped the request from arriving. */ + readonly error: unknown; + readonly response: undefined; + }; /** * A queue of requests a simulated service sends outwards. @@ -175,8 +179,8 @@ async function send(delivery: WebhookDelivery): Promise { method: delivery.method ?? "POST", }); - return { delivery, error: undefined, response }; + return { delivered: true, delivery, error: undefined, response }; } catch (error) { - return { delivery, error, response: undefined }; + return { delivered: false, delivery, error, response: undefined }; } } diff --git a/test/github-rest-api-issue-collection.ts b/test/github-rest-api-issue-collection.ts index 6083d17..bf254da 100644 --- a/test/github-rest-api-issue-collection.ts +++ b/test/github-rest-api-issue-collection.ts @@ -1,5 +1,4 @@ -import type { RestResource } from "../src/index.js"; -import { pathParameter } from "./github-rest-api-issue-identity.js"; +import { requirePathParameter, type RestResource } from "../src/index.js"; import { paginate } from "./github-rest-api-middleware.js"; import type { GitHubIssue } from "./github-rest-api-types.js"; @@ -24,7 +23,7 @@ export const configureIssueCollection = ( ): void => { issues.operations.list.override({ handle({ params, resource }) { - const repositoryUrl = `${githubOrigin}/repos/${pathParameter(params, "owner")}/${pathParameter(params, "repository")}`; + const repositoryUrl = `${githubOrigin}/repos/${requirePathParameter(params, "owner")}/${requirePathParameter(params, "repository")}`; return resource .list() .filter((issue) => issue.repository_url === repositoryUrl); @@ -35,8 +34,8 @@ export const configureIssueCollection = ( let nextIssueId = 1_000_000; issues.operations.create.override({ handle({ input, params, resource }) { - const owner = pathParameter(params, "owner"); - const repository = pathParameter(params, "repository"); + const owner = requirePathParameter(params, "owner"); + const repository = requirePathParameter(params, "repository"); const repositoryUrl = `${githubOrigin}/repos/${owner}/${repository}`; const number = nextIssueNumber(resource.list(), repositoryUrl); const issue: GitHubIssue = { diff --git a/test/github-rest-api-issue-identity.ts b/test/github-rest-api-issue-identity.ts index c835332..4f82c4f 100644 --- a/test/github-rest-api-issue-identity.ts +++ b/test/github-rest-api-issue-identity.ts @@ -1,18 +1,5 @@ import type { GitHubIssue } from "./github-rest-api-types.js"; -export const pathParameter = ( - params: Readonly>, - name: string, -): string => { - const value = params[name]; - - if (value === undefined) { - throw new TypeError(`GitHub route has no ":${name}" path parameter.`); - } - - return value; -}; - export const issueIdentity = ( owner: string, repository: string, diff --git a/test/github-rest-api-issue-item.ts b/test/github-rest-api-issue-item.ts index f910c93..4bb057e 100644 --- a/test/github-rest-api-issue-item.ts +++ b/test/github-rest-api-issue-item.ts @@ -1,30 +1,13 @@ import type { RestResource } from "../src/index.js"; -import { - issueIdentity, - pathParameter, -} from "./github-rest-api-issue-identity.js"; import { conditionallyCache } from "./github-rest-api-middleware.js"; import type { GitHubIssue } from "./github-rest-api-types.js"; export const configureIssueItem = (issues: RestResource): void => { - issues.operations.get.override({ - handle({ params, resource }) { - const owner = pathParameter(params, "owner"); - const repository = pathParameter(params, "repository"); - const number = Number(pathParameter(params, "id")); - return resource.get(issueIdentity(owner, repository, number)); - }, - }); issues.operations.get.use(conditionallyCache); issues.operations.update.override({ handle({ input, params, resource }) { - const identity = issueIdentity( - pathParameter(params, "owner"), - pathParameter(params, "repository"), - Number(pathParameter(params, "id")), - ); - return resource.update(identity, { + return resource.update(resource.locate(params), { ...input, updated_at: new Date().toISOString(), }); diff --git a/test/github-rest-api-issues.ts b/test/github-rest-api-issues.ts index b092485..5b38005 100644 --- a/test/github-rest-api-issues.ts +++ b/test/github-rest-api-issues.ts @@ -1,6 +1,13 @@ -import type { RestResource, SimApi } from "../src/index.js"; +import { + requirePathParameter, + type RestResource, + type SimApi, +} from "../src/index.js"; import { configureIssueCollection } from "./github-rest-api-issue-collection.js"; -import { identityForIssue } from "./github-rest-api-issue-identity.js"; +import { + identityForIssue, + issueIdentity, +} from "./github-rest-api-issue-identity.js"; import { configureIssueItem } from "./github-rest-api-issue-item.js"; import type { GitHubIssue } from "./github-rest-api-types.js"; @@ -10,15 +17,18 @@ export const createGitHubIssues = ( ): RestResource => { const issues = api.resource({ identify: identityForIssue, + itemPath: "/:number", + locate: (params) => + issueIdentity( + requirePathParameter(params, "owner"), + requirePathParameter(params, "repository"), + Number(requirePathParameter(params, "number")), + ), name: "issue", operations: { - create: { path: "/:owner/:repository/issues" }, - delete: { path: "/:owner/:repository/issues/:id" }, - get: { path: "/:owner/:repository/issues/:id" }, - list: { path: "/:owner/:repository/issues" }, - update: { path: "/:owner/:repository/issues/:id" }, + delete: false, }, - path: "/repos", + path: "/repos/:owner/:repository/issues", }); configureIssueCollection(issues, githubOrigin); configureIssueItem(issues); From b3989197dff1cc8789a407e4d785666e685d8e95 Mon Sep 17 00:00:00 2001 From: Hugh Grigg Date: Wed, 2 Sep 2026 17:01:44 +0100 Subject: [PATCH 2/2] fix: reject inherited path parameters --- src/api.test.ts | 35 +++++++++++++++++++++++++++++++++++ src/http/path-parameter.ts | 4 ++++ 2 files changed, 39 insertions(+) diff --git a/src/api.test.ts b/src/api.test.ts index 871967c..27cbdc8 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -747,6 +747,41 @@ describe("a simulated API", () => { assertObjectEquals(await response.json(), {}); }); + it("requires path parameters to be own string-valued properties", () => { + // Given inherited, undefined and explicitly provided path parameters. + const explicitValue = faker.string.uuid(); + const params = Object.create({ + constructor: faker.string.uuid(), + }) as Record; + Object.defineProperty(params, "toString", { value: explicitValue }); + const undefinedParams = { id: undefined } as unknown as Record< + string, + string + >; + + // When each parameter is required. + const inheritedError = assertThrowsError(() => + requirePathParameter(params, "constructor"), + ); + const undefinedError = assertThrowsError(() => + requirePathParameter(undefinedParams, "id"), + ); + const explicitResult = requirePathParameter(params, "toString"); + + // Then only the explicitly provided string is returned. + assertInstanceOf(inheritedError, TypeError); + assertIdentical( + inheritedError.message, + 'Matched operation has no ":constructor" path parameter.', + ); + assertInstanceOf(undefinedError, TypeError); + assertIdentical( + undefinedError.message, + 'Matched operation has no ":id" path parameter.', + ); + assertIdentical(explicitResult, explicitValue); + }); + it("refuses middleware which calls next more than once", async () => { // Given API middleware which dispatches its downstream operation twice. const api = new SimApi(); diff --git a/src/http/path-parameter.ts b/src/http/path-parameter.ts index 91bd7fd..d994ea3 100644 --- a/src/http/path-parameter.ts +++ b/src/http/path-parameter.ts @@ -3,6 +3,10 @@ export function requirePathParameter( params: Readonly>, name: string, ): string { + if (!Object.hasOwn(params, name)) { + throw new TypeError(`Matched operation has no ":${name}" path parameter.`); + } + const value = params[name]; if (value === undefined) {