feat(codemode): add OpenAPI tool adapter (#35192)
This commit is contained in:
parent
709af58612
commit
a8983bd2c7
16 changed files with 29461 additions and 62 deletions
19
packages/codemode/src/openapi/TODO.md
Normal file
19
packages/codemode/src/openapi/TODO.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# OpenAPI Follow-ups
|
||||
|
||||
The initial adapter intentionally skips operations it cannot execute correctly. Future work may add:
|
||||
|
||||
- Cookie parameters, authentication, and cookie-header merging.
|
||||
- Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization.
|
||||
- External references and complete nested `$defs` support.
|
||||
- Relative or templated server URLs and server variables.
|
||||
- Base URLs containing query strings or fragments.
|
||||
- Runtime response-schema validation and full content negotiation.
|
||||
- Binary response values and explicit byte-oriented return types.
|
||||
- Request/response projection for `readOnly` and `writeOnly` properties.
|
||||
- SSE, WebSocket, and other streaming transports.
|
||||
- Recovery of responses rejected by a status-filtering `HttpClient`.
|
||||
- Configurable request and response size limits.
|
||||
- Adapter-enforced redirect policy independent of the supplied `HttpClient`.
|
||||
- Strict UTF-8 and empty-body validation for JSON responses.
|
||||
- Compile-time rejection of parameter schemas with nested values unsupported by their serialization style; runtime rejects them before auth resolution.
|
||||
- Complete malformed-security-scheme validation and broader auth-combination coverage.
|
||||
130
packages/codemode/src/openapi/index.ts
Normal file
130
packages/codemode/src/openapi/index.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Tool, type Definition } from "../tool.js"
|
||||
import { invoke } from "./runtime.js"
|
||||
import {
|
||||
componentDefinitions,
|
||||
inputSchema,
|
||||
isRecord,
|
||||
methods,
|
||||
nonEmptyString,
|
||||
operationInput,
|
||||
operationOutput,
|
||||
operationPath,
|
||||
operationSecurityRequirements,
|
||||
securityRequirements,
|
||||
securitySchemes,
|
||||
specServerUrl,
|
||||
validateBaseUrl,
|
||||
} from "./spec.js"
|
||||
import type { Operation, Options, Result, Skipped, Tools } from "./types.js"
|
||||
|
||||
export type {
|
||||
AuthResolver,
|
||||
Credential,
|
||||
Document,
|
||||
Operation,
|
||||
Options,
|
||||
Result,
|
||||
SecurityScheme,
|
||||
Skipped,
|
||||
Tools,
|
||||
} from "./types.js"
|
||||
|
||||
/**
|
||||
* Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per
|
||||
* operation. Auth is resolved host-side via `auth.resolve` and never
|
||||
* model-visible. Tools require `HttpClient.HttpClient`; unrepresentable
|
||||
* operations land in `skipped`.
|
||||
*/
|
||||
export const fromSpec = (options: Options): Result => {
|
||||
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 used = new Set<string>()
|
||||
const namespaces = new Set<string>()
|
||||
const skipped: Array<Skipped> = []
|
||||
const tools = Object.create(null) as Tools
|
||||
|
||||
for (const [path, pathValue] of Object.entries(paths)) {
|
||||
if (!isRecord(pathValue)) continue
|
||||
for (const [method, operationValue] of Object.entries(pathValue)) {
|
||||
if (!methods.has(method) || !isRecord(operationValue)) continue
|
||||
const segments = operationPath(method, path, operationValue, used, namespaces)
|
||||
const operation: Operation = {
|
||||
operationId: nonEmptyString(operationValue.operationId),
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: nonEmptyString(operationValue.summary),
|
||||
description: nonEmptyString(operationValue.description),
|
||||
}
|
||||
const output = operationOutput(document, operationValue, definitions)
|
||||
if (!output.ok) {
|
||||
skipped.push({ method: operation.method, path, reason: output.reason })
|
||||
continue
|
||||
}
|
||||
|
||||
const resolvedBaseUrl = (() => {
|
||||
if (options.baseUrl !== undefined) return validateBaseUrl(options.baseUrl)
|
||||
if (operationValue.servers !== undefined) return specServerUrl(operationValue)
|
||||
if (pathValue.servers !== undefined) return specServerUrl(pathValue)
|
||||
return specServerUrl(document)
|
||||
})()
|
||||
if (!resolvedBaseUrl.ok) {
|
||||
skipped.push({ method: operation.method, path, reason: resolvedBaseUrl.reason })
|
||||
continue
|
||||
}
|
||||
const parsedInput = operationInput(document, pathValue, operationValue)
|
||||
if (!parsedInput.ok) {
|
||||
skipped.push({ method: operation.method, path, reason: parsedInput.reason })
|
||||
continue
|
||||
}
|
||||
const input = parsedInput.value
|
||||
|
||||
const security = operationSecurityRequirements(operationValue.security, defaultSecurity, schemes)
|
||||
if (!security.ok) {
|
||||
skipped.push({ method: operation.method, path, reason: security.reason })
|
||||
continue
|
||||
}
|
||||
const plan = {
|
||||
operation,
|
||||
url: `${resolvedBaseUrl.value.replace(/\/+$/, "")}${path}`,
|
||||
fields: input.fields,
|
||||
body: input.body,
|
||||
security: security.value,
|
||||
schemes,
|
||||
auth: options.auth,
|
||||
headers: options.headers ?? {},
|
||||
}
|
||||
used.add(segments.join("."))
|
||||
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
|
||||
setTool(
|
||||
tools,
|
||||
segments,
|
||||
Tool.make({
|
||||
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
||||
input: inputSchema(input.fields, definitions),
|
||||
output: output.value,
|
||||
run: (input) => invoke(plan, input),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { tools, skipped }
|
||||
}
|
||||
|
||||
const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
|
||||
const [head, ...rest] = path
|
||||
if (head === undefined) return
|
||||
if (rest.length === 0) {
|
||||
tools[head] = definition
|
||||
return
|
||||
}
|
||||
const child = tools[head]
|
||||
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
|
||||
tools[head] = Object.create(null) as Tools
|
||||
}
|
||||
setTool(tools[head] as Tools, rest, definition)
|
||||
}
|
||||
324
packages/codemode/src/openapi/runtime.ts
Normal file
324
packages/codemode/src/openapi/runtime.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse, type HttpMethod } from "effect/unstable/http"
|
||||
import { ToolError, toolError } from "../tool-error.js"
|
||||
import { isRecord, own } from "./spec.js"
|
||||
import type { AppliedAuth, Credential, Plan, SecurityScheme } from "./types.js"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const maxErrorBodyChars = 1_024
|
||||
const maxResponseBodyBytes = 50 * 1024 * 1024
|
||||
|
||||
export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unknown, HttpClient.HttpClient> =>
|
||||
Effect.gen(function* () {
|
||||
const value = isRecord(input) ? input : {}
|
||||
|
||||
let request = yield* buildRequest(plan, value)
|
||||
|
||||
const auth = yield* resolveAuth(plan)
|
||||
for (const [name, item] of Object.entries(auth.query)) {
|
||||
request = HttpClientRequest.setUrlParam(request, name, item)
|
||||
}
|
||||
request = HttpClientRequest.setHeaders(request, auth.headers)
|
||||
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const response = yield* client
|
||||
.execute(request)
|
||||
.pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause)),
|
||||
),
|
||||
)
|
||||
const text = yield* readResponseBody(response, plan)
|
||||
const mediaType = response.headers["content-type"]?.split(";")[0]?.trim().toLowerCase()
|
||||
const json = mediaType === "application/json" || mediaType?.endsWith("+json") === true
|
||||
const decoded = text === "" ? Option.some(null) : json ? decodeJson(text) : Option.none()
|
||||
const parsed = json ? Option.getOrElse(decoded, () => text) : text === "" ? null : text
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const rendered = typeof parsed === "string" ? parsed : (JSON.stringify(parsed) ?? "")
|
||||
const summary =
|
||||
rendered === "" || rendered === "null"
|
||||
? "no response body"
|
||||
: rendered.length > maxErrorBodyChars
|
||||
? `${rendered.slice(0, maxErrorBodyChars)}...`
|
||||
: rendered
|
||||
return yield* Effect.fail(
|
||||
toolError(`${plan.operation.method} ${plan.operation.path} failed with HTTP ${response.status}: ${summary}`),
|
||||
)
|
||||
}
|
||||
if (json && Option.isNone(decoded)) {
|
||||
return yield* Effect.fail(
|
||||
toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`),
|
||||
)
|
||||
}
|
||||
return parsed
|
||||
})
|
||||
|
||||
const buildRequest = (
|
||||
plan: Plan,
|
||||
input: Readonly<Record<string, unknown>>,
|
||||
): Effect.Effect<HttpClientRequest.HttpClientRequest, ToolError> =>
|
||||
Effect.gen(function* () {
|
||||
// Validate every model-controlled value before auth resolution, which may refresh tokens.
|
||||
const url = buildUrl(plan, input)
|
||||
if (url instanceof ToolError) return yield* Effect.fail(url)
|
||||
const missing = plan.fields.find(
|
||||
(field) => field.required && field.location !== "path" && own(input, field.inputName) === undefined,
|
||||
)
|
||||
if (missing !== undefined) {
|
||||
const label = missing.location === "body" ? "body field" : `${missing.location} parameter`
|
||||
return yield* Effect.fail(toolError(`Missing required ${label} '${missing.inputName}'.`))
|
||||
}
|
||||
|
||||
let request = HttpClientRequest.make(plan.operation.method as HttpMethod.HttpMethod)(url)
|
||||
for (const field of plan.fields) {
|
||||
if (field.location !== "query") continue
|
||||
const item = own(input, field.inputName)
|
||||
if (item === undefined) continue
|
||||
const serialized = serializeQuery(request, field, item)
|
||||
if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
|
||||
request = serialized
|
||||
}
|
||||
|
||||
// Host headers first, then declared header parameters.
|
||||
request = HttpClientRequest.setHeaders(request, plan.headers)
|
||||
for (const field of plan.fields) {
|
||||
if (field.location !== "header") continue
|
||||
const item = own(input, field.inputName)
|
||||
if (item === undefined) continue
|
||||
const serialized = serializeSimple(field, item, String)
|
||||
if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
|
||||
request = HttpClientRequest.setHeader(request, field.name, serialized)
|
||||
}
|
||||
|
||||
const setBody = (value: unknown, mediaType: string) =>
|
||||
HttpClientRequest.bodyJson(request, value).pipe(
|
||||
Effect.map((next) => HttpClientRequest.setHeader(next, "content-type", mediaType)),
|
||||
Effect.mapError((cause) =>
|
||||
toolError(`Invalid JSON body for ${plan.operation.method} ${plan.operation.path}.`, cause),
|
||||
),
|
||||
)
|
||||
if (plan.body?.mode === "value") {
|
||||
const field = plan.fields.find((field) => field.location === "body")
|
||||
const body = field === undefined ? undefined : own(input, field.inputName)
|
||||
if (body !== undefined) request = yield* setBody(body, plan.body.mediaType)
|
||||
}
|
||||
if (plan.body?.mode === "object") {
|
||||
const entries = plan.fields.flatMap((field) => {
|
||||
if (field.location !== "body") return []
|
||||
const item = own(input, field.inputName)
|
||||
return item === undefined ? [] : [[field.name, item] as const]
|
||||
})
|
||||
if (plan.body.required || entries.length > 0) {
|
||||
request = yield* setBody(Object.fromEntries(entries), plan.body.mediaType)
|
||||
}
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
const resolveAuth = (plan: Plan): Effect.Effect<AppliedAuth, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const none: AppliedAuth = { headers: {}, query: {} }
|
||||
if (plan.security.length === 0) return none
|
||||
|
||||
const unavailable: Array<string> = []
|
||||
alternatives: for (const requirement of plan.security) {
|
||||
const names = Object.keys(requirement)
|
||||
if (names.length === 0) return none
|
||||
const credentials: Array<readonly [string, SecurityScheme, Credential]> = []
|
||||
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({
|
||||
name,
|
||||
definition: scheme,
|
||||
scopes: requirement[name] ?? [],
|
||||
operation: plan.operation,
|
||||
})
|
||||
if (credential === undefined) {
|
||||
unavailable.push(name)
|
||||
continue alternatives
|
||||
}
|
||||
credentials.push([name, 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<readonly [string, SecurityScheme, Credential]>,
|
||||
): AppliedAuth | ToolError => {
|
||||
const headers = new Map<string, string>()
|
||||
const query = new Map<string, string>()
|
||||
const add = (carrier: "header" | "query", name: string, value: string): ToolError | undefined => {
|
||||
const target = carrier === "header" ? headers : query
|
||||
if (target.has(name)) return toolError(`Authentication resolves multiple credentials for ${carrier} '${name}'.`)
|
||||
target.set(name, value)
|
||||
}
|
||||
for (const [name, definition, credential] of credentials) {
|
||||
if (credential.type === "bearer") {
|
||||
const duplicate = add("header", "authorization", `Bearer ${credential.token}`)
|
||||
if (duplicate !== undefined) return duplicate
|
||||
continue
|
||||
}
|
||||
if (credential.type === "basic") {
|
||||
// Buffer instead of btoa: btoa throws on non-Latin-1 credentials.
|
||||
const duplicate = add(
|
||||
"header",
|
||||
"authorization",
|
||||
`Basic ${Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64")}`,
|
||||
)
|
||||
if (duplicate !== undefined) return duplicate
|
||||
continue
|
||||
}
|
||||
if (credential.type === "header") {
|
||||
const duplicate = add("header", credential.name.toLowerCase(), credential.value)
|
||||
if (duplicate !== undefined) return duplicate
|
||||
continue
|
||||
}
|
||||
// apiKey: the carrier comes from the scheme declaration.
|
||||
if (definition.type !== "apiKey") {
|
||||
return toolError(
|
||||
`Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`,
|
||||
)
|
||||
}
|
||||
if (definition.in === "cookie") return toolError(`Cookie authentication '${name}' is not supported.`)
|
||||
const parameter = definition.in === "header" ? definition.name.toLowerCase() : definition.name
|
||||
const duplicate = add(definition.in, parameter, credential.value)
|
||||
if (duplicate !== undefined) return duplicate
|
||||
}
|
||||
return { headers: Object.fromEntries(headers), query: Object.fromEntries(query) }
|
||||
}
|
||||
|
||||
const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string | ToolError => {
|
||||
let url = plan.url
|
||||
for (const field of plan.fields) {
|
||||
if (field.location !== "path") continue
|
||||
const item = own(input, field.inputName)
|
||||
if (item === undefined) {
|
||||
return toolError(`Missing required path parameter '${field.inputName}'.`)
|
||||
}
|
||||
const fieldValue = serializeSimple(field, item, (value) =>
|
||||
encodeURIComponent(value).replace(/[!'()*]/g, (character) =>
|
||||
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
),
|
||||
)
|
||||
if (fieldValue instanceof ToolError) return fieldValue
|
||||
// '.'/'..' survive encoding and URL normalization collapses them, letting a
|
||||
// model-supplied value retarget the request to a different endpoint.
|
||||
if (fieldValue === "" || fieldValue === "." || fieldValue === "..") {
|
||||
return toolError(`Invalid path parameter '${field.inputName}'.`)
|
||||
}
|
||||
url = url.replaceAll(`{${field.name}}`, fieldValue)
|
||||
}
|
||||
const unresolved = url.match(/\{[^{}]+\}/)
|
||||
if (unresolved !== null) return toolError(`Unresolved path parameter ${unresolved[0]}.`)
|
||||
return url
|
||||
}
|
||||
|
||||
const serializeSimple = (
|
||||
field: Plan["fields"][number],
|
||||
value: unknown,
|
||||
encode: (value: string) => string,
|
||||
): string | ToolError => {
|
||||
const scalar = (item: unknown): string | ToolError =>
|
||||
item !== null && typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean"
|
||||
? toolError(`Parameter '${field.inputName}' contains an unsupported nested value.`)
|
||||
: encode(String(item))
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(scalar)
|
||||
const invalid = items.find((item): item is ToolError => item instanceof ToolError)
|
||||
return invalid ?? items.join(",")
|
||||
}
|
||||
if (!isRecord(value)) return scalar(value)
|
||||
const entries = Object.entries(value).flatMap<string | ToolError>(([name, item]) => {
|
||||
const rendered = scalar(item)
|
||||
if (rendered instanceof ToolError) return [rendered]
|
||||
return field.explode ? [`${encode(name)}=${rendered}`] : [encode(name), rendered]
|
||||
})
|
||||
const invalid = entries.find((item): item is ToolError => item instanceof ToolError)
|
||||
return invalid ?? entries.join(",")
|
||||
}
|
||||
|
||||
const serializeQuery = (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
field: Plan["fields"][number],
|
||||
value: unknown,
|
||||
): HttpClientRequest.HttpClientRequest | ToolError => {
|
||||
if (field.style === "deepObject") {
|
||||
if (!isRecord(value)) return toolError(`Deep-object parameter '${field.inputName}' must be an object.`)
|
||||
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
|
||||
if (current instanceof ToolError) return current
|
||||
if (item === undefined || (item !== null && typeof item === "object")) {
|
||||
return toolError(`Deep-object parameter '${field.inputName}' contains an unsupported nested value.`)
|
||||
}
|
||||
return HttpClientRequest.appendUrlParam(current, `${field.name}[${name}]`, String(item))
|
||||
}, request)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const rendered = serializeSimple(field, value, String)
|
||||
if (rendered instanceof ToolError) return rendered
|
||||
if (!field.explode) return HttpClientRequest.appendUrlParam(request, field.name, rendered)
|
||||
if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) {
|
||||
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
|
||||
}
|
||||
return value.reduce(
|
||||
(current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)),
|
||||
request,
|
||||
)
|
||||
}
|
||||
if (isRecord(value) && field.explode) {
|
||||
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
|
||||
if (current instanceof ToolError) return current
|
||||
if (item === undefined || (item !== null && typeof item === "object")) {
|
||||
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
|
||||
}
|
||||
return HttpClientRequest.appendUrlParam(current, name, String(item))
|
||||
}, request)
|
||||
}
|
||||
const rendered = serializeSimple(field, value, String)
|
||||
return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered)
|
||||
}
|
||||
|
||||
const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan: Plan): Effect.Effect<string, ToolError> =>
|
||||
Effect.gen(function* () {
|
||||
const contentLength = response.headers["content-length"]
|
||||
const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10)
|
||||
const declaredSize = parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
|
||||
if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) {
|
||||
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
|
||||
}
|
||||
let body = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, declaredSize ?? 64 * 1024))
|
||||
let size = 0
|
||||
yield* Stream.runForEach(response.stream, (chunk) => {
|
||||
if (size + chunk.byteLength > maxResponseBodyBytes) {
|
||||
return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
|
||||
}
|
||||
if (size + chunk.byteLength > body.byteLength) {
|
||||
const grown = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)))
|
||||
body.copy(grown, 0, 0, size)
|
||||
body = grown
|
||||
}
|
||||
body.set(chunk, size)
|
||||
size += chunk.byteLength
|
||||
return Effect.void
|
||||
}).pipe(
|
||||
Effect.catch((cause) => {
|
||||
if (cause instanceof ToolError) return Effect.fail(cause)
|
||||
if (cause.reason._tag === "EmptyBodyError") return Effect.void
|
||||
return Effect.fail(
|
||||
toolError(`${plan.operation.method} ${plan.operation.path} failed while reading the response body.`, cause),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return new TextDecoder().decode(body.subarray(0, size))
|
||||
})
|
||||
507
packages/codemode/src/openapi/spec.ts
Normal file
507
packages/codemode/src/openapi/spec.ts
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
import { fromSchemaOpenApi3_0, fromSchemaOpenApi3_1 } from "effect/JsonSchema"
|
||||
import type { JsonSchema } from "../tool.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import type {
|
||||
Body,
|
||||
Document,
|
||||
InputField,
|
||||
OperationInput,
|
||||
Parsed,
|
||||
SecurityRequirement,
|
||||
SecurityScheme,
|
||||
} from "./types.js"
|
||||
|
||||
export const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"])
|
||||
const parameterLocations = ["path", "query", "header"] as const
|
||||
const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"])
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
|
||||
const asArray = (value: unknown): ReadonlyArray<unknown> => (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 = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
|
||||
Object.hasOwn(record, key) ? record[key] : undefined
|
||||
|
||||
export const resolve = (document: Document, value: unknown): unknown => {
|
||||
const next = (current: unknown, seen: ReadonlySet<string>): unknown => {
|
||||
if (!isRecord(current)) return current
|
||||
const ref = nonEmptyString(current.$ref)
|
||||
if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current
|
||||
const target = ref
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document)
|
||||
return target === undefined ? current : next(target, new Set([...seen, ref]))
|
||||
}
|
||||
return next(value, new Set())
|
||||
}
|
||||
|
||||
const projectSchema = (document: Document, value: unknown): JsonSchema => {
|
||||
if (!isRecord(value)) return {}
|
||||
const normalized = nonEmptyString(document.openapi)?.startsWith("3.0")
|
||||
? fromSchemaOpenApi3_0(value)
|
||||
: fromSchemaOpenApi3_1(value)
|
||||
return Object.keys(normalized.definitions).length === 0
|
||||
? normalized.schema
|
||||
: { ...normalized.schema, $defs: normalized.definitions }
|
||||
}
|
||||
|
||||
export const componentDefinitions = (document: Document): Readonly<Record<string, JsonSchema>> => {
|
||||
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(document, value)]))
|
||||
}
|
||||
|
||||
const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): JsonSchema => {
|
||||
if (Object.keys(definitions).length === 0) return schema
|
||||
const local = isRecord(schema.$defs) ? schema.$defs : {}
|
||||
return { ...schema, $defs: { ...definitions, ...local } }
|
||||
}
|
||||
|
||||
const isJsonMediaType = (mediaType: string): boolean => {
|
||||
const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
|
||||
return normalized === "application/json" || normalized.endsWith("+json")
|
||||
}
|
||||
|
||||
const isBinaryMediaType = (document: Document, mediaType: string, value: unknown): boolean => {
|
||||
const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
|
||||
if (!isJsonMediaType(normalized) && !normalized.startsWith("text/")) return true
|
||||
if (!isRecord(value)) return false
|
||||
const schema = resolve(document, value.schema)
|
||||
return isRecord(schema) && schema.format === "binary"
|
||||
}
|
||||
|
||||
const jsonContent = (content: Record<string, unknown>): { readonly mediaType: string; readonly schema: unknown } | undefined => {
|
||||
const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
|
||||
return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined
|
||||
}
|
||||
|
||||
const isFlattenableObjectBody = (
|
||||
schema: unknown,
|
||||
requestRequired: boolean,
|
||||
): schema is Record<string, unknown> & { readonly properties: Record<string, unknown> } =>
|
||||
isRecord(schema) &&
|
||||
requestRequired &&
|
||||
schema.type === "object" &&
|
||||
isRecord(schema.properties) &&
|
||||
schema.additionalProperties === false &&
|
||||
schema.nullable !== true &&
|
||||
schema.allOf === undefined &&
|
||||
schema.anyOf === undefined &&
|
||||
schema.oneOf === undefined
|
||||
|
||||
type PlannedField = Omit<InputField, "inputName">
|
||||
|
||||
const operationParameters = (
|
||||
document: Document,
|
||||
pathItem: Record<string, unknown>,
|
||||
operation: Record<string, unknown>,
|
||||
): Parsed<ReadonlyArray<PlannedField>> => {
|
||||
// Operation-level parameters override path-level ones sharing (location, name).
|
||||
const declared = new Map<
|
||||
string,
|
||||
{ readonly name: string; readonly location: string; readonly parameter: Record<string, unknown> }
|
||||
>()
|
||||
for (const raw of [...asArray(pathItem.parameters), ...asArray(operation.parameters)]) {
|
||||
const resolved = resolve(document, raw)
|
||||
if (!isRecord(resolved)) return { ok: false, reason: "parameter declaration is invalid or unresolved" }
|
||||
const name = nonEmptyString(resolved.name)
|
||||
const location = nonEmptyString(resolved.in)
|
||||
if (name === undefined || location === undefined)
|
||||
return { ok: false, reason: "parameter declaration is missing name or location" }
|
||||
declared.set(`${location}:${name}`, { name, location, parameter: resolved })
|
||||
}
|
||||
const unordered: Array<PlannedField> = []
|
||||
for (const item of declared.values()) {
|
||||
const name = item.name
|
||||
const location = item.location
|
||||
const resolved = item.parameter
|
||||
if (location === "cookie") return { ok: false, reason: `cookie parameter '${name}' is not supported` }
|
||||
if (location !== "path" && location !== "query" && location !== "header") {
|
||||
return { ok: false, reason: `parameter '${name}' uses unsupported location '${location}'` }
|
||||
}
|
||||
if (location === "header" && ignoredHeaderParameters.has(name.toLowerCase())) continue
|
||||
if (resolved.schema === undefined && resolved.content === undefined) {
|
||||
return { ok: false, reason: `parameter '${name}' declares neither schema nor content` }
|
||||
}
|
||||
if (resolved.content !== undefined)
|
||||
return { ok: false, reason: `parameter '${name}' uses unsupported content encoding` }
|
||||
if (resolved.style !== undefined && nonEmptyString(resolved.style) === undefined) {
|
||||
return { ok: false, reason: `parameter '${name}' has an invalid style` }
|
||||
}
|
||||
if (resolved.explode !== undefined && typeof resolved.explode !== "boolean") {
|
||||
return { ok: false, reason: `parameter '${name}' has an invalid explode value` }
|
||||
}
|
||||
if (resolved.allowReserved !== undefined && typeof resolved.allowReserved !== "boolean") {
|
||||
return { ok: false, reason: `parameter '${name}' has an invalid allowReserved value` }
|
||||
}
|
||||
if (resolved.allowReserved === true)
|
||||
return { ok: false, reason: `parameter '${name}' uses unsupported allowReserved encoding` }
|
||||
const declaredStyle = nonEmptyString(resolved.style) ?? (location === "query" ? "form" : "simple")
|
||||
if (location === "query" && declaredStyle !== "form" && declaredStyle !== "deepObject") {
|
||||
return { ok: false, reason: `query parameter '${name}' uses unsupported style '${declaredStyle}'` }
|
||||
}
|
||||
if (location !== "query" && declaredStyle !== "simple") {
|
||||
return { ok: false, reason: `${location} parameter '${name}' uses unsupported style '${declaredStyle}'` }
|
||||
}
|
||||
const style = declaredStyle === "deepObject" ? "deepObject" : declaredStyle === "form" ? "form" : "simple"
|
||||
const explode = typeof resolved.explode === "boolean" ? resolved.explode : style === "form"
|
||||
if (style === "deepObject" && !explode) {
|
||||
return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` }
|
||||
}
|
||||
const base = projectSchema(document, resolved.schema)
|
||||
const description = nonEmptyString(resolved.description)
|
||||
unordered.push({
|
||||
name,
|
||||
location,
|
||||
required: resolved.required === true || location === "path",
|
||||
style,
|
||||
explode,
|
||||
schema: {
|
||||
...base,
|
||||
...(base.description === undefined && description !== undefined ? { description } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: parameterLocations.flatMap((location) => unordered.filter((field) => field.location === location)),
|
||||
}
|
||||
}
|
||||
|
||||
const operationBody = (
|
||||
document: Document,
|
||||
operation: Record<string, unknown>,
|
||||
): Parsed<{ readonly fields: ReadonlyArray<PlannedField>; readonly body: Body | undefined }> => {
|
||||
const resolved = resolve(document, operation.requestBody)
|
||||
if (!isRecord(resolved)) return { ok: true, value: { fields: [], body: undefined } }
|
||||
const content = isRecord(resolved.content) ? resolved.content : {}
|
||||
const selected = jsonContent(content)
|
||||
if (selected === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`,
|
||||
}
|
||||
}
|
||||
const schema = resolve(document, selected.schema)
|
||||
const required = resolved.required === true
|
||||
if (!isFlattenableObjectBody(schema, required)) {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
fields: [
|
||||
{
|
||||
name: "body",
|
||||
location: "body",
|
||||
required,
|
||||
schema: projectSchema(document, selected.schema),
|
||||
style: undefined,
|
||||
explode: undefined,
|
||||
},
|
||||
],
|
||||
body: { required, mode: "value", mediaType: selected.mediaType },
|
||||
},
|
||||
}
|
||||
}
|
||||
const requiredProperties = new Set(
|
||||
Array.isArray(schema.required) ? schema.required.filter((item): item is string => typeof item === "string") : [],
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
fields: Object.entries(schema.properties).map(([name, value]) => ({
|
||||
name,
|
||||
location: "body" as const,
|
||||
required: required && requiredProperties.has(name),
|
||||
schema: projectSchema(document, value),
|
||||
style: undefined,
|
||||
explode: undefined,
|
||||
})),
|
||||
body: { required, mode: "object", mediaType: selected.mediaType },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const operationInput = (
|
||||
document: Document,
|
||||
pathItem: Record<string, unknown>,
|
||||
operation: Record<string, unknown>,
|
||||
): Parsed<OperationInput> => {
|
||||
const parameters = operationParameters(document, pathItem, operation)
|
||||
if (!parameters.ok) return parameters
|
||||
const requestBody = operationBody(document, operation)
|
||||
if (!requestBody.ok) return requestBody
|
||||
const fields = [...parameters.value, ...requestBody.value.fields]
|
||||
|
||||
const conflicts = new Set(
|
||||
[...Map.groupBy(fields, (field) => field.name)]
|
||||
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
|
||||
.map(([name]) => name),
|
||||
)
|
||||
const used = new Set<string>()
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
fields: fields.map((field) => {
|
||||
const visibleName = isBlockedMember(field.name) ? `${field.name}_2` : field.name
|
||||
const base = conflicts.has(field.name) ? `${field.location}_${visibleName}` : visibleName
|
||||
const next = (index: number): string => {
|
||||
const candidate = index === 1 ? base : `${base}_${index}`
|
||||
return used.has(candidate) ? next(index + 1) : candidate
|
||||
}
|
||||
const inputName = next(1)
|
||||
used.add(inputName)
|
||||
return { ...field, inputName }
|
||||
}),
|
||||
body: requestBody.value.body,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const inputSchema = (
|
||||
fields: ReadonlyArray<InputField>,
|
||||
definitions: Readonly<Record<string, JsonSchema>>,
|
||||
): JsonSchema => {
|
||||
const required = fields.filter((field) => field.required).map((field) => field.inputName)
|
||||
return withDefinitions(
|
||||
{
|
||||
type: "object",
|
||||
properties: Object.fromEntries(fields.map((field) => [field.inputName, field.schema])),
|
||||
...(required.length === 0 ? {} : { required }),
|
||||
},
|
||||
definitions,
|
||||
)
|
||||
}
|
||||
|
||||
const successfulResponses = (
|
||||
document: Document,
|
||||
operation: Record<string, unknown>,
|
||||
): Parsed<ReadonlyArray<Record<string, unknown>>> => {
|
||||
if (!isRecord(operation.responses)) return { ok: true, value: [] }
|
||||
const entries = Object.entries(operation.responses)
|
||||
const selected = [
|
||||
...entries.filter(([status]) => /^2\d\d$/.test(status)).sort(([a], [b]) => a.localeCompare(b)),
|
||||
...entries.filter(([status]) => status.toUpperCase() === "2XX"),
|
||||
]
|
||||
const responses: Array<Record<string, unknown>> = []
|
||||
for (const [, value] of selected) {
|
||||
const resolved = resolve(document, value)
|
||||
if (!isRecord(resolved) || nonEmptyString(resolved.$ref) !== undefined) {
|
||||
return { ok: false, reason: "successful response declaration is invalid or unresolved" }
|
||||
}
|
||||
responses.push(resolved)
|
||||
}
|
||||
return { ok: true, value: responses }
|
||||
}
|
||||
|
||||
export const operationOutput = (
|
||||
document: Document,
|
||||
operation: Record<string, unknown>,
|
||||
definitions: Readonly<Record<string, JsonSchema>>,
|
||||
): Parsed<JsonSchema | undefined> => {
|
||||
if (operation["x-websocket"] === true) return { ok: false, reason: "WebSocket operations are not supported" }
|
||||
const responses = successfulResponses(document, operation)
|
||||
if (!responses.ok) return responses
|
||||
const streams = responses.value.some(
|
||||
(response) =>
|
||||
isRecord(response.content) &&
|
||||
Object.keys(response.content).some(
|
||||
(mediaType) => mediaType.split(";")[0]?.trim().toLowerCase() === "text/event-stream",
|
||||
),
|
||||
)
|
||||
if (streams) return { ok: false, reason: "SSE operations are not supported" }
|
||||
const binary = responses.value.some(
|
||||
(response) =>
|
||||
isRecord(response.content) &&
|
||||
Object.entries(response.content).some(([mediaType, value]) => isBinaryMediaType(document, mediaType, value)),
|
||||
)
|
||||
if (binary) return { ok: false, reason: "binary responses are not supported" }
|
||||
|
||||
const outcomes: Array<JsonSchema> = []
|
||||
for (const response of responses.value) {
|
||||
if (response.content !== undefined && !isRecord(response.content)) return { ok: true, value: undefined }
|
||||
const content = isRecord(response.content) ? response.content : {}
|
||||
if (Object.keys(content).length === 0) {
|
||||
outcomes.push({ type: "null" })
|
||||
continue
|
||||
}
|
||||
for (const [mediaType, value] of Object.entries(content)) {
|
||||
if (!isJsonMediaType(mediaType)) {
|
||||
outcomes.push({ type: "string" })
|
||||
continue
|
||||
}
|
||||
if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined }
|
||||
outcomes.push(projectSchema(document, value.schema))
|
||||
}
|
||||
}
|
||||
if (outcomes.length === 0) return { ok: true, value: undefined }
|
||||
return {
|
||||
ok: true,
|
||||
value: withDefinitions(outcomes.length === 1 ? outcomes[0] ?? {} : { anyOf: outcomes }, definitions),
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeOperationSegment = (raw: string): string => {
|
||||
const base =
|
||||
raw
|
||||
.replaceAll(/[^A-Za-z0-9_$]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.replace(/^([0-9])/, "_$1") || "operation"
|
||||
return isBlockedMember(base) ? `${base}_2` : base
|
||||
}
|
||||
|
||||
const fallbackOperationId = (method: string, path: string): string =>
|
||||
[
|
||||
method,
|
||||
...path
|
||||
.split("/")
|
||||
.filter((part) => part !== "")
|
||||
.flatMap((part) => (part.startsWith("{") && part.endsWith("}") ? ["by", part.slice(1, -1)] : [part]))
|
||||
.flatMap((part) => part.split(/[^A-Za-z0-9]+/).filter((word) => word !== "")),
|
||||
]
|
||||
.map((word, index) => {
|
||||
const lower = word.toLowerCase()
|
||||
return index === 0 ? lower : `${lower.charAt(0).toUpperCase()}${lower.slice(1)}`
|
||||
})
|
||||
.join("")
|
||||
|
||||
export const operationPath = (
|
||||
method: string,
|
||||
path: string,
|
||||
operation: Record<string, unknown>,
|
||||
used: ReadonlySet<string>,
|
||||
namespaces: ReadonlySet<string>,
|
||||
): ReadonlyArray<string> => {
|
||||
const raw = nonEmptyString(operation.operationId)
|
||||
const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(sanitizeOperationSegment)
|
||||
if (isOperationPathAvailable(segments, used, namespaces)) return segments
|
||||
const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
|
||||
if (conflict >= 0 && conflict + 1 < segments.length) {
|
||||
const collapsed = segments.flatMap((segment, index) => {
|
||||
if (index === conflict) {
|
||||
const next = segments[index + 1] ?? ""
|
||||
return [`${segment}${next.charAt(0).toUpperCase()}${next.slice(1)}`]
|
||||
}
|
||||
return index === conflict + 1 ? [] : [segment]
|
||||
})
|
||||
if (isOperationPathAvailable(collapsed, used, namespaces)) return collapsed
|
||||
}
|
||||
const fallback = segments.join("_")
|
||||
const next = (index: number): string => {
|
||||
const candidate = `${fallback}_${index}`
|
||||
return isOperationPathAvailable([candidate], used, namespaces) ? candidate : next(index + 1)
|
||||
}
|
||||
return [next(2)]
|
||||
}
|
||||
|
||||
const isOperationPathAvailable = (
|
||||
segments: ReadonlyArray<string>,
|
||||
used: ReadonlySet<string>,
|
||||
namespaces: ReadonlySet<string>,
|
||||
): boolean => {
|
||||
const key = segments.join(".")
|
||||
if (used.has(key) || namespaces.has(key)) return false
|
||||
return segments.slice(0, -1).every((_, index) => !used.has(segments.slice(0, index + 1).join(".")))
|
||||
}
|
||||
|
||||
export const specServerUrl = (source: Record<string, unknown>): Parsed<string> => {
|
||||
const server = asArray(source.servers).find(isRecord)
|
||||
const url = server === undefined ? undefined : nonEmptyString(server.url)
|
||||
if (url === undefined) return { ok: false, reason: "spec declares no servers; pass baseUrl" }
|
||||
if (/\{[^{}]+\}/.test(url)) {
|
||||
return { ok: false, reason: `server URL '${url}' is not an absolute URL; pass baseUrl` }
|
||||
}
|
||||
return validateBaseUrl(url)
|
||||
}
|
||||
|
||||
export const validateBaseUrl = (value: string): Parsed<string> => {
|
||||
if (!/^https?:\/\//i.test(value)) return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
|
||||
const url = URL.parse(value)
|
||||
if (url === null || (url.protocol !== "http:" && url.protocol !== "https:")) {
|
||||
return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
|
||||
}
|
||||
if (url.search !== "" || url.hash !== "") {
|
||||
return { ok: false, reason: `server URL '${value}' contains an unsupported query string or fragment` }
|
||||
}
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
export const securityRequirements = (value: unknown): Parsed<ReadonlyArray<SecurityRequirement>> => {
|
||||
if (value === undefined) return { ok: true, value: [] }
|
||||
if (!Array.isArray(value)) return { ok: false, reason: "security declaration is not an array" }
|
||||
const requirements: Array<SecurityRequirement> = []
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) return { ok: false, reason: "security requirement is not an object" }
|
||||
const requirement = Object.create(null) as Record<string, ReadonlyArray<string>>
|
||||
for (const [name, scopes] of Object.entries(item)) {
|
||||
if (!Array.isArray(scopes)) return { ok: false, reason: "security requirement scopes are not string arrays" }
|
||||
const parsed = scopes.filter((scope): scope is string => typeof scope === "string")
|
||||
if (parsed.length !== scopes.length) {
|
||||
return { ok: false, reason: "security requirement scopes are not string arrays" }
|
||||
}
|
||||
requirement[name] = parsed
|
||||
}
|
||||
requirements.push(requirement)
|
||||
}
|
||||
return { ok: true, value: requirements }
|
||||
}
|
||||
|
||||
export const operationSecurityRequirements = (
|
||||
value: unknown,
|
||||
defaults: Parsed<ReadonlyArray<SecurityRequirement>>,
|
||||
schemes: Readonly<Record<string, SecurityScheme>>,
|
||||
): Parsed<ReadonlyArray<SecurityRequirement>> => {
|
||||
const parsed = value === undefined ? defaults : securityRequirements(value)
|
||||
if (!parsed.ok) return parsed
|
||||
const supported = parsed.value.filter((requirement) =>
|
||||
Object.keys(requirement).every((name) => {
|
||||
const scheme = own(schemes, name)
|
||||
return scheme !== undefined && !(scheme.type === "apiKey" && scheme.in === "cookie")
|
||||
}),
|
||||
)
|
||||
if (parsed.value.length === 0 || supported.length > 0) return { ok: true, value: supported }
|
||||
|
||||
const names = [...new Set(parsed.value.flatMap((requirement) => Object.keys(requirement)))]
|
||||
const cookieScheme = names.find((name) => {
|
||||
const definition = own(schemes, name)
|
||||
return definition?.type === "apiKey" && definition.in === "cookie"
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
cookieScheme === undefined
|
||||
? `security requirement references missing or malformed scheme: ${names.join(", ")}`
|
||||
: `cookie authentication '${cookieScheme}' is not supported`,
|
||||
}
|
||||
}
|
||||
|
||||
export const securitySchemes = (document: Document): Readonly<Record<string, SecurityScheme>> => {
|
||||
const components = isRecord(document.components) ? document.components : {}
|
||||
const declared = isRecord(components.securitySchemes) ? components.securitySchemes : {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(declared).flatMap<readonly [string, SecurityScheme]>(([name, value]) => {
|
||||
const resolved = resolve(document, value)
|
||||
if (!isRecord(resolved)) return []
|
||||
const type = nonEmptyString(resolved.type)
|
||||
if (type === "apiKey") {
|
||||
const carrier = nonEmptyString(resolved.in)
|
||||
const parameter = nonEmptyString(resolved.name)
|
||||
if (parameter === undefined || (carrier !== "header" && carrier !== "query" && carrier !== "cookie")) return []
|
||||
return [[name, { type, name: parameter, in: carrier }] as const]
|
||||
}
|
||||
if (type === "http") {
|
||||
const scheme = nonEmptyString(resolved.scheme)?.toLowerCase()
|
||||
return scheme === undefined ? [] : [[name, { type, scheme }] as const]
|
||||
}
|
||||
if (type === "oauth2" || type === "openIdConnect") return [[name, { type }] as const]
|
||||
return []
|
||||
}),
|
||||
)
|
||||
}
|
||||
112
packages/codemode/src/openapi/types.ts
Normal file
112
packages/codemode/src/openapi/types.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { Effect } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import type { Definition, JsonSchema } from "../tool.js"
|
||||
|
||||
/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */
|
||||
export type Document = Record<string, unknown>
|
||||
|
||||
/** The operation identity handed to auth resolution and errors. */
|
||||
export type Operation = {
|
||||
readonly operationId: string | undefined
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly summary: string | undefined
|
||||
readonly description: string | undefined
|
||||
}
|
||||
|
||||
/** A resolved OpenAPI security scheme from `components.securitySchemes`. */
|
||||
export type SecurityScheme =
|
||||
| { readonly type: "apiKey"; readonly name: string; readonly in: "header" | "query" | "cookie" }
|
||||
| { readonly type: "http"; readonly scheme: string }
|
||||
| { readonly type: "oauth2" }
|
||||
| { readonly type: "openIdConnect" }
|
||||
|
||||
/**
|
||||
* Credential material returned by a host auth resolver. The carrier for `apiKey`
|
||||
* 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 }
|
||||
| { readonly type: "basic"; readonly username: string; readonly password: string }
|
||||
| { readonly type: "apiKey"; readonly value: string }
|
||||
| { readonly type: "header"; readonly name: string; readonly value: string }
|
||||
|
||||
/**
|
||||
* Resolves credential material for one named security scheme at call time.
|
||||
* `undefined` means unavailable, try the next OR alternative; a failure aborts
|
||||
* the call rather than falling through.
|
||||
*/
|
||||
export type AuthResolver = (context: {
|
||||
readonly name: string
|
||||
readonly definition: SecurityScheme
|
||||
readonly scopes: ReadonlyArray<string>
|
||||
readonly operation: Operation
|
||||
}) => Effect.Effect<Credential | undefined, unknown>
|
||||
|
||||
export type Options = {
|
||||
readonly spec: Document
|
||||
/** Overrides all document, path, and operation `servers`. Required when no applicable absolute server URL exists. */
|
||||
readonly baseUrl?: string | undefined
|
||||
/** Host credential resolution, keyed by security scheme name. */
|
||||
readonly auth?: { readonly resolve: AuthResolver } | undefined
|
||||
/** Static headers on every request. Not model-visible; declared header params may override them, auth always wins. */
|
||||
readonly headers?: Readonly<Record<string, string>> | undefined
|
||||
}
|
||||
|
||||
/** An operation that could not be represented as a tool, and why. */
|
||||
export type Skipped = {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
|
||||
|
||||
export type Result = {
|
||||
/** Tool subtree; the host places it under a key in its `tools` tree. */
|
||||
readonly tools: Tools
|
||||
readonly skipped: ReadonlyArray<Skipped>
|
||||
}
|
||||
|
||||
export type Parsed<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly reason: string }
|
||||
|
||||
export type InputLocation = "path" | "query" | "header" | "body"
|
||||
|
||||
export type InputField = {
|
||||
/** Model-visible field name after cross-location collision handling. */
|
||||
readonly inputName: string
|
||||
/** Original parameter or body-property name used on the wire. */
|
||||
readonly name: string
|
||||
readonly location: InputLocation
|
||||
readonly required: boolean
|
||||
readonly schema: JsonSchema
|
||||
readonly style: "simple" | "form" | "deepObject" | undefined
|
||||
readonly explode: boolean | undefined
|
||||
}
|
||||
|
||||
export type Body = { readonly required: boolean; readonly mode: "object" | "value"; readonly mediaType: string }
|
||||
|
||||
export type OperationInput = {
|
||||
readonly fields: ReadonlyArray<InputField>
|
||||
readonly body: Body | undefined
|
||||
}
|
||||
|
||||
/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */
|
||||
export type SecurityRequirement = Readonly<Record<string, ReadonlyArray<string>>>
|
||||
|
||||
export type Plan = {
|
||||
readonly operation: Operation
|
||||
readonly url: string
|
||||
readonly fields: ReadonlyArray<InputField>
|
||||
readonly body: Body | undefined
|
||||
readonly security: ReadonlyArray<SecurityRequirement>
|
||||
readonly schemes: Readonly<Record<string, SecurityScheme>>
|
||||
readonly auth: { readonly resolve: AuthResolver } | undefined
|
||||
readonly headers: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export type AppliedAuth = {
|
||||
readonly headers: Readonly<Record<string, string>>
|
||||
readonly query: Readonly<Record<string, string>>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue