@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.
Files changed (131) hide show
  1. package/README.md +51 -0
  2. package/docs/api-ref/go.md +399 -0
  3. package/docs/api-ref/python.md +399 -0
  4. package/docs/api-ref/rust.md +399 -0
  5. package/docs/api-ref/typescript.md +399 -0
  6. package/docs/invalidate-spec.md +303 -0
  7. package/docs/sdk.md +722 -0
  8. package/package.json +120 -0
  9. package/src/accepts.ts +78 -0
  10. package/src/body-limit.ts +100 -0
  11. package/src/build/index.ts +174 -0
  12. package/src/cli-go/auth.go +15 -0
  13. package/src/cli-go/config.go +165 -0
  14. package/src/cli-go/errors.go +57 -0
  15. package/src/cli-go/multipart.go +55 -0
  16. package/src/cli-go/output.go +109 -0
  17. package/src/cli-go/root.go +30 -0
  18. package/src/cli-go/stream.go +88 -0
  19. package/src/cli-go/version.go +5 -0
  20. package/src/cli.ts +204 -0
  21. package/src/client/error.ts +27 -0
  22. package/src/client/http.ts +502 -0
  23. package/src/client/index.ts +127 -0
  24. package/src/client/sdk.ts +265 -0
  25. package/src/client/sse.ts +102 -0
  26. package/src/client/types.ts +211 -0
  27. package/src/client/ws.ts +115 -0
  28. package/src/client-go/errors.go +207 -0
  29. package/src/client-go/invalidation.go +373 -0
  30. package/src/client-go/realtime.go +317 -0
  31. package/src/client-go/result.go +20 -0
  32. package/src/client-go/runtime.go +413 -0
  33. package/src/client-go/sse.go +149 -0
  34. package/src/client-go/transport.go +452 -0
  35. package/src/client-go/ws.go +104 -0
  36. package/src/client-mcp/README.md +10 -0
  37. package/src/client-mcp/server.ts +94 -0
  38. package/src/client-mcp/types.ts +19 -0
  39. package/src/client-python/_errors.py +115 -0
  40. package/src/client-python/_invalidation.py +578 -0
  41. package/src/client-python/_realtime.py +156 -0
  42. package/src/client-python/_runtime.py +469 -0
  43. package/src/client-python/_sse.py +99 -0
  44. package/src/client-python/_transport.py +335 -0
  45. package/src/client-python/_ws.py +131 -0
  46. package/src/client-python/py.typed +0 -0
  47. package/src/client-rust/errors.rs +200 -0
  48. package/src/client-rust/invalidation.rs +441 -0
  49. package/src/client-rust/invalidation_sync.rs +306 -0
  50. package/src/client-rust/realtime.rs +695 -0
  51. package/src/client-rust/result.rs +22 -0
  52. package/src/client-rust/runtime.rs +547 -0
  53. package/src/client-rust/runtime_sync.rs +452 -0
  54. package/src/client-rust/sse.rs +167 -0
  55. package/src/client-rust/ws.rs +143 -0
  56. package/src/cloudflare/realtime.ts +236 -0
  57. package/src/codegen-go-cli.ts +1180 -0
  58. package/src/codegen-go.ts +1010 -0
  59. package/src/codegen-hash.ts +8 -0
  60. package/src/codegen-ir.ts +517 -0
  61. package/src/codegen-loaders.ts +53 -0
  62. package/src/codegen-mcp.ts +364 -0
  63. package/src/codegen-python.ts +1028 -0
  64. package/src/codegen-rust.ts +1652 -0
  65. package/src/codegen-sanitize.ts +67 -0
  66. package/src/codegen-schema-naming.ts +146 -0
  67. package/src/codegen.ts +5015 -0
  68. package/src/context.ts +210 -0
  69. package/src/cookie-sign.ts +58 -0
  70. package/src/cookie.ts +89 -0
  71. package/src/cors.ts +96 -0
  72. package/src/crypto.ts +19 -0
  73. package/src/csrf.ts +54 -0
  74. package/src/curl-logger.ts +235 -0
  75. package/src/detect-runtime.ts +19 -0
  76. package/src/error.ts +52 -0
  77. package/src/errors.ts +87 -0
  78. package/src/etag.ts +74 -0
  79. package/src/feature-load.ts +23 -0
  80. package/src/go-type-emitter.ts +404 -0
  81. package/src/honey-response.ts +354 -0
  82. package/src/i18n-slot.ts +20 -0
  83. package/src/i18n.ts +234 -0
  84. package/src/index.ts +3843 -0
  85. package/src/init.ts +149 -0
  86. package/src/input.ts +8 -0
  87. package/src/ip-restrict.ts +192 -0
  88. package/src/logger.ts +149 -0
  89. package/src/middleware.ts +199 -0
  90. package/src/node-request.ts +240 -0
  91. package/src/node.ts +212 -0
  92. package/src/openapi/register.ts +19 -0
  93. package/src/openapi/scalar.ts +33 -0
  94. package/src/openapi/spec-factory.ts +46 -0
  95. package/src/openapi/spec.ts +31 -0
  96. package/src/openapi/swagger.ts +34 -0
  97. package/src/plugin.ts +771 -0
  98. package/src/powered-by.ts +17 -0
  99. package/src/pretty-json.ts +39 -0
  100. package/src/proxy.ts +155 -0
  101. package/src/python-type-emitter.ts +144 -0
  102. package/src/realtime/buffer.ts +101 -0
  103. package/src/realtime/bus.ts +93 -0
  104. package/src/realtime/longpoll.ts +148 -0
  105. package/src/realtime/protocol.ts +128 -0
  106. package/src/realtime/route.ts +104 -0
  107. package/src/realtime/runtime.ts +430 -0
  108. package/src/request-id.ts +23 -0
  109. package/src/request-to-curl.ts +34 -0
  110. package/src/response.ts +414 -0
  111. package/src/rust-type-emitter.ts +412 -0
  112. package/src/secure-headers.ts +69 -0
  113. package/src/serve-register.ts +8 -0
  114. package/src/serve-slot.ts +20 -0
  115. package/src/serve.ts +141 -0
  116. package/src/server-timing.ts +66 -0
  117. package/src/static.ts +60 -0
  118. package/src/telemetry/otel.ts +139 -0
  119. package/src/testing.ts +114 -0
  120. package/src/timeout.ts +36 -0
  121. package/src/tree.ts +464 -0
  122. package/src/ts-type-emitter.ts +106 -0
  123. package/src/type-emitter.ts +524 -0
  124. package/src/type-extractor.ts +749 -0
  125. package/src/types.ts +638 -0
  126. package/src/validation.ts +300 -0
  127. package/src/ws/bun.ts +89 -0
  128. package/src/ws/cloudflare.ts +133 -0
  129. package/src/ws/deno.ts +109 -0
  130. package/src/ws/node.ts +161 -0
  131. package/src/yaml.ts +80 -0
@@ -0,0 +1,1010 @@
1
+ /* Go SDK emitter — generates a complete Go module from an OpenAPI 3.1 spec.
2
+ * Mirrors the TS emitter pattern (string-array + join, tab indent).
3
+ * Static runtime files (sse.go, ws.go, etc.) are read from ./client-go/ at
4
+ * module load time and copied verbatim into the output file map.
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process"
8
+ import { readFileSync } from "node:fs"
9
+ import { fileURLToPath } from "node:url"
10
+ import { collectSDKMethods, extractOpenApiPathParams, isSSEOperation, resolveRefs } from "./codegen.ts"
11
+ import { methodsOf, namespacesOf, toIR } from "./codegen-ir.ts"
12
+ import type { IRNamespace, IROperation } from "./codegen-ir.ts"
13
+ import { GO_KEYWORDS, goPascal, isStringEnum, renderTopLevel, renderUse } from "./go-type-emitter.ts"
14
+
15
+ /* ── types ── */
16
+
17
+ export type GoSDKOptions = {
18
+ /* post-process every emitted .go file with `gofmt`. Off by default — gofmt
19
+ * spawns a subprocess per file and byte-equivalence snapshot tests want
20
+ * stable unformatted output. Consumers publishing to pkg.go.dev should
21
+ * enable it so the rendered docs are canonical Go (aligned struct fields,
22
+ * multi-line if blocks, etc.). */
23
+ gofmt?: boolean
24
+ modulePath?: string
25
+ throwOnError?: boolean
26
+ }
27
+
28
+ export type GeneratedGoSDK = {
29
+ files: Record<string, string>
30
+ serviceMap: Record<string, Record<string, unknown>>
31
+ }
32
+
33
+ type OpenApiSpec = {
34
+ components?: {
35
+ schemas?: Record<string, Record<string, unknown>>
36
+ }
37
+ info: { description?: string; title: string; version: string }
38
+ openapi: string
39
+ paths: Record<string, Record<string, Record<string, unknown>>>
40
+ }
41
+
42
+ /* ── runtime template loader ── */
43
+
44
+ let runtimeCache: Map<string, string> | null = null
45
+
46
+ /** Reads static .go runtime files from ./client-go/ and caches them. */
47
+ export function loadGoRuntimeTemplates(): Map<string, string> {
48
+ if (runtimeCache) return runtimeCache
49
+
50
+ const names = ["result.go", "errors.go", "runtime.go", "invalidation.go", "sse.go", "ws.go", "transport.go", "realtime.go"]
51
+ const cache = new Map<string, string>()
52
+ for (const name of names) {
53
+ const filePath = fileURLToPath(new URL(`./client-go/${name}`, import.meta.url))
54
+ let content: string
55
+ try {
56
+ content = readFileSync(filePath, "utf8")
57
+ } catch {
58
+ throw new Error(`loadGoRuntimeTemplates: missing file ${filePath}`)
59
+ }
60
+ cache.set(name, content)
61
+ }
62
+ runtimeCache = cache
63
+ return cache
64
+ }
65
+
66
+ /* ── helpers ── */
67
+
68
+ export function safeResourceName(name: string): string {
69
+ const lower = name.toLowerCase()
70
+ return GO_KEYWORDS.has(lower) ? `${name}_` : name
71
+ }
72
+
73
+ function nestedStructName(path: string[]): string {
74
+ return `${path.map(goPascal).join("")}Resource`
75
+ }
76
+
77
+ /**
78
+ * Derive auth header name + prefix from OpenAPI securitySchemes.
79
+ * Defaults to Authorization + "Bearer " when no scheme is declared, preserving
80
+ * legacy behavior. apiKey type sends the raw token unless the scheme description
81
+ * embeds a "Format: <Prefix> {token}" hint (common pattern for APIs like Anyrow
82
+ * that use `Authorization: ApiKey <token>`).
83
+ */
84
+ export function detectAuthScheme(spec: Record<string, unknown>): { headerName: string; prefix: string } {
85
+ const components = (spec.components ?? {}) as Record<string, unknown>
86
+ const schemes = (components.securitySchemes ?? {}) as Record<string, Record<string, unknown>>
87
+ const keys = Object.keys(schemes).sort()
88
+ if (keys.length === 0) return { headerName: "Authorization", prefix: "Bearer " }
89
+ const scheme = schemes[keys[0]]
90
+ const type = String(scheme.type ?? "")
91
+ if (type === "http") {
92
+ const httpScheme = String(scheme.scheme ?? "").toLowerCase()
93
+ if (httpScheme === "basic") return { headerName: "Authorization", prefix: "Basic " }
94
+ return { headerName: "Authorization", prefix: "Bearer " }
95
+ }
96
+ if (type === "apiKey" && scheme.in === "header") {
97
+ const headerName = String(scheme.name ?? "Authorization")
98
+ const desc = String(scheme.description ?? "")
99
+ const match = /Format:\s*(\S+)\s+\{/i.exec(desc)
100
+ if (match) return { headerName, prefix: `${match[1]} ` }
101
+ return { headerName, prefix: "" }
102
+ }
103
+ return { headerName: "Authorization", prefix: "Bearer " }
104
+ }
105
+
106
+ function goImportBlock(stdlib: string[], thirdParty: string[]): string {
107
+ const l: string[] = []
108
+ l.push(`import (`)
109
+ for (const p of [...stdlib].sort()) l.push(`\t"${p}"`)
110
+ if (thirdParty.length > 0) {
111
+ l.push(``)
112
+ for (const p of [...thirdParty].sort()) l.push(`\t"${p}"`)
113
+ }
114
+ l.push(`)`)
115
+ return l.join("\n")
116
+ }
117
+
118
+ /** Converts /users/{id} → string expression pieces for URL construction. */
119
+ function pathToGoExpr(path: string): string {
120
+ const params = extractOpenApiPathParams(path)
121
+ if (params.length === 0) return JSON.stringify(path)
122
+ let expr = JSON.stringify(path)
123
+ for (const p of params) {
124
+ expr = expr.replace(`{${p}}`, `" + url.PathEscape(${p}) + "`)
125
+ }
126
+ /* clean up empty string concatenation at edges */
127
+ expr = expr.replace(/"" \+ /g, "").replace(/ \+ ""/g, "")
128
+ return expr
129
+ }
130
+
131
+ /** Extracts the Go response type for an operation from its 2xx responses. */
132
+ function goResponseType(op: Record<string, unknown>): string {
133
+ const responses = op.responses as Record<string, Record<string, unknown>> | undefined
134
+ if (!responses) return "json.RawMessage"
135
+
136
+ for (const [status, response] of Object.entries(responses)) {
137
+ const code = Number.parseInt(status, 10)
138
+ if (Number.isNaN(code) || code < 200 || code >= 300) continue
139
+ if (code === 204) return ""
140
+ const content = response.content as Record<string, Record<string, unknown>> | undefined
141
+ if (!content) return ""
142
+ const jsonSchema = content["application/json"]?.schema as Record<string, unknown> | undefined
143
+ if (!jsonSchema) return "json.RawMessage"
144
+ if (jsonSchema.$ref) {
145
+ return goPascal((jsonSchema.$ref as string).split("/").pop() ?? "")
146
+ }
147
+ if (jsonSchema.type === "array" && jsonSchema.items) {
148
+ const items = jsonSchema.items as Record<string, unknown>
149
+ if (items.$ref) {
150
+ return `[]${goPascal((items.$ref as string).split("/").pop() ?? "")}`
151
+ }
152
+ return `[]${renderUse(items, { decls: new Map(), fieldName: "item", parentName: "Body" })}`
153
+ }
154
+ if (jsonSchema.type === "string") return "string"
155
+ if (jsonSchema.type === "integer") return "int64"
156
+ if (jsonSchema.type === "number") return "float64"
157
+ if (jsonSchema.type === "boolean") return "bool"
158
+ return "json.RawMessage"
159
+ }
160
+ return "json.RawMessage"
161
+ }
162
+
163
+ /** Extracts the Go input type (request body schema name) for an operation. */
164
+ function goBodyType(op: Record<string, unknown>): string | null {
165
+ const requestBody = op.requestBody as Record<string, unknown> | undefined
166
+ if (!requestBody) return null
167
+ const content = requestBody.content as Record<string, Record<string, unknown>> | undefined
168
+ if (!content) return null
169
+ const jsonContent = content["application/json"]
170
+ if (!jsonContent?.schema) return null
171
+ const schema = jsonContent.schema as Record<string, unknown>
172
+ if (schema.$ref) {
173
+ return goPascal((schema.$ref as string).split("/").pop() ?? "")
174
+ }
175
+ return "json.RawMessage"
176
+ }
177
+
178
+ /** True iff operation declares `application/octet-stream` request body. */
179
+ function hasOctetStreamBody(op: Record<string, unknown>): boolean {
180
+ const requestBody = op.requestBody as Record<string, unknown> | undefined
181
+ const content = requestBody?.content as Record<string, unknown> | undefined
182
+ return content !== undefined && "application/octet-stream" in content
183
+ }
184
+
185
+ type QueryParam = {
186
+ name: string
187
+ required: boolean
188
+ schema: Record<string, unknown>
189
+ }
190
+
191
+ /**
192
+ * Produces a Go expression that converts a query-param value to `string`,
193
+ * suitable as the 2nd argument of `url.Values.Set`. Nullable fields should
194
+ * already be dereferenced by the caller — `varExpr` is the concrete value.
195
+ */
196
+ function qpStringExpr(varExpr: string, schema: Record<string, unknown>): string {
197
+ if (isStringEnum(schema)) return `string(${varExpr})`
198
+ const t = schema.type
199
+ if (t === "integer") return `strconv.FormatInt(${varExpr}, 10)`
200
+ if (t === "number") return `strconv.FormatFloat(${varExpr}, 'f', -1, 64)`
201
+ if (t === "boolean") return `strconv.FormatBool(${varExpr})`
202
+ return varExpr
203
+ }
204
+
205
+ function queryParamsNeedStrconv(methods: MethodInfo[]): boolean {
206
+ for (const m of methods) {
207
+ for (const qp of m.queryParams) {
208
+ const t = qp.schema.type
209
+ if (t === "integer" || t === "number" || t === "boolean") return true
210
+ }
211
+ }
212
+ return false
213
+ }
214
+
215
+ function getQueryParams(op: Record<string, unknown>): QueryParam[] {
216
+ const parameters = op.parameters as Array<Record<string, unknown>> | undefined
217
+ if (!parameters) return []
218
+ return parameters
219
+ .filter((p) => p.in === "query")
220
+ .map((p) => ({
221
+ name: String(p.name),
222
+ required: p.required === true,
223
+ schema: (p.schema as Record<string, unknown>) ?? { type: "string" },
224
+ }))
225
+ .sort((a, b) => a.name.localeCompare(b.name))
226
+ }
227
+
228
+ /* ── buildGoTypes ── */
229
+
230
+ /* Schema names defined in the Go runtime files — skip them to avoid redeclaration. */
231
+ const GO_RUNTIME_TYPES = new Set([
232
+ "APIError",
233
+ "BadGatewayError",
234
+ "BadRequestError",
235
+ "Config",
236
+ "ConflictError",
237
+ "ConnectionState",
238
+ "ForbiddenError",
239
+ "GatewayTimeoutError",
240
+ "InternalServerError",
241
+ "InvalidationConfig",
242
+ "LongpollTransport",
243
+ "NotFoundError",
244
+ "RateLimitError",
245
+ "RequestMeta",
246
+ "ResumableConnection",
247
+ "SDKResult",
248
+ "SSEEvent",
249
+ "ServiceUnavailableError",
250
+ "SseTransport",
251
+ "StaleTracker",
252
+ "StatusError",
253
+ "StatusErrorMeta",
254
+ "Transport",
255
+ "TransportConn",
256
+ "TransportKind",
257
+ "TransportOpts",
258
+ "TypedWebSocket",
259
+ "UnauthorizedError",
260
+ "UnprocessableEntityError",
261
+ "WsTransport",
262
+ ])
263
+
264
+ function buildGoTypes(spec: OpenApiSpec): string {
265
+ const schemas = spec.components?.schemas ?? {}
266
+ const l: string[] = []
267
+ /* blank line after generator header detaches it from package-doc on pkg.go.dev */
268
+ l.push(`// Code generated by honey. DO NOT EDIT.`)
269
+ l.push(``)
270
+ l.push(`package sdk`)
271
+ l.push(``)
272
+
273
+ const sortedSchemas = Object.entries(schemas)
274
+ .filter(([name]) => !GO_RUNTIME_TYPES.has(name))
275
+ .sort(([a], [b]) => a.localeCompare(b))
276
+ const body: string[] = []
277
+ const decls = new Map<string, string>()
278
+ for (const [name, schema] of sortedSchemas) {
279
+ const goType = renderTopLevel(name, schema, decls)
280
+ body.push(goType)
281
+ body.push(``)
282
+ }
283
+ /* append hoisted enum decls (deduped by name) after top-level types */
284
+ const hoistedNames = [...decls.keys()].sort((a, b) => a.localeCompare(b))
285
+ for (const hname of hoistedNames) {
286
+ body.push(decls.get(hname) as string)
287
+ body.push(``)
288
+ }
289
+
290
+ /* emit encoding/json import only when generated type bodies actually use it
291
+ * (json.RawMessage, json.Unmarshal, etc). Keeps types.go compilable whether
292
+ * schemas produced additionalProperties helpers or not. */
293
+ const bodyStr = body.join("\n")
294
+ if (bodyStr.includes("json.RawMessage") || bodyStr.includes("json.Unmarshal") || bodyStr.includes("json.Marshal")) {
295
+ l.push(`import "encoding/json"`)
296
+ l.push(``)
297
+ }
298
+ l.push(bodyStr)
299
+
300
+ return l.join("\n")
301
+ }
302
+
303
+ /* ── buildGoMod ── */
304
+
305
+ function buildGoMod(modulePath?: string): string {
306
+ const mod = modulePath ?? "example.com/sdk"
307
+ return [
308
+ `module ${mod}`,
309
+ ``,
310
+ `go 1.23`,
311
+ ``,
312
+ `require nhooyr.io/websocket v1.8.17`,
313
+ ``,
314
+ ].join("\n")
315
+ }
316
+
317
+ /* ── buildGoDoc ── */
318
+
319
+ function buildGoDoc(spec: OpenApiSpec): string {
320
+ const title = spec.info.title
321
+ const desc = spec.info.description
322
+ const descSuffix = desc ? ` ${desc}` : ""
323
+ /* Generator directive on its own line, separated from package-doc by blank
324
+ * line so pkg.go.dev does not aggregate "Code generated..." into Overview.
325
+ * The `// Package sdk ...` line IS attached to `package sdk` (no blank line
326
+ * before it) and becomes the package-level doc shown in the Overview. */
327
+ const l: string[] = []
328
+ l.push(`// Code generated by honey. DO NOT EDIT.`)
329
+ l.push(``)
330
+ l.push(`// Package sdk is an auto-generated client for ${title}.${descSuffix}`)
331
+ l.push(`package sdk`)
332
+ l.push(``)
333
+ return l.join("\n")
334
+ }
335
+
336
+ /* ── buildGoClient ── */
337
+
338
+ type MethodInfo = {
339
+ /* first segment of operationId — used as invalidationMap key */
340
+ resource: string
341
+ /* segments[1..] joined with "." — used as invalidationMap action key */
342
+ action: string
343
+ /* all segments except last — namespace path for struct name generation */
344
+ namePath: string[]
345
+ /* last segment of operationId — used for the Go method name */
346
+ methodAction: string
347
+ bodyIsStream: boolean
348
+ bodyType: string | null
349
+ description: string
350
+ httpMethod: string
351
+ isIdempotent: boolean
352
+ isRealtime: boolean
353
+ isSSE: boolean
354
+ isWS: boolean
355
+ op: Record<string, unknown>
356
+ opId: string
357
+ path: string
358
+ pathParams: string[]
359
+ queryParams: QueryParam[]
360
+ responseType: string
361
+ summary: string
362
+ /* Override for the Pascal-cased method/opts name. Set when a top-level
363
+ * operation's resource name collides with an existing resource bucket,
364
+ * so the emitted `Client` doesn't have a same-named field + method. */
365
+ methodNameOverride?: string
366
+ }
367
+
368
+ /** Build a MethodInfo from an IROperation + the raw spec op object. */
369
+ function methodInfoFromIROp(
370
+ irOp: IROperation,
371
+ rawOp: Record<string, unknown>,
372
+ ): MethodInfo {
373
+ const segments = irOp.id.split(".")
374
+ const isTopLevel = segments.length === 1
375
+ const resource = segments[0] ?? irOp.id
376
+ const action = isTopLevel ? "_call" : segments.slice(1).join(".")
377
+ const namePath = segments.slice(0, -1)
378
+ const methodAction = segments[segments.length - 1] ?? irOp.id
379
+ const isRealtime = rawOp["x-realtime"] === true
380
+ return {
381
+ action,
382
+ bodyIsStream: hasOctetStreamBody(rawOp),
383
+ bodyType: goBodyType(rawOp),
384
+ description: String(rawOp.description ?? ""),
385
+ httpMethod: irOp.method,
386
+ isIdempotent: rawOp["x-idempotency-key"] === true,
387
+ isRealtime,
388
+ isSSE: !isRealtime && isSSEOperation(rawOp),
389
+ isWS: !isRealtime && rawOp["x-websocket"] === true,
390
+ methodAction,
391
+ namePath,
392
+ op: rawOp,
393
+ opId: irOp.id,
394
+ path: irOp.path,
395
+ pathParams: irOp.params.path.map((p) => p.name),
396
+ queryParams: getQueryParams(rawOp),
397
+ resource,
398
+ responseType: goResponseType(rawOp),
399
+ summary: String(rawOp.summary ?? ""),
400
+ }
401
+ }
402
+
403
+ /** Walk the IR tree and collect all MethodInfos, pairing with raw spec ops. */
404
+ function collectMethodsFromIR(
405
+ ns: IRNamespace,
406
+ spec: OpenApiSpec,
407
+ ): MethodInfo[] {
408
+ const rawOpById = new Map<string, Record<string, unknown>>()
409
+ for (const [, pathMethods] of Object.entries(spec.paths ?? {})) {
410
+ for (const [, operation] of Object.entries(pathMethods)) {
411
+ const op = operation as Record<string, unknown>
412
+ const id = op.operationId as string | undefined
413
+ if (id) rawOpById.set(id, op)
414
+ }
415
+ }
416
+
417
+ const results: MethodInfo[] = []
418
+
419
+ function walk(cur: IRNamespace): void {
420
+ for (const [, entry] of methodsOf(cur)) {
421
+ const raw = rawOpById.get(entry.id) ?? {}
422
+ results.push(methodInfoFromIROp(entry, raw))
423
+ }
424
+ for (const [, childNs] of namespacesOf(cur)) {
425
+ walk(childNs)
426
+ }
427
+ }
428
+
429
+ walk(ns)
430
+ return results
431
+ }
432
+
433
+ function buildGoClient(spec: OpenApiSpec, options: GoSDKOptions): string {
434
+ const throwOnError = options.throwOnError ?? true
435
+
436
+ /* toIR validates operationId collisions — throws before any codegen runs */
437
+ const ir = toIR(spec as Parameters<typeof toIR>[0])
438
+ const methods = collectMethodsFromIR(ir.tree, spec)
439
+
440
+ /* top-level single-segment ops (action === "_call") emit as methods on *Client */
441
+ const topLevel: MethodInfo[] = methods.filter((m) => m.action === "_call")
442
+ /* collect root namespace names to detect field/method name collision */
443
+ const rootNsNames = new Set(namespacesOf(ir.tree).map(([seg]) => safeResourceName(seg)))
444
+
445
+ /* top-level op whose resource name collides with a root namespace field needs a
446
+ * unique method name — Go forbids struct field + method sharing a name.
447
+ * Suffix with "Call": extract + extract.table → Extract field + ExtractCall method. */
448
+ for (const m of topLevel) {
449
+ if (rootNsNames.has(safeResourceName(m.resource))) {
450
+ m.methodNameOverride = `${goPascal(m.resource)}Call`
451
+ }
452
+ }
453
+
454
+ const hasWS = methods.some((m) => m.isWS)
455
+
456
+ /* collect invalidation data for map */
457
+ const serviceMap = collectSDKMethods(spec as Parameters<typeof collectSDKMethods>[0]).serviceMap
458
+
459
+ const body: string[] = []
460
+
461
+ /* serviceEntry struct */
462
+ body.push(`type serviceEntry struct {`)
463
+ body.push(`\tMethod string`)
464
+ body.push(`\tPath string`)
465
+ body.push(`\tParams []string`)
466
+ body.push(`\tSSE bool`)
467
+ body.push(`\tWS bool`)
468
+ body.push(`\tInvalidate []string`)
469
+ body.push(`}`)
470
+ body.push(``)
471
+
472
+ /* invalidationMap */
473
+ body.push(`var invalidationMap = map[string]map[string]serviceEntry{`)
474
+ for (const [resource, actions] of Object.entries(serviceMap).sort(([a], [b]) => a.localeCompare(b))) {
475
+ body.push(`\t"${resource}": {`)
476
+ for (const [action, entry] of Object.entries(actions as Record<string, Record<string, unknown>>).sort(([a], [b]) => a.localeCompare(b))) {
477
+ const inv = entry.invalidate as string[] | undefined
478
+ const invStr = inv && inv.length > 0
479
+ ? `[]string{${inv.map((s) => JSON.stringify(s)).join(", ")}}`
480
+ : `nil`
481
+ const params = entry.params as string[] | undefined
482
+ const paramsStr = params && params.length > 0
483
+ ? `[]string{${params.map((s) => JSON.stringify(s)).join(", ")}}`
484
+ : `nil`
485
+ body.push(`\t\t"${action}": {Method: ${JSON.stringify(String(entry.method))}, Path: ${JSON.stringify(String(entry.path))}, Params: ${paramsStr}, SSE: ${entry.sse === true}, WS: ${entry.ws === true}, Invalidate: ${invStr}},`)
486
+ }
487
+ body.push(`\t},`)
488
+ }
489
+ body.push(`}`)
490
+ body.push(``)
491
+
492
+ /* ---- Client struct: cfg + stale + one field per root namespace ---- */
493
+ body.push(`// Client is the generated SDK client.`)
494
+ body.push(`type Client struct {`)
495
+ body.push(`\tcfg Config`)
496
+ body.push(`\tstale *StaleTracker`)
497
+ for (const [seg] of namespacesOf(ir.tree)) {
498
+ const safeName = safeResourceName(seg)
499
+ body.push(`\t${goPascal(safeName)} *${nestedStructName([safeName])}`)
500
+ }
501
+ body.push(`}`)
502
+ body.push(``)
503
+
504
+ /* ---- NewClient: init + cascade nested struct init ---- */
505
+ const auth = detectAuthScheme(spec)
506
+ body.push(`// NewClient creates a new SDK client. BaseURL must be non-empty.`)
507
+ body.push(`func NewClient(cfg Config) *Client {`)
508
+ body.push(`\tif cfg.BaseURL == "" {`)
509
+ body.push(`\t\tpanic("honey sdk: Config.BaseURL must not be empty")`)
510
+ body.push(`\t}`)
511
+ body.push(`\tif cfg.HTTPClient == nil {`)
512
+ body.push(`\t\tcfg.HTTPClient = &http.Client{Timeout: 30 * time.Second}`)
513
+ body.push(`\t}`)
514
+ body.push(`\tif cfg.AuthHeaderName == "" {`)
515
+ body.push(`\t\tcfg.AuthHeaderName = ${JSON.stringify(auth.headerName)}`)
516
+ body.push(`\t}`)
517
+ body.push(`\tif cfg.AuthHeaderPrefix == "" {`)
518
+ body.push(`\t\tcfg.AuthHeaderPrefix = ${JSON.stringify(auth.prefix)}`)
519
+ body.push(`\t}`)
520
+ body.push(`\tc := &Client{cfg: cfg}`)
521
+ body.push(`\tc.stale = NewStaleTracker(cfg.Invalidation)`)
522
+ /* emit a helper fn per root namespace that inits itself + its children,
523
+ * then assign: c.Checkout = newCheckoutResource(c) */
524
+ for (const [seg] of namespacesOf(ir.tree)) {
525
+ const safeName = safeResourceName(seg)
526
+ body.push(`\tc.${goPascal(safeName)} = new${nestedStructName([safeName])}(c)`)
527
+ }
528
+ body.push(`\treturn c`)
529
+ body.push(`}`)
530
+ body.push(``)
531
+
532
+ /* IsStale predicate */
533
+ body.push(`// IsStale reports whether (method, path) currently sits inside an active stale window.`)
534
+ body.push(`func (c *Client) IsStale(method, path string) bool {`)
535
+ body.push(`\tif c.stale == nil { return false }`)
536
+ body.push(`\treturn c.stale.IsStale(method, path)`)
537
+ body.push(`}`)
538
+ body.push(``)
539
+
540
+ /* top-level single-segment methods on *Client */
541
+ for (const m of topLevel.sort((a, b) => a.resource.localeCompare(b.resource))) {
542
+ body.push(...emitMethod(m, "c", "*Client", throwOnError))
543
+ }
544
+
545
+ /* ---- recursive resource struct + method emission ---- */
546
+ function emitResourceStructs(ns: IRNamespace, path: string[]): void {
547
+ const structName = nestedStructName(path)
548
+ const recvName = path[path.length - 1]?.[0]?.toLowerCase() ?? "r"
549
+
550
+ /* struct definition: client pointer + one field per child namespace */
551
+ body.push(`type ${structName} struct {`)
552
+ body.push(`\tclient *Client`)
553
+ for (const [childSeg] of namespacesOf(ns)) {
554
+ const safeSeg = safeResourceName(childSeg)
555
+ body.push(`\t${goPascal(safeSeg)} *${nestedStructName([...path, safeSeg])}`)
556
+ }
557
+ body.push(`}`)
558
+ body.push(``)
559
+
560
+ /* constructor helper: newXResource(c) sets client + child fields */
561
+ body.push(`func new${structName}(c *Client) *${structName} {`)
562
+ body.push(`\tr := &${structName}{client: c}`)
563
+ for (const [childSeg] of namespacesOf(ns)) {
564
+ const safeSeg = safeResourceName(childSeg)
565
+ body.push(`\tr.${goPascal(safeSeg)} = new${nestedStructName([...path, safeSeg])}(c)`)
566
+ }
567
+ body.push(`\treturn r`)
568
+ body.push(`}`)
569
+ body.push(``)
570
+
571
+ /* direct methods on this struct */
572
+ for (const [, op] of methodsOf(ns)) {
573
+ const raw = (methods.find((m) => m.opId === op.id)) ?? (() => { throw new Error(`missing MethodInfo for ${op.id}`) })()
574
+ body.push(...emitMethod(raw, recvName, `*${structName}`, throwOnError))
575
+ }
576
+
577
+ /* recurse into child namespaces */
578
+ for (const [childSeg, childNs] of namespacesOf(ns)) {
579
+ emitResourceStructs(childNs, [...path, safeResourceName(childSeg)])
580
+ }
581
+ }
582
+
583
+ for (const [seg, ns] of namespacesOf(ir.tree)) {
584
+ emitResourceStructs(ns, [safeResourceName(seg)])
585
+ }
586
+
587
+ /* opts structs — emit once per unique opts name, sorted by opId */
588
+ const optsEmitted = new Set<string>()
589
+ for (const m of methods.sort((a, b) => a.opId.localeCompare(b.opId))) {
590
+ const optsName = optsStructName(m)
591
+ if (optsEmitted.has(optsName)) continue
592
+ optsEmitted.add(optsName)
593
+ body.push(...emitOptsStruct(m))
594
+ }
595
+
596
+ const bodyStr = body.join("\n")
597
+
598
+ const hasSSE = methods.some((m) => m.isSSE)
599
+ const stdlib = ["context", "encoding/json", "fmt", "io", "net/http", "net/url", "strings", "sync", "time"]
600
+ if (hasSSE) stdlib.push("iter")
601
+ if (queryParamsNeedStrconv(methods)) stdlib.push("strconv")
602
+ const thirdParty = hasWS ? ["nhooyr.io/websocket"] : []
603
+
604
+ /* emit a `var _ = <symbol>` suppression only for stdlib imports the body
605
+ * does not already reference, so unused-import compile errors are avoided
606
+ * without polluting generated code with dead suppressions. */
607
+ const suppressions: { line: string; symbol: string }[] = [
608
+ { line: `var _ json.RawMessage`, symbol: "json." },
609
+ { line: `var _ = fmt.Sprintf`, symbol: "fmt." },
610
+ { line: `var _ = io.Discard`, symbol: "io." },
611
+ { line: `var _ *url.URL`, symbol: "url." },
612
+ { line: `var _ = strings.NewReader`, symbol: "strings." },
613
+ { line: `var _ = sync.Mutex{}`, symbol: "sync." },
614
+ { line: `var _ = time.Second`, symbol: "time." },
615
+ ]
616
+ const suppressionLines = suppressions
617
+ .filter((s) => !bodyStr.includes(s.symbol))
618
+ .map((s) => s.line)
619
+
620
+ const out: string[] = []
621
+ /* blank line after generator header detaches it from package-doc on pkg.go.dev */
622
+ out.push(`// Code generated by honey. DO NOT EDIT.`)
623
+ out.push(``)
624
+ out.push(`package sdk`)
625
+ out.push(``)
626
+ out.push(goImportBlock([...new Set(stdlib)].sort(), thirdParty))
627
+ out.push(``)
628
+ if (suppressionLines.length > 0) {
629
+ out.push(...suppressionLines)
630
+ out.push(``)
631
+ }
632
+ out.push(bodyStr)
633
+ return out.join("\n")
634
+ }
635
+
636
+ function optsStructName(m: MethodInfo): string {
637
+ if (m.methodNameOverride) return `${m.methodNameOverride}Opts`
638
+ /* For top-level single-segment ops: namePath=[], methodAction=opId → e.g. GetUserOpts
639
+ * For multi-segment: namePath=[checkout,sessions], methodAction=create → CheckoutSessionsCreateOpts */
640
+ const prefix = m.namePath.map(goPascal).join("")
641
+ const suffix = goPascal(m.methodAction)
642
+ return `${prefix}${suffix}Opts`
643
+ }
644
+
645
+ function emitOptsStruct(m: MethodInfo): string[] {
646
+ const l: string[] = []
647
+ const optsName = optsStructName(m)
648
+ if (m.isRealtime) {
649
+ /* realtime ops get a fixed opts shape — no generated query fields;
650
+ * transports receive ReconnectToken / LastEventID via TransportOpts. */
651
+ l.push(`type ${optsName} struct {`)
652
+ l.push(`\tReconnectToken string`)
653
+ l.push(`\tLastEventID string`)
654
+ l.push(`\tProtocols []string`)
655
+ l.push(`\tMaxReconnectAttempts int`)
656
+ l.push(`\tReconnectDelayMs int`)
657
+ l.push(`\tHeaders map[string]string`)
658
+ l.push(`}`)
659
+ l.push(``)
660
+ return l
661
+ }
662
+ const decls = new Map<string, string>()
663
+ const fields: string[] = []
664
+ for (const qp of m.queryParams) {
665
+ const fieldName = goPascal(qp.name)
666
+ const goType = renderUse(qp.schema, {
667
+ decls,
668
+ fieldName: qp.name,
669
+ parentName: optsName,
670
+ })
671
+ const ptr = qp.required ? goType : `*${goType}`
672
+ fields.push(`\t${fieldName} ${ptr}`)
673
+ }
674
+ /* hoisted query-param enums emit before the opts struct so refs resolve */
675
+ for (const decl of decls.values()) {
676
+ l.push(decl)
677
+ l.push(``)
678
+ }
679
+ l.push(`type ${optsName} struct {`)
680
+ l.push(...fields)
681
+ if (m.isSSE) {
682
+ l.push(`\tLastEventID string`)
683
+ }
684
+ if (m.isWS) {
685
+ l.push(`\tReconnectToken string`)
686
+ l.push(`\tProtocols []string`)
687
+ }
688
+ if (m.isIdempotent && !m.isRealtime && !m.isSSE && !m.isWS) {
689
+ l.push(`\tIdempotencyKey string`)
690
+ }
691
+ l.push(`\tHeaders map[string]string`)
692
+ l.push(`}`)
693
+ l.push(``)
694
+ return l
695
+ }
696
+
697
+ function emitMethod(m: MethodInfo, recv: string, recvType: string, throwOnError: boolean): string[] {
698
+ const l: string[] = []
699
+ /* top-level single-segment ops (action === "_call") use resource as the method name */
700
+ const actionPascal = m.methodNameOverride ?? goPascal(m.action === "_call" ? m.resource : m.methodAction)
701
+ const optsName = optsStructName(m)
702
+ const httpMethod = m.httpMethod
703
+ const pathStr = m.path
704
+
705
+ /* godoc */
706
+ if (m.summary) {
707
+ l.push(`// ${actionPascal} — ${m.summary}`)
708
+ if (m.description) l.push(`// ${m.description}`)
709
+ }
710
+
711
+ /* signature */
712
+ const params: string[] = [`ctx context.Context`]
713
+ for (const pp of m.pathParams) params.push(`${pp} string`)
714
+ if (m.bodyIsStream) params.push(`body io.Reader`)
715
+ else if (m.bodyType) params.push(`body ${m.bodyType}`)
716
+
717
+ let ret: string
718
+ if (m.isRealtime) {
719
+ params.push(`opts *${optsName}`)
720
+ ret = `*ResumableConnection`
721
+ } else if (m.isSSE) {
722
+ params.push(`opts *${optsName}`)
723
+ ret = `iter.Seq2[SSEEvent, error]`
724
+ } else if (m.isWS) {
725
+ params.push(`opts *${optsName}`)
726
+ ret = `(*TypedWebSocket, error)`
727
+ } else if (throwOnError) {
728
+ params.push(`opts *${optsName}`)
729
+ ret = m.responseType !== "" ? `(*${m.responseType}, error)` : `error`
730
+ } else {
731
+ params.push(`opts *${optsName}`)
732
+ ret = m.responseType !== "" ? `(SDKResult[${m.responseType}], error)` : `error`
733
+ }
734
+
735
+ l.push(`func (${recv} ${recvType}) ${actionPascal}(${params.join(", ")}) ${ret} {`)
736
+
737
+ const httpMethodStr = httpMethod.toUpperCase()
738
+
739
+ /* client accessor */
740
+ const cfgAccess = recvType === "*Client" ? `${recv}.cfg` : `${recv}.client.cfg`
741
+ const staleAccess = recvType === "*Client" ? `${recv}.stale` : `${recv}.client.stale`
742
+
743
+ if (m.isRealtime) {
744
+ const urlExpr = pathToGoExpr(pathStr)
745
+ /* ctx is accepted for API symmetry with sibling methods but not used
746
+ * here — consumer binds a ctx at ResumableConnection.Connect(ctx) time. */
747
+ l.push(`\t_ = ctx`)
748
+ l.push(`\ttransports := []Transport{&WsTransport{}, &SseTransport{}, &LongpollTransport{}}`)
749
+ l.push(`\ttopts := &TransportOpts{}`)
750
+ l.push(`\tif opts != nil {`)
751
+ l.push(`\t\ttopts.ReconnectToken = opts.ReconnectToken`)
752
+ l.push(`\t\ttopts.LastEventID = opts.LastEventID`)
753
+ l.push(`\t\ttopts.Protocols = opts.Protocols`)
754
+ l.push(`\t\ttopts.MaxReconnectAttempts = opts.MaxReconnectAttempts`)
755
+ l.push(`\t\ttopts.ReconnectDelayMs = opts.ReconnectDelayMs`)
756
+ l.push(`\t\tif len(opts.Headers) > 0 {`)
757
+ l.push(`\t\t\ttopts.Headers = http.Header{}`)
758
+ l.push(`\t\t\tfor k, v := range opts.Headers { topts.Headers.Set(k, v) }`)
759
+ l.push(`\t\t}`)
760
+ l.push(`\t}`)
761
+ l.push(`\treturn NewResumableConnection(${cfgAccess}.BaseURL + ${urlExpr}, transports, topts)`)
762
+ l.push(`}`)
763
+ l.push(``)
764
+ return l
765
+ }
766
+
767
+ if (m.isSSE) {
768
+ const sseQueryParams = m.queryParams.filter((qp) => qp.name !== "last-event-id")
769
+ l.push(`\treturn func(yield func(SSEEvent, error) bool) {`)
770
+ if (sseQueryParams.length > 0) {
771
+ l.push(`\t\tq := url.Values{}`)
772
+ for (const qp of sseQueryParams) {
773
+ const fieldName = goPascal(qp.name)
774
+ if (qp.required) {
775
+ l.push(`\t\tif opts != nil { q.Set(${JSON.stringify(qp.name)}, ${qpStringExpr(`opts.${fieldName}`, qp.schema)}) }`)
776
+ } else {
777
+ l.push(`\t\tif opts != nil && opts.${fieldName} != nil { q.Set(${JSON.stringify(qp.name)}, ${qpStringExpr(`*opts.${fieldName}`, qp.schema)}) }`)
778
+ }
779
+ }
780
+ }
781
+ const urlExpr = pathToGoExpr(pathStr)
782
+ const sseURLExpr = sseQueryParams.length > 0
783
+ ? `${cfgAccess}.BaseURL+${urlExpr}+"?"+q.Encode()`
784
+ : `${cfgAccess}.BaseURL+${urlExpr}`
785
+ l.push(`\t\tcallHeaders := map[string]string{"Accept": "text/event-stream"}`)
786
+ l.push(`\t\tif opts != nil && opts.LastEventID != "" {`)
787
+ l.push(`\t\t\tcallHeaders["Last-Event-ID"] = opts.LastEventID`)
788
+ l.push(`\t\t}`)
789
+ l.push(`\t\tif opts != nil {`)
790
+ l.push(`\t\t\tfor k, v := range opts.Headers { callHeaders[k] = v }`)
791
+ l.push(`\t\t}`)
792
+ l.push(`\t\treq, err := http.NewRequestWithContext(ctx, "GET", ${sseURLExpr}, nil)`)
793
+ l.push(`\t\tif err != nil { yield(SSEEvent{}, err); return }`)
794
+ l.push(`\t\tfor k, v := range ${cfgAccess}.Headers { req.Header.Set(k, v) }`)
795
+ l.push(`\t\tfor k, v := range callHeaders { req.Header.Set(k, v) }`)
796
+ l.push(`\t\tif ${cfgAccess}.BearerToken != "" { req.Header.Set(${cfgAccess}.AuthHeaderName, ${cfgAccess}.AuthHeaderPrefix+${cfgAccess}.BearerToken) }`)
797
+ l.push(`\t\tresp, err := ${cfgAccess}.HTTPClient.Do(req)`)
798
+ l.push(`\t\tif err != nil { yield(SSEEvent{}, err); return }`)
799
+ l.push(`\t\tfor ev, err := range parseSSEStream(ctx, resp) {`)
800
+ l.push(`\t\t\tif !yield(ev, err) { return }`)
801
+ l.push(`\t\t}`)
802
+ l.push(`\t}`)
803
+ l.push(`}`)
804
+ l.push(``)
805
+ return l
806
+ }
807
+
808
+ if (m.isWS) {
809
+ const urlExpr = pathToGoExpr(pathStr)
810
+ l.push(`\tq := url.Values{}`)
811
+ for (const qp of m.queryParams) {
812
+ if (qp.name === "reconnect_token") continue
813
+ const fieldName = goPascal(qp.name)
814
+ if (qp.required) {
815
+ l.push(`\tif opts != nil { q.Set(${JSON.stringify(qp.name)}, ${qpStringExpr(`opts.${fieldName}`, qp.schema)}) }`)
816
+ } else {
817
+ l.push(`\tif opts != nil && opts.${fieldName} != nil { q.Set(${JSON.stringify(qp.name)}, ${qpStringExpr(`*opts.${fieldName}`, qp.schema)}) }`)
818
+ }
819
+ }
820
+ l.push(`\tif opts != nil && opts.ReconnectToken != "" { q.Set("reconnect_token", opts.ReconnectToken) }`)
821
+ l.push(`\twsURL := ${cfgAccess}.BaseURL + ${urlExpr}`)
822
+ l.push(`\tif len(q) > 0 { wsURL += "?" + q.Encode() }`)
823
+ l.push(`\tdialOpts := &websocket.DialOptions{}`)
824
+ l.push(`\tif opts != nil && len(opts.Protocols) > 0 { dialOpts.Subprotocols = opts.Protocols }`)
825
+ l.push(`\tif opts != nil && len(opts.Headers) > 0 {`)
826
+ l.push(`\t\tdialOpts.HTTPHeader = http.Header{}`)
827
+ l.push(`\t\tfor k, v := range opts.Headers { dialOpts.HTTPHeader.Set(k, v) }`)
828
+ l.push(`\t}`)
829
+ l.push(`\tconn, _, err := websocket.Dial(ctx, wsURL, dialOpts)`)
830
+ l.push(`\tif err != nil { return nil, err }`)
831
+ l.push(`\treturn newTypedWebSocket(conn), nil`)
832
+ l.push(`}`)
833
+ l.push(``)
834
+ return l
835
+ }
836
+
837
+ /* HTTP method */
838
+ l.push(`\tq := url.Values{}`)
839
+ for (const qp of m.queryParams) {
840
+ const fieldName = goPascal(qp.name)
841
+ if (qp.required) {
842
+ l.push(`\tif opts != nil { q.Set(${JSON.stringify(qp.name)}, ${qpStringExpr(`opts.${fieldName}`, qp.schema)}) }`)
843
+ } else {
844
+ l.push(`\tif opts != nil && opts.${fieldName} != nil { q.Set(${JSON.stringify(qp.name)}, ${qpStringExpr(`*opts.${fieldName}`, qp.schema)}) }`)
845
+ }
846
+ }
847
+
848
+ const urlExpr = pathToGoExpr(pathStr)
849
+ const bodyArg = !m.bodyIsStream && m.bodyType ? "body" : "nil"
850
+ const bodyReaderArg = m.bodyIsStream ? "body" : "nil"
851
+ const contentTypeArg = m.bodyIsStream ? `"application/octet-stream"` : `""`
852
+
853
+ l.push(`\tvar callHeaders map[string]string`)
854
+ l.push(`\tif opts != nil { callHeaders = opts.Headers }`)
855
+
856
+ /* idempotency-key auto-injection: case-insensitive existing-header check
857
+ * matches caller's intent if they supplied any casing. opts==nil branch
858
+ * still emits a key so the contract holds (every dispatch carries a key
859
+ * for ops marked x-idempotency-key). */
860
+ if (m.isIdempotent && !m.isRealtime && !m.isSSE && !m.isWS) {
861
+ l.push(`\tif opts != nil {`)
862
+ l.push(`\t\t_idemExisting := ""`)
863
+ l.push(`\t\tfor _k, _v := range callHeaders {`)
864
+ l.push(`\t\t\tif strings.EqualFold(_k, "Idempotency-Key") { _idemExisting = _v; break }`)
865
+ l.push(`\t\t}`)
866
+ l.push(`\t\tif _idemExisting == "" {`)
867
+ l.push(`\t\t\tif callHeaders == nil { callHeaders = map[string]string{} }`)
868
+ l.push(`\t\t\tif opts.IdempotencyKey != "" {`)
869
+ l.push(`\t\t\t\tcallHeaders["Idempotency-Key"] = opts.IdempotencyKey`)
870
+ l.push(`\t\t\t} else {`)
871
+ l.push(`\t\t\t\tcallHeaders["Idempotency-Key"] = newUUIDv4()`)
872
+ l.push(`\t\t\t}`)
873
+ l.push(`\t\t}`)
874
+ l.push(`\t} else {`)
875
+ l.push(`\t\tcallHeaders = map[string]string{"Idempotency-Key": newUUIDv4()}`)
876
+ l.push(`\t}`)
877
+ }
878
+
879
+ /* invalidation pre-flight — build path-params map, interpolate to concrete
880
+ * selector, snapshot RequestMeta from the tracker. Emitted unconditionally
881
+ * so consumer can flip cfg.Invalidation on/off without regen; tracker's
882
+ * BuildRequestMeta short-circuits to nil when disabled. */
883
+ const hasInvalidate = Array.isArray(m.op["x-invalidate"]) && (m.op["x-invalidate"] as unknown[]).length > 0
884
+ const pathParamsMapExpr = m.pathParams.length > 0
885
+ ? `map[string]string{${m.pathParams.map((p) => `${JSON.stringify(p)}: ${p}`).join(", ")}}`
886
+ : `map[string]string(nil)`
887
+ if (hasInvalidate) {
888
+ l.push(`\t_pathParamsMap := ${pathParamsMapExpr}`)
889
+ l.push(`\t_concretePath, _ := interpolatePath(${JSON.stringify(pathStr)}, _pathParamsMap)`)
890
+ l.push(`\t_concreteSelector := ${JSON.stringify(`${httpMethodStr} `)} + _concretePath`)
891
+ l.push(`\tvar _requestMeta *RequestMeta`)
892
+ l.push(`\tif ${staleAccess} != nil { _requestMeta = ${staleAccess}.BuildRequestMeta(_concreteSelector, _concretePath, ${JSON.stringify(httpMethodStr)}) }`)
893
+ }
894
+
895
+ const resultBind = m.responseType === "" ? "_, err" : "result, err"
896
+ const operationStr = JSON.stringify(`${httpMethodStr} ${pathStr}`)
897
+ const requestMetaArg = hasInvalidate ? "_requestMeta" : "nil"
898
+ l.push(`\t${resultBind} := doRequest(ctx, ${cfgAccess}, "${httpMethodStr}", ${urlExpr}, q, ${bodyArg}, ${bodyReaderArg}, ${contentTypeArg}, callHeaders, ${operationStr}, ${requestMetaArg})`)
899
+ l.push(`\tif err != nil {`)
900
+
901
+ if (throwOnError) {
902
+ if (m.responseType !== "") {
903
+ l.push(`\t\treturn nil, err`)
904
+ } else {
905
+ l.push(`\t\treturn err`)
906
+ }
907
+ } else {
908
+ if (m.responseType !== "") {
909
+ /* unwrap typed APIError if doRequest produced one, else synthesize
910
+ * a StatusError so callers always see a typed Err without panicking
911
+ * on ctx cancel / network errors. */
912
+ l.push(`\t\tif apiErr, ok := err.(APIError); ok {`)
913
+ l.push(`\t\t\treturn SDKResult[${m.responseType}]{Err: apiErr, Status: 0, Response: nil}, nil`)
914
+ l.push(`\t\t}`)
915
+ l.push(`\t\treturn SDKResult[${m.responseType}]{Err: &StatusError{StatusCode: 0, Message: err.Error()}, Status: 0, Response: nil}, nil`)
916
+ } else {
917
+ l.push(`\t\treturn err`)
918
+ }
919
+ }
920
+ l.push(`\t}`)
921
+
922
+ /* invalidation post-flight — expand templated x-invalidate targets with the
923
+ * call's concrete params (TS parity: GET /users/{id} → GET /users/abc),
924
+ * mark them stale, then clear keys that were already stale before this
925
+ * mutation completed (seq-bounded to avoid clobbering newer invalidations). */
926
+ if (hasInvalidate) {
927
+ l.push(`\tif ${staleAccess} != nil {`)
928
+ l.push(`\t\t${staleAccess}.MarkStale(invalidationMap["${m.resource}"]["${m.action}"].Invalidate, _pathParamsMap, _concreteSelector)`)
929
+ l.push(`\t\tif _requestMeta != nil && _requestMeta.IsStale {`)
930
+ l.push(`\t\t\t${staleAccess}.ClearStale(_concreteSelector, _concretePath, ${JSON.stringify(httpMethodStr)}, _requestMeta.SeqSnapshot)`)
931
+ l.push(`\t\t}`)
932
+ l.push(`\t}`)
933
+ }
934
+
935
+ if (m.responseType === "") {
936
+ l.push(`\treturn nil`)
937
+ l.push(`}`)
938
+ l.push(``)
939
+ return l
940
+ }
941
+
942
+ if (throwOnError) {
943
+ l.push(`\tvar out ${m.responseType}`)
944
+ l.push(`\tif len(result.body) > 0 {`)
945
+ l.push(`\t\tif err := json.Unmarshal(result.body, &out); err != nil { return nil, err }`)
946
+ l.push(`\t}`)
947
+ l.push(`\treturn &out, nil`)
948
+ } else {
949
+ l.push(`\tvar out ${m.responseType}`)
950
+ l.push(`\tif len(result.body) > 0 {`)
951
+ l.push(`\t\tif err := json.Unmarshal(result.body, &out); err != nil {`)
952
+ l.push(`\t\t\treturn SDKResult[${m.responseType}]{Err: &StatusError{StatusCode: result.status, Body: result.body, Response: result.resp, Message: err.Error()}, Status: result.status, Response: result.resp}, nil`)
953
+ l.push(`\t\t}`)
954
+ l.push(`\t}`)
955
+ l.push(`\treturn SDKResult[${m.responseType}]{Data: &out, Status: result.status, Response: result.resp}, nil`)
956
+ }
957
+
958
+ l.push(`}`)
959
+ l.push(``)
960
+ return l
961
+ }
962
+
963
+ /* ── public entrypoint ── */
964
+
965
+ /**
966
+ * Generates a complete Go SDK module from an OpenAPI 3.1 spec.
967
+ * Returns a file map (filename → content) ready to write to disk as a Go module.
968
+ */
969
+ export function generateGoSDK(
970
+ spec: Record<string, unknown>,
971
+ options: GoSDKOptions = {},
972
+ ): GeneratedGoSDK {
973
+ const resolved = resolveRefs(spec as Parameters<typeof resolveRefs>[0]) as unknown as OpenApiSpec
974
+
975
+ const { serviceMap } = collectSDKMethods(spec as Parameters<typeof resolveRefs>[0])
976
+
977
+ const files: Record<string, string> = {}
978
+
979
+ /* static runtime templates */
980
+ for (const [name, content] of loadGoRuntimeTemplates()) {
981
+ files[name] = content
982
+ }
983
+
984
+ /* types.go uses unresolved spec because resolveRefs strips components.schemas. */
985
+ files["types.go"] = buildGoTypes(spec as unknown as OpenApiSpec)
986
+ /* client uses unresolved spec so request/response $refs survive for type
987
+ * resolution into named Go types (resolveRefs inlines and drops names). */
988
+ files["client.go"] = buildGoClient(spec as unknown as OpenApiSpec, options)
989
+ files["go.mod"] = buildGoMod(options.modulePath)
990
+ files["doc.go"] = buildGoDoc(resolved)
991
+
992
+ /* Opt-in post-process. Consumers publishing to pkg.go.dev pass `gofmt: true`
993
+ * so struct fields align and single-line `if` blocks expand. No-op when
994
+ * gofmt is not on PATH (pure-JS environments), preserving generator
995
+ * portability. */
996
+ if (options.gofmt) {
997
+ for (const [name, content] of Object.entries(files)) {
998
+ if (!name.endsWith(".go")) continue
999
+ files[name] = tryGofmt(content)
1000
+ }
1001
+ }
1002
+
1003
+ return { files, serviceMap }
1004
+ }
1005
+
1006
+ function tryGofmt(src: string): string {
1007
+ const res = spawnSync("gofmt", [], { encoding: "utf8", input: src })
1008
+ if (res.error || res.status !== 0 || !res.stdout) return src
1009
+ return res.stdout
1010
+ }