@workos/oagen-emitters 0.3.0 → 0.5.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 +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.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 +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- 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 +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- 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/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- 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 +35 -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 +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- 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 +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- 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/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/tests.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
servicePropertyName,
|
|
10
10
|
resolveMethodName,
|
|
11
11
|
resolveInterfaceName,
|
|
12
|
+
wireInterfaceName,
|
|
12
13
|
} from './naming.js';
|
|
13
14
|
import { generateFixtures } from './fixtures.js';
|
|
14
15
|
import { resolveResourceClassName } from './resources.js';
|
|
@@ -19,6 +20,8 @@ import {
|
|
|
19
20
|
relativeImport,
|
|
20
21
|
isListMetadataModel,
|
|
21
22
|
isListWrapperModel,
|
|
23
|
+
modelHasNewFields,
|
|
24
|
+
computeNonEventReachable,
|
|
22
25
|
} from './utils.js';
|
|
23
26
|
import { groupByMount } from '../shared/resolved-ops.js';
|
|
24
27
|
|
|
@@ -28,7 +31,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
28
31
|
// Generate fixture JSON files
|
|
29
32
|
const fixtures = generateFixtures(spec, ctx);
|
|
30
33
|
for (const f of fixtures) {
|
|
31
|
-
files.push({ path: f.path, content: f.content, headerPlacement: 'skip',
|
|
34
|
+
files.push({ path: f.path, content: f.content, headerPlacement: 'skip', skipIfExists: true });
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
// Build model lookup for response field assertions
|
|
@@ -53,11 +56,33 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
53
56
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
54
57
|
: spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
|
|
55
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
|
+
|
|
56
76
|
for (const { name: mountName, operations } of testEntries) {
|
|
57
77
|
if (operations.length === 0) continue;
|
|
58
78
|
const mergedService: Service = { name: mountName, operations };
|
|
59
79
|
const ops = uncoveredOperations(mergedService, ctx);
|
|
60
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
|
+
|
|
61
86
|
const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
|
|
62
87
|
files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
|
|
63
88
|
}
|
|
@@ -154,7 +179,7 @@ function generateServiceTest(
|
|
|
154
179
|
// List fixtures are always generated in the current service's directory
|
|
155
180
|
// (the service owning the list operation), not in the model's home service.
|
|
156
181
|
// Always use a local import path.
|
|
157
|
-
const fixturePath = `./fixtures/list-${fileName(itemModelName)}.
|
|
182
|
+
const fixturePath = `./fixtures/list-${fileName(itemModelName)}.json`;
|
|
158
183
|
fixtureImports.add(`import list${itemModelName}Fixture from '${fixturePath}';`);
|
|
159
184
|
}
|
|
160
185
|
} else if (plan.responseModelName) {
|
|
@@ -162,8 +187,8 @@ function generateServiceTest(
|
|
|
162
187
|
const respDir = resolveDir(respService);
|
|
163
188
|
const fixturePath =
|
|
164
189
|
respDir === serviceDir
|
|
165
|
-
? `./fixtures/${fileName(plan.responseModelName)}.
|
|
166
|
-
: `../${respDir}/fixtures/${fileName(plan.responseModelName)}.
|
|
190
|
+
? `./fixtures/${fileName(plan.responseModelName)}.json`
|
|
191
|
+
: `../${respDir}/fixtures/${fileName(plan.responseModelName)}.json`;
|
|
167
192
|
fixtureImports.add(`import ${toCamelCase(plan.responseModelName)}Fixture from '${fixturePath}';`);
|
|
168
193
|
}
|
|
169
194
|
// NOTE: Request body fixtures are not imported for body tests because
|
|
@@ -215,7 +240,7 @@ function generateServiceTest(
|
|
|
215
240
|
|
|
216
241
|
lines.push('});');
|
|
217
242
|
|
|
218
|
-
return { path: testPath, content: lines.join('\n'),
|
|
243
|
+
return { path: testPath, content: lines.join('\n'), overwriteExisting: true };
|
|
219
244
|
}
|
|
220
245
|
|
|
221
246
|
/** Compute the test value for a single path parameter.
|
|
@@ -751,9 +776,21 @@ function buildTestPayload(
|
|
|
751
776
|
op: Operation,
|
|
752
777
|
modelMap: Map<string, Model>,
|
|
753
778
|
): { camelCaseObj: string; snakeCaseObj: string } | null {
|
|
754
|
-
if (!op.requestBody
|
|
755
|
-
|
|
756
|
-
|
|
779
|
+
if (!op.requestBody) return null;
|
|
780
|
+
|
|
781
|
+
// For discriminated unions, build a payload from the first variant so the
|
|
782
|
+
// generated test produces a value that satisfies the union type. Without
|
|
783
|
+
// this, emitted tests pass `{} as any` for union bodies — fine for older
|
|
784
|
+
// permissive runtimes, but the dispatch switch now throws on unknown
|
|
785
|
+
// discriminator values, so the tests would fail before hitting fetch.
|
|
786
|
+
let model: Model | undefined;
|
|
787
|
+
if (op.requestBody.kind === 'union') {
|
|
788
|
+
const firstVariant = op.requestBody.variants.find((v) => v.kind === 'model');
|
|
789
|
+
if (!firstVariant || firstVariant.kind !== 'model') return null;
|
|
790
|
+
model = modelMap.get(firstVariant.name);
|
|
791
|
+
} else if (op.requestBody.kind === 'model') {
|
|
792
|
+
model = modelMap.get(op.requestBody.name);
|
|
793
|
+
}
|
|
757
794
|
if (!model) return null;
|
|
758
795
|
|
|
759
796
|
const fields = model.fields.filter((f) => f.required);
|
|
@@ -789,8 +826,14 @@ function buildTestPayload(
|
|
|
789
826
|
* fall back to `{} as any` to bypass type checking for complex required fields.
|
|
790
827
|
*/
|
|
791
828
|
function fallbackBodyArg(op: Operation, modelMap: Map<string, Model>): string {
|
|
792
|
-
if (!op.requestBody
|
|
793
|
-
|
|
829
|
+
if (!op.requestBody) return '{} as any';
|
|
830
|
+
let model: Model | undefined;
|
|
831
|
+
if (op.requestBody.kind === 'union') {
|
|
832
|
+
const firstVariant = op.requestBody.variants.find((v) => v.kind === 'model');
|
|
833
|
+
if (firstVariant && firstVariant.kind === 'model') model = modelMap.get(firstVariant.name);
|
|
834
|
+
} else if (op.requestBody.kind === 'model') {
|
|
835
|
+
model = modelMap.get(op.requestBody.name);
|
|
836
|
+
}
|
|
794
837
|
if (!model) return '{} as any';
|
|
795
838
|
const hasRequiredFields = model.fields.some((f) => f.required);
|
|
796
839
|
return hasRequiredFields ? '{} as any' : '{}';
|
|
@@ -821,12 +864,24 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
821
864
|
|
|
822
865
|
// Only generate round-trip tests for models with fields that have serializers generated.
|
|
823
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);
|
|
824
870
|
const eligibleModels = spec.models.filter(
|
|
825
|
-
(m) =>
|
|
871
|
+
(m) =>
|
|
872
|
+
nonEventReachable.has(m.name) &&
|
|
873
|
+
modelNeedsRoundTripTest(m) &&
|
|
874
|
+
!isListMetadataModel(m) &&
|
|
875
|
+
!isListWrapperModel(m) &&
|
|
876
|
+
modelHasNewFields(m, ctx),
|
|
826
877
|
);
|
|
827
878
|
|
|
828
879
|
if (eligibleModels.length === 0) return files;
|
|
829
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
|
+
|
|
830
885
|
// Group eligible models by service directory for one test file per service
|
|
831
886
|
const modelsByDir = new Map<string, Model[]>();
|
|
832
887
|
for (const model of eligibleModels) {
|
|
@@ -844,6 +899,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
844
899
|
|
|
845
900
|
// Collect imports
|
|
846
901
|
const serializerImports: string[] = [];
|
|
902
|
+
const interfaceImports: string[] = [];
|
|
847
903
|
const fixtureImports: string[] = [];
|
|
848
904
|
|
|
849
905
|
for (const model of models) {
|
|
@@ -851,18 +907,30 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
851
907
|
const service = modelToService.get(model.name);
|
|
852
908
|
const modelDir = resolveDir(service);
|
|
853
909
|
const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
854
|
-
const
|
|
910
|
+
const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
911
|
+
const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
|
|
855
912
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
+
}
|
|
922
|
+
const wireName = wireInterfaceName(domainName);
|
|
923
|
+
interfaceImports.push(`import type { ${wireName} } from '${relativeImport(testPath, interfacePath)}';`);
|
|
924
|
+
const camelName = toCamelCase(domainName);
|
|
860
925
|
fixtureImports.push(`import ${camelName}Fixture from '${relativeImport(testPath, fixturePath)}';`);
|
|
861
926
|
}
|
|
862
927
|
|
|
863
928
|
for (const imp of serializerImports) {
|
|
864
929
|
lines.push(imp);
|
|
865
930
|
}
|
|
931
|
+
for (const imp of interfaceImports) {
|
|
932
|
+
lines.push(imp);
|
|
933
|
+
}
|
|
866
934
|
for (const imp of fixtureImports) {
|
|
867
935
|
lines.push(imp);
|
|
868
936
|
}
|
|
@@ -870,25 +938,36 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
870
938
|
|
|
871
939
|
for (const model of models) {
|
|
872
940
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
873
|
-
const
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
941
|
+
const fixtureName = `${toCamelCase(domainName)}Fixture`;
|
|
942
|
+
const wireName = wireInterfaceName(domainName);
|
|
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
|
+
}
|
|
884
964
|
lines.push('');
|
|
885
965
|
}
|
|
886
966
|
|
|
887
967
|
files.push({
|
|
888
968
|
path: testPath,
|
|
889
969
|
content: lines.join('\n'),
|
|
890
|
-
|
|
891
|
-
integrateTarget: false,
|
|
970
|
+
overwriteExisting: true,
|
|
892
971
|
});
|
|
893
972
|
}
|
|
894
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.
|
package/src/node/utils.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Model, EmitterContext, Service, Operation } from '@workos/oagen';
|
|
1
|
+
import type { Model, EmitterContext, Service, Operation, TypeRef } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase } from '@workos/oagen';
|
|
3
3
|
export {
|
|
4
4
|
collectModelRefs,
|
|
@@ -269,17 +269,31 @@ function modelFingerprint(model: Model): string {
|
|
|
269
269
|
*
|
|
270
270
|
* Returns a Map from duplicate model name → canonical model name.
|
|
271
271
|
*/
|
|
272
|
-
export function buildDeduplicationMap(
|
|
272
|
+
export function buildDeduplicationMap(
|
|
273
|
+
models: Model[],
|
|
274
|
+
ctx?: EmitterContext,
|
|
275
|
+
reachable?: Set<string>,
|
|
276
|
+
): Map<string, string> {
|
|
273
277
|
const dedup = new Map<string, string>();
|
|
274
278
|
|
|
275
279
|
// Pass 1: structural fingerprint dedup (exact match)
|
|
280
|
+
// When a reachability set is provided, prefer reachable models as canonicals
|
|
281
|
+
// so that aliases always point to models that will actually be generated.
|
|
276
282
|
const fingerprints = new Map<string, string>();
|
|
277
283
|
for (const model of models) {
|
|
278
284
|
if (model.fields.length === 0) continue;
|
|
279
285
|
const fp = modelFingerprint(model);
|
|
280
286
|
const existing = fingerprints.get(fp);
|
|
281
287
|
if (existing) {
|
|
282
|
-
|
|
288
|
+
// If the existing canonical is unreachable but this model is reachable,
|
|
289
|
+
// swap: make this model the canonical and demote the old one to alias.
|
|
290
|
+
if (reachable && !reachable.has(existing) && reachable.has(model.name)) {
|
|
291
|
+
dedup.delete(existing); // remove stale alias if present
|
|
292
|
+
dedup.set(existing, model.name);
|
|
293
|
+
fingerprints.set(fp, model.name);
|
|
294
|
+
} else {
|
|
295
|
+
dedup.set(model.name, existing);
|
|
296
|
+
}
|
|
283
297
|
} else {
|
|
284
298
|
fingerprints.set(fp, model.name);
|
|
285
299
|
}
|
|
@@ -287,7 +301,8 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
|
|
|
287
301
|
|
|
288
302
|
// Pass 2: name-based dedup for models that resolve to the same interface
|
|
289
303
|
// name across services. Only applies when context with name resolution is
|
|
290
|
-
// available. Picks the model with the most fields as canonical
|
|
304
|
+
// available. Picks the model with the most fields as canonical, preferring
|
|
305
|
+
// reachable models when a reachability set is provided.
|
|
291
306
|
if (ctx) {
|
|
292
307
|
const byDomainName = new Map<string, Model[]>();
|
|
293
308
|
for (const model of models) {
|
|
@@ -303,8 +318,15 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
|
|
|
303
318
|
}
|
|
304
319
|
for (const [, group] of byDomainName) {
|
|
305
320
|
if (group.length < 2) continue;
|
|
306
|
-
// Choose canonical: most fields, then alphabetically
|
|
307
|
-
group.sort((a, b) =>
|
|
321
|
+
// Choose canonical: prefer reachable, then most fields, then alphabetically
|
|
322
|
+
group.sort((a, b) => {
|
|
323
|
+
if (reachable) {
|
|
324
|
+
const aReach = reachable.has(a.name) ? 0 : 1;
|
|
325
|
+
const bReach = reachable.has(b.name) ? 0 : 1;
|
|
326
|
+
if (aReach !== bReach) return aReach - bReach;
|
|
327
|
+
}
|
|
328
|
+
return b.fields.length - a.fields.length || a.name.localeCompare(b.name);
|
|
329
|
+
});
|
|
308
330
|
const canonical = group[0];
|
|
309
331
|
for (let i = 1; i < group.length; i++) {
|
|
310
332
|
dedup.set(group[i].name, canonical.name);
|
|
@@ -397,6 +419,26 @@ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterConte
|
|
|
397
419
|
return false;
|
|
398
420
|
}
|
|
399
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Check whether an IR model has fields not present in the baseline interface.
|
|
424
|
+
* Returns true if the model has new fields that need generation.
|
|
425
|
+
* Returns true if no baseline exists (new model entirely).
|
|
426
|
+
*/
|
|
427
|
+
export function modelHasNewFields(model: Model, ctx: EmitterContext): boolean {
|
|
428
|
+
if (!ctx.apiSurface?.interfaces) return true; // No surface = generate everything
|
|
429
|
+
|
|
430
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
431
|
+
const baseline = ctx.apiSurface.interfaces[domainName];
|
|
432
|
+
if (!baseline?.fields) return true; // No baseline for this model = new model
|
|
433
|
+
|
|
434
|
+
for (const field of model.fields) {
|
|
435
|
+
const camelName = fieldName(field.name);
|
|
436
|
+
if (!baseline.fields[camelName]) return true; // New field found
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return false; // All fields exist in baseline
|
|
440
|
+
}
|
|
441
|
+
|
|
400
442
|
/**
|
|
401
443
|
* Return operations in a service that are NOT covered by existing hand-written
|
|
402
444
|
* service classes. For fully uncovered services, returns all operations.
|
|
@@ -417,3 +459,51 @@ export function uncoveredOperations(service: Service, ctx: EmitterContext): Oper
|
|
|
417
459
|
return !existingClassNames.has(match.className); // Class doesn't exist → uncovered
|
|
418
460
|
});
|
|
419
461
|
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Compute the set of model names reachable from non-event service operations.
|
|
465
|
+
* The Events service pulls in hundreds of webhook payload models that the
|
|
466
|
+
* existing SDK handles via hand-written event types, so those models are
|
|
467
|
+
* excluded from generation.
|
|
468
|
+
*
|
|
469
|
+
* Shared between model generation, barrel generation, dedup, and tests to
|
|
470
|
+
* ensure consistency: every module agrees on which models will be generated.
|
|
471
|
+
*/
|
|
472
|
+
export function computeNonEventReachable(services: Service[], models: Model[]): Set<string> {
|
|
473
|
+
const seeds = new Set<string>();
|
|
474
|
+
for (const svc of services) {
|
|
475
|
+
if (svc.name.toLowerCase() === 'events') continue;
|
|
476
|
+
for (const op of svc.operations) {
|
|
477
|
+
const collectFromRef = (t: TypeRef | undefined): void => {
|
|
478
|
+
if (!t) return;
|
|
479
|
+
if (t.kind === 'model') seeds.add(t.name);
|
|
480
|
+
if (t.kind === 'array') collectFromRef(t.items);
|
|
481
|
+
if (t.kind === 'nullable') collectFromRef(t.inner);
|
|
482
|
+
if (t.kind === 'union') t.variants.forEach(collectFromRef);
|
|
483
|
+
};
|
|
484
|
+
collectFromRef(op.response);
|
|
485
|
+
collectFromRef(op.requestBody);
|
|
486
|
+
if (op.pagination?.itemType) collectFromRef(op.pagination.itemType);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const modelMap = new Map(models.map((m) => [m.name, m]));
|
|
490
|
+
const reachable = new Set<string>();
|
|
491
|
+
const queue = [...seeds];
|
|
492
|
+
while (queue.length > 0) {
|
|
493
|
+
const name = queue.pop()!;
|
|
494
|
+
if (reachable.has(name)) continue;
|
|
495
|
+
reachable.add(name);
|
|
496
|
+
const m = modelMap.get(name);
|
|
497
|
+
if (!m) continue;
|
|
498
|
+
for (const field of m.fields) {
|
|
499
|
+
const walk = (t: TypeRef): void => {
|
|
500
|
+
if (t.kind === 'model' && !reachable.has(t.name)) queue.push(t.name);
|
|
501
|
+
if (t.kind === 'array') walk(t.items);
|
|
502
|
+
if (t.kind === 'nullable') walk(t.inner);
|
|
503
|
+
if (t.kind === 'union') t.variants.forEach(walk);
|
|
504
|
+
};
|
|
505
|
+
walk(field.type);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return reachable;
|
|
509
|
+
}
|
package/src/node/wrappers.ts
CHANGED
|
@@ -77,7 +77,37 @@ function emitWrapperMethod(
|
|
|
77
77
|
const returnType = responseTypeName ?? 'void';
|
|
78
78
|
|
|
79
79
|
// JSDoc
|
|
80
|
-
|
|
80
|
+
const docParts: string[] = [];
|
|
81
|
+
docParts.push(formatWrapperDescription(wrapper.name) + '.');
|
|
82
|
+
|
|
83
|
+
for (const p of op.pathParams) {
|
|
84
|
+
if (p.description) {
|
|
85
|
+
docParts.push(`@param ${fieldName(p.name)} - ${p.description}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const { paramName, field } of wrapperParams) {
|
|
90
|
+
const tsName = fieldName(paramName);
|
|
91
|
+
if (field?.description) {
|
|
92
|
+
docParts.push(`@param ${tsName} - ${field.description}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (responseTypeName) {
|
|
97
|
+
docParts.push(`@returns {Promise<${returnType}>}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (docParts.length === 1) {
|
|
101
|
+
lines.push(` /** ${docParts[0]} */`);
|
|
102
|
+
} else {
|
|
103
|
+
lines.push(' /**');
|
|
104
|
+
for (const part of docParts) {
|
|
105
|
+
for (const line of part.split('\n')) {
|
|
106
|
+
lines.push(line === '' ? ' *' : ` * ${line}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
lines.push(' */');
|
|
110
|
+
}
|
|
81
111
|
|
|
82
112
|
// Method signature
|
|
83
113
|
lines.push(` async ${method}(${paramParts.join(', ')}): Promise<${returnType}> {`);
|
package/src/php/client.ts
CHANGED
|
@@ -127,10 +127,13 @@ function generateMainClient(
|
|
|
127
127
|
for (const svc of services) {
|
|
128
128
|
lines.push(` private ?Service\\${svc.name} $${svc.propName} = null;`);
|
|
129
129
|
}
|
|
130
|
-
// Non-spec service properties
|
|
130
|
+
// Non-spec service properties — wrapped in ignore markers so the target
|
|
131
|
+
// SDK can hand-maintain the list. The emitter provides a positional anchor.
|
|
132
|
+
lines.push(' // @oagen-ignore-start — non-spec service properties (hand-maintained)');
|
|
131
133
|
for (const a of nonSpecAccessors) {
|
|
132
134
|
lines.push(` private ?${a.className} $${a.propName} = null;`);
|
|
133
135
|
}
|
|
136
|
+
lines.push(' // @oagen-ignore-end');
|
|
134
137
|
|
|
135
138
|
lines.push('');
|
|
136
139
|
lines.push(' public function __construct(');
|
|
@@ -140,11 +143,12 @@ function generateMainClient(
|
|
|
140
143
|
lines.push(' int $timeout = 60,');
|
|
141
144
|
lines.push(' int $maxRetries = 3,');
|
|
142
145
|
lines.push(' ?\\GuzzleHttp\\HandlerStack $handler = null,');
|
|
146
|
+
lines.push(' ?string $userAgent = null,');
|
|
143
147
|
lines.push(' ) {');
|
|
144
148
|
lines.push(" $apiKey ??= getenv('WORKOS_API_KEY') ?: self::$apiKey ?? '';");
|
|
145
149
|
lines.push(" $clientId ??= getenv('WORKOS_CLIENT_ID') ?: self::$clientId;");
|
|
146
150
|
lines.push(
|
|
147
|
-
' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler);',
|
|
151
|
+
' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler, $userAgent);',
|
|
148
152
|
);
|
|
149
153
|
lines.push(' }');
|
|
150
154
|
|
|
@@ -157,7 +161,10 @@ function generateMainClient(
|
|
|
157
161
|
lines.push(' }');
|
|
158
162
|
}
|
|
159
163
|
|
|
160
|
-
// Non-spec service accessors
|
|
164
|
+
// Non-spec service accessors — wrapped in ignore markers so the target
|
|
165
|
+
// SDK can hand-maintain these. The emitter provides a positional anchor.
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push(' // @oagen-ignore-start — non-spec service accessors (hand-maintained)');
|
|
161
168
|
for (const a of nonSpecAccessors) {
|
|
162
169
|
lines.push('');
|
|
163
170
|
lines.push(` public function ${a.propName}(): ${a.className}`);
|
|
@@ -165,6 +172,7 @@ function generateMainClient(
|
|
|
165
172
|
lines.push(` return $this->${a.propName} ??= new ${a.className}($this->httpClient);`);
|
|
166
173
|
lines.push(' }');
|
|
167
174
|
}
|
|
175
|
+
lines.push(' // @oagen-ignore-end');
|
|
168
176
|
|
|
169
177
|
lines.push('}');
|
|
170
178
|
return lines.join('\n');
|
package/src/php/models.ts
CHANGED
|
@@ -13,30 +13,6 @@ export { isListMetadataModel, isListWrapperModel };
|
|
|
13
13
|
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
14
14
|
if (models.length === 0) return [];
|
|
15
15
|
|
|
16
|
-
// Build structural hash for deduplication
|
|
17
|
-
const modelHashMap = new Map<string, string>();
|
|
18
|
-
const hashGroups = new Map<string, string[]>();
|
|
19
|
-
for (const model of models) {
|
|
20
|
-
if (isListMetadataModel(model)) continue;
|
|
21
|
-
if (isListWrapperModel(model)) continue;
|
|
22
|
-
const hash = structuralHash(model);
|
|
23
|
-
modelHashMap.set(model.name, hash);
|
|
24
|
-
if (!hashGroups.has(hash)) hashGroups.set(hash, []);
|
|
25
|
-
hashGroups.get(hash)!.push(model.name);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Pick canonical for each duplicate group (shortest class name wins)
|
|
29
|
-
const aliasOf = new Map<string, string>();
|
|
30
|
-
for (const [hash, names] of hashGroups) {
|
|
31
|
-
if (names.length <= 1) continue;
|
|
32
|
-
if (hash === '') continue;
|
|
33
|
-
const sorted = [...names].sort((a, b) => className(a).length - className(b).length);
|
|
34
|
-
const canonical = sorted[0];
|
|
35
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
36
|
-
aliasOf.set(sorted[i], canonical);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
16
|
const files: GeneratedFile[] = [];
|
|
41
17
|
|
|
42
18
|
// Emit shared JsonSerializableTrait once
|
|
@@ -59,8 +35,6 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
59
35
|
for (const model of models) {
|
|
60
36
|
if (isListMetadataModel(model)) continue;
|
|
61
37
|
if (isListWrapperModel(model)) continue;
|
|
62
|
-
if (aliasOf.has(model.name)) continue; // skip structural duplicates
|
|
63
|
-
|
|
64
38
|
const name = className(model.name);
|
|
65
39
|
const lines: string[] = [];
|
|
66
40
|
|
|
@@ -301,10 +275,3 @@ function needsVarAnnotation(ref: TypeRef): boolean {
|
|
|
301
275
|
return false;
|
|
302
276
|
}
|
|
303
277
|
}
|
|
304
|
-
|
|
305
|
-
function structuralHash(model: Model): string {
|
|
306
|
-
return model.fields
|
|
307
|
-
.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`)
|
|
308
|
-
.sort()
|
|
309
|
-
.join('|');
|
|
310
|
-
}
|
package/src/php/naming.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Service, Operation, EmitterContext, Enum } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase, toCamelCase, toSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
|
|
4
|
-
import { stripUrnPrefix } from '../shared/naming-utils.js';
|
|
4
|
+
import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
|
|
5
5
|
|
|
6
6
|
/** Namespace grouping result (shared with client.ts). */
|
|
7
7
|
export interface NamespaceGroup {
|
|
@@ -16,22 +16,6 @@ export interface NamespaceGrouping {
|
|
|
16
16
|
namespaces: NamespaceGroup[];
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Map of lowercase acronym forms to their correct casing.
|
|
21
|
-
*/
|
|
22
|
-
const ACRONYM_FIXES: [RegExp, string][] = [
|
|
23
|
-
[/Workos/g, 'WorkOS'],
|
|
24
|
-
[/Sso/g, 'SSO'],
|
|
25
|
-
[/Mfa/g, 'MFA'],
|
|
26
|
-
[/Jwt/g, 'JWT'],
|
|
27
|
-
[/Cors/g, 'CORS'],
|
|
28
|
-
[/Saml/g, 'SAML'],
|
|
29
|
-
[/Scim/g, 'SCIM'],
|
|
30
|
-
[/Rbac/g, 'RBAC'],
|
|
31
|
-
[/Oauth/g, 'OAuth'],
|
|
32
|
-
[/Oidc/g, 'OIDC'],
|
|
33
|
-
];
|
|
34
|
-
|
|
35
19
|
/**
|
|
36
20
|
* PHP reserved class names that would collide with builtins.
|
|
37
21
|
*/
|
|
@@ -99,10 +83,7 @@ export function enumClassName(name: string): string {
|
|
|
99
83
|
|
|
100
84
|
/** PascalCase class name with acronym preservation. */
|
|
101
85
|
export function className(name: string): string {
|
|
102
|
-
let result = toPascalCase(stripUrnPrefix(name));
|
|
103
|
-
for (const [pattern, replacement] of ACRONYM_FIXES) {
|
|
104
|
-
result = result.replace(pattern, replacement);
|
|
105
|
-
}
|
|
86
|
+
let result = applyAcronymFixes(toPascalCase(stripUrnPrefix(name)));
|
|
106
87
|
if (PHP_RESERVED_CLASS_NAMES.has(result)) {
|
|
107
88
|
result += 'Model';
|
|
108
89
|
}
|