@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,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
|
+
}
|