589 lines
22 KiB
TypeScript
589 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/openapi.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",
|
|
flows: {
|
|
authorizationCode: {
|
|
authorizationUrl: "https://auth.widgets.dev/authorize",
|
|
tokenUrl: "https://auth.widgets.dev/token",
|
|
scopes: { "widgets:write": "Modify widgets" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
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
|
|
}
|
|
|
|
/** Test transport: records every request and returns a canned response. */
|
|
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).toStrictEqual([
|
|
{ method: "POST", path: "/upload", reason: "request body has no JSON content (declared: 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("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: "GET /widgets requires authentication; no credential available for: 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("2XX wildcard responses and error-only responses advertise the right output", () => {
|
|
const ranges = {
|
|
openapi: "3.1.0",
|
|
info: { title: "Ranges", version: "1.0.0" },
|
|
servers: [{ url: "https://ranges.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" } } },
|
|
},
|
|
},
|
|
}
|
|
const runtime = CodeMode.make({ tools: { api: OpenAPI.fromSpec({ spec: ranges }).tools } })
|
|
const instructions = runtime.instructions()
|
|
expect(instructions).toContain("tools.api.wild(input: {}): Promise<{ ok?: boolean }>")
|
|
expect(instructions).toContain("tools.api.errorsOnly(input: {}): Promise<unknown>")
|
|
})
|
|
|
|
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("declared non-JSON responses advertise unknown instead of null", () => {
|
|
const plain = {
|
|
openapi: "3.1.0",
|
|
info: { title: "Plain", version: "1.0.0" },
|
|
servers: [{ url: "https://plain.example" }],
|
|
paths: {
|
|
"/text": {
|
|
get: {
|
|
operationId: "getText",
|
|
responses: { "200": { description: "ok", content: { "text/plain": { schema: { type: "string" } } } } },
|
|
},
|
|
},
|
|
},
|
|
}
|
|
const runtime = CodeMode.make({ tools: { api: OpenAPI.fromSpec({ spec: plain }).tools } })
|
|
expect(runtime.instructions()).toContain("tools.api.getText(input: {}): Promise<unknown>")
|
|
})
|
|
|
|
test("two credentials colliding on one query parameter fail clearly", async () => {
|
|
const colliding = {
|
|
openapi: "3.1.0",
|
|
info: { title: "Collide", version: "1.0.0" },
|
|
servers: [{ url: "https://collide.example" }],
|
|
paths: {
|
|
"/x": {
|
|
get: {
|
|
operationId: "x",
|
|
security: [{ KeyA: [], KeyB: [] }],
|
|
responses: { "200": { description: "ok" } },
|
|
},
|
|
},
|
|
},
|
|
components: {
|
|
securitySchemes: {
|
|
KeyA: { type: "apiKey", in: "query", name: "api_key" },
|
|
KeyB: { type: "apiKey", in: "query", name: "api_key" },
|
|
},
|
|
},
|
|
}
|
|
const { requests, layer } = recordingClient(() => json({}))
|
|
const runtime = CodeMode.make({
|
|
tools: {
|
|
api: OpenAPI.fromSpec({
|
|
spec: colliding,
|
|
auth: { resolve: () => Effect.succeed({ type: "apiKey", value: "secret" } as const) },
|
|
}).tools,
|
|
},
|
|
})
|
|
|
|
const result = await Effect.runPromise(runtime.execute("return await tools.api.x({})").pipe(Effect.provide(layer)))
|
|
|
|
expect(result).toMatchObject({ ok: false })
|
|
expect(JSON.stringify(result)).toContain("two credentials on the 'api_key' query parameter")
|
|
expect(requests).toHaveLength(0)
|
|
})
|
|
|
|
test("a no-content success response resolves to null even when default declares an error shape", () => {
|
|
const mixed = {
|
|
openapi: "3.1.0",
|
|
info: { title: "Mixed", version: "1.0.0" },
|
|
servers: [{ url: "https://mixed.example" }],
|
|
paths: {
|
|
"/thing": {
|
|
delete: {
|
|
operationId: "deleteThing",
|
|
responses: {
|
|
"204": { description: "deleted" },
|
|
default: {
|
|
description: "error",
|
|
content: {
|
|
"application/json": { schema: { type: "object", properties: { message: { type: "string" } } } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
const runtime = CodeMode.make({ tools: { api: OpenAPI.fromSpec({ spec: mixed }).tools } })
|
|
expect(runtime.instructions()).toContain("tools.api.deleteThing(input: {}): Promise<null>")
|
|
})
|
|
|
|
test("auth cookies shadow model cookie parameters and model values are encoded", async () => {
|
|
const cookieSpec = {
|
|
openapi: "3.1.0",
|
|
info: { title: "Cookies", version: "1.0.0" },
|
|
servers: [{ url: "https://cookies.example" }],
|
|
paths: {
|
|
"/c": {
|
|
get: {
|
|
operationId: "c",
|
|
security: [{ CookieKey: [] }],
|
|
parameters: [
|
|
{ name: "session", in: "cookie", schema: { type: "string" } },
|
|
{ name: "theme", in: "cookie", schema: { type: "string" } },
|
|
],
|
|
responses: { "200": { description: "ok" } },
|
|
},
|
|
},
|
|
},
|
|
components: { securitySchemes: { CookieKey: { type: "apiKey", in: "cookie", name: "session" } } },
|
|
}
|
|
const { requests, layer } = recordingClient(() => json({}))
|
|
const runtime = CodeMode.make({
|
|
tools: {
|
|
api: OpenAPI.fromSpec({
|
|
spec: cookieSpec,
|
|
auth: { resolve: () => Effect.succeed({ type: "apiKey", value: "real" } as const) },
|
|
}).tools,
|
|
},
|
|
})
|
|
|
|
const result = await Effect.runPromise(
|
|
runtime
|
|
.execute(`return await tools.api.c({ cookies: { session: "forged", theme: "dark; session=forged" } })`)
|
|
.pipe(Effect.provide(layer)),
|
|
)
|
|
|
|
expect(result).toMatchObject({ ok: true })
|
|
expect(requests[0]!.headers["cookie"]).toBe("session=real; theme=dark%3B%20session%3Dforged")
|
|
})
|
|
|
|
test("missing required non-path parameters fail locally before auth or network", async () => {
|
|
const strict = {
|
|
openapi: "3.1.0",
|
|
info: { title: "Strict", version: "1.0.0" },
|
|
servers: [{ url: "https://strict.example" }],
|
|
security: [{ Key: [] }],
|
|
paths: {
|
|
"/s": {
|
|
get: {
|
|
operationId: "s",
|
|
parameters: [{ name: "tenant", in: "query", required: true, schema: { type: "string" } }],
|
|
responses: { "200": { description: "ok" } },
|
|
},
|
|
},
|
|
},
|
|
components: { securitySchemes: { Key: { type: "apiKey", in: "header", name: "X-Key" } } },
|
|
}
|
|
const { requests, layer } = recordingClient(() => json({}))
|
|
const resolved: Array<string> = []
|
|
const runtime = CodeMode.make({
|
|
tools: {
|
|
api: OpenAPI.fromSpec({
|
|
spec: strict,
|
|
auth: {
|
|
resolve: ({ schemeName }) => {
|
|
resolved.push(schemeName)
|
|
return Effect.succeed({ type: "apiKey", value: "k" } as const)
|
|
},
|
|
},
|
|
}).tools,
|
|
},
|
|
})
|
|
|
|
const result = await Effect.runPromise(runtime.execute("return await tools.api.s({})").pipe(Effect.provide(layer)))
|
|
|
|
expect(result).toMatchObject({ ok: false })
|
|
expect(JSON.stringify(result)).toContain("Missing required query parameter 'tenant'")
|
|
expect(resolved).toHaveLength(0)
|
|
expect(requests).toHaveLength(0)
|
|
})
|
|
|
|
test("relative server URLs are skipped with a clear reason", () => {
|
|
const relative = {
|
|
openapi: "3.1.0",
|
|
info: { title: "Relative", version: "1.0.0" },
|
|
servers: [{ url: "/v2" }],
|
|
paths: { "/ping": { get: { operationId: "ping", responses: { "200": { description: "ok" } } } } },
|
|
}
|
|
const result = OpenAPI.fromSpec({ spec: relative })
|
|
expect(Object.keys(result.tools)).toStrictEqual([])
|
|
expect(result.skipped[0]!.reason).toContain("relative server URL")
|
|
|
|
const overridden = OpenAPI.fromSpec({ spec: relative, baseUrl: "https://real.example" })
|
|
expect(Object.keys(overridden.tools)).toStrictEqual(["ping"])
|
|
})
|
|
|
|
test("spec server variables substitute defaults and explicit values", () => {
|
|
const templated = {
|
|
openapi: "3.1.0",
|
|
info: { title: "T", version: "1" },
|
|
servers: [{ url: "https://{region}.example/{version}", variables: { version: { default: "v1" } } }],
|
|
paths: { "/ping": { get: { operationId: "ping", responses: { "200": { description: "ok" } } } } },
|
|
}
|
|
const unresolved = OpenAPI.fromSpec({ spec: templated })
|
|
expect(Object.keys(unresolved.tools)).toStrictEqual([])
|
|
expect(unresolved.skipped[0]!.reason).toContain("unresolved variables")
|
|
|
|
const resolved = OpenAPI.fromSpec({ spec: templated, serverVariables: { region: "eu" } })
|
|
expect(Object.keys(resolved.tools)).toStrictEqual(["ping"])
|
|
})
|
|
|
|
test("throws on structurally invalid specs", () => {
|
|
expect(() => OpenAPI.fromSpec({ spec: "[1,2]" })).toThrow("OpenAPI spec must be a JSON object.")
|
|
})
|
|
})
|