Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/openapi-wildcard-path.md
Original file line number Diff line number Diff line change
@@ -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.
118 changes: 118 additions & 0 deletions e2e/scenarios/openapi-wildcard-path.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
),
);
}),
);
15 changes: 15 additions & 0 deletions packages/plugins/openapi/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
102 changes: 102 additions & 0 deletions packages/plugins/openapi/src/sdk/wildcard-path.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
);
}
Loading