@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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +12 -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-C408Wh-o.mjs} +2082 -514
- package/dist/plugin-C408Wh-o.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 +1 -1
- 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 +110 -0
- package/src/rust/index.ts +95 -0
- package/src/rust/manifest.ts +31 -0
- package/src/rust/models.ts +150 -0
- package/src/rust/naming.ts +131 -0
- package/src/rust/resources.ts +689 -0
- package/src/rust/secret.ts +59 -0
- package/src/rust/tests.ts +298 -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/manifest.test.ts +73 -0
- package/test/rust/models.test.ts +139 -0
- package/test/rust/resources.test.ts +245 -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,110 @@
|
|
|
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
|
+
result[field.name] = exampleFor(field.type, modelMap, enumMap, visiting, field.name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
visiting.delete(model.name);
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function exampleFor(
|
|
52
|
+
type: TypeRef,
|
|
53
|
+
modelMap: Map<string, Model>,
|
|
54
|
+
enumMap: Map<string, Enum>,
|
|
55
|
+
visiting: Set<string>,
|
|
56
|
+
fieldName: string,
|
|
57
|
+
): unknown {
|
|
58
|
+
switch (type.kind) {
|
|
59
|
+
case 'primitive':
|
|
60
|
+
switch (type.type) {
|
|
61
|
+
case 'string':
|
|
62
|
+
if (type.format === 'date-time') return '2023-01-01T00:00:00.000Z';
|
|
63
|
+
if (type.format === 'date') return '2023-01-01';
|
|
64
|
+
if (type.format === 'uuid') return '00000000-0000-0000-0000-000000000000';
|
|
65
|
+
if (fieldName === 'id') return 'test_id';
|
|
66
|
+
if (fieldName === 'email') return 'test@example.com';
|
|
67
|
+
return `test_${fieldName}`;
|
|
68
|
+
case 'integer':
|
|
69
|
+
return 0;
|
|
70
|
+
case 'number':
|
|
71
|
+
return 0;
|
|
72
|
+
case 'boolean':
|
|
73
|
+
return false;
|
|
74
|
+
case 'unknown':
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
case 'array':
|
|
79
|
+
return [exampleFor(type.items, modelMap, enumMap, visiting, fieldName)];
|
|
80
|
+
case 'map':
|
|
81
|
+
return {};
|
|
82
|
+
case 'nullable':
|
|
83
|
+
return exampleFor(type.inner, modelMap, enumMap, visiting, fieldName);
|
|
84
|
+
case 'literal':
|
|
85
|
+
return type.value;
|
|
86
|
+
case 'enum': {
|
|
87
|
+
const e = enumMap.get(type.name);
|
|
88
|
+
const v = e?.values?.[0]?.value;
|
|
89
|
+
return v ?? '';
|
|
90
|
+
}
|
|
91
|
+
case 'model': {
|
|
92
|
+
const m = modelMap.get(type.name);
|
|
93
|
+
if (!m) return {};
|
|
94
|
+
return generateModelFixture(m, modelMap, enumMap, visiting);
|
|
95
|
+
}
|
|
96
|
+
case 'union': {
|
|
97
|
+
// Find first model variant; fall back to empty object.
|
|
98
|
+
let result: unknown = null;
|
|
99
|
+
walkTypeRef(type.variants[0]!, {
|
|
100
|
+
primitive: () => {
|
|
101
|
+
result = exampleFor(type.variants[0]!, modelMap, enumMap, visiting, fieldName);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
if (result === null) {
|
|
105
|
+
result = exampleFor(type.variants[0]!, modelMap, enumMap, visiting, fieldName);
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -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,150 @@
|
|
|
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
|
+
if (field.description) {
|
|
109
|
+
for (const c of docComment(field.description)) lines.push(` ${c}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rename = rustField !== field.name ? field.name : null;
|
|
113
|
+
|
|
114
|
+
let baseType = mapTypeRef(field.type, {
|
|
115
|
+
hint: `${typeName(modelName)}${typeName(field.name)}`,
|
|
116
|
+
registry,
|
|
117
|
+
});
|
|
118
|
+
const isOptional = !field.required || field.type.kind === 'nullable';
|
|
119
|
+
if (isOptional && !baseType.startsWith('Option<')) {
|
|
120
|
+
baseType = makeOptional(baseType);
|
|
121
|
+
}
|
|
122
|
+
// Wrap String / Option<String> in SecretString when the field name implies
|
|
123
|
+
// the value is a credential or token. Wire format is unchanged.
|
|
124
|
+
baseType = applySecretRedaction(baseType, field.name);
|
|
125
|
+
|
|
126
|
+
if (rename) lines.push(` #[serde(rename = "${rename}")]`);
|
|
127
|
+
if (baseType.startsWith('Option<')) {
|
|
128
|
+
lines.push(' #[serde(skip_serializing_if = "Option::is_none", default)]');
|
|
129
|
+
}
|
|
130
|
+
if (field.deprecated) lines.push(' #[deprecated]');
|
|
131
|
+
lines.push(` pub ${rustField}: ${baseType},`);
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderModelsBarrel(modules: string[]): string {
|
|
136
|
+
const sorted = [...new Set(modules)].sort();
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
for (const m of sorted) lines.push(`pub mod ${m};`);
|
|
139
|
+
lines.push('');
|
|
140
|
+
for (const m of sorted) lines.push(`pub use ${m}::*;`);
|
|
141
|
+
return lines.join('\n') + '\n';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function docComment(text: string): string[] {
|
|
145
|
+
return text
|
|
146
|
+
.split('\n')
|
|
147
|
+
.map((l) => l.trim())
|
|
148
|
+
.filter((l) => l.length > 0)
|
|
149
|
+
.map((l) => `/// ${l}`);
|
|
150
|
+
}
|