@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.
- package/.turbo/turbo-build$colon$ci.log +4 -4
- package/.turbo/turbo-test$colon$ci.log +19 -19
- package/CHANGELOG.md +35 -0
- package/README.md +8 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts +9 -1
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/codegen-sdk.d.ts +8 -0
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/index.js +487 -168
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/codegen-mcp.ts +15 -7
- package/src/codegen-operation.ts +243 -167
- package/src/codegen-sdk.ts +274 -49
- package/src/index.ts +1 -1
- package/tests/codegen-operation.test.ts +288 -7
- package/tests/codegen-sdk.test.ts +184 -6
- package/tests/helpers.ts +20 -1
|
@@ -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
|
-
|
|
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('
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|