@contractkit/plugin-typescript 0.29.0 → 0.31.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.
@@ -11,6 +11,7 @@ import {
11
11
  opRequest,
12
12
  opMultiRequest,
13
13
  opResponse,
14
+ opResponseMulti,
14
15
  opOperation,
15
16
  opRoute,
16
17
  opRoot,
@@ -101,6 +102,66 @@ describe('generateOperation', () => {
101
102
  const output = generateOp(root);
102
103
  expect(output).not.toContain('luxon');
103
104
  });
105
+
106
+ // Every conditional import must be justified by a reference in the generated body —
107
+ // an unused import trips `noUnusedLocals` and lint in the consuming project.
108
+ it('imports bodyParserMiddleware only when an operation has a request body', () => {
109
+ const withBody = generateOp(opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]));
110
+ expect(withBody).toContain('bodyParserMiddleware');
111
+
112
+ const withoutBody = generateOp(opRoot([opRoute('/users', [opOperation('get')])]));
113
+ expect(withoutBody).not.toContain('bodyParserMiddleware');
114
+ });
115
+
116
+ it('omits MultipartBody when a multipart body shares its shape with the other MIME types', () => {
117
+ // Structurally equal bodies collapse to a single parseAndValidate call, so nothing
118
+ // references MultipartBody even though the operation does declare multipart.
119
+ const root = opRoot([
120
+ opRoute('/upload', [
121
+ opOperation('post', {
122
+ request: opMultiRequest([
123
+ ['multipart/form-data', 'UploadForm'],
124
+ ['application/json', 'UploadForm'],
125
+ ]),
126
+ }),
127
+ ]),
128
+ ]);
129
+ const output = generateOp(root);
130
+ expect(output).not.toContain('MultipartBody');
131
+ });
132
+
133
+ it('imports MultipartBody when the multipart body is handled on its own', () => {
134
+ const root = opRoot([opRoute('/upload', [opOperation('post', { request: opMultiRequest([['multipart/form-data', 'UploadForm']]) })])]);
135
+ expect(generateOp(root)).toContain("import { MultipartBody } from '@maroonedsoftware/multipart';");
136
+ });
137
+
138
+ it('leaves no import unreferenced in the generated body', () => {
139
+ const root = opRoot([
140
+ opRoute(
141
+ '/users/{id}',
142
+ [
143
+ opOperation('get', { security: SECURITY_NONE }),
144
+ opOperation('post', { request: opRequest('CreateUser'), signature: 'webhookKey' }),
145
+ ],
146
+ [opParam('id', scalarType('uuid'))],
147
+ ),
148
+ ]);
149
+ const output = generateOp(root);
150
+ const importLines = output.split('\n').filter(l => l.startsWith('import '));
151
+ expect(importLines.length).toBeGreaterThan(0);
152
+
153
+ const bodyText = output
154
+ .split('\n')
155
+ .filter(l => !l.startsWith('import '))
156
+ .join('\n');
157
+ for (const line of importLines) {
158
+ const named = line.match(/^import \{([^}]*)\}/);
159
+ if (!named) continue;
160
+ for (const symbol of named[1]!.split(',').map(s => s.trim().replace(/^type /, ''))) {
161
+ expect(bodyText, `${symbol} is imported but never used`).toMatch(new RegExp(`\\b${symbol}\\b`));
162
+ }
163
+ }
164
+ });
104
165
  });
105
166
 
106
167
  // ─── Handler signature ─────────────────────────────────────────
@@ -583,8 +644,8 @@ describe('generateOperation', () => {
583
644
  responses: [
584
645
  {
585
646
  statusCode: 200,
586
- contentType: 'application/json',
587
- bodyType: { kind: 'ref', name: 'Transfer' },
647
+ hasBlock: true,
648
+ bodies: [{ contentType: 'application/json', bodyType: { kind: 'ref', name: 'Transfer' } }],
588
649
  headers: [
589
650
  { name: 'preference-applied', optional: true, type: { kind: 'scalar', name: 'string' } },
590
651
  { name: 'etag', optional: false, type: { kind: 'scalar', name: 'string' } },
@@ -614,6 +675,8 @@ describe('generateOperation', () => {
614
675
  responses: [
615
676
  {
616
677
  statusCode: 204,
678
+ hasBlock: true,
679
+ bodies: [],
617
680
  headers: [{ name: 'x-deleted-at', optional: false, type: { kind: 'scalar', name: 'string' } }],
618
681
  },
619
682
  ],
@@ -634,6 +697,188 @@ describe('generateOperation', () => {
634
697
  expect(output).toContain('ctx.status = 200');
635
698
  });
636
699
 
700
+ // ─── Which statuses the service produces ─────────────────────────
701
+
702
+ describe('emitted-set dispatch', () => {
703
+ const artBodies = [
704
+ { contentType: 'image/png', bodyType: scalarType('binary') },
705
+ { contentType: 'image/jpeg', bodyType: scalarType('binary') },
706
+ ];
707
+
708
+ it('leaves the common success-plus-bodyless-errors operation alone', () => {
709
+ const root = opRoot([
710
+ opRoute('/pet', [
711
+ opOperation('get', {
712
+ responses: [opResponse(200, 'Pet', 'application/json'), opResponse(400), opResponse(404)],
713
+ }),
714
+ ]),
715
+ ]);
716
+ const output = generateOp(root);
717
+ expect(output).toContain('const result: Pet = await service.list();');
718
+ expect(output).toContain('ctx.status = 200;');
719
+ expect(output).toContain("ctx.type = 'application/json';");
720
+ expect(output).toContain('ctx.body = result;');
721
+ expect(output).not.toContain('switch (result.status)');
722
+ });
723
+
724
+ it('lets the service pick the mime when a status declares several', () => {
725
+ const root = opRoot([
726
+ opRoute('/art', [
727
+ opOperation('get', {
728
+ responses: [
729
+ opResponseMulti(200, artBodies, {
730
+ headers: [{ name: 'etag', optional: true, type: scalarType('string') }],
731
+ }),
732
+ opResponse(304),
733
+ ],
734
+ }),
735
+ ]),
736
+ ]);
737
+ const output = generateOp(root);
738
+ expect(output).toContain(
739
+ "const result: { contentType: 'image/png' | 'image/jpeg'; body: Buffer; headers: { etag?: string } } = await service.list();",
740
+ );
741
+ expect(output).toContain('ctx.status = 200;');
742
+ expect(output).toContain('ctx.type = result.contentType;');
743
+ expect(output).toContain('ctx.body = result.body;');
744
+ // The bare 304 is documentation — middleware produces it, not the service.
745
+ expect(output).not.toContain('switch (result.status)');
746
+ expect(output).not.toContain('304');
747
+ });
748
+
749
+ it('keeps contentType correlated with body when the mimes carry different types', () => {
750
+ const root = opRoot([
751
+ opRoute('/pet', [
752
+ opOperation('get', {
753
+ responses: [
754
+ opResponseMulti(200, [
755
+ { contentType: 'application/json', bodyType: refType('Pet') },
756
+ { contentType: 'text/csv', bodyType: scalarType('string') },
757
+ ]),
758
+ ],
759
+ }),
760
+ ]),
761
+ ]);
762
+ const output = generateOp(root);
763
+ expect(output).toContain(
764
+ "const result: { contentType: 'application/json'; body: Pet } | { contentType: 'text/csv'; body: string } = await service.list();",
765
+ );
766
+ });
767
+
768
+ it('switches on status when the service produces more than one', () => {
769
+ const root = opRoot([
770
+ opRoute('/art', [
771
+ opOperation('get', {
772
+ responses: [
773
+ opResponseMulti(200, artBodies, {
774
+ headers: [{ name: 'etag', optional: true, type: scalarType('string') }],
775
+ }),
776
+ opResponse(202, 'JobRef', 'application/json'),
777
+ opResponse(304),
778
+ opResponse(404),
779
+ ],
780
+ }),
781
+ ]),
782
+ ]);
783
+ const output = generateOp(root);
784
+ expect(output).toContain("| { status: 200; contentType: 'image/png' | 'image/jpeg'; body: Buffer; headers: { etag?: string } }");
785
+ expect(output).toContain("| { status: 202; contentType: 'application/json'; body: JobRef }");
786
+ expect(output).toContain('ctx.status = result.status;');
787
+ expect(output).toContain('switch (result.status) {');
788
+ expect(output).toContain(' case 200:');
789
+ expect(output).toContain(' case 202:');
790
+ // Neither the middleware-produced 304 nor the thrown 404 is a case.
791
+ expect(output).not.toContain('case 304:');
792
+ expect(output).not.toContain('case 404:');
793
+ });
794
+
795
+ it('returns a body-bearing error status rather than leaving it to be thrown', () => {
796
+ const root = opRoot([
797
+ opRoute('/pet', [
798
+ opOperation('get', {
799
+ responses: [
800
+ opResponse(200, 'Pet', 'application/json'),
801
+ opResponse(422, 'Problem', 'application/json'),
802
+ opResponse(404),
803
+ ],
804
+ }),
805
+ ]),
806
+ ]);
807
+ const output = generateOp(root);
808
+ expect(output).toContain("| { status: 200; contentType: 'application/json'; body: Pet }");
809
+ expect(output).toContain("| { status: 422; contentType: 'application/json'; body: Problem }");
810
+ expect(output).toContain(' case 422:');
811
+ });
812
+
813
+ it('puts a documented status back on the throw path', () => {
814
+ const root = opRoot([
815
+ opRoute('/pet', [
816
+ opOperation('get', {
817
+ responses: [
818
+ opResponse(200, 'Pet', 'application/json'),
819
+ { ...opResponse(422, 'Problem', 'application/json'), emit: 'documented' },
820
+ ],
821
+ }),
822
+ ]),
823
+ ]);
824
+ const output = generateOp(root);
825
+ expect(output).toContain('const result: Pet = await service.list();');
826
+ expect(output).not.toContain('switch (result.status)');
827
+ expect(output).not.toContain('422');
828
+ });
829
+
830
+ it('emits a bodyless status the service opts into with an empty block', () => {
831
+ const root = opRoot([
832
+ opRoute('/art', [
833
+ opOperation('get', {
834
+ responses: [opResponse(200, 'Art', 'application/json'), { statusCode: 304, bodies: [], hasBlock: true }],
835
+ }),
836
+ ]),
837
+ ]);
838
+ const output = generateOp(root);
839
+ expect(output).toContain("| { status: 200; contentType: 'application/json'; body: Art }");
840
+ expect(output).toContain('| { status: 304 }');
841
+ expect(output).toContain(' case 304:');
842
+ // Nothing to write for a bodyless member beyond the status itself.
843
+ expect(output).toMatch(/case 304:\n\s+break;/);
844
+ });
845
+
846
+ it('gives each status its own schema variable so two complex bodies cannot collide', () => {
847
+ const root = opRoot([
848
+ opRoute('/pet', [
849
+ opOperation('get', {
850
+ responses: [
851
+ opResponse(200, inlineObjectType([field('id', scalarType('int'))]), 'application/json'),
852
+ opResponse(422, inlineObjectType([field('detail', scalarType('string'))]), 'application/json'),
853
+ ],
854
+ }),
855
+ ]),
856
+ ]);
857
+ const output = generateOp(root);
858
+ expect(output).toContain('const result200Type = ');
859
+ expect(output).toContain('const result422Type = ');
860
+ });
861
+
862
+ it('writes only the headers belonging to the status that was returned', () => {
863
+ const root = opRoot([
864
+ opRoute('/art', [
865
+ opOperation('get', {
866
+ responses: [
867
+ opResponse(200, 'Art', 'application/json'),
868
+ {
869
+ ...opResponse(202, 'JobRef', 'application/json'),
870
+ headers: [{ name: 'retry-after', optional: false, type: scalarType('string') }],
871
+ },
872
+ ],
873
+ }),
874
+ ]),
875
+ ]);
876
+ const output = generateOp(root);
877
+ expect(output).toMatch(/case 202:\n\s+ctx\.set\('retry-after'/);
878
+ expect(output).not.toMatch(/case 200:\n\s+ctx\.set/);
879
+ });
880
+ });
881
+
637
882
  it('uses Output variant for result type when response model has format(output=...)', () => {
638
883
  const root = opRoot([
639
884
  opRoute('/auth/token', [
@@ -702,6 +947,32 @@ describe('generateOperation', () => {
702
947
  ]);
703
948
  expect(generateOp(root)).toContain('const result: Buffer[] = await service.list();');
704
949
  });
950
+
951
+ it('emits no _ZodBinary helper for a binary response body', () => {
952
+ // A response body is an annotation, not a schema — the handler never validates it,
953
+ // so declaring the helper would leave it unused and trip `noUnusedLocals`.
954
+ const root = opRoot([
955
+ opRoute('/art', [
956
+ opOperation('get', {
957
+ responses: [
958
+ opResponseMulti(200, [
959
+ { contentType: 'image/png', bodyType: scalarType('binary') },
960
+ { contentType: 'image/jpeg', bodyType: scalarType('binary') },
961
+ ]),
962
+ ],
963
+ }),
964
+ ]),
965
+ ]);
966
+ const output = generateOp(root);
967
+ expect(output).toContain('body: Buffer');
968
+ expect(output).not.toContain('_ZodBinary');
969
+ expect(output).not.toContain("import { z } from 'zod';");
970
+ });
971
+
972
+ it('still emits _ZodBinary when a request body actually validates binary', () => {
973
+ const root = opRoot([opRoute('/upload', [opOperation('post', { request: opRequest(scalarType('binary')) })])]);
974
+ expect(generateOp(root)).toContain('const _ZodBinary =');
975
+ });
705
976
  });
706
977
 
707
978
  describe('luxon imports cover every scalar that references a luxon class', () => {
@@ -710,13 +981,23 @@ describe('generateOperation', () => {
710
981
  expect(generateOp(root)).toContain("import { Duration } from 'luxon';");
711
982
  });
712
983
 
713
- it('imports Interval and emits the _ZodInterval helper for an interval body', () => {
714
- const root = opRoot([opRoute('/x', [opOperation('get', { responses: [opResponse(200, scalarType('interval'), 'application/json')] })])]);
984
+ it('imports Interval and emits the _ZodInterval helper for an interval request body', () => {
985
+ const root = opRoot([opRoute('/x', [opOperation('post', { request: opRequest(scalarType('interval')) })])]);
715
986
  const output = generateOp(root);
716
987
  expect(output).toContain("import { Interval } from 'luxon';");
717
988
  expect(output).toContain('const _ZodInterval =');
718
989
  });
719
990
 
991
+ it('emits neither for an interval response body, which is a plain string on the way out', () => {
992
+ // _ZodInterval transforms to an ISO string, so a response-side interval needs no
993
+ // schema and no luxon class — emitting either would leave both unused.
994
+ const root = opRoot([opRoute('/x', [opOperation('get', { responses: [opResponse(200, scalarType('interval'), 'application/json')] })])]);
995
+ const output = generateOp(root);
996
+ expect(output).toContain('const result: string = await service.list();');
997
+ expect(output).not.toContain('_ZodInterval');
998
+ expect(output).not.toContain('luxon');
999
+ });
1000
+
720
1001
  it('imports DateTime and Duration together when both are used', () => {
721
1002
  const root = opRoot([
722
1003
  opRoute('/x', [
@@ -974,7 +1255,7 @@ describe('generateOp — route modifiers JSDoc', () => {
974
1255
  const root = opRoot([opRoute('/users', [op])]);
975
1256
  const out = generateOp(root);
976
1257
  expect(out).not.toContain('requireSignature');
977
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requirePolicy }`);
1258
+ expect(out).toContain(`import { ServerKitRouter, requirePolicy }`);
978
1259
  });
979
1260
  });
980
1261
 
@@ -985,7 +1266,7 @@ describe('generateOp — route modifiers JSDoc', () => {
985
1266
  const op = opOperation('get');
986
1267
  const root = opRoot([opRoute('/users', [op])]);
987
1268
  const out = generateOp(root);
988
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requirePolicy }`);
1269
+ expect(out).toContain(`import { ServerKitRouter, requirePolicy }`);
989
1270
  expect(out).toContain(`requirePolicy()`);
990
1271
  });
991
1272
 
@@ -1019,7 +1300,7 @@ describe('generateOp — route modifiers JSDoc', () => {
1019
1300
  const op = opOperation('get', { security: SECURITY_NONE });
1020
1301
  const root = opRoot([opRoute('/health', [op])]);
1021
1302
  const out = generateOp(root);
1022
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware }`);
1303
+ expect(out).toContain(`import { ServerKitRouter } from`);
1023
1304
  expect(out).not.toContain('requirePolicy');
1024
1305
  });
1025
1306
 
@@ -14,6 +14,7 @@ import {
14
14
  hasPublicOperations,
15
15
  generateSdkPackageJson,
16
16
  generateSdkTsconfig,
17
+ generateErrorBodyAliases,
17
18
  } from '../src/codegen-sdk.js';
18
19
  import { collectPublicTypeNames } from '@contractkit/core';
19
20
  import { renderTsType, renderInputTsType } from '../src/ts-render.js';
@@ -25,6 +26,7 @@ import {
25
26
  opRequest,
26
27
  opMultiRequest,
27
28
  opResponse,
29
+ opResponseMulti,
28
30
  scalarType,
29
31
  refType,
30
32
  arrayType,
@@ -351,8 +353,8 @@ describe('generateSdk', () => {
351
353
  responses: [
352
354
  {
353
355
  statusCode: 200,
354
- contentType: 'application/json',
355
- bodyType: { kind: 'ref', name: 'Transfer' },
356
+ hasBlock: true,
357
+ bodies: [{ contentType: 'application/json', bodyType: { kind: 'ref', name: 'Transfer' } }],
356
358
  headers: [
357
359
  { name: 'preference-applied', optional: true, type: scalarType('string') },
358
360
  { name: 'etag', optional: false, type: scalarType('string') },
@@ -381,6 +383,8 @@ describe('generateSdk', () => {
381
383
  responses: [
382
384
  {
383
385
  statusCode: 204,
386
+ hasBlock: true,
387
+ bodies: [],
384
388
  headers: [{ name: 'x-deleted-at', optional: false, type: scalarType('string') }],
385
389
  },
386
390
  ],
@@ -649,7 +653,7 @@ describe('generateSdk', () => {
649
653
  expect(out).toContain('baseUrl: string');
650
654
  expect(out).toContain('headers?:');
651
655
  expect(out).toContain('fetch?: SdkFetch');
652
- expect(out).toContain('export class SdkError extends Error');
656
+ expect(out).toContain('export class SdkError<TBody = unknown> extends Error');
653
657
  expect(out).toContain('public readonly headers: Headers');
654
658
  expect(out).toContain('throw new SdkError(res.status, res.statusText, body, res.headers)');
655
659
  });
@@ -721,12 +725,186 @@ describe('generateSdk', () => {
721
725
  const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
722
726
  const out = generateSdk(root);
723
727
  expect(out).toContain('export interface SdkOptions');
724
- expect(out).toContain('export class SdkError extends Error');
728
+ expect(out).toContain('export class SdkError<TBody = unknown> extends Error');
725
729
  expect(out).toContain('export type SdkFetch');
726
730
  expect(out).toContain('export function createSdkFetch(');
727
731
  expect(out).toContain('fetch?: SdkFetch');
728
732
  });
729
733
  });
734
+
735
+ // ─── What the client can receive ─────────────────────────────────────
736
+
737
+ describe('observable-set returns', () => {
738
+ const sharedOptions = { sdkOptionsPath: '/x/sdk-options.ts', outPath: '/x/client.ts' };
739
+ const artBodies = [
740
+ { contentType: 'image/png', bodyType: scalarType('binary') },
741
+ { contentType: 'image/jpeg', bodyType: scalarType('binary') },
742
+ ];
743
+
744
+ it('leaves the common success-plus-bodyless-errors method alone', () => {
745
+ const root = opRoot([
746
+ opRoute('/pet', [
747
+ opOperation('get', {
748
+ sdk: 'listPets',
749
+ responses: [opResponse(200, 'Pet', 'application/json'), opResponse(400), opResponse(404)],
750
+ }),
751
+ ]),
752
+ ]);
753
+ const out = generateSdk(root, sharedOptions);
754
+ expect(out).toContain('async listPets(): Promise<Pet> {');
755
+ expect(out).toContain('return await parseJson<Pet>(result);');
756
+ expect(out).not.toContain('expectStatuses');
757
+ expect(out).not.toContain('switch (result.status)');
758
+ });
759
+
760
+ it('tells the caller which mime came back when a status declares several', () => {
761
+ const root = opRoot([
762
+ opRoute('/art', [opOperation('get', { sdk: 'getArt', responses: [opResponseMulti(200, artBodies)] })]),
763
+ ]);
764
+ const out = generateSdk(root, sharedOptions);
765
+ expect(out).toContain("async getArt(): Promise<{ contentType: 'image/png' | 'image/jpeg'; data: Blob }> {");
766
+ expect(out).toContain("contentType: readContentType(result) as 'image/png' | 'image/jpeg'");
767
+ expect(out).toContain('data: await result.blob()');
768
+ expect(out).toContain("import { readContentType } from '/x/sdk-options.js'".replace('/x/', './'));
769
+ });
770
+
771
+ it('dispatches on the response content type when the mimes read differently', () => {
772
+ const root = opRoot([
773
+ opRoute('/pet', [
774
+ opOperation('get', {
775
+ sdk: 'getPet',
776
+ responses: [
777
+ opResponseMulti(200, [
778
+ { contentType: 'application/json', bodyType: refType('Pet') },
779
+ { contentType: 'text/csv', bodyType: scalarType('string') },
780
+ ]),
781
+ ],
782
+ }),
783
+ ]),
784
+ ]);
785
+ const out = generateSdk(root, sharedOptions);
786
+ expect(out).toContain("| { contentType: 'application/json'; data: Pet }");
787
+ expect(out).toContain("| { contentType: 'text/csv'; data: string }");
788
+ expect(out).toContain('switch (readContentType(result)) {');
789
+ expect(out).toContain("case 'text/csv':");
790
+ });
791
+
792
+ it('returns a union over every status a client can receive, including one middleware produces', () => {
793
+ const root = opRoot([
794
+ opRoute('/art', [
795
+ opOperation('get', {
796
+ sdk: 'getArt',
797
+ responses: [opResponse(200, 'Art', 'application/json'), opResponse(304), opResponse(404)],
798
+ }),
799
+ ]),
800
+ ]);
801
+ const out = generateSdk(root, sharedOptions);
802
+ expect(out).toContain("| { status: 200; contentType: 'application/json'; data: Art }");
803
+ expect(out).toContain('| { status: 304 }');
804
+ // The bare 404 still throws, so it is not a member.
805
+ expect(out).not.toContain('status: 404');
806
+ expect(out).toContain('switch (result.status) {');
807
+ });
808
+
809
+ it('stops throwing for a status declared as a value rather than an error', () => {
810
+ const root = opRoot([
811
+ opRoute('/pet', [
812
+ opOperation('get', {
813
+ sdk: 'getPet',
814
+ responses: [opResponse(200, 'Pet', 'application/json'), opResponse(422, 'Problem', 'application/json'), opResponse(404)],
815
+ }),
816
+ ]),
817
+ ]);
818
+ const out = generateSdk(root, sharedOptions);
819
+ expect(out).toContain('expectStatuses: [422]');
820
+ expect(out).toContain("| { status: 422; contentType: 'application/json'; data: Problem }");
821
+ expect(out).toContain('case 422:');
822
+ });
823
+
824
+ it('passes expectStatuses so a declared 304 does not surface as SdkError', () => {
825
+ const root = opRoot([
826
+ opRoute('/art', [opOperation('get', { sdk: 'getArt', responses: [opResponse(200, 'Art', 'application/json'), opResponse(304)] })]),
827
+ ]);
828
+ expect(generateSdk(root, sharedOptions)).toContain('expectStatuses: [304]');
829
+ });
830
+
831
+ it('types the throw path from the statuses that stay errors', () => {
832
+ const root = opRoot([
833
+ opRoute('/pet', [
834
+ opOperation('get', {
835
+ sdk: 'getPet',
836
+ responses: [
837
+ opResponse(200, 'Pet', 'application/json'),
838
+ { ...opResponse(404, refType('Problem'), 'application/json'), emit: 'documented' as const },
839
+ opResponse(500),
840
+ ],
841
+ }),
842
+ ]),
843
+ ]);
844
+ const out = generateSdk(root, sharedOptions);
845
+ expect(out).toContain('export type GetPetErrorBody = Problem;');
846
+ expect(out).toContain('/** @throws {SdkError<GetPetErrorBody>} on 404, 500 */');
847
+ // Documented, so it is not a return-union member.
848
+ expect(out).toContain('async getPet(): Promise<Pet> {');
849
+ });
850
+
851
+ it('emits no alias when the error statuses declare no body', () => {
852
+ const root = opRoot([
853
+ opRoute('/pet', [opOperation('get', { sdk: 'getPet', responses: [opResponse(200, 'Pet', 'application/json'), opResponse(404)] })]),
854
+ ]);
855
+ expect(generateSdk(root, sharedOptions)).not.toContain('ErrorBody');
856
+ });
857
+ });
858
+ });
859
+
860
+ describe('generateErrorBodyAliases', () => {
861
+ it('emits one alias per operation whose thrown statuses declare a body', () => {
862
+ const root = opRoot([
863
+ opRoute('/pet', [
864
+ opOperation('get', {
865
+ sdk: 'getPet',
866
+ responses: [
867
+ opResponse(200, 'Pet', 'application/json'),
868
+ { ...opResponse(404, refType('Problem'), 'application/json'), emit: 'documented' as const },
869
+ ],
870
+ }),
871
+ ]),
872
+ ]);
873
+ expect(generateErrorBodyAliases(root, {})).toEqual(['export type GetPetErrorBody = Problem;']);
874
+ });
875
+
876
+ it('merges the bodies of several thrown statuses into one union', () => {
877
+ const root = opRoot([
878
+ opRoute('/pet', [
879
+ opOperation('get', {
880
+ sdk: 'getPet',
881
+ responses: [
882
+ opResponse(200, 'Pet', 'application/json'),
883
+ { ...opResponse(404, refType('NotFound'), 'application/json'), emit: 'documented' as const },
884
+ { ...opResponse(500, refType('Problem'), 'application/json'), emit: 'documented' as const },
885
+ ],
886
+ }),
887
+ ]),
888
+ ]);
889
+ expect(generateErrorBodyAliases(root, {})).toEqual(['export type GetPetErrorBody = NotFound | Problem;']);
890
+ });
891
+
892
+ it('emits nothing when the thrown statuses are bodyless', () => {
893
+ const root = opRoot([
894
+ opRoute('/pet', [opOperation('get', { sdk: 'getPet', responses: [opResponse(200, 'Pet', 'application/json'), opResponse(404)] })]),
895
+ ]);
896
+ expect(generateErrorBodyAliases(root, {})).toEqual([]);
897
+ });
898
+
899
+ it('skips internal operations unless asked for them', () => {
900
+ const responses = [
901
+ opResponse(200, 'Pet', 'application/json'),
902
+ { ...opResponse(404, refType('Problem'), 'application/json'), emit: 'documented' as const },
903
+ ];
904
+ const root = opRoot([opRoute('/pet', [opOperation('get', { sdk: 'getPet', responses })], undefined, ['internal'])]);
905
+ expect(generateErrorBodyAliases(root, {})).toEqual([]);
906
+ expect(generateErrorBodyAliases(root, { includeInternal: true })).toHaveLength(1);
907
+ });
730
908
  });
731
909
 
732
910
  describe('deriveClientClassName', () => {
@@ -757,9 +935,9 @@ describe('generateSdkOptions', () => {
757
935
  expect(out).toContain('headers?:');
758
936
  expect(out).toContain('fetch?: SdkFetch');
759
937
  expect(out).toContain('requestIdFactory?: () => string');
760
- expect(out).toContain('export class SdkError extends Error');
938
+ expect(out).toContain('export class SdkError<TBody = unknown> extends Error');
761
939
  expect(out).toContain('public readonly status: number');
762
- expect(out).toContain('public readonly body: unknown');
940
+ expect(out).toContain('public readonly body: TBody');
763
941
  expect(out).toContain('public readonly headers: Headers');
764
942
  expect(out).toContain('throw new SdkError(res.status, res.statusText, body, res.headers)');
765
943
  expect(out).toContain('export type SdkFetch');
package/tests/helpers.ts CHANGED
@@ -135,7 +135,26 @@ export function opMultiRequest(entries: Array<[string, string | ContractTypeNode
135
135
  export function opResponse(statusCode: number, bodyType?: string | ContractTypeNode, contentType?: string): OpResponseNode {
136
136
  const bt: ContractTypeNode | undefined =
137
137
  bodyType === undefined ? undefined : typeof bodyType === 'string' ? parseBodyTypeString(bodyType) : bodyType;
138
- return { statusCode, contentType, bodyType: bt };
138
+ const bodies = bt === undefined ? [] : [{ contentType: contentType ?? 'application/json', bodyType: bt }];
139
+ return { statusCode, bodies, ...(bt !== undefined ? { hasBlock: true } : {}) };
140
+ }
141
+
142
+ /** A status declaring several mimes, as `200: { image/png: binary, image/jpeg: binary }` parses. */
143
+ export function opResponseMulti(
144
+ statusCode: number,
145
+ bodies: { contentType: string; bodyType: string | ContractTypeNode }[],
146
+ extra?: Partial<OpResponseNode>,
147
+ ): OpResponseNode {
148
+ const resolved = bodies.map(b => ({
149
+ contentType: b.contentType,
150
+ bodyType: typeof b.bodyType === 'string' ? parseBodyTypeString(b.bodyType) : b.bodyType,
151
+ }));
152
+ return {
153
+ statusCode,
154
+ bodies: resolved,
155
+ hasBlock: true,
156
+ ...extra,
157
+ };
139
158
  }
140
159
 
141
160
  function parseBodyTypeString(s: string): ContractTypeNode {