@workos/oagen-emitters 0.0.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/.github/workflows/ci.yml +20 -0
- package/.github/workflows/lint-pr-title.yml +16 -0
- package/.github/workflows/lint.yml +21 -0
- package/.github/workflows/release-please.yml +28 -0
- package/.github/workflows/release.yml +32 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +1 -0
- package/.node-version +1 -0
- package/.oxfmtrc.json +10 -0
- package/.oxlintrc.json +29 -0
- package/.vscode/settings.json +11 -0
- package/LICENSE.txt +21 -0
- package/README.md +123 -0
- package/commitlint.config.ts +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2158 -0
- package/docs/endpoint-coverage.md +275 -0
- package/docs/sdk-architecture/node.md +355 -0
- package/oagen.config.ts +51 -0
- package/package.json +83 -0
- package/renovate.json +26 -0
- package/smoke/sdk-dotnet.ts +903 -0
- package/smoke/sdk-elixir.ts +771 -0
- package/smoke/sdk-go.ts +948 -0
- package/smoke/sdk-kotlin.ts +799 -0
- package/smoke/sdk-node.ts +516 -0
- package/smoke/sdk-php.ts +699 -0
- package/smoke/sdk-python.ts +738 -0
- package/smoke/sdk-ruby.ts +723 -0
- package/smoke/sdk-rust.ts +774 -0
- package/src/compat/extractors/dotnet.ts +8 -0
- package/src/compat/extractors/elixir.ts +8 -0
- package/src/compat/extractors/go.ts +8 -0
- package/src/compat/extractors/kotlin.ts +8 -0
- package/src/compat/extractors/node.ts +8 -0
- package/src/compat/extractors/php.ts +8 -0
- package/src/compat/extractors/python.ts +8 -0
- package/src/compat/extractors/ruby.ts +8 -0
- package/src/compat/extractors/rust.ts +8 -0
- package/src/index.ts +1 -0
- package/src/node/client.ts +356 -0
- package/src/node/common.ts +203 -0
- package/src/node/config.ts +70 -0
- package/src/node/enums.ts +87 -0
- package/src/node/errors.ts +205 -0
- package/src/node/fixtures.ts +139 -0
- package/src/node/index.ts +57 -0
- package/src/node/manifest.ts +23 -0
- package/src/node/models.ts +323 -0
- package/src/node/naming.ts +96 -0
- package/src/node/resources.ts +380 -0
- package/src/node/serializers.ts +286 -0
- package/src/node/tests.ts +336 -0
- package/src/node/type-map.ts +56 -0
- package/src/node/utils.ts +164 -0
- package/test/compat/extractors/node.test.ts +145 -0
- package/test/fixtures/sample-sdk-node/package.json +7 -0
- package/test/fixtures/sample-sdk-node/src/client.ts +24 -0
- package/test/fixtures/sample-sdk-node/src/index.ts +4 -0
- package/test/fixtures/sample-sdk-node/src/models.ts +28 -0
- package/test/fixtures/sample-sdk-node/tsconfig.json +13 -0
- package/test/node/client.test.ts +165 -0
- package/test/node/enums.test.ts +128 -0
- package/test/node/errors.test.ts +65 -0
- package/test/node/models.test.ts +301 -0
- package/test/node/naming.test.ts +212 -0
- package/test/node/resources.test.ts +260 -0
- package/test/node/serializers.test.ts +206 -0
- package/test/node/type-map.test.ts +127 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +8 -0
- package/vitest.config.ts +4 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import type { Service, Operation, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { planOperation, toPascalCase } from '@workos/oagen';
|
|
3
|
+
import type { OperationPlan } from '@workos/oagen';
|
|
4
|
+
import { mapTypeRef } from './type-map.js';
|
|
5
|
+
import {
|
|
6
|
+
fieldName,
|
|
7
|
+
fileName,
|
|
8
|
+
serviceDirName,
|
|
9
|
+
resolveMethodName,
|
|
10
|
+
resolveInterfaceName,
|
|
11
|
+
resolveServiceName,
|
|
12
|
+
buildServiceNameMap,
|
|
13
|
+
wireInterfaceName,
|
|
14
|
+
} from './naming.js';
|
|
15
|
+
import { collectModelRefs, assignModelsToServices, docComment } from './utils.js';
|
|
16
|
+
|
|
17
|
+
export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
18
|
+
if (services.length === 0) return [];
|
|
19
|
+
return services.map((service) => generateResourceClass(service, ctx));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function generateResourceClass(service: Service, ctx: EmitterContext): GeneratedFile {
|
|
23
|
+
const resolvedName = resolveServiceName(service, ctx);
|
|
24
|
+
const serviceDir = serviceDirName(resolvedName);
|
|
25
|
+
const serviceClass = resolvedName;
|
|
26
|
+
const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
|
|
27
|
+
|
|
28
|
+
const plans = service.operations.map((op) => ({
|
|
29
|
+
op,
|
|
30
|
+
plan: planOperation(op),
|
|
31
|
+
method: resolveMethodName(op, service, ctx),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const hasPaginated = plans.some((p) => p.plan.isPaginated);
|
|
35
|
+
|
|
36
|
+
// Collect models for imports
|
|
37
|
+
const responseModels = new Set<string>();
|
|
38
|
+
const requestModels = new Set<string>();
|
|
39
|
+
for (const { op, plan } of plans) {
|
|
40
|
+
if (plan.responseModelName) responseModels.add(plan.responseModelName);
|
|
41
|
+
if (op.requestBody) {
|
|
42
|
+
for (const name of collectModelRefs(op.requestBody)) {
|
|
43
|
+
requestModels.add(name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const allModels = new Set([...responseModels, ...requestModels]);
|
|
48
|
+
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
|
|
51
|
+
// Imports
|
|
52
|
+
lines.push("import type { WorkOS } from '../workos';");
|
|
53
|
+
if (hasPaginated) {
|
|
54
|
+
lines.push("import type { PaginationOptions } from '../common/interfaces/pagination-options.interface';");
|
|
55
|
+
lines.push("import { AutoPaginatable } from '../common/utils/pagination';");
|
|
56
|
+
lines.push("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check if any operation is an idempotent POST
|
|
60
|
+
const hasIdempotentPost = plans.some((p) => p.plan.isIdempotentPost);
|
|
61
|
+
if (hasIdempotentPost) {
|
|
62
|
+
lines.push("import type { PostOptions } from '../common/interfaces/post-options.interface';");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Compute model-to-service mapping for correct cross-service import paths
|
|
66
|
+
const modelToService = assignModelsToServices(ctx.spec.models, ctx.spec.services);
|
|
67
|
+
const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
|
|
68
|
+
const resolveDir = (irService: string | undefined) =>
|
|
69
|
+
irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
|
|
70
|
+
|
|
71
|
+
for (const name of allModels) {
|
|
72
|
+
const resolved = resolveInterfaceName(name, ctx);
|
|
73
|
+
const modelDir = modelToService.get(name);
|
|
74
|
+
const modelServiceDir = resolveDir(modelDir);
|
|
75
|
+
const relPath =
|
|
76
|
+
modelServiceDir === serviceDir
|
|
77
|
+
? `./interfaces/${fileName(name)}.interface`
|
|
78
|
+
: `../${modelServiceDir}/interfaces/${fileName(name)}.interface`;
|
|
79
|
+
lines.push(`import type { ${resolved}, ${wireInterfaceName(resolved)} } from '${relPath}';`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const name of responseModels) {
|
|
83
|
+
const resolved = resolveInterfaceName(name, ctx);
|
|
84
|
+
const modelDir = modelToService.get(name);
|
|
85
|
+
const modelServiceDir = resolveDir(modelDir);
|
|
86
|
+
const relPath =
|
|
87
|
+
modelServiceDir === serviceDir
|
|
88
|
+
? `./serializers/${fileName(name)}.serializer`
|
|
89
|
+
: `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
|
|
90
|
+
lines.push(`import { deserialize${resolved} } from '${relPath}';`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const name of requestModels) {
|
|
94
|
+
const resolved = resolveInterfaceName(name, ctx);
|
|
95
|
+
const modelDir = modelToService.get(name);
|
|
96
|
+
const modelServiceDir = resolveDir(modelDir);
|
|
97
|
+
const relPath =
|
|
98
|
+
modelServiceDir === serviceDir
|
|
99
|
+
? `./serializers/${fileName(name)}.serializer`
|
|
100
|
+
: `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
|
|
101
|
+
lines.push(`import { serialize${resolved} } from '${relPath}';`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
lines.push('');
|
|
105
|
+
|
|
106
|
+
// List options interfaces for paginated operations with extra query params
|
|
107
|
+
for (const { op, plan, method } of plans) {
|
|
108
|
+
if (plan.isPaginated) {
|
|
109
|
+
const extraParams = op.queryParams.filter((p) => !['limit', 'before', 'after', 'order'].includes(p.name));
|
|
110
|
+
if (extraParams.length > 0) {
|
|
111
|
+
const optionsName = toPascalCase(method) + 'Options';
|
|
112
|
+
lines.push(`export interface ${optionsName} extends PaginationOptions {`);
|
|
113
|
+
for (const param of extraParams) {
|
|
114
|
+
const opt = !param.required ? '?' : '';
|
|
115
|
+
if (param.description || param.deprecated) {
|
|
116
|
+
const parts: string[] = [];
|
|
117
|
+
if (param.description) parts.push(param.description);
|
|
118
|
+
if (param.deprecated) parts.push('@deprecated');
|
|
119
|
+
lines.push(...docComment(parts.join('\n'), 2));
|
|
120
|
+
}
|
|
121
|
+
lines.push(` ${fieldName(param.name)}${opt}: ${mapTypeRef(param.type)};`);
|
|
122
|
+
}
|
|
123
|
+
lines.push('}');
|
|
124
|
+
lines.push('');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Resource class
|
|
130
|
+
if (service.description) {
|
|
131
|
+
lines.push(...docComment(service.description));
|
|
132
|
+
}
|
|
133
|
+
lines.push(`export class ${serviceClass} {`);
|
|
134
|
+
lines.push(' constructor(private readonly workos: WorkOS) {}');
|
|
135
|
+
|
|
136
|
+
for (const { op, plan, method } of plans) {
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push(...renderMethod(op, plan, method, service, ctx));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
lines.push('}');
|
|
142
|
+
|
|
143
|
+
return { path: resourcePath, content: lines.join('\n'), skipIfExists: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderMethod(
|
|
147
|
+
op: Operation,
|
|
148
|
+
plan: OperationPlan,
|
|
149
|
+
method: string,
|
|
150
|
+
service: Service,
|
|
151
|
+
ctx: EmitterContext,
|
|
152
|
+
): string[] {
|
|
153
|
+
const lines: string[] = [];
|
|
154
|
+
const responseModel = plan.responseModelName ? resolveInterfaceName(plan.responseModelName, ctx) : null;
|
|
155
|
+
|
|
156
|
+
// Path interpolation: replace {param} with ${param}
|
|
157
|
+
const interpolatedPath = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
|
|
158
|
+
const usesTemplate = interpolatedPath.includes('${');
|
|
159
|
+
const pathStr = usesTemplate ? `\`${interpolatedPath}\`` : `'${op.path}'`;
|
|
160
|
+
|
|
161
|
+
const docParts: string[] = [];
|
|
162
|
+
if (op.description) docParts.push(op.description);
|
|
163
|
+
for (const param of op.pathParams) {
|
|
164
|
+
if (param.description) {
|
|
165
|
+
docParts.push(`@param ${fieldName(param.name)} - ${param.description}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (op.deprecated) docParts.push('@deprecated');
|
|
169
|
+
for (const err of op.errors) {
|
|
170
|
+
const exceptionName = statusToExceptionName(err.statusCode);
|
|
171
|
+
if (exceptionName) {
|
|
172
|
+
docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (docParts.length > 0) {
|
|
177
|
+
// Flatten all parts, splitting multiline descriptions into individual lines
|
|
178
|
+
const allLines: string[] = [];
|
|
179
|
+
for (const part of docParts) {
|
|
180
|
+
for (const line of part.split('\n')) {
|
|
181
|
+
allLines.push(line);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (allLines.length === 1) {
|
|
185
|
+
lines.push(` /** ${allLines[0]} */`);
|
|
186
|
+
} else {
|
|
187
|
+
lines.push(' /**');
|
|
188
|
+
for (const line of allLines) {
|
|
189
|
+
lines.push(line === '' ? ' *' : ` * ${line}`);
|
|
190
|
+
}
|
|
191
|
+
lines.push(' */');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (plan.isPaginated) {
|
|
196
|
+
if (!responseModel) {
|
|
197
|
+
console.warn(
|
|
198
|
+
`[oagen] Warning: Skipping paginated method "${method}" (${op.httpMethod.toUpperCase()} ${op.path}) — response has no named model. Ensure the spec uses a $ref for paginated item types.`,
|
|
199
|
+
);
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
renderPaginatedMethod(lines, op, plan, method, responseModel);
|
|
203
|
+
} else if (plan.isDelete) {
|
|
204
|
+
renderDeleteMethod(lines, op, plan, method, pathStr);
|
|
205
|
+
} else if (plan.hasBody && responseModel) {
|
|
206
|
+
renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx);
|
|
207
|
+
} else if (responseModel) {
|
|
208
|
+
renderGetMethod(lines, op, plan, method, responseModel, pathStr);
|
|
209
|
+
} else {
|
|
210
|
+
renderVoidMethod(lines, op, plan, method, pathStr);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return lines;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function renderPaginatedMethod(
|
|
217
|
+
lines: string[],
|
|
218
|
+
op: Operation,
|
|
219
|
+
plan: OperationPlan,
|
|
220
|
+
method: string,
|
|
221
|
+
itemType: string,
|
|
222
|
+
): void {
|
|
223
|
+
const extraParams = op.queryParams.filter((p) => !['limit', 'before', 'after', 'order'].includes(p.name));
|
|
224
|
+
const optionsType = extraParams.length > 0 ? toPascalCase(method) + 'Options' : 'PaginationOptions';
|
|
225
|
+
|
|
226
|
+
const pathStr = buildPathStr(op);
|
|
227
|
+
|
|
228
|
+
lines.push(` async ${method}(options?: ${optionsType}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
|
|
229
|
+
lines.push(' return new AutoPaginatable(');
|
|
230
|
+
lines.push(` await fetchAndDeserialize<${wireInterfaceName(itemType)}, ${itemType}>(`);
|
|
231
|
+
lines.push(' this.workos,');
|
|
232
|
+
lines.push(` ${pathStr},`);
|
|
233
|
+
lines.push(` deserialize${itemType},`);
|
|
234
|
+
lines.push(' options,');
|
|
235
|
+
lines.push(' ),');
|
|
236
|
+
lines.push(' (params) =>');
|
|
237
|
+
lines.push(` fetchAndDeserialize<${wireInterfaceName(itemType)}, ${itemType}>(`);
|
|
238
|
+
lines.push(' this.workos,');
|
|
239
|
+
lines.push(` ${pathStr},`);
|
|
240
|
+
lines.push(` deserialize${itemType},`);
|
|
241
|
+
lines.push(' params,');
|
|
242
|
+
lines.push(' ),');
|
|
243
|
+
lines.push(' options,');
|
|
244
|
+
lines.push(' );');
|
|
245
|
+
lines.push(' }');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function renderDeleteMethod(
|
|
249
|
+
lines: string[],
|
|
250
|
+
op: Operation,
|
|
251
|
+
plan: OperationPlan,
|
|
252
|
+
method: string,
|
|
253
|
+
pathStr: string,
|
|
254
|
+
): void {
|
|
255
|
+
const params = buildPathParams(op);
|
|
256
|
+
lines.push(` async ${method}(${params}): Promise<void> {`);
|
|
257
|
+
lines.push(` await this.workos.delete(${pathStr});`);
|
|
258
|
+
lines.push(' }');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function renderBodyMethod(
|
|
262
|
+
lines: string[],
|
|
263
|
+
op: Operation,
|
|
264
|
+
plan: OperationPlan,
|
|
265
|
+
method: string,
|
|
266
|
+
responseModel: string,
|
|
267
|
+
pathStr: string,
|
|
268
|
+
ctx: EmitterContext,
|
|
269
|
+
): void {
|
|
270
|
+
const requestBodyModel = extractRequestBodyModelName(op);
|
|
271
|
+
const requestType = requestBodyModel ? resolveInterfaceName(requestBodyModel, ctx) : 'any';
|
|
272
|
+
|
|
273
|
+
const paramParts: string[] = [];
|
|
274
|
+
|
|
275
|
+
// Always pass path params as individual parameters (matches existing SDK pattern)
|
|
276
|
+
for (const param of op.pathParams) {
|
|
277
|
+
paramParts.push(`${fieldName(param.name)}: ${mapTypeRef(param.type)}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
paramParts.push(`payload: ${requestType}`);
|
|
281
|
+
|
|
282
|
+
if (plan.isIdempotentPost) {
|
|
283
|
+
paramParts.push('requestOptions: PostOptions = {}');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const paramsStr = paramParts.join(', ');
|
|
287
|
+
const bodyExpr = requestBodyModel && requestType !== 'any' ? `serialize${requestType}(payload)` : 'payload';
|
|
288
|
+
|
|
289
|
+
lines.push(` async ${method}(${paramsStr}): Promise<${responseModel}> {`);
|
|
290
|
+
if (plan.isIdempotentPost) {
|
|
291
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
|
|
292
|
+
lines.push(` ${pathStr},`);
|
|
293
|
+
lines.push(` ${bodyExpr},`);
|
|
294
|
+
lines.push(' requestOptions,');
|
|
295
|
+
lines.push(' );');
|
|
296
|
+
} else {
|
|
297
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
|
|
298
|
+
lines.push(` ${pathStr},`);
|
|
299
|
+
lines.push(` ${bodyExpr},`);
|
|
300
|
+
lines.push(' );');
|
|
301
|
+
}
|
|
302
|
+
lines.push(` return deserialize${responseModel}(data);`);
|
|
303
|
+
lines.push(' }');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderGetMethod(
|
|
307
|
+
lines: string[],
|
|
308
|
+
op: Operation,
|
|
309
|
+
plan: OperationPlan,
|
|
310
|
+
method: string,
|
|
311
|
+
responseModel: string,
|
|
312
|
+
pathStr: string,
|
|
313
|
+
): void {
|
|
314
|
+
const params = buildPathParams(op);
|
|
315
|
+
const hasQuery = op.queryParams.length > 0 && !plan.isPaginated;
|
|
316
|
+
|
|
317
|
+
const allParams = hasQuery
|
|
318
|
+
? params
|
|
319
|
+
? `${params}, options?: Record<string, any>`
|
|
320
|
+
: 'options?: Record<string, any>'
|
|
321
|
+
: params;
|
|
322
|
+
|
|
323
|
+
lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
|
|
324
|
+
if (hasQuery) {
|
|
325
|
+
lines.push(
|
|
326
|
+
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
|
|
327
|
+
);
|
|
328
|
+
lines.push(' query: options,');
|
|
329
|
+
lines.push(' });');
|
|
330
|
+
} else {
|
|
331
|
+
lines.push(
|
|
332
|
+
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr});`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
lines.push(` return deserialize${responseModel}(data);`);
|
|
336
|
+
lines.push(' }');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function renderVoidMethod(lines: string[], op: Operation, plan: OperationPlan, method: string, pathStr: string): void {
|
|
340
|
+
const params = buildPathParams(op);
|
|
341
|
+
const allParams = plan.hasBody ? (params ? `${params}, payload: any` : 'payload: any') : params;
|
|
342
|
+
|
|
343
|
+
lines.push(` async ${method}(${allParams}): Promise<void> {`);
|
|
344
|
+
if (plan.hasBody) {
|
|
345
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, payload);`);
|
|
346
|
+
} else {
|
|
347
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr});`);
|
|
348
|
+
}
|
|
349
|
+
lines.push(' }');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function buildPathStr(op: Operation): string {
|
|
353
|
+
const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
|
|
354
|
+
return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function buildPathParams(op: Operation): string {
|
|
358
|
+
return op.pathParams.map((p) => `${fieldName(p.name)}: ${mapTypeRef(p.type)}`).join(', ');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function extractRequestBodyModelName(op: Operation): string | null {
|
|
362
|
+
if (!op.requestBody) return null;
|
|
363
|
+
if (op.requestBody.kind === 'model') return op.requestBody.name;
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const STATUS_TO_EXCEPTION: Record<number, string> = {
|
|
368
|
+
400: 'BadRequestException',
|
|
369
|
+
401: 'UnauthorizedException',
|
|
370
|
+
404: 'NotFoundException',
|
|
371
|
+
409: 'ConflictException',
|
|
372
|
+
422: 'UnprocessableEntityException',
|
|
373
|
+
429: 'RateLimitExceededException',
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
function statusToExceptionName(statusCode: number): string | null {
|
|
377
|
+
if (STATUS_TO_EXCEPTION[statusCode]) return STATUS_TO_EXCEPTION[statusCode];
|
|
378
|
+
if (statusCode >= 500) return 'GenericServerException';
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { Model, EmitterContext, GeneratedFile, TypeRef, UnionType } from '@workos/oagen';
|
|
2
|
+
import {
|
|
3
|
+
fieldName,
|
|
4
|
+
wireFieldName,
|
|
5
|
+
fileName,
|
|
6
|
+
serviceDirName,
|
|
7
|
+
resolveInterfaceName,
|
|
8
|
+
buildServiceNameMap,
|
|
9
|
+
wireInterfaceName,
|
|
10
|
+
} from './naming.js';
|
|
11
|
+
import { assignModelsToServices, relativeImport } from './utils.js';
|
|
12
|
+
|
|
13
|
+
export function generateSerializers(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
14
|
+
if (models.length === 0) return [];
|
|
15
|
+
|
|
16
|
+
const modelToService = assignModelsToServices(models, ctx.spec.services);
|
|
17
|
+
const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
|
|
18
|
+
const resolveDir = (irService: string | undefined) =>
|
|
19
|
+
irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
|
|
20
|
+
const files: GeneratedFile[] = [];
|
|
21
|
+
|
|
22
|
+
for (const model of models) {
|
|
23
|
+
const service = modelToService.get(model.name);
|
|
24
|
+
const dirName = resolveDir(service);
|
|
25
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
26
|
+
const responseName = wireInterfaceName(domainName);
|
|
27
|
+
const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
28
|
+
|
|
29
|
+
// Find nested model refs that need their own serializer imports.
|
|
30
|
+
// Only collect models that will actually be called in serialize/deserialize expressions
|
|
31
|
+
// (direct model refs, array-of-model items, nullable-wrapped models, single-model-variant unions).
|
|
32
|
+
const nestedModelRefs = new Set<string>();
|
|
33
|
+
for (const field of model.fields) {
|
|
34
|
+
for (const ref of collectSerializedModelRefs(field.type)) {
|
|
35
|
+
if (ref !== model.name) nestedModelRefs.add(ref);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const lines: string[] = [];
|
|
40
|
+
|
|
41
|
+
// Import model interfaces
|
|
42
|
+
const interfacePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
43
|
+
lines.push(
|
|
44
|
+
`import type { ${domainName}, ${responseName} } from '${relativeImport(serializerPath, interfacePath)}';`,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Import nested model deserializers/serializers
|
|
48
|
+
for (const dep of nestedModelRefs) {
|
|
49
|
+
const depService = modelToService.get(dep);
|
|
50
|
+
const depDir = resolveDir(depService);
|
|
51
|
+
const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
|
|
52
|
+
const depName = resolveInterfaceName(dep, ctx);
|
|
53
|
+
const imports = [`deserialize${depName}`, `serialize${depName}`];
|
|
54
|
+
lines.push(`import { ${imports.join(', ')} } from '${relativeImport(serializerPath, depSerializerPath)}';`);
|
|
55
|
+
}
|
|
56
|
+
lines.push('');
|
|
57
|
+
|
|
58
|
+
// Deserialize function (wire → domain) — deduplicate by camelCase name
|
|
59
|
+
const seenDeserFields = new Set<string>();
|
|
60
|
+
lines.push(`export const deserialize${domainName} = (`);
|
|
61
|
+
lines.push(` response: ${responseName},`);
|
|
62
|
+
lines.push(`): ${domainName} => ({`);
|
|
63
|
+
for (const field of model.fields) {
|
|
64
|
+
const domain = fieldName(field.name);
|
|
65
|
+
if (seenDeserFields.has(domain)) continue;
|
|
66
|
+
seenDeserFields.add(domain);
|
|
67
|
+
const wire = wireFieldName(field.name);
|
|
68
|
+
const wireAccess = `response.${wire}`;
|
|
69
|
+
const expr = deserializeExpression(field.type, wireAccess, ctx);
|
|
70
|
+
// If the field is optional and the expression involves a function call,
|
|
71
|
+
// wrap with a null check to avoid passing undefined to the deserializer
|
|
72
|
+
if (!field.required && expr !== wireAccess && needsNullGuard(field.type)) {
|
|
73
|
+
lines.push(` ${domain}: ${wireAccess} != null ? ${expr} : undefined,`);
|
|
74
|
+
} else if (field.required && expr === wireAccess) {
|
|
75
|
+
// Required field with direct assignment — add fallback for cases where
|
|
76
|
+
// the response interface makes the field optional (baseline override)
|
|
77
|
+
const fallback = defaultForType(field.type);
|
|
78
|
+
if (fallback) {
|
|
79
|
+
lines.push(` ${domain}: ${expr} ?? ${fallback},`);
|
|
80
|
+
} else {
|
|
81
|
+
lines.push(` ${domain}: ${expr},`);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
lines.push(` ${domain}: ${expr},`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
lines.push('});');
|
|
88
|
+
|
|
89
|
+
// Serialize function (domain → wire)
|
|
90
|
+
lines.push('');
|
|
91
|
+
lines.push(`export const serialize${domainName} = (`);
|
|
92
|
+
lines.push(` model: ${domainName},`);
|
|
93
|
+
lines.push(`): ${responseName} => ({`);
|
|
94
|
+
const seenSerFields = new Set<string>();
|
|
95
|
+
for (const field of model.fields) {
|
|
96
|
+
const wire = wireFieldName(field.name);
|
|
97
|
+
if (seenSerFields.has(wire)) continue;
|
|
98
|
+
seenSerFields.add(wire);
|
|
99
|
+
const domain = fieldName(field.name);
|
|
100
|
+
const domainAccess = `model.${domain}`;
|
|
101
|
+
const expr = serializeExpression(field.type, domainAccess, ctx);
|
|
102
|
+
// If the field is optional and the expression involves a function call,
|
|
103
|
+
// wrap with a null check to avoid passing undefined to the serializer
|
|
104
|
+
if (!field.required && expr !== domainAccess && needsNullGuard(field.type)) {
|
|
105
|
+
lines.push(` ${wire}: ${domainAccess} != null ? ${expr} : undefined,`);
|
|
106
|
+
} else {
|
|
107
|
+
lines.push(` ${wire}: ${expr},`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
lines.push('});');
|
|
111
|
+
|
|
112
|
+
files.push({
|
|
113
|
+
path: serializerPath,
|
|
114
|
+
content: lines.join('\n'),
|
|
115
|
+
skipIfExists: true,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return files;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Collect model names that will actually be called in serialize/deserialize expressions.
|
|
124
|
+
* Unlike collectModelRefs (which walks all union variants), this only includes models
|
|
125
|
+
* that the expression functions will actually invoke a serializer/deserializer for.
|
|
126
|
+
*/
|
|
127
|
+
function collectSerializedModelRefs(ref: TypeRef): string[] {
|
|
128
|
+
switch (ref.kind) {
|
|
129
|
+
case 'model':
|
|
130
|
+
return [ref.name];
|
|
131
|
+
case 'array':
|
|
132
|
+
if (ref.items.kind === 'model') return [ref.items.name];
|
|
133
|
+
return collectSerializedModelRefs(ref.items);
|
|
134
|
+
case 'nullable':
|
|
135
|
+
return collectSerializedModelRefs(ref.inner);
|
|
136
|
+
case 'union': {
|
|
137
|
+
const models = uniqueModelVariants(ref);
|
|
138
|
+
// Only if exactly one unique model variant — that's when we call its serializer
|
|
139
|
+
if (models.length === 1) return models;
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
case 'map':
|
|
143
|
+
case 'primitive':
|
|
144
|
+
case 'literal':
|
|
145
|
+
case 'enum':
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function deserializeExpression(ref: TypeRef, wireExpr: string, ctx: EmitterContext): string {
|
|
151
|
+
switch (ref.kind) {
|
|
152
|
+
case 'primitive':
|
|
153
|
+
case 'literal':
|
|
154
|
+
case 'enum':
|
|
155
|
+
return wireExpr;
|
|
156
|
+
case 'model': {
|
|
157
|
+
const name = resolveInterfaceName(ref.name, ctx);
|
|
158
|
+
return `deserialize${name}(${wireExpr})`;
|
|
159
|
+
}
|
|
160
|
+
case 'array':
|
|
161
|
+
if (ref.items.kind === 'model') {
|
|
162
|
+
const name = resolveInterfaceName(ref.items.name, ctx);
|
|
163
|
+
return `${wireExpr}.map(deserialize${name})`;
|
|
164
|
+
}
|
|
165
|
+
return wireExpr;
|
|
166
|
+
case 'nullable': {
|
|
167
|
+
const innerExpr = deserializeExpression(ref.inner, wireExpr, ctx);
|
|
168
|
+
// If the inner type involves a function call (model or array-of-model),
|
|
169
|
+
// wrap with a null check to avoid passing null to the deserializer
|
|
170
|
+
if (innerExpr !== wireExpr) {
|
|
171
|
+
return `${wireExpr} != null ? ${innerExpr} : null`;
|
|
172
|
+
}
|
|
173
|
+
return `${wireExpr} ?? null`;
|
|
174
|
+
}
|
|
175
|
+
case 'union': {
|
|
176
|
+
// If the union has exactly one unique model variant, deserialize using that model's deserializer
|
|
177
|
+
const deserModelVariants = uniqueModelVariants(ref);
|
|
178
|
+
if (deserModelVariants.length === 1) {
|
|
179
|
+
const name = resolveInterfaceName(deserModelVariants[0], ctx);
|
|
180
|
+
return `deserialize${name}(${wireExpr})`;
|
|
181
|
+
}
|
|
182
|
+
return wireExpr;
|
|
183
|
+
}
|
|
184
|
+
case 'map':
|
|
185
|
+
return wireExpr;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function serializeExpression(ref: TypeRef, domainExpr: string, ctx: EmitterContext): string {
|
|
190
|
+
switch (ref.kind) {
|
|
191
|
+
case 'primitive':
|
|
192
|
+
case 'literal':
|
|
193
|
+
case 'enum':
|
|
194
|
+
return domainExpr;
|
|
195
|
+
case 'model': {
|
|
196
|
+
const name = resolveInterfaceName(ref.name, ctx);
|
|
197
|
+
return `serialize${name}(${domainExpr})`;
|
|
198
|
+
}
|
|
199
|
+
case 'array':
|
|
200
|
+
if (ref.items.kind === 'model') {
|
|
201
|
+
const name = resolveInterfaceName(ref.items.name, ctx);
|
|
202
|
+
return `${domainExpr}.map(serialize${name})`;
|
|
203
|
+
}
|
|
204
|
+
return domainExpr;
|
|
205
|
+
case 'nullable': {
|
|
206
|
+
const innerExpr = serializeExpression(ref.inner, domainExpr, ctx);
|
|
207
|
+
// If the inner type involves a function call (model or array-of-model),
|
|
208
|
+
// wrap with a null check to avoid passing null to the serializer
|
|
209
|
+
if (innerExpr !== domainExpr) {
|
|
210
|
+
return `${domainExpr} != null ? ${innerExpr} : null`;
|
|
211
|
+
}
|
|
212
|
+
return domainExpr;
|
|
213
|
+
}
|
|
214
|
+
case 'union': {
|
|
215
|
+
// If the union has exactly one unique model variant, serialize using that model's serializer
|
|
216
|
+
const serModelVariants = uniqueModelVariants(ref);
|
|
217
|
+
if (serModelVariants.length === 1) {
|
|
218
|
+
const name = resolveInterfaceName(serModelVariants[0], ctx);
|
|
219
|
+
return `serialize${name}(${domainExpr})`;
|
|
220
|
+
}
|
|
221
|
+
return domainExpr;
|
|
222
|
+
}
|
|
223
|
+
case 'map':
|
|
224
|
+
return domainExpr;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Extract unique model names from a union's variants.
|
|
230
|
+
* Used to determine if a union can be deserialized/serialized as a single model.
|
|
231
|
+
*/
|
|
232
|
+
function uniqueModelVariants(ref: UnionType): string[] {
|
|
233
|
+
const modelNames = new Set<string>();
|
|
234
|
+
for (const v of ref.variants) {
|
|
235
|
+
if (v.kind === 'model') modelNames.add(v.name);
|
|
236
|
+
}
|
|
237
|
+
return [...modelNames];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check whether a TypeRef involves a model reference that would produce
|
|
242
|
+
* a function call in serialization/deserialization. Used to determine
|
|
243
|
+
* whether optional fields need a null guard wrapper.
|
|
244
|
+
*/
|
|
245
|
+
function needsNullGuard(ref: TypeRef): boolean {
|
|
246
|
+
switch (ref.kind) {
|
|
247
|
+
case 'model':
|
|
248
|
+
return true;
|
|
249
|
+
case 'array':
|
|
250
|
+
return ref.items.kind === 'model';
|
|
251
|
+
case 'nullable':
|
|
252
|
+
return needsNullGuard(ref.inner);
|
|
253
|
+
case 'union':
|
|
254
|
+
return uniqueModelVariants(ref).length === 1;
|
|
255
|
+
default:
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Return a TypeScript default value expression for a type, used as a null
|
|
262
|
+
* coalesce fallback when a required domain field may be optional in the
|
|
263
|
+
* response interface (baseline override mismatch).
|
|
264
|
+
*/
|
|
265
|
+
function defaultForType(ref: TypeRef): string | null {
|
|
266
|
+
switch (ref.kind) {
|
|
267
|
+
case 'map':
|
|
268
|
+
return '{}';
|
|
269
|
+
case 'primitive':
|
|
270
|
+
switch (ref.type) {
|
|
271
|
+
case 'boolean':
|
|
272
|
+
return 'false';
|
|
273
|
+
case 'string':
|
|
274
|
+
return "''";
|
|
275
|
+
case 'integer':
|
|
276
|
+
case 'number':
|
|
277
|
+
return '0';
|
|
278
|
+
default:
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
case 'array':
|
|
282
|
+
return '[]';
|
|
283
|
+
default:
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|