openapi-remote-codegen 0.1.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/README.md +173 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.js +45 -0
- package/dist/generators/api-client.d.ts +6 -0
- package/dist/generators/api-client.js +72 -0
- package/dist/generators/remote-functions.d.ts +3 -0
- package/dist/generators/remote-functions.js +439 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +88 -0
- package/dist/parser.d.ts +3 -0
- package/dist/parser.js +236 -0
- package/dist/public.d.ts +6 -0
- package/dist/public.js +5 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.js +1 -0
- package/dist/utils/client-mapping.d.ts +6 -0
- package/dist/utils/client-mapping.js +9 -0
- package/dist/utils/naming.d.ts +26 -0
- package/dist/utils/naming.js +89 -0
- package/package.json +37 -0
- package/src/__tests__/client-mapping.test.ts +38 -0
- package/src/__tests__/config.test.ts +59 -0
- package/src/__tests__/naming.test.ts +68 -0
- package/src/__tests__/parser.test.ts +576 -0
- package/src/__tests__/remote-functions.test.ts +315 -0
- package/src/config.ts +95 -0
- package/src/generators/api-client.ts +82 -0
- package/src/generators/remote-functions.ts +521 -0
- package/src/index.ts +105 -0
- package/src/parser.ts +303 -0
- package/src/public.ts +7 -0
- package/src/types.ts +48 -0
- package/src/utils/client-mapping.ts +9 -0
- package/src/utils/naming.ts +99 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateRemoteFunctions } from '../generators/remote-functions.js';
|
|
3
|
+
import { resolveConfig } from '../config.js';
|
|
4
|
+
import type { OperationInfo, ParsedSpec } from '../types.js';
|
|
5
|
+
|
|
6
|
+
const defaultConfig = resolveConfig({});
|
|
7
|
+
|
|
8
|
+
function createOperation(overrides: Partial<OperationInfo> = {}): OperationInfo {
|
|
9
|
+
return {
|
|
10
|
+
operationId: 'Foods_GetFavorites',
|
|
11
|
+
tag: 'V4 Foods',
|
|
12
|
+
method: 'get',
|
|
13
|
+
path: '/api/v4/foods/favorites',
|
|
14
|
+
remoteType: 'query',
|
|
15
|
+
invalidates: [],
|
|
16
|
+
parameters: [],
|
|
17
|
+
isVoidResponse: false,
|
|
18
|
+
clientPropertyName: 'foodsV4',
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getGeneratedFile(parsed: ParsedSpec, fileName: string): string {
|
|
24
|
+
const files = generateRemoteFunctions(parsed, defaultConfig);
|
|
25
|
+
const content = files.get(fileName);
|
|
26
|
+
if (!content) {
|
|
27
|
+
const available = Array.from(files.keys()).join(', ');
|
|
28
|
+
throw new Error(`File "${fileName}" not found. Available: ${available}`);
|
|
29
|
+
}
|
|
30
|
+
return content;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('generateRemoteFunctions', () => {
|
|
34
|
+
describe('imports', () => {
|
|
35
|
+
it('imports redirect from @sveltejs/kit', () => {
|
|
36
|
+
const parsed: ParsedSpec = {
|
|
37
|
+
operations: [createOperation()],
|
|
38
|
+
tags: ['V4 Foods'],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
42
|
+
expect(content).toContain("import { error, redirect } from '@sveltejs/kit';");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('auth error handling in query functions', () => {
|
|
47
|
+
it('includes 401 redirect in no-arg query catch block', () => {
|
|
48
|
+
const parsed: ParsedSpec = {
|
|
49
|
+
operations: [createOperation()],
|
|
50
|
+
tags: ['V4 Foods'],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
54
|
+
expect(content).toContain("const status = (err as any)?.status;");
|
|
55
|
+
expect(content).toContain("if (status === 401) { const { url } = getRequestEvent(); throw redirect(302, `/auth/login?returnUrl=${encodeURIComponent(url.pathname + url.search)}`); }");
|
|
56
|
+
expect(content).toContain("if (status === 403) throw error(403, 'Forbidden');");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('includes 401 redirect in parameterized query catch block', () => {
|
|
60
|
+
const parsed: ParsedSpec = {
|
|
61
|
+
operations: [createOperation({
|
|
62
|
+
operationId: 'Foods_GetById',
|
|
63
|
+
parameters: [{ name: 'id', in: 'path', required: true, type: 'string' }],
|
|
64
|
+
})],
|
|
65
|
+
tags: ['V4 Foods'],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
69
|
+
expect(content).toContain("const status = (err as any)?.status;");
|
|
70
|
+
expect(content).toContain("if (status === 401) { const { url } = getRequestEvent(); throw redirect(302, `/auth/login?returnUrl=${encodeURIComponent(url.pathname + url.search)}`); }");
|
|
71
|
+
expect(content).toContain("if (status === 403) throw error(403, 'Forbidden');");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('preserves existing error(500) fallback in query', () => {
|
|
75
|
+
const parsed: ParsedSpec = {
|
|
76
|
+
operations: [createOperation()],
|
|
77
|
+
tags: ['V4 Foods'],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
81
|
+
expect(content).toContain("throw error(500, 'Failed to get favorites');");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('auth error handling in command functions', () => {
|
|
86
|
+
it('includes 401 redirect in no-arg command catch block', () => {
|
|
87
|
+
const parsed: ParsedSpec = {
|
|
88
|
+
operations: [createOperation({
|
|
89
|
+
operationId: 'Foods_SyncAll',
|
|
90
|
+
remoteType: 'command',
|
|
91
|
+
isVoidResponse: true,
|
|
92
|
+
})],
|
|
93
|
+
tags: ['V4 Foods'],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
97
|
+
expect(content).toContain("const status = (err as any)?.status;");
|
|
98
|
+
expect(content).toContain("if (status === 401) { const { url } = getRequestEvent(); throw redirect(302, `/auth/login?returnUrl=${encodeURIComponent(url.pathname + url.search)}`); }");
|
|
99
|
+
expect(content).toContain("if (status === 403) throw error(403, 'Forbidden');");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('includes 401 redirect in parameterized command catch block', () => {
|
|
103
|
+
const parsed: ParsedSpec = {
|
|
104
|
+
operations: [createOperation({
|
|
105
|
+
operationId: 'Foods_DeleteFood',
|
|
106
|
+
remoteType: 'command',
|
|
107
|
+
isVoidResponse: true,
|
|
108
|
+
parameters: [{ name: 'id', in: 'path', required: true, type: 'string' }],
|
|
109
|
+
})],
|
|
110
|
+
tags: ['V4 Foods'],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
114
|
+
expect(content).toContain("const status = (err as any)?.status;");
|
|
115
|
+
expect(content).toContain("if (status === 401) { const { url } = getRequestEvent(); throw redirect(302, `/auth/login?returnUrl=${encodeURIComponent(url.pathname + url.search)}`); }");
|
|
116
|
+
expect(content).toContain("if (status === 403) throw error(403, 'Forbidden');");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('preserves existing error(500) fallback in command', () => {
|
|
120
|
+
const parsed: ParsedSpec = {
|
|
121
|
+
operations: [createOperation({
|
|
122
|
+
operationId: 'Foods_DeleteFood',
|
|
123
|
+
remoteType: 'command',
|
|
124
|
+
isVoidResponse: true,
|
|
125
|
+
parameters: [{ name: 'id', in: 'path', required: true, type: 'string' }],
|
|
126
|
+
})],
|
|
127
|
+
tags: ['V4 Foods'],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
131
|
+
expect(content).toContain("throw error(500, 'Failed to delete food');");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('catch block ordering', () => {
|
|
136
|
+
it('places auth checks before console.error in query', () => {
|
|
137
|
+
const parsed: ParsedSpec = {
|
|
138
|
+
operations: [createOperation()],
|
|
139
|
+
tags: ['V4 Foods'],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
143
|
+
const statusIndex = content.indexOf("const status = (err as any)?.status;");
|
|
144
|
+
const redirectIndex = content.indexOf("if (status === 401) {");
|
|
145
|
+
const forbiddenIndex = content.indexOf("if (status === 403) throw error(403, 'Forbidden');");
|
|
146
|
+
const consoleIndex = content.indexOf("console.error('Error in foodsV4.getFavorites:', err);");
|
|
147
|
+
const error500Index = content.indexOf("throw error(500, 'Failed to get favorites');");
|
|
148
|
+
|
|
149
|
+
expect(statusIndex).toBeLessThan(redirectIndex);
|
|
150
|
+
expect(redirectIndex).toBeLessThan(forbiddenIndex);
|
|
151
|
+
expect(forbiddenIndex).toBeLessThan(consoleIndex);
|
|
152
|
+
expect(consoleIndex).toBeLessThan(error500Index);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('places auth checks before console.error in command', () => {
|
|
156
|
+
const parsed: ParsedSpec = {
|
|
157
|
+
operations: [createOperation({
|
|
158
|
+
operationId: 'Foods_SyncAll',
|
|
159
|
+
remoteType: 'command',
|
|
160
|
+
isVoidResponse: true,
|
|
161
|
+
})],
|
|
162
|
+
tags: ['V4 Foods'],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
166
|
+
const statusIndex = content.indexOf("const status = (err as any)?.status;");
|
|
167
|
+
const redirectIndex = content.indexOf("if (status === 401) {");
|
|
168
|
+
const forbiddenIndex = content.indexOf("if (status === 403) throw error(403, 'Forbidden');");
|
|
169
|
+
const consoleIndex = content.indexOf("console.error('Error in foodsV4.syncAll:', err);");
|
|
170
|
+
const error500Index = content.indexOf("throw error(500, 'Failed to sync all');");
|
|
171
|
+
|
|
172
|
+
expect(statusIndex).toBeLessThan(redirectIndex);
|
|
173
|
+
expect(redirectIndex).toBeLessThan(forbiddenIndex);
|
|
174
|
+
expect(forbiddenIndex).toBeLessThan(consoleIndex);
|
|
175
|
+
expect(consoleIndex).toBeLessThan(error500Index);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('form functions', () => {
|
|
180
|
+
it('generates form() wrapper for form operations', () => {
|
|
181
|
+
const parsed: ParsedSpec = {
|
|
182
|
+
operations: [createOperation({
|
|
183
|
+
operationId: 'Foods_AddFavorite',
|
|
184
|
+
remoteType: 'form',
|
|
185
|
+
requestBodySchema: 'AddFavoriteRequestSchema',
|
|
186
|
+
})],
|
|
187
|
+
tags: ['V4 Foods'],
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
191
|
+
expect(content).toContain('= form(');
|
|
192
|
+
expect(content).not.toContain('= command(');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('imports form from $app/server when forms present', () => {
|
|
196
|
+
const parsed: ParsedSpec = {
|
|
197
|
+
operations: [createOperation({
|
|
198
|
+
operationId: 'Foods_AddFavorite',
|
|
199
|
+
remoteType: 'form',
|
|
200
|
+
requestBodySchema: 'AddFavoriteRequestSchema',
|
|
201
|
+
})],
|
|
202
|
+
tags: ['V4 Foods'],
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
206
|
+
expect(content).toContain("import { getRequestEvent, form } from '$app/server'");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('imports invalid from @sveltejs/kit when forms present', () => {
|
|
210
|
+
const parsed: ParsedSpec = {
|
|
211
|
+
operations: [createOperation({
|
|
212
|
+
operationId: 'Foods_AddFavorite',
|
|
213
|
+
remoteType: 'form',
|
|
214
|
+
requestBodySchema: 'AddFavoriteRequestSchema',
|
|
215
|
+
})],
|
|
216
|
+
tags: ['V4 Foods'],
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
220
|
+
expect(content).toContain("import { error, redirect, invalid } from '@sveltejs/kit'");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('includes refresh calls in form functions', () => {
|
|
224
|
+
const parsed: ParsedSpec = {
|
|
225
|
+
operations: [
|
|
226
|
+
createOperation({
|
|
227
|
+
operationId: 'Foods_GetFavorites',
|
|
228
|
+
remoteType: 'query',
|
|
229
|
+
}),
|
|
230
|
+
createOperation({
|
|
231
|
+
operationId: 'Foods_AddFavorite',
|
|
232
|
+
remoteType: 'form',
|
|
233
|
+
requestBodySchema: 'AddFavoriteRequestSchema',
|
|
234
|
+
invalidates: ['GetFavorites'],
|
|
235
|
+
}),
|
|
236
|
+
],
|
|
237
|
+
tags: ['V4 Foods'],
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
241
|
+
expect(content).toContain('refresh()');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('does not import invalid when no forms present', () => {
|
|
245
|
+
const parsed: ParsedSpec = {
|
|
246
|
+
operations: [createOperation()],
|
|
247
|
+
tags: ['V4 Foods'],
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
251
|
+
expect(content).not.toContain('invalid');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('batch query functions', () => {
|
|
256
|
+
it('generates query.batch() for batch queries', () => {
|
|
257
|
+
const parsed: ParsedSpec = {
|
|
258
|
+
operations: [createOperation({
|
|
259
|
+
operationId: 'Foods_GetById',
|
|
260
|
+
remoteType: 'query',
|
|
261
|
+
isBatch: true,
|
|
262
|
+
parameters: [{ name: 'id', in: 'path', required: true, type: 'string' }],
|
|
263
|
+
})],
|
|
264
|
+
tags: ['V4 Foods'],
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
268
|
+
expect(content).toContain('= query.batch(');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('falls back to regular query for no-arg batch', () => {
|
|
272
|
+
const parsed: ParsedSpec = {
|
|
273
|
+
operations: [createOperation({
|
|
274
|
+
operationId: 'Foods_GetAll',
|
|
275
|
+
remoteType: 'query',
|
|
276
|
+
isBatch: true,
|
|
277
|
+
parameters: [],
|
|
278
|
+
})],
|
|
279
|
+
tags: ['V4 Foods'],
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const content = getGeneratedFile(parsed, 'foods.generated.remote.ts');
|
|
283
|
+
expect(content).toContain('= query(async');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('config customization', () => {
|
|
288
|
+
it('uses custom import paths from config', () => {
|
|
289
|
+
const config = resolveConfig({
|
|
290
|
+
imports: { server: '@my/server', kit: '@my/kit', schemas: '@my/schemas', apiTypes: '@my/types', zod: 'zod4' },
|
|
291
|
+
});
|
|
292
|
+
const parsed: ParsedSpec = {
|
|
293
|
+
operations: [createOperation()],
|
|
294
|
+
tags: ['V4 Foods'],
|
|
295
|
+
};
|
|
296
|
+
const files = generateRemoteFunctions(parsed, config);
|
|
297
|
+
const content = files.get('foods.generated.remote.ts')!;
|
|
298
|
+
expect(content).toContain("from '@my/server'");
|
|
299
|
+
expect(content).toContain("from '@my/kit'");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('uses custom client access expression', () => {
|
|
303
|
+
const config = resolveConfig({
|
|
304
|
+
clientAccess: 'container.resolve("ApiClient")',
|
|
305
|
+
});
|
|
306
|
+
const parsed: ParsedSpec = {
|
|
307
|
+
operations: [createOperation()],
|
|
308
|
+
tags: ['V4 Foods'],
|
|
309
|
+
};
|
|
310
|
+
const files = generateRemoteFunctions(parsed, config);
|
|
311
|
+
const content = files.get('foods.generated.remote.ts')!;
|
|
312
|
+
expect(content).toContain('container.resolve("ApiClient")');
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export interface ImportPaths {
|
|
2
|
+
/** Module providing query, command, form, getRequestEvent. Default: '$app/server' */
|
|
3
|
+
server: string;
|
|
4
|
+
/** Module providing error, redirect. Default: '@sveltejs/kit' */
|
|
5
|
+
kit: string;
|
|
6
|
+
/** Module providing Zod schemas. Default: '$lib/api/generated/schemas' */
|
|
7
|
+
schemas: string;
|
|
8
|
+
/** Module providing API types/enums. Default: '$api' */
|
|
9
|
+
apiTypes: string;
|
|
10
|
+
/** Zod module. Default: 'zod' */
|
|
11
|
+
zod: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ErrorHandling {
|
|
15
|
+
/** Code to execute on 401. Has access to `url` (current URL). Default: redirect to /auth/login */
|
|
16
|
+
on401: string;
|
|
17
|
+
/** Code to execute on 403. Default: error(403, 'Forbidden') */
|
|
18
|
+
on403: string;
|
|
19
|
+
/** Function that takes a human-readable function name and returns code for 500. */
|
|
20
|
+
on500: (functionName: string) => string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GeneratorConfig {
|
|
24
|
+
/** Path to the OpenAPI spec JSON file. Default: './openapi.json' */
|
|
25
|
+
openApiPath: string;
|
|
26
|
+
/** Base output directory. Default: './src/lib' */
|
|
27
|
+
outputDir: string;
|
|
28
|
+
/** Subdirectory within outputDir for remote function files. Default: 'api/generated' */
|
|
29
|
+
remoteFunctionsOutput: string;
|
|
30
|
+
/** Path within outputDir for the ApiClient wrapper. Default: 'api/api-client.generated.ts' */
|
|
31
|
+
apiClientOutput: string;
|
|
32
|
+
/** Import paths used in generated code. */
|
|
33
|
+
imports: ImportPaths;
|
|
34
|
+
/** Expression to access the API client in generated functions. Default: 'getRequestEvent().locals.apiClient' */
|
|
35
|
+
clientAccess: string;
|
|
36
|
+
/** Error handling templates for generated catch blocks. */
|
|
37
|
+
errorHandling: ErrorHandling;
|
|
38
|
+
/** Path to the NSwag-generated client module (used in ApiClient imports). Default: './generated/api-client' */
|
|
39
|
+
nswagClientPath: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type UserConfig = Partial<Omit<GeneratorConfig, 'imports' | 'errorHandling'>> & {
|
|
43
|
+
imports?: Partial<ImportPaths>;
|
|
44
|
+
errorHandling?: Partial<ErrorHandling>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const DEFAULT_IMPORTS: ImportPaths = {
|
|
48
|
+
server: '$app/server',
|
|
49
|
+
kit: '@sveltejs/kit',
|
|
50
|
+
schemas: '$lib/api/generated/schemas',
|
|
51
|
+
apiTypes: '$api',
|
|
52
|
+
zod: 'zod',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const DEFAULT_ERROR_HANDLING: ErrorHandling = {
|
|
56
|
+
on401: 'const { url } = getRequestEvent(); throw redirect(302, `/auth/login?returnUrl=${encodeURIComponent(url.pathname + url.search)}`)',
|
|
57
|
+
on403: "throw error(403, 'Forbidden')",
|
|
58
|
+
on500: (functionName: string) => `throw error(500, 'Failed to ${functionName}')`,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const DEFAULTS: GeneratorConfig = {
|
|
62
|
+
openApiPath: './openapi.json',
|
|
63
|
+
outputDir: './src/lib',
|
|
64
|
+
remoteFunctionsOutput: 'api/generated',
|
|
65
|
+
apiClientOutput: 'api/api-client.generated.ts',
|
|
66
|
+
imports: DEFAULT_IMPORTS,
|
|
67
|
+
clientAccess: 'getRequestEvent().locals.apiClient',
|
|
68
|
+
errorHandling: DEFAULT_ERROR_HANDLING,
|
|
69
|
+
nswagClientPath: './generated/api-client',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Type-helper for config files. Returns the input as-is. */
|
|
73
|
+
export function defineConfig(config: UserConfig): UserConfig {
|
|
74
|
+
return config;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Merge user config with defaults to produce a fully resolved config. */
|
|
78
|
+
export function resolveConfig(user: UserConfig): GeneratorConfig {
|
|
79
|
+
return {
|
|
80
|
+
openApiPath: user.openApiPath ?? DEFAULTS.openApiPath,
|
|
81
|
+
outputDir: user.outputDir ?? DEFAULTS.outputDir,
|
|
82
|
+
remoteFunctionsOutput: user.remoteFunctionsOutput ?? DEFAULTS.remoteFunctionsOutput,
|
|
83
|
+
apiClientOutput: user.apiClientOutput ?? DEFAULTS.apiClientOutput,
|
|
84
|
+
imports: {
|
|
85
|
+
...DEFAULTS.imports,
|
|
86
|
+
...user.imports,
|
|
87
|
+
},
|
|
88
|
+
clientAccess: user.clientAccess ?? DEFAULTS.clientAccess,
|
|
89
|
+
errorHandling: {
|
|
90
|
+
...DEFAULTS.errorHandling,
|
|
91
|
+
...user.errorHandling,
|
|
92
|
+
},
|
|
93
|
+
nswagClientPath: user.nswagClientPath ?? DEFAULTS.nswagClientPath,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { OpenAPIV3 } from 'openapi-types';
|
|
2
|
+
import type { GeneratorConfig } from '../config.js';
|
|
3
|
+
import { getClientPropertyName } from '../utils/client-mapping.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate the ApiClient wrapper class.
|
|
7
|
+
*/
|
|
8
|
+
export function generateApiClient(spec: OpenAPIV3.Document, config: GeneratorConfig): string {
|
|
9
|
+
// Map each tag to its NSwag client class name and optional client property override
|
|
10
|
+
type TagInfo = { prefix: string; clientProperty?: string };
|
|
11
|
+
const tagInfo = new Map<string, TagInfo>();
|
|
12
|
+
|
|
13
|
+
for (const pathItem of Object.values(spec.paths ?? {})) {
|
|
14
|
+
if (!pathItem) continue;
|
|
15
|
+
|
|
16
|
+
for (const method of ['get', 'post', 'put', 'patch', 'delete'] as const) {
|
|
17
|
+
const operation = pathItem[method] as (OpenAPIV3.OperationObject & { 'x-client-property'?: string }) | undefined;
|
|
18
|
+
if (operation?.tags?.[0] && operation.operationId) {
|
|
19
|
+
const tag = operation.tags[0];
|
|
20
|
+
if (!tagInfo.has(tag)) {
|
|
21
|
+
const prefix = operation.operationId.split('_')[0];
|
|
22
|
+
tagInfo.set(tag, {
|
|
23
|
+
prefix,
|
|
24
|
+
clientProperty: operation['x-client-property'],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Deduplicate by className (NSwag merges tags like "Treatments" and "V4 Treatments"
|
|
32
|
+
// into a single TreatmentsClient)
|
|
33
|
+
const seen = new Set<string>();
|
|
34
|
+
const clients = Array.from(tagInfo.entries())
|
|
35
|
+
.map(([tag, info]) => ({
|
|
36
|
+
className: `${info.prefix}Client`,
|
|
37
|
+
propertyName: info.clientProperty ?? getClientPropertyName(tag),
|
|
38
|
+
}))
|
|
39
|
+
.filter(c => {
|
|
40
|
+
if (seen.has(c.className)) return false;
|
|
41
|
+
seen.add(c.className);
|
|
42
|
+
return true;
|
|
43
|
+
})
|
|
44
|
+
.sort((a, b) => a.propertyName.localeCompare(b.propertyName));
|
|
45
|
+
|
|
46
|
+
const imports = clients.map(c => c.className).join(',\n ');
|
|
47
|
+
const properties = clients.map(c => ` public readonly ${c.propertyName}: ${c.className};`).join('\n');
|
|
48
|
+
const initializers = clients.map(c =>
|
|
49
|
+
` this.${c.propertyName} = new ${c.className}(apiBaseUrl, http);`
|
|
50
|
+
).join('\n');
|
|
51
|
+
|
|
52
|
+
return `// AUTO-GENERATED - DO NOT EDIT
|
|
53
|
+
// Generated by openapi-remote-codegen
|
|
54
|
+
// Source: openapi.json
|
|
55
|
+
//
|
|
56
|
+
import {
|
|
57
|
+
${imports}
|
|
58
|
+
} from "${config.nswagClientPath}";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* API client wrapper.
|
|
62
|
+
* Provides typed access to all backend endpoints.
|
|
63
|
+
*/
|
|
64
|
+
export class ApiClient {
|
|
65
|
+
public readonly baseUrl: string;
|
|
66
|
+
${properties}
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
baseUrl: string,
|
|
70
|
+
http?: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> }
|
|
71
|
+
) {
|
|
72
|
+
const apiBaseUrl = baseUrl;
|
|
73
|
+
this.baseUrl = apiBaseUrl;
|
|
74
|
+
|
|
75
|
+
${initializers}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Export the generated client types for use in components
|
|
80
|
+
export * from "${config.nswagClientPath}";
|
|
81
|
+
`;
|
|
82
|
+
}
|