@workos/oagen-emitters 0.4.0 → 0.6.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +9 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -15234
- package/dist/plugin-Dws9b6T7.mjs +21441 -0
- package/dist/plugin-Dws9b6T7.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +5 -5
- package/oagen.config.ts +5 -373
- package/package.json +17 -41
- package/smoke/sdk-dotnet.ts +11 -5
- package/smoke/sdk-elixir.ts +11 -5
- package/smoke/sdk-go.ts +10 -4
- package/smoke/sdk-kotlin.ts +11 -5
- package/smoke/sdk-node.ts +11 -5
- package/smoke/sdk-php.ts +9 -4
- package/smoke/sdk-python.ts +10 -4
- package/smoke/sdk-ruby.ts +10 -4
- package/smoke/sdk-rust.ts +11 -5
- package/src/dotnet/index.ts +9 -7
- package/src/dotnet/manifest.ts +5 -11
- package/src/dotnet/models.ts +58 -82
- package/src/dotnet/naming.ts +44 -6
- package/src/dotnet/resources.ts +350 -29
- package/src/dotnet/tests.ts +44 -24
- package/src/dotnet/type-map.ts +44 -17
- package/src/dotnet/wrappers.ts +21 -10
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +13 -8
- package/src/go/manifest.ts +5 -11
- package/src/go/models.ts +6 -1
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +14 -0
- package/src/kotlin/client.ts +7 -2
- package/src/kotlin/enums.ts +30 -3
- package/src/kotlin/index.ts +3 -3
- package/src/kotlin/manifest.ts +9 -15
- package/src/kotlin/models.ts +97 -6
- package/src/kotlin/naming.ts +7 -1
- package/src/kotlin/resources.ts +370 -39
- package/src/kotlin/tests.ts +120 -6
- package/src/node/client.ts +38 -11
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +3 -3
- package/src/node/manifest.ts +4 -11
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +156 -52
- package/src/node/tests.ts +76 -27
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/index.ts +3 -3
- package/src/php/manifest.ts +5 -11
- package/src/php/models.ts +0 -33
- package/src/php/resources.ts +199 -18
- package/src/php/tests.ts +26 -2
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +6 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +13 -3
- package/src/python/enums.ts +28 -3
- package/src/python/index.ts +38 -30
- package/src/python/manifest.ts +5 -12
- package/src/python/models.ts +138 -1
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +28 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +131 -7
- package/src/shared/naming-utils.ts +36 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/test/dotnet/client.test.ts +2 -2
- package/test/dotnet/manifest.test.ts +13 -12
- package/test/dotnet/models.test.ts +7 -9
- package/test/dotnet/resources.test.ts +135 -3
- package/test/dotnet/tests.test.ts +5 -5
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +1 -1
- package/test/kotlin/resources.test.ts +210 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +134 -26
- package/test/node/utils.test.ts +140 -0
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +66 -1
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/manifest.test.ts +7 -7
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsconfig.json +1 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
package/src/kotlin/tests.ts
CHANGED
|
@@ -12,7 +12,13 @@ import type {
|
|
|
12
12
|
import { planOperation } from '@workos/oagen';
|
|
13
13
|
import { apiClassName, packageSegment, resolveMethodName, ktStringLiteral, className, propertyName } from './naming.js';
|
|
14
14
|
import { mapTypeRef } from './type-map.js';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
groupByMount,
|
|
17
|
+
lookupResolved,
|
|
18
|
+
buildResolvedLookup,
|
|
19
|
+
buildHiddenParams,
|
|
20
|
+
collectGroupedParamNames,
|
|
21
|
+
} from '../shared/resolved-ops.js';
|
|
16
22
|
import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
|
|
17
23
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
18
24
|
import { isHandwrittenOverride } from './overrides.js';
|
|
@@ -72,6 +78,8 @@ interface OpTest {
|
|
|
72
78
|
requiredBodyPaths: string[];
|
|
73
79
|
/** `name=value` pairs required on the query string — asserted via matchingRegex. */
|
|
74
80
|
requiredQueryAssertions: { name: string; valueRegex: string }[];
|
|
81
|
+
/** Wire field names that must NOT appear as query params (e.g. password on POST). */
|
|
82
|
+
forbiddenQueryParams: string[];
|
|
75
83
|
/** Assertions on response fields: { kotlinAccessor, expectedExpr }. */
|
|
76
84
|
responseAssertions: { accessor: string; expectedExpr: string }[];
|
|
77
85
|
}
|
|
@@ -148,7 +156,7 @@ function generateServiceTestClass(
|
|
|
148
156
|
const verifyMethods = new Set<string>();
|
|
149
157
|
for (const t of uniqueTests) {
|
|
150
158
|
if (!t.canEmitHappyPath) continue;
|
|
151
|
-
if (t.requiredBodyPaths.length > 0 || t.requiredQueryAssertions.length > 0) {
|
|
159
|
+
if (t.requiredBodyPaths.length > 0 || t.requiredQueryAssertions.length > 0 || t.forbiddenQueryParams.length > 0) {
|
|
152
160
|
verifyMethods.add(t.httpMethod);
|
|
153
161
|
}
|
|
154
162
|
}
|
|
@@ -160,8 +168,10 @@ function generateServiceTestClass(
|
|
|
160
168
|
}
|
|
161
169
|
const anyBody = uniqueTests.some((t) => t.canEmitHappyPath && t.requiredBodyPaths.length > 0);
|
|
162
170
|
const anyQuery = uniqueTests.some((t) => t.canEmitHappyPath && t.requiredQueryAssertions.length > 0);
|
|
171
|
+
const anyForbidden = uniqueTests.some((t) => t.canEmitHappyPath && t.forbiddenQueryParams.length > 0);
|
|
163
172
|
if (anyBody) imports.add('com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath');
|
|
164
173
|
if (anyQuery) imports.add('com.github.tomakehurst.wiremock.client.WireMock.matching');
|
|
174
|
+
if (anyForbidden) imports.add('com.github.tomakehurst.wiremock.client.WireMock.absent');
|
|
165
175
|
// assertEquals is needed when any test has response field assertions.
|
|
166
176
|
if (uniqueTests.some((t) => t.canEmitHappyPath && t.responseAssertions.length > 0)) {
|
|
167
177
|
imports.add('org.junit.jupiter.api.Assertions.assertEquals');
|
|
@@ -217,6 +227,7 @@ function buildOperationTest(
|
|
|
217
227
|
if (!svc) return null;
|
|
218
228
|
const method = resolveMethodName(op, svc, ctx);
|
|
219
229
|
const plan = planOperation(op);
|
|
230
|
+
const mountPackage = packageSegment(resolved?.mountOn ?? svc.name);
|
|
220
231
|
|
|
221
232
|
const hidden = buildHiddenParams(resolved);
|
|
222
233
|
|
|
@@ -229,8 +240,18 @@ function buildOperationTest(
|
|
|
229
240
|
|
|
230
241
|
for (const _pp of op.pathParams) argParts.push(ktStringLiteral('sample-arg'));
|
|
231
242
|
|
|
232
|
-
const
|
|
243
|
+
const groupedParamNames = collectGroupedParamNames(op);
|
|
244
|
+
|
|
245
|
+
const queryFields = op.queryParams.filter((p) => !hidden.has(p.name) && !groupedParamNames.has(p.name));
|
|
233
246
|
const sortedQuery = [...queryFields].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
|
|
247
|
+
const sharedQueryBodyParams = new Set<string>();
|
|
248
|
+
const bodyModel = resolveBodyModel(op, ctx);
|
|
249
|
+
for (const qp of queryFields) {
|
|
250
|
+
const matchingBodyField = bodyModel?.fields.find((field) => field.name === qp.name);
|
|
251
|
+
if (matchingBodyField && mapTypeRef(qp.type) === mapTypeRef(matchingBodyField.type)) {
|
|
252
|
+
sharedQueryBodyParams.add(qp.name);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
234
255
|
for (const qp of sortedQuery) {
|
|
235
256
|
if (!qp.required) break;
|
|
236
257
|
const val = synthValue(qp.type, ctx, imports);
|
|
@@ -242,14 +263,25 @@ function buildOperationTest(
|
|
|
242
263
|
if (regex !== null) requiredQueryAssertions.push({ name: qp.name, valueRegex: regex });
|
|
243
264
|
}
|
|
244
265
|
|
|
245
|
-
|
|
266
|
+
// Parameter group args — emit as named args (they appear after optionals in the signature)
|
|
267
|
+
const groupParamNames = assignGroupParameterNames(op, hidden, queryFields, bodyModel, groupedParamNames);
|
|
268
|
+
for (const group of op.parameterGroups ?? []) {
|
|
269
|
+
const variant = group.variants[0];
|
|
270
|
+
const sealedName = sealedGroupName(group.name);
|
|
271
|
+
const variantName = className(variant.name);
|
|
272
|
+
const variantArgs = variant.parameters.map((_p) => ktStringLiteral('sample-arg')).join(', ');
|
|
273
|
+
imports.add(`com.workos.${mountPackage}.${sealedName}`);
|
|
274
|
+
argParts.push(`${groupParamNames.get(group.name)!} = ${sealedName}.${variantName}(${variantArgs})`);
|
|
275
|
+
}
|
|
276
|
+
|
|
246
277
|
if (bodyModel) {
|
|
247
278
|
// Body fields always pass; colliding names are renamed (e.g. slug →
|
|
248
279
|
// bodySlug) by the resources emitter, so every required body field still
|
|
249
280
|
// needs a test argument here.
|
|
250
|
-
const bodyFields = bodyModel.fields.filter((f) => !hidden.has(f.name));
|
|
281
|
+
const bodyFields = bodyModel.fields.filter((f) => !hidden.has(f.name) && !groupedParamNames.has(f.name));
|
|
251
282
|
const sortedBody = [...bodyFields].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
|
|
252
283
|
for (const bf of sortedBody) {
|
|
284
|
+
if (sharedQueryBodyParams.has(bf.name)) continue;
|
|
253
285
|
if (!bf.required) break;
|
|
254
286
|
const val = synthValue(bf.type, ctx, imports);
|
|
255
287
|
if (val === null) return null;
|
|
@@ -284,6 +316,20 @@ function buildOperationTest(
|
|
|
284
316
|
? buildResponseAssertions(plan2.responseModelName, ctx)
|
|
285
317
|
: [];
|
|
286
318
|
|
|
319
|
+
// For POST/PUT/PATCH with parameter groups, collect all wire field names
|
|
320
|
+
// from the groups — these must NOT appear as query parameters.
|
|
321
|
+
const forbiddenQueryParams: string[] = [];
|
|
322
|
+
const httpUpper = op.httpMethod.toUpperCase();
|
|
323
|
+
if (['POST', 'PUT', 'PATCH'].includes(httpUpper) && (op.parameterGroups?.length ?? 0) > 0) {
|
|
324
|
+
for (const group of op.parameterGroups!) {
|
|
325
|
+
for (const variant of group.variants) {
|
|
326
|
+
for (const p of variant.parameters) {
|
|
327
|
+
if (!forbiddenQueryParams.includes(p.name)) forbiddenQueryParams.push(p.name);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
287
333
|
return {
|
|
288
334
|
method,
|
|
289
335
|
httpMethod: op.httpMethod.toLowerCase(),
|
|
@@ -295,10 +341,69 @@ function buildOperationTest(
|
|
|
295
341
|
imports,
|
|
296
342
|
requiredBodyPaths,
|
|
297
343
|
requiredQueryAssertions,
|
|
344
|
+
forbiddenQueryParams,
|
|
298
345
|
responseAssertions,
|
|
299
346
|
};
|
|
300
347
|
}
|
|
301
348
|
|
|
349
|
+
function assignGroupParameterNames(
|
|
350
|
+
op: Operation,
|
|
351
|
+
hidden: Set<string>,
|
|
352
|
+
queryFields: Operation['queryParams'],
|
|
353
|
+
bodyModel: Model | null,
|
|
354
|
+
groupedParamNames: Set<string> = new Set(),
|
|
355
|
+
): Map<string, string> {
|
|
356
|
+
const occupiedNames = new Set<string>();
|
|
357
|
+
|
|
358
|
+
for (const pp of op.pathParams) occupiedNames.add(propertyName(pp.name));
|
|
359
|
+
for (const qp of queryFields) occupiedNames.add(propertyName(qp.name));
|
|
360
|
+
|
|
361
|
+
for (const bf of bodyModel?.fields ?? []) {
|
|
362
|
+
if (hidden.has(bf.name) || groupedParamNames.has(bf.name)) continue;
|
|
363
|
+
const natural = propertyName(bf.name);
|
|
364
|
+
if (occupiedNames.has(natural)) {
|
|
365
|
+
occupiedNames.add(`body${natural.charAt(0).toUpperCase()}${natural.slice(1)}`);
|
|
366
|
+
} else {
|
|
367
|
+
occupiedNames.add(natural);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const names = new Map<string, string>();
|
|
372
|
+
for (const group of op.parameterGroups ?? []) {
|
|
373
|
+
const natural = propertyName(sealedGroupName(group.name));
|
|
374
|
+
const assigned = reserveUniqueGroupParameterName(natural, occupiedNames);
|
|
375
|
+
names.set(group.name, assigned);
|
|
376
|
+
}
|
|
377
|
+
return names;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function sealedGroupName(name: string): string {
|
|
381
|
+
const resolved = className(name);
|
|
382
|
+
if (resolved === 'Password') return 'CreateUserPassword';
|
|
383
|
+
if (resolved === 'Role') return 'CreateUserRole';
|
|
384
|
+
return resolved;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function reserveUniqueGroupParameterName(base: string, occupiedNames: Set<string>): string {
|
|
388
|
+
if (!occupiedNames.has(base)) {
|
|
389
|
+
occupiedNames.add(base);
|
|
390
|
+
return base;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const capitalized = `${base.charAt(0).toUpperCase()}${base.slice(1)}`;
|
|
394
|
+
const prefixed = `group${capitalized}`;
|
|
395
|
+
if (!occupiedNames.has(prefixed)) {
|
|
396
|
+
occupiedNames.add(prefixed);
|
|
397
|
+
return prefixed;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let index = 2;
|
|
401
|
+
while (occupiedNames.has(`${prefixed}${index}`)) index += 1;
|
|
402
|
+
const fallback = `${prefixed}${index}`;
|
|
403
|
+
occupiedNames.add(fallback);
|
|
404
|
+
return fallback;
|
|
405
|
+
}
|
|
406
|
+
|
|
302
407
|
/** True if the synthesized body value serializes to a concrete JSON scalar. */
|
|
303
408
|
function isScalarBodyField(type: TypeRef): boolean {
|
|
304
409
|
const inner = type.kind === 'nullable' ? type.inner : type;
|
|
@@ -383,6 +488,7 @@ function buildWrapperTest(op: Operation, wrapper: ResolvedWrapper, ctx: EmitterC
|
|
|
383
488
|
imports,
|
|
384
489
|
requiredBodyPaths: [],
|
|
385
490
|
requiredQueryAssertions: [],
|
|
491
|
+
forbiddenQueryParams: [],
|
|
386
492
|
responseAssertions,
|
|
387
493
|
};
|
|
388
494
|
}
|
|
@@ -643,7 +749,7 @@ function emitHappyPathTest(lines: string[], t: OpTest): void {
|
|
|
643
749
|
// Verify the outbound request shape. Body fields and query assertions
|
|
644
750
|
// live on the `OpTest` and are only emitted when we know the synthesized
|
|
645
751
|
// arguments produce a deterministic wire representation.
|
|
646
|
-
if (t.requiredBodyPaths.length > 0 || t.requiredQueryAssertions.length > 0) {
|
|
752
|
+
if (t.requiredBodyPaths.length > 0 || t.requiredQueryAssertions.length > 0 || t.forbiddenQueryParams.length > 0) {
|
|
647
753
|
lines.push(' wireMockRule.verify(');
|
|
648
754
|
lines.push(` ${t.httpMethod}RequestedFor(urlPathMatching(${ktStringLiteral(t.pathForWireMock)}))`);
|
|
649
755
|
for (const path of t.requiredBodyPaths) {
|
|
@@ -652,6 +758,10 @@ function emitHappyPathTest(lines: string[], t: OpTest): void {
|
|
|
652
758
|
for (const qa of t.requiredQueryAssertions) {
|
|
653
759
|
lines.push(` .withQueryParam(${ktStringLiteral(qa.name)}, matching(${ktStringLiteral(qa.valueRegex)}))`);
|
|
654
760
|
}
|
|
761
|
+
// Assert sensitive fields from parameter groups never leak into the URL.
|
|
762
|
+
for (const name of t.forbiddenQueryParams) {
|
|
763
|
+
lines.push(` .withQueryParam(${ktStringLiteral(name)}, absent())`);
|
|
764
|
+
}
|
|
655
765
|
lines.push(' )');
|
|
656
766
|
}
|
|
657
767
|
lines.push(' }');
|
|
@@ -874,9 +984,13 @@ function generateModelRoundTripTest(spec: ApiSpec, ctx: EmitterContext): Generat
|
|
|
874
984
|
// models, arrays, maps, and literals — much broader than the old
|
|
875
985
|
// primitives-only filter.
|
|
876
986
|
const targets: { model: Model; json: string }[] = [];
|
|
987
|
+
const seenModelClassNames = new Set<string>();
|
|
877
988
|
for (const m of spec.models) {
|
|
878
989
|
if (isListWrapperModel(m) || isListMetadataModel(m)) continue;
|
|
879
990
|
if (m.fields.length === 0) continue;
|
|
991
|
+
const cls = className(m.name);
|
|
992
|
+
if (seenModelClassNames.has(cls)) continue;
|
|
993
|
+
seenModelClassNames.add(cls);
|
|
880
994
|
// Only include models where ALL fields are required AND all types are
|
|
881
995
|
// round-trip safe (primitives, nullable, literals, simple arrays/maps).
|
|
882
996
|
// Nested model/enum references break round-trip because Jackson
|
package/src/node/client.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import type { ApiSpec, AuthScheme, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
import { fileName, resolveServiceDir, servicePropertyName, resolveInterfaceName, wireInterfaceName } from './naming.js';
|
|
6
6
|
import {
|
|
7
7
|
docComment,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isServiceCoveredByExisting,
|
|
10
10
|
isListMetadataModel,
|
|
11
11
|
isListWrapperModel,
|
|
12
|
+
computeNonEventReachable,
|
|
12
13
|
} from './utils.js';
|
|
13
14
|
import { resolveResourceClassName } from './resources.js';
|
|
14
15
|
|
|
@@ -186,12 +187,12 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
|
|
|
186
187
|
// Models -> service directories
|
|
187
188
|
// Skip list wrapper and list metadata models — they use shared List<T>/ListMetadata
|
|
188
189
|
// from common utils, so no per-resource interface file is generated.
|
|
189
|
-
// Also skip unreachable models —
|
|
190
|
-
//
|
|
191
|
-
const barrelReachable =
|
|
190
|
+
// Also skip unreachable models — use the same non-event reachability as model
|
|
191
|
+
// generation so every barrel entry has a corresponding generated file.
|
|
192
|
+
const barrelReachable = computeNonEventReachable(spec.services, spec.models);
|
|
192
193
|
for (const model of spec.models) {
|
|
193
194
|
if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
|
|
194
|
-
if (!barrelReachable.
|
|
195
|
+
if (!barrelReachable.has(model.name)) continue;
|
|
195
196
|
const service = modelToService.get(model.name);
|
|
196
197
|
const dirName = resolveDir(service);
|
|
197
198
|
if (!dirExports.has(dirName)) {
|
|
@@ -265,12 +266,38 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
|
|
|
265
266
|
addBaselineExports(ctx.apiSurface.typeAliases);
|
|
266
267
|
addBaselineExports(ctx.apiSurface.enums);
|
|
267
268
|
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
269
|
+
// Preserve existing barrel entries: read the current barrel from the
|
|
270
|
+
// target directory and keep every `export * from './<stem>'` whose
|
|
271
|
+
// corresponding file still exists on disk. This prevents dropping
|
|
272
|
+
// hand-written types (e.g., Factor in multi-factor-auth) when a
|
|
273
|
+
// generated model in the same file causes a symbol collision.
|
|
272
274
|
if (ctx.targetDir) {
|
|
273
275
|
const interfacesDir = path.join(ctx.targetDir, 'src', dirName, 'interfaces');
|
|
276
|
+
try {
|
|
277
|
+
const barrelPath = path.join(interfacesDir, 'index.ts');
|
|
278
|
+
const barrelContent = fs.readFileSync(barrelPath, 'utf-8');
|
|
279
|
+
for (const line of barrelContent.split('\n')) {
|
|
280
|
+
const match = line.match(/^export \* from '\.\/(.*?)';?$/);
|
|
281
|
+
if (!match) continue;
|
|
282
|
+
const stem = match[1];
|
|
283
|
+
const exportLine = `export * from './${stem}';`;
|
|
284
|
+
if (exportSet.has(exportLine)) continue;
|
|
285
|
+
// Verify the referenced file still exists
|
|
286
|
+
const filePath = path.join(interfacesDir, `${stem}.ts`);
|
|
287
|
+
try {
|
|
288
|
+
fs.accessSync(filePath);
|
|
289
|
+
exportSet.add(exportLine);
|
|
290
|
+
} catch {
|
|
291
|
+
// File no longer exists — don't preserve stale entry
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
// No existing barrel — nothing to preserve
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Also scan for NEW interface files not in the existing barrel or
|
|
299
|
+
// apiSurface (e.g., list wrappers, hand-written types added after
|
|
300
|
+
// the last generation).
|
|
274
301
|
const symbols = dirSymbols.get(dirName) ?? new Set<string>();
|
|
275
302
|
try {
|
|
276
303
|
for (const entry of fs.readdirSync(interfacesDir)) {
|
|
@@ -545,8 +572,8 @@ function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
|
545
572
|
// Filter to reachable models only: oagen's generateAllFiles passes only
|
|
546
573
|
// service-referenced models to generateModels, so unreachable models
|
|
547
574
|
// never get interface files. Exporting them here would create broken imports.
|
|
548
|
-
const reachable =
|
|
549
|
-
const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name) && reachable.
|
|
575
|
+
const reachable = computeNonEventReachable(spec.services, spec.models);
|
|
576
|
+
const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name) && reachable.has(m.name));
|
|
550
577
|
const commonEnums = spec.enums.filter((e) => {
|
|
551
578
|
const enumService = findEnumService(e.name, spec.services);
|
|
552
579
|
return !enumService;
|
package/src/node/field-plan.ts
CHANGED
|
@@ -582,7 +582,6 @@ function emitAssignment(lhs: string, expr: string, accessExpr: string, guard: Gu
|
|
|
582
582
|
interface SerializerContext {
|
|
583
583
|
modelToService: Map<string, string>;
|
|
584
584
|
resolveDir: (irService: string | undefined) => string;
|
|
585
|
-
useStringDates: boolean;
|
|
586
585
|
dedup: Map<string, string>;
|
|
587
586
|
skippedSerializeModels: Set<string>;
|
|
588
587
|
ctx: EmitterContext;
|
|
@@ -614,31 +613,30 @@ export function buildSerializerImports(
|
|
|
614
613
|
const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
|
|
615
614
|
const depName = resolveInterfaceName(dep, sctx.ctx);
|
|
616
615
|
const rel = relativeImport(serializerPath, depSerializerPath);
|
|
617
|
-
|
|
616
|
+
// Check the canonical name for dedup'd models
|
|
617
|
+
const canon = sctx.dedup.get(dep);
|
|
618
|
+
const depSkipSerialize =
|
|
619
|
+
sctx.skippedSerializeModels.has(dep) || (canon != null && sctx.skippedSerializeModels.has(canon));
|
|
620
|
+
if (depSkipSerialize) {
|
|
621
|
+
lines.push(`import { deserialize${depName} } from '${rel}';`);
|
|
622
|
+
} else {
|
|
623
|
+
lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
|
|
624
|
+
}
|
|
618
625
|
}
|
|
619
626
|
lines.push('');
|
|
620
627
|
return lines;
|
|
621
628
|
}
|
|
622
629
|
|
|
623
630
|
/** Build the set of field names where format conversion should be skipped. */
|
|
624
|
-
export function buildSkipFormatFields(
|
|
625
|
-
model: Model,
|
|
626
|
-
useStringDates: boolean,
|
|
627
|
-
baselineDomain: BaselineInterface | undefined,
|
|
628
|
-
): Set<string> {
|
|
631
|
+
export function buildSkipFormatFields(model: Model, baselineDomain: BaselineInterface | undefined): Set<string> {
|
|
629
632
|
const skipFormatFields = new Set<string>();
|
|
630
|
-
if (useStringDates) {
|
|
631
|
-
for (const field of model.fields) {
|
|
632
|
-
if (hasDateTimeConversion(field.type)) {
|
|
633
|
-
skipFormatFields.add(field.name);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
633
|
if (baselineDomain) {
|
|
638
634
|
for (const field of model.fields) {
|
|
639
635
|
if (skipFormatFields.has(field.name)) continue;
|
|
640
636
|
const baselineField = baselineDomain.fields?.[fieldName(field.name)];
|
|
641
637
|
if (baselineField && !baselineField.type.includes('Date') && hasFormatConversion(field.type)) {
|
|
638
|
+
// Always convert date-time fields to Date regardless of baseline
|
|
639
|
+
if (hasDateTimeConversion(field.type)) continue;
|
|
642
640
|
skipFormatFields.add(field.name);
|
|
643
641
|
}
|
|
644
642
|
}
|
package/src/node/fixtures.ts
CHANGED
|
@@ -48,15 +48,51 @@ export function generateFixtures(
|
|
|
48
48
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
49
49
|
const files: { path: string; content: string }[] = [];
|
|
50
50
|
|
|
51
|
+
// Only generate fixtures for models reachable from non-event operations
|
|
52
|
+
const fixtureSeeds = new Set<string>();
|
|
53
|
+
for (const svc of spec.services) {
|
|
54
|
+
if (svc.name.toLowerCase() === 'events') continue;
|
|
55
|
+
for (const op of svc.operations) {
|
|
56
|
+
const collectFromRef = (t: import('@workos/oagen').TypeRef | undefined): void => {
|
|
57
|
+
if (!t) return;
|
|
58
|
+
if (t.kind === 'model') fixtureSeeds.add(t.name);
|
|
59
|
+
if (t.kind === 'array') collectFromRef(t.items);
|
|
60
|
+
if (t.kind === 'nullable') collectFromRef(t.inner);
|
|
61
|
+
if (t.kind === 'union') t.variants.forEach(collectFromRef);
|
|
62
|
+
};
|
|
63
|
+
collectFromRef(op.response);
|
|
64
|
+
collectFromRef(op.requestBody);
|
|
65
|
+
if (op.pagination?.itemType) collectFromRef(op.pagination.itemType);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const fixtureModelMap = new Map(spec.models.map((m: Model) => [m.name, m]));
|
|
69
|
+
const fixtureReachable = new Set<string>();
|
|
70
|
+
const fixtureQueue = [...fixtureSeeds];
|
|
71
|
+
while (fixtureQueue.length > 0) {
|
|
72
|
+
const name = fixtureQueue.pop()!;
|
|
73
|
+
if (fixtureReachable.has(name)) continue;
|
|
74
|
+
fixtureReachable.add(name);
|
|
75
|
+
const m = fixtureModelMap.get(name);
|
|
76
|
+
if (!m) continue;
|
|
77
|
+
for (const field of m.fields) {
|
|
78
|
+
const walk = (t: import('@workos/oagen').TypeRef): void => {
|
|
79
|
+
if (t.kind === 'model' && !fixtureReachable.has(t.name)) fixtureQueue.push(t.name);
|
|
80
|
+
if (t.kind === 'array') walk(t.items);
|
|
81
|
+
if (t.kind === 'nullable') walk(t.inner);
|
|
82
|
+
if (t.kind === 'union') t.variants.forEach(walk);
|
|
83
|
+
};
|
|
84
|
+
walk(field.type);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
51
87
|
const seenFixturePaths = new Set<string>();
|
|
52
88
|
for (const model of spec.models) {
|
|
53
|
-
|
|
89
|
+
if (!fixtureReachable.has(model.name)) continue;
|
|
54
90
|
if (isListMetadataModel(model)) continue;
|
|
55
91
|
if (isListWrapperModel(model)) continue;
|
|
56
92
|
|
|
57
93
|
const service = modelToService.get(model.name);
|
|
58
94
|
const dirName = resolveDir(service);
|
|
59
|
-
const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.
|
|
95
|
+
const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.json`;
|
|
60
96
|
|
|
61
97
|
// After noise suffix stripping, multiple models may resolve to the same
|
|
62
98
|
// fixture path (e.g., OrganizationDto and Organization). Skip duplicates.
|
|
@@ -94,7 +130,7 @@ export function generateFixtures(
|
|
|
94
130
|
},
|
|
95
131
|
};
|
|
96
132
|
files.push({
|
|
97
|
-
path: `src/${serviceDir}/fixtures/list-${fileName(itemModel.name)}.
|
|
133
|
+
path: `src/${serviceDir}/fixtures/list-${fileName(itemModel.name)}.json`,
|
|
98
134
|
content: JSON.stringify(listFixture, null, 2),
|
|
99
135
|
});
|
|
100
136
|
}
|
package/src/node/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { generateClient } from './client.js';
|
|
|
18
18
|
import { generateErrors } from './errors.js';
|
|
19
19
|
|
|
20
20
|
import { generateTests } from './tests.js';
|
|
21
|
-
import {
|
|
21
|
+
import { buildOperationsMap } from './manifest.js';
|
|
22
22
|
|
|
23
23
|
/** Ensure every generated file's content ends with a trailing newline. */
|
|
24
24
|
function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
@@ -62,8 +62,8 @@ export const nodeEmitter: Emitter = {
|
|
|
62
62
|
return ensureTrailingNewlines(generateTests(spec, ctx));
|
|
63
63
|
},
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
return
|
|
65
|
+
buildOperationsMap(spec: ApiSpec, ctx: EmitterContext) {
|
|
66
|
+
return buildOperationsMap(spec, ctx);
|
|
67
67
|
},
|
|
68
68
|
|
|
69
69
|
fileHeader(): string {
|
package/src/node/manifest.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { ApiSpec, EmitterContext,
|
|
1
|
+
import type { ApiSpec, EmitterContext, OperationsMap } from '@workos/oagen';
|
|
2
2
|
import { resolveMethodName, servicePropertyName } from './naming.js';
|
|
3
3
|
import { resolveResourceClassName } from './resources.js';
|
|
4
4
|
|
|
5
|
-
export function
|
|
6
|
-
const manifest:
|
|
5
|
+
export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): OperationsMap {
|
|
6
|
+
const manifest: OperationsMap = {};
|
|
7
7
|
|
|
8
8
|
for (const service of spec.services) {
|
|
9
9
|
const propName = servicePropertyName(resolveResourceClassName(service, ctx));
|
|
@@ -14,12 +14,5 @@ export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
return
|
|
18
|
-
{
|
|
19
|
-
path: 'smoke-manifest.json',
|
|
20
|
-
content: JSON.stringify(manifest, null, 2),
|
|
21
|
-
integrateTarget: false,
|
|
22
|
-
overwriteExisting: true,
|
|
23
|
-
},
|
|
24
|
-
];
|
|
17
|
+
return manifest;
|
|
25
18
|
}
|