@workos/oagen-emitters 0.12.3 → 0.12.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-D2N2ZT5W.mjs → plugin-Ca9LUkWW.mjs} +95 -23
- package/dist/plugin-Ca9LUkWW.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/node/field-plan.ts +27 -13
- package/src/node/models.ts +87 -6
- package/src/node/tests.ts +48 -8
- package/test/node/models.test.ts +88 -0
- package/test/node/tests.test.ts +57 -0
- package/dist/plugin-D2N2ZT5W.mjs.map +0 -1
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as workosEmittersPlugin } from "./plugin-
|
|
1
|
+
import { t as workosEmittersPlugin } from "./plugin-Ca9LUkWW.mjs";
|
|
2
2
|
export { workosEmittersPlugin };
|
package/package.json
CHANGED
package/src/node/field-plan.ts
CHANGED
|
@@ -666,6 +666,10 @@ interface SerializerContext {
|
|
|
666
666
|
resolveDir: (irService: string | undefined) => string;
|
|
667
667
|
dedup: Map<string, string>;
|
|
668
668
|
skippedSerializeModels: Set<string>;
|
|
669
|
+
/** Models reachable from any response — anything outside this set is
|
|
670
|
+
* request-only and won't have a `deserialize<X>` emitted. `undefined`
|
|
671
|
+
* means "no usage info available, assume deserialize exists". */
|
|
672
|
+
responseReachableModels: Set<string> | undefined;
|
|
669
673
|
ctx: EmitterContext;
|
|
670
674
|
}
|
|
671
675
|
|
|
@@ -723,6 +727,10 @@ export function buildSerializerImports(
|
|
|
723
727
|
const canon = sctx.dedup.get(dep);
|
|
724
728
|
const depSkipSerialize =
|
|
725
729
|
sctx.skippedSerializeModels.has(dep) || (canon != null && sctx.skippedSerializeModels.has(canon));
|
|
730
|
+
const depSkipDeserialize =
|
|
731
|
+
sctx.responseReachableModels !== undefined &&
|
|
732
|
+
!sctx.responseReachableModels.has(dep) &&
|
|
733
|
+
(canon == null || !sctx.responseReachableModels.has(canon));
|
|
726
734
|
|
|
727
735
|
// Decide whether this serializer is reachable at runtime:
|
|
728
736
|
// - file on disk → honor what it exports (hasDeser/hasSer)
|
|
@@ -759,8 +767,11 @@ export function buildSerializerImports(
|
|
|
759
767
|
continue;
|
|
760
768
|
}
|
|
761
769
|
|
|
770
|
+
if (depSkipSerialize && depSkipDeserialize) continue;
|
|
762
771
|
if (depSkipSerialize) {
|
|
763
772
|
lines.push(`import { deserialize${depName} } from '${rel}';`);
|
|
773
|
+
} else if (depSkipDeserialize) {
|
|
774
|
+
lines.push(`import { serialize${depName} } from '${rel}';`);
|
|
764
775
|
} else {
|
|
765
776
|
lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
|
|
766
777
|
}
|
|
@@ -821,27 +832,30 @@ export function emitSerializerBody(
|
|
|
821
832
|
baselineResponse: BaselineInterface | undefined,
|
|
822
833
|
skipFormatFields: Set<string>,
|
|
823
834
|
shouldSkipSerialize: boolean,
|
|
835
|
+
shouldSkipDeserialize: boolean,
|
|
824
836
|
ctx: EmitterContext,
|
|
825
837
|
): string[] {
|
|
826
838
|
const lines: string[] = [];
|
|
827
839
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
840
|
+
if (!shouldSkipDeserialize) {
|
|
841
|
+
const seenDeserFields = new Set<string>();
|
|
842
|
+
const deserParamPrefix = model.fields.length === 0 ? '_' : '';
|
|
843
|
+
lines.push(`export const deserialize${domainName} = ${typeParams.decl}(`);
|
|
844
|
+
lines.push(` ${deserParamPrefix}response: ${responseName}${typeParams.usage},`);
|
|
845
|
+
lines.push(`): ${domainName}${typeParams.usage} => ({`);
|
|
846
|
+
for (const field of model.fields) {
|
|
847
|
+
const domain = fieldName(field.name);
|
|
848
|
+
if (seenDeserFields.has(domain)) continue;
|
|
849
|
+
seenDeserFields.add(domain);
|
|
850
|
+
const plan = planDeserializeField(field, baselineDomain, baselineResponse, skipFormatFields, ctx);
|
|
851
|
+
if (!plan.skip) lines.push(plan.line);
|
|
852
|
+
}
|
|
853
|
+
lines.push('});');
|
|
839
854
|
}
|
|
840
|
-
lines.push('});');
|
|
841
855
|
|
|
842
856
|
if (!shouldSkipSerialize) {
|
|
857
|
+
if (!shouldSkipDeserialize) lines.push('');
|
|
843
858
|
const serParamPrefix = model.fields.length === 0 ? '_' : '';
|
|
844
|
-
lines.push('');
|
|
845
859
|
lines.push(`export const serialize${domainName} = ${typeParams.decl}(`);
|
|
846
860
|
lines.push(` ${serParamPrefix}model: ${domainName}${typeParams.usage},`);
|
|
847
861
|
lines.push(`): ${responseName}${typeParams.usage} => ({`);
|
package/src/node/models.ts
CHANGED
|
@@ -61,6 +61,10 @@ interface SharedModelContext {
|
|
|
61
61
|
interface GeneratedResourceModelUsage {
|
|
62
62
|
interfaceRoots: Set<string>;
|
|
63
63
|
serializerRoots: Set<string>;
|
|
64
|
+
/** Models that are directly used as a request body. Drive `serialize<X>`. */
|
|
65
|
+
requestRoots: Set<string>;
|
|
66
|
+
/** Models that are directly used as a response body. Drive `deserialize<X>`. */
|
|
67
|
+
responseRoots: Set<string>;
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
|
|
@@ -655,6 +659,14 @@ export function generateSerializers(
|
|
|
655
659
|
const serializerEligibleModels = resourceUsage
|
|
656
660
|
? expandModelRoots(resourceUsage.serializerRoots, projectedByName)
|
|
657
661
|
: undefined;
|
|
662
|
+
// Models reachable from any response — only these need a `deserialize<X>`.
|
|
663
|
+
// A model used solely as a request body (e.g. `CreateWebhookEndpoint`)
|
|
664
|
+
// would otherwise emit a deserializer with a partial response shape that
|
|
665
|
+
// silently misbehaves if called. Undefined means "no resource usage info,
|
|
666
|
+
// emit both halves" (standalone generation, smoke tests).
|
|
667
|
+
const responseReachableModels = resourceUsage
|
|
668
|
+
? expandModelRoots(resourceUsage.responseRoots, projectedByName)
|
|
669
|
+
: undefined;
|
|
658
670
|
|
|
659
671
|
const serializerReachable = computeNonEventReachable(ctx.spec.services, models);
|
|
660
672
|
|
|
@@ -765,9 +777,19 @@ export function generateSerializers(
|
|
|
765
777
|
if (domainName === canonDomainName) continue;
|
|
766
778
|
const rel = relativeImport(serializerPath, canonSerializerPath);
|
|
767
779
|
const canonSkipSerialize = skippedSerializeModels.has(canonicalName) || skippedSerializeModels.has(model.name);
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
780
|
+
const canonSkipDeserialize =
|
|
781
|
+
responseReachableModels !== undefined &&
|
|
782
|
+
!responseReachableModels.has(canonicalName) &&
|
|
783
|
+
!responseReachableModels.has(model.name);
|
|
784
|
+
if (canonSkipSerialize && canonSkipDeserialize) continue;
|
|
785
|
+
const parts: string[] = [];
|
|
786
|
+
if (!canonSkipDeserialize) {
|
|
787
|
+
parts.push(`deserialize${canonDomainName} as deserialize${domainName}`);
|
|
788
|
+
}
|
|
789
|
+
if (!canonSkipSerialize) {
|
|
790
|
+
parts.push(`serialize${canonDomainName} as serialize${domainName}`);
|
|
791
|
+
}
|
|
792
|
+
const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
|
|
771
793
|
files.push({
|
|
772
794
|
path: serializerPath,
|
|
773
795
|
content: reexportContent,
|
|
@@ -787,8 +809,21 @@ export function generateSerializers(
|
|
|
787
809
|
|
|
788
810
|
const skipFormatFields = buildSkipFormatFields(model, baselineDomain);
|
|
789
811
|
const shouldSkipSerialize = skippedSerializeModels.has(model.name);
|
|
790
|
-
|
|
791
|
-
|
|
812
|
+
// Skip `deserialize<X>` when the model never appears as a response (and
|
|
813
|
+
// we have usage info to verify that — `undefined` means "emit both halves
|
|
814
|
+
// conservatively"). Cuts unused, partially-typed deserializers like
|
|
815
|
+
// `deserializeCreateWebhookEndpoint` from request-body-only models.
|
|
816
|
+
const shouldSkipDeserialize = responseReachableModels !== undefined && !responseReachableModels.has(model.name);
|
|
817
|
+
if (shouldSkipSerialize && shouldSkipDeserialize) continue;
|
|
818
|
+
|
|
819
|
+
const sctx = {
|
|
820
|
+
modelToService,
|
|
821
|
+
resolveDir,
|
|
822
|
+
dedup,
|
|
823
|
+
skippedSerializeModels,
|
|
824
|
+
responseReachableModels,
|
|
825
|
+
ctx,
|
|
826
|
+
};
|
|
792
827
|
const lines = [
|
|
793
828
|
...buildSerializerImports(model, serializerPath, dirName, domainName, responseName, sctx),
|
|
794
829
|
...emitSerializerBody(
|
|
@@ -800,6 +835,7 @@ export function generateSerializers(
|
|
|
800
835
|
baselineResponse,
|
|
801
836
|
skipFormatFields,
|
|
802
837
|
shouldSkipSerialize,
|
|
838
|
+
shouldSkipDeserialize,
|
|
803
839
|
ctx,
|
|
804
840
|
),
|
|
805
841
|
];
|
|
@@ -812,6 +848,45 @@ export function generateSerializers(
|
|
|
812
848
|
}
|
|
813
849
|
|
|
814
850
|
(ctx as any)._skippedSerializeModels = skippedSerializeModels;
|
|
851
|
+
// Surface the response-reachable set so the serializer-roundtrip test
|
|
852
|
+
// generator can fall back to a deserialize-skipped path for request-only
|
|
853
|
+
// models (where `deserialize<X>` was deliberately not emitted).
|
|
854
|
+
(ctx as any)._responseReachableModels = responseReachableModels;
|
|
855
|
+
|
|
856
|
+
// Emit a `serializers/index.ts` barrel per directory that received serializer
|
|
857
|
+
// files in this pass. Mirrors the per-service `interfaces/index.ts` barrel so
|
|
858
|
+
// consumers can `import { ... } from './serializers'` rather than reaching
|
|
859
|
+
// into individual `.serializer.ts` files. Also includes any pre-existing
|
|
860
|
+
// `*.serializer.ts` files in the same directory (e.g. hand-written option
|
|
861
|
+
// serializers in an owned service) so we don't strand them from the barrel.
|
|
862
|
+
const serializersByDir = new Map<string, Set<string>>();
|
|
863
|
+
for (const f of files) {
|
|
864
|
+
const match = f.path.match(/^src\/([^/]+)\/serializers\/(.+)\.serializer\.ts$/);
|
|
865
|
+
if (!match) continue;
|
|
866
|
+
const [, dir, stem] = match;
|
|
867
|
+
if (!serializersByDir.has(dir)) serializersByDir.set(dir, new Set());
|
|
868
|
+
serializersByDir.get(dir)!.add(stem);
|
|
869
|
+
}
|
|
870
|
+
const liveRootForBarrel = ctx.outputDir ?? ctx.targetDir;
|
|
871
|
+
for (const [dir, stems] of serializersByDir) {
|
|
872
|
+
if (liveRootForBarrel) {
|
|
873
|
+
const serializersDir = path.join(liveRootForBarrel, 'src', dir, 'serializers');
|
|
874
|
+
try {
|
|
875
|
+
for (const entry of fs.readdirSync(serializersDir)) {
|
|
876
|
+
if (!entry.endsWith('.serializer.ts')) continue;
|
|
877
|
+
stems.add(entry.replace(/\.serializer\.ts$/, ''));
|
|
878
|
+
}
|
|
879
|
+
} catch {
|
|
880
|
+
// Directory doesn't exist yet — only this-pass serializers will appear.
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
const lines = [...stems].sort().map((stem) => `export * from './${stem}.serializer';`);
|
|
884
|
+
files.push({
|
|
885
|
+
path: `src/${dir}/serializers/index.ts`,
|
|
886
|
+
content: lines.join('\n') + '\n',
|
|
887
|
+
overwriteExisting: true,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
815
890
|
|
|
816
891
|
return files;
|
|
817
892
|
}
|
|
@@ -847,6 +922,8 @@ function buildGeneratedResourceModelUsage(
|
|
|
847
922
|
const modelMap = new Map(models.map((model) => [model.name, model]));
|
|
848
923
|
const interfaceRoots = new Set<string>();
|
|
849
924
|
const serializerRoots = new Set<string>();
|
|
925
|
+
const requestRoots = new Set<string>();
|
|
926
|
+
const responseRoots = new Set<string>();
|
|
850
927
|
const resolvedLookup = buildResolvedLookup(ctx);
|
|
851
928
|
const mountGroups = groupByMount(ctx);
|
|
852
929
|
const services: Service[] =
|
|
@@ -889,16 +966,19 @@ function buildGeneratedResourceModelUsage(
|
|
|
889
966
|
if (unwrapped) itemName = unwrapped.name;
|
|
890
967
|
interfaceRoots.add(itemName);
|
|
891
968
|
serializerRoots.add(itemName);
|
|
969
|
+
responseRoots.add(itemName);
|
|
892
970
|
}
|
|
893
971
|
} else if (plan.responseModelName) {
|
|
894
972
|
interfaceRoots.add(plan.responseModelName);
|
|
895
973
|
serializerRoots.add(plan.responseModelName);
|
|
974
|
+
responseRoots.add(plan.responseModelName);
|
|
896
975
|
}
|
|
897
976
|
|
|
898
977
|
const bodyInfo = extractRequestBodyModels(op, ctx);
|
|
899
978
|
for (const name of bodyInfo) {
|
|
900
979
|
interfaceRoots.add(name);
|
|
901
980
|
serializerRoots.add(name);
|
|
981
|
+
requestRoots.add(name);
|
|
902
982
|
}
|
|
903
983
|
|
|
904
984
|
for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
|
|
@@ -910,6 +990,7 @@ function buildGeneratedResourceModelUsage(
|
|
|
910
990
|
for (const name of collectWrapperResponseModels(resolved)) {
|
|
911
991
|
interfaceRoots.add(name);
|
|
912
992
|
serializerRoots.add(name);
|
|
993
|
+
responseRoots.add(name);
|
|
913
994
|
}
|
|
914
995
|
for (const wrapper of resolved.wrappers ?? []) {
|
|
915
996
|
for (const { field } of resolveWrapperParams(wrapper, ctx)) {
|
|
@@ -920,7 +1001,7 @@ function buildGeneratedResourceModelUsage(
|
|
|
920
1001
|
}
|
|
921
1002
|
}
|
|
922
1003
|
|
|
923
|
-
return { interfaceRoots, serializerRoots };
|
|
1004
|
+
return { interfaceRoots, serializerRoots, requestRoots, responseRoots };
|
|
924
1005
|
}
|
|
925
1006
|
|
|
926
1007
|
function extractRequestBodyModels(op: Operation, ctx: EmitterContext): string[] {
|
package/src/node/tests.ts
CHANGED
|
@@ -838,18 +838,33 @@ function buildTestPayload(
|
|
|
838
838
|
}
|
|
839
839
|
if (!model) return null;
|
|
840
840
|
|
|
841
|
-
const
|
|
841
|
+
const requiredFields = model.fields.filter((f) => f.required);
|
|
842
842
|
// Only use fields that we can generate deterministic values for (primitives, enums, and nested models)
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
843
|
+
const usableRequired = requiredFields.filter(
|
|
844
|
+
(f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null,
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
let chosenFields: typeof model.fields;
|
|
848
|
+
if (requiredFields.length > 0) {
|
|
849
|
+
// Only generate a typed payload when ALL required fields have fixture values.
|
|
850
|
+
// A partial payload missing required fields would fail TypeScript type checking.
|
|
851
|
+
if (usableRequired.length < requiredFields.length) return null;
|
|
852
|
+
chosenFields = usableRequired;
|
|
853
|
+
} else {
|
|
854
|
+
// All-optional model (e.g. PATCH `Update<X>` bodies). Pick the first few
|
|
855
|
+
// optional fields with available fixture values so the test asserts the
|
|
856
|
+
// wire format instead of falling back to `expect(fetchBody()).toBeDefined()`.
|
|
857
|
+
const usableOptional = model.fields.filter(
|
|
858
|
+
(f) => !f.required && fixtureValueForType(f.type, f.name, model.name, modelMap) !== null,
|
|
859
|
+
);
|
|
860
|
+
if (usableOptional.length === 0) return null;
|
|
861
|
+
chosenFields = usableOptional.slice(0, 2);
|
|
862
|
+
}
|
|
848
863
|
|
|
849
864
|
const camelEntries: string[] = [];
|
|
850
865
|
const snakeEntries: string[] = [];
|
|
851
866
|
|
|
852
|
-
for (const field of
|
|
867
|
+
for (const field of chosenFields) {
|
|
853
868
|
const camelValue = fixtureValueForType(field.type, field.name, model.name, modelMap)!;
|
|
854
869
|
const wireValue = fixtureValueForType(field.type, field.name, model.name, modelMap, true)!;
|
|
855
870
|
const camelKey = fieldName(field.name);
|
|
@@ -935,6 +950,10 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
935
950
|
// Use the skipped-serialize set computed by the serializer generator.
|
|
936
951
|
// It's stashed on the context during generateSerializers.
|
|
937
952
|
const serializeSkipped: Set<string> = (ctx as any)._skippedSerializeModels ?? new Set<string>();
|
|
953
|
+
// Response-reachable models — anything outside this set is request-only
|
|
954
|
+
// and has no `deserialize<X>` to test. `undefined` means "no usage info,
|
|
955
|
+
// assume deserialize exists" (standalone generation, smoke tests).
|
|
956
|
+
const responseReachableModels: Set<string> | undefined = (ctx as any)._responseReachableModels;
|
|
938
957
|
|
|
939
958
|
// Group eligible models by service directory for one test file per service
|
|
940
959
|
const modelsByDir = new Map<string, Model[]>();
|
|
@@ -958,6 +977,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
958
977
|
const interfaceImports: string[] = [];
|
|
959
978
|
const fixtureImports: string[] = [];
|
|
960
979
|
const deserializeOnlyModels = new Set<string>();
|
|
980
|
+
const serializeOnlyModels = new Set<string>();
|
|
961
981
|
|
|
962
982
|
for (const model of models) {
|
|
963
983
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
@@ -966,13 +986,24 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
966
986
|
const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
967
987
|
const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
968
988
|
const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
|
|
969
|
-
|
|
989
|
+
// Request-only models (e.g. `CreateWebhookEndpoint`) have no
|
|
990
|
+
// `deserialize<X>` emitted, so they can only be tested via serialize.
|
|
991
|
+
// This check has to come first: a hand-owned fixture would otherwise
|
|
992
|
+
// route through the deserialize-only branch, which then imports a
|
|
993
|
+
// function that doesn't exist.
|
|
994
|
+
const isRequestOnly = responseReachableModels !== undefined && !responseReachableModels.has(model.name);
|
|
995
|
+
const deserializeOnly =
|
|
996
|
+
!isRequestOnly && (serializeSkipped.has(model.name) || fixtureIsHandOwned(fixturePath, ctx));
|
|
997
|
+
const serializeOnly = isRequestOnly;
|
|
970
998
|
if (deserializeOnly) deserializeOnlyModels.add(model.name);
|
|
999
|
+
if (serializeOnly) serializeOnlyModels.add(model.name);
|
|
971
1000
|
|
|
972
1001
|
if (deserializeOnly) {
|
|
973
1002
|
serializerImports.push(
|
|
974
1003
|
`import { deserialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
975
1004
|
);
|
|
1005
|
+
} else if (serializeOnly) {
|
|
1006
|
+
serializerImports.push(`import { serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`);
|
|
976
1007
|
} else {
|
|
977
1008
|
serializerImports.push(
|
|
978
1009
|
`import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
@@ -1009,6 +1040,15 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
1009
1040
|
lines.push(' expect(deserialized).toBeDefined();');
|
|
1010
1041
|
lines.push(' });');
|
|
1011
1042
|
lines.push('});');
|
|
1043
|
+
} else if (serializeOnlyModels.has(model.name)) {
|
|
1044
|
+
// Serialize-only test for request-body-only models without a deserializer.
|
|
1045
|
+
lines.push(`describe('${domainName}Serializer', () => {`);
|
|
1046
|
+
lines.push(" it('serializes correctly', () => {");
|
|
1047
|
+
lines.push(` const fixture = ${fixtureName} as ${wireName};`);
|
|
1048
|
+
lines.push(` const serialized = serialize${domainName}(fixture as any);`);
|
|
1049
|
+
lines.push(' expect(serialized).toBeDefined();');
|
|
1050
|
+
lines.push(' });');
|
|
1051
|
+
lines.push('});');
|
|
1012
1052
|
} else {
|
|
1013
1053
|
// Round-trip test
|
|
1014
1054
|
lines.push(`describe('${domainName}Serializer', () => {`);
|
package/test/node/models.test.ts
CHANGED
|
@@ -558,4 +558,92 @@ describe('generateSerializers', () => {
|
|
|
558
558
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
559
559
|
}
|
|
560
560
|
});
|
|
561
|
+
|
|
562
|
+
it('emits a serializers/index.ts barrel listing every emitted serializer', () => {
|
|
563
|
+
const models: Model[] = [
|
|
564
|
+
{
|
|
565
|
+
name: 'Organization',
|
|
566
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
name: 'OrganizationMember',
|
|
570
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
571
|
+
},
|
|
572
|
+
];
|
|
573
|
+
|
|
574
|
+
const spec = makeSpec(models);
|
|
575
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
576
|
+
const result = generateSerializers(models, ctxWithModels);
|
|
577
|
+
|
|
578
|
+
const serializerFiles = result.filter((f) => f.path.endsWith('.serializer.ts'));
|
|
579
|
+
expect(serializerFiles.length).toBeGreaterThan(0);
|
|
580
|
+
|
|
581
|
+
// Every emitted serializer file should appear in a barrel at the same
|
|
582
|
+
// directory's `serializers/index.ts`.
|
|
583
|
+
for (const sf of serializerFiles) {
|
|
584
|
+
const match = sf.path.match(/^src\/([^/]+)\/serializers\/(.+)\.serializer\.ts$/);
|
|
585
|
+
expect(match).not.toBeNull();
|
|
586
|
+
const [, dir, stem] = match!;
|
|
587
|
+
const barrel = result.find((f) => f.path === `src/${dir}/serializers/index.ts`);
|
|
588
|
+
expect(barrel, `expected barrel for ${dir}`).toBeDefined();
|
|
589
|
+
expect(barrel!.content).toContain(`export * from './${stem}.serializer';`);
|
|
590
|
+
expect(barrel!.overwriteExisting).toBe(true);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('omits deserialize half for request-body-only models', () => {
|
|
595
|
+
// `CreateOrganization` is sent as a POST body but the operation responds
|
|
596
|
+
// with a separate `Organization` model. The deserializer for the request
|
|
597
|
+
// model would be dead code AND would silently misbehave if called (the
|
|
598
|
+
// response wire shape doesn't match), so it shouldn't be emitted.
|
|
599
|
+
const models: Model[] = [
|
|
600
|
+
{
|
|
601
|
+
name: 'Organization',
|
|
602
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
name: 'CreateOrganization',
|
|
606
|
+
fields: [{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
607
|
+
},
|
|
608
|
+
];
|
|
609
|
+
|
|
610
|
+
const spec: ApiSpec = {
|
|
611
|
+
...emptySpec,
|
|
612
|
+
models,
|
|
613
|
+
services: [
|
|
614
|
+
{
|
|
615
|
+
name: 'Organizations',
|
|
616
|
+
operations: [
|
|
617
|
+
{
|
|
618
|
+
name: 'createOrganization',
|
|
619
|
+
httpMethod: 'post',
|
|
620
|
+
path: '/organizations',
|
|
621
|
+
pathParams: [],
|
|
622
|
+
queryParams: [],
|
|
623
|
+
headerParams: [],
|
|
624
|
+
response: { kind: 'model', name: 'Organization' },
|
|
625
|
+
requestBody: { kind: 'model', name: 'CreateOrganization' },
|
|
626
|
+
errors: [],
|
|
627
|
+
injectIdempotencyKey: false,
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
},
|
|
631
|
+
],
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
635
|
+
const result = generateSerializers(models, ctxWithModels);
|
|
636
|
+
|
|
637
|
+
const createSerializer = result.find((f) => f.path.endsWith('create-organization.serializer.ts'));
|
|
638
|
+
expect(createSerializer).toBeDefined();
|
|
639
|
+
expect(createSerializer!.content).toContain('export const serializeCreateOrganization');
|
|
640
|
+
expect(createSerializer!.content).not.toContain('export const deserializeCreateOrganization');
|
|
641
|
+
|
|
642
|
+
// Response-side model still gets both halves.
|
|
643
|
+
const orgSerializer = result.find(
|
|
644
|
+
(f) => f.path.endsWith('organization.serializer.ts') && !f.path.includes('create'),
|
|
645
|
+
);
|
|
646
|
+
expect(orgSerializer).toBeDefined();
|
|
647
|
+
expect(orgSerializer!.content).toContain('export const deserializeOrganization');
|
|
648
|
+
});
|
|
561
649
|
});
|
package/test/node/tests.test.ts
CHANGED
|
@@ -211,4 +211,61 @@ describe('node test generation ownership', () => {
|
|
|
211
211
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
212
212
|
}
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
it('asserts wire format on all-optional request bodies instead of toBeDefined()', () => {
|
|
216
|
+
// For PATCH/Update bodies where every field is optional, the test
|
|
217
|
+
// emitter previously fell back to `expect(fetchBody()).toBeDefined()`,
|
|
218
|
+
// which passes even if the serializer writes the wrong keys. Picking a
|
|
219
|
+
// couple of optional fields with deterministic fixture values makes the
|
|
220
|
+
// test actually validate snake_case conversion on the wire.
|
|
221
|
+
const updateModel: Model = {
|
|
222
|
+
name: 'UpdateGroup',
|
|
223
|
+
fields: [
|
|
224
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: false },
|
|
225
|
+
{ name: 'description', type: { kind: 'primitive', type: 'string' }, required: false },
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const updateOp = {
|
|
230
|
+
name: 'updateGroup',
|
|
231
|
+
httpMethod: 'patch' as const,
|
|
232
|
+
path: '/organizations/{organizationId}/groups/{id}',
|
|
233
|
+
pathParams: [
|
|
234
|
+
{ name: 'organizationId', type: { kind: 'primitive' as const, type: 'string' as const }, required: true },
|
|
235
|
+
{ name: 'id', type: { kind: 'primitive' as const, type: 'string' as const }, required: true },
|
|
236
|
+
],
|
|
237
|
+
queryParams: [],
|
|
238
|
+
headerParams: [],
|
|
239
|
+
response: { kind: 'model' as const, name: 'Group' },
|
|
240
|
+
requestBody: { kind: 'model' as const, name: 'UpdateGroup' },
|
|
241
|
+
errors: [],
|
|
242
|
+
injectIdempotencyKey: false,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const updateService: Service = { name: 'Groups', operations: [updateOp] };
|
|
246
|
+
const updateSpec: ApiSpec = {
|
|
247
|
+
...spec,
|
|
248
|
+
models: [groupModel, updateModel],
|
|
249
|
+
services: [updateService],
|
|
250
|
+
};
|
|
251
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
252
|
+
try {
|
|
253
|
+
const result = nodeEmitter.generateTests!(updateSpec, {
|
|
254
|
+
...ctx,
|
|
255
|
+
spec: updateSpec,
|
|
256
|
+
outputDir: tmpRoot,
|
|
257
|
+
emitterOptions: { ownedServices: ['Groups'], regenerateOwnedTests: true },
|
|
258
|
+
} as EmitterContext);
|
|
259
|
+
|
|
260
|
+
const testFile = result.find((f) => f.path === 'src/groups/groups.spec.ts');
|
|
261
|
+
expect(testFile).toBeDefined();
|
|
262
|
+
const content = testFile!.content;
|
|
263
|
+
// The body assertion picks at least one optional field and checks its
|
|
264
|
+
// snake_case wire format — not just `.toBeDefined()`.
|
|
265
|
+
expect(content).toContain('expect(fetchBody()).toEqual(');
|
|
266
|
+
expect(content).toMatch(/expect\.objectContaining\(\{[^}]*\bname\b/);
|
|
267
|
+
} finally {
|
|
268
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
214
271
|
});
|