@workos/oagen-emitters 0.11.0 → 0.12.1
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +19 -0
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{plugin-DW3cnedr.mjs → plugin-CmfzawTp.mjs} +2851 -524
- package/dist/plugin-CmfzawTp.mjs.map +1 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/docs/sdk-architecture/rust.md +323 -0
- package/package.json +9 -9
- package/src/index.ts +1 -0
- package/src/plugin.ts +2 -1
- package/src/python/path-expression.ts +75 -26
- package/src/python/resources.ts +0 -9
- package/src/rust/client.ts +62 -0
- package/src/rust/enums.ts +201 -0
- package/src/rust/fixtures.ts +196 -0
- package/src/rust/index.ts +95 -0
- package/src/rust/manifest.ts +31 -0
- package/src/rust/models.ts +165 -0
- package/src/rust/naming.ts +131 -0
- package/src/rust/resources.ts +1324 -0
- package/src/rust/secret.ts +59 -0
- package/src/rust/tests.ts +818 -0
- package/src/rust/type-map.ts +225 -0
- package/test/entrypoint.test.ts +1 -0
- package/test/plugin.test.ts +2 -1
- package/test/python/resources.test.ts +2 -2
- package/test/rust/client.test.ts +62 -0
- package/test/rust/enums.test.ts +117 -0
- package/test/rust/fixtures.test.ts +227 -0
- package/test/rust/manifest.test.ts +73 -0
- package/test/rust/models.test.ts +177 -0
- package/test/rust/resources.test.ts +748 -0
- package/test/rust/tests.test.ts +504 -0
- package/test/rust/type-map.test.ts +83 -0
- package/dist/plugin-DW3cnedr.mjs.map +0 -1
|
@@ -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
|
+
}
|
package/test/entrypoint.test.ts
CHANGED
package/test/plugin.test.ts
CHANGED
|
@@ -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).
|
|
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(`
|
|
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
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { EmitterContext, ApiSpec, Enum } from '@workos/oagen';
|
|
3
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
|
+
import { generateEnums } from '../../src/rust/enums.js';
|
|
5
|
+
|
|
6
|
+
const emptySpec: ApiSpec = {
|
|
7
|
+
name: 'Test',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
baseUrl: '',
|
|
10
|
+
services: [],
|
|
11
|
+
models: [],
|
|
12
|
+
enums: [],
|
|
13
|
+
sdk: defaultSdkBehavior(),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ctx: EmitterContext = {
|
|
17
|
+
namespace: 'workos',
|
|
18
|
+
namespacePascal: 'WorkOS',
|
|
19
|
+
spec: emptySpec,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('rust/enums', () => {
|
|
23
|
+
it('emits a non_exhaustive enum with manual Serialize/Deserialize and a fallback variant', () => {
|
|
24
|
+
const enums: Enum[] = [
|
|
25
|
+
{
|
|
26
|
+
name: 'Status',
|
|
27
|
+
values: [
|
|
28
|
+
{ name: 'active', value: 'active' },
|
|
29
|
+
{ name: 'inactive', value: 'inactive' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
const files = generateEnums(enums, ctx);
|
|
34
|
+
const f = files.find((x) => x.path === 'src/enums/status.rs')!;
|
|
35
|
+
expect(f.content).toContain('pub enum Status {');
|
|
36
|
+
expect(f.content).toContain('Active,');
|
|
37
|
+
expect(f.content).toContain('Inactive,');
|
|
38
|
+
expect(f.content).toContain('#[non_exhaustive]');
|
|
39
|
+
expect(f.content).toContain('Unknown(String),');
|
|
40
|
+
expect(f.content).toContain('impl Serialize for Status');
|
|
41
|
+
expect(f.content).toContain("impl<'de> Deserialize<'de> for Status");
|
|
42
|
+
expect(f.content).toContain('Self::Active => "active"');
|
|
43
|
+
expect(f.content).toContain('Self::Inactive => "inactive"');
|
|
44
|
+
// No derive(Serialize/Deserialize) — they're hand-written.
|
|
45
|
+
expect(f.content).not.toContain('Serialize, Deserialize)]');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('round-trips non-snake-case wire values through canonical strings', () => {
|
|
49
|
+
const enums: Enum[] = [
|
|
50
|
+
{
|
|
51
|
+
name: 'Mode',
|
|
52
|
+
values: [
|
|
53
|
+
{ name: 'kebab-case', value: 'kebab-case' },
|
|
54
|
+
{ name: 'mixedCase', value: 'mixedCase' },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
const files = generateEnums(enums, ctx);
|
|
59
|
+
const f = files.find((x) => x.path === 'src/enums/mode.rs')!;
|
|
60
|
+
// FromStr matches the original wire string; as_str returns it back.
|
|
61
|
+
expect(f.content).toContain('"kebab-case" => Self::KebabCase');
|
|
62
|
+
expect(f.content).toContain('"mixedCase" => Self::MixedCase');
|
|
63
|
+
expect(f.content).toContain('Self::KebabCase => "kebab-case"');
|
|
64
|
+
expect(f.content).toContain('Self::MixedCase => "mixedCase"');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('collapses alias wire values into a single canonical variant', () => {
|
|
68
|
+
const enums: Enum[] = [
|
|
69
|
+
{
|
|
70
|
+
name: 'Trigger',
|
|
71
|
+
values: [
|
|
72
|
+
{ name: 'sign-up', value: 'sign-up' },
|
|
73
|
+
{ name: 'sign_up', value: 'sign_up' },
|
|
74
|
+
{ name: 'sign up', value: 'sign up' },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
const files = generateEnums(enums, ctx);
|
|
79
|
+
const f = files.find((x) => x.path === 'src/enums/trigger.rs')!;
|
|
80
|
+
// One Rust variant for all three aliases.
|
|
81
|
+
expect(f.content.match(/^\s+SignUp,$/m)).not.toBeNull();
|
|
82
|
+
// Canonical wire value is the first one seen.
|
|
83
|
+
expect(f.content).toContain('Self::SignUp => "sign-up"');
|
|
84
|
+
// Every alias deserializes into the same variant.
|
|
85
|
+
expect(f.content).toContain('"sign-up" => Self::SignUp');
|
|
86
|
+
expect(f.content).toContain('"sign_up" => Self::SignUp');
|
|
87
|
+
expect(f.content).toContain('"sign up" => Self::SignUp');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('falls back to a non-Unknown name when the spec defines an Unknown variant', () => {
|
|
91
|
+
const enums: Enum[] = [
|
|
92
|
+
{
|
|
93
|
+
name: 'State',
|
|
94
|
+
values: [
|
|
95
|
+
{ name: 'unknown', value: 'unknown' },
|
|
96
|
+
{ name: 'ready', value: 'ready' },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
const files = generateEnums(enums, ctx);
|
|
101
|
+
const f = files.find((x) => x.path === 'src/enums/state.rs')!;
|
|
102
|
+
expect(f.content).toContain('Unknown,');
|
|
103
|
+
expect(f.content).toContain('Unrecognized(String),');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('skips empty enums', () => {
|
|
107
|
+
const enums: Enum[] = [{ name: 'Empty', values: [] }];
|
|
108
|
+
const files = generateEnums(enums, ctx);
|
|
109
|
+
expect(files.find((f) => f.path === 'src/enums/empty.rs')).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('always emits a barrel even when no enums', () => {
|
|
113
|
+
const files = generateEnums([], ctx);
|
|
114
|
+
expect(files).toHaveLength(1);
|
|
115
|
+
expect(files[0]!.path).toBe('src/enums/mod.rs');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { ApiSpec, Enum, Model } from '@workos/oagen';
|
|
3
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
|
+
import { exampleFromSpec, generateFixtures, generateModelFixture } from '../../src/rust/fixtures.js';
|
|
5
|
+
|
|
6
|
+
function spec(models: Model[], enums: Enum[] = []): ApiSpec {
|
|
7
|
+
return {
|
|
8
|
+
name: 'Test',
|
|
9
|
+
version: '1.0.0',
|
|
10
|
+
baseUrl: '',
|
|
11
|
+
services: [],
|
|
12
|
+
models,
|
|
13
|
+
enums,
|
|
14
|
+
sdk: defaultSdkBehavior(),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('rust/fixtures', () => {
|
|
19
|
+
it('prefers a spec `example` over the generated placeholder for a primitive field', () => {
|
|
20
|
+
const models: Model[] = [
|
|
21
|
+
{
|
|
22
|
+
name: 'Event',
|
|
23
|
+
fields: [
|
|
24
|
+
{
|
|
25
|
+
name: 'id',
|
|
26
|
+
type: { kind: 'primitive', type: 'string' },
|
|
27
|
+
required: true,
|
|
28
|
+
example: 'event_01XXXX',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'created_at',
|
|
32
|
+
type: { kind: 'primitive', type: 'string', format: 'date-time' },
|
|
33
|
+
required: true,
|
|
34
|
+
example: '2026-02-02T16:35:39.317Z',
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
const files = generateFixtures(spec(models));
|
|
40
|
+
const file = files.find((f) => f.path === 'tests/fixtures/event.json')!;
|
|
41
|
+
expect(file).toBeDefined();
|
|
42
|
+
const parsed = JSON.parse(file.content);
|
|
43
|
+
expect(parsed.id).toBe('event_01XXXX');
|
|
44
|
+
expect(parsed.created_at).toBe('2026-02-02T16:35:39.317Z');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('falls back to the placeholder when the example shape does not match the type', () => {
|
|
48
|
+
const models: Model[] = [
|
|
49
|
+
{
|
|
50
|
+
name: 'Wrong',
|
|
51
|
+
fields: [
|
|
52
|
+
// Type is integer but example is a string — must fall back.
|
|
53
|
+
{
|
|
54
|
+
name: 'count',
|
|
55
|
+
type: { kind: 'primitive', type: 'integer' },
|
|
56
|
+
required: true,
|
|
57
|
+
example: 'not-a-number',
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
const files = generateFixtures(spec(models));
|
|
63
|
+
const file = files.find((f) => f.path === 'tests/fixtures/wrong.json')!;
|
|
64
|
+
const parsed = JSON.parse(file.content);
|
|
65
|
+
expect(parsed.count).toBe(0); // placeholder fallback
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('uses an example array of strings for an array<string> field', () => {
|
|
69
|
+
const models: Model[] = [
|
|
70
|
+
{
|
|
71
|
+
name: 'Org',
|
|
72
|
+
fields: [
|
|
73
|
+
{
|
|
74
|
+
name: 'domains',
|
|
75
|
+
type: {
|
|
76
|
+
kind: 'array',
|
|
77
|
+
items: { kind: 'primitive', type: 'string' },
|
|
78
|
+
},
|
|
79
|
+
required: true,
|
|
80
|
+
example: ['example.com', 'foo.com'],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
const files = generateFixtures(spec(models));
|
|
86
|
+
const file = files.find((f) => f.path === 'tests/fixtures/org.json')!;
|
|
87
|
+
const parsed = JSON.parse(file.content);
|
|
88
|
+
expect(parsed.domains).toEqual(['example.com', 'foo.com']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('skips a model-shaped example to avoid mis-shaped nested structs', () => {
|
|
92
|
+
const models: Model[] = [
|
|
93
|
+
{
|
|
94
|
+
name: 'Outer',
|
|
95
|
+
fields: [
|
|
96
|
+
{
|
|
97
|
+
name: 'actor',
|
|
98
|
+
type: { kind: 'model', name: 'Actor' },
|
|
99
|
+
required: true,
|
|
100
|
+
// Provided as a free-form example; we should NOT use it verbatim.
|
|
101
|
+
example: { not_a_real_field: 'whoops' },
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'Actor',
|
|
107
|
+
fields: [
|
|
108
|
+
{
|
|
109
|
+
name: 'id',
|
|
110
|
+
type: { kind: 'primitive', type: 'string' },
|
|
111
|
+
required: true,
|
|
112
|
+
example: 'user_TF4C5938',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'type',
|
|
116
|
+
type: { kind: 'primitive', type: 'string' },
|
|
117
|
+
required: true,
|
|
118
|
+
example: 'user',
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
const files = generateFixtures(spec(models));
|
|
124
|
+
const file = files.find((f) => f.path === 'tests/fixtures/outer.json')!;
|
|
125
|
+
const parsed = JSON.parse(file.content);
|
|
126
|
+
// The nested model is regenerated from its own fields' examples, not from
|
|
127
|
+
// the parent's free-form example blob.
|
|
128
|
+
expect(parsed.actor).toEqual({ id: 'user_TF4C5938', type: 'user' });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('uses an enum example only when it matches a known enum value', () => {
|
|
132
|
+
const enums: Enum[] = [
|
|
133
|
+
{
|
|
134
|
+
name: 'Status',
|
|
135
|
+
values: [
|
|
136
|
+
{ name: 'Active', value: 'active' },
|
|
137
|
+
{ name: 'Pending', value: 'pending' },
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
const models: Model[] = [
|
|
142
|
+
{
|
|
143
|
+
name: 'GoodEx',
|
|
144
|
+
fields: [
|
|
145
|
+
{
|
|
146
|
+
name: 'status',
|
|
147
|
+
type: { kind: 'enum', name: 'Status' },
|
|
148
|
+
required: true,
|
|
149
|
+
example: 'pending',
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'BadEx',
|
|
155
|
+
fields: [
|
|
156
|
+
{
|
|
157
|
+
name: 'status',
|
|
158
|
+
type: { kind: 'enum', name: 'Status' },
|
|
159
|
+
required: true,
|
|
160
|
+
example: 'something_unknown',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
const files = generateFixtures(spec(models, enums));
|
|
166
|
+
const good = JSON.parse(files.find((f) => f.path === 'tests/fixtures/good_ex.json')!.content);
|
|
167
|
+
const bad = JSON.parse(files.find((f) => f.path === 'tests/fixtures/bad_ex.json')!.content);
|
|
168
|
+
expect(good.status).toBe('pending'); // valid example wins
|
|
169
|
+
expect(bad.status).toBe('active'); // unknown example → first enum value
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('treats null examples as unusable so required fields keep a value', () => {
|
|
173
|
+
const models: Model[] = [
|
|
174
|
+
{
|
|
175
|
+
name: 'Nullish',
|
|
176
|
+
fields: [
|
|
177
|
+
{
|
|
178
|
+
name: 'name',
|
|
179
|
+
type: { kind: 'primitive', type: 'string' },
|
|
180
|
+
required: true,
|
|
181
|
+
example: null,
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
const files = generateFixtures(spec(models));
|
|
187
|
+
const parsed = JSON.parse(files.find((f) => f.path === 'tests/fixtures/nullish.json')!.content);
|
|
188
|
+
expect(parsed.name).toBe('test_name');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('exampleFromSpec exposes the shape-checking helper for reuse', () => {
|
|
192
|
+
const enums = new Map<string, Enum>();
|
|
193
|
+
// Primitives are unwrapped through nullable.
|
|
194
|
+
expect(exampleFromSpec('hello', { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } }, enums)).toBe(
|
|
195
|
+
'hello',
|
|
196
|
+
);
|
|
197
|
+
// Integer floats are rejected (they would corrupt typed deserialisation).
|
|
198
|
+
expect(exampleFromSpec(1.5, { kind: 'primitive', type: 'integer' }, enums)).toBeUndefined();
|
|
199
|
+
// Empty arrays fall back so the placeholder can emit a one-element array.
|
|
200
|
+
expect(exampleFromSpec([], { kind: 'array', items: { kind: 'primitive', type: 'string' } }, enums)).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('threads required-only field selection through generateModelFixture', () => {
|
|
204
|
+
const models: Model[] = [
|
|
205
|
+
{
|
|
206
|
+
name: 'Mixed',
|
|
207
|
+
fields: [
|
|
208
|
+
{
|
|
209
|
+
name: 'kept',
|
|
210
|
+
type: { kind: 'primitive', type: 'string' },
|
|
211
|
+
required: true,
|
|
212
|
+
example: 'real',
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'dropped',
|
|
216
|
+
type: { kind: 'primitive', type: 'string' },
|
|
217
|
+
required: false,
|
|
218
|
+
example: 'ignored',
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
const modelMap = new Map(models.map((m) => [m.name, m]));
|
|
224
|
+
const fixture = generateModelFixture(models[0]!, modelMap, new Map(), new Set());
|
|
225
|
+
expect(fixture).toEqual({ kept: 'real' });
|
|
226
|
+
});
|
|
227
|
+
});
|