refactor(codemode): simplify OpenAPI adapter split
This commit is contained in:
@@ -1,76 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { ToolError, toolError } from "../../tool-error.js"
|
||||
import { own } from "./shared.js"
|
||||
import type { AppliedAuth, Credential, Plan, SecurityScheme } from "./types.js"
|
||||
|
||||
export const resolveAuth = (plan: Plan): Effect.Effect<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 }
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Tool, type Definition } from "../../tool.js"
|
||||
import { invoke } from "./invoke.js"
|
||||
import { componentDefinitions } from "./schema.js"
|
||||
import { isRecord, methods, nonEmptyString } from "./shared.js"
|
||||
import { invoke } from "./runtime.js"
|
||||
import {
|
||||
componentDefinitions,
|
||||
inputSchema,
|
||||
isRecord,
|
||||
methods,
|
||||
nonEmptyString,
|
||||
operationName,
|
||||
operationParameters,
|
||||
outputSchema,
|
||||
|
||||
+74
-3
@@ -1,9 +1,8 @@
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, type HttpMethod } from "effect/unstable/http"
|
||||
import { ToolError, toolError } from "../../tool-error.js"
|
||||
import { resolveAuth } from "./auth.js"
|
||||
import { isRecord, maxErrorBodyChars, own } from "./shared.js"
|
||||
import type { Plan } from "./types.js"
|
||||
import { isRecord, maxErrorBodyChars, own } from "./spec.js"
|
||||
import type { AppliedAuth, Credential, Plan, SecurityScheme } from "./types.js"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
@@ -79,6 +78,78 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unkno
|
||||
return parsed
|
||||
})
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
const summarizeBody = (body: unknown): string => {
|
||||
const rendered = typeof body === "string" ? body : (JSON.stringify(body) ?? "")
|
||||
if (rendered === "" || rendered === "null") return "no response body"
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { JsonSchema } from "../../tool.js"
|
||||
import { isRecord, nonEmptyString } from "./shared.js"
|
||||
import type { Document } from "./types.js"
|
||||
|
||||
export const projectSchema = (value: unknown, depth = 0): JsonSchema => {
|
||||
if (depth > 24 || !isRecord(value)) return {}
|
||||
const ref = nonEmptyString(value.$ref)
|
||||
if (ref !== undefined) {
|
||||
// `#/components/schemas/X` becomes `#/$defs/X`, the only ref form the
|
||||
// signature renderer resolves. `~` is unescaped to match the `$defs` key;
|
||||
// `/` must stay escaped because the renderer takes the last `/` segment.
|
||||
const name = ref.match(/^#\/components\/schemas\/(.+)$/)?.[1]
|
||||
return { $ref: name === undefined ? ref : `#/$defs/${name.replaceAll("~0", "~")}` }
|
||||
}
|
||||
|
||||
const type = Array.isArray(value.type)
|
||||
? value.type.filter((item): item is string => typeof item === "string")
|
||||
: nonEmptyString(value.type)
|
||||
const description = nonEmptyString(value.description)
|
||||
const format = nonEmptyString(value.format)
|
||||
const projected: JsonSchema = {
|
||||
...(type === undefined ? {} : { type }),
|
||||
...(Array.isArray(value.enum) ? { enum: value.enum } : {}),
|
||||
...(value.const === undefined ? {} : { const: value.const }),
|
||||
...(Array.isArray(value.anyOf) ? { anyOf: value.anyOf.map((item) => projectSchema(item, depth + 1)) } : {}),
|
||||
...(Array.isArray(value.oneOf) ? { oneOf: value.oneOf.map((item) => projectSchema(item, depth + 1)) } : {}),
|
||||
...(Array.isArray(value.allOf) ? { allOf: value.allOf.map((item) => projectSchema(item, depth + 1)) } : {}),
|
||||
...(isRecord(value.properties)
|
||||
? {
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(value.properties).map(([key, item]) => [key, projectSchema(item, depth + 1)]),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(Array.isArray(value.required)
|
||||
? { required: value.required.filter((item): item is string => typeof item === "string") }
|
||||
: {}),
|
||||
...(isRecord(value.items) ? { items: projectSchema(value.items, depth + 1) } : {}),
|
||||
...(typeof value.additionalProperties === "boolean"
|
||||
? { additionalProperties: value.additionalProperties }
|
||||
: isRecord(value.additionalProperties)
|
||||
? { additionalProperties: projectSchema(value.additionalProperties, depth + 1) }
|
||||
: {}),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(value.default === undefined ? {} : { default: value.default }),
|
||||
...(format === undefined ? {} : { format }),
|
||||
...(value.deprecated === true ? { deprecated: true } : {}),
|
||||
...(typeof value.minItems === "number" ? { minItems: value.minItems } : {}),
|
||||
...(typeof value.maxItems === "number" ? { maxItems: value.maxItems } : {}),
|
||||
}
|
||||
// OpenAPI 3.0 nullable -> union with null, matching what 3.1 expresses via type arrays.
|
||||
if (value.nullable !== true) return projected
|
||||
if (Array.isArray(projected.type)) return { ...projected, type: [...projected.type, "null"] }
|
||||
if (typeof projected.type === "string") return { ...projected, type: [projected.type, "null"] }
|
||||
return { anyOf: [projected, { type: "null" }] }
|
||||
}
|
||||
|
||||
export const componentDefinitions = (document: Document): Readonly<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
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
export const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"])
|
||||
export const parameterLocations = new Set(["path", "query", "header"])
|
||||
|
||||
// OpenAPI: header parameters with these names SHALL be ignored.
|
||||
export const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"])
|
||||
export const schemeTypes = new Set(["apiKey", "http", "oauth2", "openIdConnect"])
|
||||
export const blockedOperationNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
export const maxErrorBodyChars = 1_024
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<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
|
||||
@@ -1,16 +1,26 @@
|
||||
import type { JsonSchema } from "../../tool.js"
|
||||
import {
|
||||
asArray,
|
||||
blockedOperationNames,
|
||||
ignoredHeaderParameters,
|
||||
isRecord,
|
||||
nonEmptyString,
|
||||
parameterLocations,
|
||||
schemeTypes,
|
||||
} from "./shared.js"
|
||||
import { isJsonMediaType, jsonContentSchema, projectSchema, withDefinitions } from "./schema.js"
|
||||
import type { Body, Document, Parameter, ParameterLocation, SecurityRequirement, SecurityScheme, Skip } from "./types.js"
|
||||
|
||||
export const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"])
|
||||
const parameterLocations = new Set(["path", "query", "header"])
|
||||
const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"])
|
||||
const schemeTypes = new Set(["apiKey", "http", "oauth2", "openIdConnect"])
|
||||
const blockedOperationNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
export const maxErrorBodyChars = 1_024
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<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 => {
|
||||
if (!isRecord(value)) return value
|
||||
const ref = nonEmptyString(value.$ref)
|
||||
@@ -23,6 +33,78 @@ export const resolve = (document: Document, value: unknown): unknown => {
|
||||
return target ?? value
|
||||
}
|
||||
|
||||
const projectSchema = (value: unknown, depth = 0): JsonSchema => {
|
||||
if (depth > 24 || !isRecord(value)) return {}
|
||||
const ref = nonEmptyString(value.$ref)
|
||||
if (ref !== undefined) {
|
||||
// `#/components/schemas/X` becomes `#/$defs/X`, the only ref form the
|
||||
// signature renderer resolves. `~` is unescaped to match the `$defs` key;
|
||||
// `/` must stay escaped because the renderer takes the last `/` segment.
|
||||
const name = ref.match(/^#\/components\/schemas\/(.+)$/)?.[1]
|
||||
return { $ref: name === undefined ? ref : `#/$defs/${name.replaceAll("~0", "~")}` }
|
||||
}
|
||||
|
||||
const type = Array.isArray(value.type)
|
||||
? value.type.filter((item): item is string => typeof item === "string")
|
||||
: nonEmptyString(value.type)
|
||||
const description = nonEmptyString(value.description)
|
||||
const format = nonEmptyString(value.format)
|
||||
const projected: JsonSchema = {
|
||||
...(type === undefined ? {} : { type }),
|
||||
...(Array.isArray(value.enum) ? { enum: value.enum } : {}),
|
||||
...(value.const === undefined ? {} : { const: value.const }),
|
||||
...(Array.isArray(value.anyOf) ? { anyOf: value.anyOf.map((item) => projectSchema(item, depth + 1)) } : {}),
|
||||
...(Array.isArray(value.oneOf) ? { oneOf: value.oneOf.map((item) => projectSchema(item, depth + 1)) } : {}),
|
||||
...(Array.isArray(value.allOf) ? { allOf: value.allOf.map((item) => projectSchema(item, depth + 1)) } : {}),
|
||||
...(isRecord(value.properties)
|
||||
? {
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(value.properties).map(([key, item]) => [key, projectSchema(item, depth + 1)]),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(Array.isArray(value.required)
|
||||
? { required: value.required.filter((item): item is string => typeof item === "string") }
|
||||
: {}),
|
||||
...(isRecord(value.items) ? { items: projectSchema(value.items, depth + 1) } : {}),
|
||||
...(typeof value.additionalProperties === "boolean"
|
||||
? { additionalProperties: value.additionalProperties }
|
||||
: isRecord(value.additionalProperties)
|
||||
? { additionalProperties: projectSchema(value.additionalProperties, depth + 1) }
|
||||
: {}),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(value.default === undefined ? {} : { default: value.default }),
|
||||
...(format === undefined ? {} : { format }),
|
||||
...(value.deprecated === true ? { deprecated: true } : {}),
|
||||
...(typeof value.minItems === "number" ? { minItems: value.minItems } : {}),
|
||||
...(typeof value.maxItems === "number" ? { maxItems: value.maxItems } : {}),
|
||||
}
|
||||
// OpenAPI 3.0 nullable -> union with null, matching what 3.1 expresses via type arrays.
|
||||
if (value.nullable !== true) return projected
|
||||
if (Array.isArray(projected.type)) return { ...projected, type: [...projected.type, "null"] }
|
||||
if (typeof projected.type === "string") return { ...projected, type: [projected.type, "null"] }
|
||||
return { anyOf: [projected, { type: "null" }] }
|
||||
}
|
||||
|
||||
export const componentDefinitions = (document: Document): Readonly<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 }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export const operationParameters = (
|
||||
document: Document,
|
||||
pathItem: Record<string, unknown>,
|
||||
|
||||
Reference in New Issue
Block a user