@lovrozagar/honey 0.1.0
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/README.md +51 -0
- package/docs/api-ref/go.md +399 -0
- package/docs/api-ref/python.md +399 -0
- package/docs/api-ref/rust.md +399 -0
- package/docs/api-ref/typescript.md +399 -0
- package/docs/invalidate-spec.md +303 -0
- package/docs/sdk.md +722 -0
- package/package.json +120 -0
- package/src/accepts.ts +78 -0
- package/src/body-limit.ts +100 -0
- package/src/build/index.ts +174 -0
- package/src/cli-go/auth.go +15 -0
- package/src/cli-go/config.go +165 -0
- package/src/cli-go/errors.go +57 -0
- package/src/cli-go/multipart.go +55 -0
- package/src/cli-go/output.go +109 -0
- package/src/cli-go/root.go +30 -0
- package/src/cli-go/stream.go +88 -0
- package/src/cli-go/version.go +5 -0
- package/src/cli.ts +204 -0
- package/src/client/error.ts +27 -0
- package/src/client/http.ts +502 -0
- package/src/client/index.ts +127 -0
- package/src/client/sdk.ts +265 -0
- package/src/client/sse.ts +102 -0
- package/src/client/types.ts +211 -0
- package/src/client/ws.ts +115 -0
- package/src/client-go/errors.go +207 -0
- package/src/client-go/invalidation.go +373 -0
- package/src/client-go/realtime.go +317 -0
- package/src/client-go/result.go +20 -0
- package/src/client-go/runtime.go +413 -0
- package/src/client-go/sse.go +149 -0
- package/src/client-go/transport.go +452 -0
- package/src/client-go/ws.go +104 -0
- package/src/client-mcp/README.md +10 -0
- package/src/client-mcp/server.ts +94 -0
- package/src/client-mcp/types.ts +19 -0
- package/src/client-python/_errors.py +115 -0
- package/src/client-python/_invalidation.py +578 -0
- package/src/client-python/_realtime.py +156 -0
- package/src/client-python/_runtime.py +469 -0
- package/src/client-python/_sse.py +99 -0
- package/src/client-python/_transport.py +335 -0
- package/src/client-python/_ws.py +131 -0
- package/src/client-python/py.typed +0 -0
- package/src/client-rust/errors.rs +200 -0
- package/src/client-rust/invalidation.rs +441 -0
- package/src/client-rust/invalidation_sync.rs +306 -0
- package/src/client-rust/realtime.rs +695 -0
- package/src/client-rust/result.rs +22 -0
- package/src/client-rust/runtime.rs +547 -0
- package/src/client-rust/runtime_sync.rs +452 -0
- package/src/client-rust/sse.rs +167 -0
- package/src/client-rust/ws.rs +143 -0
- package/src/cloudflare/realtime.ts +236 -0
- package/src/codegen-go-cli.ts +1180 -0
- package/src/codegen-go.ts +1010 -0
- package/src/codegen-hash.ts +8 -0
- package/src/codegen-ir.ts +517 -0
- package/src/codegen-loaders.ts +53 -0
- package/src/codegen-mcp.ts +364 -0
- package/src/codegen-python.ts +1028 -0
- package/src/codegen-rust.ts +1652 -0
- package/src/codegen-sanitize.ts +67 -0
- package/src/codegen-schema-naming.ts +146 -0
- package/src/codegen.ts +5015 -0
- package/src/context.ts +210 -0
- package/src/cookie-sign.ts +58 -0
- package/src/cookie.ts +89 -0
- package/src/cors.ts +96 -0
- package/src/crypto.ts +19 -0
- package/src/csrf.ts +54 -0
- package/src/curl-logger.ts +235 -0
- package/src/detect-runtime.ts +19 -0
- package/src/error.ts +52 -0
- package/src/errors.ts +87 -0
- package/src/etag.ts +74 -0
- package/src/feature-load.ts +23 -0
- package/src/go-type-emitter.ts +404 -0
- package/src/honey-response.ts +354 -0
- package/src/i18n-slot.ts +20 -0
- package/src/i18n.ts +234 -0
- package/src/index.ts +3843 -0
- package/src/init.ts +149 -0
- package/src/input.ts +8 -0
- package/src/ip-restrict.ts +192 -0
- package/src/logger.ts +149 -0
- package/src/middleware.ts +199 -0
- package/src/node-request.ts +240 -0
- package/src/node.ts +212 -0
- package/src/openapi/register.ts +19 -0
- package/src/openapi/scalar.ts +33 -0
- package/src/openapi/spec-factory.ts +46 -0
- package/src/openapi/spec.ts +31 -0
- package/src/openapi/swagger.ts +34 -0
- package/src/plugin.ts +771 -0
- package/src/powered-by.ts +17 -0
- package/src/pretty-json.ts +39 -0
- package/src/proxy.ts +155 -0
- package/src/python-type-emitter.ts +144 -0
- package/src/realtime/buffer.ts +101 -0
- package/src/realtime/bus.ts +93 -0
- package/src/realtime/longpoll.ts +148 -0
- package/src/realtime/protocol.ts +128 -0
- package/src/realtime/route.ts +104 -0
- package/src/realtime/runtime.ts +430 -0
- package/src/request-id.ts +23 -0
- package/src/request-to-curl.ts +34 -0
- package/src/response.ts +414 -0
- package/src/rust-type-emitter.ts +412 -0
- package/src/secure-headers.ts +69 -0
- package/src/serve-register.ts +8 -0
- package/src/serve-slot.ts +20 -0
- package/src/serve.ts +141 -0
- package/src/server-timing.ts +66 -0
- package/src/static.ts +60 -0
- package/src/telemetry/otel.ts +139 -0
- package/src/testing.ts +114 -0
- package/src/timeout.ts +36 -0
- package/src/tree.ts +464 -0
- package/src/ts-type-emitter.ts +106 -0
- package/src/type-emitter.ts +524 -0
- package/src/type-extractor.ts +749 -0
- package/src/types.ts +638 -0
- package/src/validation.ts +300 -0
- package/src/ws/bun.ts +89 -0
- package/src/ws/cloudflare.ts +133 -0
- package/src/ws/deno.ts +109 -0
- package/src/ws/node.ts +161 -0
- package/src/yaml.ts +80 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { type OpenApiSpecInput, extractOpenApiPathParams, resolveRefs } from "./codegen.ts"
|
|
2
|
+
|
|
3
|
+
export type IRScalarType = "string" | "number" | "integer" | "boolean" | "null"
|
|
4
|
+
|
|
5
|
+
export type IRSchema =
|
|
6
|
+
| { kind: "scalar"; type: IRScalarType; format?: string; enum?: (string | number | boolean)[] }
|
|
7
|
+
| { kind: "const"; value: string | number | boolean }
|
|
8
|
+
| { kind: "object"; fields: IRField[]; additional?: IRSchema | false }
|
|
9
|
+
| { kind: "array"; items: IRSchema }
|
|
10
|
+
| { kind: "tuple"; items: IRSchema[] }
|
|
11
|
+
| { kind: "union"; variants: IRSchema[]; discriminator?: IRDiscriminator }
|
|
12
|
+
| { kind: "allOf"; parts: IRSchema[] }
|
|
13
|
+
| { kind: "ref"; name: string }
|
|
14
|
+
| { kind: "nullable"; inner: IRSchema }
|
|
15
|
+
| { kind: "binary" }
|
|
16
|
+
| { kind: "unknown" }
|
|
17
|
+
|
|
18
|
+
export type IRField = {
|
|
19
|
+
name: string /* raw JSON key, no mangling */
|
|
20
|
+
schema: IRSchema
|
|
21
|
+
required: boolean
|
|
22
|
+
description?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type IRDiscriminator = {
|
|
26
|
+
propertyName: string
|
|
27
|
+
mapping?: Record<string, string> /* value → bare schema name */
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type IRParam = {
|
|
31
|
+
name: string
|
|
32
|
+
schema: IRSchema
|
|
33
|
+
description?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type IRMultipartPart = {
|
|
37
|
+
name: string
|
|
38
|
+
/** "file" for schemas with kind === "binary" (or array-of-binary), otherwise "text". */
|
|
39
|
+
type: "file" | "text"
|
|
40
|
+
schema?: IRSchema
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type IRBody =
|
|
44
|
+
| { kind: "raw"; contentType: string; schema: IRSchema; required: boolean }
|
|
45
|
+
| { kind: "stream"; contentType: "application/octet-stream"; required: boolean }
|
|
46
|
+
| {
|
|
47
|
+
kind: "multipart"
|
|
48
|
+
contentType: "multipart/form-data"
|
|
49
|
+
parts: IRMultipartPart[]
|
|
50
|
+
required: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type IRResponse = {
|
|
54
|
+
status: string /* "200" | "204" | "default" | ... — preserved verbatim */
|
|
55
|
+
contentType?: string /* undefined for 204 / no-content */
|
|
56
|
+
schema?: IRSchema
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type IROperationExtensions = {
|
|
60
|
+
websocket?: true
|
|
61
|
+
realtime?: true
|
|
62
|
+
sse?: true
|
|
63
|
+
deprecated?: true
|
|
64
|
+
idempotencyKey?: true
|
|
65
|
+
mcp?: true
|
|
66
|
+
invalidates?: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type IROperation = {
|
|
70
|
+
id: string /* operationId */
|
|
71
|
+
method: string /* upper-cased HTTP method */
|
|
72
|
+
path: string /* OpenAPI path template, {param} preserved */
|
|
73
|
+
description?: string
|
|
74
|
+
params: {
|
|
75
|
+
path: IRParam[]
|
|
76
|
+
query: IRParam[]
|
|
77
|
+
header: IRParam[]
|
|
78
|
+
}
|
|
79
|
+
body?: IRBody
|
|
80
|
+
responses: Record<string, IRResponse>
|
|
81
|
+
extensions: IROperationExtensions
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type IRTreeEntry =
|
|
85
|
+
| { kind: "method"; op: IROperation }
|
|
86
|
+
| { kind: "namespace"; ns: IRNamespace }
|
|
87
|
+
|
|
88
|
+
export type IRNamespace = {
|
|
89
|
+
entries: Map<string, IRTreeEntry>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type IR = {
|
|
93
|
+
operations: IROperation[]
|
|
94
|
+
schemas: Record<string, IRSchema>
|
|
95
|
+
tree: IRNamespace
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function methodsOf(ns: IRNamespace): Array<[string, IROperation]> {
|
|
99
|
+
return [...ns.entries.entries()]
|
|
100
|
+
.filter((e): e is [string, Extract<IRTreeEntry, { kind: "method" }>] => e[1].kind === "method")
|
|
101
|
+
.map(([k, v]): [string, IROperation] => [k, v.op])
|
|
102
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function namespacesOf(ns: IRNamespace): Array<[string, IRNamespace]> {
|
|
106
|
+
return [...ns.entries.entries()]
|
|
107
|
+
.filter((e): e is [string, Extract<IRTreeEntry, { kind: "namespace" }>] => e[1].kind === "namespace")
|
|
108
|
+
.map(([k, v]): [string, IRNamespace] => [k, v.ns])
|
|
109
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function firstDescendantId(ns: IRNamespace): string {
|
|
113
|
+
for (const [, entry] of ns.entries) {
|
|
114
|
+
if (entry.kind === "method") return entry.op.id
|
|
115
|
+
const sub = firstDescendantId(entry.ns)
|
|
116
|
+
if (sub) return sub
|
|
117
|
+
}
|
|
118
|
+
return ""
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function buildResourceTree(operations: IROperation[]): IRNamespace {
|
|
122
|
+
const root: IRNamespace = { entries: new Map() }
|
|
123
|
+
|
|
124
|
+
for (const op of operations) {
|
|
125
|
+
const segments = op.id.split(".")
|
|
126
|
+
let cur = root
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
129
|
+
const seg = segments[i] ?? ""
|
|
130
|
+
const existing = cur.entries.get(seg)
|
|
131
|
+
if (existing) {
|
|
132
|
+
if (existing.kind === "method") {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`operationId conflict: "${existing.op.id}" is callable but also a namespace prefix of "${op.id}".\n` +
|
|
135
|
+
`Rename one. Suggested: "${existing.op.id}" → "${existing.op.id}One" or drop "${op.id}".`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
cur = existing.ns
|
|
139
|
+
} else {
|
|
140
|
+
const ns: IRNamespace = { entries: new Map() }
|
|
141
|
+
cur.entries.set(seg, { kind: "namespace", ns })
|
|
142
|
+
cur = ns
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const leafName = segments[segments.length - 1] ?? ""
|
|
147
|
+
const existing = cur.entries.get(leafName)
|
|
148
|
+
if (existing && existing.kind === "namespace") {
|
|
149
|
+
const otherId = firstDescendantId(existing.ns)
|
|
150
|
+
throw new Error(
|
|
151
|
+
`operationId conflict: "${op.id}" is callable but also a namespace prefix of "${otherId}".\n` +
|
|
152
|
+
`Rename one. Suggested: "${op.id}" → "${op.id}One" or drop "${otherId}".`,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
cur.entries.set(leafName, { kind: "method", op })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return root
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isNullableSchema(schema: Record<string, unknown>): boolean {
|
|
162
|
+
if (schema.nullable === true) return true
|
|
163
|
+
const t = schema.type
|
|
164
|
+
if (Array.isArray(t) && (t as unknown[]).includes("null")) return true
|
|
165
|
+
const variants = (schema.anyOf ?? schema.oneOf) as Record<string, unknown>[] | undefined
|
|
166
|
+
if (variants && variants.some((v) => v.type === "null")) return true
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isStringEnum(schema: Record<string, unknown>): boolean {
|
|
171
|
+
const e = schema.enum as unknown[] | undefined
|
|
172
|
+
if (!e || e.length === 0) return false
|
|
173
|
+
const t = schema.type
|
|
174
|
+
if (t === "string") return true
|
|
175
|
+
if (!t && e.every((v) => typeof v === "string")) return true
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isIntEnum(schema: Record<string, unknown>): boolean {
|
|
180
|
+
const e = schema.enum as unknown[] | undefined
|
|
181
|
+
if (!e || e.length === 0) return false
|
|
182
|
+
const t = schema.type
|
|
183
|
+
if (t === "integer") return true
|
|
184
|
+
if (!t && e.every((v) => typeof v === "number" && Number.isInteger(v))) return true
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Strip null variant from anyOf/oneOf; returns remaining schemas. */
|
|
189
|
+
function stripNullVariants(schema: Record<string, unknown>): Record<string, unknown>[] | null {
|
|
190
|
+
const variants = (schema.anyOf ?? schema.oneOf) as Record<string, unknown>[] | undefined
|
|
191
|
+
if (!variants) return null
|
|
192
|
+
return variants.filter((v) => v.type !== "null")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Strip "null" from a type array, returning the first non-null type string. */
|
|
196
|
+
function stripNullFromTypeArray(schema: Record<string, unknown>): Record<string, unknown> {
|
|
197
|
+
const t = schema.type as string[]
|
|
198
|
+
const nonNull = t.filter((v) => v !== "null")
|
|
199
|
+
return { ...schema, type: nonNull.length === 1 ? nonNull[0] : nonNull }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function schemaToIR(schema: Record<string, unknown> | undefined): IRSchema {
|
|
203
|
+
if (!schema) return { kind: "unknown" }
|
|
204
|
+
|
|
205
|
+
if (typeof schema.$ref === "string") {
|
|
206
|
+
const parts = schema.$ref.split("/")
|
|
207
|
+
const name = parts[parts.length - 1] ?? schema.$ref
|
|
208
|
+
return { kind: "ref", name }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (Array.isArray(schema.allOf)) {
|
|
212
|
+
const parts = (schema.allOf as Record<string, unknown>[]).map(schemaToIR)
|
|
213
|
+
return { kind: "allOf", parts }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* const precedes type inference */
|
|
217
|
+
if (schema.const !== undefined) {
|
|
218
|
+
const v = schema.const
|
|
219
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
220
|
+
return { kind: "const", value: v }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (isNullableSchema(schema)) {
|
|
225
|
+
let inner: IRSchema
|
|
226
|
+
|
|
227
|
+
if (Array.isArray(schema.type) && (schema.type as unknown[]).includes("null")) {
|
|
228
|
+
inner = schemaToIR(stripNullFromTypeArray(schema))
|
|
229
|
+
} else if (schema.anyOf || schema.oneOf) {
|
|
230
|
+
const remaining = stripNullVariants(schema) ?? []
|
|
231
|
+
if (remaining.length === 0) {
|
|
232
|
+
inner = { kind: "unknown" }
|
|
233
|
+
} else if (remaining.length === 1) {
|
|
234
|
+
inner = schemaToIR(remaining[0])
|
|
235
|
+
} else {
|
|
236
|
+
inner = { kind: "union", variants: remaining.map(schemaToIR) }
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
/* nullable: true on a plain schema — strip the flag and recurse */
|
|
240
|
+
const { nullable: _n, ...rest } = schema
|
|
241
|
+
inner = schemaToIR(rest as Record<string, unknown>)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* idempotent: never double-wrap */
|
|
245
|
+
if (inner.kind === "nullable") return inner
|
|
246
|
+
return { inner, kind: "nullable" }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* multi-type array without null — nullable path already handled null-containing arrays above */
|
|
250
|
+
if (Array.isArray(schema.type) && (schema.type as string[]).length > 1) {
|
|
251
|
+
const types = schema.type as string[]
|
|
252
|
+
const variants: IRSchema[] = types.map((t) => schemaToIR({ ...schema, type: t }))
|
|
253
|
+
if (variants.length === 1) return variants[0]
|
|
254
|
+
return { kind: "union", variants }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) {
|
|
258
|
+
const variants = ((schema.oneOf ?? schema.anyOf) as Record<string, unknown>[]).map(schemaToIR)
|
|
259
|
+
const rawDisc = schema.discriminator as Record<string, unknown> | undefined
|
|
260
|
+
if (rawDisc) {
|
|
261
|
+
const propertyName = rawDisc.propertyName as string
|
|
262
|
+
const rawMapping = rawDisc.mapping as Record<string, string> | undefined
|
|
263
|
+
let mapping: Record<string, string> | undefined
|
|
264
|
+
if (rawMapping) {
|
|
265
|
+
mapping = {}
|
|
266
|
+
for (const [k, v] of Object.entries(rawMapping)) {
|
|
267
|
+
const parts = v.split("/")
|
|
268
|
+
mapping[k] = parts[parts.length - 1]
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const discriminator: IRDiscriminator = { propertyName }
|
|
272
|
+
if (mapping) discriminator.mapping = mapping
|
|
273
|
+
return { discriminator, kind: "union", variants }
|
|
274
|
+
}
|
|
275
|
+
return { kind: "union", variants }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* enum detection precedes type branching — {enum:[...]} can lack type */
|
|
279
|
+
if (Array.isArray(schema.enum)) {
|
|
280
|
+
const enumVals = schema.enum as (string | number | boolean)[]
|
|
281
|
+
if (isStringEnum(schema)) return { enum: enumVals, kind: "scalar", type: "string" }
|
|
282
|
+
if (isIntEnum(schema)) return { enum: enumVals, kind: "scalar", type: "integer" }
|
|
283
|
+
/* mixed enum (string + number values) — emit as union of const literals */
|
|
284
|
+
if (enumVals.length > 0 && enumVals.every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean")) {
|
|
285
|
+
return { kind: "union", variants: enumVals.map((v) => ({ kind: "const" as const, value: v })) }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (schema.format === "binary" && (schema.type === "string" || schema.type === undefined)) {
|
|
290
|
+
return { kind: "binary" }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (schema.type === "array" || Array.isArray(schema.items)) {
|
|
294
|
+
if (Array.isArray(schema.items)) {
|
|
295
|
+
const items = (schema.items as Record<string, unknown>[]).map(schemaToIR)
|
|
296
|
+
return { items, kind: "tuple" }
|
|
297
|
+
}
|
|
298
|
+
const items = schema.items ? schemaToIR(schema.items as Record<string, unknown>) : { kind: "unknown" as const }
|
|
299
|
+
return { items, kind: "array" }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (
|
|
303
|
+
schema.type === "object" ||
|
|
304
|
+
schema.properties !== undefined ||
|
|
305
|
+
schema.additionalProperties !== undefined
|
|
306
|
+
) {
|
|
307
|
+
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>
|
|
308
|
+
const requiredKeys = new Set((schema.required as string[] | undefined) ?? [])
|
|
309
|
+
const fields: IRField[] = Object.entries(props).map(([name, propSchema]) => {
|
|
310
|
+
const field: IRField = {
|
|
311
|
+
name,
|
|
312
|
+
required: requiredKeys.has(name),
|
|
313
|
+
schema: schemaToIR(propSchema),
|
|
314
|
+
}
|
|
315
|
+
if (typeof propSchema.description === "string") field.description = propSchema.description
|
|
316
|
+
return field
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
let additional: IRSchema | false | undefined
|
|
320
|
+
const ap = schema.additionalProperties
|
|
321
|
+
if (ap === false) {
|
|
322
|
+
additional = false
|
|
323
|
+
} else if (ap === true) {
|
|
324
|
+
additional = { kind: "unknown" }
|
|
325
|
+
} else if (ap !== undefined) {
|
|
326
|
+
additional = schemaToIR(ap as Record<string, unknown>)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const result: { kind: "object"; fields: IRField[]; additional?: IRSchema | false } = {
|
|
330
|
+
fields,
|
|
331
|
+
kind: "object",
|
|
332
|
+
}
|
|
333
|
+
if (additional !== undefined) result.additional = additional
|
|
334
|
+
return result
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const t = schema.type as string | undefined
|
|
338
|
+
if (t === "string" || t === "integer" || t === "number" || t === "boolean" || t === "null") {
|
|
339
|
+
const scalar: Extract<IRSchema, { kind: "scalar" }> = { kind: "scalar", type: t as IRScalarType }
|
|
340
|
+
if (typeof schema.format === "string") scalar.format = schema.format
|
|
341
|
+
if (Array.isArray(schema.enum)) scalar.enum = schema.enum as (string | number | boolean)[]
|
|
342
|
+
return scalar
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { kind: "unknown" }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const
|
|
349
|
+
|
|
350
|
+
/** Detect whether a multipart/form-data schema has at least one binary (file) property. */
|
|
351
|
+
function hasBinaryMultipartPart(mediaType: Record<string, unknown> | undefined): boolean {
|
|
352
|
+
const schema = mediaType?.schema as Record<string, unknown> | undefined
|
|
353
|
+
const props = schema?.properties as Record<string, Record<string, unknown>> | undefined
|
|
354
|
+
if (!props) return false
|
|
355
|
+
for (const prop of Object.values(props)) {
|
|
356
|
+
if (prop.format === "binary") return true
|
|
357
|
+
if (prop.type === "array") {
|
|
358
|
+
const items = prop.items as Record<string, unknown> | undefined
|
|
359
|
+
if (items?.format === "binary") return true
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Normalise a multipart/form-data object schema into typed parts. */
|
|
366
|
+
function extractMultipartParts(
|
|
367
|
+
mediaType: Record<string, unknown> | undefined,
|
|
368
|
+
): IRMultipartPart[] {
|
|
369
|
+
const schema = mediaType?.schema as Record<string, unknown> | undefined
|
|
370
|
+
const props = schema?.properties as Record<string, Record<string, unknown>> | undefined
|
|
371
|
+
if (!props) return []
|
|
372
|
+
const parts: IRMultipartPart[] = []
|
|
373
|
+
for (const [name, propSchema] of Object.entries(props)) {
|
|
374
|
+
const ir = schemaToIR(propSchema)
|
|
375
|
+
const isBinary =
|
|
376
|
+
ir.kind === "binary" || (ir.kind === "array" && ir.items.kind === "binary")
|
|
377
|
+
parts.push({ name, schema: ir, type: isBinary ? "file" : "text" })
|
|
378
|
+
}
|
|
379
|
+
return parts
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function buildOperation(
|
|
383
|
+
id: string,
|
|
384
|
+
method: string,
|
|
385
|
+
path: string,
|
|
386
|
+
op: Record<string, unknown>,
|
|
387
|
+
): IROperation {
|
|
388
|
+
const upperMethod = method.toUpperCase()
|
|
389
|
+
|
|
390
|
+
const declaredParams = (op.parameters as Record<string, unknown>[] | undefined) ?? []
|
|
391
|
+
const pathParams: IRParam[] = []
|
|
392
|
+
const queryParams: IRParam[] = []
|
|
393
|
+
const headerParams: IRParam[] = []
|
|
394
|
+
const declaredPathNames = new Set<string>()
|
|
395
|
+
|
|
396
|
+
for (const p of declaredParams) {
|
|
397
|
+
const pIn = p.in as string
|
|
398
|
+
const pName = p.name as string
|
|
399
|
+
const pSchema = p.schema ? schemaToIR(p.schema as Record<string, unknown>) : { kind: "scalar" as const, type: "string" as const }
|
|
400
|
+
const param: IRParam = { name: pName, schema: pSchema }
|
|
401
|
+
if (typeof p.description === "string") param.description = p.description
|
|
402
|
+
if (pIn === "path") {
|
|
403
|
+
pathParams.push(param)
|
|
404
|
+
declaredPathNames.add(pName)
|
|
405
|
+
} else if (pIn === "query") {
|
|
406
|
+
queryParams.push(param)
|
|
407
|
+
} else if (pIn === "header") {
|
|
408
|
+
headerParams.push(param)
|
|
409
|
+
}
|
|
410
|
+
/* cookie params discarded per OpenAPI client convention */
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* back-fill path params absent from parameters[] but present in the URL template */
|
|
414
|
+
for (const name of extractOpenApiPathParams(path)) {
|
|
415
|
+
if (!declaredPathNames.has(name)) {
|
|
416
|
+
pathParams.push({ name, schema: { kind: "scalar", type: "string" } })
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let body: IRBody | undefined
|
|
421
|
+
const requestBody = op.requestBody as Record<string, unknown> | undefined
|
|
422
|
+
if (requestBody) {
|
|
423
|
+
const content = requestBody.content as Record<string, Record<string, unknown>> | undefined
|
|
424
|
+
const firstCt = content ? Object.keys(content)[0] : undefined
|
|
425
|
+
if (firstCt && content) {
|
|
426
|
+
const mediaType = content[firstCt]
|
|
427
|
+
const required = requestBody.required === true
|
|
428
|
+
|
|
429
|
+
if (firstCt === "application/octet-stream") {
|
|
430
|
+
body = { contentType: "application/octet-stream", kind: "stream", required }
|
|
431
|
+
} else if (firstCt === "multipart/form-data" && hasBinaryMultipartPart(mediaType)) {
|
|
432
|
+
body = {
|
|
433
|
+
contentType: "multipart/form-data",
|
|
434
|
+
kind: "multipart",
|
|
435
|
+
parts: extractMultipartParts(mediaType),
|
|
436
|
+
required,
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
const bodySchema = mediaType?.schema
|
|
440
|
+
? schemaToIR(mediaType.schema as Record<string, unknown>)
|
|
441
|
+
: { kind: "unknown" as const }
|
|
442
|
+
body = { contentType: firstCt, kind: "raw", required, schema: bodySchema }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const responses: Record<string, IRResponse> = {}
|
|
448
|
+
const extensions: IROperationExtensions = {}
|
|
449
|
+
|
|
450
|
+
const rawResponses = op.responses as Record<string, Record<string, unknown>> | undefined
|
|
451
|
+
if (rawResponses) {
|
|
452
|
+
for (const [status, response] of Object.entries(rawResponses)) {
|
|
453
|
+
const content = response.content as Record<string, Record<string, unknown>> | undefined
|
|
454
|
+
if (!content || Object.keys(content).length === 0) {
|
|
455
|
+
responses[status] = { status }
|
|
456
|
+
} else {
|
|
457
|
+
const firstCt = Object.keys(content)[0]
|
|
458
|
+
/* SSE detected by response content-type, not a request extension */
|
|
459
|
+
if (firstCt === "text/event-stream") extensions.sse = true
|
|
460
|
+
const mediaType = content[firstCt]
|
|
461
|
+
const respSchema = mediaType?.schema
|
|
462
|
+
? schemaToIR(mediaType.schema as Record<string, unknown>)
|
|
463
|
+
: undefined
|
|
464
|
+
const resp: IRResponse = { contentType: firstCt, status }
|
|
465
|
+
if (respSchema !== undefined) resp.schema = respSchema
|
|
466
|
+
responses[status] = resp
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (op["x-websocket"] === true) extensions.websocket = true
|
|
472
|
+
if (op["x-realtime"] === true) extensions.realtime = true
|
|
473
|
+
if (op["x-deprecated"] === true) extensions.deprecated = true
|
|
474
|
+
if (op["x-idempotency-key"] === true) extensions.idempotencyKey = true
|
|
475
|
+
if (op["x-mcp"] === true) extensions.mcp = true
|
|
476
|
+
if (Array.isArray(op["x-invalidate"])) extensions.invalidates = op["x-invalidate"] as string[]
|
|
477
|
+
|
|
478
|
+
const operation: IROperation = {
|
|
479
|
+
extensions,
|
|
480
|
+
id,
|
|
481
|
+
method: upperMethod,
|
|
482
|
+
params: { header: headerParams, path: pathParams, query: queryParams },
|
|
483
|
+
path,
|
|
484
|
+
responses,
|
|
485
|
+
}
|
|
486
|
+
if (body) operation.body = body
|
|
487
|
+
if (typeof op.description === "string") operation.description = op.description
|
|
488
|
+
|
|
489
|
+
return operation
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function toIR(spec: OpenApiSpecInput): IR {
|
|
493
|
+
/* schemas built from raw components BEFORE resolveRefs, which strips them */
|
|
494
|
+
const schemas: Record<string, IRSchema> = {}
|
|
495
|
+
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
|
|
496
|
+
schemas[name] = schemaToIR(schema)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const resolved = resolveRefs(spec)
|
|
500
|
+
const operations: IROperation[] = []
|
|
501
|
+
const seen = new Set<string>()
|
|
502
|
+
|
|
503
|
+
for (const [path, pathItem] of Object.entries(resolved.paths)) {
|
|
504
|
+
for (const method of HTTP_METHODS) {
|
|
505
|
+
const op = pathItem[method] as Record<string, unknown> | undefined
|
|
506
|
+
if (!op) continue
|
|
507
|
+
const id = op.operationId as string | undefined
|
|
508
|
+
if (!id) continue
|
|
509
|
+
if (seen.has(id)) throw new Error(`Duplicate operationId: "${id}"`)
|
|
510
|
+
seen.add(id)
|
|
511
|
+
operations.push(buildOperation(id, method, path, op))
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const tree = buildResourceTree(operations)
|
|
516
|
+
return { operations, schemas, tree }
|
|
517
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Dynamic Zod/Effect JSON Schema loaders. Not a package export — tests may import this file. */
|
|
2
|
+
|
|
3
|
+
export let toJSONSchemaFn: ((schema: unknown, opts?: { io?: "input" | "output" }) => unknown) | undefined
|
|
4
|
+
let toJSONSchemaLoaded = false
|
|
5
|
+
|
|
6
|
+
export async function loadToJSONSchema(): Promise<
|
|
7
|
+
((schema: unknown, opts?: { io?: "input" | "output" }) => unknown) | undefined
|
|
8
|
+
> {
|
|
9
|
+
if (toJSONSchemaLoaded) return toJSONSchemaFn
|
|
10
|
+
toJSONSchemaLoaded = true
|
|
11
|
+
try {
|
|
12
|
+
const id = "zod"
|
|
13
|
+
const zod = await import(id)
|
|
14
|
+
if (typeof zod.toJSONSchema === "function") {
|
|
15
|
+
toJSONSchemaFn = zod.toJSONSchema as (
|
|
16
|
+
schema: unknown,
|
|
17
|
+
opts?: { io?: "input" | "output" },
|
|
18
|
+
) => unknown
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
/* zod not available */
|
|
22
|
+
}
|
|
23
|
+
return toJSONSchemaFn
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export let effectJsonSchemaFn: ((schema: unknown) => unknown) | undefined
|
|
27
|
+
let effectLoaded = false
|
|
28
|
+
|
|
29
|
+
export async function loadEffectJsonSchema(): Promise<((schema: unknown) => unknown) | undefined> {
|
|
30
|
+
if (effectLoaded) return effectJsonSchemaFn
|
|
31
|
+
effectLoaded = true
|
|
32
|
+
try {
|
|
33
|
+
const id = "effect"
|
|
34
|
+
const effect = await import(id)
|
|
35
|
+
if (effect.JSONSchema && typeof effect.JSONSchema.make === "function") {
|
|
36
|
+
effectJsonSchemaFn = effect.JSONSchema.make as (schema: unknown) => unknown
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
/* effect not available */
|
|
40
|
+
}
|
|
41
|
+
return effectJsonSchemaFn
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function prepareCodegen(): Promise<void> {
|
|
45
|
+
await Promise.all([loadToJSONSchema(), loadEffectJsonSchema()])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resetCodegenJsonSchemaLoadersForTests(): void {
|
|
49
|
+
toJSONSchemaFn = undefined
|
|
50
|
+
toJSONSchemaLoaded = true
|
|
51
|
+
effectJsonSchemaFn = undefined
|
|
52
|
+
effectLoaded = true
|
|
53
|
+
}
|