@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,1652 @@
|
|
|
1
|
+
/* Rust SDK emitter — generates a complete Rust crate from an OpenAPI 3.1 spec. */
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import { collectSDKMethods, isSSEOperation, isStandardErrEnvelope } from "./codegen.ts"
|
|
6
|
+
import { detectAuthScheme } from "./codegen-go.ts"
|
|
7
|
+
import { methodsOf, namespacesOf, toIR } from "./codegen-ir.ts"
|
|
8
|
+
import type { IRNamespace, IROperation } from "./codegen-ir.ts"
|
|
9
|
+
import { RUST_KEYWORDS, renderTopLevelRust, renderUseRust, rustIdent, rustMethodIdent, rustPascal, rustSnake } from "./rust-type-emitter.ts"
|
|
10
|
+
|
|
11
|
+
export type RustSDKOptions = {
|
|
12
|
+
crateName?: string
|
|
13
|
+
throwOnError?: boolean
|
|
14
|
+
version?: string
|
|
15
|
+
description?: string
|
|
16
|
+
homepage?: string
|
|
17
|
+
repository?: string
|
|
18
|
+
license?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type GeneratedRustSDK = {
|
|
22
|
+
files: Record<string, string>
|
|
23
|
+
serviceMap: Record<string, Record<string, unknown>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type OpenApiSpec = {
|
|
27
|
+
components?: {
|
|
28
|
+
schemas?: Record<string, Record<string, unknown>>
|
|
29
|
+
}
|
|
30
|
+
info: { description?: string; title: string; version: string }
|
|
31
|
+
openapi: string
|
|
32
|
+
paths: Record<string, Record<string, Record<string, unknown>>>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let runtimeCache: Map<string, string> | null = null
|
|
36
|
+
|
|
37
|
+
export function loadRustRuntimeTemplates(): Map<string, string> {
|
|
38
|
+
if (runtimeCache) return runtimeCache
|
|
39
|
+
|
|
40
|
+
const names = ["errors.rs", "result.rs", "runtime.rs", "invalidation.rs", "invalidation_sync.rs", "sse.rs", "ws.rs", "realtime.rs", "runtime_sync.rs"]
|
|
41
|
+
const cache = new Map<string, string>()
|
|
42
|
+
for (const name of names) {
|
|
43
|
+
const filePath = fileURLToPath(new URL(`./client-rust/${name}`, import.meta.url))
|
|
44
|
+
let content: string
|
|
45
|
+
try {
|
|
46
|
+
content = readFileSync(filePath, "utf8")
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(`loadRustRuntimeTemplates: missing file ${filePath}`)
|
|
49
|
+
}
|
|
50
|
+
cache.set(name, content)
|
|
51
|
+
}
|
|
52
|
+
runtimeCache = cache
|
|
53
|
+
return cache
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Validates and normalises a crate name. Throws if invalid per cargo rules. */
|
|
57
|
+
export function validateCrateName(name: string): string {
|
|
58
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(name)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Invalid crate name: ${JSON.stringify(name)}. Must match ^[a-z][a-z0-9_-]*$`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
return name
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeResourceName(name: string): string {
|
|
67
|
+
const snake = rustSnake(name)
|
|
68
|
+
return RUST_KEYWORDS.has(snake) ? `${snake}_` : snake
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function rustResponseType(op: Record<string, unknown>): string {
|
|
72
|
+
const responses = op.responses as Record<string, Record<string, unknown>> | undefined
|
|
73
|
+
if (!responses) return "serde_json::Value"
|
|
74
|
+
|
|
75
|
+
for (const [status, response] of Object.entries(responses)) {
|
|
76
|
+
const code = Number.parseInt(status, 10)
|
|
77
|
+
if (Number.isNaN(code) || code < 200 || code >= 300) continue
|
|
78
|
+
if (code === 204) return ""
|
|
79
|
+
const content = response.content as Record<string, Record<string, unknown>> | undefined
|
|
80
|
+
if (!content) return ""
|
|
81
|
+
const jsonSchema = content["application/json"]?.schema as Record<string, unknown> | undefined
|
|
82
|
+
if (!jsonSchema) return "serde_json::Value"
|
|
83
|
+
if (jsonSchema.$ref) {
|
|
84
|
+
return rustPascal((jsonSchema.$ref as string).split("/").pop() ?? "")
|
|
85
|
+
}
|
|
86
|
+
if (jsonSchema.type === "array" && jsonSchema.items) {
|
|
87
|
+
const items = jsonSchema.items as Record<string, unknown>
|
|
88
|
+
if (items.$ref) {
|
|
89
|
+
return `Vec<${rustPascal((items.$ref as string).split("/").pop() ?? "")}>`
|
|
90
|
+
}
|
|
91
|
+
return `Vec<${renderUseRust(items, { decls: new Map(), fieldName: "item", parentName: "Body" })}>`
|
|
92
|
+
}
|
|
93
|
+
if (jsonSchema.type === "string") return "String"
|
|
94
|
+
if (jsonSchema.type === "integer") return "i64"
|
|
95
|
+
if (jsonSchema.type === "number") return "f64"
|
|
96
|
+
if (jsonSchema.type === "boolean") return "bool"
|
|
97
|
+
return "serde_json::Value"
|
|
98
|
+
}
|
|
99
|
+
return "serde_json::Value"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type BodyInfo = {
|
|
103
|
+
kind: "json" | "form-url" | "multipart" | "stream" | "none"
|
|
104
|
+
type: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rustBodyInfo(op: Record<string, unknown>): BodyInfo {
|
|
108
|
+
const requestBody = op.requestBody as Record<string, unknown> | undefined
|
|
109
|
+
if (!requestBody) return { kind: "none", type: "" }
|
|
110
|
+
const content = requestBody.content as Record<string, Record<string, unknown>> | undefined
|
|
111
|
+
if (!content) return { kind: "none", type: "" }
|
|
112
|
+
|
|
113
|
+
/* octet-stream takes precedence: a single binary body has no sibling JSON/multipart
|
|
114
|
+
* entry in practice; detect early so the emitted method accepts a stream/reader. */
|
|
115
|
+
if (content["application/octet-stream"]) {
|
|
116
|
+
return { kind: "stream", type: "" }
|
|
117
|
+
}
|
|
118
|
+
if (content["multipart/form-data"]) {
|
|
119
|
+
return { kind: "multipart", type: "reqwest::multipart::Form" }
|
|
120
|
+
}
|
|
121
|
+
if (content["application/x-www-form-urlencoded"]) {
|
|
122
|
+
return { kind: "form-url", type: "std::collections::HashMap<String, String>" }
|
|
123
|
+
}
|
|
124
|
+
const jsonContent = content["application/json"]
|
|
125
|
+
if (!jsonContent?.schema) return { kind: "none", type: "" }
|
|
126
|
+
const schema = jsonContent.schema as Record<string, unknown>
|
|
127
|
+
if (schema.$ref) {
|
|
128
|
+
return { kind: "json", type: rustPascal((schema.$ref as string).split("/").pop() ?? "") }
|
|
129
|
+
}
|
|
130
|
+
return { kind: "json", type: "serde_json::Value" }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type QueryParam = {
|
|
134
|
+
name: string
|
|
135
|
+
required: boolean
|
|
136
|
+
schema: Record<string, unknown>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getQueryParams(op: Record<string, unknown>): QueryParam[] {
|
|
140
|
+
const parameters = op.parameters as Array<Record<string, unknown>> | undefined
|
|
141
|
+
if (!parameters) return []
|
|
142
|
+
return parameters
|
|
143
|
+
.filter((p) => p.in === "query")
|
|
144
|
+
.map((p) => ({
|
|
145
|
+
name: String(p.name),
|
|
146
|
+
required: p.required === true,
|
|
147
|
+
schema: (p.schema as Record<string, unknown>) ?? { type: "string" },
|
|
148
|
+
}))
|
|
149
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type ErrEnvelopeInfo = {
|
|
153
|
+
keyEnumName: string
|
|
154
|
+
keys: string[]
|
|
155
|
+
status: number
|
|
156
|
+
variantName: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Returns per-status error envelope infos for an operation (only standard envelope shapes). */
|
|
160
|
+
function getOperationErrorEnvelopes(opId: string, op: Record<string, unknown>): ErrEnvelopeInfo[] {
|
|
161
|
+
const responses = op.responses as Record<string, Record<string, unknown>> | undefined
|
|
162
|
+
if (!responses) return []
|
|
163
|
+
const envelopes: ErrEnvelopeInfo[] = []
|
|
164
|
+
for (const [status, response] of Object.entries(responses)) {
|
|
165
|
+
const code = Number.parseInt(status, 10)
|
|
166
|
+
if (code < 400) continue
|
|
167
|
+
const content = response.content as Record<string, Record<string, unknown>> | undefined
|
|
168
|
+
const schema = content?.["application/json"]?.schema as Record<string, unknown> | undefined
|
|
169
|
+
if (!schema) continue
|
|
170
|
+
const envelope = isStandardErrEnvelope(schema)
|
|
171
|
+
if (!envelope) continue
|
|
172
|
+
const opPascal = pascalizeOpId(opId)
|
|
173
|
+
envelopes.push({
|
|
174
|
+
keyEnumName: `${opPascal}Err${code}Key`,
|
|
175
|
+
keys: envelope.keys,
|
|
176
|
+
status: code,
|
|
177
|
+
variantName: `Status${code}`,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
return envelopes
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Emits per-op typed error enum + key enums + per-status structs. Returns lines for the resource file. */
|
|
184
|
+
function emitOperationErrorTypes(opId: string, op: Record<string, unknown>): string[] {
|
|
185
|
+
const envelopes = getOperationErrorEnvelopes(opId, op)
|
|
186
|
+
if (envelopes.length === 0) return []
|
|
187
|
+
const opPascal = pascalizeOpId(opId)
|
|
188
|
+
const errorEnumName = `${opPascal}Error`
|
|
189
|
+
const l: string[] = []
|
|
190
|
+
|
|
191
|
+
for (const env of envelopes) {
|
|
192
|
+
/* key enum */
|
|
193
|
+
const variants = env.keys.map((k) => rustPascal(k))
|
|
194
|
+
l.push(`#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]`)
|
|
195
|
+
l.push(`#[serde(rename_all = "snake_case")]`)
|
|
196
|
+
l.push(`pub enum ${env.keyEnumName} {`)
|
|
197
|
+
for (const v of variants) {
|
|
198
|
+
l.push(`\t${v},`)
|
|
199
|
+
}
|
|
200
|
+
l.push(`}`)
|
|
201
|
+
l.push(``)
|
|
202
|
+
|
|
203
|
+
/* per-status struct with error_key typed field */
|
|
204
|
+
const structName = `${opPascal}Err${env.status}`
|
|
205
|
+
l.push(`#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]`)
|
|
206
|
+
l.push(`pub struct ${structName} {`)
|
|
207
|
+
l.push(`\tpub error_key: ${env.keyEnumName},`)
|
|
208
|
+
l.push(`\tpub message: String,`)
|
|
209
|
+
l.push(`\tpub fields: std::collections::HashMap<String, Vec<crate::types::ErrField>>,`)
|
|
210
|
+
l.push(`}`)
|
|
211
|
+
l.push(``)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
l.push(`#[derive(Debug)]`)
|
|
215
|
+
l.push(`pub enum ${errorEnumName} {`)
|
|
216
|
+
for (const env of envelopes) {
|
|
217
|
+
const structName = `${opPascal}Err${env.status}`
|
|
218
|
+
l.push(`\t${env.variantName}(${structName}),`)
|
|
219
|
+
}
|
|
220
|
+
l.push(`\tOther(Error),`)
|
|
221
|
+
l.push(`}`)
|
|
222
|
+
l.push(``)
|
|
223
|
+
return l
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Returns the per-op typed error enum name if operation has typed error envelopes, else null. */
|
|
227
|
+
function getOpErrorEnumName(opId: string, op: Record<string, unknown>): string | null {
|
|
228
|
+
const envelopes = getOperationErrorEnvelopes(opId, op)
|
|
229
|
+
if (envelopes.length === 0) return null
|
|
230
|
+
return `${pascalizeOpId(opId)}Error`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
type MethodInfo = {
|
|
234
|
+
/* first segment of operationId — invalidationMap key */
|
|
235
|
+
resource: string
|
|
236
|
+
/* segments[1..] joined with "." — invalidationMap action key */
|
|
237
|
+
action: string
|
|
238
|
+
/* all segments except last — namespace path for struct name generation */
|
|
239
|
+
namePath: string[]
|
|
240
|
+
/* last segment of operationId — used for the Rust fn name */
|
|
241
|
+
methodAction: string
|
|
242
|
+
bodyInfo: BodyInfo
|
|
243
|
+
description: string
|
|
244
|
+
httpMethod: string
|
|
245
|
+
isIdempotent: boolean
|
|
246
|
+
isSSE: boolean
|
|
247
|
+
isWS: boolean
|
|
248
|
+
isRealtime: boolean
|
|
249
|
+
op: Record<string, unknown>
|
|
250
|
+
opId: string
|
|
251
|
+
path: string
|
|
252
|
+
pathParams: string[]
|
|
253
|
+
queryParams: QueryParam[]
|
|
254
|
+
responseType: string
|
|
255
|
+
summary: string
|
|
256
|
+
/** x-invalidate targets from the raw OpenAPI op, falling back to IR extensions. */
|
|
257
|
+
invalidates: string[]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function pascalizeOpId(opId: string): string {
|
|
261
|
+
return opId.split(".").map(rustPascal).join("")
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function invalidatesOf(irOp: IROperation, rawOp: Record<string, unknown>): string[] {
|
|
265
|
+
const fromRaw = rawOp["x-invalidate"]
|
|
266
|
+
if (Array.isArray(fromRaw) && fromRaw.length > 0 && fromRaw.every((x) => typeof x === "string")) {
|
|
267
|
+
return fromRaw as string[]
|
|
268
|
+
}
|
|
269
|
+
const fromIr = irOp.extensions.invalidates
|
|
270
|
+
if (Array.isArray(fromIr) && fromIr.length > 0) return fromIr
|
|
271
|
+
return []
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function methodInfoFromIROp(
|
|
275
|
+
irOp: IROperation,
|
|
276
|
+
rawOp: Record<string, unknown>,
|
|
277
|
+
): MethodInfo {
|
|
278
|
+
const segments = irOp.id.split(".")
|
|
279
|
+
const isTopLevel = segments.length === 1
|
|
280
|
+
const resource = segments[0] ?? irOp.id
|
|
281
|
+
const action = isTopLevel ? "_call" : segments.slice(1).join(".")
|
|
282
|
+
const namePath = segments.slice(0, -1)
|
|
283
|
+
const methodAction = segments[segments.length - 1] ?? irOp.id
|
|
284
|
+
return {
|
|
285
|
+
action,
|
|
286
|
+
bodyInfo: rustBodyInfo(rawOp),
|
|
287
|
+
description: String(rawOp.description ?? ""),
|
|
288
|
+
httpMethod: irOp.method,
|
|
289
|
+
invalidates: invalidatesOf(irOp, rawOp),
|
|
290
|
+
isIdempotent: rawOp["x-idempotency-key"] === true,
|
|
291
|
+
isRealtime: rawOp["x-realtime"] === true,
|
|
292
|
+
isSSE: isSSEOperation(rawOp),
|
|
293
|
+
isWS: rawOp["x-websocket"] === true,
|
|
294
|
+
methodAction,
|
|
295
|
+
namePath,
|
|
296
|
+
op: rawOp,
|
|
297
|
+
opId: irOp.id,
|
|
298
|
+
path: irOp.path,
|
|
299
|
+
pathParams: irOp.params.path.map((p) => p.name),
|
|
300
|
+
queryParams: getQueryParams(rawOp),
|
|
301
|
+
resource,
|
|
302
|
+
responseType: rustResponseType(rawOp),
|
|
303
|
+
summary: String(rawOp.summary ?? ""),
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function collectMethodsFromIR(ns: IRNamespace, spec: OpenApiSpec): MethodInfo[] {
|
|
308
|
+
const rawOpById = new Map<string, Record<string, unknown>>()
|
|
309
|
+
for (const [, pathMethods] of Object.entries(spec.paths ?? {})) {
|
|
310
|
+
for (const [, operation] of Object.entries(pathMethods)) {
|
|
311
|
+
const op = operation as Record<string, unknown>
|
|
312
|
+
const id = op.operationId as string | undefined
|
|
313
|
+
if (id) rawOpById.set(id, op)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const results: MethodInfo[] = []
|
|
317
|
+
function walk(cur: IRNamespace): void {
|
|
318
|
+
for (const [, entry] of methodsOf(cur)) {
|
|
319
|
+
const raw = rawOpById.get(entry.id) ?? {}
|
|
320
|
+
results.push(methodInfoFromIROp(entry, raw))
|
|
321
|
+
}
|
|
322
|
+
for (const [, childNs] of namespacesOf(cur)) walk(childNs)
|
|
323
|
+
}
|
|
324
|
+
walk(ns)
|
|
325
|
+
return results
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function optsStructName(m: MethodInfo): string {
|
|
329
|
+
/* namePath=[checkout,sessions], methodAction=create → CheckoutSessionsCreateOpts
|
|
330
|
+
* namePath=[], methodAction=getUser → GetUserOpts (top-level single-segment) */
|
|
331
|
+
const prefix = m.namePath.map(rustPascal).join("")
|
|
332
|
+
const suffix = rustPascal(m.methodAction)
|
|
333
|
+
return `${prefix}${suffix}Opts`
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function emitOptsStruct(m: MethodInfo): string[] {
|
|
337
|
+
const l: string[] = []
|
|
338
|
+
const optsName = optsStructName(m)
|
|
339
|
+
const decls = new Map<string, string>()
|
|
340
|
+
const fields: string[] = []
|
|
341
|
+
|
|
342
|
+
const emitted = new Set<string>()
|
|
343
|
+
for (const qp of m.queryParams) {
|
|
344
|
+
const fieldIdent = rustIdent(qp.name)
|
|
345
|
+
emitted.add(fieldIdent)
|
|
346
|
+
const rustType = renderUseRust(qp.schema, {
|
|
347
|
+
decls,
|
|
348
|
+
fieldName: qp.name,
|
|
349
|
+
parentName: optsName,
|
|
350
|
+
})
|
|
351
|
+
const finalType = qp.required ? rustType : `Option<${rustType}>`
|
|
352
|
+
const needsRename = fieldIdent !== qp.name
|
|
353
|
+
const needsSkip = !qp.required
|
|
354
|
+
if (needsRename && needsSkip) {
|
|
355
|
+
fields.push(`\t#[serde(rename = ${JSON.stringify(qp.name)}, skip_serializing_if = "Option::is_none")]`)
|
|
356
|
+
} else if (needsRename) {
|
|
357
|
+
fields.push(`\t#[serde(rename = ${JSON.stringify(qp.name)})]`)
|
|
358
|
+
} else if (needsSkip) {
|
|
359
|
+
fields.push(`\t#[serde(skip_serializing_if = "Option::is_none")]`)
|
|
360
|
+
}
|
|
361
|
+
fields.push(`\tpub ${fieldIdent}: ${finalType},`)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (m.isSSE && !emitted.has("last_event_id")) {
|
|
365
|
+
fields.push(`\tpub last_event_id: Option<String>,`)
|
|
366
|
+
}
|
|
367
|
+
if (m.isWS && !emitted.has("reconnect_token")) {
|
|
368
|
+
fields.push(`\tpub reconnect_token: Option<String>,`)
|
|
369
|
+
}
|
|
370
|
+
if (m.isWS && !emitted.has("protocols")) {
|
|
371
|
+
fields.push(`\tpub protocols: Option<Vec<String>>,`)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* idempotency-key auto-injection opt-in field (9.a.5). Shared between async + sync
|
|
375
|
+
* method bodies; emitted once per opts struct. Not meaningful for SSE/WS/realtime. */
|
|
376
|
+
if (m.isIdempotent && !m.isSSE && !m.isWS && !m.isRealtime && !emitted.has("idempotency_key")) {
|
|
377
|
+
fields.push(`\t#[serde(skip_serializing_if = "Option::is_none")]`)
|
|
378
|
+
fields.push(`\tpub idempotency_key: Option<String>,`)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* per-call headers for all non-WS methods (SSE included) */
|
|
382
|
+
if (!m.isWS && !emitted.has("headers")) {
|
|
383
|
+
fields.push(`\t#[serde(skip)]`)
|
|
384
|
+
fields.push(`\tpub headers: Option<std::collections::HashMap<String, String>>,`)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* per-call timeout for non-SSE non-WS methods */
|
|
388
|
+
if (!m.isSSE && !m.isWS && !emitted.has("timeout")) {
|
|
389
|
+
fields.push(`\t#[serde(skip)]`)
|
|
390
|
+
fields.push(`\tpub timeout: Option<std::time::Duration>,`)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* per-call cancellation tokens for non-SSE non-WS methods. Two separate fields:
|
|
394
|
+
async path consumes `cancel_token` (tokio_util::sync::CancellationToken);
|
|
395
|
+
sync path consumes `sync_cancel_token` (Arc<AtomicBool>). The opts struct is
|
|
396
|
+
shared between async + sync emitted methods, so both fields coexist; each
|
|
397
|
+
path ignores the other. Both #[serde(skip)] — runtime-only state. */
|
|
398
|
+
if (!m.isSSE && !m.isWS) {
|
|
399
|
+
if (!emitted.has("cancel_token")) {
|
|
400
|
+
fields.push(`\t#[serde(skip)]`)
|
|
401
|
+
fields.push(`\tpub cancel_token: Option<tokio_util::sync::CancellationToken>,`)
|
|
402
|
+
}
|
|
403
|
+
if (!emitted.has("sync_cancel_token")) {
|
|
404
|
+
fields.push(`\t#[serde(skip)]`)
|
|
405
|
+
fields.push(`\tpub sync_cancel_token: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,`)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
for (const decl of decls.values()) {
|
|
410
|
+
l.push(decl)
|
|
411
|
+
l.push(``)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
l.push(`#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]`)
|
|
415
|
+
l.push(`pub struct ${optsName} {`)
|
|
416
|
+
l.push(...fields)
|
|
417
|
+
l.push(`}`)
|
|
418
|
+
l.push(``)
|
|
419
|
+
return l
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function emitSSEMethod(
|
|
423
|
+
m: MethodInfo,
|
|
424
|
+
params: string[],
|
|
425
|
+
cfgAccess: string,
|
|
426
|
+
httpClientAccess: string,
|
|
427
|
+
actionFn: string,
|
|
428
|
+
optsName: string,
|
|
429
|
+
l: string[],
|
|
430
|
+
): string[] {
|
|
431
|
+
params.push(`opts: &${optsName}`)
|
|
432
|
+
l.push(`pub fn ${actionFn}(${params.join(", ")}) -> impl Stream<Item = Result<SseEvent, Error>> {`)
|
|
433
|
+
/* clone all opts fields into owned locals before the stream block to avoid capturing &opts lifetime */
|
|
434
|
+
l.push(`\tlet last_event_id_owned = opts.last_event_id.clone();`)
|
|
435
|
+
l.push(`\tlet opts_headers_owned = opts.headers.clone();`)
|
|
436
|
+
for (const qp of m.queryParams) {
|
|
437
|
+
if (qp.name === "last-event-id") continue
|
|
438
|
+
const fieldIdent = rustIdent(qp.name)
|
|
439
|
+
l.push(`\tlet ${fieldIdent}_owned = opts.${fieldIdent}.clone();`)
|
|
440
|
+
}
|
|
441
|
+
const urlExpr = buildUrlExpr(m.path, m.pathParams)
|
|
442
|
+
l.push(`\tlet url_path = ${urlExpr};`)
|
|
443
|
+
const sseQueryMut = m.queryParams.filter((q) => q.name !== "last-event-id").length > 0 ? "mut " : ""
|
|
444
|
+
l.push(`\tlet ${sseQueryMut}query: Vec<(String, String)> = Vec::new();`)
|
|
445
|
+
for (const qp of m.queryParams) {
|
|
446
|
+
if (qp.name === "last-event-id") continue
|
|
447
|
+
const fieldIdent = rustIdent(qp.name)
|
|
448
|
+
if (qp.required) {
|
|
449
|
+
l.push(`\tquery.push((${JSON.stringify(qp.name)}.to_string(), ${fieldIdent}_owned.clone()));`)
|
|
450
|
+
} else {
|
|
451
|
+
l.push(`\tif let Some(ref v) = ${fieldIdent}_owned { query.push((${JSON.stringify(qp.name)}.to_string(), v.clone())); }`)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
l.push(`\tlet cfg_clone = ${cfgAccess}.clone();`)
|
|
455
|
+
l.push(`\tlet client_clone = ${httpClientAccess}.clone();`)
|
|
456
|
+
l.push(`\tasync_stream::stream! {`)
|
|
457
|
+
l.push(`\t\tuse crate::sse::parse_sse_stream;`)
|
|
458
|
+
l.push(`\t\tlet mut req_headers = std::collections::HashMap::new();`)
|
|
459
|
+
l.push(`\t\treq_headers.insert("Accept".to_string(), "text/event-stream".to_string());`)
|
|
460
|
+
l.push(`\t\tif let Some(ref lei) = last_event_id_owned {`)
|
|
461
|
+
l.push(`\t\t\treq_headers.insert("Last-Event-ID".to_string(), lei.clone());`)
|
|
462
|
+
l.push(`\t\t}`)
|
|
463
|
+
l.push(`\t\tfor (k, v) in &cfg_clone.headers {`)
|
|
464
|
+
l.push(`\t\t\treq_headers.insert(k.clone(), v.clone());`)
|
|
465
|
+
l.push(`\t\t}`)
|
|
466
|
+
l.push(`\t\tif let Some(ref oh) = opts_headers_owned {`)
|
|
467
|
+
l.push(`\t\t\tfor (k, v) in oh { req_headers.insert(k.clone(), v.clone()); }`)
|
|
468
|
+
l.push(`\t\t}`)
|
|
469
|
+
l.push(`\t\tlet mut req = client_clone`)
|
|
470
|
+
l.push(`\t\t\t.get(format!("{}{}", cfg_clone.base_url, url_path))`)
|
|
471
|
+
l.push(`\t\t\t.header("Accept", "text/event-stream");`)
|
|
472
|
+
l.push(`\t\tif let Some(ref lei) = last_event_id_owned {`)
|
|
473
|
+
l.push(`\t\t\treq = req.header("Last-Event-ID", lei.clone());`)
|
|
474
|
+
l.push(`\t\t}`)
|
|
475
|
+
l.push(`\t\tmatch req.send().await {`)
|
|
476
|
+
l.push(`\t\t\tErr(e) => { yield Err(Error::Transport(e)); return; }`)
|
|
477
|
+
l.push(`\t\t\tOk(r) => {`)
|
|
478
|
+
l.push(`\t\t\t\tuse futures_util::StreamExt;`)
|
|
479
|
+
l.push(`\t\t\t\tlet mut s = std::pin::pin!(parse_sse_stream(r));`)
|
|
480
|
+
l.push(`\t\t\t\twhile let Some(item) = s.next().await { yield item; }`)
|
|
481
|
+
l.push(`\t\t\t}`)
|
|
482
|
+
l.push(`\t\t}`)
|
|
483
|
+
l.push(`\t}`)
|
|
484
|
+
l.push(`}`)
|
|
485
|
+
l.push(``)
|
|
486
|
+
return l
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function emitWSMethod(
|
|
490
|
+
m: MethodInfo,
|
|
491
|
+
params: string[],
|
|
492
|
+
cfgAccess: string,
|
|
493
|
+
actionFn: string,
|
|
494
|
+
optsName: string,
|
|
495
|
+
l: string[],
|
|
496
|
+
): string[] {
|
|
497
|
+
params.push(`opts: &${optsName}`)
|
|
498
|
+
l.push(`pub async fn ${actionFn}(${params.join(", ")}) -> Result<TypedWebSocket, Error> {`)
|
|
499
|
+
const urlExpr = buildUrlExpr(m.path, m.pathParams)
|
|
500
|
+
l.push(`\tlet url_path = ${urlExpr};`)
|
|
501
|
+
l.push(`\tlet mut query_str = String::new();`)
|
|
502
|
+
for (const qp of m.queryParams) {
|
|
503
|
+
if (qp.name === "reconnect_token") continue
|
|
504
|
+
const fieldIdent = rustIdent(qp.name)
|
|
505
|
+
if (qp.required) {
|
|
506
|
+
l.push(`\tif !query_str.is_empty() { query_str.push('&'); }`)
|
|
507
|
+
l.push(`\tquery_str.push_str(&format!("{}={}", ${JSON.stringify(qp.name)}, urlencoding::encode(&opts.${fieldIdent})));`)
|
|
508
|
+
} else {
|
|
509
|
+
l.push(`\tif let Some(ref v) = opts.${fieldIdent} {`)
|
|
510
|
+
l.push(`\t\tif !query_str.is_empty() { query_str.push('&'); }`)
|
|
511
|
+
l.push(`\t\tquery_str.push_str(&format!("{}={}", ${JSON.stringify(qp.name)}, urlencoding::encode(v)));`)
|
|
512
|
+
l.push(`\t}`)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
l.push(`\tif let Some(ref rt) = opts.reconnect_token {`)
|
|
516
|
+
l.push(`\t\tif !query_str.is_empty() { query_str.push('&'); }`)
|
|
517
|
+
l.push(`\t\tquery_str.push_str(&format!("reconnect_token={}", urlencoding::encode(rt)));`)
|
|
518
|
+
l.push(`\t}`)
|
|
519
|
+
l.push(`\tlet full_url = if query_str.is_empty() {`)
|
|
520
|
+
l.push(`\t\tformat!("{}{}", ${cfgAccess}.base_url, url_path)`)
|
|
521
|
+
l.push(`\t} else {`)
|
|
522
|
+
l.push(`\t\tformat!("{}{}?{}", ${cfgAccess}.base_url, url_path, query_str)`)
|
|
523
|
+
l.push(`\t};`)
|
|
524
|
+
l.push(`\t/* convert http(s) → ws(s) */`)
|
|
525
|
+
l.push(`\tlet ws_url = full_url.replacen("https://", "wss://", 1).replacen("http://", "ws://", 1);`)
|
|
526
|
+
l.push(`\tuse tokio_tungstenite::tungstenite::client::IntoClientRequest;`)
|
|
527
|
+
l.push(`\tlet mut req = ws_url.as_str().into_client_request()`)
|
|
528
|
+
l.push(`\t\t.map_err(|e| Error::Other(e.to_string()))?;`)
|
|
529
|
+
l.push(`\tif let Some(ref protos) = opts.protocols {`)
|
|
530
|
+
l.push(`\t\tif !protos.is_empty() {`)
|
|
531
|
+
l.push(`\t\t\treq.headers_mut().insert(`)
|
|
532
|
+
l.push(`\t\t\t\t"Sec-WebSocket-Protocol",`)
|
|
533
|
+
l.push(`\t\t\t\tprotos.join(", ").parse().map_err(|e: tokio_tungstenite::tungstenite::http::header::InvalidHeaderValue| Error::Other(e.to_string()))?,`)
|
|
534
|
+
l.push(`\t\t\t);`)
|
|
535
|
+
l.push(`\t\t}`)
|
|
536
|
+
l.push(`\t}`)
|
|
537
|
+
l.push(`\tlet (stream, _) = tokio_tungstenite::connect_async(req).await`)
|
|
538
|
+
l.push(`\t\t.map_err(|e| Error::Other(e.to_string()))?;`)
|
|
539
|
+
l.push(`\tOk(TypedWebSocket::new(stream))`)
|
|
540
|
+
l.push(`}`)
|
|
541
|
+
l.push(``)
|
|
542
|
+
return l
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function buildAccessors(selfAccess: "self" | "self.client"): { cfg: string; stale: string; httpClient: string } {
|
|
546
|
+
return selfAccess === "self"
|
|
547
|
+
? { cfg: "self.inner.cfg", stale: "self.inner.stale", httpClient: "self.inner.http_client" }
|
|
548
|
+
: { cfg: "self.client.cfg", stale: "self.client.stale", httpClient: "self.client.http_client" }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function emitMethod(
|
|
552
|
+
m: MethodInfo,
|
|
553
|
+
selfAccess: "self" | "self.client",
|
|
554
|
+
throwOnError: boolean,
|
|
555
|
+
): string[] {
|
|
556
|
+
const l: string[] = []
|
|
557
|
+
const actionFn = rustMethodIdent(m.action === "_call" ? m.resource : m.methodAction)
|
|
558
|
+
const optsName = optsStructName(m)
|
|
559
|
+
const httpMethod = m.httpMethod
|
|
560
|
+
|
|
561
|
+
if (m.summary) {
|
|
562
|
+
l.push(`/// ${m.summary}`)
|
|
563
|
+
if (m.description) l.push(`/// ${m.description}`)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const params: string[] = [`&self`]
|
|
567
|
+
for (const pp of m.pathParams) {
|
|
568
|
+
params.push(`${rustSnake(pp)}: &str`)
|
|
569
|
+
}
|
|
570
|
+
/* body param depends on body kind; `asyncGenerics` collects generic parameter
|
|
571
|
+
* declarations for the function signature (only stream bodies need generics
|
|
572
|
+
* for zero-cost monomorphization over any `Stream<Bytes>`). */
|
|
573
|
+
const asyncGenerics: string[] = []
|
|
574
|
+
const bi = m.bodyInfo
|
|
575
|
+
if (bi.kind === "json") {
|
|
576
|
+
params.push(`body: &${bi.type}`)
|
|
577
|
+
} else if (bi.kind === "form-url") {
|
|
578
|
+
params.push(`body: &std::collections::HashMap<String, String>`)
|
|
579
|
+
} else if (bi.kind === "multipart") {
|
|
580
|
+
/* multipart Form is consumed (not Clone), passed by value */
|
|
581
|
+
params.push(`body: reqwest::multipart::Form`)
|
|
582
|
+
} else if (bi.kind === "stream") {
|
|
583
|
+
/* octet-stream body: generic over any `Stream<Item = Result<Bytes, reqwest::Error>>`
|
|
584
|
+
* (reqwest::Body::wrap_stream bound). Owned body, single-shot, non-retryable on 401. */
|
|
585
|
+
asyncGenerics.push(
|
|
586
|
+
`S: futures::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + Sync + 'static`,
|
|
587
|
+
)
|
|
588
|
+
params.push(`body: S`)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const { cfg: cfgAccess, stale: staleAccess, httpClient: httpClientAccess } = buildAccessors(selfAccess)
|
|
592
|
+
|
|
593
|
+
if (m.isSSE) {
|
|
594
|
+
params.push(`opts: &${optsName}`)
|
|
595
|
+
l.push(`pub fn ${actionFn}(${params.join(", ")}) -> impl Stream<Item = Result<SseEvent, Error>> {`)
|
|
596
|
+
/* clone all opts fields into owned locals before the stream block to avoid capturing &opts lifetime */
|
|
597
|
+
l.push(`\tlet last_event_id_owned = opts.last_event_id.clone();`)
|
|
598
|
+
l.push(`\tlet opts_headers_owned = opts.headers.clone();`)
|
|
599
|
+
for (const qp of m.queryParams) {
|
|
600
|
+
if (qp.name === "last-event-id") continue
|
|
601
|
+
const fieldIdent = rustIdent(qp.name)
|
|
602
|
+
l.push(`\tlet ${fieldIdent}_owned = opts.${fieldIdent}.clone();`)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const urlExpr = buildUrlExpr(m.path, m.pathParams)
|
|
606
|
+
l.push(`\tlet url_path = ${urlExpr};`)
|
|
607
|
+
const sseQueryMut = m.queryParams.filter((q) => q.name !== "last-event-id").length > 0 ? "mut " : ""
|
|
608
|
+
l.push(`\tlet ${sseQueryMut}query: Vec<(String, String)> = Vec::new();`)
|
|
609
|
+
for (const qp of m.queryParams) {
|
|
610
|
+
if (qp.name === "last-event-id") continue
|
|
611
|
+
const fieldIdent = rustIdent(qp.name)
|
|
612
|
+
if (qp.required) {
|
|
613
|
+
l.push(`\tquery.push((${JSON.stringify(qp.name)}.to_string(), ${fieldIdent}_owned.clone()));`)
|
|
614
|
+
} else {
|
|
615
|
+
l.push(`\tif let Some(ref v) = ${fieldIdent}_owned { query.push((${JSON.stringify(qp.name)}.to_string(), v.clone())); }`)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
l.push(`\tlet cfg_clone = ${cfgAccess}.clone();`)
|
|
619
|
+
l.push(`\tlet client_clone = ${httpClientAccess}.clone();`)
|
|
620
|
+
l.push(`\tasync_stream::stream! {`)
|
|
621
|
+
l.push(`\t\tuse crate::sse::parse_sse_stream;`)
|
|
622
|
+
l.push(`\t\tlet mut req_headers = std::collections::HashMap::new();`)
|
|
623
|
+
l.push(`\t\treq_headers.insert("Accept".to_string(), "text/event-stream".to_string());`)
|
|
624
|
+
l.push(`\t\tif let Some(ref lei) = last_event_id_owned {`)
|
|
625
|
+
l.push(`\t\t\treq_headers.insert("Last-Event-ID".to_string(), lei.clone());`)
|
|
626
|
+
l.push(`\t\t}`)
|
|
627
|
+
l.push(`\t\tfor (k, v) in &cfg_clone.headers {`)
|
|
628
|
+
l.push(`\t\t\treq_headers.insert(k.clone(), v.clone());`)
|
|
629
|
+
l.push(`\t\t}`)
|
|
630
|
+
l.push(`\t\tif let Some(ref oh) = opts_headers_owned {`)
|
|
631
|
+
l.push(`\t\t\tfor (k, v) in oh { req_headers.insert(k.clone(), v.clone()); }`)
|
|
632
|
+
l.push(`\t\t}`)
|
|
633
|
+
l.push(`\t\tlet mut req = client_clone`)
|
|
634
|
+
l.push(`\t\t\t.get(format!("{}{}", cfg_clone.base_url, url_path))`)
|
|
635
|
+
l.push(`\t\t\t.header("Accept", "text/event-stream");`)
|
|
636
|
+
l.push(`\t\tif let Some(ref lei) = last_event_id_owned {`)
|
|
637
|
+
l.push(`\t\t\treq = req.header("Last-Event-ID", lei.clone());`)
|
|
638
|
+
l.push(`\t\t}`)
|
|
639
|
+
l.push(`\t\tmatch req.send().await {`)
|
|
640
|
+
l.push(`\t\t\tErr(e) => { yield Err(Error::Transport(e)); return; }`)
|
|
641
|
+
l.push(`\t\t\tOk(r) => {`)
|
|
642
|
+
l.push(`\t\t\t\tuse futures_util::StreamExt;`)
|
|
643
|
+
l.push(`\t\t\t\tlet mut s = std::pin::pin!(parse_sse_stream(r));`)
|
|
644
|
+
l.push(`\t\t\t\twhile let Some(item) = s.next().await { yield item; }`)
|
|
645
|
+
l.push(`\t\t\t}`)
|
|
646
|
+
l.push(`\t\t}`)
|
|
647
|
+
l.push(`\t}`)
|
|
648
|
+
l.push(`}`)
|
|
649
|
+
l.push(``)
|
|
650
|
+
return l
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (m.isWS) {
|
|
654
|
+
params.push(`opts: &${optsName}`)
|
|
655
|
+
l.push(`pub async fn ${actionFn}(${params.join(", ")}) -> Result<TypedWebSocket, Error> {`)
|
|
656
|
+
const urlExpr = buildUrlExpr(m.path, m.pathParams)
|
|
657
|
+
l.push(`\tlet url_path = ${urlExpr};`)
|
|
658
|
+
l.push(`\tlet mut query_str = String::new();`)
|
|
659
|
+
for (const qp of m.queryParams) {
|
|
660
|
+
if (qp.name === "reconnect_token") continue
|
|
661
|
+
const fieldIdent = rustIdent(qp.name)
|
|
662
|
+
if (qp.required) {
|
|
663
|
+
l.push(`\tif !query_str.is_empty() { query_str.push('&'); }`)
|
|
664
|
+
l.push(`\tquery_str.push_str(&format!("{}={}", ${JSON.stringify(qp.name)}, urlencoding::encode(&opts.${fieldIdent})));`)
|
|
665
|
+
} else {
|
|
666
|
+
l.push(`\tif let Some(ref v) = opts.${fieldIdent} {`)
|
|
667
|
+
l.push(`\t\tif !query_str.is_empty() { query_str.push('&'); }`)
|
|
668
|
+
l.push(`\t\tquery_str.push_str(&format!("{}={}", ${JSON.stringify(qp.name)}, urlencoding::encode(v)));`)
|
|
669
|
+
l.push(`\t}`)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
l.push(`\tif let Some(ref rt) = opts.reconnect_token {`)
|
|
673
|
+
l.push(`\t\tif !query_str.is_empty() { query_str.push('&'); }`)
|
|
674
|
+
l.push(`\t\tquery_str.push_str(&format!("reconnect_token={}", urlencoding::encode(rt)));`)
|
|
675
|
+
l.push(`\t}`)
|
|
676
|
+
l.push(`\tlet full_url = if query_str.is_empty() {`)
|
|
677
|
+
l.push(`\t\tformat!("{}{}", ${cfgAccess}.base_url, url_path)`)
|
|
678
|
+
l.push(`\t} else {`)
|
|
679
|
+
l.push(`\t\tformat!("{}{}?{}", ${cfgAccess}.base_url, url_path, query_str)`)
|
|
680
|
+
l.push(`\t};`)
|
|
681
|
+
l.push(`\t/* convert http(s) → ws(s) */`)
|
|
682
|
+
l.push(`\tlet ws_url = full_url.replacen("https://", "wss://", 1).replacen("http://", "ws://", 1);`)
|
|
683
|
+
l.push(`\tuse tokio_tungstenite::tungstenite::client::IntoClientRequest;`)
|
|
684
|
+
l.push(`\tlet mut req = ws_url.as_str().into_client_request()`)
|
|
685
|
+
l.push(`\t\t.map_err(|e| Error::Other(e.to_string()))?;`)
|
|
686
|
+
l.push(`\tif let Some(ref protos) = opts.protocols {`)
|
|
687
|
+
l.push(`\t\tif !protos.is_empty() {`)
|
|
688
|
+
l.push(`\t\t\treq.headers_mut().insert(`)
|
|
689
|
+
l.push(`\t\t\t\t"Sec-WebSocket-Protocol",`)
|
|
690
|
+
l.push(`\t\t\t\tprotos.join(", ").parse().map_err(|e: tokio_tungstenite::tungstenite::http::header::InvalidHeaderValue| Error::Other(e.to_string()))?,`)
|
|
691
|
+
l.push(`\t\t\t);`)
|
|
692
|
+
l.push(`\t\t}`)
|
|
693
|
+
l.push(`\t}`)
|
|
694
|
+
l.push(`\tlet (stream, _) = tokio_tungstenite::connect_async(req).await`)
|
|
695
|
+
l.push(`\t\t.map_err(|e| Error::Other(e.to_string()))?;`)
|
|
696
|
+
l.push(`\tOk(TypedWebSocket::new(stream))`)
|
|
697
|
+
l.push(`}`)
|
|
698
|
+
l.push(``)
|
|
699
|
+
return l
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/* standard HTTP method */
|
|
703
|
+
const opErrEnum = !throwOnError ? getOpErrorEnumName(m.opId, m.op) : null
|
|
704
|
+
let retType: string
|
|
705
|
+
if (throwOnError) {
|
|
706
|
+
retType = m.responseType !== "" ? `Result<${m.responseType}, Error>` : `Result<(), Error>`
|
|
707
|
+
} else if (opErrEnum && m.responseType !== "") {
|
|
708
|
+
/* typed per-op error: SdkResult<T, OpError> — uses shorthand (no verbose Error default) */
|
|
709
|
+
retType = `Result<SdkResult<${m.responseType}, ${opErrEnum}>, Error>`
|
|
710
|
+
} else {
|
|
711
|
+
/* SdkResult<T> — shorthand, E defaults to Error */
|
|
712
|
+
retType = m.responseType !== "" ? `Result<SdkResult<${m.responseType}>, Error>` : `Result<(), Error>`
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
params.push(`opts: &${optsName}`)
|
|
716
|
+
const asyncGenericStr = asyncGenerics.length > 0 ? `<${asyncGenerics.join(", ")}>` : ""
|
|
717
|
+
l.push(`pub async fn ${actionFn}${asyncGenericStr}(${params.join(", ")}) -> ${retType} {`)
|
|
718
|
+
|
|
719
|
+
const urlExpr = buildUrlExpr(m.path, m.pathParams)
|
|
720
|
+
l.push(`\tlet url_path = ${urlExpr};`)
|
|
721
|
+
const queryMut = m.queryParams.length > 0 ? "mut " : ""
|
|
722
|
+
l.push(`\tlet ${queryMut}query: Vec<(String, String)> = Vec::new();`)
|
|
723
|
+
emitQueryParamLoop(m.queryParams, l)
|
|
724
|
+
|
|
725
|
+
/* RequestBody variant based on body kind */
|
|
726
|
+
let bodyArg: string
|
|
727
|
+
if (bi.kind === "json") {
|
|
728
|
+
bodyArg = `crate::runtime::RequestBody::Json(&serde_json::to_value(body).unwrap_or_default())`
|
|
729
|
+
} else if (bi.kind === "form-url") {
|
|
730
|
+
bodyArg = `crate::runtime::RequestBody::FormUrl(body)`
|
|
731
|
+
} else if (bi.kind === "multipart") {
|
|
732
|
+
bodyArg = `crate::runtime::RequestBody::Multipart(body)`
|
|
733
|
+
} else if (bi.kind === "stream") {
|
|
734
|
+
bodyArg = `crate::runtime::RequestBody::Stream(reqwest::Body::wrap_stream(body))`
|
|
735
|
+
} else {
|
|
736
|
+
bodyArg = `crate::runtime::RequestBody::None`
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/* invalidation pre-flight — build path-params map, interpolate to concrete
|
|
740
|
+
* selector, snapshot RequestMeta from the tracker. Emitted only for ops
|
|
741
|
+
* with x-invalidate; BuildRequestMeta short-circuits to None when disabled. */
|
|
742
|
+
const hasInvalidate = m.invalidates.length > 0
|
|
743
|
+
if (hasInvalidate) {
|
|
744
|
+
const pathParamsMapExpr = m.pathParams.length > 0
|
|
745
|
+
? `std::collections::HashMap::from([${m.pathParams.map((p) => `(${JSON.stringify(p)}.to_string(), ${rustSnake(p)}.to_string())`).join(", ")}])`
|
|
746
|
+
: `std::collections::HashMap::<String, String>::new()`
|
|
747
|
+
l.push(`\tlet _path_params_map: std::collections::HashMap<String, String> = ${pathParamsMapExpr};`)
|
|
748
|
+
l.push(`\tlet _concrete_path = crate::invalidation::interpolate_path(${JSON.stringify(m.path)}, &_path_params_map).unwrap_or_else(|_| ${JSON.stringify(m.path)}.to_string());`)
|
|
749
|
+
l.push(`\tlet _concrete_selector = format!("${httpMethod} {}", _concrete_path);`)
|
|
750
|
+
l.push(`\tlet _request_meta: Option<crate::invalidation::RequestMeta> = match ${staleAccess} {`)
|
|
751
|
+
l.push(`\t\tSome(ref s) => s.build_request_meta(&_concrete_selector, &_concrete_path, ${JSON.stringify(httpMethod)}).await,`)
|
|
752
|
+
l.push(`\t\tNone => None,`)
|
|
753
|
+
l.push(`\t};`)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/* idempotency-key auto-injection: build a local headers map that carries the key.
|
|
757
|
+
* opts is &-borrowed, so mutation happens on a clone. Precedence:
|
|
758
|
+
* explicit opts.headers["Idempotency-Key"] (case-insensitive) → kept
|
|
759
|
+
* opts.idempotency_key → used
|
|
760
|
+
* else → uuid::Uuid::new_v4()
|
|
761
|
+
* Matches TS/Python/Go emitter semantics (9.a.2 / 9.a.3 / 9.a.4). */
|
|
762
|
+
const emitIdem = m.isIdempotent && !m.isSSE && !m.isWS && !m.isRealtime
|
|
763
|
+
if (emitIdem) {
|
|
764
|
+
l.push(`\tlet mut _call_headers: std::collections::HashMap<String, String> = opts.headers.clone().unwrap_or_default();`)
|
|
765
|
+
l.push(`\tlet _has_idem = _call_headers.keys().any(|k| k.eq_ignore_ascii_case("Idempotency-Key"));`)
|
|
766
|
+
l.push(`\tif !_has_idem {`)
|
|
767
|
+
l.push(`\t\tlet _k = opts.idempotency_key.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string());`)
|
|
768
|
+
l.push(`\t\t_call_headers.insert("Idempotency-Key".to_string(), _k);`)
|
|
769
|
+
l.push(`\t}`)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const operationStr = JSON.stringify(`${httpMethod} ${m.path}`)
|
|
773
|
+
const requestMetaArg = hasInvalidate ? `_request_meta.as_ref()` : `None`
|
|
774
|
+
const headersArg = emitIdem ? `Some(&_call_headers)` : `opts.headers.as_ref()`
|
|
775
|
+
l.push(`\tlet result = crate::runtime::do_request(`)
|
|
776
|
+
l.push(`\t\t&${httpClientAccess},`)
|
|
777
|
+
l.push(`\t\t&${cfgAccess},`)
|
|
778
|
+
l.push(`\t\treqwest::Method::${httpMethod},`)
|
|
779
|
+
l.push(`\t\t&url_path,`)
|
|
780
|
+
l.push(`\t\t&query,`)
|
|
781
|
+
l.push(`\t\t${bodyArg},`)
|
|
782
|
+
l.push(`\t\t${headersArg},`)
|
|
783
|
+
l.push(`\t\topts.timeout,`)
|
|
784
|
+
l.push(`\t\topts.cancel_token.clone(),`)
|
|
785
|
+
l.push(`\t\t${operationStr},`)
|
|
786
|
+
l.push(`\t\t${requestMetaArg},`)
|
|
787
|
+
l.push(`\t).await?;`)
|
|
788
|
+
|
|
789
|
+
/* invalidation post-flight — expand templated x-invalidate targets with the
|
|
790
|
+
* call's concrete params (TS parity: GET /users/{id} → GET /users/abc),
|
|
791
|
+
* mark them stale, then clear keys that were already stale before this
|
|
792
|
+
* mutation completed (seq-bounded to avoid clobbering newer invalidations). */
|
|
793
|
+
if (hasInvalidate) {
|
|
794
|
+
l.push(`\tif let Some(ref stale) = ${staleAccess} {`)
|
|
795
|
+
l.push(`\t\tif let Some(resource_map) = crate::client::INVALIDATION_MAP.get(${JSON.stringify(m.resource)}) {`)
|
|
796
|
+
l.push(`\t\t\tif let Some(entry) = resource_map.get(${JSON.stringify(m.action)}) {`)
|
|
797
|
+
l.push(`\t\t\t\tlet targets: Vec<String> = entry.invalidate.iter().map(|s| s.to_string()).collect();`)
|
|
798
|
+
l.push(`\t\t\t\tstale.mark_stale(&targets, &_path_params_map, &_concrete_selector).await;`)
|
|
799
|
+
l.push(`\t\t\t}`)
|
|
800
|
+
l.push(`\t\t}`)
|
|
801
|
+
l.push(`\t\tif let Some(ref meta) = _request_meta {`)
|
|
802
|
+
l.push(`\t\t\tif meta.is_stale {`)
|
|
803
|
+
l.push(`\t\t\t\tstale.clear_stale(&_concrete_selector, &_concrete_path, ${JSON.stringify(httpMethod)}, meta.seq_snapshot).await;`)
|
|
804
|
+
l.push(`\t\t\t}`)
|
|
805
|
+
l.push(`\t\t}`)
|
|
806
|
+
l.push(`\t}`)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (m.responseType === "") {
|
|
810
|
+
l.push(`\tOk(())`)
|
|
811
|
+
l.push(`}`)
|
|
812
|
+
l.push(``)
|
|
813
|
+
return l
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (throwOnError) {
|
|
817
|
+
l.push(`\tlet out: ${m.responseType} = serde_json::from_slice(&result.body)?;`)
|
|
818
|
+
l.push(`\tOk(out)`)
|
|
819
|
+
} else {
|
|
820
|
+
l.push(`\tlet out: ${m.responseType} = serde_json::from_slice(&result.body)?;`)
|
|
821
|
+
l.push(`\tOk(SdkResult { data: Some(out), error: None, status: result.status, response: crate::runtime::ResponseMeta { status: result.status, headers: result.headers.clone(), url: result.url.clone(), body: result.body.clone() } })`)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
l.push(`}`)
|
|
825
|
+
l.push(``)
|
|
826
|
+
return l
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** emitSyncMethod is the blocking twin of emitMethod — no async, calls do_request_blocking. */
|
|
830
|
+
function emitSyncMethod(
|
|
831
|
+
m: MethodInfo,
|
|
832
|
+
selfAccess: "self" | "self.client",
|
|
833
|
+
throwOnError: boolean,
|
|
834
|
+
): string[] {
|
|
835
|
+
/* SSE and WS operations have no sync variant */
|
|
836
|
+
if (m.isSSE || m.isWS) return []
|
|
837
|
+
|
|
838
|
+
const l: string[] = []
|
|
839
|
+
const actionFn = rustMethodIdent(m.action === "_call" ? m.resource : m.methodAction)
|
|
840
|
+
const optsName = optsStructName(m)
|
|
841
|
+
const httpMethod = m.httpMethod
|
|
842
|
+
|
|
843
|
+
if (m.summary) {
|
|
844
|
+
l.push(`/// ${m.summary}`)
|
|
845
|
+
if (m.description) l.push(`/// ${m.description}`)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const params: string[] = [`&self`]
|
|
849
|
+
for (const pp of m.pathParams) {
|
|
850
|
+
params.push(`${rustSnake(pp)}: &str`)
|
|
851
|
+
}
|
|
852
|
+
/* sync generics: stream body is generic over `R: Read + Send + 'static`
|
|
853
|
+
* (reqwest::blocking::Body::new bound). */
|
|
854
|
+
const syncGenerics: string[] = []
|
|
855
|
+
const bi = m.bodyInfo
|
|
856
|
+
if (bi.kind === "json") {
|
|
857
|
+
params.push(`body: &${bi.type}`)
|
|
858
|
+
} else if (bi.kind === "form-url") {
|
|
859
|
+
params.push(`body: &std::collections::HashMap<String, String>`)
|
|
860
|
+
} else if (bi.kind === "multipart") {
|
|
861
|
+
params.push(`body: reqwest::blocking::multipart::Form`)
|
|
862
|
+
} else if (bi.kind === "stream") {
|
|
863
|
+
syncGenerics.push(`R: std::io::Read + Send + 'static`)
|
|
864
|
+
params.push(`body: R`)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const { cfg: cfgAccess, stale: staleAccess, httpClient: httpClientAccess } = buildAccessors(selfAccess)
|
|
868
|
+
|
|
869
|
+
const opErrEnum = !throwOnError ? getOpErrorEnumName(m.opId, m.op) : null
|
|
870
|
+
let retType: string
|
|
871
|
+
if (throwOnError) {
|
|
872
|
+
retType = m.responseType !== "" ? `Result<${m.responseType}, Error>` : `Result<(), Error>`
|
|
873
|
+
} else if (opErrEnum && m.responseType !== "") {
|
|
874
|
+
retType = `Result<SdkResult<${m.responseType}, ${opErrEnum}>, Error>`
|
|
875
|
+
} else {
|
|
876
|
+
retType = m.responseType !== "" ? `Result<SdkResult<${m.responseType}>, Error>` : `Result<(), Error>`
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
params.push(`opts: &${optsName}`)
|
|
880
|
+
const syncGenericStr = syncGenerics.length > 0 ? `<${syncGenerics.join(", ")}>` : ""
|
|
881
|
+
l.push(`pub fn ${actionFn}${syncGenericStr}(${params.join(", ")}) -> ${retType} {`)
|
|
882
|
+
|
|
883
|
+
const urlExpr = buildUrlExpr(m.path, m.pathParams)
|
|
884
|
+
l.push(`\tlet url_path = ${urlExpr};`)
|
|
885
|
+
const queryMut = m.queryParams.length > 0 ? "mut " : ""
|
|
886
|
+
l.push(`\tlet ${queryMut}query: Vec<(String, String)> = Vec::new();`)
|
|
887
|
+
emitQueryParamLoop(m.queryParams, l)
|
|
888
|
+
|
|
889
|
+
let bodyArg: string
|
|
890
|
+
if (bi.kind === "json") {
|
|
891
|
+
bodyArg = `crate::runtime_sync::SyncRequestBody::Json(&serde_json::to_value(body).unwrap_or_default())`
|
|
892
|
+
} else if (bi.kind === "form-url") {
|
|
893
|
+
bodyArg = `crate::runtime_sync::SyncRequestBody::FormUrl(body)`
|
|
894
|
+
} else if (bi.kind === "multipart") {
|
|
895
|
+
bodyArg = `crate::runtime_sync::SyncRequestBody::Multipart(body)`
|
|
896
|
+
} else if (bi.kind === "stream") {
|
|
897
|
+
bodyArg = `crate::runtime_sync::SyncRequestBody::Stream(reqwest::blocking::Body::new(body))`
|
|
898
|
+
} else {
|
|
899
|
+
bodyArg = `crate::runtime_sync::SyncRequestBody::None`
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/* invalidation pre-flight (sync) — same shape as async, no .await. */
|
|
903
|
+
const hasInvalidate = m.invalidates.length > 0
|
|
904
|
+
if (hasInvalidate) {
|
|
905
|
+
const pathParamsMapExpr = m.pathParams.length > 0
|
|
906
|
+
? `std::collections::HashMap::from([${m.pathParams.map((p) => `(${JSON.stringify(p)}.to_string(), ${rustSnake(p)}.to_string())`).join(", ")}])`
|
|
907
|
+
: `std::collections::HashMap::<String, String>::new()`
|
|
908
|
+
l.push(`\tlet _path_params_map: std::collections::HashMap<String, String> = ${pathParamsMapExpr};`)
|
|
909
|
+
l.push(`\tlet _concrete_path = crate::invalidation::interpolate_path(${JSON.stringify(m.path)}, &_path_params_map).unwrap_or_else(|_| ${JSON.stringify(m.path)}.to_string());`)
|
|
910
|
+
l.push(`\tlet _concrete_selector = format!("${httpMethod} {}", _concrete_path);`)
|
|
911
|
+
l.push(`\tlet _request_meta: Option<crate::invalidation::RequestMeta> = match ${staleAccess} {`)
|
|
912
|
+
l.push(`\t\tSome(ref s) => s.build_request_meta(&_concrete_selector, &_concrete_path, ${JSON.stringify(httpMethod)}),`)
|
|
913
|
+
l.push(`\t\tNone => None,`)
|
|
914
|
+
l.push(`\t};`)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/* idempotency-key auto-injection (sync mirror of async path — same semantics). */
|
|
918
|
+
const emitIdem = m.isIdempotent && !m.isSSE && !m.isWS && !m.isRealtime
|
|
919
|
+
if (emitIdem) {
|
|
920
|
+
l.push(`\tlet mut _call_headers: std::collections::HashMap<String, String> = opts.headers.clone().unwrap_or_default();`)
|
|
921
|
+
l.push(`\tlet _has_idem = _call_headers.keys().any(|k| k.eq_ignore_ascii_case("Idempotency-Key"));`)
|
|
922
|
+
l.push(`\tif !_has_idem {`)
|
|
923
|
+
l.push(`\t\tlet _k = opts.idempotency_key.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string());`)
|
|
924
|
+
l.push(`\t\t_call_headers.insert("Idempotency-Key".to_string(), _k);`)
|
|
925
|
+
l.push(`\t}`)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const operationStr = JSON.stringify(`${httpMethod} ${m.path}`)
|
|
929
|
+
const requestMetaArg = hasInvalidate ? `_request_meta.as_ref()` : `None`
|
|
930
|
+
const headersArg = emitIdem ? `Some(&_call_headers)` : `opts.headers.as_ref()`
|
|
931
|
+
l.push(`\tlet result = crate::runtime_sync::do_request_blocking(`)
|
|
932
|
+
l.push(`\t\t&${httpClientAccess},`)
|
|
933
|
+
l.push(`\t\t&${cfgAccess},`)
|
|
934
|
+
l.push(`\t\treqwest::Method::${httpMethod},`)
|
|
935
|
+
l.push(`\t\t&url_path,`)
|
|
936
|
+
l.push(`\t\t&query,`)
|
|
937
|
+
l.push(`\t\t${bodyArg},`)
|
|
938
|
+
l.push(`\t\t${headersArg},`)
|
|
939
|
+
l.push(`\t\topts.timeout,`)
|
|
940
|
+
l.push(`\t\topts.sync_cancel_token.clone(),`)
|
|
941
|
+
l.push(`\t\t${operationStr},`)
|
|
942
|
+
l.push(`\t\t${requestMetaArg},`)
|
|
943
|
+
l.push(`\t)?;`)
|
|
944
|
+
|
|
945
|
+
if (hasInvalidate) {
|
|
946
|
+
l.push(`\tif let Some(ref stale) = ${staleAccess} {`)
|
|
947
|
+
l.push(`\t\tif let Some(resource_map) = crate::client::INVALIDATION_MAP.get(${JSON.stringify(m.resource)}) {`)
|
|
948
|
+
l.push(`\t\t\tif let Some(entry) = resource_map.get(${JSON.stringify(m.action)}) {`)
|
|
949
|
+
l.push(`\t\t\t\tlet targets: Vec<String> = entry.invalidate.iter().map(|s| s.to_string()).collect();`)
|
|
950
|
+
l.push(`\t\t\t\tstale.mark_stale(&targets, &_path_params_map, &_concrete_selector);`)
|
|
951
|
+
l.push(`\t\t\t}`)
|
|
952
|
+
l.push(`\t\t}`)
|
|
953
|
+
l.push(`\t\tif let Some(ref meta) = _request_meta {`)
|
|
954
|
+
l.push(`\t\t\tif meta.is_stale {`)
|
|
955
|
+
l.push(`\t\t\t\tstale.clear_stale(&_concrete_selector, &_concrete_path, ${JSON.stringify(httpMethod)}, meta.seq_snapshot);`)
|
|
956
|
+
l.push(`\t\t\t}`)
|
|
957
|
+
l.push(`\t\t}`)
|
|
958
|
+
l.push(`\t}`)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (m.responseType === "") {
|
|
962
|
+
l.push(`\tOk(())`)
|
|
963
|
+
l.push(`}`)
|
|
964
|
+
l.push(``)
|
|
965
|
+
return l
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (throwOnError) {
|
|
969
|
+
l.push(`\tlet out: ${m.responseType} = serde_json::from_slice(&result.body)?;`)
|
|
970
|
+
l.push(`\tOk(out)`)
|
|
971
|
+
} else {
|
|
972
|
+
l.push(`\tlet out: ${m.responseType} = serde_json::from_slice(&result.body)?;`)
|
|
973
|
+
l.push(`\tOk(SdkResult { data: Some(out), error: None, status: result.status, response: crate::runtime::ResponseMeta { status: result.status, headers: result.headers.clone(), url: result.url.clone(), body: result.body.clone() } })`)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
l.push(`}`)
|
|
977
|
+
l.push(``)
|
|
978
|
+
return l
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Builds a Rust expression that produces an owned `String` from a query-param value place.
|
|
982
|
+
* `valuePlace` is the place expression — either `opts.<field>` (required, owned T) or
|
|
983
|
+
* `v` from `if let Some(ref v) = opts.<field>` (optional, &T). Both accept the same method
|
|
984
|
+
* call syntax thanks to Rust's auto-deref on method dispatch.
|
|
985
|
+
* - String → `.clone()` (owned copy)
|
|
986
|
+
* - i64 / f64 / bool → `.to_string()` via `Display`
|
|
987
|
+
* - Enums + complex types → `serde_json::to_string`; strip surrounding quotes so plain-string
|
|
988
|
+
* enum serialisations land as `value` not `"value"` in the URL.
|
|
989
|
+
*/
|
|
990
|
+
function rustQueryValueToString(schema: Record<string, unknown>, valuePlace: string): string {
|
|
991
|
+
if (Array.isArray(schema.enum)) {
|
|
992
|
+
return `serde_json::to_string(&${valuePlace}).map(|s| s.trim_matches('"').to_string()).unwrap_or_default()`
|
|
993
|
+
}
|
|
994
|
+
const t = schema.type
|
|
995
|
+
if (t === "string") return `${valuePlace}.clone()`
|
|
996
|
+
if (t === "integer" || t === "number" || t === "boolean") return `${valuePlace}.to_string()`
|
|
997
|
+
return `serde_json::to_string(&${valuePlace}).unwrap_or_default()`
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Emits query param push lines for a standard REST method (not SSE/WS). */
|
|
1001
|
+
function emitQueryParamLoop(qps: QueryParam[], l: string[]): void {
|
|
1002
|
+
for (const qp of qps) {
|
|
1003
|
+
const fieldIdent = rustIdent(qp.name)
|
|
1004
|
+
if (qp.required) {
|
|
1005
|
+
const expr = rustQueryValueToString(qp.schema, `opts.${fieldIdent}`)
|
|
1006
|
+
l.push(`\tquery.push((${JSON.stringify(qp.name)}.to_string(), ${expr}));`)
|
|
1007
|
+
} else {
|
|
1008
|
+
const expr = rustQueryValueToString(qp.schema, `v`)
|
|
1009
|
+
l.push(`\tif let Some(ref v) = opts.${fieldIdent} { query.push((${JSON.stringify(qp.name)}.to_string(), ${expr})); }`)
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function buildUrlExpr(path: string, pathParams: string[]): string {
|
|
1015
|
+
/* always returns a String (not &str) so &url_path is &String, not &&str */
|
|
1016
|
+
if (pathParams.length === 0) return `${JSON.stringify(path)}.to_string()`
|
|
1017
|
+
let fmtStr = path
|
|
1018
|
+
const args: string[] = []
|
|
1019
|
+
for (const p of pathParams) {
|
|
1020
|
+
fmtStr = fmtStr.replace(`{${p}}`, `{}`)
|
|
1021
|
+
args.push(`urlencoding::encode(${rustSnake(p)}).into_owned()`)
|
|
1022
|
+
}
|
|
1023
|
+
return `format!(${JSON.stringify(fmtStr)}, ${args.join(", ")})`
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/* Schema names defined in Rust runtime files — skip to avoid redeclaration. */
|
|
1027
|
+
const RUST_RUNTIME_TYPES = new Set([
|
|
1028
|
+
"ClientConfig",
|
|
1029
|
+
"Error",
|
|
1030
|
+
"InvalidationConfig",
|
|
1031
|
+
"LogEntry",
|
|
1032
|
+
"OnAuthExpiredHook",
|
|
1033
|
+
"OnLogHook",
|
|
1034
|
+
"OnRequestHook",
|
|
1035
|
+
"OnResponseHook",
|
|
1036
|
+
"RequestBody",
|
|
1037
|
+
"RequestContext",
|
|
1038
|
+
"RequestMeta",
|
|
1039
|
+
"ResponseContext",
|
|
1040
|
+
"ResponseMeta",
|
|
1041
|
+
"SdkResult",
|
|
1042
|
+
"SseEvent",
|
|
1043
|
+
"StaleEntry",
|
|
1044
|
+
"StaleTracker",
|
|
1045
|
+
"StaleTrackerSync",
|
|
1046
|
+
"StatusError",
|
|
1047
|
+
"SyncClientConfig",
|
|
1048
|
+
"SyncRequestBody",
|
|
1049
|
+
"SyncRequestContext",
|
|
1050
|
+
"SyncResponseContext",
|
|
1051
|
+
"TypedWebSocket",
|
|
1052
|
+
])
|
|
1053
|
+
|
|
1054
|
+
function buildRustTypes(spec: OpenApiSpec): string {
|
|
1055
|
+
const schemas = spec.components?.schemas ?? {}
|
|
1056
|
+
const l: string[] = []
|
|
1057
|
+
l.push(`/* Code generated by honey. DO NOT EDIT. */`)
|
|
1058
|
+
l.push(``)
|
|
1059
|
+
l.push(`#![allow(unused_imports)]`)
|
|
1060
|
+
l.push(`use serde::{Serialize, Deserialize};`)
|
|
1061
|
+
l.push(`use serde_repr::{Serialize_repr, Deserialize_repr};`)
|
|
1062
|
+
l.push(`use std::collections::HashMap;`)
|
|
1063
|
+
l.push(``)
|
|
1064
|
+
|
|
1065
|
+
const sortedSchemas = Object.entries(schemas)
|
|
1066
|
+
.filter(([name]) => !RUST_RUNTIME_TYPES.has(name))
|
|
1067
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1068
|
+
const body: string[] = []
|
|
1069
|
+
const decls = new Map<string, string>()
|
|
1070
|
+
|
|
1071
|
+
/* always emit shared error-envelope field type used by per-op error structs */
|
|
1072
|
+
body.push(`#[derive(Debug, Clone, Serialize, Deserialize)]`)
|
|
1073
|
+
body.push(`pub struct ErrField {`)
|
|
1074
|
+
body.push(`\tpub error_key: String,`)
|
|
1075
|
+
body.push(`\tpub message: String,`)
|
|
1076
|
+
body.push(`\tpub path: String,`)
|
|
1077
|
+
body.push(`}`)
|
|
1078
|
+
body.push(``)
|
|
1079
|
+
|
|
1080
|
+
for (const [name, schema] of sortedSchemas) {
|
|
1081
|
+
const rustType = renderTopLevelRust(name, schema, decls)
|
|
1082
|
+
body.push(rustType)
|
|
1083
|
+
body.push(``)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const hoistedNames = [...decls.keys()].sort((a, b) => a.localeCompare(b))
|
|
1087
|
+
for (const hname of hoistedNames) {
|
|
1088
|
+
body.push(decls.get(hname) as string)
|
|
1089
|
+
body.push(``)
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
l.push(body.join("\n"))
|
|
1093
|
+
return l.join("\n")
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function buildCargoToml(crateName: string, meta: { version: string; description?: string; homepage?: string; repository?: string; license?: string }): string {
|
|
1097
|
+
const pkgLines = [
|
|
1098
|
+
`[package]`,
|
|
1099
|
+
`name = "${crateName}"`,
|
|
1100
|
+
`version = ${JSON.stringify(meta.version)}`,
|
|
1101
|
+
`edition = "2021"`,
|
|
1102
|
+
`rust-version = "1.75"`,
|
|
1103
|
+
]
|
|
1104
|
+
if (meta.description) pkgLines.push(`description = ${JSON.stringify(meta.description)}`)
|
|
1105
|
+
if (meta.homepage) pkgLines.push(`homepage = ${JSON.stringify(meta.homepage)}`)
|
|
1106
|
+
if (meta.repository) pkgLines.push(`repository = ${JSON.stringify(meta.repository)}`)
|
|
1107
|
+
if (meta.license) pkgLines.push(`license = ${JSON.stringify(meta.license)}`)
|
|
1108
|
+
return [
|
|
1109
|
+
...pkgLines,
|
|
1110
|
+
``,
|
|
1111
|
+
`[dependencies]`,
|
|
1112
|
+
`tokio = { version = "^1.40", features = ["rt-multi-thread", "macros", "sync", "time"] }`,
|
|
1113
|
+
`tokio-util = { version = "^0.7", features = ["rt"] }`,
|
|
1114
|
+
`reqwest = { version = "^0.12", default-features = false, features = ["json", "stream", "rustls-tls", "multipart", "blocking"] }`,
|
|
1115
|
+
`tokio-tungstenite = { version = "^0.24", features = ["rustls-tls-webpki-roots"] }`,
|
|
1116
|
+
`futures = "^0.3"`,
|
|
1117
|
+
`futures-util = "^0.3"`,
|
|
1118
|
+
`serde = { version = "^1.0", features = ["derive"] }`,
|
|
1119
|
+
`serde_json = "^1.0"`,
|
|
1120
|
+
`serde_repr = "^0.1"`,
|
|
1121
|
+
`url = "^2.5"`,
|
|
1122
|
+
`urlencoding = "^2.1"`,
|
|
1123
|
+
`regex = "^1.10"`,
|
|
1124
|
+
`once_cell = "^1.20"`,
|
|
1125
|
+
`bytes = "^1.7"`,
|
|
1126
|
+
`uuid = { version = "^1", features = ["v4"] }`,
|
|
1127
|
+
`async-trait = "^0.1"`,
|
|
1128
|
+
`async-stream = "^0.3"`,
|
|
1129
|
+
``,
|
|
1130
|
+
].join("\n")
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function buildLibRs(hasRealtime: boolean): string {
|
|
1134
|
+
const lines = [
|
|
1135
|
+
`/* Code generated by honey. DO NOT EDIT. */`,
|
|
1136
|
+
``,
|
|
1137
|
+
`pub mod client;`,
|
|
1138
|
+
`pub mod errors;`,
|
|
1139
|
+
`pub mod invalidation;`,
|
|
1140
|
+
`pub mod invalidation_sync;`,
|
|
1141
|
+
`pub mod resources;`,
|
|
1142
|
+
`pub mod result;`,
|
|
1143
|
+
`pub mod runtime;`,
|
|
1144
|
+
`pub mod runtime_sync;`,
|
|
1145
|
+
`pub mod sse;`,
|
|
1146
|
+
`pub mod types;`,
|
|
1147
|
+
`pub mod ws;`,
|
|
1148
|
+
]
|
|
1149
|
+
if (hasRealtime) {
|
|
1150
|
+
lines.push(`pub mod realtime;`)
|
|
1151
|
+
}
|
|
1152
|
+
lines.push(``)
|
|
1153
|
+
lines.push(`pub use client::Client;`)
|
|
1154
|
+
lines.push(`pub use client::SyncClient;`)
|
|
1155
|
+
lines.push(`pub use errors::Error;`)
|
|
1156
|
+
lines.push(`pub use runtime::ClientConfig;`)
|
|
1157
|
+
lines.push(`pub use runtime_sync::SyncClientConfig;`)
|
|
1158
|
+
lines.push(`pub use result::SdkResult;`)
|
|
1159
|
+
lines.push(``)
|
|
1160
|
+
return lines.join("\n")
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function buildRustClient(spec: OpenApiSpec, options: RustSDKOptions, ir: ReturnType<typeof toIR>): string {
|
|
1164
|
+
const throwOnError = options.throwOnError ?? true
|
|
1165
|
+
const methods = collectMethodsFromIR(ir.tree, spec)
|
|
1166
|
+
const topLevel = methods.filter((m) => m.action === "_call")
|
|
1167
|
+
|
|
1168
|
+
const serviceMap = collectSDKMethods(spec as Parameters<typeof collectSDKMethods>[0]).serviceMap
|
|
1169
|
+
|
|
1170
|
+
const body: string[] = []
|
|
1171
|
+
|
|
1172
|
+
/* build a realtime lookup map from collected methods */
|
|
1173
|
+
const realtimeLookup = new Map<string, boolean>()
|
|
1174
|
+
for (const m of methods) {
|
|
1175
|
+
realtimeLookup.set(`${m.resource}.${m.action}`, m.isRealtime)
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
body.push(`pub(crate) struct ServiceEntry {`)
|
|
1179
|
+
body.push(`\tpub method: &'static str,`)
|
|
1180
|
+
body.push(`\tpub path: &'static str,`)
|
|
1181
|
+
body.push(`\tpub params: &'static [&'static str],`)
|
|
1182
|
+
body.push(`\tpub sse: bool,`)
|
|
1183
|
+
body.push(`\tpub ws: bool,`)
|
|
1184
|
+
body.push(`\tpub realtime: bool,`)
|
|
1185
|
+
body.push(`\tpub invalidate: &'static [&'static str],`)
|
|
1186
|
+
body.push(`}`)
|
|
1187
|
+
body.push(``)
|
|
1188
|
+
|
|
1189
|
+
body.push(`pub(crate) static INVALIDATION_MAP: once_cell::sync::Lazy<std::collections::HashMap<&'static str, std::collections::HashMap<&'static str, ServiceEntry>>> =`)
|
|
1190
|
+
body.push(`\tonce_cell::sync::Lazy::new(|| {`)
|
|
1191
|
+
body.push(`\t\tlet mut map = std::collections::HashMap::new();`)
|
|
1192
|
+
for (const [resource, actions] of Object.entries(serviceMap).sort(([a], [b]) => a.localeCompare(b))) {
|
|
1193
|
+
body.push(`\t\t{`)
|
|
1194
|
+
body.push(`\t\t\tlet mut resource_map = std::collections::HashMap::new();`)
|
|
1195
|
+
for (const [action, entry] of Object.entries(actions as Record<string, Record<string, unknown>>).sort(([a], [b]) => a.localeCompare(b))) {
|
|
1196
|
+
const inv = entry.invalidate as string[] | undefined
|
|
1197
|
+
const invStr = inv && inv.length > 0
|
|
1198
|
+
? `&[${inv.map((s) => JSON.stringify(s)).join(", ")}]`
|
|
1199
|
+
: `&[]`
|
|
1200
|
+
const params = entry.params as string[] | undefined
|
|
1201
|
+
const paramsStr = params && params.length > 0
|
|
1202
|
+
? `&[${params.map((s) => JSON.stringify(s)).join(", ")}]`
|
|
1203
|
+
: `&[]`
|
|
1204
|
+
const isRealtime = realtimeLookup.get(`${resource}.${action}`) ?? false
|
|
1205
|
+
body.push(`\t\t\tresource_map.insert(${JSON.stringify(action)}, ServiceEntry { method: ${JSON.stringify(String(entry.method))}, path: ${JSON.stringify(String(entry.path))}, params: ${paramsStr}, sse: ${entry.sse === true}, ws: ${entry.ws === true}, realtime: ${isRealtime}, invalidate: ${invStr} });`)
|
|
1206
|
+
}
|
|
1207
|
+
body.push(`\t\t\tmap.insert(${JSON.stringify(resource)}, resource_map);`)
|
|
1208
|
+
body.push(`\t\t}`)
|
|
1209
|
+
}
|
|
1210
|
+
body.push(`\t\tmap`)
|
|
1211
|
+
body.push(`\t});`)
|
|
1212
|
+
body.push(``)
|
|
1213
|
+
|
|
1214
|
+
body.push(`pub(crate) struct ClientInner {`)
|
|
1215
|
+
body.push(`\tpub(crate) cfg: crate::runtime::ClientConfig,`)
|
|
1216
|
+
body.push(`\tpub(crate) stale: Option<crate::invalidation::StaleTracker>,`)
|
|
1217
|
+
body.push(`\tpub(crate) http_client: reqwest::Client,`)
|
|
1218
|
+
body.push(`}`)
|
|
1219
|
+
body.push(``)
|
|
1220
|
+
|
|
1221
|
+
const auth = detectAuthScheme(spec as Record<string, unknown>)
|
|
1222
|
+
body.push(`/// Client is the generated SDK client.`)
|
|
1223
|
+
body.push(`pub struct Client {`)
|
|
1224
|
+
body.push(`\tpub(crate) inner: std::sync::Arc<ClientInner>,`)
|
|
1225
|
+
for (const [seg] of namespacesOf(ir.tree)) {
|
|
1226
|
+
const safeName = safeResourceName(seg)
|
|
1227
|
+
body.push(`\t${rustSnake(safeName)}: ${rustPascal(safeName)}Resource,`)
|
|
1228
|
+
}
|
|
1229
|
+
body.push(`}`)
|
|
1230
|
+
body.push(``)
|
|
1231
|
+
|
|
1232
|
+
body.push(`impl Client {`)
|
|
1233
|
+
body.push(`\t/// new creates a new SDK client. base_url must be non-empty.`)
|
|
1234
|
+
body.push(`\tpub fn new(mut cfg: crate::runtime::ClientConfig) -> Self {`)
|
|
1235
|
+
body.push(`\t\tif cfg.base_url.is_empty() {`)
|
|
1236
|
+
body.push(`\t\t\tpanic!("honey sdk: ClientConfig.base_url must not be empty");`)
|
|
1237
|
+
body.push(`\t\t}`)
|
|
1238
|
+
body.push(`\t\t/* http_client: None → build a default reqwest::Client */`)
|
|
1239
|
+
body.push(`\t\tlet http_client = if cfg.http_client.is_none() {`)
|
|
1240
|
+
body.push(`\t\t\treqwest::Client::new()`)
|
|
1241
|
+
body.push(`\t\t} else {`)
|
|
1242
|
+
body.push(`\t\t\tcfg.http_client.take().unwrap()`)
|
|
1243
|
+
body.push(`\t\t};`)
|
|
1244
|
+
body.push(`\t\tif cfg.auth_header_name.is_none() {`)
|
|
1245
|
+
body.push(`\t\t\tcfg.auth_header_name = Some(${JSON.stringify(auth.headerName)}.to_string());`)
|
|
1246
|
+
body.push(`\t\t}`)
|
|
1247
|
+
body.push(`\t\tif cfg.auth_header_prefix.is_none() {`)
|
|
1248
|
+
body.push(`\t\t\tcfg.auth_header_prefix = Some(${JSON.stringify(auth.prefix)}.to_string());`)
|
|
1249
|
+
body.push(`\t\t}`)
|
|
1250
|
+
body.push(`\t\tlet stale = if let Some(ref inv) = cfg.invalidation {`)
|
|
1251
|
+
body.push(`\t\t\tif inv.stale_time > 0 {`)
|
|
1252
|
+
body.push(`\t\t\t\tSome(crate::invalidation::StaleTracker::new(Some(inv)))`)
|
|
1253
|
+
body.push(`\t\t\t} else { None }`)
|
|
1254
|
+
body.push(`\t\t} else { None };`)
|
|
1255
|
+
body.push(`\t\tlet inner = std::sync::Arc::new(ClientInner { cfg, stale, http_client });`)
|
|
1256
|
+
body.push(`\t\tClient {`)
|
|
1257
|
+
for (const [seg] of namespacesOf(ir.tree)) {
|
|
1258
|
+
const safeName = safeResourceName(seg)
|
|
1259
|
+
body.push(`\t\t\t${rustSnake(safeName)}: crate::resources::${rustSnake(safeName)}::new_${rustSnake(safeName)}_resource(&inner),`)
|
|
1260
|
+
}
|
|
1261
|
+
body.push(`\t\t\tinner,`)
|
|
1262
|
+
body.push(`\t\t}`)
|
|
1263
|
+
body.push(`\t}`)
|
|
1264
|
+
body.push(``)
|
|
1265
|
+
|
|
1266
|
+
for (const [seg] of namespacesOf(ir.tree)) {
|
|
1267
|
+
const safeName = safeResourceName(seg)
|
|
1268
|
+
const structName = `${rustPascal(safeName)}Resource`
|
|
1269
|
+
body.push(`\t/// ${rustSnake(safeName)} returns a reference to the ${structName}.`)
|
|
1270
|
+
body.push(`\tpub fn ${rustSnake(safeName)}(&self) -> &${structName} {`)
|
|
1271
|
+
body.push(`\t\t&self.${rustSnake(safeName)}`)
|
|
1272
|
+
body.push(`\t}`)
|
|
1273
|
+
body.push(``)
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
for (const m of topLevel.sort((a, b) => a.resource.localeCompare(b.resource))) {
|
|
1277
|
+
body.push(...emitMethod(m, "self", throwOnError))
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/* is_stale — public predicate consumers call to drive refetch decisions.
|
|
1281
|
+
* Delegates to the StaleTracker; returns false when invalidation is disabled. */
|
|
1282
|
+
body.push(`\t/// is_stale reports whether (method, path) is currently within an active stale window.`)
|
|
1283
|
+
body.push(`\tpub async fn is_stale(&self, method: &str, path: &str) -> bool {`)
|
|
1284
|
+
body.push(`\t\tmatch self.inner.stale {`)
|
|
1285
|
+
body.push(`\t\t\tSome(ref s) => s.is_stale(method, path).await,`)
|
|
1286
|
+
body.push(`\t\t\tNone => false,`)
|
|
1287
|
+
body.push(`\t\t}`)
|
|
1288
|
+
body.push(`\t}`)
|
|
1289
|
+
body.push(``)
|
|
1290
|
+
|
|
1291
|
+
body.push(`}`)
|
|
1292
|
+
body.push(``)
|
|
1293
|
+
|
|
1294
|
+
const topLevelOptsEmitted = new Set<string>()
|
|
1295
|
+
for (const m of topLevel.sort((a, b) => a.opId.localeCompare(b.opId))) {
|
|
1296
|
+
const optsName = optsStructName(m)
|
|
1297
|
+
if (topLevelOptsEmitted.has(optsName)) continue
|
|
1298
|
+
topLevelOptsEmitted.add(optsName)
|
|
1299
|
+
body.push(...emitOptsStruct(m))
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/* ── SyncClient ── */
|
|
1303
|
+
|
|
1304
|
+
/* root namespaces that have at least one non-SSE/non-WS descendant method */
|
|
1305
|
+
const syncNsSegs = namespacesOf(ir.tree)
|
|
1306
|
+
.map(([seg]) => seg)
|
|
1307
|
+
.filter((seg) => {
|
|
1308
|
+
const nsMs = methods.filter((m) => !m.isSSE && !m.isWS && m.namePath[0] === seg)
|
|
1309
|
+
return nsMs.length > 0
|
|
1310
|
+
})
|
|
1311
|
+
|
|
1312
|
+
body.push(`pub(crate) struct SyncClientInner {`)
|
|
1313
|
+
body.push(`\tpub(crate) cfg: crate::runtime_sync::SyncClientConfig,`)
|
|
1314
|
+
body.push(`\tpub(crate) stale: Option<crate::invalidation_sync::StaleTrackerSync>,`)
|
|
1315
|
+
body.push(`\tpub(crate) http_client: reqwest::blocking::Client,`)
|
|
1316
|
+
body.push(`}`)
|
|
1317
|
+
body.push(``)
|
|
1318
|
+
|
|
1319
|
+
body.push(`/// SyncClient is the generated blocking SDK client.`)
|
|
1320
|
+
body.push(`/// WARNING: Do not use from inside a tokio async task — reqwest blocking will deadlock.`)
|
|
1321
|
+
body.push(`pub struct SyncClient {`)
|
|
1322
|
+
body.push(`\tpub(crate) inner: std::sync::Arc<SyncClientInner>,`)
|
|
1323
|
+
for (const seg of syncNsSegs) {
|
|
1324
|
+
const safeName = safeResourceName(seg)
|
|
1325
|
+
body.push(`\t${rustSnake(safeName)}: ${rustPascal(safeName)}ResourceSync,`)
|
|
1326
|
+
}
|
|
1327
|
+
body.push(`}`)
|
|
1328
|
+
body.push(``)
|
|
1329
|
+
|
|
1330
|
+
body.push(`impl SyncClient {`)
|
|
1331
|
+
body.push(`\t/// new creates a new blocking SDK client. base_url must be non-empty.`)
|
|
1332
|
+
body.push(`\tpub fn new(mut cfg: crate::runtime_sync::SyncClientConfig) -> Self {`)
|
|
1333
|
+
body.push(`\t\tif cfg.base_url.is_empty() {`)
|
|
1334
|
+
body.push(`\t\t\tpanic!("honey sdk: SyncClientConfig.base_url must not be empty");`)
|
|
1335
|
+
body.push(`\t\t}`)
|
|
1336
|
+
body.push(`\t\tlet http_client = if cfg.http_client.is_none() {`)
|
|
1337
|
+
body.push(`\t\t\treqwest::blocking::Client::new()`)
|
|
1338
|
+
body.push(`\t\t} else {`)
|
|
1339
|
+
body.push(`\t\t\tcfg.http_client.take().unwrap()`)
|
|
1340
|
+
body.push(`\t\t};`)
|
|
1341
|
+
body.push(`\t\tif cfg.auth_header_name.is_none() {`)
|
|
1342
|
+
body.push(`\t\t\tcfg.auth_header_name = Some(${JSON.stringify(auth.headerName)}.to_string());`)
|
|
1343
|
+
body.push(`\t\t}`)
|
|
1344
|
+
body.push(`\t\tif cfg.auth_header_prefix.is_none() {`)
|
|
1345
|
+
body.push(`\t\t\tcfg.auth_header_prefix = Some(${JSON.stringify(auth.prefix)}.to_string());`)
|
|
1346
|
+
body.push(`\t\t}`)
|
|
1347
|
+
body.push(`\t\tlet stale = if let Some(ref inv) = cfg.invalidation {`)
|
|
1348
|
+
body.push(`\t\t\tif inv.stale_time > 0 {`)
|
|
1349
|
+
body.push(`\t\t\t\tSome(crate::invalidation_sync::StaleTrackerSync::new(Some(inv)))`)
|
|
1350
|
+
body.push(`\t\t\t} else { None }`)
|
|
1351
|
+
body.push(`\t\t} else { None };`)
|
|
1352
|
+
body.push(`\t\tlet inner = std::sync::Arc::new(SyncClientInner { cfg, stale, http_client });`)
|
|
1353
|
+
body.push(`\t\tSyncClient {`)
|
|
1354
|
+
for (const seg of syncNsSegs) {
|
|
1355
|
+
const safeName = safeResourceName(seg)
|
|
1356
|
+
body.push(`\t\t\t${rustSnake(safeName)}: ${rustPascal(safeName)}ResourceSync { client: std::sync::Arc::clone(&inner) },`)
|
|
1357
|
+
}
|
|
1358
|
+
body.push(`\t\t\tinner,`)
|
|
1359
|
+
body.push(`\t\t}`)
|
|
1360
|
+
body.push(`\t}`)
|
|
1361
|
+
body.push(``)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
for (const seg of syncNsSegs) {
|
|
1365
|
+
const safeName = safeResourceName(seg)
|
|
1366
|
+
const structName = `${rustPascal(safeName)}ResourceSync`
|
|
1367
|
+
body.push(`\t/// ${rustSnake(safeName)} returns a reference to the ${structName}.`)
|
|
1368
|
+
body.push(`\tpub fn ${rustSnake(safeName)}(&self) -> &${structName} {`)
|
|
1369
|
+
body.push(`\t\t&self.${rustSnake(safeName)}`)
|
|
1370
|
+
body.push(`\t}`)
|
|
1371
|
+
body.push(``)
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/* top-level sync methods (operationIds without resource.action split, e.g. `getUser`)
|
|
1375
|
+
mirror the async Client's top-level methods. SSE/WS skipped — no sync variant. */
|
|
1376
|
+
const syncTopLevel = topLevel.filter((m) => !m.isSSE && !m.isWS)
|
|
1377
|
+
for (const m of syncTopLevel.sort((a, b) => a.resource.localeCompare(b.resource))) {
|
|
1378
|
+
body.push(...emitSyncMethod(m, "self", throwOnError))
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/* is_stale (sync) — public predicate consumers call to drive refetch decisions.
|
|
1382
|
+
* Delegates to the StaleTrackerSync; returns false when invalidation is disabled. */
|
|
1383
|
+
body.push(`\t/// is_stale reports whether (method, path) is currently within an active stale window.`)
|
|
1384
|
+
body.push(`\tpub fn is_stale(&self, method: &str, path: &str) -> bool {`)
|
|
1385
|
+
body.push(`\t\tmatch self.inner.stale {`)
|
|
1386
|
+
body.push(`\t\t\tSome(ref s) => s.is_stale(method, path),`)
|
|
1387
|
+
body.push(`\t\t\tNone => false,`)
|
|
1388
|
+
body.push(`\t\t}`)
|
|
1389
|
+
body.push(`\t}`)
|
|
1390
|
+
body.push(``)
|
|
1391
|
+
|
|
1392
|
+
body.push(`}`)
|
|
1393
|
+
body.push(``)
|
|
1394
|
+
|
|
1395
|
+
const bodyStr = body.join("\n")
|
|
1396
|
+
|
|
1397
|
+
const out: string[] = []
|
|
1398
|
+
out.push(`/* Code generated by honey. DO NOT EDIT. */`)
|
|
1399
|
+
out.push(``)
|
|
1400
|
+
out.push(`#![allow(unused_imports, unused_variables, dead_code)]`)
|
|
1401
|
+
out.push(`use std::sync::Arc;`)
|
|
1402
|
+
out.push(`use once_cell;`)
|
|
1403
|
+
out.push(`use reqwest;`)
|
|
1404
|
+
out.push(`use tokio_tungstenite;`)
|
|
1405
|
+
out.push(`use urlencoding;`)
|
|
1406
|
+
out.push(`use futures_util::{self, Stream};`)
|
|
1407
|
+
out.push(`use crate::errors::Error;`)
|
|
1408
|
+
out.push(`use crate::resources::*;`)
|
|
1409
|
+
out.push(`use crate::result::SdkResult;`)
|
|
1410
|
+
out.push(`use crate::runtime::ClientConfig;`)
|
|
1411
|
+
out.push(`use crate::sse::SseEvent;`)
|
|
1412
|
+
out.push(`use crate::types::*;`)
|
|
1413
|
+
out.push(`use crate::ws::TypedWebSocket;`)
|
|
1414
|
+
out.push(``)
|
|
1415
|
+
out.push(bodyStr)
|
|
1416
|
+
return out.join("\n")
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/* ── buildRustResources ── */
|
|
1420
|
+
|
|
1421
|
+
/** Emit standard file header imports for a resource .rs file. */
|
|
1422
|
+
function emitRustResourceFileHeader(rMethods: MethodInfo[]): string[] {
|
|
1423
|
+
const hasSSE = rMethods.some((m) => m.isSSE)
|
|
1424
|
+
const syncMethods = rMethods.filter((m) => !m.isSSE && !m.isWS)
|
|
1425
|
+
const hasSyncMethods = syncMethods.length > 0
|
|
1426
|
+
const lines: string[] = []
|
|
1427
|
+
lines.push(`/* Code generated by honey. DO NOT EDIT. */`)
|
|
1428
|
+
lines.push(``)
|
|
1429
|
+
lines.push(`#![allow(unused_imports, unused_variables, dead_code)]`)
|
|
1430
|
+
lines.push(`use std::sync::Arc;`)
|
|
1431
|
+
lines.push(`use futures_util::{self, Stream};`)
|
|
1432
|
+
lines.push(`use reqwest;`)
|
|
1433
|
+
lines.push(`use serde::{Serialize, Deserialize};`)
|
|
1434
|
+
lines.push(`use tokio_tungstenite;`)
|
|
1435
|
+
lines.push(`use urlencoding;`)
|
|
1436
|
+
lines.push(`use crate::client::ClientInner;`)
|
|
1437
|
+
if (hasSyncMethods) lines.push(`use crate::client::SyncClientInner;`)
|
|
1438
|
+
lines.push(`use crate::errors::Error;`)
|
|
1439
|
+
lines.push(`use crate::result::SdkResult;`)
|
|
1440
|
+
lines.push(`use crate::runtime::*;`)
|
|
1441
|
+
if (hasSyncMethods) lines.push(`use crate::runtime_sync::*;`)
|
|
1442
|
+
lines.push(`use crate::types::*;`)
|
|
1443
|
+
if (hasSSE) {
|
|
1444
|
+
lines.push(`use crate::sse::{SseEvent, parse_sse_stream};`)
|
|
1445
|
+
} else {
|
|
1446
|
+
lines.push(`use crate::sse::SseEvent;`)
|
|
1447
|
+
}
|
|
1448
|
+
lines.push(`use crate::ws::TypedWebSocket;`)
|
|
1449
|
+
lines.push(``)
|
|
1450
|
+
return lines
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function buildRustResources(
|
|
1454
|
+
spec: OpenApiSpec,
|
|
1455
|
+
options: RustSDKOptions,
|
|
1456
|
+
ir: ReturnType<typeof toIR>,
|
|
1457
|
+
): Map<string, string> {
|
|
1458
|
+
const throwOnError = options.throwOnError ?? true
|
|
1459
|
+
const allMethods = collectMethodsFromIR(ir.tree, spec)
|
|
1460
|
+
|
|
1461
|
+
const files = new Map<string, string>()
|
|
1462
|
+
|
|
1463
|
+
/* ---- src/resources/mod.rs: one `pub mod` per root namespace ---- */
|
|
1464
|
+
const modLines: string[] = [`/* Code generated by honey. DO NOT EDIT. */`, ``]
|
|
1465
|
+
for (const [seg] of namespacesOf(ir.tree)) {
|
|
1466
|
+
modLines.push(`pub mod ${rustSnake(safeResourceName(seg))};`)
|
|
1467
|
+
}
|
|
1468
|
+
for (const [seg] of namespacesOf(ir.tree)) {
|
|
1469
|
+
const safeName = safeResourceName(seg)
|
|
1470
|
+
modLines.push(`pub use ${rustSnake(safeName)}::${rustPascal(safeName)}Resource;`)
|
|
1471
|
+
const hasSyncDescendants = allMethods.some((m) => !m.isSSE && !m.isWS && m.namePath[0] === seg)
|
|
1472
|
+
if (hasSyncDescendants) {
|
|
1473
|
+
modLines.push(`pub use ${rustSnake(safeName)}::${rustPascal(safeName)}ResourceSync;`)
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
modLines.push(``)
|
|
1477
|
+
files.set("src/resources/mod.rs", modLines.join("\n"))
|
|
1478
|
+
|
|
1479
|
+
/* ---- recursive resource file emitter ---- */
|
|
1480
|
+
function emitNsFile(ns: IRNamespace, path: string[]): void {
|
|
1481
|
+
const safePath = path.map((s) => safeResourceName(s))
|
|
1482
|
+
const structName = `${safePath.map(rustPascal).join("")}Resource`
|
|
1483
|
+
const syncStructName = `${structName}Sync`
|
|
1484
|
+
|
|
1485
|
+
/* collect methods directly on this ns (not descendants) */
|
|
1486
|
+
const directMethods = methodsOf(ns).map(([, op]) => {
|
|
1487
|
+
const m = allMethods.find((x) => x.opId === op.id)
|
|
1488
|
+
if (!m) throw new Error(`missing MethodInfo for ${op.id}`)
|
|
1489
|
+
return m
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
const childSegs = namespacesOf(ns).map(([seg]) => seg)
|
|
1493
|
+
const hasChildren = childSegs.length > 0
|
|
1494
|
+
|
|
1495
|
+
const fileLines: string[] = [...emitRustResourceFileHeader(directMethods)]
|
|
1496
|
+
|
|
1497
|
+
/* helper: full Pascal struct name for a child at a given path */
|
|
1498
|
+
const childStructName = (childSeg: string): string =>
|
|
1499
|
+
`${[...path, childSeg].map((s) => rustPascal(safeResourceName(s))).join("")}Resource`
|
|
1500
|
+
|
|
1501
|
+
if (hasChildren) {
|
|
1502
|
+
/* sub-modules for child namespaces */
|
|
1503
|
+
for (const childSeg of childSegs) {
|
|
1504
|
+
fileLines.push(`pub mod ${rustSnake(safeResourceName(childSeg))};`)
|
|
1505
|
+
}
|
|
1506
|
+
/* re-export child resource types for convenience */
|
|
1507
|
+
for (const childSeg of childSegs) {
|
|
1508
|
+
const childSafe = safeResourceName(childSeg)
|
|
1509
|
+
fileLines.push(`pub use ${rustSnake(childSafe)}::${childStructName(childSeg)};`)
|
|
1510
|
+
}
|
|
1511
|
+
fileLines.push(``)
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/* async resource struct — includes child namespace accessor fields */
|
|
1515
|
+
fileLines.push(`pub struct ${structName} {`)
|
|
1516
|
+
fileLines.push(`\tpub(crate) client: std::sync::Arc<crate::client::ClientInner>,`)
|
|
1517
|
+
for (const childSeg of childSegs) {
|
|
1518
|
+
const childSafe = safeResourceName(childSeg)
|
|
1519
|
+
fileLines.push(`\tpub ${rustSnake(childSafe)}: ${childStructName(childSeg)},`)
|
|
1520
|
+
}
|
|
1521
|
+
fileLines.push(`}`)
|
|
1522
|
+
fileLines.push(``)
|
|
1523
|
+
|
|
1524
|
+
fileLines.push(`impl ${structName} {`)
|
|
1525
|
+
/* accessor methods for child namespaces */
|
|
1526
|
+
for (const childSeg of childSegs) {
|
|
1527
|
+
const childSafe = safeResourceName(childSeg)
|
|
1528
|
+
fileLines.push(`\tpub fn ${rustSnake(childSafe)}(&self) -> &${childStructName(childSeg)} {`)
|
|
1529
|
+
fileLines.push(`\t\t&self.${rustSnake(childSafe)}`)
|
|
1530
|
+
fileLines.push(`\t}`)
|
|
1531
|
+
fileLines.push(``)
|
|
1532
|
+
}
|
|
1533
|
+
/* direct methods */
|
|
1534
|
+
for (const m of directMethods.sort((a, b) => a.methodAction.localeCompare(b.methodAction))) {
|
|
1535
|
+
fileLines.push(...emitMethod(m, "self.client", throwOnError).map((l) => `\t${l}`))
|
|
1536
|
+
}
|
|
1537
|
+
fileLines.push(`}`)
|
|
1538
|
+
fileLines.push(``)
|
|
1539
|
+
|
|
1540
|
+
/* constructor free-fn used by Client::new / parent resource constructors */
|
|
1541
|
+
const constructorFn = `new_${safePath.map(rustSnake).join("_")}_resource`
|
|
1542
|
+
fileLines.push(`pub(crate) fn ${constructorFn}(client: &std::sync::Arc<crate::client::ClientInner>) -> ${structName} {`)
|
|
1543
|
+
fileLines.push(`\t${structName} {`)
|
|
1544
|
+
fileLines.push(`\t\tclient: std::sync::Arc::clone(client),`)
|
|
1545
|
+
for (const childSeg of childSegs) {
|
|
1546
|
+
const childSafe = safeResourceName(childSeg)
|
|
1547
|
+
const childConstructor = `${rustSnake(childSafe)}::new_${[...safePath.map(rustSnake), rustSnake(childSafe)].join("_")}_resource`
|
|
1548
|
+
fileLines.push(`\t\t${rustSnake(childSafe)}: ${childConstructor}(client),`)
|
|
1549
|
+
}
|
|
1550
|
+
fileLines.push(`\t}`)
|
|
1551
|
+
fileLines.push(`}`)
|
|
1552
|
+
fileLines.push(``)
|
|
1553
|
+
|
|
1554
|
+
/* sync struct — only when direct non-SSE/non-WS methods exist */
|
|
1555
|
+
const directSyncMethods = directMethods.filter((m) => !m.isSSE && !m.isWS)
|
|
1556
|
+
if (directSyncMethods.length > 0) {
|
|
1557
|
+
fileLines.push(`pub struct ${syncStructName} {`)
|
|
1558
|
+
fileLines.push(`\tpub(crate) client: std::sync::Arc<crate::client::SyncClientInner>,`)
|
|
1559
|
+
fileLines.push(`}`)
|
|
1560
|
+
fileLines.push(``)
|
|
1561
|
+
fileLines.push(`impl ${syncStructName} {`)
|
|
1562
|
+
for (const m of directSyncMethods.sort((a, b) => a.methodAction.localeCompare(b.methodAction))) {
|
|
1563
|
+
fileLines.push(...emitSyncMethod(m, "self.client", throwOnError).map((l) => `\t${l}`))
|
|
1564
|
+
}
|
|
1565
|
+
fileLines.push(`}`)
|
|
1566
|
+
fileLines.push(``)
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/* opts structs */
|
|
1570
|
+
const optsEmitted = new Set<string>()
|
|
1571
|
+
for (const m of directMethods.sort((a, b) => a.opId.localeCompare(b.opId))) {
|
|
1572
|
+
const optsName = optsStructName(m)
|
|
1573
|
+
if (optsEmitted.has(optsName)) continue
|
|
1574
|
+
optsEmitted.add(optsName)
|
|
1575
|
+
fileLines.push(...emitOptsStruct(m))
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/* per-op error envelopes */
|
|
1579
|
+
const errEmitted = new Set<string>()
|
|
1580
|
+
for (const m of directMethods.sort((a, b) => a.opId.localeCompare(b.opId))) {
|
|
1581
|
+
if (errEmitted.has(m.opId)) continue
|
|
1582
|
+
errEmitted.add(m.opId)
|
|
1583
|
+
fileLines.push(...emitOperationErrorTypes(m.opId, m.op))
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/* determine file path: namespace with children → dir/mod.rs, leaf → dir.rs */
|
|
1587
|
+
const dirPath = safePath.map(rustSnake).join("/")
|
|
1588
|
+
const filePath = hasChildren
|
|
1589
|
+
? `src/resources/${dirPath}/mod.rs`
|
|
1590
|
+
: `src/resources/${dirPath}.rs`
|
|
1591
|
+
files.set(filePath, fileLines.join("\n"))
|
|
1592
|
+
|
|
1593
|
+
/* recurse into children */
|
|
1594
|
+
for (const [childSeg, childNs] of namespacesOf(ns)) {
|
|
1595
|
+
emitNsFile(childNs, [...path, childSeg])
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
for (const [seg, ns] of namespacesOf(ir.tree)) {
|
|
1600
|
+
emitNsFile(ns, [seg])
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
return files
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
export function generateRustSDK(
|
|
1607
|
+
spec: Record<string, unknown>,
|
|
1608
|
+
options: RustSDKOptions = {},
|
|
1609
|
+
): GeneratedRustSDK {
|
|
1610
|
+
const crateName = validateCrateName(options.crateName ?? "honey-sdk")
|
|
1611
|
+
|
|
1612
|
+
/* toIR validates operationId collisions — throws before any codegen runs */
|
|
1613
|
+
const ir = toIR(spec as Parameters<typeof toIR>[0])
|
|
1614
|
+
|
|
1615
|
+
const { serviceMap } = collectSDKMethods(spec as Parameters<typeof collectSDKMethods>[0])
|
|
1616
|
+
|
|
1617
|
+
/* detect whether any operation uses x-realtime */
|
|
1618
|
+
const hasRealtime = collectMethodsFromIR(ir.tree, spec as unknown as OpenApiSpec).some((m) => m.isRealtime)
|
|
1619
|
+
|
|
1620
|
+
const files: Record<string, string> = {}
|
|
1621
|
+
|
|
1622
|
+
/* copy static runtime files; realtime.rs is conditional; runtime_sync + invalidation_sync always */
|
|
1623
|
+
for (const [name, content] of loadRustRuntimeTemplates()) {
|
|
1624
|
+
if (name === "realtime.rs") {
|
|
1625
|
+
if (hasRealtime) files[`src/${name}`] = content
|
|
1626
|
+
} else {
|
|
1627
|
+
files[`src/${name}`] = content
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/* types.rs uses unresolved spec — resolveRefs strips components.schemas */
|
|
1632
|
+
files["src/types.rs"] = buildRustTypes(spec as unknown as OpenApiSpec)
|
|
1633
|
+
files["src/client.rs"] = buildRustClient(spec as unknown as OpenApiSpec, options, ir)
|
|
1634
|
+
files["src/lib.rs"] = buildLibRs(hasRealtime)
|
|
1635
|
+
files["Cargo.toml"] = buildCargoToml(crateName, {
|
|
1636
|
+
version: options.version ?? "0.1.0",
|
|
1637
|
+
description: options.description,
|
|
1638
|
+
homepage: options.homepage,
|
|
1639
|
+
repository: options.repository,
|
|
1640
|
+
license: options.license,
|
|
1641
|
+
})
|
|
1642
|
+
|
|
1643
|
+
for (const [relPath, content] of buildRustResources(spec as unknown as OpenApiSpec, options, ir)) {
|
|
1644
|
+
files[relPath] = content
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
if (!files["src/resources/mod.rs"]) {
|
|
1648
|
+
files["src/resources/mod.rs"] = `/* Code generated by honey. DO NOT EDIT. */\n`
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
return { files, serviceMap }
|
|
1652
|
+
}
|