@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/node/resources.ts
CHANGED
|
@@ -263,17 +263,15 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
263
263
|
|
|
264
264
|
for (const service of mergedServices) {
|
|
265
265
|
if (isServiceCoveredByExisting(service, ctx)) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const file = generateResourceClass(service, ctx);
|
|
269
|
-
// When the baseline class is missing methods for some operations,
|
|
270
|
-
// remove skipIfExists so the merger adds the new methods.
|
|
271
|
-
if (hasMethodsAbsentFromBaseline(service, ctx)) {
|
|
272
|
-
delete file.skipIfExists;
|
|
273
|
-
// Suppress auto-generated header — the file is a merge target
|
|
274
|
-
// containing hand-written code, not a fully generated file.
|
|
275
|
-
file.headerPlacement = 'skip';
|
|
266
|
+
if (!hasMethodsAbsentFromBaseline(service, ctx)) {
|
|
267
|
+
continue; // Fully covered, no new methods -- skip entirely
|
|
276
268
|
}
|
|
269
|
+
// Partially covered -- has new methods that need to be added.
|
|
270
|
+
const file = generateResourceClass(service, ctx);
|
|
271
|
+
delete file.skipIfExists;
|
|
272
|
+
// Suppress auto-generated header — the file is a merge target
|
|
273
|
+
// containing hand-written code, not a fully generated file.
|
|
274
|
+
file.headerPlacement = 'skip';
|
|
277
275
|
files.push(file);
|
|
278
276
|
continue;
|
|
279
277
|
}
|
|
@@ -307,6 +305,7 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
307
305
|
// stable. Placing them under `interfaces/` lets the per-service barrel
|
|
308
306
|
// pick them up automatically.
|
|
309
307
|
for (const service of mergedServices) {
|
|
308
|
+
if (isServiceCoveredByExisting(service, ctx) && !hasMethodsAbsentFromBaseline(service, ctx)) continue;
|
|
310
309
|
files.push(...generatePaginatedOptionsInterfaces(service, ctx, topLevelEnumNames));
|
|
311
310
|
}
|
|
312
311
|
|
|
@@ -384,11 +383,19 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
384
383
|
const paramEnums = new Set<string>();
|
|
385
384
|
const paramModels = new Set<string>();
|
|
386
385
|
for (const { op, plan, method } of plans) {
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
386
|
+
// Always collect param type refs for enums — inline options interfaces
|
|
387
|
+
// are generated for all methods (including baseline ones), so their
|
|
388
|
+
// type dependencies must always be imported.
|
|
389
|
+
const queryParams = plan.isPaginated
|
|
390
|
+
? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name))
|
|
391
|
+
: op.queryParams;
|
|
392
|
+
for (const param of [...queryParams, ...op.pathParams]) {
|
|
393
|
+
collectParamTypeRefs(param.type, paramEnums, paramModels);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Skip response/request model imports for methods that already exist in
|
|
397
|
+
// the baseline class. The merger keeps baseline method bodies, so their
|
|
398
|
+
// imports are already present in the existing file.
|
|
392
399
|
if (baselineMethodSet.has(method)) continue;
|
|
393
400
|
|
|
394
401
|
if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
|
|
@@ -427,15 +434,6 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
427
434
|
}
|
|
428
435
|
}
|
|
429
436
|
}
|
|
430
|
-
// Collect types referenced in query and path parameters.
|
|
431
|
-
// For paginated operations, skip standard pagination params (limit, before, after, order)
|
|
432
|
-
// since they're handled by PaginationOptions and don't need explicit imports.
|
|
433
|
-
const queryParams = plan.isPaginated
|
|
434
|
-
? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name))
|
|
435
|
-
: op.queryParams;
|
|
436
|
-
for (const param of [...queryParams, ...op.pathParams]) {
|
|
437
|
-
collectParamTypeRefs(param.type, paramEnums, paramModels);
|
|
438
|
-
}
|
|
439
437
|
}
|
|
440
438
|
|
|
441
439
|
// Collect response models from union split wrappers so their types and
|
|
@@ -467,8 +465,8 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
467
465
|
lines.push("import type { WorkOS } from '../workos';");
|
|
468
466
|
if (hasPaginated) {
|
|
469
467
|
lines.push("import type { PaginationOptions } from '../common/interfaces/pagination-options.interface';");
|
|
470
|
-
lines.push("import
|
|
471
|
-
lines.push("import {
|
|
468
|
+
lines.push("import { AutoPaginatable } from '../common/utils/pagination';");
|
|
469
|
+
lines.push("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';");
|
|
472
470
|
}
|
|
473
471
|
|
|
474
472
|
// Paginated list options live in their own interface files so they're
|
|
@@ -731,12 +729,16 @@ function renderMethod(
|
|
|
731
729
|
if (op.description) docParts.push(op.description);
|
|
732
730
|
for (const param of op.pathParams) {
|
|
733
731
|
const paramName = fieldName(param.name);
|
|
734
|
-
|
|
732
|
+
// When the overlay folds a path param into the options object,
|
|
733
|
+
// document it as @param options.paramName instead of top-level.
|
|
734
|
+
const folded = validParamNames && !validParamNames.has(paramName) && validParamNames.has('options');
|
|
735
|
+
if (validParamNames && !validParamNames.has(paramName) && !folded) continue;
|
|
736
|
+
const docName = folded ? `options.${paramName}` : paramName;
|
|
735
737
|
const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
|
|
736
738
|
if (param.description) {
|
|
737
|
-
docParts.push(`@param ${
|
|
739
|
+
docParts.push(`@param ${docName} - ${deprecatedPrefix}${param.description}`);
|
|
738
740
|
} else if (param.deprecated) {
|
|
739
|
-
docParts.push(`@param ${
|
|
741
|
+
docParts.push(`@param ${docName} - (deprecated)`);
|
|
740
742
|
}
|
|
741
743
|
if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
|
|
742
744
|
if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
|
|
@@ -763,36 +765,122 @@ function renderMethod(
|
|
|
763
765
|
}
|
|
764
766
|
// Skip header and cookie params in JSDoc — they are not exposed in the method signature.
|
|
765
767
|
// The SDK handles headers and cookies internally, so documenting them would be misleading.
|
|
766
|
-
// Document payload parameter when there is a request body
|
|
768
|
+
// Document payload/body parameter when there is a request body.
|
|
769
|
+
// Detect the actual param name from the overlay — the hand-written SDK may
|
|
770
|
+
// fold the body into an 'options' param instead of the generated 'payload'.
|
|
771
|
+
let bodyDocParamName: string | null = null;
|
|
767
772
|
if (plan.hasBody) {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
773
|
+
let bodyParamName = 'payload';
|
|
774
|
+
let overlayHadUnusableName = false;
|
|
775
|
+
if (validParamNames && !validParamNames.has('payload') && overlayMethod) {
|
|
776
|
+
const pathNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
|
|
777
|
+
const candidates = overlayMethod.params.filter((p) => !pathNames.has(p.name) && p.name !== 'requestOptions');
|
|
778
|
+
// Filter out destructuring artifacts (e.g., __0) from extracted param names
|
|
779
|
+
const isUsableName = (name: string) => !/^__\d+$/.test(name);
|
|
780
|
+
if (candidates.length === 1 && isUsableName(candidates[0].name)) {
|
|
781
|
+
bodyParamName = candidates[0].name;
|
|
782
|
+
} else if (candidates.length === 1 && !isUsableName(candidates[0].name)) {
|
|
783
|
+
overlayHadUnusableName = true;
|
|
784
|
+
} else if (candidates.length > 1) {
|
|
785
|
+
// Multiple non-path params — match by type against the request body model
|
|
786
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
787
|
+
if (bodyInfo?.kind === 'model') {
|
|
788
|
+
const bodyTypeName = resolveInterfaceName(bodyInfo.name, ctx);
|
|
789
|
+
const typeMatch = candidates.find((p) => p.type === bodyTypeName && isUsableName(p.name));
|
|
790
|
+
if (typeMatch) {
|
|
791
|
+
bodyParamName = typeMatch.name;
|
|
792
|
+
} else {
|
|
793
|
+
// Type names diverge (overlay vs spec). Fall back to matching
|
|
794
|
+
// overlay param names against body model field names so each
|
|
795
|
+
// extracted param gets documented individually.
|
|
796
|
+
const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
|
|
797
|
+
if (bodyModel) {
|
|
798
|
+
const fieldMap = new Map(bodyModel.fields.map((f) => [fieldName(f.name), f]));
|
|
799
|
+
for (const candidate of candidates) {
|
|
800
|
+
const matchingField = fieldMap.get(candidate.name);
|
|
801
|
+
if (matchingField?.description) {
|
|
802
|
+
docParts.push(`@param ${candidate.name} - ${matchingField.description}`);
|
|
803
|
+
if (matchingField.example !== undefined)
|
|
804
|
+
docParts.push(`@example ${JSON.stringify(matchingField.example)}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
bodyDocParamName = '__multi__'; // prevent duplicate body doc below
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// When the overlay had an unusable param name (e.g., destructured __0),
|
|
815
|
+
// force documentation under 'payload' so the body isn't silently dropped.
|
|
816
|
+
if (
|
|
817
|
+
bodyDocParamName !== '__multi__' &&
|
|
818
|
+
(overlayHadUnusableName || !validParamNames || validParamNames.has(bodyParamName))
|
|
819
|
+
) {
|
|
820
|
+
bodyDocParamName = bodyParamName;
|
|
821
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
822
|
+
if (bodyInfo?.kind === 'model') {
|
|
823
|
+
const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
|
|
824
|
+
let bodyDesc: string;
|
|
825
|
+
if (bodyModel?.description) {
|
|
826
|
+
bodyDesc = `@param ${bodyParamName} - ${bodyModel.description}`;
|
|
827
|
+
} else if (bodyModel) {
|
|
828
|
+
// When the model lacks a description, list its required fields to help
|
|
829
|
+
// callers understand what must be provided.
|
|
830
|
+
const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
|
|
831
|
+
bodyDesc =
|
|
832
|
+
requiredFieldNames.length > 0
|
|
833
|
+
? `@param ${bodyParamName} - Object containing ${requiredFieldNames.join(', ')}.`
|
|
834
|
+
: `@param ${bodyParamName} - The request body.`;
|
|
835
|
+
} else {
|
|
836
|
+
bodyDesc = `@param ${bodyParamName} - The request body.`;
|
|
837
|
+
}
|
|
838
|
+
docParts.push(bodyDesc);
|
|
839
|
+
|
|
840
|
+
// When the body is folded into an options-style param (not 'payload'),
|
|
841
|
+
// expand individual fields so IDEs surface per-field documentation.
|
|
842
|
+
if (bodyParamName !== 'payload' && bodyModel) {
|
|
843
|
+
for (const bField of bodyModel.fields) {
|
|
844
|
+
const fName = `${bodyParamName}.${fieldName(bField.name)}`;
|
|
845
|
+
const deprecatedPrefix = bField.deprecated ? '(deprecated) ' : '';
|
|
846
|
+
if (bField.description) {
|
|
847
|
+
docParts.push(`@param ${fName} - ${deprecatedPrefix}${bField.description}`);
|
|
848
|
+
} else if (bField.deprecated) {
|
|
849
|
+
docParts.push(`@param ${fName} - (deprecated)`);
|
|
850
|
+
}
|
|
851
|
+
if (bField.example !== undefined) docParts.push(`@example ${JSON.stringify(bField.example)}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
782
854
|
} else {
|
|
783
|
-
|
|
855
|
+
docParts.push(`@param ${bodyParamName} - The request body.`);
|
|
784
856
|
}
|
|
785
|
-
docParts.push(payloadDesc);
|
|
786
|
-
} else {
|
|
787
|
-
docParts.push('@param payload - The request body.');
|
|
788
857
|
}
|
|
789
858
|
}
|
|
790
859
|
// Document options parameter for paginated operations
|
|
860
|
+
const hasOptionsSummary = () => docParts.some((p) => /^@param options(\s|$)/.test(p));
|
|
791
861
|
if (plan.isPaginated) {
|
|
792
862
|
docParts.push('@param options - Pagination and filter options.');
|
|
793
|
-
} else if (op.queryParams.filter((q) => !hiddenParams.has(q.name)).length > 0) {
|
|
863
|
+
} else if (!hasOptionsSummary() && op.queryParams.filter((q) => !hiddenParams.has(q.name)).length > 0) {
|
|
794
864
|
docParts.push('@param options - Additional query options.');
|
|
795
865
|
}
|
|
866
|
+
// Ensure an @param options summary exists when there are @param options.xxx
|
|
867
|
+
// entries (from folded path/body params) or when the overlay exposes an
|
|
868
|
+
// options param that wasn't otherwise documented.
|
|
869
|
+
if (validParamNames?.has('options') && !hasOptionsSummary()) {
|
|
870
|
+
const hasOptionsDotEntries = docParts.some((p) => p.startsWith('@param options.'));
|
|
871
|
+
if (hasOptionsDotEntries || overlayMethod) {
|
|
872
|
+
docParts.push('@param options - The request options.');
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
// Reorder: ensure @param options summary comes before any @param options.xxx entries
|
|
876
|
+
{
|
|
877
|
+
const summaryIdx = docParts.findIndex((p) => /^@param options(\s|$)/.test(p));
|
|
878
|
+
const firstDotIdx = docParts.findIndex((p) => p.startsWith('@param options.'));
|
|
879
|
+
if (summaryIdx > firstDotIdx && firstDotIdx >= 0) {
|
|
880
|
+
const [summary] = docParts.splice(summaryIdx, 1);
|
|
881
|
+
docParts.splice(firstDotIdx, 0, summary);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
796
884
|
// @returns for the primary response model.
|
|
797
885
|
// When an overlay method exists, prefer its return type so the JSDoc
|
|
798
886
|
// matches the actual TypeScript signature (the overlay may use a
|
|
@@ -924,10 +1012,26 @@ function renderPaginatedMethod(
|
|
|
924
1012
|
const pathParams = buildPathParams(op, specEnumNames);
|
|
925
1013
|
const allParams = pathParams ? `${pathParams}, options?: ${optionsType}` : `options?: ${optionsType}`;
|
|
926
1014
|
|
|
1015
|
+
const wireType = wireInterfaceName(itemType);
|
|
1016
|
+
const serializeCall = serializerArg ? `options ? serialize${optionsType}(options) : undefined` : 'options';
|
|
1017
|
+
|
|
927
1018
|
lines.push(` async ${method}(${allParams}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
|
|
928
|
-
lines.push(
|
|
929
|
-
|
|
930
|
-
);
|
|
1019
|
+
lines.push(` return new AutoPaginatable(`);
|
|
1020
|
+
lines.push(` await fetchAndDeserialize<${wireType}, ${itemType}>(`);
|
|
1021
|
+
lines.push(` this.workos,`);
|
|
1022
|
+
lines.push(` ${pathStr},`);
|
|
1023
|
+
lines.push(` deserialize${itemType},`);
|
|
1024
|
+
lines.push(` ${serializeCall},`);
|
|
1025
|
+
lines.push(` ),`);
|
|
1026
|
+
lines.push(` (params) =>`);
|
|
1027
|
+
lines.push(` fetchAndDeserialize<${wireType}, ${itemType}>(`);
|
|
1028
|
+
lines.push(` this.workos,`);
|
|
1029
|
+
lines.push(` ${pathStr},`);
|
|
1030
|
+
lines.push(` deserialize${itemType},`);
|
|
1031
|
+
lines.push(` params,`);
|
|
1032
|
+
lines.push(` ),`);
|
|
1033
|
+
lines.push(` options,`);
|
|
1034
|
+
lines.push(` );`);
|
|
931
1035
|
lines.push(' }');
|
|
932
1036
|
}
|
|
933
1037
|
|
package/src/node/tests.ts
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
relativeImport,
|
|
21
21
|
isListMetadataModel,
|
|
22
22
|
isListWrapperModel,
|
|
23
|
+
modelHasNewFields,
|
|
24
|
+
computeNonEventReachable,
|
|
23
25
|
} from './utils.js';
|
|
24
26
|
import { groupByMount } from '../shared/resolved-ops.js';
|
|
25
27
|
|
|
@@ -29,7 +31,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
29
31
|
// Generate fixture JSON files
|
|
30
32
|
const fixtures = generateFixtures(spec, ctx);
|
|
31
33
|
for (const f of fixtures) {
|
|
32
|
-
files.push({ path: f.path, content: f.content, headerPlacement: 'skip',
|
|
34
|
+
files.push({ path: f.path, content: f.content, headerPlacement: 'skip', skipIfExists: true });
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
// Build model lookup for response field assertions
|
|
@@ -54,11 +56,33 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
54
56
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
55
57
|
: spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
|
|
56
58
|
|
|
59
|
+
// When integrating into an existing SDK, only generate tests for services
|
|
60
|
+
// that have a registered property on the WorkOS class. The WorkOS client
|
|
61
|
+
// file uses skipIfExists, so new service properties aren't added — tests
|
|
62
|
+
// referencing workos.<service> would fail at compile time.
|
|
63
|
+
const baselineWorkOSProps = new Set<string>();
|
|
64
|
+
if (ctx.apiSurface?.classes?.['WorkOS']?.methods) {
|
|
65
|
+
// The extractor stores property accessors as methods
|
|
66
|
+
for (const name of Object.keys(ctx.apiSurface.classes['WorkOS'].methods)) {
|
|
67
|
+
baselineWorkOSProps.add(name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (ctx.apiSurface?.classes?.['WorkOS']?.properties) {
|
|
71
|
+
for (const name of Object.keys(ctx.apiSurface.classes['WorkOS'].properties)) {
|
|
72
|
+
baselineWorkOSProps.add(name);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
for (const { name: mountName, operations } of testEntries) {
|
|
58
77
|
if (operations.length === 0) continue;
|
|
59
78
|
const mergedService: Service = { name: mountName, operations };
|
|
60
79
|
const ops = uncoveredOperations(mergedService, ctx);
|
|
61
80
|
if (ops.length === 0) continue;
|
|
81
|
+
|
|
82
|
+
// Skip tests for services without a WorkOS property in the baseline
|
|
83
|
+
const propName = mountAccessors.get(mountName) ?? servicePropertyName(mountName);
|
|
84
|
+
if (ctx.apiSurface && baselineWorkOSProps.size > 0 && !baselineWorkOSProps.has(propName)) continue;
|
|
85
|
+
|
|
62
86
|
const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
|
|
63
87
|
files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
|
|
64
88
|
}
|
|
@@ -155,7 +179,7 @@ function generateServiceTest(
|
|
|
155
179
|
// List fixtures are always generated in the current service's directory
|
|
156
180
|
// (the service owning the list operation), not in the model's home service.
|
|
157
181
|
// Always use a local import path.
|
|
158
|
-
const fixturePath = `./fixtures/list-${fileName(itemModelName)}.
|
|
182
|
+
const fixturePath = `./fixtures/list-${fileName(itemModelName)}.json`;
|
|
159
183
|
fixtureImports.add(`import list${itemModelName}Fixture from '${fixturePath}';`);
|
|
160
184
|
}
|
|
161
185
|
} else if (plan.responseModelName) {
|
|
@@ -163,8 +187,8 @@ function generateServiceTest(
|
|
|
163
187
|
const respDir = resolveDir(respService);
|
|
164
188
|
const fixturePath =
|
|
165
189
|
respDir === serviceDir
|
|
166
|
-
? `./fixtures/${fileName(plan.responseModelName)}.
|
|
167
|
-
: `../${respDir}/fixtures/${fileName(plan.responseModelName)}.
|
|
190
|
+
? `./fixtures/${fileName(plan.responseModelName)}.json`
|
|
191
|
+
: `../${respDir}/fixtures/${fileName(plan.responseModelName)}.json`;
|
|
168
192
|
fixtureImports.add(`import ${toCamelCase(plan.responseModelName)}Fixture from '${fixturePath}';`);
|
|
169
193
|
}
|
|
170
194
|
// NOTE: Request body fixtures are not imported for body tests because
|
|
@@ -216,7 +240,7 @@ function generateServiceTest(
|
|
|
216
240
|
|
|
217
241
|
lines.push('});');
|
|
218
242
|
|
|
219
|
-
return { path: testPath, content: lines.join('\n'),
|
|
243
|
+
return { path: testPath, content: lines.join('\n'), overwriteExisting: true };
|
|
220
244
|
}
|
|
221
245
|
|
|
222
246
|
/** Compute the test value for a single path parameter.
|
|
@@ -840,12 +864,24 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
840
864
|
|
|
841
865
|
// Only generate round-trip tests for models with fields that have serializers generated.
|
|
842
866
|
// Skip list metadata and list wrapper models since their serializers are not emitted.
|
|
867
|
+
// Skip models unchanged from baseline (no new fields) since their serializers are not regenerated.
|
|
868
|
+
// Skip models unreachable from non-event services (no model/serializer files generated).
|
|
869
|
+
const nonEventReachable = computeNonEventReachable(spec.services, spec.models);
|
|
843
870
|
const eligibleModels = spec.models.filter(
|
|
844
|
-
(m) =>
|
|
871
|
+
(m) =>
|
|
872
|
+
nonEventReachable.has(m.name) &&
|
|
873
|
+
modelNeedsRoundTripTest(m) &&
|
|
874
|
+
!isListMetadataModel(m) &&
|
|
875
|
+
!isListWrapperModel(m) &&
|
|
876
|
+
modelHasNewFields(m, ctx),
|
|
845
877
|
);
|
|
846
878
|
|
|
847
879
|
if (eligibleModels.length === 0) return files;
|
|
848
880
|
|
|
881
|
+
// Use the skipped-serialize set computed by the serializer generator.
|
|
882
|
+
// It's stashed on the context during generateSerializers.
|
|
883
|
+
const serializeSkipped: Set<string> = (ctx as any)._skippedSerializeModels ?? new Set<string>();
|
|
884
|
+
|
|
849
885
|
// Group eligible models by service directory for one test file per service
|
|
850
886
|
const modelsByDir = new Map<string, Model[]>();
|
|
851
887
|
for (const model of eligibleModels) {
|
|
@@ -872,14 +908,20 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
872
908
|
const modelDir = resolveDir(service);
|
|
873
909
|
const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
874
910
|
const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
875
|
-
const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.
|
|
911
|
+
const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
|
|
876
912
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
913
|
+
if (serializeSkipped.has(model.name)) {
|
|
914
|
+
serializerImports.push(
|
|
915
|
+
`import { deserialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
916
|
+
);
|
|
917
|
+
} else {
|
|
918
|
+
serializerImports.push(
|
|
919
|
+
`import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
880
922
|
const wireName = wireInterfaceName(domainName);
|
|
881
923
|
interfaceImports.push(`import type { ${wireName} } from '${relativeImport(testPath, interfacePath)}';`);
|
|
882
|
-
const camelName =
|
|
924
|
+
const camelName = toCamelCase(domainName);
|
|
883
925
|
fixtureImports.push(`import ${camelName}Fixture from '${relativeImport(testPath, fixturePath)}';`);
|
|
884
926
|
}
|
|
885
927
|
|
|
@@ -896,29 +938,36 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
896
938
|
|
|
897
939
|
for (const model of models) {
|
|
898
940
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
899
|
-
const
|
|
900
|
-
const fixtureName = `${camelDomain}Fixture`;
|
|
901
|
-
|
|
941
|
+
const fixtureName = `${toCamelCase(domainName)}Fixture`;
|
|
902
942
|
const wireName = wireInterfaceName(domainName);
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
943
|
+
|
|
944
|
+
if (serializeSkipped.has(model.name)) {
|
|
945
|
+
// Deserialize-only test (no serialize function available)
|
|
946
|
+
lines.push(`describe('${domainName}Serializer', () => {`);
|
|
947
|
+
lines.push(" it('deserializes correctly', () => {");
|
|
948
|
+
lines.push(` const fixture = ${fixtureName} as ${wireName};`);
|
|
949
|
+
lines.push(` const deserialized = deserialize${domainName}(fixture);`);
|
|
950
|
+
lines.push(' expect(deserialized).toBeDefined();');
|
|
951
|
+
lines.push(' });');
|
|
952
|
+
lines.push('});');
|
|
953
|
+
} else {
|
|
954
|
+
// Round-trip test
|
|
955
|
+
lines.push(`describe('${domainName}Serializer', () => {`);
|
|
956
|
+
lines.push(" it('round-trips through serialize/deserialize', () => {");
|
|
957
|
+
lines.push(` const fixture = ${fixtureName} as ${wireName};`);
|
|
958
|
+
lines.push(` const deserialized = deserialize${domainName}(fixture);`);
|
|
959
|
+
lines.push(` const reserialized = serialize${domainName}(deserialized);`);
|
|
960
|
+
lines.push(' expect(reserialized).toEqual(expect.objectContaining(fixture));');
|
|
961
|
+
lines.push(' });');
|
|
962
|
+
lines.push('});');
|
|
963
|
+
}
|
|
915
964
|
lines.push('');
|
|
916
965
|
}
|
|
917
966
|
|
|
918
967
|
files.push({
|
|
919
968
|
path: testPath,
|
|
920
969
|
content: lines.join('\n'),
|
|
921
|
-
|
|
970
|
+
overwriteExisting: true,
|
|
922
971
|
});
|
|
923
972
|
}
|
|
924
973
|
|
package/src/node/type-map.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
|
|
|
3
3
|
import { wireInterfaceName } from './naming.js';
|
|
4
4
|
|
|
5
5
|
export interface MapTypeRefOpts {
|
|
6
|
-
stringDates?: boolean;
|
|
7
6
|
/** Map from model name → default type args (e.g., `'<Record<string, unknown>>'`).
|
|
8
7
|
* When present, model refs for generic models get their defaults appended. */
|
|
9
8
|
genericDefaults?: Map<string, string>;
|
|
@@ -13,16 +12,12 @@ export interface MapTypeRefOpts {
|
|
|
13
12
|
* Map an IR TypeRef to a TypeScript domain type string.
|
|
14
13
|
* Domain types use PascalCase model names (e.g., `Organization`).
|
|
15
14
|
*
|
|
16
|
-
* @param opts.stringDates - When true, map `date-time` to `string` instead of `Date`.
|
|
17
|
-
* Use this when integrating into an existing SDK that represents timestamps as
|
|
18
|
-
* ISO 8601 strings rather than Date objects.
|
|
19
15
|
* @param opts.genericDefaults - When present, appends default type args to generic model refs.
|
|
20
16
|
*/
|
|
21
17
|
export function mapTypeRef(ref: TypeRef, opts?: MapTypeRefOpts): string {
|
|
22
|
-
const primMapper = opts?.stringDates ? mapPrimitiveStringDates : mapPrimitive;
|
|
23
18
|
const genericDefaults = opts?.genericDefaults;
|
|
24
19
|
return irMapTypeRef<string>(ref, {
|
|
25
|
-
primitive:
|
|
20
|
+
primitive: mapPrimitive,
|
|
26
21
|
array: (_r, items) => `${parenthesizeUnion(items)}[]`,
|
|
27
22
|
model: (r) => r.name + (genericDefaults?.get(r.name) ?? ''),
|
|
28
23
|
enum: (r) => r.name,
|
|
@@ -74,31 +69,6 @@ function mapPrimitive(ref: PrimitiveType): string {
|
|
|
74
69
|
}
|
|
75
70
|
}
|
|
76
71
|
|
|
77
|
-
/**
|
|
78
|
-
* Map a primitive type using string representation for dates.
|
|
79
|
-
* Used when the existing SDK represents timestamps as ISO 8601 strings.
|
|
80
|
-
*/
|
|
81
|
-
function mapPrimitiveStringDates(ref: PrimitiveType): string {
|
|
82
|
-
if (ref.format) {
|
|
83
|
-
switch (ref.format) {
|
|
84
|
-
case 'int64':
|
|
85
|
-
return 'bigint';
|
|
86
|
-
// date-time intentionally falls through to the string case
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
switch (ref.type) {
|
|
90
|
-
case 'string':
|
|
91
|
-
return 'string';
|
|
92
|
-
case 'integer':
|
|
93
|
-
case 'number':
|
|
94
|
-
return 'number';
|
|
95
|
-
case 'boolean':
|
|
96
|
-
return 'boolean';
|
|
97
|
-
case 'unknown':
|
|
98
|
-
return 'any';
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
72
|
/**
|
|
103
73
|
* Map an IR PrimitiveType to a TypeScript wire/JSON type string.
|
|
104
74
|
* Wire types match JSON encoding: date-time stays string, int64 stays string/number.
|