@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,511 @@
|
|
|
1
|
+
import { fromSchemaOpenApi3_0, fromSchemaOpenApi3_1 } from "effect/JsonSchema"
|
|
2
|
+
import type { JsonSchema } from "../tool.js"
|
|
3
|
+
import { isBlockedMember } from "../tool-runtime.js"
|
|
4
|
+
import type {
|
|
5
|
+
Body,
|
|
6
|
+
Document,
|
|
7
|
+
InputField,
|
|
8
|
+
OperationInput,
|
|
9
|
+
Parsed,
|
|
10
|
+
SecurityRequirement,
|
|
11
|
+
SecurityScheme,
|
|
12
|
+
} from "./types.js"
|
|
13
|
+
|
|
14
|
+
export const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"])
|
|
15
|
+
const parameterLocations = ["path", "query", "header"] as const
|
|
16
|
+
const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"])
|
|
17
|
+
|
|
18
|
+
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
19
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
20
|
+
|
|
21
|
+
const asArray = (value: unknown): ReadonlyArray<unknown> => (Array.isArray(value) ? value : [])
|
|
22
|
+
|
|
23
|
+
export const nonEmptyString = (value: unknown): string | undefined =>
|
|
24
|
+
typeof value === "string" && value !== "" ? value : undefined
|
|
25
|
+
|
|
26
|
+
// Guards record lookups keyed by spec- or model-controlled names against
|
|
27
|
+
// prototype-inherited values (e.g. a parameter named `toString`).
|
|
28
|
+
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
|
|
29
|
+
Object.hasOwn(record, key) ? record[key] : undefined
|
|
30
|
+
|
|
31
|
+
export const resolve = (document: Document, value: unknown): unknown => {
|
|
32
|
+
const next = (current: unknown, seen: ReadonlySet<string>): unknown => {
|
|
33
|
+
if (!isRecord(current)) return current
|
|
34
|
+
const ref = nonEmptyString(current.$ref)
|
|
35
|
+
if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current
|
|
36
|
+
const target = ref
|
|
37
|
+
.slice(2)
|
|
38
|
+
.split("/")
|
|
39
|
+
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
40
|
+
.reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document)
|
|
41
|
+
return target === undefined ? current : next(target, new Set([...seen, ref]))
|
|
42
|
+
}
|
|
43
|
+
return next(value, new Set())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const projectSchema = (document: Document, value: unknown): JsonSchema => {
|
|
47
|
+
if (!isRecord(value)) return {}
|
|
48
|
+
const normalized = nonEmptyString(document.openapi)?.startsWith("3.0")
|
|
49
|
+
? fromSchemaOpenApi3_0(value)
|
|
50
|
+
: fromSchemaOpenApi3_1(value)
|
|
51
|
+
return Object.keys(normalized.definitions).length === 0
|
|
52
|
+
? normalized.schema
|
|
53
|
+
: { ...normalized.schema, $defs: normalized.definitions }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const componentDefinitions = (document: Document): Readonly<Record<string, JsonSchema>> => {
|
|
57
|
+
const components = isRecord(document.components) ? document.components : {}
|
|
58
|
+
const schemas = isRecord(components.schemas) ? components.schemas : {}
|
|
59
|
+
return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value)]))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): JsonSchema => {
|
|
63
|
+
if (Object.keys(definitions).length === 0) return schema
|
|
64
|
+
const local = isRecord(schema.$defs) ? schema.$defs : {}
|
|
65
|
+
return { ...schema, $defs: { ...definitions, ...local } }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isJsonMediaType = (mediaType: string): boolean => {
|
|
69
|
+
const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
|
|
70
|
+
return normalized === "application/json" || normalized.endsWith("+json")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isBinaryMediaType = (document: Document, mediaType: string, value: unknown): boolean => {
|
|
74
|
+
const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
|
|
75
|
+
if (!isJsonMediaType(normalized) && !normalized.startsWith("text/")) return true
|
|
76
|
+
if (!isRecord(value)) return false
|
|
77
|
+
const schema = resolve(document, value.schema)
|
|
78
|
+
return isRecord(schema) && schema.format === "binary"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const jsonContent = (
|
|
82
|
+
content: Record<string, unknown>,
|
|
83
|
+
): { readonly mediaType: string; readonly schema: unknown } | undefined => {
|
|
84
|
+
const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
|
|
85
|
+
return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isFlattenableObjectBody = (
|
|
89
|
+
schema: unknown,
|
|
90
|
+
requestRequired: boolean,
|
|
91
|
+
): schema is Record<string, unknown> & { readonly properties: Record<string, unknown> } =>
|
|
92
|
+
isRecord(schema) &&
|
|
93
|
+
requestRequired &&
|
|
94
|
+
schema.type === "object" &&
|
|
95
|
+
isRecord(schema.properties) &&
|
|
96
|
+
schema.additionalProperties === false &&
|
|
97
|
+
schema.nullable !== true &&
|
|
98
|
+
schema.allOf === undefined &&
|
|
99
|
+
schema.anyOf === undefined &&
|
|
100
|
+
schema.oneOf === undefined
|
|
101
|
+
|
|
102
|
+
type PlannedField = Omit<InputField, "inputName">
|
|
103
|
+
|
|
104
|
+
const operationParameters = (
|
|
105
|
+
document: Document,
|
|
106
|
+
pathItem: Record<string, unknown>,
|
|
107
|
+
operation: Record<string, unknown>,
|
|
108
|
+
): Parsed<ReadonlyArray<PlannedField>> => {
|
|
109
|
+
// Operation-level parameters override path-level ones sharing (location, name).
|
|
110
|
+
const declared = new Map<
|
|
111
|
+
string,
|
|
112
|
+
{ readonly name: string; readonly location: string; readonly parameter: Record<string, unknown> }
|
|
113
|
+
>()
|
|
114
|
+
for (const raw of [...asArray(pathItem.parameters), ...asArray(operation.parameters)]) {
|
|
115
|
+
const resolved = resolve(document, raw)
|
|
116
|
+
if (!isRecord(resolved)) return { ok: false, reason: "parameter declaration is invalid or unresolved" }
|
|
117
|
+
const name = nonEmptyString(resolved.name)
|
|
118
|
+
const location = nonEmptyString(resolved.in)
|
|
119
|
+
if (name === undefined || location === undefined)
|
|
120
|
+
return { ok: false, reason: "parameter declaration is missing name or location" }
|
|
121
|
+
declared.set(`${location}:${name}`, { name, location, parameter: resolved })
|
|
122
|
+
}
|
|
123
|
+
const unordered: Array<PlannedField> = []
|
|
124
|
+
for (const item of declared.values()) {
|
|
125
|
+
const name = item.name
|
|
126
|
+
const location = item.location
|
|
127
|
+
const resolved = item.parameter
|
|
128
|
+
if (location === "cookie") return { ok: false, reason: `cookie parameter '${name}' is not supported` }
|
|
129
|
+
if (location !== "path" && location !== "query" && location !== "header") {
|
|
130
|
+
return { ok: false, reason: `parameter '${name}' uses unsupported location '${location}'` }
|
|
131
|
+
}
|
|
132
|
+
if (location === "header" && ignoredHeaderParameters.has(name.toLowerCase())) continue
|
|
133
|
+
if (resolved.schema === undefined && resolved.content === undefined) {
|
|
134
|
+
return { ok: false, reason: `parameter '${name}' declares neither schema nor content` }
|
|
135
|
+
}
|
|
136
|
+
if (resolved.content !== undefined)
|
|
137
|
+
return { ok: false, reason: `parameter '${name}' uses unsupported content encoding` }
|
|
138
|
+
if (resolved.style !== undefined && nonEmptyString(resolved.style) === undefined) {
|
|
139
|
+
return { ok: false, reason: `parameter '${name}' has an invalid style` }
|
|
140
|
+
}
|
|
141
|
+
if (resolved.explode !== undefined && typeof resolved.explode !== "boolean") {
|
|
142
|
+
return { ok: false, reason: `parameter '${name}' has an invalid explode value` }
|
|
143
|
+
}
|
|
144
|
+
if (resolved.allowReserved !== undefined && typeof resolved.allowReserved !== "boolean") {
|
|
145
|
+
return { ok: false, reason: `parameter '${name}' has an invalid allowReserved value` }
|
|
146
|
+
}
|
|
147
|
+
if (resolved.allowReserved === true)
|
|
148
|
+
return { ok: false, reason: `parameter '${name}' uses unsupported allowReserved encoding` }
|
|
149
|
+
const declaredStyle = nonEmptyString(resolved.style) ?? (location === "query" ? "form" : "simple")
|
|
150
|
+
if (location === "query" && declaredStyle !== "form" && declaredStyle !== "deepObject") {
|
|
151
|
+
return { ok: false, reason: `query parameter '${name}' uses unsupported style '${declaredStyle}'` }
|
|
152
|
+
}
|
|
153
|
+
if (location !== "query" && declaredStyle !== "simple") {
|
|
154
|
+
return { ok: false, reason: `${location} parameter '${name}' uses unsupported style '${declaredStyle}'` }
|
|
155
|
+
}
|
|
156
|
+
const style = declaredStyle === "deepObject" ? "deepObject" : declaredStyle === "form" ? "form" : "simple"
|
|
157
|
+
const explode = typeof resolved.explode === "boolean" ? resolved.explode : style === "form"
|
|
158
|
+
if (style === "deepObject" && !explode) {
|
|
159
|
+
return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` }
|
|
160
|
+
}
|
|
161
|
+
const base = projectSchema(document, resolved.schema)
|
|
162
|
+
const description = nonEmptyString(resolved.description)
|
|
163
|
+
unordered.push({
|
|
164
|
+
name,
|
|
165
|
+
location,
|
|
166
|
+
required: resolved.required === true || location === "path",
|
|
167
|
+
style,
|
|
168
|
+
explode,
|
|
169
|
+
schema: {
|
|
170
|
+
...base,
|
|
171
|
+
...(base.description === undefined && description !== undefined ? { description } : {}),
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
value: parameterLocations.flatMap((location) => unordered.filter((field) => field.location === location)),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const operationBody = (
|
|
182
|
+
document: Document,
|
|
183
|
+
operation: Record<string, unknown>,
|
|
184
|
+
): Parsed<{ readonly fields: ReadonlyArray<PlannedField>; readonly body: Body | undefined }> => {
|
|
185
|
+
const resolved = resolve(document, operation.requestBody)
|
|
186
|
+
if (!isRecord(resolved)) return { ok: true, value: { fields: [], body: undefined } }
|
|
187
|
+
const content = isRecord(resolved.content) ? resolved.content : {}
|
|
188
|
+
const selected = jsonContent(content)
|
|
189
|
+
if (selected === undefined) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const schema = resolve(document, selected.schema)
|
|
196
|
+
const required = resolved.required === true
|
|
197
|
+
if (!isFlattenableObjectBody(schema, required)) {
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
value: {
|
|
201
|
+
fields: [
|
|
202
|
+
{
|
|
203
|
+
name: "body",
|
|
204
|
+
location: "body",
|
|
205
|
+
required,
|
|
206
|
+
schema: projectSchema(document, selected.schema),
|
|
207
|
+
style: undefined,
|
|
208
|
+
explode: undefined,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
body: { required, mode: "value", mediaType: selected.mediaType },
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const requiredProperties = new Set(
|
|
216
|
+
Array.isArray(schema.required) ? schema.required.filter((item): item is string => typeof item === "string") : [],
|
|
217
|
+
)
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
value: {
|
|
221
|
+
fields: Object.entries(schema.properties).map(([name, value]) => ({
|
|
222
|
+
name,
|
|
223
|
+
location: "body" as const,
|
|
224
|
+
required: required && requiredProperties.has(name),
|
|
225
|
+
schema: projectSchema(document, value),
|
|
226
|
+
style: undefined,
|
|
227
|
+
explode: undefined,
|
|
228
|
+
})),
|
|
229
|
+
body: { required, mode: "object", mediaType: selected.mediaType },
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const operationInput = (
|
|
235
|
+
document: Document,
|
|
236
|
+
pathItem: Record<string, unknown>,
|
|
237
|
+
operation: Record<string, unknown>,
|
|
238
|
+
): Parsed<OperationInput> => {
|
|
239
|
+
const parameters = operationParameters(document, pathItem, operation)
|
|
240
|
+
if (!parameters.ok) return parameters
|
|
241
|
+
const requestBody = operationBody(document, operation)
|
|
242
|
+
if (!requestBody.ok) return requestBody
|
|
243
|
+
const fields = [...parameters.value, ...requestBody.value.fields]
|
|
244
|
+
|
|
245
|
+
const conflicts = new Set(
|
|
246
|
+
[...Map.groupBy(fields, (field) => field.name)]
|
|
247
|
+
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
|
|
248
|
+
.map(([name]) => name),
|
|
249
|
+
)
|
|
250
|
+
const used = new Set<string>()
|
|
251
|
+
return {
|
|
252
|
+
ok: true,
|
|
253
|
+
value: {
|
|
254
|
+
fields: fields.map((field) => {
|
|
255
|
+
const visibleName = isBlockedMember(field.name) ? `${field.name}_2` : field.name
|
|
256
|
+
const base = conflicts.has(field.name) ? `${field.location}_${visibleName}` : visibleName
|
|
257
|
+
const next = (index: number): string => {
|
|
258
|
+
const candidate = index === 1 ? base : `${base}_${index}`
|
|
259
|
+
return used.has(candidate) ? next(index + 1) : candidate
|
|
260
|
+
}
|
|
261
|
+
const inputName = next(1)
|
|
262
|
+
used.add(inputName)
|
|
263
|
+
return { ...field, inputName }
|
|
264
|
+
}),
|
|
265
|
+
body: requestBody.value.body,
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const inputSchema = (
|
|
271
|
+
fields: ReadonlyArray<InputField>,
|
|
272
|
+
definitions: Readonly<Record<string, JsonSchema>>,
|
|
273
|
+
): JsonSchema => {
|
|
274
|
+
const required = fields.filter((field) => field.required).map((field) => field.inputName)
|
|
275
|
+
return withDefinitions(
|
|
276
|
+
{
|
|
277
|
+
type: "object",
|
|
278
|
+
properties: Object.fromEntries(fields.map((field) => [field.inputName, field.schema])),
|
|
279
|
+
...(required.length === 0 ? {} : { required }),
|
|
280
|
+
},
|
|
281
|
+
definitions,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const successfulResponses = (
|
|
286
|
+
document: Document,
|
|
287
|
+
operation: Record<string, unknown>,
|
|
288
|
+
): Parsed<ReadonlyArray<Record<string, unknown>>> => {
|
|
289
|
+
if (!isRecord(operation.responses)) return { ok: true, value: [] }
|
|
290
|
+
const entries = Object.entries(operation.responses)
|
|
291
|
+
const selected = [
|
|
292
|
+
...entries.filter(([status]) => /^2\d\d$/.test(status)).sort(([a], [b]) => a.localeCompare(b)),
|
|
293
|
+
...entries.filter(([status]) => status.toUpperCase() === "2XX"),
|
|
294
|
+
]
|
|
295
|
+
const responses: Array<Record<string, unknown>> = []
|
|
296
|
+
for (const [, value] of selected) {
|
|
297
|
+
const resolved = resolve(document, value)
|
|
298
|
+
if (!isRecord(resolved) || nonEmptyString(resolved.$ref) !== undefined) {
|
|
299
|
+
return { ok: false, reason: "successful response declaration is invalid or unresolved" }
|
|
300
|
+
}
|
|
301
|
+
responses.push(resolved)
|
|
302
|
+
}
|
|
303
|
+
return { ok: true, value: responses }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export const operationOutput = (
|
|
307
|
+
document: Document,
|
|
308
|
+
operation: Record<string, unknown>,
|
|
309
|
+
definitions: Readonly<Record<string, JsonSchema>>,
|
|
310
|
+
): Parsed<JsonSchema | undefined> => {
|
|
311
|
+
if (operation["x-websocket"] === true) return { ok: false, reason: "WebSocket operations are not supported" }
|
|
312
|
+
const responses = successfulResponses(document, operation)
|
|
313
|
+
if (!responses.ok) return responses
|
|
314
|
+
const streams = responses.value.some(
|
|
315
|
+
(response) =>
|
|
316
|
+
isRecord(response.content) &&
|
|
317
|
+
Object.keys(response.content).some(
|
|
318
|
+
(mediaType) => mediaType.split(";")[0]?.trim().toLowerCase() === "text/event-stream",
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
if (streams) return { ok: false, reason: "SSE operations are not supported" }
|
|
322
|
+
const binary = responses.value.some(
|
|
323
|
+
(response) =>
|
|
324
|
+
isRecord(response.content) &&
|
|
325
|
+
Object.entries(response.content).some(([mediaType, value]) => isBinaryMediaType(document, mediaType, value)),
|
|
326
|
+
)
|
|
327
|
+
if (binary) return { ok: false, reason: "binary responses are not supported" }
|
|
328
|
+
|
|
329
|
+
const outcomes: Array<JsonSchema> = []
|
|
330
|
+
for (const response of responses.value) {
|
|
331
|
+
if (response.content !== undefined && !isRecord(response.content)) return { ok: true, value: undefined }
|
|
332
|
+
const content = isRecord(response.content) ? response.content : {}
|
|
333
|
+
if (Object.keys(content).length === 0) {
|
|
334
|
+
outcomes.push({ type: "null" })
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
for (const [mediaType, value] of Object.entries(content)) {
|
|
338
|
+
if (!isJsonMediaType(mediaType)) {
|
|
339
|
+
outcomes.push({ type: "string" })
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined }
|
|
343
|
+
outcomes.push(projectSchema(document, value.schema))
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (outcomes.length === 0) return { ok: true, value: undefined }
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
value: withDefinitions(outcomes.length === 1 ? (outcomes[0] ?? {}) : { anyOf: outcomes }, definitions),
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const sanitizeOperationSegment = (raw: string): string => {
|
|
354
|
+
const base =
|
|
355
|
+
raw
|
|
356
|
+
.replaceAll(/[^A-Za-z0-9_$]+/g, "_")
|
|
357
|
+
.replace(/^_+|_+$/g, "")
|
|
358
|
+
.replace(/^([0-9])/, "_$1") || "operation"
|
|
359
|
+
return isBlockedMember(base) ? `${base}_2` : base
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const fallbackOperationId = (method: string, path: string): string =>
|
|
363
|
+
[
|
|
364
|
+
method,
|
|
365
|
+
...path
|
|
366
|
+
.split("/")
|
|
367
|
+
.filter((part) => part !== "")
|
|
368
|
+
.flatMap((part) => (part.startsWith("{") && part.endsWith("}") ? ["by", part.slice(1, -1)] : [part]))
|
|
369
|
+
.flatMap((part) => part.split(/[^A-Za-z0-9]+/).filter((word) => word !== "")),
|
|
370
|
+
]
|
|
371
|
+
.map((word, index) => {
|
|
372
|
+
const lower = word.toLowerCase()
|
|
373
|
+
return index === 0 ? lower : `${lower.charAt(0).toUpperCase()}${lower.slice(1)}`
|
|
374
|
+
})
|
|
375
|
+
.join("")
|
|
376
|
+
|
|
377
|
+
export const operationPath = (
|
|
378
|
+
method: string,
|
|
379
|
+
path: string,
|
|
380
|
+
operation: Record<string, unknown>,
|
|
381
|
+
used: ReadonlySet<string>,
|
|
382
|
+
namespaces: ReadonlySet<string>,
|
|
383
|
+
): ReadonlyArray<string> => {
|
|
384
|
+
const raw = nonEmptyString(operation.operationId)
|
|
385
|
+
const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(
|
|
386
|
+
sanitizeOperationSegment,
|
|
387
|
+
)
|
|
388
|
+
if (isOperationPathAvailable(segments, used, namespaces)) return segments
|
|
389
|
+
const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
|
|
390
|
+
if (conflict >= 0 && conflict + 1 < segments.length) {
|
|
391
|
+
const collapsed = segments.flatMap((segment, index) => {
|
|
392
|
+
if (index === conflict) {
|
|
393
|
+
const next = segments[index + 1] ?? ""
|
|
394
|
+
return [`${segment}${next.charAt(0).toUpperCase()}${next.slice(1)}`]
|
|
395
|
+
}
|
|
396
|
+
return index === conflict + 1 ? [] : [segment]
|
|
397
|
+
})
|
|
398
|
+
if (isOperationPathAvailable(collapsed, used, namespaces)) return collapsed
|
|
399
|
+
}
|
|
400
|
+
const fallback = segments.join("_")
|
|
401
|
+
const next = (index: number): string => {
|
|
402
|
+
const candidate = `${fallback}_${index}`
|
|
403
|
+
return isOperationPathAvailable([candidate], used, namespaces) ? candidate : next(index + 1)
|
|
404
|
+
}
|
|
405
|
+
return [next(2)]
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const isOperationPathAvailable = (
|
|
409
|
+
segments: ReadonlyArray<string>,
|
|
410
|
+
used: ReadonlySet<string>,
|
|
411
|
+
namespaces: ReadonlySet<string>,
|
|
412
|
+
): boolean => {
|
|
413
|
+
const key = segments.join(".")
|
|
414
|
+
if (used.has(key) || namespaces.has(key)) return false
|
|
415
|
+
return segments.slice(0, -1).every((_, index) => !used.has(segments.slice(0, index + 1).join(".")))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export const specServerUrl = (source: Record<string, unknown>): Parsed<string> => {
|
|
419
|
+
const server = asArray(source.servers).find(isRecord)
|
|
420
|
+
const url = server === undefined ? undefined : nonEmptyString(server.url)
|
|
421
|
+
if (url === undefined) return { ok: false, reason: "spec declares no servers; pass baseUrl" }
|
|
422
|
+
if (/\{[^{}]+\}/.test(url)) {
|
|
423
|
+
return { ok: false, reason: `server URL '${url}' is not an absolute URL; pass baseUrl` }
|
|
424
|
+
}
|
|
425
|
+
return validateBaseUrl(url)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export const validateBaseUrl = (value: string): Parsed<string> => {
|
|
429
|
+
if (!/^https?:\/\//i.test(value)) return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
|
|
430
|
+
const url = URL.parse(value)
|
|
431
|
+
if (url === null || (url.protocol !== "http:" && url.protocol !== "https:")) {
|
|
432
|
+
return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
|
|
433
|
+
}
|
|
434
|
+
if (url.search !== "" || url.hash !== "") {
|
|
435
|
+
return { ok: false, reason: `server URL '${value}' contains an unsupported query string or fragment` }
|
|
436
|
+
}
|
|
437
|
+
return { ok: true, value }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export const securityRequirements = (value: unknown): Parsed<ReadonlyArray<SecurityRequirement>> => {
|
|
441
|
+
if (value === undefined) return { ok: true, value: [] }
|
|
442
|
+
if (!Array.isArray(value)) return { ok: false, reason: "security declaration is not an array" }
|
|
443
|
+
const requirements: Array<SecurityRequirement> = []
|
|
444
|
+
for (const item of value) {
|
|
445
|
+
if (!isRecord(item)) return { ok: false, reason: "security requirement is not an object" }
|
|
446
|
+
const requirement = Object.create(null) as Record<string, ReadonlyArray<string>>
|
|
447
|
+
for (const [name, scopes] of Object.entries(item)) {
|
|
448
|
+
if (!Array.isArray(scopes)) return { ok: false, reason: "security requirement scopes are not string arrays" }
|
|
449
|
+
const parsed = scopes.filter((scope): scope is string => typeof scope === "string")
|
|
450
|
+
if (parsed.length !== scopes.length) {
|
|
451
|
+
return { ok: false, reason: "security requirement scopes are not string arrays" }
|
|
452
|
+
}
|
|
453
|
+
requirement[name] = parsed
|
|
454
|
+
}
|
|
455
|
+
requirements.push(requirement)
|
|
456
|
+
}
|
|
457
|
+
return { ok: true, value: requirements }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export const operationSecurityRequirements = (
|
|
461
|
+
value: unknown,
|
|
462
|
+
defaults: Parsed<ReadonlyArray<SecurityRequirement>>,
|
|
463
|
+
schemes: Readonly<Record<string, SecurityScheme>>,
|
|
464
|
+
): Parsed<ReadonlyArray<SecurityRequirement>> => {
|
|
465
|
+
const parsed = value === undefined ? defaults : securityRequirements(value)
|
|
466
|
+
if (!parsed.ok) return parsed
|
|
467
|
+
const supported = parsed.value.filter((requirement) =>
|
|
468
|
+
Object.keys(requirement).every((name) => {
|
|
469
|
+
const scheme = own(schemes, name)
|
|
470
|
+
return scheme !== undefined && !(scheme.type === "apiKey" && scheme.in === "cookie")
|
|
471
|
+
}),
|
|
472
|
+
)
|
|
473
|
+
if (parsed.value.length === 0 || supported.length > 0) return { ok: true, value: supported }
|
|
474
|
+
|
|
475
|
+
const names = [...new Set(parsed.value.flatMap((requirement) => Object.keys(requirement)))]
|
|
476
|
+
const cookieScheme = names.find((name) => {
|
|
477
|
+
const definition = own(schemes, name)
|
|
478
|
+
return definition?.type === "apiKey" && definition.in === "cookie"
|
|
479
|
+
})
|
|
480
|
+
return {
|
|
481
|
+
ok: false,
|
|
482
|
+
reason:
|
|
483
|
+
cookieScheme === undefined
|
|
484
|
+
? `security requirement references missing or malformed scheme: ${names.join(", ")}`
|
|
485
|
+
: `cookie authentication '${cookieScheme}' is not supported`,
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export const securitySchemes = (document: Document): Readonly<Record<string, SecurityScheme>> => {
|
|
490
|
+
const components = isRecord(document.components) ? document.components : {}
|
|
491
|
+
const declared = isRecord(components.securitySchemes) ? components.securitySchemes : {}
|
|
492
|
+
return Object.fromEntries(
|
|
493
|
+
Object.entries(declared).flatMap<readonly [string, SecurityScheme]>(([name, value]) => {
|
|
494
|
+
const resolved = resolve(document, value)
|
|
495
|
+
if (!isRecord(resolved)) return []
|
|
496
|
+
const type = nonEmptyString(resolved.type)
|
|
497
|
+
if (type === "apiKey") {
|
|
498
|
+
const carrier = nonEmptyString(resolved.in)
|
|
499
|
+
const parameter = nonEmptyString(resolved.name)
|
|
500
|
+
if (parameter === undefined || (carrier !== "header" && carrier !== "query" && carrier !== "cookie")) return []
|
|
501
|
+
return [[name, { type, name: parameter, in: carrier }] as const]
|
|
502
|
+
}
|
|
503
|
+
if (type === "http") {
|
|
504
|
+
const scheme = nonEmptyString(resolved.scheme)?.toLowerCase()
|
|
505
|
+
return scheme === undefined ? [] : [[name, { type, scheme }] as const]
|
|
506
|
+
}
|
|
507
|
+
if (type === "oauth2" || type === "openIdConnect") return [[name, { type }] as const]
|
|
508
|
+
return []
|
|
509
|
+
}),
|
|
510
|
+
)
|
|
511
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { HttpClient } from "effect/unstable/http"
|
|
3
|
+
import type { Definition, JsonSchema } from "../tool.js"
|
|
4
|
+
|
|
5
|
+
/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */
|
|
6
|
+
export type Document = Record<string, unknown>
|
|
7
|
+
|
|
8
|
+
/** The operation identity handed to auth resolution and errors. */
|
|
9
|
+
export type Operation = {
|
|
10
|
+
readonly operationId: string | undefined
|
|
11
|
+
readonly method: string
|
|
12
|
+
readonly path: string
|
|
13
|
+
readonly summary: string | undefined
|
|
14
|
+
readonly description: string | undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A resolved OpenAPI security scheme from `components.securitySchemes`. */
|
|
18
|
+
export type SecurityScheme =
|
|
19
|
+
| { readonly type: "apiKey"; readonly name: string; readonly in: "header" | "query" | "cookie" }
|
|
20
|
+
| { readonly type: "http"; readonly scheme: string }
|
|
21
|
+
| { readonly type: "oauth2" }
|
|
22
|
+
| { readonly type: "openIdConnect" }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Credential material returned by a host auth resolver. The carrier for `apiKey`
|
|
26
|
+
* comes from the scheme definition, not the credential. `header` is the escape
|
|
27
|
+
* hatch for nonstandard schemes.
|
|
28
|
+
*/
|
|
29
|
+
export type Credential =
|
|
30
|
+
| { readonly type: "bearer"; readonly token: string }
|
|
31
|
+
| { readonly type: "basic"; readonly username: string; readonly password: string }
|
|
32
|
+
| { readonly type: "apiKey"; readonly value: string }
|
|
33
|
+
| { readonly type: "header"; readonly name: string; readonly value: string }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolves credential material for one named security scheme at call time.
|
|
37
|
+
* `undefined` means unavailable, try the next OR alternative; a failure aborts
|
|
38
|
+
* the call rather than falling through.
|
|
39
|
+
*/
|
|
40
|
+
export type AuthResolver = (context: {
|
|
41
|
+
readonly name: string
|
|
42
|
+
readonly definition: SecurityScheme
|
|
43
|
+
readonly scopes: ReadonlyArray<string>
|
|
44
|
+
readonly operation: Operation
|
|
45
|
+
}) => Effect.Effect<Credential | undefined, unknown>
|
|
46
|
+
|
|
47
|
+
export type Options = {
|
|
48
|
+
readonly spec: Document
|
|
49
|
+
/** Overrides all document, path, and operation `servers`. Required when no applicable absolute server URL exists. */
|
|
50
|
+
readonly baseUrl?: string | undefined
|
|
51
|
+
/** Host credential resolution, keyed by security scheme name. */
|
|
52
|
+
readonly auth?: { readonly resolve: AuthResolver } | undefined
|
|
53
|
+
/** Static headers on every request. Not model-visible; declared header params may override them, auth always wins. */
|
|
54
|
+
readonly headers?: Readonly<Record<string, string>> | undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An operation that could not be represented as a tool, and why. */
|
|
58
|
+
export type Skipped = {
|
|
59
|
+
readonly method: string
|
|
60
|
+
readonly path: string
|
|
61
|
+
readonly reason: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
|
|
65
|
+
|
|
66
|
+
export type Result = {
|
|
67
|
+
/** Tool subtree; the host places it under a key in its `tools` tree. */
|
|
68
|
+
readonly tools: Tools
|
|
69
|
+
readonly skipped: ReadonlyArray<Skipped>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type Parsed<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly reason: string }
|
|
73
|
+
|
|
74
|
+
export type InputLocation = "path" | "query" | "header" | "body"
|
|
75
|
+
|
|
76
|
+
export type InputField = {
|
|
77
|
+
/** Model-visible field name after cross-location collision handling. */
|
|
78
|
+
readonly inputName: string
|
|
79
|
+
/** Original parameter or body-property name used on the wire. */
|
|
80
|
+
readonly name: string
|
|
81
|
+
readonly location: InputLocation
|
|
82
|
+
readonly required: boolean
|
|
83
|
+
readonly schema: JsonSchema
|
|
84
|
+
readonly style: "simple" | "form" | "deepObject" | undefined
|
|
85
|
+
readonly explode: boolean | undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type Body = { readonly required: boolean; readonly mode: "object" | "value"; readonly mediaType: string }
|
|
89
|
+
|
|
90
|
+
export type OperationInput = {
|
|
91
|
+
readonly fields: ReadonlyArray<InputField>
|
|
92
|
+
readonly body: Body | undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */
|
|
96
|
+
export type SecurityRequirement = Readonly<Record<string, ReadonlyArray<string>>>
|
|
97
|
+
|
|
98
|
+
export type Plan = {
|
|
99
|
+
readonly operation: Operation
|
|
100
|
+
readonly url: string
|
|
101
|
+
readonly fields: ReadonlyArray<InputField>
|
|
102
|
+
readonly body: Body | undefined
|
|
103
|
+
readonly security: ReadonlyArray<SecurityRequirement>
|
|
104
|
+
readonly schemes: Readonly<Record<string, SecurityScheme>>
|
|
105
|
+
readonly auth: { readonly resolve: AuthResolver } | undefined
|
|
106
|
+
readonly headers: Readonly<Record<string, string>>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type AppliedAuth = {
|
|
110
|
+
readonly headers: Readonly<Record<string, string>>
|
|
111
|
+
readonly query: Readonly<Record<string, string>>
|
|
112
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const arrayMethods = new Set([
|
|
2
|
+
"map",
|
|
3
|
+
"filter",
|
|
4
|
+
"find",
|
|
5
|
+
"findIndex",
|
|
6
|
+
"findLast",
|
|
7
|
+
"findLastIndex",
|
|
8
|
+
"some",
|
|
9
|
+
"every",
|
|
10
|
+
"includes",
|
|
11
|
+
"join",
|
|
12
|
+
"reduce",
|
|
13
|
+
"reduceRight",
|
|
14
|
+
"flatMap",
|
|
15
|
+
"forEach",
|
|
16
|
+
"sort",
|
|
17
|
+
"toSorted",
|
|
18
|
+
"slice",
|
|
19
|
+
"concat",
|
|
20
|
+
"indexOf",
|
|
21
|
+
"lastIndexOf",
|
|
22
|
+
"at",
|
|
23
|
+
"flat",
|
|
24
|
+
"reverse",
|
|
25
|
+
"toReversed",
|
|
26
|
+
"with",
|
|
27
|
+
"push",
|
|
28
|
+
"pop",
|
|
29
|
+
"shift",
|
|
30
|
+
"unshift",
|
|
31
|
+
"splice",
|
|
32
|
+
"fill",
|
|
33
|
+
"copyWithin",
|
|
34
|
+
"keys",
|
|
35
|
+
"values",
|
|
36
|
+
"entries",
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
export const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
|
|
40
|
+
|
|
41
|
+
export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
|
|
42
|
+
|
|
43
|
+
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
|
|
44
|
+
if (Array.isArray(value)) return value
|
|
45
|
+
if (typeof value === "string") return Array.from(value)
|
|
46
|
+
if (value instanceof SandboxMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
|
|
47
|
+
if (value instanceof SandboxSet) return Array.from(value.set.values())
|
|
48
|
+
if (value instanceof SandboxURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
import { SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js"
|