@neurocode-ai/codemode 1.18.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +23 -0
- package/README.md +369 -0
- package/codemode.md +176 -0
- package/package.json +25 -0
- package/src/codemode.ts +159 -0
- package/src/index.ts +4 -0
- package/src/interpreter/model.ts +201 -0
- package/src/interpreter/runtime.ts +3465 -0
- package/src/openapi/TODO.md +19 -0
- package/src/openapi/index.ts +130 -0
- package/src/openapi/runtime.ts +326 -0
- package/src/openapi/spec.ts +511 -0
- package/src/openapi/types.ts +112 -0
- package/src/stdlib/collections.ts +51 -0
- package/src/stdlib/console.ts +4 -0
- package/src/stdlib/date.ts +94 -0
- package/src/stdlib/json.ts +42 -0
- package/src/stdlib/math.ts +65 -0
- package/src/stdlib/number.ts +66 -0
- package/src/stdlib/object.ts +77 -0
- package/src/stdlib/promise.ts +6 -0
- package/src/stdlib/regexp.ts +74 -0
- package/src/stdlib/string.ts +52 -0
- package/src/stdlib/url.ts +90 -0
- package/src/stdlib/value.ts +86 -0
- package/src/tool-error.ts +11 -0
- package/src/tool-runtime.ts +806 -0
- package/src/tool-schema.ts +301 -0
- package/src/tool.ts +96 -0
- package/src/values.ts +49 -0
- package/sst-env.d.ts +10 -0
- package/test/codemode.test.ts +1163 -0
- package/test/enumeration.test.ts +159 -0
- package/test/fixtures/openapi-happy-path.json +230 -0
- package/test/fixtures/opencode-v2-openapi.json +23730 -0
- package/test/openapi.test.ts +964 -0
- package/test/parity.test.ts +425 -0
- package/test/promise.test.ts +456 -0
- package/test/signature.test.ts +449 -0
- package/test/stdlib.test.ts +715 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# OpenAPI Follow-ups
|
|
2
|
+
|
|
3
|
+
The initial adapter intentionally skips operations it cannot execute correctly. Future work may add:
|
|
4
|
+
|
|
5
|
+
- Cookie parameters, authentication, and cookie-header merging.
|
|
6
|
+
- Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization.
|
|
7
|
+
- External references and complete nested `$defs` support.
|
|
8
|
+
- Relative or templated server URLs and server variables.
|
|
9
|
+
- Base URLs containing query strings or fragments.
|
|
10
|
+
- Runtime response-schema validation and full content negotiation.
|
|
11
|
+
- Binary response values and explicit byte-oriented return types.
|
|
12
|
+
- Request/response projection for `readOnly` and `writeOnly` properties.
|
|
13
|
+
- SSE, WebSocket, and other streaming transports.
|
|
14
|
+
- Recovery of responses rejected by a status-filtering `HttpClient`.
|
|
15
|
+
- Configurable request and response size limits.
|
|
16
|
+
- Adapter-enforced redirect policy independent of the supplied `HttpClient`.
|
|
17
|
+
- Strict UTF-8 and empty-body validation for JSON responses.
|
|
18
|
+
- Compile-time rejection of parameter schemas with nested values unsupported by their serialization style; runtime rejects them before auth resolution.
|
|
19
|
+
- Complete malformed-security-scheme validation and broader auth-combination coverage.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { HttpClient } from "effect/unstable/http"
|
|
2
|
+
import { make, type Definition } from "../tool.js"
|
|
3
|
+
import { invoke } from "./runtime.js"
|
|
4
|
+
import {
|
|
5
|
+
componentDefinitions,
|
|
6
|
+
inputSchema,
|
|
7
|
+
isRecord,
|
|
8
|
+
methods,
|
|
9
|
+
nonEmptyString,
|
|
10
|
+
operationInput,
|
|
11
|
+
operationOutput,
|
|
12
|
+
operationPath,
|
|
13
|
+
operationSecurityRequirements,
|
|
14
|
+
securityRequirements,
|
|
15
|
+
securitySchemes,
|
|
16
|
+
specServerUrl,
|
|
17
|
+
validateBaseUrl,
|
|
18
|
+
} from "./spec.js"
|
|
19
|
+
import type { Operation, Options, Result, Skipped, Tools } from "./types.js"
|
|
20
|
+
|
|
21
|
+
export type {
|
|
22
|
+
AuthResolver,
|
|
23
|
+
Credential,
|
|
24
|
+
Document,
|
|
25
|
+
Operation,
|
|
26
|
+
Options,
|
|
27
|
+
Result,
|
|
28
|
+
SecurityScheme,
|
|
29
|
+
Skipped,
|
|
30
|
+
Tools,
|
|
31
|
+
} from "./types.js"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per
|
|
35
|
+
* operation. Auth is resolved host-side via `auth.resolve` and never
|
|
36
|
+
* model-visible. Tools require `HttpClient.HttpClient`; unrepresentable
|
|
37
|
+
* operations land in `skipped`.
|
|
38
|
+
*/
|
|
39
|
+
export const fromSpec = (options: Options): Result => {
|
|
40
|
+
const document = options.spec
|
|
41
|
+
const schemes = securitySchemes(document)
|
|
42
|
+
const defaultSecurity = securityRequirements(document.security)
|
|
43
|
+
const definitions = componentDefinitions(document)
|
|
44
|
+
const paths = isRecord(document.paths) ? document.paths : {}
|
|
45
|
+
const used = new Set<string>()
|
|
46
|
+
const namespaces = new Set<string>()
|
|
47
|
+
const skipped: Array<Skipped> = []
|
|
48
|
+
const tools = Object.create(null) as Tools
|
|
49
|
+
|
|
50
|
+
for (const [path, pathValue] of Object.entries(paths)) {
|
|
51
|
+
if (!isRecord(pathValue)) continue
|
|
52
|
+
for (const [method, operationValue] of Object.entries(pathValue)) {
|
|
53
|
+
if (!methods.has(method) || !isRecord(operationValue)) continue
|
|
54
|
+
const segments = operationPath(method, path, operationValue, used, namespaces)
|
|
55
|
+
const operation: Operation = {
|
|
56
|
+
operationId: nonEmptyString(operationValue.operationId),
|
|
57
|
+
method: method.toUpperCase(),
|
|
58
|
+
path,
|
|
59
|
+
summary: nonEmptyString(operationValue.summary),
|
|
60
|
+
description: nonEmptyString(operationValue.description),
|
|
61
|
+
}
|
|
62
|
+
const output = operationOutput(document, operationValue, definitions)
|
|
63
|
+
if (!output.ok) {
|
|
64
|
+
skipped.push({ method: operation.method, path, reason: output.reason })
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const resolvedBaseUrl = (() => {
|
|
69
|
+
if (options.baseUrl !== undefined) return validateBaseUrl(options.baseUrl)
|
|
70
|
+
if (operationValue.servers !== undefined) return specServerUrl(operationValue)
|
|
71
|
+
if (pathValue.servers !== undefined) return specServerUrl(pathValue)
|
|
72
|
+
return specServerUrl(document)
|
|
73
|
+
})()
|
|
74
|
+
if (!resolvedBaseUrl.ok) {
|
|
75
|
+
skipped.push({ method: operation.method, path, reason: resolvedBaseUrl.reason })
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
const parsedInput = operationInput(document, pathValue, operationValue)
|
|
79
|
+
if (!parsedInput.ok) {
|
|
80
|
+
skipped.push({ method: operation.method, path, reason: parsedInput.reason })
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
const input = parsedInput.value
|
|
84
|
+
|
|
85
|
+
const security = operationSecurityRequirements(operationValue.security, defaultSecurity, schemes)
|
|
86
|
+
if (!security.ok) {
|
|
87
|
+
skipped.push({ method: operation.method, path, reason: security.reason })
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const plan = {
|
|
91
|
+
operation,
|
|
92
|
+
url: `${resolvedBaseUrl.value.replace(/\/+$/, "")}${path}`,
|
|
93
|
+
fields: input.fields,
|
|
94
|
+
body: input.body,
|
|
95
|
+
security: security.value,
|
|
96
|
+
schemes,
|
|
97
|
+
auth: options.auth,
|
|
98
|
+
headers: options.headers ?? {},
|
|
99
|
+
}
|
|
100
|
+
used.add(segments.join("."))
|
|
101
|
+
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
|
|
102
|
+
setTool(
|
|
103
|
+
tools,
|
|
104
|
+
segments,
|
|
105
|
+
make({
|
|
106
|
+
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
|
107
|
+
input: inputSchema(input.fields, definitions),
|
|
108
|
+
output: output.value,
|
|
109
|
+
run: (input) => invoke(plan, input),
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { tools, skipped }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
|
|
119
|
+
const [head, ...rest] = path
|
|
120
|
+
if (head === undefined) return
|
|
121
|
+
if (rest.length === 0) {
|
|
122
|
+
tools[head] = definition
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
const child = tools[head]
|
|
126
|
+
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
|
|
127
|
+
tools[head] = Object.create(null) as Tools
|
|
128
|
+
}
|
|
129
|
+
setTool(tools[head] as Tools, rest, definition)
|
|
130
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { Effect, Option, Schema, Stream } from "effect"
|
|
2
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse, type HttpMethod } from "effect/unstable/http"
|
|
3
|
+
import { ToolError, toolError } from "../tool-error.js"
|
|
4
|
+
import { isRecord, own } from "./spec.js"
|
|
5
|
+
import type { AppliedAuth, Credential, Plan, SecurityScheme } from "./types.js"
|
|
6
|
+
|
|
7
|
+
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
|
8
|
+
const maxErrorBodyChars = 1_024
|
|
9
|
+
const maxResponseBodyBytes = 50 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unknown, HttpClient.HttpClient> =>
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
const value = isRecord(input) ? input : {}
|
|
14
|
+
|
|
15
|
+
let request = yield* buildRequest(plan, value)
|
|
16
|
+
|
|
17
|
+
const auth = yield* resolveAuth(plan)
|
|
18
|
+
for (const [name, item] of Object.entries(auth.query)) {
|
|
19
|
+
request = HttpClientRequest.setUrlParam(request, name, item)
|
|
20
|
+
}
|
|
21
|
+
request = HttpClientRequest.setHeaders(request, auth.headers)
|
|
22
|
+
|
|
23
|
+
const client = yield* HttpClient.HttpClient
|
|
24
|
+
const response = yield* client
|
|
25
|
+
.execute(request)
|
|
26
|
+
.pipe(
|
|
27
|
+
Effect.catch((cause) =>
|
|
28
|
+
Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause)),
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
const text = yield* readResponseBody(response, plan)
|
|
32
|
+
const mediaType = response.headers["content-type"]?.split(";")[0]?.trim().toLowerCase()
|
|
33
|
+
const json = mediaType === "application/json" || mediaType?.endsWith("+json") === true
|
|
34
|
+
const decoded = text === "" ? Option.some(null) : json ? decodeJson(text) : Option.none()
|
|
35
|
+
const parsed = json ? Option.getOrElse(decoded, () => text) : text === "" ? null : text
|
|
36
|
+
if (response.status < 200 || response.status >= 300) {
|
|
37
|
+
const rendered = typeof parsed === "string" ? parsed : (JSON.stringify(parsed) ?? "")
|
|
38
|
+
const summary =
|
|
39
|
+
rendered === "" || rendered === "null"
|
|
40
|
+
? "no response body"
|
|
41
|
+
: rendered.length > maxErrorBodyChars
|
|
42
|
+
? `${rendered.slice(0, maxErrorBodyChars)}...`
|
|
43
|
+
: rendered
|
|
44
|
+
return yield* Effect.fail(
|
|
45
|
+
toolError(`${plan.operation.method} ${plan.operation.path} failed with HTTP ${response.status}: ${summary}`),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
if (json && Option.isNone(decoded)) {
|
|
49
|
+
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`))
|
|
50
|
+
}
|
|
51
|
+
return parsed
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const buildRequest = (
|
|
55
|
+
plan: Plan,
|
|
56
|
+
input: Readonly<Record<string, unknown>>,
|
|
57
|
+
): Effect.Effect<HttpClientRequest.HttpClientRequest, ToolError> =>
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
// Validate every model-controlled value before auth resolution, which may refresh tokens.
|
|
60
|
+
const url = buildUrl(plan, input)
|
|
61
|
+
if (url instanceof ToolError) return yield* Effect.fail(url)
|
|
62
|
+
const missing = plan.fields.find(
|
|
63
|
+
(field) => field.required && field.location !== "path" && own(input, field.inputName) === undefined,
|
|
64
|
+
)
|
|
65
|
+
if (missing !== undefined) {
|
|
66
|
+
const label = missing.location === "body" ? "body field" : `${missing.location} parameter`
|
|
67
|
+
return yield* Effect.fail(toolError(`Missing required ${label} '${missing.inputName}'.`))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let request = HttpClientRequest.make(plan.operation.method as HttpMethod.HttpMethod)(url)
|
|
71
|
+
for (const field of plan.fields) {
|
|
72
|
+
if (field.location !== "query") continue
|
|
73
|
+
const item = own(input, field.inputName)
|
|
74
|
+
if (item === undefined) continue
|
|
75
|
+
const serialized = serializeQuery(request, field, item)
|
|
76
|
+
if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
|
|
77
|
+
request = serialized
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Host headers first, then declared header parameters.
|
|
81
|
+
request = HttpClientRequest.setHeaders(request, plan.headers)
|
|
82
|
+
for (const field of plan.fields) {
|
|
83
|
+
if (field.location !== "header") continue
|
|
84
|
+
const item = own(input, field.inputName)
|
|
85
|
+
if (item === undefined) continue
|
|
86
|
+
const serialized = serializeSimple(field, item, String)
|
|
87
|
+
if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
|
|
88
|
+
request = HttpClientRequest.setHeader(request, field.name, serialized)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const setBody = (value: unknown, mediaType: string) =>
|
|
92
|
+
HttpClientRequest.bodyJson(request, value).pipe(
|
|
93
|
+
Effect.map((next) => HttpClientRequest.setHeader(next, "content-type", mediaType)),
|
|
94
|
+
Effect.mapError((cause) =>
|
|
95
|
+
toolError(`Invalid JSON body for ${plan.operation.method} ${plan.operation.path}.`, cause),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
if (plan.body?.mode === "value") {
|
|
99
|
+
const field = plan.fields.find((field) => field.location === "body")
|
|
100
|
+
const body = field === undefined ? undefined : own(input, field.inputName)
|
|
101
|
+
if (body !== undefined) request = yield* setBody(body, plan.body.mediaType)
|
|
102
|
+
}
|
|
103
|
+
if (plan.body?.mode === "object") {
|
|
104
|
+
const entries = plan.fields.flatMap((field) => {
|
|
105
|
+
if (field.location !== "body") return []
|
|
106
|
+
const item = own(input, field.inputName)
|
|
107
|
+
return item === undefined ? [] : [[field.name, item] as const]
|
|
108
|
+
})
|
|
109
|
+
if (plan.body.required || entries.length > 0) {
|
|
110
|
+
request = yield* setBody(Object.fromEntries(entries), plan.body.mediaType)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return request
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const resolveAuth = (plan: Plan): Effect.Effect<AppliedAuth, unknown> =>
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
const none: AppliedAuth = { headers: {}, query: {} }
|
|
119
|
+
if (plan.security.length === 0) return none
|
|
120
|
+
|
|
121
|
+
const unavailable: Array<string> = []
|
|
122
|
+
alternatives: for (const requirement of plan.security) {
|
|
123
|
+
const names = Object.keys(requirement)
|
|
124
|
+
if (names.length === 0) return none
|
|
125
|
+
const credentials: Array<readonly [string, SecurityScheme, Credential]> = []
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
const scheme = own(plan.schemes, name)
|
|
128
|
+
if (scheme === undefined || plan.auth === undefined) {
|
|
129
|
+
unavailable.push(name)
|
|
130
|
+
continue alternatives
|
|
131
|
+
}
|
|
132
|
+
const credential = yield* plan.auth.resolve({
|
|
133
|
+
name,
|
|
134
|
+
definition: scheme,
|
|
135
|
+
scopes: requirement[name] ?? [],
|
|
136
|
+
operation: plan.operation,
|
|
137
|
+
})
|
|
138
|
+
if (credential === undefined) {
|
|
139
|
+
unavailable.push(name)
|
|
140
|
+
continue alternatives
|
|
141
|
+
}
|
|
142
|
+
credentials.push([name, scheme, credential])
|
|
143
|
+
}
|
|
144
|
+
const applied = applyCredentials(credentials)
|
|
145
|
+
return applied instanceof ToolError ? yield* Effect.fail(applied) : applied
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return yield* Effect.fail(
|
|
149
|
+
toolError(
|
|
150
|
+
`${plan.operation.method} ${plan.operation.path} requires authentication; no credential available for: ${[...new Set(unavailable)].join(", ")}.`,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const applyCredentials = (
|
|
156
|
+
credentials: ReadonlyArray<readonly [string, SecurityScheme, Credential]>,
|
|
157
|
+
): AppliedAuth | ToolError => {
|
|
158
|
+
const headers = new Map<string, string>()
|
|
159
|
+
const query = new Map<string, string>()
|
|
160
|
+
const add = (carrier: "header" | "query", name: string, value: string): ToolError | undefined => {
|
|
161
|
+
const target = carrier === "header" ? headers : query
|
|
162
|
+
if (target.has(name)) return toolError(`Authentication resolves multiple credentials for ${carrier} '${name}'.`)
|
|
163
|
+
target.set(name, value)
|
|
164
|
+
}
|
|
165
|
+
for (const [name, definition, credential] of credentials) {
|
|
166
|
+
if (credential.type === "bearer") {
|
|
167
|
+
const duplicate = add("header", "authorization", `Bearer ${credential.token}`)
|
|
168
|
+
if (duplicate !== undefined) return duplicate
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
if (credential.type === "basic") {
|
|
172
|
+
// Buffer instead of btoa: btoa throws on non-Latin-1 credentials.
|
|
173
|
+
const duplicate = add(
|
|
174
|
+
"header",
|
|
175
|
+
"authorization",
|
|
176
|
+
`Basic ${Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64")}`,
|
|
177
|
+
)
|
|
178
|
+
if (duplicate !== undefined) return duplicate
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
if (credential.type === "header") {
|
|
182
|
+
const duplicate = add("header", credential.name.toLowerCase(), credential.value)
|
|
183
|
+
if (duplicate !== undefined) return duplicate
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
// apiKey: the carrier comes from the scheme declaration.
|
|
187
|
+
if (definition.type !== "apiKey") {
|
|
188
|
+
return toolError(
|
|
189
|
+
`Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
if (definition.in === "cookie") return toolError(`Cookie authentication '${name}' is not supported.`)
|
|
193
|
+
const parameter = definition.in === "header" ? definition.name.toLowerCase() : definition.name
|
|
194
|
+
const duplicate = add(definition.in, parameter, credential.value)
|
|
195
|
+
if (duplicate !== undefined) return duplicate
|
|
196
|
+
}
|
|
197
|
+
return { headers: Object.fromEntries(headers), query: Object.fromEntries(query) }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string | ToolError => {
|
|
201
|
+
let url = plan.url
|
|
202
|
+
for (const field of plan.fields) {
|
|
203
|
+
if (field.location !== "path") continue
|
|
204
|
+
const item = own(input, field.inputName)
|
|
205
|
+
if (item === undefined) {
|
|
206
|
+
return toolError(`Missing required path parameter '${field.inputName}'.`)
|
|
207
|
+
}
|
|
208
|
+
const fieldValue = serializeSimple(field, item, (value) =>
|
|
209
|
+
encodeURIComponent(value).replace(
|
|
210
|
+
/[!'()*]/g,
|
|
211
|
+
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
if (fieldValue instanceof ToolError) return fieldValue
|
|
215
|
+
// '.'/'..' survive encoding and URL normalization collapses them, letting a
|
|
216
|
+
// model-supplied value retarget the request to a different endpoint.
|
|
217
|
+
if (fieldValue === "" || fieldValue === "." || fieldValue === "..") {
|
|
218
|
+
return toolError(`Invalid path parameter '${field.inputName}'.`)
|
|
219
|
+
}
|
|
220
|
+
url = url.replaceAll(`{${field.name}}`, fieldValue)
|
|
221
|
+
}
|
|
222
|
+
const unresolved = url.match(/\{[^{}]+\}/)
|
|
223
|
+
if (unresolved !== null) return toolError(`Unresolved path parameter ${unresolved[0]}.`)
|
|
224
|
+
return url
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const serializeSimple = (
|
|
228
|
+
field: Plan["fields"][number],
|
|
229
|
+
value: unknown,
|
|
230
|
+
encode: (value: string) => string,
|
|
231
|
+
): string | ToolError => {
|
|
232
|
+
const scalar = (item: unknown): string | ToolError =>
|
|
233
|
+
item !== null && typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean"
|
|
234
|
+
? toolError(`Parameter '${field.inputName}' contains an unsupported nested value.`)
|
|
235
|
+
: encode(String(item))
|
|
236
|
+
if (Array.isArray(value)) {
|
|
237
|
+
const items = value.map(scalar)
|
|
238
|
+
const invalid = items.find((item): item is ToolError => item instanceof ToolError)
|
|
239
|
+
return invalid ?? items.join(",")
|
|
240
|
+
}
|
|
241
|
+
if (!isRecord(value)) return scalar(value)
|
|
242
|
+
const entries = Object.entries(value).flatMap<string | ToolError>(([name, item]) => {
|
|
243
|
+
const rendered = scalar(item)
|
|
244
|
+
if (rendered instanceof ToolError) return [rendered]
|
|
245
|
+
return field.explode ? [`${encode(name)}=${rendered}`] : [encode(name), rendered]
|
|
246
|
+
})
|
|
247
|
+
const invalid = entries.find((item): item is ToolError => item instanceof ToolError)
|
|
248
|
+
return invalid ?? entries.join(",")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const serializeQuery = (
|
|
252
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
253
|
+
field: Plan["fields"][number],
|
|
254
|
+
value: unknown,
|
|
255
|
+
): HttpClientRequest.HttpClientRequest | ToolError => {
|
|
256
|
+
if (field.style === "deepObject") {
|
|
257
|
+
if (!isRecord(value)) return toolError(`Deep-object parameter '${field.inputName}' must be an object.`)
|
|
258
|
+
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
|
|
259
|
+
if (current instanceof ToolError) return current
|
|
260
|
+
if (item === undefined || (item !== null && typeof item === "object")) {
|
|
261
|
+
return toolError(`Deep-object parameter '${field.inputName}' contains an unsupported nested value.`)
|
|
262
|
+
}
|
|
263
|
+
return HttpClientRequest.appendUrlParam(current, `${field.name}[${name}]`, String(item))
|
|
264
|
+
}, request)
|
|
265
|
+
}
|
|
266
|
+
if (Array.isArray(value)) {
|
|
267
|
+
const rendered = serializeSimple(field, value, String)
|
|
268
|
+
if (rendered instanceof ToolError) return rendered
|
|
269
|
+
if (!field.explode) return HttpClientRequest.appendUrlParam(request, field.name, rendered)
|
|
270
|
+
if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) {
|
|
271
|
+
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
|
|
272
|
+
}
|
|
273
|
+
return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request)
|
|
274
|
+
}
|
|
275
|
+
if (isRecord(value) && field.explode) {
|
|
276
|
+
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
|
|
277
|
+
if (current instanceof ToolError) return current
|
|
278
|
+
if (item === undefined || (item !== null && typeof item === "object")) {
|
|
279
|
+
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
|
|
280
|
+
}
|
|
281
|
+
return HttpClientRequest.appendUrlParam(current, name, String(item))
|
|
282
|
+
}, request)
|
|
283
|
+
}
|
|
284
|
+
const rendered = serializeSimple(field, value, String)
|
|
285
|
+
return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const readResponseBody = (
|
|
289
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
290
|
+
plan: Plan,
|
|
291
|
+
): Effect.Effect<string, ToolError> =>
|
|
292
|
+
Effect.gen(function* () {
|
|
293
|
+
const contentLength = response.headers["content-length"]
|
|
294
|
+
const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10)
|
|
295
|
+
const declaredSize =
|
|
296
|
+
parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
|
|
297
|
+
if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) {
|
|
298
|
+
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
|
|
299
|
+
}
|
|
300
|
+
let body = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, declaredSize ?? 64 * 1024))
|
|
301
|
+
let size = 0
|
|
302
|
+
yield* Stream.runForEach(response.stream, (chunk) => {
|
|
303
|
+
if (size + chunk.byteLength > maxResponseBodyBytes) {
|
|
304
|
+
return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
|
|
305
|
+
}
|
|
306
|
+
if (size + chunk.byteLength > body.byteLength) {
|
|
307
|
+
const grown = Buffer.allocUnsafe(
|
|
308
|
+
Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)),
|
|
309
|
+
)
|
|
310
|
+
body.copy(grown, 0, 0, size)
|
|
311
|
+
body = grown
|
|
312
|
+
}
|
|
313
|
+
body.set(chunk, size)
|
|
314
|
+
size += chunk.byteLength
|
|
315
|
+
return Effect.void
|
|
316
|
+
}).pipe(
|
|
317
|
+
Effect.catch((cause) => {
|
|
318
|
+
if (cause instanceof ToolError) return Effect.fail(cause)
|
|
319
|
+
if (cause.reason._tag === "EmptyBodyError") return Effect.void
|
|
320
|
+
return Effect.fail(
|
|
321
|
+
toolError(`${plan.operation.method} ${plan.operation.path} failed while reading the response body.`, cause),
|
|
322
|
+
)
|
|
323
|
+
}),
|
|
324
|
+
)
|
|
325
|
+
return new TextDecoder().decode(body.subarray(0, size))
|
|
326
|
+
})
|