@contractkit/plugin-typescript 0.28.2 → 0.30.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 +5 -5
- package/.turbo/turbo-test$colon$ci.log +19 -19
- package/CHANGELOG.md +23 -0
- package/README.md +4 -0
- package/dist/codegen-contract.d.ts +7 -0
- package/dist/codegen-contract.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-plain-types.d.ts +3 -0
- package/dist/codegen-plain-types.d.ts.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +144 -120
- package/dist/index.js.map +1 -1
- package/dist/ts-render.d.ts +21 -3
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen-contract.ts +7 -0
- package/src/codegen-operation.ts +103 -78
- package/src/codegen-plain-types.ts +47 -22
- package/src/index.ts +21 -1
- package/src/ts-render.ts +52 -32
- package/tests/codegen-operation.test.ts +134 -3
- package/tests/codegen-plain-types.test.ts +48 -0
- package/tests/codegen-server.test.ts +37 -0
package/src/ts-render.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { ContractTypeNode, FieldNode, ScalarTypeNode } from '@contractkit/core';
|
|
2
2
|
|
|
3
|
+
/** Declaration emitted into generated files that reference the `json` scalar. */
|
|
3
4
|
export const JSON_VALUE_TYPE_DECL = 'export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };';
|
|
4
5
|
|
|
6
|
+
/** Quote a property name unless it is already a valid bare TypeScript identifier. */
|
|
5
7
|
export function quoteKey(name: string): string {
|
|
6
8
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `'${name}'`;
|
|
7
9
|
}
|
|
@@ -31,12 +33,25 @@ export function headerNameToProperty(name: string): string {
|
|
|
31
33
|
|
|
32
34
|
// ─── TypeScript type rendering ────────────────────────────────────────────
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Which runtime the emitted types describe. A few scalars have no single correct TypeScript
|
|
38
|
+
* type: `binary` is a `Blob` in a fetch-based client but a `Buffer` on a Node server. Everything
|
|
39
|
+
* else renders identically for both targets. Defaults to `'client'`.
|
|
40
|
+
*/
|
|
41
|
+
export type TsRenderTarget = 'client' | 'server';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Render a contract type as a plain TypeScript type expression. Model refs render as their bare
|
|
45
|
+
* name; use `renderInputTsType` / `renderOutputTsType` to substitute Input/Output variants.
|
|
46
|
+
*
|
|
47
|
+
* @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
|
|
48
|
+
*/
|
|
49
|
+
export function renderTsType(type: ContractTypeNode, target: TsRenderTarget = 'client'): string {
|
|
35
50
|
switch (type.kind) {
|
|
36
51
|
case 'scalar':
|
|
37
|
-
return renderTsScalar(type.name);
|
|
52
|
+
return renderTsScalar(type.name, target);
|
|
38
53
|
case 'array': {
|
|
39
|
-
const inner = renderTsType(type.item);
|
|
54
|
+
const inner = renderTsType(type.item, target);
|
|
40
55
|
const needsParens =
|
|
41
56
|
type.item.kind === 'union' ||
|
|
42
57
|
type.item.kind === 'discriminatedUnion' ||
|
|
@@ -45,31 +60,31 @@ export function renderTsType(type: ContractTypeNode): string {
|
|
|
45
60
|
return needsParens ? `(${inner})[]` : `${inner}[]`;
|
|
46
61
|
}
|
|
47
62
|
case 'tuple':
|
|
48
|
-
return `[${type.items.map(renderTsType).join(', ')}]`;
|
|
63
|
+
return `[${type.items.map(i => renderTsType(i, target)).join(', ')}]`;
|
|
49
64
|
case 'record':
|
|
50
|
-
return `Record<${renderTsType(type.key)}, ${renderTsType(type.value)}>`;
|
|
65
|
+
return `Record<${renderTsType(type.key, target)}, ${renderTsType(type.value, target)}>`;
|
|
51
66
|
case 'enum':
|
|
52
67
|
return type.values.map(v => `'${escapeSingleQuoted(v)}'`).join(' | ');
|
|
53
68
|
case 'literal':
|
|
54
69
|
return typeof type.value === 'string' ? `'${escapeSingleQuoted(type.value)}'` : String(type.value);
|
|
55
70
|
case 'union':
|
|
56
|
-
return type.members.map(renderTsType).join(' | ');
|
|
71
|
+
return type.members.map(m => renderTsType(m, target)).join(' | ');
|
|
57
72
|
case 'discriminatedUnion':
|
|
58
|
-
return type.members.map(renderTsType).join(' | ');
|
|
73
|
+
return type.members.map(m => renderTsType(m, target)).join(' | ');
|
|
59
74
|
case 'intersection':
|
|
60
|
-
return type.members.map(renderTsType).join(' & ');
|
|
75
|
+
return type.members.map(m => renderTsType(m, target)).join(' & ');
|
|
61
76
|
case 'ref':
|
|
62
77
|
return type.name;
|
|
63
78
|
case 'lazy':
|
|
64
|
-
return renderTsType(type.inner);
|
|
79
|
+
return renderTsType(type.inner, target);
|
|
65
80
|
case 'inlineObject':
|
|
66
|
-
return renderTsInlineObject(type.fields);
|
|
81
|
+
return renderTsInlineObject(type.fields, target);
|
|
67
82
|
default:
|
|
68
83
|
return 'unknown';
|
|
69
84
|
}
|
|
70
85
|
}
|
|
71
86
|
|
|
72
|
-
function renderTsScalar(name: ScalarTypeNode['name']): string {
|
|
87
|
+
function renderTsScalar(name: ScalarTypeNode['name'], target: TsRenderTarget): string {
|
|
73
88
|
switch (name) {
|
|
74
89
|
case 'string':
|
|
75
90
|
case 'email':
|
|
@@ -96,7 +111,8 @@ function renderTsScalar(name: ScalarTypeNode['name']): string {
|
|
|
96
111
|
case 'object':
|
|
97
112
|
return 'Record<string, unknown>';
|
|
98
113
|
case 'binary':
|
|
99
|
-
|
|
114
|
+
// Node servers hand the handler a Buffer (matching `_ZodBinary`); fetch clients get a Blob.
|
|
115
|
+
return target === 'server' ? 'Buffer' : 'Blob';
|
|
100
116
|
case 'json':
|
|
101
117
|
return 'JsonValue';
|
|
102
118
|
default: {
|
|
@@ -106,10 +122,10 @@ function renderTsScalar(name: ScalarTypeNode['name']): string {
|
|
|
106
122
|
}
|
|
107
123
|
}
|
|
108
124
|
|
|
109
|
-
function renderTsInlineObject(fields: FieldNode[]): string {
|
|
125
|
+
function renderTsInlineObject(fields: FieldNode[], target: TsRenderTarget): string {
|
|
110
126
|
const entries = fields.map(f => {
|
|
111
127
|
const opt = f.optional ? '?' : '';
|
|
112
|
-
return `${quoteKey(f.name)}${opt}: ${renderTsType(f.type)}`;
|
|
128
|
+
return `${quoteKey(f.name)}${opt}: ${renderTsType(f.type, target)}`;
|
|
113
129
|
});
|
|
114
130
|
return `{ ${entries.join('; ')} }`;
|
|
115
131
|
}
|
|
@@ -118,14 +134,16 @@ function renderTsInlineObject(fields: FieldNode[]): string {
|
|
|
118
134
|
* Like renderTsType, but substitutes model refs with their Input variant
|
|
119
135
|
* when the model has visibility modifiers. Used for request-side types
|
|
120
136
|
* (body, params, query, headers).
|
|
137
|
+
*
|
|
138
|
+
* @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
|
|
121
139
|
*/
|
|
122
|
-
export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string
|
|
123
|
-
if (!modelsWithInput || modelsWithInput.size === 0) return renderTsType(type);
|
|
140
|
+
export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string>, target: TsRenderTarget = 'client'): string {
|
|
141
|
+
if (!modelsWithInput || modelsWithInput.size === 0) return renderTsType(type, target);
|
|
124
142
|
switch (type.kind) {
|
|
125
143
|
case 'ref':
|
|
126
144
|
return modelsWithInput.has(type.name) ? `${type.name}Input` : type.name;
|
|
127
145
|
case 'array': {
|
|
128
|
-
const inner = renderInputTsType(type.item, modelsWithInput);
|
|
146
|
+
const inner = renderInputTsType(type.item, modelsWithInput, target);
|
|
129
147
|
const needsParens =
|
|
130
148
|
type.item.kind === 'union' ||
|
|
131
149
|
type.item.kind === 'discriminatedUnion' ||
|
|
@@ -134,17 +152,17 @@ export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<
|
|
|
134
152
|
return needsParens ? `(${inner})[]` : `${inner}[]`;
|
|
135
153
|
}
|
|
136
154
|
case 'intersection':
|
|
137
|
-
return type.members.map(m => renderInputTsType(m, modelsWithInput)).join(' & ');
|
|
155
|
+
return type.members.map(m => renderInputTsType(m, modelsWithInput, target)).join(' & ');
|
|
138
156
|
case 'union':
|
|
139
|
-
return type.members.map(m => renderInputTsType(m, modelsWithInput)).join(' | ');
|
|
157
|
+
return type.members.map(m => renderInputTsType(m, modelsWithInput, target)).join(' | ');
|
|
140
158
|
case 'discriminatedUnion':
|
|
141
|
-
return type.members.map(m => renderInputTsType(m, modelsWithInput)).join(' | ');
|
|
159
|
+
return type.members.map(m => renderInputTsType(m, modelsWithInput, target)).join(' | ');
|
|
142
160
|
case 'inlineObject':
|
|
143
|
-
return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderInputTsType(f.type, modelsWithInput)}`).join('; ')} }`;
|
|
161
|
+
return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderInputTsType(f.type, modelsWithInput, target)}`).join('; ')} }`;
|
|
144
162
|
case 'lazy':
|
|
145
|
-
return renderInputTsType(type.inner, modelsWithInput);
|
|
163
|
+
return renderInputTsType(type.inner, modelsWithInput, target);
|
|
146
164
|
default:
|
|
147
|
-
return renderTsType(type);
|
|
165
|
+
return renderTsType(type, target);
|
|
148
166
|
}
|
|
149
167
|
}
|
|
150
168
|
|
|
@@ -153,14 +171,16 @@ export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<
|
|
|
153
171
|
* (post-transform wire shape) when the model has format(output=...) or
|
|
154
172
|
* transitively references one. Used for response-side types in routers
|
|
155
173
|
* and SDK return types.
|
|
174
|
+
*
|
|
175
|
+
* @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
|
|
156
176
|
*/
|
|
157
|
-
export function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string
|
|
158
|
-
if (!modelsWithOutput || modelsWithOutput.size === 0) return renderTsType(type);
|
|
177
|
+
export function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string>, target: TsRenderTarget = 'client'): string {
|
|
178
|
+
if (!modelsWithOutput || modelsWithOutput.size === 0) return renderTsType(type, target);
|
|
159
179
|
switch (type.kind) {
|
|
160
180
|
case 'ref':
|
|
161
181
|
return modelsWithOutput.has(type.name) ? `${type.name}Output` : type.name;
|
|
162
182
|
case 'array': {
|
|
163
|
-
const inner = renderOutputTsType(type.item, modelsWithOutput);
|
|
183
|
+
const inner = renderOutputTsType(type.item, modelsWithOutput, target);
|
|
164
184
|
const needsParens =
|
|
165
185
|
type.item.kind === 'union' ||
|
|
166
186
|
type.item.kind === 'discriminatedUnion' ||
|
|
@@ -169,16 +189,16 @@ export function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Se
|
|
|
169
189
|
return needsParens ? `(${inner})[]` : `${inner}[]`;
|
|
170
190
|
}
|
|
171
191
|
case 'intersection':
|
|
172
|
-
return type.members.map(m => renderOutputTsType(m, modelsWithOutput)).join(' & ');
|
|
192
|
+
return type.members.map(m => renderOutputTsType(m, modelsWithOutput, target)).join(' & ');
|
|
173
193
|
case 'union':
|
|
174
|
-
return type.members.map(m => renderOutputTsType(m, modelsWithOutput)).join(' | ');
|
|
194
|
+
return type.members.map(m => renderOutputTsType(m, modelsWithOutput, target)).join(' | ');
|
|
175
195
|
case 'discriminatedUnion':
|
|
176
|
-
return type.members.map(m => renderOutputTsType(m, modelsWithOutput)).join(' | ');
|
|
196
|
+
return type.members.map(m => renderOutputTsType(m, modelsWithOutput, target)).join(' | ');
|
|
177
197
|
case 'inlineObject':
|
|
178
|
-
return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderOutputTsType(f.type, modelsWithOutput)}`).join('; ')} }`;
|
|
198
|
+
return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderOutputTsType(f.type, modelsWithOutput, target)}`).join('; ')} }`;
|
|
179
199
|
case 'lazy':
|
|
180
|
-
return renderOutputTsType(type.inner, modelsWithOutput);
|
|
200
|
+
return renderOutputTsType(type.inner, modelsWithOutput, target);
|
|
181
201
|
default:
|
|
182
|
-
return renderTsType(type);
|
|
202
|
+
return renderTsType(type, target);
|
|
183
203
|
}
|
|
184
204
|
}
|
|
@@ -101,6 +101,66 @@ describe('generateOperation', () => {
|
|
|
101
101
|
const output = generateOp(root);
|
|
102
102
|
expect(output).not.toContain('luxon');
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
// Every conditional import must be justified by a reference in the generated body —
|
|
106
|
+
// an unused import trips `noUnusedLocals` and lint in the consuming project.
|
|
107
|
+
it('imports bodyParserMiddleware only when an operation has a request body', () => {
|
|
108
|
+
const withBody = generateOp(opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]));
|
|
109
|
+
expect(withBody).toContain('bodyParserMiddleware');
|
|
110
|
+
|
|
111
|
+
const withoutBody = generateOp(opRoot([opRoute('/users', [opOperation('get')])]));
|
|
112
|
+
expect(withoutBody).not.toContain('bodyParserMiddleware');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('omits MultipartBody when a multipart body shares its shape with the other MIME types', () => {
|
|
116
|
+
// Structurally equal bodies collapse to a single parseAndValidate call, so nothing
|
|
117
|
+
// references MultipartBody even though the operation does declare multipart.
|
|
118
|
+
const root = opRoot([
|
|
119
|
+
opRoute('/upload', [
|
|
120
|
+
opOperation('post', {
|
|
121
|
+
request: opMultiRequest([
|
|
122
|
+
['multipart/form-data', 'UploadForm'],
|
|
123
|
+
['application/json', 'UploadForm'],
|
|
124
|
+
]),
|
|
125
|
+
}),
|
|
126
|
+
]),
|
|
127
|
+
]);
|
|
128
|
+
const output = generateOp(root);
|
|
129
|
+
expect(output).not.toContain('MultipartBody');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('imports MultipartBody when the multipart body is handled on its own', () => {
|
|
133
|
+
const root = opRoot([opRoute('/upload', [opOperation('post', { request: opMultiRequest([['multipart/form-data', 'UploadForm']]) })])]);
|
|
134
|
+
expect(generateOp(root)).toContain("import { MultipartBody } from '@maroonedsoftware/multipart';");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('leaves no import unreferenced in the generated body', () => {
|
|
138
|
+
const root = opRoot([
|
|
139
|
+
opRoute(
|
|
140
|
+
'/users/{id}',
|
|
141
|
+
[
|
|
142
|
+
opOperation('get', { security: SECURITY_NONE }),
|
|
143
|
+
opOperation('post', { request: opRequest('CreateUser'), signature: 'webhookKey' }),
|
|
144
|
+
],
|
|
145
|
+
[opParam('id', scalarType('uuid'))],
|
|
146
|
+
),
|
|
147
|
+
]);
|
|
148
|
+
const output = generateOp(root);
|
|
149
|
+
const importLines = output.split('\n').filter(l => l.startsWith('import '));
|
|
150
|
+
expect(importLines.length).toBeGreaterThan(0);
|
|
151
|
+
|
|
152
|
+
const bodyText = output
|
|
153
|
+
.split('\n')
|
|
154
|
+
.filter(l => !l.startsWith('import '))
|
|
155
|
+
.join('\n');
|
|
156
|
+
for (const line of importLines) {
|
|
157
|
+
const named = line.match(/^import \{([^}]*)\}/);
|
|
158
|
+
if (!named) continue;
|
|
159
|
+
for (const symbol of named[1]!.split(',').map(s => s.trim().replace(/^type /, ''))) {
|
|
160
|
+
expect(bodyText, `${symbol} is imported but never used`).toMatch(new RegExp(`\\b${symbol}\\b`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
104
164
|
});
|
|
105
165
|
|
|
106
166
|
// ─── Handler signature ─────────────────────────────────────────
|
|
@@ -658,6 +718,77 @@ describe('generateOperation', () => {
|
|
|
658
718
|
const output = generateOp(root, { modelsWithOutput: new Set(['AuthToken']) });
|
|
659
719
|
expect(output).toContain('result: AuthTokenOutput[]');
|
|
660
720
|
});
|
|
721
|
+
|
|
722
|
+
// Scalar response bodies used to emit the .ck scalar name verbatim (`result: binary`),
|
|
723
|
+
// which only happened to compile for `string`.
|
|
724
|
+
describe('scalar response bodies map to the server-side TypeScript type', () => {
|
|
725
|
+
const cases: Array<[string, string]> = [
|
|
726
|
+
['binary', 'Buffer'],
|
|
727
|
+
['int', 'number'],
|
|
728
|
+
['number', 'number'],
|
|
729
|
+
['bigint', 'bigint'],
|
|
730
|
+
['boolean', 'boolean'],
|
|
731
|
+
['string', 'string'],
|
|
732
|
+
['uuid', 'string'],
|
|
733
|
+
['email', 'string'],
|
|
734
|
+
['url', 'string'],
|
|
735
|
+
['datetime', 'DateTime'],
|
|
736
|
+
['date', 'DateTime'],
|
|
737
|
+
['time', 'DateTime'],
|
|
738
|
+
['duration', 'Duration'],
|
|
739
|
+
['interval', 'string'],
|
|
740
|
+
['json', '_JsonValue'],
|
|
741
|
+
['object', 'Record<string, unknown>'],
|
|
742
|
+
['unknown', 'unknown'],
|
|
743
|
+
['null', 'null'],
|
|
744
|
+
];
|
|
745
|
+
|
|
746
|
+
for (const [scalar, tsType] of cases) {
|
|
747
|
+
it(`renders ${scalar} as ${tsType}`, () => {
|
|
748
|
+
const root = opRoot([
|
|
749
|
+
opRoute('/x', [
|
|
750
|
+
opOperation('get', {
|
|
751
|
+
responses: [opResponse(200, scalarType(scalar as never), 'application/octet-stream')],
|
|
752
|
+
}),
|
|
753
|
+
]),
|
|
754
|
+
]);
|
|
755
|
+
expect(generateOp(root)).toContain(`const result: ${tsType} = await service.list();`);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
it('renders an array of binary as Buffer[]', () => {
|
|
760
|
+
const root = opRoot([
|
|
761
|
+
opRoute('/x', [opOperation('get', { responses: [opResponse(200, arrayType(scalarType('binary')), 'application/json')] })]),
|
|
762
|
+
]);
|
|
763
|
+
expect(generateOp(root)).toContain('const result: Buffer[] = await service.list();');
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
describe('luxon imports cover every scalar that references a luxon class', () => {
|
|
768
|
+
it('imports Duration for a duration response body', () => {
|
|
769
|
+
const root = opRoot([opRoute('/x', [opOperation('get', { responses: [opResponse(200, scalarType('duration'), 'application/json')] })])]);
|
|
770
|
+
expect(generateOp(root)).toContain("import { Duration } from 'luxon';");
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it('imports Interval and emits the _ZodInterval helper for an interval body', () => {
|
|
774
|
+
const root = opRoot([opRoute('/x', [opOperation('get', { responses: [opResponse(200, scalarType('interval'), 'application/json')] })])]);
|
|
775
|
+
const output = generateOp(root);
|
|
776
|
+
expect(output).toContain("import { Interval } from 'luxon';");
|
|
777
|
+
expect(output).toContain('const _ZodInterval =');
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('imports DateTime and Duration together when both are used', () => {
|
|
781
|
+
const root = opRoot([
|
|
782
|
+
opRoute('/x', [
|
|
783
|
+
opOperation('post', {
|
|
784
|
+
request: opRequest(scalarType('datetime')),
|
|
785
|
+
responses: [opResponse(200, scalarType('duration'), 'application/json')],
|
|
786
|
+
}),
|
|
787
|
+
]),
|
|
788
|
+
]);
|
|
789
|
+
expect(generateOp(root)).toContain("import { DateTime, Duration } from 'luxon';");
|
|
790
|
+
});
|
|
791
|
+
});
|
|
661
792
|
});
|
|
662
793
|
|
|
663
794
|
// ─── Service inference ────────────────────────────────────────
|
|
@@ -903,7 +1034,7 @@ describe('generateOp — route modifiers JSDoc', () => {
|
|
|
903
1034
|
const root = opRoot([opRoute('/users', [op])]);
|
|
904
1035
|
const out = generateOp(root);
|
|
905
1036
|
expect(out).not.toContain('requireSignature');
|
|
906
|
-
expect(out).toContain(`import { ServerKitRouter,
|
|
1037
|
+
expect(out).toContain(`import { ServerKitRouter, requirePolicy }`);
|
|
907
1038
|
});
|
|
908
1039
|
});
|
|
909
1040
|
|
|
@@ -914,7 +1045,7 @@ describe('generateOp — route modifiers JSDoc', () => {
|
|
|
914
1045
|
const op = opOperation('get');
|
|
915
1046
|
const root = opRoot([opRoute('/users', [op])]);
|
|
916
1047
|
const out = generateOp(root);
|
|
917
|
-
expect(out).toContain(`import { ServerKitRouter,
|
|
1048
|
+
expect(out).toContain(`import { ServerKitRouter, requirePolicy }`);
|
|
918
1049
|
expect(out).toContain(`requirePolicy()`);
|
|
919
1050
|
});
|
|
920
1051
|
|
|
@@ -948,7 +1079,7 @@ describe('generateOp — route modifiers JSDoc', () => {
|
|
|
948
1079
|
const op = opOperation('get', { security: SECURITY_NONE });
|
|
949
1080
|
const root = opRoot([opRoute('/health', [op])]);
|
|
950
1081
|
const out = generateOp(root);
|
|
951
|
-
expect(out).toContain(`import { ServerKitRouter
|
|
1082
|
+
expect(out).toContain(`import { ServerKitRouter } from`);
|
|
952
1083
|
expect(out).not.toContain('requirePolicy');
|
|
953
1084
|
});
|
|
954
1085
|
|
|
@@ -98,6 +98,54 @@ describe('generatePlainTypes', () => {
|
|
|
98
98
|
expect(output).toContain('o: Record<string, unknown>;');
|
|
99
99
|
expect(output).toContain('bin: Blob;');
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
// `binary` is the one scalar with no runtime-independent TypeScript type: a fetch client
|
|
103
|
+
// sees a Blob, a Koa handler sees the Buffer that _ZodBinary validates.
|
|
104
|
+
describe('binary follows the render target', () => {
|
|
105
|
+
const ctx = (target: 'client' | 'server') => ({
|
|
106
|
+
modelOutPaths: new Map<string, string>(),
|
|
107
|
+
currentOutPath: 'out.ts',
|
|
108
|
+
target,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('renders Buffer for the server target', () => {
|
|
112
|
+
const root = contractRoot([model('M', [field('bin', scalarType('binary'))])]);
|
|
113
|
+
expect(generatePlainTypes(root, ctx('server'))).toContain('bin: Buffer;');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('renders Blob for the client target', () => {
|
|
117
|
+
const root = contractRoot([model('M', [field('bin', scalarType('binary'))])]);
|
|
118
|
+
expect(generatePlainTypes(root, ctx('client'))).toContain('bin: Blob;');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('defaults to the client target when unset', () => {
|
|
122
|
+
const root = contractRoot([model('M', [field('bin', scalarType('binary'))])]);
|
|
123
|
+
expect(generatePlainTypes(root)).toContain('bin: Blob;');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('applies to nested and compound positions', () => {
|
|
127
|
+
const root = contractRoot([
|
|
128
|
+
model('M', [
|
|
129
|
+
field('list', arrayType(scalarType('binary'))),
|
|
130
|
+
field('nested', inlineObjectType([field('bin', scalarType('binary'))])),
|
|
131
|
+
field('map', recordType(scalarType('string'), scalarType('binary'))),
|
|
132
|
+
]),
|
|
133
|
+
]);
|
|
134
|
+
const output = generatePlainTypes(root, ctx('server'));
|
|
135
|
+
expect(output).toContain('list: Buffer[];');
|
|
136
|
+
expect(output).toContain('nested: { bin: Buffer };');
|
|
137
|
+
expect(output).toContain('map: Record<string, Buffer>;');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('applies to type aliases and their Input variants', () => {
|
|
141
|
+
const root = contractRoot([
|
|
142
|
+
model('Blob1', [field('bin', scalarType('binary')), field('secret', scalarType('binary'), { visibility: 'readonly' })]),
|
|
143
|
+
]);
|
|
144
|
+
const output = generatePlainTypes(root, { ...ctx('server'), modelsWithInput: new Set(['Blob1']) });
|
|
145
|
+
expect(output).toContain('bin: Buffer;');
|
|
146
|
+
expect(output).not.toContain('Blob;');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
101
149
|
});
|
|
102
150
|
|
|
103
151
|
// ─── Compound types ───────────────────────────────────────────
|
|
@@ -120,6 +120,43 @@ describe('createTypescriptPlugin (server)', () => {
|
|
|
120
120
|
expect(typeContent).not.toContain('z.');
|
|
121
121
|
expect(typeContent).toContain('export interface User');
|
|
122
122
|
});
|
|
123
|
+
|
|
124
|
+
it('renders binary as Buffer in server plain types', async () => {
|
|
125
|
+
const plugin = createTypescriptPlugin({ server: { output: { types: 'src/types/{filename}.ts' } } }, '/project');
|
|
126
|
+
const ctx = makeCtx('/project');
|
|
127
|
+
const contractRoots = [contractRoot([model('Upload', [field('data', scalarType('binary'))])], '/project/contracts/uploads.ck')];
|
|
128
|
+
await plugin.generateTargets!(inputs([], contractRoots as any), ctx);
|
|
129
|
+
const typeContent = [...ctx.emitted.values()][0]!;
|
|
130
|
+
expect(typeContent).toContain('data: Buffer;');
|
|
131
|
+
expect(typeContent).not.toContain('Blob');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('renders binary as Blob in SDK plain types', async () => {
|
|
135
|
+
const plugin = createTypescriptPlugin({ sdk: { output: { types: 'src/types/{filename}.ts' } } }, '/project');
|
|
136
|
+
const ctx = makeCtx('/project');
|
|
137
|
+
const contractRoots = [contractRoot([model('Upload', [field('data', scalarType('binary'))])], '/project/contracts/uploads.ck')];
|
|
138
|
+
await plugin.generateTargets!(inputs([], contractRoots as any), ctx);
|
|
139
|
+
const typeContent = [...ctx.emitted.values()].find(c => c.includes('interface Upload'))!;
|
|
140
|
+
expect(typeContent).toContain('data: Blob;');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('honors types.target on the standalone types sub-generator', async () => {
|
|
144
|
+
const contractRoots = [contractRoot([model('Upload', [field('data', scalarType('binary'))])], '/project/contracts/uploads.ck')];
|
|
145
|
+
|
|
146
|
+
const serverCtx = makeCtx('/project');
|
|
147
|
+
await createTypescriptPlugin({ types: { output: 'src/{filename}.types.ts', target: 'server' } }, '/project').generateTargets!(
|
|
148
|
+
inputs([], contractRoots as any),
|
|
149
|
+
serverCtx,
|
|
150
|
+
);
|
|
151
|
+
expect([...serverCtx.emitted.values()][0]!).toContain('data: Buffer;');
|
|
152
|
+
|
|
153
|
+
const defaultCtx = makeCtx('/project');
|
|
154
|
+
await createTypescriptPlugin({ types: { output: 'src/{filename}.types.ts' } }, '/project').generateTargets!(
|
|
155
|
+
inputs([], contractRoots as any),
|
|
156
|
+
defaultCtx,
|
|
157
|
+
);
|
|
158
|
+
expect([...defaultCtx.emitted.values()][0]!).toContain('data: Blob;');
|
|
159
|
+
});
|
|
123
160
|
});
|
|
124
161
|
|
|
125
162
|
describe('generated content', () => {
|