diff --git a/packages/codemode/README.md b/packages/codemode/README.md index db9f7f0469..6de6825240 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -163,7 +163,7 @@ import { Effect } from "effect" import { FetchHttpClient } from "effect/unstable/http" const api = OpenAPI.fromSpec({ - spec: await Bun.file("openapi.json").json(), // parsed object or JSON text (no YAML) + spec: await Bun.file("openapi.json").json(), // parsed document (no YAML) auth: { resolve: ({ schemeName, scopes, operation }) => schemeName === "BearerAuth" @@ -176,11 +176,9 @@ const runtime = CodeMode.make({ tools: { opencode: api.tools } }) const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer))) ``` -`fromSpec` is synchronous and returns `{ tools, skipped }`. It throws when given JSON text that does not parse to an object; operations it cannot represent (non-JSON request bodies, unresolved server URL templates) are reported in `skipped` with a reason instead of producing broken tools. See the `Options` and `AuthResolver` docstrings in `src/openapi.ts` for the full option and auth semantics; the essentials: +`fromSpec` is synchronous and returns `{ tools, skipped }`; operations it cannot represent (non-JSON request bodies, non-absolute server URLs) land in `skipped` with a reason instead of producing broken tools. Tool inputs group parameters by location - `{ path, query, headers, body }` - and never include auth. Non-2xx responses become safe tool failures carrying the status and a size-capped body summary, so programs can `catch` and read them. -- Tool inputs group parameters by OpenAPI location - `{ path, query, headers, cookies, body }` - and never include auth. Output schemas come from the first success (2xx/`2XX`) JSON response; `#/components/schemas/*` refs are expanded by the signature renderer. No-content success responses (e.g. 204) resolve to `null`; declared non-JSON responses advertise `unknown` and return the raw body. Non-2xx responses become safe tool failures carrying the status and a size-capped body summary, so programs can `catch` and read them. -- Auth follows OpenAPI `security` semantics (root default, operation override, `security: []` = unauthenticated, OR across requirements, AND within one). `auth.resolve` supplies credential material per scheme per invocation; the apiKey carrier comes from the scheme declaration, never from the host. Returning `undefined` tries the next alternative; failing aborts the call; two credentials colliding on one carrier fail clearly. Credential storage, OAuth flows, and token refresh stay host-side behind `resolve`. -- `headers` adds static host headers (not model-visible; declared header parameters may override them, auth always wins). Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. +Auth follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the adapter. See the `Options` and `AuthResolver` docstrings in `src/openapi.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. ## Discovery diff --git a/packages/codemode/src/openapi.ts b/packages/codemode/src/openapi.ts index c15ea549f3..b774dd630d 100644 --- a/packages/codemode/src/openapi.ts +++ b/packages/codemode/src/openapi.ts @@ -3,7 +3,7 @@ import { HttpClient, HttpClientRequest, type HttpMethod } from "effect/unstable/ import { ToolError, toolError } from "./tool-error.js" import { Tool, type Definition, type JsonSchema } from "./tool.js" -/** A parsed OpenAPI 3.x document. */ +/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */ export type Document = Record /** The operation identity handed to auth resolution and errors. */ @@ -29,8 +29,8 @@ export type SecurityScheme = { /** * Credential material returned by a host auth resolver. The carrier for `apiKey` - * comes from the scheme definition, not the credential, so a host cannot place a - * key on the wrong carrier. `header` is the escape hatch for nonstandard schemes. + * comes from the scheme definition, not the credential. `header` is the escape + * hatch for nonstandard schemes. */ export type Credential = | { readonly type: "bearer"; readonly token: string } @@ -40,9 +40,8 @@ export type Credential = /** * Resolves credential material for one named security scheme at call time. - * `undefined` means "this scheme is unavailable, try the next OR alternative"; - * a failure aborts the tool call (an expired refresh token must not silently - * fall through to an unauthenticated alternative). + * `undefined` means unavailable, try the next OR alternative; a failure aborts + * the call rather than falling through. */ export type AuthResolver = (context: { readonly schemeName: string @@ -52,22 +51,12 @@ export type AuthResolver = (context: { }) => Effect.Effect export type Options = { - /** Parsed OpenAPI document or JSON text. YAML must be parsed by the host. */ - readonly spec: Document | string - /** - * Overrides the spec's `servers` (of which only the first entry is used). - * Required when the spec declares no absolute server URL. - */ + readonly spec: Document + /** Overrides the spec's `servers` (only the first entry is used). Required when the spec has no absolute server URL. */ readonly baseUrl?: string | undefined - /** Values for templated server URL variables; spec defaults apply when omitted. */ - readonly serverVariables?: Readonly> | undefined /** Host credential resolution, keyed by security scheme name. */ readonly auth?: { readonly resolve: AuthResolver } | undefined - /** - * Static headers applied to every request. Not model-visible, but a - * spec-declared header parameter with the same name may override the value; - * auth headers always win over both. - */ + /** Static headers on every request. Not model-visible; declared header params may override them, auth always wins. */ readonly headers?: Readonly> | undefined /** Curate which operations become tools. Defaults to all. */ readonly operations?: ((operation: Operation) => boolean) | undefined @@ -80,7 +69,7 @@ export type Skipped = { readonly reason: string } -/** Internal marker for "this operation/URL cannot be represented; report in `skipped`". */ +/** Unrepresentable; reported in `skipped`. */ type Skip = { readonly reason: string } export type Tools = { readonly [name: string]: Definition } @@ -92,9 +81,10 @@ export type Result = { } const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]) -const parameterLocations = new Set(["path", "query", "header", "cookie"]) +const parameterLocations = new Set(["path", "query", "header"]) +// OpenAPI: header parameters with these names SHALL be ignored. +const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"]) const schemeTypes = new Set(["apiKey", "http", "oauth2", "openIdConnect"]) -/** Upstream failure bodies are summarized for the model, capped to keep context small. */ const maxErrorBodyChars = 1_024 const isRecord = (value: unknown): value is Record => @@ -106,25 +96,19 @@ const nonEmptyString = (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined /** - * Builds a CodeMode tool subtree from an OpenAPI 3.x document. One tool per - * operation, named from `operationId` (sanitized) or `method_path`. Auth is - * never part of the model-visible input: the adapter reads each operation's - * effective `security`, asks `auth.resolve` for credentials by scheme name, and - * injects them into the carrier the scheme declares. Generated tools require - * `HttpClient.HttpClient` from the Effect environment; the host provides the - * transport layer (e.g. `FetchHttpClient.layer`). - * - * Throws when `spec` is JSON text that does not parse to an object. Operations - * that cannot be represented (non-JSON request bodies, unresolved server - * templates) are reported in `skipped` instead of producing broken tools. + * Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per + * operation. Auth is never model-visible: credentials come from `auth.resolve` + * per the operation's effective `security` and are injected into the carrier + * the scheme declares. Generated tools require `HttpClient.HttpClient` from the + * Effect environment. Unrepresentable operations land in `skipped`. */ export const fromSpec = (options: Options): Result => { - const document = parseDocument(options.spec) + const document = options.spec const schemes = securitySchemes(document) const defaultSecurity = securityRequirements(document.security) const definitions = componentDefinitions(document) const paths = isRecord(document.paths) ? document.paths : {} - const base = options.baseUrl ?? specServerUrl(document, options.serverVariables ?? {}) + const base = options.baseUrl ?? specServerUrl(document) const used = new Set() const skipped: Array = [] const tools: Record> = {} @@ -185,47 +169,31 @@ export const OpenAPI = { fromSpec } const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) -const parseDocument = (spec: Document | string): Document => { - if (typeof spec !== "string") return spec - const parsed = decodeJson(spec) - if (Option.isNone(parsed) || !isRecord(parsed.value)) throw new Error("OpenAPI spec must be a JSON object.") - return parsed.value -} - -const pointerSegment = (segment: string) => segment.replaceAll("~1", "/").replaceAll("~0", "~") - -const resolvePointer = (document: Document, ref: string): unknown => { - if (!ref.startsWith("#/")) return undefined - return ref - .slice(2) - .split("/") - .map(pointerSegment) - .reduce((value, segment) => (isRecord(value) ? value[segment] : undefined), document) -} - /** Resolves a top-level `$ref` on parameter/requestBody/response objects. */ const resolve = (document: Document, value: unknown): unknown => { if (!isRecord(value)) return value const ref = nonEmptyString(value.$ref) - if (ref === undefined) return value - return resolvePointer(document, ref) ?? value + if (ref === undefined || !ref.startsWith("#/")) return value + const target = ref + .slice(2) + .split("/") + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")) + .reduce((current, segment) => (isRecord(current) ? current[segment] : undefined), document) + return target ?? value } // --------------------------------------------------------------------------- -// Schema projection - OpenAPI schema objects to render-only JsonSchema with -// `#/components/schemas/X` refs rewritten to `#/$defs/X` (the only ref form the -// signature renderer resolves). +// Schema projection // --------------------------------------------------------------------------- -const rewriteRef = (ref: string): string => { - const name = ref.match(/^#\/components\/schemas\/(.+)$/)?.[1] - return name === undefined ? ref : `#/$defs/${name}` -} - const projectSchema = (value: unknown, depth = 0): JsonSchema => { if (depth > 24 || !isRecord(value)) return {} const ref = nonEmptyString(value.$ref) - if (ref !== undefined) return { $ref: rewriteRef(ref) } + if (ref !== undefined) { + // `#/components/schemas/X` becomes `#/$defs/X`, the only ref form the signature renderer resolves. + const name = ref.match(/^#\/components\/schemas\/(.+)$/)?.[1] + return { $ref: name === undefined ? ref : `#/$defs/${name.replaceAll("~1", "/").replaceAll("~0", "~")}` } + } const type = Array.isArray(value.type) ? value.type.filter((item): item is string => typeof item === "string") @@ -283,7 +251,7 @@ const withDefinitions = (schema: JsonSchema, definitions: Readonly { return normalized === "application/json" || normalized.endsWith("+json") } -/** The schema of the JSON media-type entry in an OpenAPI `content` record, if declared. */ const jsonContentSchema = (content: Record): unknown => { const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType)) return entry !== undefined && isRecord(entry[1]) ? entry[1].schema : undefined @@ -357,7 +324,6 @@ const inputSchema = ( { name: "path", location: "path" }, { name: "query", location: "query" }, { name: "headers", location: "header" }, - { name: "cookies", location: "cookie" }, ] const grouped = groups.flatMap((group) => { const items = parameters.filter((parameter) => parameter.location === group.location) @@ -378,10 +344,7 @@ const inputSchema = ( ...grouped.filter((group) => group.required).map((group) => group.name), ...(body?.required === true ? ["body"] : []), ] - return withDefinitions( - { type: "object", properties, ...(required.length === 0 ? {} : { required }) }, - definitions, - ) + return withDefinitions({ type: "object", properties, ...(required.length === 0 ? {} : { required }) }, definitions) } const outputSchema = ( @@ -391,29 +354,23 @@ const outputSchema = ( ): JsonSchema | undefined => { if (!isRecord(operation.responses)) return undefined const entries = Object.entries(operation.responses) - // Literal 2xx codes, then the 2XX wildcard range. `default` typically - // describes errors, so it is consulted only when no success response exists. const successes = [ ...entries.filter(([status]) => /^2\d\d$/.test(status)).sort(([a], [b]) => a.localeCompare(b)), ...entries.filter(([status]) => status.toUpperCase() === "2XX"), ] - const candidates = (successes.length > 0 ? successes : entries.filter(([status]) => status === "default")) .map(([, ref]) => resolve(document, ref)) .filter(isRecord) - // The first candidate declaring a JSON schema wins, even when an earlier - // sibling has no content (e.g. "200": no content plus "201": JSON). - for (const response of candidates) { + for (const response of successes) { const schema = jsonContentSchema(isRecord(response.content) ? response.content : {}) if (schema !== undefined) return withDefinitions(projectSchema(schema), definitions) } - // Declared content without a usable JSON schema (e.g. text/plain): the tool - // returns the raw body, so advertise unknown rather than a wrong null. - const declaresContent = candidates.some( + // Declared non-JSON content (e.g. text/plain) returns the raw body -> unknown. + const declaresContent = successes.some( (response) => isRecord(response.content) && Object.keys(response.content).length > 0, ) if (declaresContent) return undefined - // Only no-content success responses (e.g. 204) remain: the tool resolves to null. - return candidates.length > 0 ? { type: "null" } : undefined + // No-content success (e.g. 204) -> null. + return successes.length > 0 ? { type: "null" } : undefined } // --------------------------------------------------------------------------- @@ -437,27 +394,15 @@ const operationName = ( return next(2) } -const specServerUrl = (document: Document, variables: Readonly>): string | Skip => { +const specServerUrl = (document: Document): string | Skip => { const server = asArray(document.servers).find(isRecord) const url = server === undefined ? undefined : nonEmptyString(server.url) if (url === undefined) return { reason: "spec declares no servers; pass baseUrl" } - const defaults = isRecord(server?.variables) ? server.variables : {} - const substituted = url.replaceAll(/\{([^{}]+)\}/g, (whole, name: string) => { - const explicit = variables[name] - if (explicit !== undefined) return explicit - const declared = defaults[name] - return (isRecord(declared) ? nonEmptyString(declared.default) : undefined) ?? whole - }) - if (/\{[^{}]+\}/.test(substituted)) { - return { reason: `server URL has unresolved variables: ${url}; pass baseUrl or serverVariables` } + // Templated or relative server URLs cannot be resolved by the adapter. + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || /\{[^{}]+\}/.test(url)) { + return { reason: `server URL '${url}' is not an absolute URL; pass baseUrl` } } - // OpenAPI allows relative server URLs (resolved against the document's own - // location), which the adapter cannot resolve; skip instead of generating - // tools whose every call fails with a transport error. - if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(substituted)) { - return { reason: `spec declares a relative server URL '${substituted}'; pass baseUrl` } - } - return substituted + return url } // --------------------------------------------------------------------------- @@ -530,26 +475,12 @@ type AppliedAuth = { const invoke = (plan: Plan, input: unknown): Effect.Effect => Effect.gen(function* () { const value = isRecord(input) ? input : {} - const path = isRecord(value.path) ? value.path : {} const query = isRecord(value.query) ? value.query : {} const headers = isRecord(value.headers) ? value.headers : {} - const cookies = isRecord(value.cookies) ? value.cookies : {} - // Cheap local validation runs before auth resolution so an unsendable call - // never triggers credential work (e.g. a token refresh). - const url = buildUrl(plan, path) + // Local validation before auth resolution, which may refresh tokens. + const url = buildUrl(plan, isRecord(value.path) ? value.path : {}) if (url instanceof ToolError) return yield* Effect.fail(url) - const hostHeaders = new Set(Object.keys(plan.headers).map((name) => name.toLowerCase())) - for (const parameter of plan.parameters) { - if (parameter.location === "path" || !parameter.required) continue - // A required header parameter is satisfied by a static host header. - if (parameter.location === "header" && hostHeaders.has(parameter.name.toLowerCase())) continue - const group = parameter.location === "query" ? query : parameter.location === "header" ? headers : cookies - const item = group[parameter.name] - if (item === undefined || item === null) { - return yield* Effect.fail(toolError(`Missing required ${parameter.location} parameter '${parameter.name}'.`)) - } - } if (plan.body?.required === true && value.body === undefined) { return yield* Effect.fail(toolError("Missing required request body.")) } @@ -561,8 +492,9 @@ const invoke = (plan: Plan, input: unknown): Effect.Effect => Effect.gen(function* () { @@ -660,11 +570,29 @@ const resolveAuth = (plan: Plan): Effect.Effect => if (plan.security.length === 0) return none const unavailable: Array = [] - for (const requirement of plan.security) { - if (Object.keys(requirement).length === 0) return none - const credentials = yield* collectCredentials(plan, requirement, unavailable) - if (credentials === undefined) continue - const applied = applyCredentials(plan.operation, credentials) + alternatives: for (const requirement of plan.security) { + const names = Object.keys(requirement) + if (names.length === 0) return none + const credentials: Array = [] + for (const name of names) { + const scheme = plan.schemes[name] + if (scheme === undefined || plan.auth === undefined) { + unavailable.push(name) + continue alternatives + } + const credential = yield* plan.auth.resolve({ + schemeName: name, + scheme, + scopes: requirement[name] ?? [], + operation: plan.operation, + }) + if (credential === undefined) { + unavailable.push(name) + continue alternatives + } + credentials.push([scheme, credential]) + } + const applied = applyCredentials(credentials) return applied instanceof ToolError ? yield* Effect.fail(applied) : applied } @@ -675,61 +603,25 @@ const resolveAuth = (plan: Plan): Effect.Effect => ) }) -/** - * Resolves every scheme in one AND requirement. Returns `undefined` when a - * scheme is unknown or its credential is unavailable (recording the name in - * `unavailable`), so the caller can try the next OR alternative. - */ -const collectCredentials = (plan: Plan, requirement: SecurityRequirement, unavailable: Array) => - Effect.gen(function* () { - const credentials: Array = [] - for (const name of Object.keys(requirement)) { - const scheme = plan.schemes[name] - if (scheme === undefined || plan.auth === undefined) { - unavailable.push(name) - return undefined - } - const credential = yield* plan.auth.resolve({ - schemeName: name, - scheme, - scopes: requirement[name] ?? [], - operation: plan.operation, - }) - if (credential === undefined) { - unavailable.push(name) - return undefined - } - credentials.push([scheme, credential]) - } - return credentials - }) - -const applyCredentials = ( - operation: Operation, - credentials: ReadonlyArray, -): AppliedAuth | ToolError => { +const applyCredentials = (credentials: ReadonlyArray): AppliedAuth | ToolError => { const headers: Record = {} const query: Record = {} const cookies: Record = {} - // Two credentials landing on the same carrier cannot both be sent. - const write = (target: Record, name: string, value: string, carrier: string) => { - if (target[name] !== undefined) { - return toolError( - `${operation.method} ${operation.path} security requires two credentials on the '${name}' ${carrier}; this cannot be satisfied.`, - ) + for (const [scheme, credential] of credentials) { + if (credential.type === "bearer") { + headers["authorization"] = `Bearer ${credential.token}` + continue } - target[name] = value - return undefined - } - const setHeader = (name: string, value: string) => write(headers, name.toLowerCase(), value, "header") - const writeCredential = (scheme: SecurityScheme, credential: Credential): ToolError | undefined => { - if (credential.type === "bearer") return setHeader("Authorization", `Bearer ${credential.token}`) if (credential.type === "basic") { // Buffer instead of btoa: btoa throws on non-Latin-1 credentials. - const encoded = Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64") - return setHeader("Authorization", `Basic ${encoded}`) + headers["authorization"] = + `Basic ${Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64")}` + continue + } + if (credential.type === "header") { + headers[credential.name.toLowerCase()] = credential.value + continue } - if (credential.type === "header") return setHeader(credential.name, credential.value) // apiKey: the carrier comes from the scheme declaration. const name = scheme.parameterName if (scheme.type !== "apiKey" || name === undefined || scheme.in === undefined) { @@ -737,14 +629,9 @@ const applyCredentials = ( `Security scheme '${scheme.name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`, ) } - if (scheme.in === "header") return setHeader(name, credential.value) - if (scheme.in === "query") return write(query, name, credential.value, "query parameter") - return write(cookies, name, credential.value, "cookie") - } - - for (const [scheme, credential] of credentials) { - const failure = writeCredential(scheme, credential) - if (failure !== undefined) return failure + if (scheme.in === "header") headers[name.toLowerCase()] = credential.value + if (scheme.in === "query") query[name] = credential.value + if (scheme.in === "cookie") cookies[name] = credential.value } return { headers, query, cookies } } diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index 30c9e6534b..8cf071184e 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -169,7 +169,13 @@ const renderSchema = ( } return alternatives.map((item) => renderSchema(item, ctx, depth + 1, seen)).join(" | ") } - if (schema.allOf) return schema.allOf.map((item) => renderSchema(item, ctx, depth + 1, seen)).join(" & ") + if (schema.allOf) { + // Parenthesize union members so `A & (B | null)` does not render as `A & B | null`. + return schema.allOf + .map((item) => renderSchema(item, ctx, depth + 1, seen)) + .map((rendered) => (rendered.includes(" | ") ? `(${rendered})` : rendered)) + .join(" & ") + } if (Array.isArray(schema.type)) { return schema.type.map((item) => renderSchema({ type: item }, ctx, depth + 1, seen)).join(" | ") } diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index eb91dc6c6c..bde928b740 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -82,16 +82,7 @@ const spec = { 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" }, - }, - }, - }, + OAuth2: { type: "oauth2" }, }, schemas: { Widget: { @@ -110,7 +101,6 @@ type Recorded = { readonly body: unknown } -/** Test transport: records every request and returns a canned response. */ const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => { const requests: Array = [] const layer = Layer.succeed(HttpClient.HttpClient)( @@ -151,8 +141,8 @@ 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)" }, + expect(result.skipped).toMatchObject([ + { method: "POST", path: "/upload", reason: expect.stringContaining("multipart/form-data") }, ]) }) @@ -177,7 +167,7 @@ describe("OpenAPI.fromSpec", () => { 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)), + 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" }] } }) @@ -220,6 +210,19 @@ describe("OpenAPI.fromSpec", () => { 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 } }) @@ -245,21 +248,16 @@ describe("OpenAPI.fromSpec", () => { 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, - }, + 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)), + 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." }, + error: { kind: "ToolFailure", message: expect.stringContaining("ApiKeyAuth") }, }) expect(requests).toHaveLength(0) }) @@ -276,7 +274,7 @@ describe("OpenAPI.fromSpec", () => { }) const result = await Effect.runPromise( - runtime.execute('return await tools.widgets.listWidgets({})').pipe(Effect.provide(layer)), + runtime.execute("return await tools.widgets.listWidgets({})").pipe(Effect.provide(layer)), ) expect(result).toMatchObject({ ok: false, error: { kind: "ToolFailure", message: "token refresh failed" } }) @@ -348,11 +346,11 @@ describe("OpenAPI.fromSpec", () => { expect(requests[1]!.headers["cookie"]).toBe("session=secret") }) - test("2XX wildcard responses and error-only responses advertise the right output", () => { - const ranges = { + test("output schemas follow response declarations", () => { + const responses = { openapi: "3.1.0", - info: { title: "Ranges", version: "1.0.0" }, - servers: [{ url: "https://ranges.example" }], + info: { title: "Responses", version: "1.0.0" }, + servers: [{ url: "https://responses.example" }], paths: { "/wild": { get: { @@ -365,226 +363,15 @@ describe("OpenAPI.fromSpec", () => { }, }, }, - "/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") - }) - - 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: { + "/errors": { get: { operationId: "errorsOnly", responses: { "404": { description: "missing" } } } }, "/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") - }) - - 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") - }) - - 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 = [] - 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("a static host header satisfies a required header parameter", async () => { - const tenanted = { - openapi: "3.1.0", - info: { title: "Tenanted", version: "1.0.0" }, - servers: [{ url: "https://tenanted.example" }], - paths: { - "/t": { - get: { - operationId: "t", - parameters: [{ name: "X-Tenant", 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: tenanted, headers: { "x-tenant": "acme" } }).tools }, - }) - - const result = await Effect.runPromise(runtime.execute("return await tools.api.t({})").pipe(Effect.provide(layer))) - - expect(result).toMatchObject({ ok: true }) - expect(requests[0]!.headers["x-tenant"]).toBe("acme") - }) - - test("a JSON success sibling wins over an earlier no-content success", () => { - const siblings = { - openapi: "3.1.0", - info: { title: "Siblings", version: "1.0.0" }, - servers: [{ url: "https://siblings.example" }], - paths: { "/thing": { + delete: { operationId: "deleteThing", responses: { "204": { description: "deleted" } } }, post: { operationId: "createThing", responses: { @@ -598,41 +385,30 @@ describe("OpenAPI.fromSpec", () => { }, }, } - const runtime = CodeMode.make({ tools: { api: OpenAPI.fromSpec({ spec: siblings }).tools } }) - expect(runtime.instructions()).toContain("tools.api.createThing(input: {}): Promise<{ id?: 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") + expect(instructions).toContain("tools.api.getText(input: {}): Promise") + expect(instructions).toContain("tools.api.deleteThing(input: {}): Promise") + expect(instructions).toContain("tools.api.createThing(input: {}): Promise<{ id?: string }>") }) - 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", () => { + 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/{version}", variables: { version: { default: "v1" } } }], + servers: [{ url: "https://{region}.example" }], 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 skipped = OpenAPI.fromSpec({ spec: templated }) + expect(Object.keys(skipped.tools)).toStrictEqual([]) + expect(skipped.skipped[0]!.reason).toContain("not an absolute URL") - const resolved = OpenAPI.fromSpec({ spec: templated, serverVariables: { region: "eu" } }) - expect(Object.keys(resolved.tools)).toStrictEqual(["ping"]) - }) + const relative = OpenAPI.fromSpec({ spec: { ...templated, servers: [{ url: "/v2" }] } }) + expect(relative.skipped[0]!.reason).toContain("not an absolute URL") - test("throws on structurally invalid specs", () => { - expect(() => OpenAPI.fromSpec({ spec: "[1,2]" })).toThrow("OpenAPI spec must be a JSON object.") + const overridden = OpenAPI.fromSpec({ spec: templated, baseUrl: "https://real.example" }) + expect(Object.keys(overridden.tools)).toStrictEqual(["ping"]) }) })