324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
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))
|
|
})
|