From 34c65b71e69a930b63ec610f3ec9ca8db0e07b5c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 3 Jul 2026 15:29:02 -0500 Subject: [PATCH] refactor(codemode): simplify OpenAPI adapter split --- .../codemode/src/adapters/openapi/auth.ts | 76 ------------- .../codemode/src/adapters/openapi/index.ts | 8 +- .../openapi/{invoke.ts => runtime.ts} | 77 ++++++++++++- .../codemode/src/adapters/openapi/schema.ts | 75 ------------- .../codemode/src/adapters/openapi/shared.ts | 21 ---- .../codemode/src/adapters/openapi/spec.ts | 102 ++++++++++++++++-- 6 files changed, 171 insertions(+), 188 deletions(-) delete mode 100644 packages/codemode/src/adapters/openapi/auth.ts rename packages/codemode/src/adapters/openapi/{invoke.ts => runtime.ts} (63%) delete mode 100644 packages/codemode/src/adapters/openapi/schema.ts delete mode 100644 packages/codemode/src/adapters/openapi/shared.ts diff --git a/packages/codemode/src/adapters/openapi/auth.ts b/packages/codemode/src/adapters/openapi/auth.ts deleted file mode 100644 index 64cdb8cb1c..0000000000 --- a/packages/codemode/src/adapters/openapi/auth.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Effect } from "effect" -import { ToolError, toolError } from "../../tool-error.js" -import { own } from "./shared.js" -import type { AppliedAuth, Credential, Plan, SecurityScheme } from "./types.js" - -export const resolveAuth = (plan: Plan): Effect.Effect => - Effect.gen(function* () { - const none: AppliedAuth = { headers: {}, query: {}, cookies: {} } - if (plan.security.length === 0) return none - - const unavailable: Array = [] - 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 = own(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 - } - - return yield* Effect.fail( - toolError( - `${plan.operation.method} ${plan.operation.path} requires authentication; no credential available for: ${[...new Set(unavailable)].join(", ")}.`, - ), - ) - }) - -const applyCredentials = (credentials: ReadonlyArray): AppliedAuth | ToolError => { - const headers: Record = {} - const query: Record = {} - const cookies: Record = {} - for (const [scheme, credential] of credentials) { - if (credential.type === "bearer") { - headers["authorization"] = `Bearer ${credential.token}` - continue - } - if (credential.type === "basic") { - // Buffer instead of btoa: btoa throws on non-Latin-1 credentials. - headers["authorization"] = - `Basic ${Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64")}` - continue - } - if (credential.type === "header") { - headers[credential.name.toLowerCase()] = credential.value - continue - } - // apiKey: the carrier comes from the scheme declaration. - const name = scheme.parameterName - if (scheme.type !== "apiKey" || name === undefined || scheme.in === undefined) { - return toolError( - `Security scheme '${scheme.name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`, - ) - } - 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/adapters/openapi/index.ts b/packages/codemode/src/adapters/openapi/index.ts index 29015b8a81..8f1b3a31ee 100644 --- a/packages/codemode/src/adapters/openapi/index.ts +++ b/packages/codemode/src/adapters/openapi/index.ts @@ -1,10 +1,12 @@ import { HttpClient } from "effect/unstable/http" import { Tool, type Definition } from "../../tool.js" -import { invoke } from "./invoke.js" -import { componentDefinitions } from "./schema.js" -import { isRecord, methods, nonEmptyString } from "./shared.js" +import { invoke } from "./runtime.js" import { + componentDefinitions, inputSchema, + isRecord, + methods, + nonEmptyString, operationName, operationParameters, outputSchema, diff --git a/packages/codemode/src/adapters/openapi/invoke.ts b/packages/codemode/src/adapters/openapi/runtime.ts similarity index 63% rename from packages/codemode/src/adapters/openapi/invoke.ts rename to packages/codemode/src/adapters/openapi/runtime.ts index eb6d8539c2..9f623d0290 100644 --- a/packages/codemode/src/adapters/openapi/invoke.ts +++ b/packages/codemode/src/adapters/openapi/runtime.ts @@ -1,9 +1,8 @@ import { Effect, Option, Schema } from "effect" import { HttpClient, HttpClientRequest, type HttpMethod } from "effect/unstable/http" import { ToolError, toolError } from "../../tool-error.js" -import { resolveAuth } from "./auth.js" -import { isRecord, maxErrorBodyChars, own } from "./shared.js" -import type { Plan } from "./types.js" +import { isRecord, maxErrorBodyChars, own } from "./spec.js" +import type { AppliedAuth, Credential, Plan, SecurityScheme } from "./types.js" const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) @@ -79,6 +78,78 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect => + Effect.gen(function* () { + const none: AppliedAuth = { headers: {}, query: {}, cookies: {} } + if (plan.security.length === 0) return none + + const unavailable: Array = [] + 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 = own(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 + } + + return yield* Effect.fail( + toolError( + `${plan.operation.method} ${plan.operation.path} requires authentication; no credential available for: ${[...new Set(unavailable)].join(", ")}.`, + ), + ) + }) + +const applyCredentials = (credentials: ReadonlyArray): AppliedAuth | ToolError => { + const headers: Record = {} + const query: Record = {} + const cookies: Record = {} + for (const [scheme, credential] of credentials) { + if (credential.type === "bearer") { + headers["authorization"] = `Bearer ${credential.token}` + continue + } + if (credential.type === "basic") { + // Buffer instead of btoa: btoa throws on non-Latin-1 credentials. + headers["authorization"] = + `Basic ${Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64")}` + continue + } + if (credential.type === "header") { + headers[credential.name.toLowerCase()] = credential.value + continue + } + // apiKey: the carrier comes from the scheme declaration. + const name = scheme.parameterName + if (scheme.type !== "apiKey" || name === undefined || scheme.in === undefined) { + return toolError( + `Security scheme '${scheme.name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`, + ) + } + 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 } +} + const summarizeBody = (body: unknown): string => { const rendered = typeof body === "string" ? body : (JSON.stringify(body) ?? "") if (rendered === "" || rendered === "null") return "no response body" diff --git a/packages/codemode/src/adapters/openapi/schema.ts b/packages/codemode/src/adapters/openapi/schema.ts deleted file mode 100644 index 64aa490234..0000000000 --- a/packages/codemode/src/adapters/openapi/schema.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { JsonSchema } from "../../tool.js" -import { isRecord, nonEmptyString } from "./shared.js" -import type { Document } from "./types.js" - -export const projectSchema = (value: unknown, depth = 0): JsonSchema => { - if (depth > 24 || !isRecord(value)) return {} - const ref = nonEmptyString(value.$ref) - if (ref !== undefined) { - // `#/components/schemas/X` becomes `#/$defs/X`, the only ref form the - // signature renderer resolves. `~` is unescaped to match the `$defs` key; - // `/` must stay escaped because the renderer takes the last `/` segment. - const name = ref.match(/^#\/components\/schemas\/(.+)$/)?.[1] - return { $ref: name === undefined ? ref : `#/$defs/${name.replaceAll("~0", "~")}` } - } - - const type = Array.isArray(value.type) - ? value.type.filter((item): item is string => typeof item === "string") - : nonEmptyString(value.type) - const description = nonEmptyString(value.description) - const format = nonEmptyString(value.format) - const projected: JsonSchema = { - ...(type === undefined ? {} : { type }), - ...(Array.isArray(value.enum) ? { enum: value.enum } : {}), - ...(value.const === undefined ? {} : { const: value.const }), - ...(Array.isArray(value.anyOf) ? { anyOf: value.anyOf.map((item) => projectSchema(item, depth + 1)) } : {}), - ...(Array.isArray(value.oneOf) ? { oneOf: value.oneOf.map((item) => projectSchema(item, depth + 1)) } : {}), - ...(Array.isArray(value.allOf) ? { allOf: value.allOf.map((item) => projectSchema(item, depth + 1)) } : {}), - ...(isRecord(value.properties) - ? { - properties: Object.fromEntries( - Object.entries(value.properties).map(([key, item]) => [key, projectSchema(item, depth + 1)]), - ), - } - : {}), - ...(Array.isArray(value.required) - ? { required: value.required.filter((item): item is string => typeof item === "string") } - : {}), - ...(isRecord(value.items) ? { items: projectSchema(value.items, depth + 1) } : {}), - ...(typeof value.additionalProperties === "boolean" - ? { additionalProperties: value.additionalProperties } - : isRecord(value.additionalProperties) - ? { additionalProperties: projectSchema(value.additionalProperties, depth + 1) } - : {}), - ...(description === undefined ? {} : { description }), - ...(value.default === undefined ? {} : { default: value.default }), - ...(format === undefined ? {} : { format }), - ...(value.deprecated === true ? { deprecated: true } : {}), - ...(typeof value.minItems === "number" ? { minItems: value.minItems } : {}), - ...(typeof value.maxItems === "number" ? { maxItems: value.maxItems } : {}), - } - // OpenAPI 3.0 nullable -> union with null, matching what 3.1 expresses via type arrays. - if (value.nullable !== true) return projected - if (Array.isArray(projected.type)) return { ...projected, type: [...projected.type, "null"] } - if (typeof projected.type === "string") return { ...projected, type: [projected.type, "null"] } - return { anyOf: [projected, { type: "null" }] } -} - -export const componentDefinitions = (document: Document): Readonly> => { - const components = isRecord(document.components) ? document.components : {} - const schemas = isRecord(components.schemas) ? components.schemas : {} - return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(value)])) -} - -export const withDefinitions = (schema: JsonSchema, definitions: Readonly>): JsonSchema => - Object.keys(definitions).length === 0 ? schema : { ...schema, $defs: definitions } - -export const isJsonMediaType = (mediaType: string): boolean => { - const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? "" - return normalized === "application/json" || normalized.endsWith("+json") -} - -export const jsonContentSchema = (content: Record): unknown => { - const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType)) - return entry !== undefined && isRecord(entry[1]) ? entry[1].schema : undefined -} diff --git a/packages/codemode/src/adapters/openapi/shared.ts b/packages/codemode/src/adapters/openapi/shared.ts deleted file mode 100644 index d4d63cb1ca..0000000000 --- a/packages/codemode/src/adapters/openapi/shared.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]) -export const parameterLocations = new Set(["path", "query", "header"]) - -// OpenAPI: header parameters with these names SHALL be ignored. -export const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"]) -export const schemeTypes = new Set(["apiKey", "http", "oauth2", "openIdConnect"]) -export const blockedOperationNames = new Set(["__proto__", "constructor", "prototype"]) -export const maxErrorBodyChars = 1_024 - -export const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - -export const asArray = (value: unknown): ReadonlyArray => (Array.isArray(value) ? value : []) - -export const nonEmptyString = (value: unknown): string | undefined => - typeof value === "string" && value !== "" ? value : undefined - -// Guards record lookups keyed by spec- or model-controlled names against -// prototype-inherited values (e.g. a parameter named `toString`). -export const own = (record: Readonly>, key: string): T | undefined => - Object.hasOwn(record, key) ? record[key] : undefined diff --git a/packages/codemode/src/adapters/openapi/spec.ts b/packages/codemode/src/adapters/openapi/spec.ts index e7472e5fb2..fa4036de93 100644 --- a/packages/codemode/src/adapters/openapi/spec.ts +++ b/packages/codemode/src/adapters/openapi/spec.ts @@ -1,16 +1,26 @@ import type { JsonSchema } from "../../tool.js" -import { - asArray, - blockedOperationNames, - ignoredHeaderParameters, - isRecord, - nonEmptyString, - parameterLocations, - schemeTypes, -} from "./shared.js" -import { isJsonMediaType, jsonContentSchema, projectSchema, withDefinitions } from "./schema.js" import type { Body, Document, Parameter, ParameterLocation, SecurityRequirement, SecurityScheme, Skip } from "./types.js" +export const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]) +const parameterLocations = new Set(["path", "query", "header"]) +const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"]) +const schemeTypes = new Set(["apiKey", "http", "oauth2", "openIdConnect"]) +const blockedOperationNames = new Set(["__proto__", "constructor", "prototype"]) +export const maxErrorBodyChars = 1_024 + +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const asArray = (value: unknown): ReadonlyArray => (Array.isArray(value) ? value : []) + +export const nonEmptyString = (value: unknown): string | undefined => + typeof value === "string" && value !== "" ? value : undefined + +// Guards record lookups keyed by spec- or model-controlled names against +// prototype-inherited values (e.g. a parameter named `toString`). +export const own = (record: Readonly>, key: string): T | undefined => + Object.hasOwn(record, key) ? record[key] : undefined + export const resolve = (document: Document, value: unknown): unknown => { if (!isRecord(value)) return value const ref = nonEmptyString(value.$ref) @@ -23,6 +33,78 @@ export const resolve = (document: Document, value: unknown): unknown => { return target ?? value } +const projectSchema = (value: unknown, depth = 0): JsonSchema => { + if (depth > 24 || !isRecord(value)) return {} + const ref = nonEmptyString(value.$ref) + if (ref !== undefined) { + // `#/components/schemas/X` becomes `#/$defs/X`, the only ref form the + // signature renderer resolves. `~` is unescaped to match the `$defs` key; + // `/` must stay escaped because the renderer takes the last `/` segment. + const name = ref.match(/^#\/components\/schemas\/(.+)$/)?.[1] + return { $ref: name === undefined ? ref : `#/$defs/${name.replaceAll("~0", "~")}` } + } + + const type = Array.isArray(value.type) + ? value.type.filter((item): item is string => typeof item === "string") + : nonEmptyString(value.type) + const description = nonEmptyString(value.description) + const format = nonEmptyString(value.format) + const projected: JsonSchema = { + ...(type === undefined ? {} : { type }), + ...(Array.isArray(value.enum) ? { enum: value.enum } : {}), + ...(value.const === undefined ? {} : { const: value.const }), + ...(Array.isArray(value.anyOf) ? { anyOf: value.anyOf.map((item) => projectSchema(item, depth + 1)) } : {}), + ...(Array.isArray(value.oneOf) ? { oneOf: value.oneOf.map((item) => projectSchema(item, depth + 1)) } : {}), + ...(Array.isArray(value.allOf) ? { allOf: value.allOf.map((item) => projectSchema(item, depth + 1)) } : {}), + ...(isRecord(value.properties) + ? { + properties: Object.fromEntries( + Object.entries(value.properties).map(([key, item]) => [key, projectSchema(item, depth + 1)]), + ), + } + : {}), + ...(Array.isArray(value.required) + ? { required: value.required.filter((item): item is string => typeof item === "string") } + : {}), + ...(isRecord(value.items) ? { items: projectSchema(value.items, depth + 1) } : {}), + ...(typeof value.additionalProperties === "boolean" + ? { additionalProperties: value.additionalProperties } + : isRecord(value.additionalProperties) + ? { additionalProperties: projectSchema(value.additionalProperties, depth + 1) } + : {}), + ...(description === undefined ? {} : { description }), + ...(value.default === undefined ? {} : { default: value.default }), + ...(format === undefined ? {} : { format }), + ...(value.deprecated === true ? { deprecated: true } : {}), + ...(typeof value.minItems === "number" ? { minItems: value.minItems } : {}), + ...(typeof value.maxItems === "number" ? { maxItems: value.maxItems } : {}), + } + // OpenAPI 3.0 nullable -> union with null, matching what 3.1 expresses via type arrays. + if (value.nullable !== true) return projected + if (Array.isArray(projected.type)) return { ...projected, type: [...projected.type, "null"] } + if (typeof projected.type === "string") return { ...projected, type: [projected.type, "null"] } + return { anyOf: [projected, { type: "null" }] } +} + +export const componentDefinitions = (document: Document): Readonly> => { + const components = isRecord(document.components) ? document.components : {} + const schemas = isRecord(components.schemas) ? components.schemas : {} + return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(value)])) +} + +const withDefinitions = (schema: JsonSchema, definitions: Readonly>): JsonSchema => + Object.keys(definitions).length === 0 ? schema : { ...schema, $defs: definitions } + +const isJsonMediaType = (mediaType: string): boolean => { + const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? "" + return normalized === "application/json" || normalized.endsWith("+json") +} + +const jsonContentSchema = (content: Record): unknown => { + const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType)) + return entry !== undefined && isRecord(entry[1]) ? entry[1].schema : undefined +} + export const operationParameters = ( document: Document, pathItem: Record,