diff --git a/.changeset/openapi-wildcard-path.md b/.changeset/openapi-wildcard-path.md new file mode 100644 index 0000000000..e1a37cb893 --- /dev/null +++ b/.changeset/openapi-wildcard-path.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Bind router-style wildcard path templates to their declared parameter names, preserving path separators while escaping each segment. Reject wildcard dot segments before URL normalization can escape the operation prefix. diff --git a/e2e/scenarios/openapi-wildcard-path.test.ts b/e2e/scenarios/openapi-wildcard-path.test.ts new file mode 100644 index 0000000000..3a07a6fbf7 --- /dev/null +++ b/e2e/scenarios/openapi-wildcard-path.test.ts @@ -0,0 +1,118 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +const outcome = Schema.fromJsonString( + Schema.Struct({ + ok: Schema.Boolean, + http: Schema.optional(Schema.Struct({ status: Schema.Number })), + error: Schema.optional(Schema.Struct({ code: Schema.String })), + }), +); + +scenario( + "OpenAPI · wildcard paths execute with the declared parameter", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`wildcard-${randomBytes(4).toString("hex")}`); + const baseUrl = yield* createEmulatorInstance("resend", "wildcard-path"); + yield* Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + slug, + name: "Wildcard paths", + baseUrl, + spec: { + kind: "blob", + value: JSON.stringify({ + openapi: "3.0.3", + info: { title: "Wildcard paths", version: "1" }, + servers: [{ url: baseUrl }], + paths: { + "/{*identifier}": { + get: { + operationId: "readPath", + parameters: [ + { + name: "identifier", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { "200": { description: "Public emulator manifest" } }, + }, + }, + }, + }), + }, + }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("public"), + integration: slug, + template: AuthTemplateSlug.make("none"), + values: {}, + }, + }); + const tools = yield* client.tools.list({ query: {} }); + const tool = tools.find((t) => String(t.integration) === slug && t.name.includes("readPath")); + expect(tool).toBeDefined(); + const schema = yield* client.tools.schema({ query: { address: tool!.address } }); + expect(schema.inputSchema).toMatchObject({ required: ["identifier"] }); + const outcomes = []; + for (const args of [ + { identifier: "_emulate/manifest" }, + {}, + { identifier: "../_emulate/manifest" }, + ]) { + const executed = yield* client.executions.execute({ + payload: { + code: `const parts = ${JSON.stringify(tool!.address)}.split('.').slice(1); let tool = tools; for (const part of parts) tool = tool[part]; return JSON.stringify(await tool(${JSON.stringify(args)}));`, + autoApprove: true, + }, + }); + expect(executed.status).toBe("completed"); + const result = yield* Schema.decodeUnknownEffect(outcome)(executed.text); + outcomes.push(result); + } + expect(outcomes).toMatchObject([ + { ok: true, http: { status: 200 } }, + { ok: false, error: { code: "invalid_tool_arguments" } }, + { ok: false, error: { code: "invalid_tool_arguments" } }, + ]); + yield* browser.session(identity, async ({ page, step }) => { + await step("Inspect the integration after its wildcard tool succeeds", async () => { + await visit(page, `/integrations/${slug}`); + await page.getByText("Wildcard paths", { exact: true }).first().waitFor(); + }); + }); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* client.connections + .remove({ + params: { owner: "org", integration: slug, name: ConnectionName.make("public") }, + }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ), + ); + }), +); diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index 8d180e6952..e2ef4a6538 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -138,6 +138,21 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( ); resolved = resolved.replaceAll(`{${param.name}}`, encoded); resolved = resolved.replaceAll(`{+${param.name}}`, encoded); + const wildcard = `{*${param.name}}`; + if (resolved.includes(wildcard)) { + // Catch-alls preserve separators, but dot segments would let URL + // normalization escape the operation's static prefix. + const segments = String(value).split("/"); + if (segments.some((segment) => segment === "." || segment === "..")) { + return yield* new OpenApiInvocationError({ + message: `Wildcard path parameter ${param.name} must not contain dot segments`, + statusCode: Option.none(), + }); + } + // Bind the unprefixed parameter name and escape every segment, including + // percent signs, query delimiters and fragment delimiters. + resolved = resolved.replaceAll(wildcard, segments.map(encodeURIComponent).join("/")); + } } const remaining = [...resolved.matchAll(/\{([^{}]+)\}/g)] diff --git a/packages/plugins/openapi/src/sdk/wildcard-path.test.ts b/packages/plugins/openapi/src/sdk/wildcard-path.test.ts new file mode 100644 index 0000000000..182fcd4667 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/wildcard-path.test.ts @@ -0,0 +1,102 @@ +import { expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; + +import { extract } from "./extract"; +import { buildRequest } from "./invoke"; +import { parse } from "./parse"; + +const operation = (template = "/run/{*identifier}") => + Effect.gen(function* () { + const document = yield* parse( + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Wildcard paths", version: "1" }, + paths: { + [template]: { + get: { + operationId: "readPath", + parameters: [ + { name: "identifier", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { "200": { description: "OK" } }, + }, + }, + }, + }), + ); + const result = yield* extract(document); + return result.operations[0]!; + }); + +it.effect("binds an imported catch-all to its declared unprefixed parameter", () => + Effect.gen(function* () { + const binding = yield* operation(); + expect(Option.getOrThrow(binding.inputSchema)).toMatchObject({ required: ["identifier"] }); + const request = yield* buildRequest(binding, { identifier: "nested/module/read" }, {}); + expect(request.url).toBe("/run/nested/module/read"); + }), +); + +it.effect("encodes wildcard segments without introducing query or fragment delimiters", () => + Effect.gen(function* () { + const binding = yield* operation(); + const request = yield* buildRequest(binding, { identifier: "a b/c?d#e/%2F/雪" }, {}); + expect(request.url).toBe("/run/a%20b/c%3Fd%23e/%252F/%E9%9B%AA"); + }), +); + +it.effect("uses the existing nested path argument contract for catch-alls", () => + Effect.gen(function* () { + const request = yield* buildRequest( + yield* operation(), + { path: { identifier: "module/read" } }, + {}, + ); + expect(request.url).toBe("/run/module/read"); + }), +); + +it.effect("still rejects missing catch-all values by the declared parameter name", () => + Effect.gen(function* () { + const error = yield* Effect.flip(buildRequest(yield* operation(), {}, {})); + expect(error).toMatchObject({ message: "Missing required path parameter: identifier" }); + }), +); + +it.effect("retains ordinary parameter encoding alongside a catch-all of the same name", () => + Effect.gen(function* () { + const request = yield* buildRequest( + yield* operation("/plain/{identifier}/wild/{*identifier}"), + { identifier: "module/read" }, + {}, + ); + expect(request.url).toBe("/plain/module%2Fread/wild/module/read"); + }), +); + +for (const identifier of ["../admin", "nested/../../admin", "./read", "nested/..", "/../admin/"]) { + it.effect(`rejects catch-all dot segments before constructing a request: ${identifier}`, () => + Effect.gen(function* () { + const failure = yield* Effect.flip(buildRequest(yield* operation(), { identifier }, {})); + expect(failure).toMatchObject({ + message: "Wildcard path parameter identifier must not contain dot segments", + }); + }), + ); +} + +for (const [identifier, expected] of [ + ["/nested/read/", "/run//nested/read/"], + ["nested//read", "/run/nested//read"], + ["nested/%2F/read", "/run/nested/%252F/read"], + ["nested/%252F/read", "/run/nested/%25252F/read"], + ["%2e%2e/read", "/run/%252e%252e/read"], + ["nested\\..\\read", "/run/nested%5C..%5Cread"], +] as const) { + it.effect(`preserves segment boundaries through URL normalization: ${identifier}`, () => + Effect.gen(function* () { + const request = yield* buildRequest(yield* operation(), { identifier }, {}); + expect(new URL(request.url, "https://api.example.test").pathname).toBe(expected); + }), + ); +}