@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,201 @@
|
|
|
1
|
+
import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { typeName, moduleName, variantName } from './naming.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate one Rust source file per enum under `src/enums/`, plus a
|
|
6
|
+
* `src/enums/mod.rs` barrel.
|
|
7
|
+
*
|
|
8
|
+
* Each enum is emitted as a string-backed, forward-compatible Rust enum:
|
|
9
|
+
* - `#[non_exhaustive]` so callers can't write exhaustive matches that
|
|
10
|
+
* break when WorkOS adds a new value server-side.
|
|
11
|
+
* - A fallback variant (`Unknown(String)` by default, or `Unrecognized` /
|
|
12
|
+
* `Other` / `OagenUnknown` if the spec already defines a variant by that
|
|
13
|
+
* name) captures any wire value the SDK doesn't yet recognize, preserving
|
|
14
|
+
* the original string instead of failing deserialization.
|
|
15
|
+
* - Manual `Serialize`/`Deserialize` map between the canonical wire string
|
|
16
|
+
* and the Rust variant; alias wire values deserialize into the canonical
|
|
17
|
+
* variant and re-serialize as the canonical wire string.
|
|
18
|
+
* - `Display`, `FromStr`, and `AsRef<str>` are implemented for ergonomics.
|
|
19
|
+
*/
|
|
20
|
+
export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFile[] {
|
|
21
|
+
const files: GeneratedFile[] = [];
|
|
22
|
+
const seen = new Set<string>();
|
|
23
|
+
const moduleNames: string[] = [];
|
|
24
|
+
|
|
25
|
+
for (const e of enums) {
|
|
26
|
+
if (!e.values || e.values.length === 0) continue;
|
|
27
|
+
const mod = moduleName(e.name);
|
|
28
|
+
if (seen.has(mod)) continue;
|
|
29
|
+
seen.add(mod);
|
|
30
|
+
moduleNames.push(mod);
|
|
31
|
+
|
|
32
|
+
files.push({
|
|
33
|
+
path: `src/enums/${mod}.rs`,
|
|
34
|
+
content: renderEnum(e),
|
|
35
|
+
overwriteExisting: true,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
files.push({
|
|
40
|
+
path: 'src/enums/mod.rs',
|
|
41
|
+
content: renderEnumsBarrel(moduleNames),
|
|
42
|
+
overwriteExisting: true,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderEnum(e: Enum): string {
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
const tname = typeName(e.name);
|
|
51
|
+
|
|
52
|
+
// Collapse wire values that map to the same Rust variant. The first wire
|
|
53
|
+
// value for a variant is the canonical one (used for serialization); the
|
|
54
|
+
// remaining ones are aliases (accepted on deserialization).
|
|
55
|
+
const order: string[] = [];
|
|
56
|
+
const wireByVariant = new Map<string, string[]>();
|
|
57
|
+
const descByVariant = new Map<string, string | undefined>();
|
|
58
|
+
const deprecatedByVariant = new Map<string, boolean>();
|
|
59
|
+
for (const v of e.values) {
|
|
60
|
+
const variant = variantName(v.value);
|
|
61
|
+
if (!wireByVariant.has(variant)) {
|
|
62
|
+
wireByVariant.set(variant, []);
|
|
63
|
+
order.push(variant);
|
|
64
|
+
descByVariant.set(variant, v.description);
|
|
65
|
+
deprecatedByVariant.set(variant, !!v.deprecated);
|
|
66
|
+
}
|
|
67
|
+
wireByVariant.get(variant)!.push(String(v.value));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Pick a fallback-variant name that doesn't collide with an existing one.
|
|
71
|
+
// Prefer `Unknown` for ergonomics; fall back to less-likely names for the
|
|
72
|
+
// (rare) enums whose spec already defines `Unknown`/`Unrecognized`/`Other`.
|
|
73
|
+
const taken = new Set(order);
|
|
74
|
+
const FB = ['Unknown', 'Unrecognized', 'Other', 'OagenUnknown'].find((c) => !taken.has(c)) ?? 'OagenUnknown';
|
|
75
|
+
|
|
76
|
+
lines.push('use serde::{Deserialize, Serialize};');
|
|
77
|
+
lines.push('use std::fmt;');
|
|
78
|
+
lines.push('use std::str::FromStr;');
|
|
79
|
+
lines.push('');
|
|
80
|
+
|
|
81
|
+
lines.push('#[derive(Debug, Clone, PartialEq, Eq, Hash)]');
|
|
82
|
+
lines.push('#[non_exhaustive]');
|
|
83
|
+
lines.push(`pub enum ${tname} {`);
|
|
84
|
+
for (const variant of order) {
|
|
85
|
+
const desc = descByVariant.get(variant);
|
|
86
|
+
if (desc) {
|
|
87
|
+
for (const c of docComment(desc)) lines.push(` ${c}`);
|
|
88
|
+
}
|
|
89
|
+
if (deprecatedByVariant.get(variant)) lines.push(' #[allow(deprecated)]');
|
|
90
|
+
lines.push(` ${variant},`);
|
|
91
|
+
}
|
|
92
|
+
lines.push(' /// Wire value not recognized by this SDK version. The original');
|
|
93
|
+
lines.push(' /// string is preserved verbatim. WorkOS may add new enum values');
|
|
94
|
+
lines.push(' /// server-side; matching on this variant lets callers handle');
|
|
95
|
+
lines.push(' /// forward-compatible values without panicking.');
|
|
96
|
+
lines.push(` ${FB}(String),`);
|
|
97
|
+
lines.push('}');
|
|
98
|
+
lines.push('');
|
|
99
|
+
|
|
100
|
+
// as_str(): canonical wire value for known variants, inner string for fallback.
|
|
101
|
+
lines.push(`impl ${tname} {`);
|
|
102
|
+
lines.push(` /// Canonical wire string for this value. For [\`Self::${FB}\`] returns the`);
|
|
103
|
+
lines.push(' /// original wire value as received from the API.');
|
|
104
|
+
lines.push(' #[allow(deprecated)]');
|
|
105
|
+
lines.push(' pub fn as_str(&self) -> &str {');
|
|
106
|
+
lines.push(' match self {');
|
|
107
|
+
for (const variant of order) {
|
|
108
|
+
const canonical = wireByVariant.get(variant)![0]!;
|
|
109
|
+
lines.push(` Self::${variant} => ${JSON.stringify(canonical)},`);
|
|
110
|
+
}
|
|
111
|
+
lines.push(` Self::${FB}(s) => s.as_str(),`);
|
|
112
|
+
lines.push(' }');
|
|
113
|
+
lines.push(' }');
|
|
114
|
+
lines.push('}');
|
|
115
|
+
lines.push('');
|
|
116
|
+
|
|
117
|
+
// Display via as_str().
|
|
118
|
+
lines.push(`impl fmt::Display for ${tname} {`);
|
|
119
|
+
lines.push(" fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {");
|
|
120
|
+
lines.push(' f.write_str(self.as_str())');
|
|
121
|
+
lines.push(' }');
|
|
122
|
+
lines.push('}');
|
|
123
|
+
lines.push('');
|
|
124
|
+
|
|
125
|
+
// AsRef<str>.
|
|
126
|
+
lines.push(`impl AsRef<str> for ${tname} {`);
|
|
127
|
+
lines.push(' fn as_ref(&self) -> &str {');
|
|
128
|
+
lines.push(' self.as_str()');
|
|
129
|
+
lines.push(' }');
|
|
130
|
+
lines.push('}');
|
|
131
|
+
lines.push('');
|
|
132
|
+
|
|
133
|
+
// FromStr — infallible (fallback variant captures anything else).
|
|
134
|
+
lines.push(`impl FromStr for ${tname} {`);
|
|
135
|
+
lines.push(' type Err = std::convert::Infallible;');
|
|
136
|
+
lines.push(' #[allow(deprecated)]');
|
|
137
|
+
lines.push(' fn from_str(s: &str) -> Result<Self, Self::Err> {');
|
|
138
|
+
lines.push(' Ok(match s {');
|
|
139
|
+
for (const variant of order) {
|
|
140
|
+
const wires = wireByVariant.get(variant)!;
|
|
141
|
+
for (const w of wires) {
|
|
142
|
+
lines.push(` ${JSON.stringify(w)} => Self::${variant},`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
lines.push(` other => Self::${FB}(other.to_string()),`);
|
|
146
|
+
lines.push(' })');
|
|
147
|
+
lines.push(' }');
|
|
148
|
+
lines.push('}');
|
|
149
|
+
lines.push('');
|
|
150
|
+
|
|
151
|
+
// From<String>/From<&str>.
|
|
152
|
+
lines.push(`impl From<String> for ${tname} {`);
|
|
153
|
+
lines.push(' fn from(s: String) -> Self {');
|
|
154
|
+
lines.push(' // Reuse the original `String` allocation in the fallback branch.');
|
|
155
|
+
lines.push(' match Self::from_str(&s) {');
|
|
156
|
+
lines.push(` Ok(Self::${FB}(_)) => Self::${FB}(s),`);
|
|
157
|
+
lines.push(' Ok(other) => other,');
|
|
158
|
+
lines.push(' }');
|
|
159
|
+
lines.push(' }');
|
|
160
|
+
lines.push('}');
|
|
161
|
+
lines.push('');
|
|
162
|
+
lines.push(`impl From<&str> for ${tname} {`);
|
|
163
|
+
lines.push(' fn from(s: &str) -> Self {');
|
|
164
|
+
lines.push(` Self::from_str(s).unwrap_or_else(|_| Self::${FB}(s.to_string()))`);
|
|
165
|
+
lines.push(' }');
|
|
166
|
+
lines.push('}');
|
|
167
|
+
lines.push('');
|
|
168
|
+
|
|
169
|
+
// Manual Serialize / Deserialize.
|
|
170
|
+
lines.push(`impl Serialize for ${tname} {`);
|
|
171
|
+
lines.push(' fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {');
|
|
172
|
+
lines.push(' serializer.serialize_str(self.as_str())');
|
|
173
|
+
lines.push(' }');
|
|
174
|
+
lines.push('}');
|
|
175
|
+
lines.push('');
|
|
176
|
+
lines.push(`impl<'de> Deserialize<'de> for ${tname} {`);
|
|
177
|
+
lines.push(" fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {");
|
|
178
|
+
lines.push(' let s = String::deserialize(deserializer)?;');
|
|
179
|
+
lines.push(' Ok(Self::from(s))');
|
|
180
|
+
lines.push(' }');
|
|
181
|
+
lines.push('}');
|
|
182
|
+
|
|
183
|
+
return lines.join('\n') + '\n';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderEnumsBarrel(modules: string[]): string {
|
|
187
|
+
const sorted = [...new Set(modules)].sort();
|
|
188
|
+
const lines: string[] = [];
|
|
189
|
+
for (const m of sorted) lines.push(`pub mod ${m};`);
|
|
190
|
+
lines.push('');
|
|
191
|
+
for (const m of sorted) lines.push(`pub use ${m}::*;`);
|
|
192
|
+
return lines.join('\n') + '\n';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function docComment(text: string): string[] {
|
|
196
|
+
return text
|
|
197
|
+
.split('\n')
|
|
198
|
+
.map((l) => l.trim())
|
|
199
|
+
.filter((l) => l.length > 0)
|
|
200
|
+
.map((l) => `/// ${l}`);
|
|
201
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { ApiSpec, GeneratedFile, Model, Enum, TypeRef } from '@workos/oagen';
|
|
2
|
+
import { walkTypeRef } from '@workos/oagen';
|
|
3
|
+
import { moduleName } from './naming.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate JSON test fixture files under `tests/fixtures/`. The Rust tests
|
|
7
|
+
* pull these in via `include_str!` so no I/O is required at test time.
|
|
8
|
+
*/
|
|
9
|
+
export function generateFixtures(spec: ApiSpec): GeneratedFile[] {
|
|
10
|
+
const files: GeneratedFile[] = [];
|
|
11
|
+
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
12
|
+
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
13
|
+
const seen = new Set<string>();
|
|
14
|
+
|
|
15
|
+
for (const model of spec.models) {
|
|
16
|
+
if (seen.has(model.name)) continue;
|
|
17
|
+
if (model.fields.length === 0) continue;
|
|
18
|
+
seen.add(model.name);
|
|
19
|
+
|
|
20
|
+
const fixture = generateModelFixture(model, modelMap, enumMap, new Set());
|
|
21
|
+
const path = `tests/fixtures/${moduleName(model.name)}.json`;
|
|
22
|
+
files.push({
|
|
23
|
+
path,
|
|
24
|
+
content: JSON.stringify(fixture, null, 2) + '\n',
|
|
25
|
+
headerPlacement: 'skip',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function generateModelFixture(
|
|
33
|
+
model: Model,
|
|
34
|
+
modelMap: Map<string, Model>,
|
|
35
|
+
enumMap: Map<string, Enum>,
|
|
36
|
+
visiting: Set<string>,
|
|
37
|
+
): Record<string, unknown> {
|
|
38
|
+
const result: Record<string, unknown> = {};
|
|
39
|
+
if (visiting.has(model.name)) return result; // Break recursion.
|
|
40
|
+
visiting.add(model.name);
|
|
41
|
+
|
|
42
|
+
for (const field of model.fields) {
|
|
43
|
+
if (!field.required) continue;
|
|
44
|
+
// Prefer the spec `example` value when it is shape-compatible with the
|
|
45
|
+
// declared type. Falls back to the placeholder generator when no example
|
|
46
|
+
// is provided or when the example would not deserialize cleanly.
|
|
47
|
+
const fromExample = exampleFromSpec(field.example, field.type, enumMap);
|
|
48
|
+
result[field.name] =
|
|
49
|
+
fromExample !== undefined ? fromExample : exampleFor(field.type, modelMap, enumMap, visiting, field.name);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
visiting.delete(model.name);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function exampleFor(
|
|
57
|
+
type: TypeRef,
|
|
58
|
+
modelMap: Map<string, Model>,
|
|
59
|
+
enumMap: Map<string, Enum>,
|
|
60
|
+
visiting: Set<string>,
|
|
61
|
+
fieldName: string,
|
|
62
|
+
): unknown {
|
|
63
|
+
switch (type.kind) {
|
|
64
|
+
case 'primitive':
|
|
65
|
+
switch (type.type) {
|
|
66
|
+
case 'string':
|
|
67
|
+
if (type.format === 'date-time') return '2023-01-01T00:00:00.000Z';
|
|
68
|
+
if (type.format === 'date') return '2023-01-01';
|
|
69
|
+
if (type.format === 'uuid') return '00000000-0000-0000-0000-000000000000';
|
|
70
|
+
if (fieldName === 'id') return 'test_id';
|
|
71
|
+
if (fieldName === 'email') return 'test@example.com';
|
|
72
|
+
return `test_${fieldName}`;
|
|
73
|
+
case 'integer':
|
|
74
|
+
return 0;
|
|
75
|
+
case 'number':
|
|
76
|
+
return 0;
|
|
77
|
+
case 'boolean':
|
|
78
|
+
return false;
|
|
79
|
+
case 'unknown':
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
case 'array':
|
|
84
|
+
return [exampleFor(type.items, modelMap, enumMap, visiting, fieldName)];
|
|
85
|
+
case 'map':
|
|
86
|
+
return {};
|
|
87
|
+
case 'nullable':
|
|
88
|
+
return exampleFor(type.inner, modelMap, enumMap, visiting, fieldName);
|
|
89
|
+
case 'literal':
|
|
90
|
+
return type.value;
|
|
91
|
+
case 'enum': {
|
|
92
|
+
const e = enumMap.get(type.name);
|
|
93
|
+
const v = e?.values?.[0]?.value;
|
|
94
|
+
return v ?? '';
|
|
95
|
+
}
|
|
96
|
+
case 'model': {
|
|
97
|
+
const m = modelMap.get(type.name);
|
|
98
|
+
if (!m) return {};
|
|
99
|
+
return generateModelFixture(m, modelMap, enumMap, visiting);
|
|
100
|
+
}
|
|
101
|
+
case 'union': {
|
|
102
|
+
// Find first model variant; fall back to empty object.
|
|
103
|
+
let result: unknown = null;
|
|
104
|
+
walkTypeRef(type.variants[0]!, {
|
|
105
|
+
primitive: () => {
|
|
106
|
+
result = exampleFor(type.variants[0]!, modelMap, enumMap, visiting, fieldName);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
if (result === null) {
|
|
110
|
+
result = exampleFor(type.variants[0]!, modelMap, enumMap, visiting, fieldName);
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a spec-provided `example` against a TypeRef and return the value to
|
|
119
|
+
* embed in the fixture, or `undefined` when the example cannot be used safely.
|
|
120
|
+
*
|
|
121
|
+
* "Safely" means the value would round-trip through serde to the generated
|
|
122
|
+
* Rust type. We deliberately only accept primitives, enum string/number
|
|
123
|
+
* values, and homogenous arrays of those; nested object examples (which the
|
|
124
|
+
* spec sometimes supplies as illustrative metadata blobs) are skipped because
|
|
125
|
+
* they rarely match the strict struct shape Rust expects.
|
|
126
|
+
*/
|
|
127
|
+
export function exampleFromSpec(example: unknown, type: TypeRef, enumMap: Map<string, Enum>): unknown {
|
|
128
|
+
if (example === undefined) return undefined;
|
|
129
|
+
// Spec authors sometimes use `null` as a sentinel; let placeholder gen
|
|
130
|
+
// handle nullable types so we don't emit `null` for required fields.
|
|
131
|
+
if (example === null) return undefined;
|
|
132
|
+
return matchExampleToType(example, type, enumMap);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function matchExampleToType(value: unknown, type: TypeRef, enumMap: Map<string, Enum>): unknown {
|
|
136
|
+
switch (type.kind) {
|
|
137
|
+
case 'primitive':
|
|
138
|
+
return matchPrimitive(value, type.type);
|
|
139
|
+
case 'literal':
|
|
140
|
+
return value === type.value ? value : undefined;
|
|
141
|
+
case 'enum': {
|
|
142
|
+
const e = enumMap.get(type.name);
|
|
143
|
+
if (!e) return undefined;
|
|
144
|
+
const ok = e.values.some((v) => v.value === value);
|
|
145
|
+
return ok ? value : undefined;
|
|
146
|
+
}
|
|
147
|
+
case 'array': {
|
|
148
|
+
if (!Array.isArray(value)) return undefined;
|
|
149
|
+
const out: unknown[] = [];
|
|
150
|
+
for (const item of value) {
|
|
151
|
+
const matched = matchExampleToType(item, type.items, enumMap);
|
|
152
|
+
if (matched === undefined) return undefined;
|
|
153
|
+
out.push(matched);
|
|
154
|
+
}
|
|
155
|
+
// Empty arrays are valid but unhelpful in fixtures — fall back so the
|
|
156
|
+
// placeholder generator can produce a one-element example.
|
|
157
|
+
if (out.length === 0) return undefined;
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
case 'nullable':
|
|
161
|
+
return matchExampleToType(value, type.inner, enumMap);
|
|
162
|
+
case 'map':
|
|
163
|
+
// Map examples are usually free-form metadata blobs that match
|
|
164
|
+
// `HashMap<String, _>`; only accept plain objects with string-keyed values.
|
|
165
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
166
|
+
return value;
|
|
167
|
+
case 'union': {
|
|
168
|
+
for (const variant of type.variants) {
|
|
169
|
+
const matched = matchExampleToType(value, variant, enumMap);
|
|
170
|
+
if (matched !== undefined) return matched;
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
case 'model':
|
|
175
|
+
// Model-shaped examples are too risky to copy verbatim: they rarely
|
|
176
|
+
// supply every required field and may use wire names that don't align
|
|
177
|
+
// with the generated struct. Let the recursive generator handle them.
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function matchPrimitive(value: unknown, primitive: 'string' | 'integer' | 'number' | 'boolean' | 'unknown'): unknown {
|
|
183
|
+
switch (primitive) {
|
|
184
|
+
case 'string':
|
|
185
|
+
return typeof value === 'string' ? value : undefined;
|
|
186
|
+
case 'integer':
|
|
187
|
+
return typeof value === 'number' && Number.isInteger(value) ? value : undefined;
|
|
188
|
+
case 'number':
|
|
189
|
+
return typeof value === 'number' ? value : undefined;
|
|
190
|
+
case 'boolean':
|
|
191
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
192
|
+
case 'unknown':
|
|
193
|
+
// `unknown` deserialises to `serde_json::Value`, so any JSON value works.
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Emitter,
|
|
3
|
+
EmitterContext,
|
|
4
|
+
FormatCommand,
|
|
5
|
+
GeneratedFile,
|
|
6
|
+
ApiSpec,
|
|
7
|
+
Model,
|
|
8
|
+
Enum,
|
|
9
|
+
Service,
|
|
10
|
+
} from '@workos/oagen';
|
|
11
|
+
|
|
12
|
+
import { generateModels } from './models.js';
|
|
13
|
+
import { generateEnums } from './enums.js';
|
|
14
|
+
import { generateResources } from './resources.js';
|
|
15
|
+
import { generateClient } from './client.js';
|
|
16
|
+
import { generateTests } from './tests.js';
|
|
17
|
+
import { buildOperationsMap } from './manifest.js';
|
|
18
|
+
import { UnionRegistry } from './type-map.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shared per-emit registry that collects synthesised oneOf-style unions
|
|
22
|
+
* encountered in *any* call (model fields, request bodies, etc.). Rendered
|
|
23
|
+
* once into `src/models/_unions.rs` during the final structural pass so
|
|
24
|
+
* downstream files can reference the synthesised type names uniformly.
|
|
25
|
+
*/
|
|
26
|
+
const unionRegistry = new UnionRegistry();
|
|
27
|
+
|
|
28
|
+
function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
29
|
+
for (const f of files) {
|
|
30
|
+
if (f.content && !f.content.endsWith('\n')) {
|
|
31
|
+
f.content += '\n';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return files;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const rustEmitter: Emitter = {
|
|
38
|
+
language: 'rust',
|
|
39
|
+
|
|
40
|
+
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
41
|
+
unionRegistry.reset();
|
|
42
|
+
return ensureTrailingNewlines(generateModels(models, ctx, unionRegistry));
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
46
|
+
return ensureTrailingNewlines(generateEnums(enums, ctx));
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
50
|
+
return ensureTrailingNewlines(generateResources(services, ctx, unionRegistry));
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
54
|
+
return ensureTrailingNewlines(generateClient(spec, ctx, unionRegistry));
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
generateErrors(): GeneratedFile[] {
|
|
58
|
+
// Hand-maintained in the target SDK — `src/error.rs`, `src/client.rs`,
|
|
59
|
+
// `src/lib.rs`, `src/pagination.rs`, and `Cargo.toml` all carry
|
|
60
|
+
// `@oagen-ignore-file` and are not regenerated.
|
|
61
|
+
return [];
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
generateTypeSignatures(): GeneratedFile[] {
|
|
65
|
+
return [];
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
69
|
+
return ensureTrailingNewlines(generateTests(spec, ctx));
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
buildOperationsMap(spec: ApiSpec, ctx: EmitterContext) {
|
|
73
|
+
return buildOperationsMap(spec, ctx);
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
fileHeader(): string {
|
|
77
|
+
return '// Code generated by oagen. DO NOT EDIT.';
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
formatCommand(_targetDir: string): FormatCommand | null {
|
|
81
|
+
// oagen appends every generated file path (mixing .rs, Cargo.toml, .json
|
|
82
|
+
// fixtures) to the format command. `rustfmt` errors on non-`.rs` paths,
|
|
83
|
+
// so wrap it in a bash filter that only forwards `.rs` files. Same
|
|
84
|
+
// shape as the Go emitter's gofmt wrapper.
|
|
85
|
+
return {
|
|
86
|
+
cmd: 'bash',
|
|
87
|
+
args: [
|
|
88
|
+
'-c',
|
|
89
|
+
'RS_FILES=$(printf "%s\\n" "$@" | grep "\\.rs$"); [ -n "$RS_FILES" ] && echo "$RS_FILES" | xargs rustfmt --edition 2024 --quiet',
|
|
90
|
+
'--',
|
|
91
|
+
],
|
|
92
|
+
batchSize: 999999,
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ApiSpec, EmitterContext, OperationsMap } from '@workos/oagen';
|
|
2
|
+
import { methodName, moduleName } from './naming.js';
|
|
3
|
+
import { groupByMount } from '../shared/resolved-ops.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the operation→SDK-method map written into `.oagen-manifest.json`,
|
|
7
|
+
* which the smoke runner consults to dispatch HTTP operations to SDK calls.
|
|
8
|
+
*
|
|
9
|
+
* Keys are `METHOD /path`; values point at the mount-target accessor on
|
|
10
|
+
* `Client` and the resolved snake_case method name. Split operations register
|
|
11
|
+
* one entry per wrapper (the wrapper name takes precedence over the raw op
|
|
12
|
+
* name since the raw method does not exist in the generated SDK).
|
|
13
|
+
*/
|
|
14
|
+
export function buildOperationsMap(_spec: ApiSpec, ctx: EmitterContext): OperationsMap {
|
|
15
|
+
const map: OperationsMap = {};
|
|
16
|
+
|
|
17
|
+
for (const [mountName, group] of groupByMount(ctx)) {
|
|
18
|
+
const accessor = moduleName(mountName);
|
|
19
|
+
for (const r of group.resolvedOps) {
|
|
20
|
+
const httpKey = `${r.operation.httpMethod.toUpperCase()} ${r.operation.path}`;
|
|
21
|
+
if ((r.wrappers?.length ?? 0) > 0) {
|
|
22
|
+
const first = r.wrappers![0]!;
|
|
23
|
+
map[httpKey] = { sdkMethod: methodName(first.name), service: accessor };
|
|
24
|
+
} else {
|
|
25
|
+
map[httpKey] = { sdkMethod: methodName(r.methodName), service: accessor };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return map;
|
|
31
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Model, EmitterContext, GeneratedFile, Field } from '@workos/oagen';
|
|
2
|
+
import { typeName, fieldName, moduleName } from './naming.js';
|
|
3
|
+
import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
|
|
4
|
+
import { applySecretRedaction } from './secret.js';
|
|
5
|
+
|
|
6
|
+
const HEADER_PLACEHOLDER = ''; // engine prepends fileHeader()
|
|
7
|
+
|
|
8
|
+
const UNIONS_MODULE = '_unions';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate one Rust source file per model under `src/models/`, plus a
|
|
12
|
+
* `src/models/mod.rs` barrel that re-exports each module. Inline IR unions
|
|
13
|
+
* encountered in field positions are synthesised into a single
|
|
14
|
+
* `src/models/_unions.rs` module so the resulting Rust types are concrete.
|
|
15
|
+
*/
|
|
16
|
+
export function generateModels(models: Model[], ctx: EmitterContext, registry: UnionRegistry): GeneratedFile[] {
|
|
17
|
+
const files: GeneratedFile[] = [];
|
|
18
|
+
const moduleNames: string[] = [];
|
|
19
|
+
const seen = new Set<string>();
|
|
20
|
+
|
|
21
|
+
for (const model of models) {
|
|
22
|
+
// Empty-field, non-discriminator models still need to be emitted as an
|
|
23
|
+
// empty struct so request bodies that reference them (e.g. an empty
|
|
24
|
+
// `CreateApplicationSecretDto`) compile.
|
|
25
|
+
const mod = moduleName(model.name);
|
|
26
|
+
if (seen.has(mod)) continue;
|
|
27
|
+
seen.add(mod);
|
|
28
|
+
moduleNames.push(mod);
|
|
29
|
+
|
|
30
|
+
const hintPath = ctx.overlayLookup?.fileBySymbol?.get(model.name);
|
|
31
|
+
const path = hintPath ?? `src/models/${mod}.rs`;
|
|
32
|
+
files.push({ path, content: renderModel(model, registry), overwriteExisting: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Always include the unions module in the barrel so downstream stages
|
|
36
|
+
// (resources, etc.) that register additional unions don't need to mutate
|
|
37
|
+
// the barrel after the fact. The actual `_unions.rs` file is rendered in
|
|
38
|
+
// generateClient once every stage has finished registering.
|
|
39
|
+
moduleNames.push(UNIONS_MODULE);
|
|
40
|
+
|
|
41
|
+
files.push({
|
|
42
|
+
path: 'src/models/mod.rs',
|
|
43
|
+
content: renderModelsBarrel(moduleNames),
|
|
44
|
+
overwriteExisting: true,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderModel(model: Model, registry: UnionRegistry): string {
|
|
51
|
+
const lines: string[] = [];
|
|
52
|
+
lines.push(HEADER_PLACEHOLDER);
|
|
53
|
+
// Match rustfmt's canonical grouping: keyword-rooted paths (`super`,
|
|
54
|
+
// `crate`) sort before external-crate paths (`serde`). Pre-emit in that
|
|
55
|
+
// order so `cargo fmt --check` does not reshuffle the file.
|
|
56
|
+
lines.push('#[allow(unused_imports)]');
|
|
57
|
+
lines.push('use super::*;');
|
|
58
|
+
lines.push('#[allow(unused_imports)]');
|
|
59
|
+
lines.push('use crate::enums::*;');
|
|
60
|
+
lines.push('use serde::{Deserialize, Serialize};');
|
|
61
|
+
lines.push('');
|
|
62
|
+
|
|
63
|
+
if (model.description) lines.push(...docComment(model.description));
|
|
64
|
+
|
|
65
|
+
lines.push('#[derive(Debug, Clone, Serialize, Deserialize)]');
|
|
66
|
+
|
|
67
|
+
const resolvedNames = resolveFieldNames(model.fields);
|
|
68
|
+
const fieldLines = model.fields.map((f, i) => renderField(f, resolvedNames[i]!, model.name, registry));
|
|
69
|
+
|
|
70
|
+
// rustfmt collapses zero-field structs to `pub struct Foo {}` on a single
|
|
71
|
+
// line. Match that shape so `cargo fmt --check` passes.
|
|
72
|
+
if (fieldLines.length === 0) {
|
|
73
|
+
lines.push(`pub struct ${typeName(model.name)} {}`);
|
|
74
|
+
} else {
|
|
75
|
+
lines.push(`pub struct ${typeName(model.name)} {`);
|
|
76
|
+
lines.push(...fieldLines);
|
|
77
|
+
lines.push('}');
|
|
78
|
+
}
|
|
79
|
+
return lines.filter((l) => l !== HEADER_PLACEHOLDER).join('\n') + '\n';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve unique Rust identifiers for struct fields. Multiple wire names can
|
|
84
|
+
* collide after `fieldName()` snake-cases them (e.g. `integration_type` and
|
|
85
|
+
* `integrationType` both become `integration_type`). Subsequent collisions get
|
|
86
|
+
* a numeric suffix so the struct compiles; serde `rename` preserves the
|
|
87
|
+
* original wire name in every case.
|
|
88
|
+
*/
|
|
89
|
+
function resolveFieldNames(fields: Field[]): string[] {
|
|
90
|
+
const used = new Set<string>();
|
|
91
|
+
const out: string[] = [];
|
|
92
|
+
for (const f of fields) {
|
|
93
|
+
const base = fieldName(f.name);
|
|
94
|
+
let candidate = base;
|
|
95
|
+
let suffix = 2;
|
|
96
|
+
while (used.has(candidate)) {
|
|
97
|
+
candidate = `${base}_${suffix}`;
|
|
98
|
+
suffix++;
|
|
99
|
+
}
|
|
100
|
+
used.add(candidate);
|
|
101
|
+
out.push(candidate);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderField(field: Field, rustField: string, modelName: string, registry: UnionRegistry): string {
|
|
107
|
+
const lines: string[] = [];
|
|
108
|
+
const hasDescription = !!field.description;
|
|
109
|
+
if (hasDescription) {
|
|
110
|
+
for (const c of docComment(field.description!)) lines.push(` ${c}`);
|
|
111
|
+
}
|
|
112
|
+
if (field.default != null) {
|
|
113
|
+
if (hasDescription) lines.push(' ///');
|
|
114
|
+
lines.push(` /// Defaults to \`${formatDefault(field.default)}\`.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const rename = rustField !== field.name ? field.name : null;
|
|
118
|
+
|
|
119
|
+
let baseType = mapTypeRef(field.type, {
|
|
120
|
+
hint: `${typeName(modelName)}${typeName(field.name)}`,
|
|
121
|
+
registry,
|
|
122
|
+
});
|
|
123
|
+
const isOptional = !field.required || field.type.kind === 'nullable';
|
|
124
|
+
if (isOptional && !baseType.startsWith('Option<')) {
|
|
125
|
+
baseType = makeOptional(baseType);
|
|
126
|
+
}
|
|
127
|
+
// Wrap String / Option<String> in SecretString when the field name implies
|
|
128
|
+
// the value is a credential or token. Wire format is unchanged.
|
|
129
|
+
baseType = applySecretRedaction(baseType, field.name);
|
|
130
|
+
|
|
131
|
+
if (rename) lines.push(` #[serde(rename = "${rename}")]`);
|
|
132
|
+
if (baseType.startsWith('Option<')) {
|
|
133
|
+
lines.push(' #[serde(skip_serializing_if = "Option::is_none", default)]');
|
|
134
|
+
}
|
|
135
|
+
if (field.deprecated) lines.push(' #[deprecated]');
|
|
136
|
+
lines.push(` pub ${rustField}: ${baseType},`);
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function renderModelsBarrel(modules: string[]): string {
|
|
141
|
+
const sorted = [...new Set(modules)].sort();
|
|
142
|
+
const lines: string[] = [];
|
|
143
|
+
for (const m of sorted) lines.push(`pub mod ${m};`);
|
|
144
|
+
lines.push('');
|
|
145
|
+
for (const m of sorted) lines.push(`pub use ${m}::*;`);
|
|
146
|
+
return lines.join('\n') + '\n';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function docComment(text: string): string[] {
|
|
150
|
+
return text
|
|
151
|
+
.split('\n')
|
|
152
|
+
.map((l) => l.trim())
|
|
153
|
+
.filter((l) => l.length > 0)
|
|
154
|
+
.map((l) => `/// ${l}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render a spec-level default value for inclusion in a doc comment. Strings
|
|
159
|
+
* render bare (e.g. `desc`) so they nest naturally inside the surrounding
|
|
160
|
+
* backticks; numbers/booleans use JSON encoding.
|
|
161
|
+
*/
|
|
162
|
+
function formatDefault(value: unknown): string {
|
|
163
|
+
if (typeof value === 'string') return value;
|
|
164
|
+
return JSON.stringify(value);
|
|
165
|
+
}
|