Files
anomalyco_opencode/packages/codemode/test/openapi.test.ts
T
2026-07-03 15:22:33 -05:00

573 lines
22 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { CodeMode, toolError } from "../src/index.js"
import { OpenAPI } from "../src/adapters/openapi/index.js"
// The example spec exercises: global apiKey default, an operation-level OAuth2
// override with scopes, path/query parameters, a JSON request body, component
// $refs, and a 204 no-content response.
const spec = {
openapi: "3.1.0",
info: { title: "Widgets API", version: "1.0.0" },
servers: [{ url: "https://api.widgets.dev" }],
security: [{ ApiKeyAuth: [] }],
paths: {
"/widgets": {
get: {
operationId: "listWidgets",
summary: "List widgets",
parameters: [
{ name: "limit", in: "query", schema: { type: "integer", default: 20 } },
{ name: "cursor", in: "query", schema: { type: "string" } },
],
responses: {
"200": {
description: "ok",
content: {
"application/json": {
schema: {
type: "object",
properties: {
items: { type: "array", items: { $ref: "#/components/schemas/Widget" } },
nextCursor: { type: "string" },
},
required: ["items"],
},
},
},
},
},
},
post: {
operationId: "createWidget",
summary: "Create a widget",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: { name: { type: "string", description: "Widget name" } },
required: ["name"],
},
},
},
},
responses: {
"201": {
description: "created",
content: { "application/json": { schema: { $ref: "#/components/schemas/Widget" } } },
},
},
},
},
"/widgets/{id}": {
delete: {
operationId: "deleteWidget",
summary: "Delete a widget",
security: [{ OAuth2: ["widgets:write"] }],
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
responses: { "204": { description: "deleted" } },
},
},
"/upload": {
post: {
operationId: "uploadFile",
requestBody: { required: true, content: { "multipart/form-data": { schema: { type: "object" } } } },
responses: { "200": { description: "ok" } },
},
},
},
components: {
securitySchemes: {
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
OAuth2: { type: "oauth2" },
},
schemas: {
Widget: {
type: "object",
properties: { id: { type: "string" }, name: { type: "string" } },
required: ["id", "name"],
},
},
},
}
type Recorded = {
readonly method: string
readonly url: string
readonly headers: Record<string, string>
readonly body: unknown
}
const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
const requests: Array<Recorded> = []
const layer = Layer.succeed(HttpClient.HttpClient)(
HttpClient.make((request) =>
Effect.gen(function* () {
const body =
request.body._tag === "Uint8Array"
? JSON.parse(new TextDecoder().decode(request.body.body))
: undefined
const url = Option.map(HttpClientRequest.toUrl(request), (resolved) => resolved.toString())
requests.push({
method: request.method,
url: Option.getOrElse(url, () => request.url),
headers: { ...request.headers },
body,
})
return HttpClientResponse.fromWeb(request, respond(request))
}),
),
)
return { requests, layer }
}
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
const auth = {
resolve: ({ schemeName, scopes }: { schemeName: string; scopes: ReadonlyArray<string> }) => {
if (schemeName === "ApiKeyAuth") return Effect.succeed({ type: "apiKey", value: "key-123" } as const)
if (schemeName === "OAuth2") {
return Effect.succeed({ type: "bearer", token: `oauth-${scopes.join("+")}` } as const)
}
return Effect.succeed(undefined)
},
}
describe("OpenAPI.fromSpec", () => {
test("generates one tool per operation and reports unrepresentable ones", () => {
const result = OpenAPI.fromSpec({ spec, auth })
expect(Object.keys(result.tools).sort()).toStrictEqual(["createWidget", "deleteWidget", "listWidgets"])
expect(result.skipped).toMatchObject([
{ method: "POST", path: "/upload", reason: expect.stringContaining("multipart/form-data") },
])
})
test("model-visible signatures expand component refs and never mention auth", () => {
const runtime = CodeMode.make({ tools: { widgets: OpenAPI.fromSpec({ spec, auth }).tools } })
const instructions = runtime.instructions()
expect(instructions).toContain(
"tools.widgets.listWidgets(input: { query?: { limit?: number; cursor?: string } }): Promise<{ items: Array<{ id: string; name: string }>; nextCursor?: string }> // List widgets",
)
expect(instructions).toContain(
"tools.widgets.deleteWidget(input: { path: { id: string } }): Promise<null> // Delete a widget",
)
expect(instructions).toContain(
"tools.widgets.createWidget(input: { body: { name: string } }): Promise<{ id: string; name: string }> // Create a widget",
)
expect(instructions).not.toContain("X-API-Key")
expect(instructions).not.toContain("Authorization")
})
test("applies the global apiKey default and serializes query parameters", async () => {
const { requests, layer } = recordingClient(() => json({ items: [{ id: "w_1", name: "one" }] }))
const runtime = CodeMode.make({ tools: { widgets: OpenAPI.fromSpec({ spec, auth }).tools } })
const result = await Effect.runPromise(
runtime.execute("return await tools.widgets.listWidgets({ query: { limit: 5 } })").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true, value: { items: [{ id: "w_1", name: "one" }] } })
expect(requests).toHaveLength(1)
expect(requests[0]!.method).toBe("GET")
expect(requests[0]!.url).toBe("https://api.widgets.dev/widgets?limit=5")
expect(requests[0]!.headers["x-api-key"]).toBe("key-123")
expect(requests[0]!.headers["authorization"]).toBeUndefined()
})
test("operation-level security replaces the default and passes scopes", async () => {
const { requests, layer } = recordingClient(() => new Response(null, { status: 204 }))
const runtime = CodeMode.make({ tools: { widgets: OpenAPI.fromSpec({ spec, auth }).tools } })
const result = await Effect.runPromise(
runtime.execute('return await tools.widgets.deleteWidget({ path: { id: "w 1" } })').pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true, value: null })
expect(requests[0]!.method).toBe("DELETE")
expect(requests[0]!.url).toBe("https://api.widgets.dev/widgets/w%201")
expect(requests[0]!.headers["authorization"]).toBe("Bearer oauth-widgets:write")
expect(requests[0]!.headers["x-api-key"]).toBeUndefined()
})
test("sends JSON request bodies and static host headers", async () => {
const { requests, layer } = recordingClient(() => json({ id: "w_2", name: "two" }, 201))
const runtime = CodeMode.make({
tools: { widgets: OpenAPI.fromSpec({ spec, auth, headers: { "x-client": "codemode" } }).tools },
})
const result = await Effect.runPromise(
runtime
.execute('return await tools.widgets.createWidget({ body: { name: "two" } })')
.pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true, value: { id: "w_2", name: "two" } })
expect(requests[0]!.body).toStrictEqual({ name: "two" })
expect(requests[0]!.headers["x-client"]).toBe("codemode")
})
test("a missing required body fails clearly without hitting the network", async () => {
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({ tools: { widgets: OpenAPI.fromSpec({ spec, auth }).tools } })
const result = await Effect.runPromise(
runtime.execute("return await tools.widgets.createWidget({})").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: false })
expect(JSON.stringify(result)).toContain("Missing required request body")
expect(requests).toHaveLength(0)
})
test("non-2xx responses become safe tool failures carrying status and body", async () => {
const { layer } = recordingClient(() => json({ message: "widget not found" }, 404))
const runtime = CodeMode.make({ tools: { widgets: OpenAPI.fromSpec({ spec, auth }).tools } })
const result = await Effect.runPromise(
runtime
.execute(`
try {
return await tools.widgets.listWidgets({})
} catch (error) {
return { message: error.message }
}
`)
.pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({
ok: true,
value: { message: 'GET /widgets failed with HTTP 404: {"message":"widget not found"}' },
})
})
test("an unavailable credential fails clearly without hitting the network", async () => {
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { widgets: OpenAPI.fromSpec({ spec, auth: { resolve: () => Effect.succeed(undefined) } }).tools },
})
const result = await Effect.runPromise(
runtime.execute("return await tools.widgets.listWidgets({})").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({
ok: false,
error: { kind: "ToolFailure", message: expect.stringContaining("ApiKeyAuth") },
})
expect(requests).toHaveLength(0)
})
test("a failing resolver aborts instead of falling through", async () => {
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: {
widgets: OpenAPI.fromSpec({
spec,
auth: { resolve: () => Effect.fail(toolError("token refresh failed")) },
}).tools,
},
})
const result = await Effect.runPromise(
runtime.execute("return await tools.widgets.listWidgets({})").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: false, error: { kind: "ToolFailure", message: "token refresh failed" } })
expect(requests).toHaveLength(0)
})
test("security: [] and empty OR alternatives allow unauthenticated calls", async () => {
const open = {
openapi: "3.1.0",
info: { title: "Open API", version: "1.0.0" },
servers: [{ url: "https://open.example" }],
security: [{ ApiKeyAuth: [] }, {}],
paths: {
"/status": {
get: { operationId: "getStatus", responses: { "200": { description: "ok" } } },
post: { operationId: "postStatus", security: [], responses: { "200": { description: "ok" } } },
},
},
components: { securitySchemes: { ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" } } },
}
const { requests, layer } = recordingClient(() => json({ ok: true }))
const runtime = CodeMode.make({
tools: { open: OpenAPI.fromSpec({ spec: open, auth: { resolve: () => Effect.succeed(undefined) } }).tools },
})
const result = await Effect.runPromise(
runtime
.execute("return [await tools.open.getStatus({}), await tools.open.postStatus({})]")
.pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true, value: [{ ok: true }, { ok: true }] })
expect(requests).toHaveLength(2)
expect(requests.every((request) => request.headers["x-api-key"] === undefined)).toBe(true)
})
test("apiKey carriers follow the scheme declaration (query and cookie)", async () => {
const carriers = {
openapi: "3.1.0",
info: { title: "Carriers", version: "1.0.0" },
servers: [{ url: "https://carriers.example" }],
paths: {
"/q": { get: { operationId: "q", security: [{ QueryKey: [] }], responses: { "200": { description: "ok" } } } },
"/c": { get: { operationId: "c", security: [{ CookieKey: [] }], responses: { "200": { description: "ok" } } } },
},
components: {
securitySchemes: {
QueryKey: { type: "apiKey", in: "query", name: "api_key" },
CookieKey: { type: "apiKey", in: "cookie", name: "session" },
},
},
}
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: {
api: OpenAPI.fromSpec({
spec: carriers,
auth: { resolve: () => Effect.succeed({ type: "apiKey", value: "secret" } as const) },
}).tools,
},
})
const result = await Effect.runPromise(
runtime.execute("return [await tools.api.q({}), await tools.api.c({})]").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true })
expect(requests[0]!.url).toBe("https://carriers.example/q?api_key=secret")
expect(requests[1]!.headers["cookie"]).toBe("session=secret")
})
test("output schemas follow response declarations", () => {
const responses = {
openapi: "3.1.0",
info: { title: "Responses", version: "1.0.0" },
servers: [{ url: "https://responses.example" }],
paths: {
"/wild": {
get: {
operationId: "wild",
responses: {
"2XX": {
description: "ok",
content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" } } } } },
},
},
},
},
"/errors": { get: { operationId: "errorsOnly", responses: { "404": { description: "missing" } } } },
"/text": {
get: {
operationId: "getText",
responses: { "200": { description: "ok", content: { "text/plain": { schema: { type: "string" } } } } },
},
},
"/thing": {
delete: { operationId: "deleteThing", responses: { "204": { description: "deleted" } } },
post: {
operationId: "createThing",
responses: {
"200": { description: "no content variant" },
"201": {
description: "created",
content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } } },
},
},
},
},
},
}
const runtime = CodeMode.make({ tools: { api: OpenAPI.fromSpec({ spec: responses }).tools } })
const instructions = runtime.instructions()
expect(instructions).toContain("tools.api.wild(input: {}): Promise<{ ok?: boolean }>")
expect(instructions).toContain("tools.api.errorsOnly(input: {}): Promise<unknown>")
expect(instructions).toContain("tools.api.getText(input: {}): Promise<unknown>")
expect(instructions).toContain("tools.api.deleteThing(input: {}): Promise<null>")
expect(instructions).toContain("tools.api.createThing(input: {}): Promise<{ id?: string }>")
})
test("operation names are sanitized, deduplicated, and derived when operationId is missing", () => {
const messy = {
openapi: "3.1.0",
info: { title: "Messy", version: "1.0.0" },
servers: [{ url: "https://messy.example" }],
paths: {
"/a": {
get: { operationId: "get widgets!", responses: { "200": { description: "ok" } } },
post: { operationId: "dup", responses: { "200": { description: "ok" } } },
},
"/b": {
get: { operationId: "dup", responses: { "200": { description: "ok" } } },
post: { responses: { "200": { description: "ok" } } },
},
"/c": {
get: { operationId: "constructor", responses: { "200": { description: "ok" } } },
post: { operationId: "prototype", responses: { "200": { description: "ok" } } },
},
},
}
const result = OpenAPI.fromSpec({ spec: messy })
expect(Object.keys(result.tools).sort()).toStrictEqual([
"constructor_2",
"dup",
"dup_2",
"get_widgets",
"post__b",
"prototype_2",
])
})
test("the operations filter curates tools without reporting them as skipped", () => {
const result = OpenAPI.fromSpec({ spec, auth, operations: (operation) => operation.method === "GET" })
expect(Object.keys(result.tools)).toStrictEqual(["listWidgets"])
expect(result.skipped.map((entry) => entry.path)).not.toContain("/widgets/{id}")
})
test("declared header parameters are sent, override host headers, and reserved names are ignored", async () => {
const headed = {
openapi: "3.1.0",
info: { title: "Headed", version: "1.0.0" },
servers: [{ url: "https://headed.example" }],
paths: {
"/h": {
get: {
operationId: "h",
parameters: [
{ name: "X-Trace", in: "header", schema: { type: "string" } },
{ name: "Authorization", in: "header", required: true, schema: { type: "string" } },
],
responses: { "200": { description: "ok" } },
},
},
},
}
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { api: OpenAPI.fromSpec({ spec: headed, headers: { "x-trace": "host", "x-static": "kept" } }).tools },
})
expect(runtime.instructions()).not.toContain("Authorization")
const result = await Effect.runPromise(
runtime.execute('return await tools.api.h({ headers: { "X-Trace": "model" } })').pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true })
expect(requests[0]!.headers["x-trace"]).toBe("model")
expect(requests[0]!.headers["x-static"]).toBe("kept")
expect(requests[0]!.headers["authorization"]).toBeUndefined()
})
test("path-level parameters merge and operation-level ones win", () => {
const layered = {
openapi: "3.1.0",
info: { title: "Layered", version: "1.0.0" },
servers: [{ url: "https://layered.example" }],
paths: {
"/l": {
parameters: [
{ name: "v", in: "query", required: true, schema: { type: "integer" } },
{ name: "shared", in: "query", schema: { type: "string" } },
],
get: {
operationId: "l",
parameters: [{ name: "v", in: "query", schema: { type: "string" } }],
responses: { "200": { description: "ok" } },
},
},
},
}
const runtime = CodeMode.make({ tools: { api: OpenAPI.fromSpec({ spec: layered }).tools } })
expect(runtime.instructions()).toContain("tools.api.l(input: { query?: { v?: string; shared?: string } })")
})
test("missing required query and header parameters fail before auth and network", async () => {
const required = {
openapi: "3.1.0",
info: { title: "Required", version: "1.0.0" },
servers: [{ url: "https://required.example" }],
security: [{ ApiKeyAuth: [] }],
paths: {
"/r": {
get: {
operationId: "r",
parameters: [
{ name: "q", in: "query", required: true, schema: { type: "string" } },
{ name: "X-Trace", in: "header", required: true, schema: { type: "string" } },
],
responses: { "200": { description: "ok" } },
},
},
},
components: { securitySchemes: { ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" } } },
}
const calls: Array<string> = []
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: {
api: OpenAPI.fromSpec({
spec: required,
auth: {
resolve: () => {
calls.push("auth")
return Effect.succeed({ type: "apiKey", value: "secret" } as const)
},
},
}).tools,
},
})
const queryResult = await Effect.runPromise(
runtime.execute("return await tools.api.r({})").pipe(Effect.provide(layer)),
)
const headerResult = await Effect.runPromise(
runtime.execute('return await tools.api.r({ query: { q: "ok" } })').pipe(Effect.provide(layer)),
)
expect(queryResult).toMatchObject({ ok: false })
expect(JSON.stringify(queryResult)).toContain("Missing required query parameter 'q'")
expect(headerResult).toMatchObject({ ok: false })
expect(JSON.stringify(headerResult)).toContain("Missing required header parameter 'X-Trace'")
expect(calls).toHaveLength(0)
expect(requests).toHaveLength(0)
})
test("path parameter values that would retarget the URL are rejected", async () => {
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({ tools: { widgets: OpenAPI.fromSpec({ spec, auth }).tools } })
const result = await Effect.runPromise(
runtime.execute('return await tools.widgets.deleteWidget({ path: { id: ".." } })').pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: false })
expect(JSON.stringify(result)).toContain("Invalid path parameter 'id'")
expect(requests).toHaveLength(0)
})
test("non-absolute server URLs are skipped unless baseUrl overrides them", () => {
const templated = {
openapi: "3.1.0",
info: { title: "T", version: "1" },
servers: [{ url: "https://{region}.example" }],
paths: { "/ping": { get: { operationId: "ping", responses: { "200": { description: "ok" } } } } },
}
const skipped = OpenAPI.fromSpec({ spec: templated })
expect(Object.keys(skipped.tools)).toStrictEqual([])
expect(skipped.skipped[0]!.reason).toContain("not an absolute URL")
const relative = OpenAPI.fromSpec({ spec: { ...templated, servers: [{ url: "/v2" }] } })
expect(relative.skipped[0]!.reason).toContain("not an absolute URL")
const overridden = OpenAPI.fromSpec({ spec: templated, baseUrl: "https://real.example" })
expect(Object.keys(overridden.tools)).toStrictEqual(["ping"])
})
})