refactor(codemode): split OpenAPI adapter modules
This commit is contained in:
@@ -178,7 +178,7 @@ const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide
|
||||
|
||||
`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.
|
||||
|
||||
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/adapters/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.
|
||||
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/adapters/openapi/types.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
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./adapters/openapi": "./src/adapters/openapi.ts"
|
||||
"./adapters/openapi": "./src/adapters/openapi/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,660 +0,0 @@
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, type HttpMethod } from "effect/unstable/http"
|
||||
import { ToolError, toolError } from "../tool-error.js"
|
||||
import { Tool, type Definition, type 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 id: string
|
||||
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 name: string
|
||||
readonly type: "apiKey" | "http" | "oauth2" | "openIdConnect"
|
||||
/** apiKey carrier declared by the spec. */
|
||||
readonly in: "header" | "query" | "cookie" | undefined
|
||||
/** apiKey parameter name declared by the spec. */
|
||||
readonly parameterName: string | undefined
|
||||
/** `http` scheme (`bearer`, `basic`, ...). */
|
||||
readonly scheme: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 schemeName: string
|
||||
readonly scheme: SecurityScheme
|
||||
readonly scopes: ReadonlyArray<string>
|
||||
readonly operation: Operation
|
||||
}) => Effect.Effect<Credential | undefined, unknown>
|
||||
|
||||
export type Options = {
|
||||
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
|
||||
/** 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
|
||||
/** Curate which operations become tools. Defaults to all. */
|
||||
readonly operations?: ((operation: Operation) => boolean) | 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
|
||||
}
|
||||
|
||||
type Skip = { readonly reason: string }
|
||||
|
||||
export type Tools = { readonly [name: string]: Definition<HttpClient.HttpClient> }
|
||||
|
||||
export type Result = {
|
||||
/** Tool subtree; the host places it under a key in its `tools` tree. */
|
||||
readonly tools: Tools
|
||||
readonly skipped: ReadonlyArray<Skipped>
|
||||
}
|
||||
|
||||
const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"])
|
||||
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"])
|
||||
const blockedOperationNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
const maxErrorBodyChars = 1_024
|
||||
|
||||
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 : [])
|
||||
|
||||
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`).
|
||||
const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
|
||||
Object.hasOwn(record, key) ? record[key] : undefined
|
||||
|
||||
/**
|
||||
* 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 base = options.baseUrl ?? specServerUrl(document)
|
||||
const used = new Set<string>()
|
||||
const skipped: Array<Skipped> = []
|
||||
const tools = Object.create(null) as Record<string, Definition<HttpClient.HttpClient>>
|
||||
|
||||
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 operation: Operation = {
|
||||
id: operationName(method, path, operationValue, used),
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: nonEmptyString(operationValue.summary),
|
||||
description: nonEmptyString(operationValue.description),
|
||||
}
|
||||
if (options.operations !== undefined && !options.operations(operation)) continue
|
||||
|
||||
if (typeof base !== "string") {
|
||||
skipped.push({ method: operation.method, path, reason: base.reason })
|
||||
continue
|
||||
}
|
||||
const body = requestBody(document, operationValue)
|
||||
if (body !== undefined && "reason" in body) {
|
||||
skipped.push({ method: operation.method, path, reason: body.reason })
|
||||
continue
|
||||
}
|
||||
|
||||
const security =
|
||||
operationValue.security === undefined ? defaultSecurity : securityRequirements(operationValue.security)
|
||||
const plan = {
|
||||
operation,
|
||||
url: `${base.replace(/\/+$/, "")}${path}`,
|
||||
parameters: operationParameters(document, pathValue, operationValue),
|
||||
body,
|
||||
security,
|
||||
schemes,
|
||||
auth: options.auth,
|
||||
headers: options.headers ?? {},
|
||||
}
|
||||
used.add(operation.id)
|
||||
tools[operation.id] = Tool.make({
|
||||
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
||||
input: inputSchema(plan.parameters, body, definitions),
|
||||
output: outputSchema(document, operationValue, definitions),
|
||||
run: (input) => invoke(plan, input),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { tools, skipped }
|
||||
}
|
||||
|
||||
export const OpenAPI = { fromSpec }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spec parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
/** 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 || !ref.startsWith("#/")) return value
|
||||
const target = ref
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), document)
|
||||
return target ?? value
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema projection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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" }] }
|
||||
}
|
||||
|
||||
/** All `components.schemas`, projected once and shared as `$defs` by every tool schema. */
|
||||
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(value)]))
|
||||
}
|
||||
|
||||
const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): JsonSchema =>
|
||||
Object.keys(definitions).length === 0 ? schema : { ...schema, $defs: definitions }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parameters and bodies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ParameterLocation = "path" | "query" | "header"
|
||||
|
||||
type Parameter = {
|
||||
readonly name: string
|
||||
readonly location: ParameterLocation
|
||||
readonly required: boolean
|
||||
readonly schema: JsonSchema
|
||||
}
|
||||
|
||||
const operationParameters = (
|
||||
document: Document,
|
||||
pathItem: Record<string, unknown>,
|
||||
operation: Record<string, unknown>,
|
||||
): ReadonlyArray<Parameter> => {
|
||||
// Operation-level parameters override path-level ones sharing (location, name).
|
||||
const merged = new Map<string, Parameter>()
|
||||
for (const raw of [...asArray(pathItem.parameters), ...asArray(operation.parameters)]) {
|
||||
const resolved = resolve(document, raw)
|
||||
if (!isRecord(resolved)) continue
|
||||
const name = nonEmptyString(resolved.name)
|
||||
const location = nonEmptyString(resolved.in)
|
||||
if (name === undefined || location === undefined || !parameterLocations.has(location)) continue
|
||||
if (location === "header" && ignoredHeaderParameters.has(name.toLowerCase())) continue
|
||||
const base = projectSchema(resolved.schema)
|
||||
const description = nonEmptyString(resolved.description)
|
||||
merged.set(`${location}:${name}`, {
|
||||
name,
|
||||
location: location as ParameterLocation,
|
||||
required: resolved.required === true || location === "path",
|
||||
schema: {
|
||||
...base,
|
||||
...(base.description === undefined && description !== undefined ? { description } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
type Body = { readonly required: boolean; readonly schema: JsonSchema }
|
||||
|
||||
const requestBody = (document: Document, operation: Record<string, unknown>): Body | Skip | undefined => {
|
||||
const resolved = resolve(document, operation.requestBody)
|
||||
if (!isRecord(resolved)) return undefined
|
||||
const content = isRecord(resolved.content) ? resolved.content : {}
|
||||
if (!Object.keys(content).some(isJsonMediaType)) {
|
||||
const declared = Object.keys(content).join(", ") || "none"
|
||||
return { reason: `request body has no JSON content (declared: ${declared})` }
|
||||
}
|
||||
return {
|
||||
required: resolved.required === true,
|
||||
schema: projectSchema(jsonContentSchema(content)),
|
||||
}
|
||||
}
|
||||
|
||||
const isJsonMediaType = (mediaType: string): boolean => {
|
||||
const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
|
||||
return normalized === "application/json" || normalized.endsWith("+json")
|
||||
}
|
||||
|
||||
const jsonContentSchema = (content: Record<string, unknown>): unknown => {
|
||||
const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
|
||||
return entry !== undefined && isRecord(entry[1]) ? entry[1].schema : undefined
|
||||
}
|
||||
|
||||
const inputSchema = (
|
||||
parameters: ReadonlyArray<Parameter>,
|
||||
body: Body | undefined,
|
||||
definitions: Readonly<Record<string, JsonSchema>>,
|
||||
): JsonSchema => {
|
||||
const groups: ReadonlyArray<{ readonly name: string; readonly location: ParameterLocation }> = [
|
||||
{ name: "path", location: "path" },
|
||||
{ name: "query", location: "query" },
|
||||
{ name: "headers", location: "header" },
|
||||
]
|
||||
const grouped = groups.flatMap((group) => {
|
||||
const items = parameters.filter((parameter) => parameter.location === group.location)
|
||||
if (items.length === 0) return []
|
||||
const required = items.filter((item) => item.required).map((item) => item.name)
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: Object.fromEntries(items.map((item) => [item.name, item.schema])),
|
||||
...(required.length === 0 ? {} : { required }),
|
||||
}
|
||||
return [{ name: group.name, schema, required: required.length > 0 }]
|
||||
})
|
||||
const properties = Object.fromEntries([
|
||||
...grouped.map((group) => [group.name, group.schema] as const),
|
||||
...(body === undefined ? [] : [["body", body.schema] as const]),
|
||||
])
|
||||
const required = [
|
||||
...grouped.filter((group) => group.required).map((group) => group.name),
|
||||
...(body?.required === true ? ["body"] : []),
|
||||
]
|
||||
return withDefinitions({ type: "object", properties, ...(required.length === 0 ? {} : { required }) }, definitions)
|
||||
}
|
||||
|
||||
const outputSchema = (
|
||||
document: Document,
|
||||
operation: Record<string, unknown>,
|
||||
definitions: Readonly<Record<string, JsonSchema>>,
|
||||
): JsonSchema | undefined => {
|
||||
if (!isRecord(operation.responses)) return undefined
|
||||
const entries = Object.entries(operation.responses)
|
||||
const successes = [
|
||||
...entries.filter(([status]) => /^2\d\d$/.test(status)).sort(([a], [b]) => a.localeCompare(b)),
|
||||
...entries.filter(([status]) => status.toUpperCase() === "2XX"),
|
||||
]
|
||||
.map(([, ref]) => resolve(document, ref))
|
||||
.filter(isRecord)
|
||||
for (const response of successes) {
|
||||
const schema = jsonContentSchema(isRecord(response.content) ? response.content : {})
|
||||
if (schema !== undefined) return withDefinitions(projectSchema(schema), definitions)
|
||||
}
|
||||
// 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
|
||||
// No-content success (e.g. 204) -> null.
|
||||
return successes.length > 0 ? { type: "null" } : undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Naming and servers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const operationName = (
|
||||
method: string,
|
||||
path: string,
|
||||
operation: Record<string, unknown>,
|
||||
used: ReadonlySet<string>,
|
||||
): string => {
|
||||
const raw = nonEmptyString(operation.operationId) ?? `${method}_${path.replaceAll(/[{}]/g, "")}`
|
||||
const base =
|
||||
raw
|
||||
.replaceAll(/[^A-Za-z0-9_$]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.replace(/^([0-9])/, "_$1") || "operation"
|
||||
if (!used.has(base) && !blockedOperationNames.has(base)) return base
|
||||
const next = (index: number): string => {
|
||||
const candidate = `${base}_${index}`
|
||||
return used.has(candidate) || blockedOperationNames.has(candidate) ? next(index + 1) : candidate
|
||||
}
|
||||
return next(2)
|
||||
}
|
||||
|
||||
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" }
|
||||
// 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` }
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */
|
||||
type SecurityRequirement = Readonly<Record<string, ReadonlyArray<string>>>
|
||||
|
||||
const securityRequirements = (value: unknown): ReadonlyArray<SecurityRequirement> =>
|
||||
asArray(value)
|
||||
.filter(isRecord)
|
||||
.map((requirement) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(requirement).map(([name, scopes]) => [
|
||||
name,
|
||||
asArray(scopes).filter((scope): scope is string => typeof scope === "string"),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
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(([name, value]) => {
|
||||
const resolved = resolve(document, value)
|
||||
if (!isRecord(resolved)) return []
|
||||
const type = nonEmptyString(resolved.type)
|
||||
if (type === undefined || !schemeTypes.has(type)) return []
|
||||
const carrier = nonEmptyString(resolved.in)
|
||||
return [
|
||||
[
|
||||
name,
|
||||
{
|
||||
name,
|
||||
type: type as SecurityScheme["type"],
|
||||
in: carrier === "header" || carrier === "query" || carrier === "cookie" ? carrier : undefined,
|
||||
parameterName: nonEmptyString(resolved.name),
|
||||
scheme: nonEmptyString(resolved.scheme)?.toLowerCase(),
|
||||
},
|
||||
] as const,
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invocation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Plan = {
|
||||
readonly operation: Operation
|
||||
readonly url: string
|
||||
readonly parameters: ReadonlyArray<Parameter>
|
||||
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>>
|
||||
}
|
||||
|
||||
type AppliedAuth = {
|
||||
readonly headers: Readonly<Record<string, string>>
|
||||
readonly query: Readonly<Record<string, string>>
|
||||
readonly cookies: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unknown, HttpClient.HttpClient> =>
|
||||
Effect.gen(function* () {
|
||||
const value = isRecord(input) ? input : {}
|
||||
const query = isRecord(value.query) ? value.query : {}
|
||||
const headers = isRecord(value.headers) ? value.headers : {}
|
||||
|
||||
// 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)
|
||||
if (plan.body?.required === true && value.body === undefined) {
|
||||
return yield* Effect.fail(toolError("Missing required request body."))
|
||||
}
|
||||
for (const parameter of plan.parameters) {
|
||||
if (!parameter.required || parameter.location === "path") continue
|
||||
const source = parameter.location === "query" ? query : headers
|
||||
const item = own(source, parameter.name)
|
||||
if (item === undefined || item === null) {
|
||||
return yield* Effect.fail(toolError(`Missing required ${parameter.location} parameter '${parameter.name}'.`))
|
||||
}
|
||||
}
|
||||
|
||||
const auth = yield* resolveAuth(plan)
|
||||
|
||||
let request = HttpClientRequest.make(plan.operation.method as HttpMethod.HttpMethod)(url)
|
||||
for (const parameter of plan.parameters) {
|
||||
if (parameter.location !== "query") continue
|
||||
const item = own(query, parameter.name)
|
||||
if (item === undefined || item === null) continue
|
||||
const rendered = Array.isArray(item) ? item.map(renderPrimitive) : [renderPrimitive(item)]
|
||||
for (const one of rendered) {
|
||||
request = HttpClientRequest.appendUrlParam(request, parameter.name, one)
|
||||
}
|
||||
}
|
||||
for (const [name, item] of Object.entries(auth.query)) {
|
||||
request = HttpClientRequest.setUrlParam(request, name, item)
|
||||
}
|
||||
// Host headers first, then declared header params, then auth - auth must win.
|
||||
request = HttpClientRequest.setHeaders(request, plan.headers)
|
||||
for (const parameter of plan.parameters) {
|
||||
if (parameter.location !== "header") continue
|
||||
const item = own(headers, parameter.name)
|
||||
if (item === undefined || item === null) continue
|
||||
request = HttpClientRequest.setHeader(request, parameter.name, renderPrimitive(item))
|
||||
}
|
||||
const cookiePairs = Object.entries(auth.cookies).map(([name, item]) => `${name}=${item}`)
|
||||
if (cookiePairs.length > 0) request = HttpClientRequest.setHeader(request, "cookie", cookiePairs.join("; "))
|
||||
request = HttpClientRequest.setHeaders(request, auth.headers)
|
||||
if (plan.body !== undefined && value.body !== undefined) {
|
||||
request = HttpClientRequest.bodyJsonUnsafe(request, value.body)
|
||||
}
|
||||
|
||||
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)),
|
||||
),
|
||||
)
|
||||
// Best effort: an unreadable body degrades to the status-only error/null result.
|
||||
const text = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const parsed = text === "" ? null : Option.getOrElse(decodeJson(text), () => text)
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return yield* Effect.fail(
|
||||
toolError(
|
||||
`${plan.operation.method} ${plan.operation.path} failed with HTTP ${response.status}: ${summarizeBody(parsed)}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return parsed
|
||||
})
|
||||
|
||||
const summarizeBody = (body: unknown): string => {
|
||||
const rendered = typeof body === "string" ? body : (JSON.stringify(body) ?? "")
|
||||
if (rendered === "" || rendered === "null") return "no response body"
|
||||
return rendered.length > maxErrorBodyChars ? `${rendered.slice(0, maxErrorBodyChars)}...` : rendered
|
||||
}
|
||||
|
||||
const renderPrimitive = (value: unknown): string =>
|
||||
typeof value === "object" && value !== null ? JSON.stringify(value) : String(value)
|
||||
|
||||
const buildUrl = (plan: Plan, path: Readonly<Record<string, unknown>>): string | ToolError => {
|
||||
let url = plan.url
|
||||
for (const parameter of plan.parameters) {
|
||||
if (parameter.location !== "path") continue
|
||||
const item = own(path, parameter.name)
|
||||
if (item === undefined || item === null) {
|
||||
return toolError(`Missing required path parameter '${parameter.name}'.`)
|
||||
}
|
||||
const rendered = encodeURIComponent(renderPrimitive(item))
|
||||
// '.'/'..' survive encoding and URL normalization collapses them, letting a
|
||||
// model-supplied value retarget the request to a different endpoint.
|
||||
if (rendered === "" || rendered === "." || rendered === "..") {
|
||||
return toolError(`Invalid path parameter '${parameter.name}'.`)
|
||||
}
|
||||
url = url.replaceAll(`{${parameter.name}}`, rendered)
|
||||
}
|
||||
const unresolved = url.match(/\{[^{}]+\}/)
|
||||
if (unresolved !== null) return toolError(`Unresolved path parameter ${unresolved[0]}.`)
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the operation's effective security: the first satisfiable OR
|
||||
* alternative wins in spec order, every scheme within it must resolve (AND),
|
||||
* and `{}` means unauthenticated is acceptable.
|
||||
*/
|
||||
const resolveAuth = (plan: Plan): Effect.Effect<AppliedAuth, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const none: AppliedAuth = { headers: {}, query: {}, cookies: {} }
|
||||
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 [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({
|
||||
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<readonly [SecurityScheme, Credential]>): AppliedAuth | ToolError => {
|
||||
const headers: Record<string, string> = {}
|
||||
const query: Record<string, string> = {}
|
||||
const cookies: Record<string, string> = {}
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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<AppliedAuth, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const none: AppliedAuth = { headers: {}, query: {}, cookies: {} }
|
||||
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 [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({
|
||||
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<readonly [SecurityScheme, Credential]>): AppliedAuth | ToolError => {
|
||||
const headers: Record<string, string> = {}
|
||||
const query: Record<string, string> = {}
|
||||
const cookies: Record<string, string> = {}
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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 {
|
||||
inputSchema,
|
||||
operationName,
|
||||
operationParameters,
|
||||
outputSchema,
|
||||
requestBody,
|
||||
securityRequirements,
|
||||
securitySchemes,
|
||||
specServerUrl,
|
||||
} from "./spec.js"
|
||||
import type { Operation, Options, Result, Skipped } 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 base = options.baseUrl ?? specServerUrl(document)
|
||||
const used = new Set<string>()
|
||||
const skipped: Array<Skipped> = []
|
||||
const tools = Object.create(null) as Record<string, Definition<HttpClient.HttpClient>>
|
||||
|
||||
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 operation: Operation = {
|
||||
id: operationName(method, path, operationValue, used),
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: nonEmptyString(operationValue.summary),
|
||||
description: nonEmptyString(operationValue.description),
|
||||
}
|
||||
if (options.operations !== undefined && !options.operations(operation)) continue
|
||||
|
||||
if (typeof base !== "string") {
|
||||
skipped.push({ method: operation.method, path, reason: base.reason })
|
||||
continue
|
||||
}
|
||||
const body = requestBody(document, operationValue)
|
||||
if (body !== undefined && "reason" in body) {
|
||||
skipped.push({ method: operation.method, path, reason: body.reason })
|
||||
continue
|
||||
}
|
||||
|
||||
const security =
|
||||
operationValue.security === undefined ? defaultSecurity : securityRequirements(operationValue.security)
|
||||
const plan = {
|
||||
operation,
|
||||
url: `${base.replace(/\/+$/, "")}${path}`,
|
||||
parameters: operationParameters(document, pathValue, operationValue),
|
||||
body,
|
||||
security,
|
||||
schemes,
|
||||
auth: options.auth,
|
||||
headers: options.headers ?? {},
|
||||
}
|
||||
used.add(operation.id)
|
||||
tools[operation.id] = Tool.make({
|
||||
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
||||
input: inputSchema(plan.parameters, body, definitions),
|
||||
output: outputSchema(document, operationValue, definitions),
|
||||
run: (input) => invoke(plan, input),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { tools, skipped }
|
||||
}
|
||||
|
||||
export const OpenAPI = { fromSpec }
|
||||
@@ -0,0 +1,110 @@
|
||||
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"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unknown, HttpClient.HttpClient> =>
|
||||
Effect.gen(function* () {
|
||||
const value = isRecord(input) ? input : {}
|
||||
const query = isRecord(value.query) ? value.query : {}
|
||||
const headers = isRecord(value.headers) ? value.headers : {}
|
||||
|
||||
// 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)
|
||||
if (plan.body?.required === true && value.body === undefined) {
|
||||
return yield* Effect.fail(toolError("Missing required request body."))
|
||||
}
|
||||
for (const parameter of plan.parameters) {
|
||||
if (!parameter.required || parameter.location === "path") continue
|
||||
const source = parameter.location === "query" ? query : headers
|
||||
const item = own(source, parameter.name)
|
||||
if (item === undefined || item === null) {
|
||||
return yield* Effect.fail(toolError(`Missing required ${parameter.location} parameter '${parameter.name}'.`))
|
||||
}
|
||||
}
|
||||
|
||||
const auth = yield* resolveAuth(plan)
|
||||
|
||||
let request = HttpClientRequest.make(plan.operation.method as HttpMethod.HttpMethod)(url)
|
||||
for (const parameter of plan.parameters) {
|
||||
if (parameter.location !== "query") continue
|
||||
const item = own(query, parameter.name)
|
||||
if (item === undefined || item === null) continue
|
||||
const rendered = Array.isArray(item) ? item.map(renderPrimitive) : [renderPrimitive(item)]
|
||||
for (const one of rendered) {
|
||||
request = HttpClientRequest.appendUrlParam(request, parameter.name, one)
|
||||
}
|
||||
}
|
||||
for (const [name, item] of Object.entries(auth.query)) {
|
||||
request = HttpClientRequest.setUrlParam(request, name, item)
|
||||
}
|
||||
// Host headers first, then declared header params, then auth - auth must win.
|
||||
request = HttpClientRequest.setHeaders(request, plan.headers)
|
||||
for (const parameter of plan.parameters) {
|
||||
if (parameter.location !== "header") continue
|
||||
const item = own(headers, parameter.name)
|
||||
if (item === undefined || item === null) continue
|
||||
request = HttpClientRequest.setHeader(request, parameter.name, renderPrimitive(item))
|
||||
}
|
||||
const cookiePairs = Object.entries(auth.cookies).map(([name, item]) => `${name}=${item}`)
|
||||
if (cookiePairs.length > 0) request = HttpClientRequest.setHeader(request, "cookie", cookiePairs.join("; "))
|
||||
request = HttpClientRequest.setHeaders(request, auth.headers)
|
||||
if (plan.body !== undefined && value.body !== undefined) {
|
||||
request = HttpClientRequest.bodyJsonUnsafe(request, value.body)
|
||||
}
|
||||
|
||||
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)),
|
||||
),
|
||||
)
|
||||
// Best effort: an unreadable body degrades to the status-only error/null result.
|
||||
const text = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const parsed = text === "" ? null : Option.getOrElse(decodeJson(text), () => text)
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return yield* Effect.fail(
|
||||
toolError(
|
||||
`${plan.operation.method} ${plan.operation.path} failed with HTTP ${response.status}: ${summarizeBody(parsed)}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return parsed
|
||||
})
|
||||
|
||||
const summarizeBody = (body: unknown): string => {
|
||||
const rendered = typeof body === "string" ? body : (JSON.stringify(body) ?? "")
|
||||
if (rendered === "" || rendered === "null") return "no response body"
|
||||
return rendered.length > maxErrorBodyChars ? `${rendered.slice(0, maxErrorBodyChars)}...` : rendered
|
||||
}
|
||||
|
||||
const renderPrimitive = (value: unknown): string =>
|
||||
typeof value === "object" && value !== null ? JSON.stringify(value) : String(value)
|
||||
|
||||
const buildUrl = (plan: Plan, path: Readonly<Record<string, unknown>>): string | ToolError => {
|
||||
let url = plan.url
|
||||
for (const parameter of plan.parameters) {
|
||||
if (parameter.location !== "path") continue
|
||||
const item = own(path, parameter.name)
|
||||
if (item === undefined || item === null) {
|
||||
return toolError(`Missing required path parameter '${parameter.name}'.`)
|
||||
}
|
||||
const rendered = encodeURIComponent(renderPrimitive(item))
|
||||
// '.'/'..' survive encoding and URL normalization collapses them, letting a
|
||||
// model-supplied value retarget the request to a different endpoint.
|
||||
if (rendered === "" || rendered === "." || rendered === "..") {
|
||||
return toolError(`Invalid path parameter '${parameter.name}'.`)
|
||||
}
|
||||
url = url.replaceAll(`{${parameter.name}}`, rendered)
|
||||
}
|
||||
const unresolved = url.match(/\{[^{}]+\}/)
|
||||
if (unresolved !== null) return toolError(`Unresolved path parameter ${unresolved[0]}.`)
|
||||
return url
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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<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(value)]))
|
||||
}
|
||||
|
||||
export const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): 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<string, unknown>): unknown => {
|
||||
const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
|
||||
return entry !== undefined && isRecord(entry[1]) ? entry[1].schema : undefined
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
|
||||
export 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
|
||||
@@ -0,0 +1,194 @@
|
||||
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 resolve = (document: Document, value: unknown): unknown => {
|
||||
if (!isRecord(value)) return value
|
||||
const ref = nonEmptyString(value.$ref)
|
||||
if (ref === undefined || !ref.startsWith("#/")) return value
|
||||
const target = ref
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), document)
|
||||
return target ?? value
|
||||
}
|
||||
|
||||
export const operationParameters = (
|
||||
document: Document,
|
||||
pathItem: Record<string, unknown>,
|
||||
operation: Record<string, unknown>,
|
||||
): ReadonlyArray<Parameter> => {
|
||||
// Operation-level parameters override path-level ones sharing (location, name).
|
||||
const merged = new Map<string, Parameter>()
|
||||
for (const raw of [...asArray(pathItem.parameters), ...asArray(operation.parameters)]) {
|
||||
const resolved = resolve(document, raw)
|
||||
if (!isRecord(resolved)) continue
|
||||
const name = nonEmptyString(resolved.name)
|
||||
const location = nonEmptyString(resolved.in)
|
||||
if (name === undefined || location === undefined || !parameterLocations.has(location)) continue
|
||||
if (location === "header" && ignoredHeaderParameters.has(name.toLowerCase())) continue
|
||||
const base = projectSchema(resolved.schema)
|
||||
const description = nonEmptyString(resolved.description)
|
||||
merged.set(`${location}:${name}`, {
|
||||
name,
|
||||
location: location as ParameterLocation,
|
||||
required: resolved.required === true || location === "path",
|
||||
schema: {
|
||||
...base,
|
||||
...(base.description === undefined && description !== undefined ? { description } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
export const requestBody = (document: Document, operation: Record<string, unknown>): Body | Skip | undefined => {
|
||||
const resolved = resolve(document, operation.requestBody)
|
||||
if (!isRecord(resolved)) return undefined
|
||||
const content = isRecord(resolved.content) ? resolved.content : {}
|
||||
if (!Object.keys(content).some(isJsonMediaType)) {
|
||||
const declared = Object.keys(content).join(", ") || "none"
|
||||
return { reason: `request body has no JSON content (declared: ${declared})` }
|
||||
}
|
||||
return {
|
||||
required: resolved.required === true,
|
||||
schema: projectSchema(jsonContentSchema(content)),
|
||||
}
|
||||
}
|
||||
|
||||
export const inputSchema = (
|
||||
parameters: ReadonlyArray<Parameter>,
|
||||
body: Body | undefined,
|
||||
definitions: Readonly<Record<string, JsonSchema>>,
|
||||
): JsonSchema => {
|
||||
const groups: ReadonlyArray<{ readonly name: string; readonly location: ParameterLocation }> = [
|
||||
{ name: "path", location: "path" },
|
||||
{ name: "query", location: "query" },
|
||||
{ name: "headers", location: "header" },
|
||||
]
|
||||
const grouped = groups.flatMap((group) => {
|
||||
const items = parameters.filter((parameter) => parameter.location === group.location)
|
||||
if (items.length === 0) return []
|
||||
const required = items.filter((item) => item.required).map((item) => item.name)
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: Object.fromEntries(items.map((item) => [item.name, item.schema])),
|
||||
...(required.length === 0 ? {} : { required }),
|
||||
}
|
||||
return [{ name: group.name, schema, required: required.length > 0 }]
|
||||
})
|
||||
const properties = Object.fromEntries([
|
||||
...grouped.map((group) => [group.name, group.schema] as const),
|
||||
...(body === undefined ? [] : [["body", body.schema] as const]),
|
||||
])
|
||||
const required = [
|
||||
...grouped.filter((group) => group.required).map((group) => group.name),
|
||||
...(body?.required === true ? ["body"] : []),
|
||||
]
|
||||
return withDefinitions({ type: "object", properties, ...(required.length === 0 ? {} : { required }) }, definitions)
|
||||
}
|
||||
|
||||
export const outputSchema = (
|
||||
document: Document,
|
||||
operation: Record<string, unknown>,
|
||||
definitions: Readonly<Record<string, JsonSchema>>,
|
||||
): JsonSchema | undefined => {
|
||||
if (!isRecord(operation.responses)) return undefined
|
||||
const entries = Object.entries(operation.responses)
|
||||
const successes = [
|
||||
...entries.filter(([status]) => /^2\d\d$/.test(status)).sort(([a], [b]) => a.localeCompare(b)),
|
||||
...entries.filter(([status]) => status.toUpperCase() === "2XX"),
|
||||
]
|
||||
.map(([, ref]) => resolve(document, ref))
|
||||
.filter(isRecord)
|
||||
for (const response of successes) {
|
||||
const schema = jsonContentSchema(isRecord(response.content) ? response.content : {})
|
||||
if (schema !== undefined) return withDefinitions(projectSchema(schema), definitions)
|
||||
}
|
||||
// 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
|
||||
// No-content success (e.g. 204) -> null.
|
||||
return successes.length > 0 ? { type: "null" } : undefined
|
||||
}
|
||||
|
||||
export const operationName = (
|
||||
method: string,
|
||||
path: string,
|
||||
operation: Record<string, unknown>,
|
||||
used: ReadonlySet<string>,
|
||||
): string => {
|
||||
const raw = nonEmptyString(operation.operationId) ?? `${method}_${path.replaceAll(/[{}]/g, "")}`
|
||||
const base =
|
||||
raw
|
||||
.replaceAll(/[^A-Za-z0-9_$]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.replace(/^([0-9])/, "_$1") || "operation"
|
||||
if (!used.has(base) && !blockedOperationNames.has(base)) return base
|
||||
const next = (index: number): string => {
|
||||
const candidate = `${base}_${index}`
|
||||
return used.has(candidate) || blockedOperationNames.has(candidate) ? next(index + 1) : candidate
|
||||
}
|
||||
return next(2)
|
||||
}
|
||||
|
||||
export 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" }
|
||||
// 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` }
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
export const securityRequirements = (value: unknown): ReadonlyArray<SecurityRequirement> =>
|
||||
asArray(value)
|
||||
.filter(isRecord)
|
||||
.map((requirement) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(requirement).map(([name, scopes]) => [
|
||||
name,
|
||||
asArray(scopes).filter((scope): scope is string => typeof scope === "string"),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
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(([name, value]) => {
|
||||
const resolved = resolve(document, value)
|
||||
if (!isRecord(resolved)) return []
|
||||
const type = nonEmptyString(resolved.type)
|
||||
if (type === undefined || !schemeTypes.has(type)) return []
|
||||
const carrier = nonEmptyString(resolved.in)
|
||||
return [
|
||||
[
|
||||
name,
|
||||
{
|
||||
name,
|
||||
type: type as SecurityScheme["type"],
|
||||
in: carrier === "header" || carrier === "query" || carrier === "cookie" ? carrier : undefined,
|
||||
parameterName: nonEmptyString(resolved.name),
|
||||
scheme: nonEmptyString(resolved.scheme)?.toLowerCase(),
|
||||
},
|
||||
] as const,
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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 id: string
|
||||
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 name: string
|
||||
readonly type: "apiKey" | "http" | "oauth2" | "openIdConnect"
|
||||
/** apiKey carrier declared by the spec. */
|
||||
readonly in: "header" | "query" | "cookie" | undefined
|
||||
/** apiKey parameter name declared by the spec. */
|
||||
readonly parameterName: string | undefined
|
||||
/** `http` scheme (`bearer`, `basic`, ...). */
|
||||
readonly scheme: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 schemeName: string
|
||||
readonly scheme: SecurityScheme
|
||||
readonly scopes: ReadonlyArray<string>
|
||||
readonly operation: Operation
|
||||
}) => Effect.Effect<Credential | undefined, unknown>
|
||||
|
||||
export type Options = {
|
||||
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
|
||||
/** 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
|
||||
/** Curate which operations become tools. Defaults to all. */
|
||||
readonly operations?: ((operation: Operation) => boolean) | 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 = { readonly [name: string]: Definition<HttpClient.HttpClient> }
|
||||
|
||||
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 Skip = { readonly reason: string }
|
||||
|
||||
export type ParameterLocation = "path" | "query" | "header"
|
||||
|
||||
export type Parameter = {
|
||||
readonly name: string
|
||||
readonly location: ParameterLocation
|
||||
readonly required: boolean
|
||||
readonly schema: JsonSchema
|
||||
}
|
||||
|
||||
export type Body = { readonly required: boolean; readonly schema: JsonSchema }
|
||||
|
||||
/** 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 parameters: ReadonlyArray<Parameter>
|
||||
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>>
|
||||
readonly cookies: Readonly<Record<string, string>>
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { CodeMode, toolError } from "../src/index.js"
|
||||
import { OpenAPI } from "../src/adapters/openapi.js"
|
||||
import { OpenAPI } from "../src/adapters/openapi/index.js"
|
||||
|
||||
// The example spec exercises: global apiKey default, an operation-level OAuth2
|
||||
// override with scopes, path/query parameters, a JSON request body, component
|
||||
|
||||
Reference in New Issue
Block a user