@workos/oagen-emitters 0.11.0 → 0.12.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.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Heuristics for spotting fields that hold secrets. The Rust emitter wraps
3
+ * such fields in `crate::SecretString` so their `Debug` representation does
4
+ * not accidentally leak the value.
5
+ *
6
+ * The list is intentionally conservative — only fields whose names strongly
7
+ * imply a credential or token are redacted. Generic names like `value` are
8
+ * skipped; the cost of a leaked secret is high enough that we'd rather miss
9
+ * the occasional secret than redact non-secret data and surprise users.
10
+ */
11
+ const EXACT_NAMES = new Set<string>([
12
+ 'password',
13
+ 'new_password',
14
+ 'old_password',
15
+ 'password_hash',
16
+ 'secret',
17
+ 'client_secret',
18
+ 'signing_secret',
19
+ 'webhook_secret',
20
+ 'token',
21
+ 'access_token',
22
+ 'refresh_token',
23
+ 'id_token',
24
+ 'session_token',
25
+ 'authentication_token',
26
+ 'pending_authentication_token',
27
+ 'invitation_token',
28
+ 'private_key',
29
+ 'pem_private_key',
30
+ 'data_key',
31
+ 'encrypted_keys',
32
+ 'encrypted_data_key',
33
+ 'shared_secret',
34
+ 'totp_secret',
35
+ 'jwt',
36
+ ]);
37
+
38
+ /** True when the field name strongly implies it holds a secret value. */
39
+ export function isSensitiveFieldName(name: string): boolean {
40
+ const norm = name.toLowerCase().replace(/-/g, '_');
41
+ if (EXACT_NAMES.has(norm)) return true;
42
+ // Common suffix forms: `*_token`, `*_secret`, `*_password`, `*_api_key`.
43
+ if (/_password(_hash)?$/.test(norm)) return true;
44
+ if (norm.endsWith('_secret')) return true;
45
+ if (norm.endsWith('_token') && norm !== 'csrf_token' && norm !== 'request_token') return true;
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * If `rustType` is `String` or `Option<String>` and `fieldName` looks
51
+ * sensitive, return the redacted equivalent (`crate::SecretString` or
52
+ * `Option<crate::SecretString>`). Otherwise return `rustType` unchanged.
53
+ */
54
+ export function applySecretRedaction(rustType: string, fieldName: string): string {
55
+ if (!isSensitiveFieldName(fieldName)) return rustType;
56
+ if (rustType === 'String') return 'crate::SecretString';
57
+ if (rustType === 'Option<String>') return 'Option<crate::SecretString>';
58
+ return rustType;
59
+ }
@@ -0,0 +1,298 @@
1
+ import type {
2
+ ApiSpec,
3
+ EmitterContext,
4
+ Enum,
5
+ GeneratedFile,
6
+ Model,
7
+ Operation,
8
+ ResolvedOperation,
9
+ ResolvedWrapper,
10
+ TypeRef,
11
+ } from '@workos/oagen';
12
+ import { methodName, moduleName, typeName } from './naming.js';
13
+ import { groupByMount } from '../shared/resolved-ops.js';
14
+ import { exampleFor, generateFixtures } from './fixtures.js';
15
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
16
+
17
+ /**
18
+ * Generate integration tests under `tests/`. Each mount group gets one
19
+ * `tests/{mount}_test.rs` file. Generated tests construct params, mock the
20
+ * expected request, then call the SDK method and assert the request was sent
21
+ * (`Mock::expect(1)`). JSON fixtures are emitted alongside.
22
+ */
23
+ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
24
+ const files: GeneratedFile[] = [];
25
+
26
+ files.push(...generateFixtures(spec));
27
+
28
+ files.push({
29
+ path: 'tests/common/mod.rs',
30
+ content: renderCommon(ctx),
31
+ overwriteExisting: true,
32
+ });
33
+
34
+ const groups = groupByMount(ctx);
35
+ const modelMap = new Map(spec.models.map((m) => [m.name, m]));
36
+ const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
37
+
38
+ for (const [mountName, group] of groups) {
39
+ if (group.operations.length === 0) continue;
40
+ files.push({
41
+ path: `tests/${moduleName(mountName)}_test.rs`,
42
+ content: renderMountTest(mountName, group.resolvedOps, ctx, modelMap, enumMap),
43
+ overwriteExisting: true,
44
+ });
45
+ }
46
+
47
+ return files;
48
+ }
49
+
50
+ function renderCommon(ctx: EmitterContext): string {
51
+ const crate = crateName(ctx);
52
+ const imports = [
53
+ { path: 'wiremock::MockServer', sort: 'wiremock::MockServer' },
54
+ { path: `${crate}::Client`, sort: `${crate}::Client` },
55
+ ].sort((a, b) => a.sort.localeCompare(b.sort));
56
+
57
+ const useLines = imports.map((i) => `use ${i.path};`).join('\n');
58
+
59
+ return `#![allow(dead_code)]
60
+
61
+ ${useLines}
62
+
63
+ pub async fn test_client(server: &MockServer) -> Client {
64
+ Client::builder()
65
+ .api_key("test_api_key")
66
+ .base_url(server.uri())
67
+ .max_retries(0)
68
+ .build()
69
+ }
70
+ `;
71
+ }
72
+
73
+ function renderMountTest(
74
+ mountName: string,
75
+ resolvedOps: ResolvedOperation[],
76
+ ctx: EmitterContext,
77
+ modelMap: Map<string, Model>,
78
+ enumMap: Map<string, Enum>,
79
+ ): string {
80
+ const accessor = moduleName(mountName);
81
+ const crate = crateName(ctx);
82
+ const lines: string[] = [];
83
+ lines.push('mod common;');
84
+ lines.push('');
85
+ lines.push('use wiremock::matchers::{method, path as path_matcher};');
86
+ lines.push('use wiremock::{Mock, MockServer, ResponseTemplate};');
87
+ lines.push('');
88
+
89
+ const seen = new Set<string>();
90
+
91
+ for (const r of resolvedOps) {
92
+ const op = r.operation;
93
+ if ((r.wrappers?.length ?? 0) > 0) {
94
+ for (const w of r.wrappers!) {
95
+ const m = methodName(w.name);
96
+ if (seen.has(m)) continue;
97
+ seen.add(m);
98
+ lines.push(...renderWrapperTest(op, w, ctx, accessor, crate, modelMap, enumMap));
99
+ lines.push('');
100
+ }
101
+ continue;
102
+ }
103
+ const m = methodName(r.methodName);
104
+ if (seen.has(m)) continue;
105
+ seen.add(m);
106
+ lines.push(...renderRegularTest(op, r, accessor, crate, modelMap, enumMap));
107
+ lines.push('');
108
+ }
109
+
110
+ // Trim a trailing blank line.
111
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
112
+
113
+ return lines.join('\n') + '\n';
114
+ }
115
+
116
+ /** Test for a non-wrapper operation. */
117
+ function renderRegularTest(
118
+ op: Operation,
119
+ resolved: ResolvedOperation,
120
+ accessor: string,
121
+ crate: string,
122
+ modelMap: Map<string, Model>,
123
+ enumMap: Map<string, Enum>,
124
+ ): string[] {
125
+ const m = methodName(resolved.methodName);
126
+ const literalPath = op.path.replace(/\{[^}]+\}/g, 'test_id');
127
+ const httpMethod = op.httpMethod.toUpperCase();
128
+ const responseExpr = responseBodyExpr(op.response, modelMap, enumMap);
129
+
130
+ const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
131
+ const visibleQuery = op.queryParams.filter((p) => !hidden.has(p.name));
132
+ const visibleHeader = op.headerParams.filter((p) => !hidden.has(p.name));
133
+ const visibleParams = [...visibleQuery, ...visibleHeader];
134
+ const requiredParams = visibleParams.filter((p) => p.required);
135
+ const hasBody = op.requestBody !== undefined;
136
+ const bodyRequired = hasBody && op.requestBody!.kind !== 'nullable';
137
+ const emptyParams = !hasBody && visibleParams.length === 0;
138
+
139
+ const callArgs: string[] = [];
140
+ for (const _ of op.pathParams) callArgs.push('"test_id"');
141
+
142
+ if (!emptyParams) {
143
+ const paramsType = `${crate}::${accessor}::${typeName(resolved.methodName)}Params`;
144
+ if (requiredParams.length === 0 && !bodyRequired) {
145
+ callArgs.push(`${paramsType}::default()`);
146
+ } else {
147
+ const ctorArgs: string[] = [];
148
+ for (const p of requiredParams) {
149
+ ctorArgs.push(stubExpr(p.type, p.name, modelMap, enumMap));
150
+ }
151
+ if (hasBody) {
152
+ if (bodyRequired) {
153
+ ctorArgs.push(stubExpr(op.requestBody!, 'body', modelMap, enumMap));
154
+ }
155
+ }
156
+ callArgs.push(`${paramsType}::new(${ctorArgs.join(', ')})`);
157
+ }
158
+ }
159
+
160
+ const lines: string[] = [];
161
+ lines.push('#[tokio::test]');
162
+ lines.push(`async fn ${accessor}_${m}_round_trip() {`);
163
+ lines.push(' let server = MockServer::start().await;');
164
+ lines.push(` Mock::given(method(${JSON.stringify(httpMethod)}))`);
165
+ lines.push(` .and(path_matcher(${JSON.stringify(literalPath)}))`);
166
+ lines.push(` .respond_with(ResponseTemplate::new(200).set_body_string(${responseExpr}))`);
167
+ lines.push(' .expect(1)');
168
+ lines.push(' .mount(&server)');
169
+ lines.push(' .await;');
170
+ lines.push(' let client = common::test_client(&server).await;');
171
+ lines.push(` let _ = client.${accessor}().${m}(${callArgs.join(', ')}).await;`);
172
+ // wiremock asserts on drop that the `.expect(1)` mock was matched once;
173
+ // mismatched method/path produces a panic at end-of-test. We deliberately
174
+ // ignore the deserialised response: stub fixtures cover the common path
175
+ // (typed model deserialisation), but discriminated-union responses can't
176
+ // always be reproduced from a generated fixture without bespoke schema
177
+ // awareness, so a strict `is_ok()` would over-trigger.
178
+ lines.push('}');
179
+ return lines;
180
+ }
181
+
182
+ /** Test for a wrapper-method operation. */
183
+ function renderWrapperTest(
184
+ op: Operation,
185
+ wrapper: ResolvedWrapper,
186
+ ctx: EmitterContext,
187
+ accessor: string,
188
+ crate: string,
189
+ modelMap: Map<string, Model>,
190
+ enumMap: Map<string, Enum>,
191
+ ): string[] {
192
+ const m = methodName(wrapper.name);
193
+ const literalPath = op.path.replace(/\{[^}]+\}/g, 'test_id');
194
+ const httpMethod = op.httpMethod.toUpperCase();
195
+ // Wrapper response is the wrapper's responseModelName (or the operation's
196
+ // declared response when none is overridden).
197
+ const responseExpr = wrapper.responseModelName
198
+ ? responseBodyExpr({ kind: 'model', name: wrapper.responseModelName }, modelMap, enumMap)
199
+ : responseBodyExpr(op.response, modelMap, enumMap);
200
+
201
+ const params = resolveWrapperParams(wrapper, ctx);
202
+ const callArgs: string[] = [];
203
+ for (const _ of op.pathParams) callArgs.push('"test_id"');
204
+
205
+ const paramsType = `${crate}::${accessor}::${typeName(wrapper.name)}Params`;
206
+ const requiredParams = params.filter((rp) => !rp.isOptional);
207
+
208
+ if (requiredParams.length === 0) {
209
+ callArgs.push(`${paramsType}::default()`);
210
+ } else {
211
+ const ctorArgs = requiredParams.map((rp) => {
212
+ if (!rp.field) return `"stub_${rp.paramName}".to_string()`;
213
+ return stubExpr(rp.field.type, rp.paramName, modelMap, enumMap);
214
+ });
215
+ callArgs.push(`${paramsType}::new(${ctorArgs.join(', ')})`);
216
+ }
217
+
218
+ const lines: string[] = [];
219
+ lines.push('#[tokio::test]');
220
+ lines.push(`async fn ${accessor}_${m}_round_trip() {`);
221
+ lines.push(' let server = MockServer::start().await;');
222
+ lines.push(` Mock::given(method(${JSON.stringify(httpMethod)}))`);
223
+ lines.push(` .and(path_matcher(${JSON.stringify(literalPath)}))`);
224
+ lines.push(` .respond_with(ResponseTemplate::new(200).set_body_string(${responseExpr}))`);
225
+ lines.push(' .expect(1)');
226
+ lines.push(' .mount(&server)');
227
+ lines.push(' .await;');
228
+ lines.push(' let client = common::test_client(&server).await;');
229
+ lines.push(` let _ = client.${accessor}().${m}(${callArgs.join(', ')}).await;`);
230
+ // Drop assertion on `Mock::expect(1)` validates path/method.
231
+ lines.push('}');
232
+ return lines;
233
+ }
234
+
235
+ /** Rust string-expression for the mock response body. */
236
+ function responseBodyExpr(ref: TypeRef | undefined, modelMap: Map<string, Model>, enumMap: Map<string, Enum>): string {
237
+ if (!ref) return JSON.stringify('{}');
238
+ if (ref.kind === 'primitive' && ref.type === 'unknown') return JSON.stringify('{}');
239
+ if (ref.kind === 'model') {
240
+ const m = modelMap.get(ref.name);
241
+ if (!m || m.fields.length === 0 || m.fields.every((f) => !f.required)) {
242
+ return JSON.stringify('{}');
243
+ }
244
+ return modelFixtureExpr(ref.name);
245
+ }
246
+ if (ref.kind === 'nullable') return responseBodyExpr(ref.inner, modelMap, enumMap);
247
+ // For arrays/primitives/maps/enums/literals/unions, synthesise inline JSON.
248
+ const example = exampleFor(ref, modelMap, enumMap, new Set(), 'value');
249
+ return JSON.stringify(JSON.stringify(example));
250
+ }
251
+
252
+ /** `include_str!("fixtures/<snake>.json")` for a model name. */
253
+ function modelFixtureExpr(name: string): string {
254
+ return `include_str!(${JSON.stringify(`fixtures/${moduleName(name)}.json`)})`;
255
+ }
256
+
257
+ /**
258
+ * Rust expression for an instance of `type` that satisfies its declared shape.
259
+ * Used to construct required constructor arguments at test-build time.
260
+ *
261
+ * For models we deserialize a JSON fixture; for everything else we synthesise
262
+ * a small example with `serde_json::from_str`. `String` is the one exception:
263
+ * the generator's `new(...)` constructor takes `impl Into<String>`, so we
264
+ * pass a string literal directly to keep type inference happy.
265
+ */
266
+ function stubExpr(ref: TypeRef, hint: string, modelMap: Map<string, Model>, enumMap: Map<string, Enum>): string {
267
+ // Strings: emit `"stub_x".to_string()`. Works in struct-literal contexts
268
+ // (which need `String`) as well as `new(...)` constructors that take
269
+ // `impl Into<String>`. Avoiding `from_str` keeps type inference simple.
270
+ if (ref.kind === 'primitive' && ref.type === 'string') {
271
+ return `${JSON.stringify(`stub_${hint}`)}.to_string()`;
272
+ }
273
+ if (ref.kind === 'nullable') return stubExpr(ref.inner, hint, modelMap, enumMap);
274
+ if (ref.kind === 'model') {
275
+ // Fixture generator skips models with no required fields; fall back to
276
+ // an inline `{}` so the test still compiles.
277
+ const m = modelMap.get(ref.name);
278
+ if (!m || m.fields.length === 0 || m.fields.every((f) => !f.required)) {
279
+ return `serde_json::from_str("{}").expect("parse stub for ${ref.name}")`;
280
+ }
281
+ return `serde_json::from_str(${modelFixtureExpr(ref.name)}).expect("parse fixture for ${ref.name}")`;
282
+ }
283
+ // For other shapes, JSON-serialise an example value and deserialise at
284
+ // runtime. The generator's constructors fully specify each parameter type,
285
+ // so type inference flows from the constructor signature back into
286
+ // `serde_json::from_str`.
287
+ const example = exampleFor(ref, modelMap, enumMap, new Set(), hint);
288
+ const json = JSON.stringify(example);
289
+ return `serde_json::from_str(${JSON.stringify(json)}).expect("parse stub")`;
290
+ }
291
+
292
+ function crateName(ctx: EmitterContext): string {
293
+ // Cargo crate names are conventionally lowercase with no separators (e.g.
294
+ // `workos`). The IR's snake-cased namespace ("work_os") inserts an
295
+ // underscore around the "os" acronym, so derive from `namespacePascal` —
296
+ // the verbatim user-supplied namespace — and lowercase it.
297
+ return ctx.namespacePascal.toLowerCase().replace(/[^a-z0-9]/g, '');
298
+ }
@@ -0,0 +1,225 @@
1
+ import type { TypeRef, UnionType } from '@workos/oagen';
2
+ import { typeName } from './naming.js';
3
+
4
+ /**
5
+ * Lightweight registry that synthesises a named Rust enum for every IR union
6
+ * encountered in field positions. Models reference the synthesised name; the
7
+ * generator emits the enum bodies into a separate module so the crate stays
8
+ * self-contained.
9
+ */
10
+ export class UnionRegistry {
11
+ private byKey = new Map<
12
+ string,
13
+ { name: string; tag?: string; arms: { name: string; type: string; rename?: string }[] }
14
+ >();
15
+ private hintCounts = new Map<string, number>();
16
+
17
+ /** Total number of registered unions (used by callers to skip emit). */
18
+ size(): number {
19
+ return this.byKey.size;
20
+ }
21
+
22
+ /** Drop all collected unions. Call at the start of every emit run. */
23
+ reset(): void {
24
+ this.byKey.clear();
25
+ this.hintCounts.clear();
26
+ }
27
+
28
+ /**
29
+ * Register a union and return the Rust type name to reference. Unions with
30
+ * identical structure (same variants + discriminator) are deduplicated.
31
+ */
32
+ register(union: UnionType, hint: string): string {
33
+ const variants = arms(union);
34
+
35
+ // Apply discriminator mapping (`{spec-value: '#/components/schemas/Foo'}`)
36
+ // as `#[serde(rename = "spec-value")]` on the matching variant. Without
37
+ // this, serde's tagged-union deserialization expects the discriminator
38
+ // value to literally equal the Rust variant name, which it never does
39
+ // when the OAS uses snake/kebab-case spec-side keys.
40
+ const mapping = union.discriminator?.mapping ?? {};
41
+ const inverseMapping = new Map<string, string>();
42
+ for (const [specKey, ref] of Object.entries(mapping)) {
43
+ const schemaName = ref.replace(/^#\/components\/schemas\//, '');
44
+ // Last value wins — matches spec semantics where the final mapping for
45
+ // a schema is canonical.
46
+ inverseMapping.set(typeName(schemaName), specKey);
47
+ }
48
+ const armsWithRename: { name: string; type: string; rename?: string }[] = variants.map((v) => {
49
+ const rename = inverseMapping.get(v.name);
50
+ return rename !== undefined ? { ...v, rename } : v;
51
+ });
52
+
53
+ const key = JSON.stringify({
54
+ variants: armsWithRename.map((v) => ({ type: v.type, rename: v.rename ?? null })),
55
+ discriminator: union.discriminator?.property ?? null,
56
+ mapping: union.discriminator?.mapping ?? null,
57
+ });
58
+ const existing = this.byKey.get(key);
59
+ if (existing) return existing.name;
60
+
61
+ const name = this.uniqueName(`${typeName(hint)}OneOf`);
62
+ this.byKey.set(key, {
63
+ name,
64
+ tag: union.discriminator?.property,
65
+ arms: armsWithRename,
66
+ });
67
+ return name;
68
+ }
69
+
70
+ /**
71
+ * Render every registered union as a Rust source file (including a leading
72
+ * `use serde::...;` line).
73
+ */
74
+ render(): string {
75
+ if (this.byKey.size === 0) return '';
76
+ const blocks: string[] = [];
77
+ blocks.push('#[allow(unused_imports)]');
78
+ blocks.push('use super::*;');
79
+ blocks.push('#[allow(unused_imports)]');
80
+ blocks.push('use crate::enums::*;');
81
+ blocks.push('use serde::{Deserialize, Serialize};');
82
+ blocks.push('');
83
+
84
+ for (const u of this.byKey.values()) {
85
+ blocks.push('#[derive(Debug, Clone, Serialize, Deserialize)]');
86
+ if (u.tag) {
87
+ blocks.push(`#[serde(tag = ${JSON.stringify(u.tag)})]`);
88
+ } else {
89
+ blocks.push('#[serde(untagged)]');
90
+ }
91
+ blocks.push(`pub enum ${u.name} {`);
92
+ for (const a of u.arms) {
93
+ if (a.rename !== undefined) {
94
+ blocks.push(` #[serde(rename = ${JSON.stringify(a.rename)})]`);
95
+ }
96
+ const single = ` ${a.name}(${a.type}),`;
97
+ if (single.length <= 100) {
98
+ blocks.push(single);
99
+ } else {
100
+ // Match rustfmt's break-shape for over-long tuple variants:
101
+ // Variant(
102
+ // LongType,
103
+ // ),
104
+ blocks.push(` ${a.name}(`);
105
+ blocks.push(` ${a.type},`);
106
+ blocks.push(' ),');
107
+ }
108
+ }
109
+ blocks.push('}');
110
+ blocks.push('');
111
+ }
112
+
113
+ return blocks.join('\n').replace(/\n+$/g, '\n');
114
+ }
115
+
116
+ private uniqueName(base: string): string {
117
+ const taken = new Set(Array.from(this.byKey.values()).map((u) => u.name));
118
+ if (!taken.has(base)) return base;
119
+ const n = (this.hintCounts.get(base) ?? 1) + 1;
120
+ this.hintCounts.set(base, n);
121
+ return `${base}${n}`;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Map an IR `TypeRef` to a Rust type expression (e.g., `String`, `Vec<u32>`,
127
+ * `Option<HashMap<String, serde_json::Value>>`).
128
+ *
129
+ * The caller decides whether to wrap the result in `Option<...>` based on the
130
+ * field's `required` flag — `mapTypeRef` itself only emits `Option` for the
131
+ * `nullable` IR variant.
132
+ *
133
+ * When `ctx.registry` is supplied, encountered unions are registered as
134
+ * synthesised Rust enums (using `ctx.hint` to name the generated type); when
135
+ * no registry is provided, unions degrade to `serde_json::Value`.
136
+ */
137
+ export function mapTypeRef(ref: TypeRef, ctx?: { hint?: string; registry?: UnionRegistry }): string {
138
+ switch (ref.kind) {
139
+ case 'primitive':
140
+ return primitiveType(ref.type, ref.format);
141
+ case 'array':
142
+ return `Vec<${mapTypeRef(ref.items, ctx)}>`;
143
+ case 'model':
144
+ return typeName(ref.name);
145
+ case 'enum':
146
+ return typeName(ref.name);
147
+ case 'nullable':
148
+ return `Option<${mapTypeRef(ref.inner, ctx)}>`;
149
+ case 'literal':
150
+ return literalType(ref.value);
151
+ case 'map':
152
+ return `std::collections::HashMap<String, ${mapTypeRef(ref.valueType, ctx)}>`;
153
+ case 'union': {
154
+ const variants = ref.variants;
155
+ const mapped = variants.map((v) => mapTypeRef(v, ctx));
156
+ const unique = Array.from(new Set(mapped));
157
+ if (unique.length === 1) return unique[0]!;
158
+ if (ctx?.registry && ctx.hint) {
159
+ return ctx.registry.register(ref, ctx.hint);
160
+ }
161
+ return 'serde_json::Value';
162
+ }
163
+ }
164
+ }
165
+
166
+ /** Variants for the registry: each gets a Rust variant name + a payload type. */
167
+ function arms(union: UnionType): { name: string; type: string }[] {
168
+ const seen = new Set<string>();
169
+ const out: { name: string; type: string }[] = [];
170
+ for (const v of union.variants) {
171
+ const t = mapTypeRef(v); // No registry — variants must already be named.
172
+ const armName = variantArmName(v, t);
173
+ let unique = armName;
174
+ let n = 1;
175
+ while (seen.has(unique)) {
176
+ n += 1;
177
+ unique = `${armName}${n}`;
178
+ }
179
+ seen.add(unique);
180
+ out.push({ name: unique, type: t });
181
+ }
182
+ return out;
183
+ }
184
+
185
+ function variantArmName(ref: TypeRef, mappedType: string): string {
186
+ if (ref.kind === 'model' || ref.kind === 'enum') return typeName(ref.name);
187
+ // Strip generics for arm naming and PascalCase.
188
+ const base = mappedType.replace(/[<>:,\s]/g, '');
189
+ return typeName(base);
190
+ }
191
+
192
+ /**
193
+ * Wrap a type in `Option<...>` if not already optional. Used for non-required
194
+ * fields where the IR did not produce a `nullable` wrapper.
195
+ */
196
+ export function makeOptional(rustType: string): string {
197
+ if (rustType.startsWith('Option<')) return rustType;
198
+ return `Option<${rustType}>`;
199
+ }
200
+
201
+ function primitiveType(type: 'string' | 'integer' | 'number' | 'boolean' | 'unknown', format?: string): string {
202
+ switch (type) {
203
+ case 'string':
204
+ if (format === 'binary') return 'Vec<u8>';
205
+ return 'String';
206
+ case 'integer':
207
+ if (format === 'int32') return 'i32';
208
+ return 'i64';
209
+ case 'number':
210
+ if (format === 'float') return 'f32';
211
+ return 'f64';
212
+ case 'boolean':
213
+ return 'bool';
214
+ case 'unknown':
215
+ return 'serde_json::Value';
216
+ }
217
+ }
218
+
219
+ function literalType(value: string | number | boolean | null): string {
220
+ if (value === null) return 'serde_json::Value';
221
+ if (typeof value === 'string') return 'String';
222
+ if (typeof value === 'number') return Number.isInteger(value) ? 'i64' : 'f64';
223
+ if (typeof value === 'boolean') return 'bool';
224
+ return 'serde_json::Value';
225
+ }
@@ -60,6 +60,7 @@ describe('public entrypoint (@workos/oagen-emitters)', () => {
60
60
  mod.dotnetEmitter,
61
61
  mod.kotlinEmitter,
62
62
  mod.rubyEmitter,
63
+ mod.rustEmitter,
63
64
  ];
64
65
  for (const emitter of directEmitters) {
65
66
  expect(pluginLanguages).toContain(emitter.language);
@@ -11,7 +11,8 @@ describe('workosEmittersPlugin', () => {
11
11
  expect(languages).toContain('dotnet');
12
12
  expect(languages).toContain('kotlin');
13
13
  expect(languages).toContain('ruby');
14
- expect(languages).toHaveLength(7);
14
+ expect(languages).toContain('rust');
15
+ expect(languages).toHaveLength(8);
15
16
  });
16
17
 
17
18
  it('exports extractors for all supported languages', () => {
@@ -83,8 +83,8 @@ describe('generateResources', () => {
83
83
  // GET method with path param
84
84
  expect(content).toContain('def get_organization(');
85
85
  expect(content).toContain('id: str,');
86
- expect(content).toContain(`f"organizations/{quote(str(id), safe='')}"`);
87
- expect(content).toContain('from urllib.parse import quote');
86
+ expect(content).toContain(`("organizations", str(id))`);
87
+ expect(content).not.toContain('from urllib.parse import quote');
88
88
  expect(content).toContain('model=Organization');
89
89
  // Public request methods (no underscore prefix)
90
90
  expect(content).toContain('self._client.request(');
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateClient } from '../../src/rust/client.js';
5
+ import { generateModels } from '../../src/rust/models.js';
6
+ import { UnionRegistry } from '../../src/rust/type-map.js';
7
+
8
+ function makeCtx(spec: ApiSpec): EmitterContext {
9
+ return { namespace: 'workos', namespacePascal: 'WorkOS', spec };
10
+ }
11
+
12
+ const emptySpec: ApiSpec = {
13
+ name: 'Test',
14
+ version: '1.0.0',
15
+ baseUrl: '',
16
+ services: [],
17
+ models: [],
18
+ enums: [],
19
+ sdk: defaultSdkBehavior(),
20
+ };
21
+
22
+ describe('rust/client', () => {
23
+ it('emits the unions module and an empty resources_api shell when there are no mount targets', () => {
24
+ const files = generateClient(emptySpec, makeCtx(emptySpec), new UnionRegistry());
25
+ expect(files.map((f) => f.path).sort()).toEqual(['src/models/_unions.rs', 'src/resources_api.rs']);
26
+ const unions = files.find((f) => f.path === 'src/models/_unions.rs')!;
27
+ expect(unions.content).toContain('No oneOf-style unions registered');
28
+ const api = files.find((f) => f.path === 'src/resources_api.rs')!;
29
+ expect(api.content).toContain('impl Client {');
30
+ });
31
+
32
+ it('renders unions registered earlier in the emit run', () => {
33
+ const registry = new UnionRegistry();
34
+ const models: Model[] = [
35
+ {
36
+ name: 'Event',
37
+ fields: [
38
+ {
39
+ name: 'payload',
40
+ type: {
41
+ kind: 'union',
42
+ variants: [
43
+ { kind: 'model', name: 'UserCreated' },
44
+ { kind: 'model', name: 'UserDeleted' },
45
+ ],
46
+ discriminator: {
47
+ property: 'event',
48
+ mapping: { 'user.created': 'UserCreated', 'user.deleted': 'UserDeleted' },
49
+ },
50
+ },
51
+ required: true,
52
+ },
53
+ ],
54
+ },
55
+ ];
56
+ generateModels(models, makeCtx(emptySpec), registry);
57
+ const files = generateClient(emptySpec, makeCtx(emptySpec), registry);
58
+ const unions = files.find((f) => f.path === 'src/models/_unions.rs')!;
59
+ expect(unions.content).toContain('#[serde(tag = "event")]');
60
+ expect(unions.content).toContain('pub enum EventPayloadOneOf {');
61
+ });
62
+ });