@taico/openapi-sdkgen 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 ADDED
@@ -0,0 +1,206 @@
1
+ # @taico/openapi-sdkgen
2
+
3
+ A TypeScript SDK generator from OpenAPI specs with focus on developer ergonomics and maintainability.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @taico/openapi-sdkgen
9
+ ```
10
+
11
+ ## CLI
12
+
13
+ ```bash
14
+ openapi-sdkgen ./openapi.json ./generated
15
+ ```
16
+
17
+ ## Motivation
18
+
19
+ The existing `@taico/client` package uses `openapi-typescript-codegen` with multiple post-processing scripts to work around limitations:
20
+ - `fix-esm-imports.js` - Adds `.js` extensions for ESM compatibility
21
+ - `make-client-instantiable.js` - Transforms static methods to accept per-instance config
22
+
23
+ This approach is fragile and hard to maintain. This package generates better SDKs from scratch instead.
24
+
25
+ ## Design Goals
26
+
27
+ 1. **Instance-based configuration** - No global singletons, auth per client instance
28
+ 2. **Ergonomic API** - Resource-based grouping, clean method signatures
29
+ 3. **Type-safe** - Full TypeScript support with strict mode
30
+ 4. **Streaming support** - SSE endpoints return AsyncIterable
31
+ 5. **Simple generator** - Transparent code generation, easy to debug
32
+ 6. **No post-processing** - Generate correct code from the start
33
+
34
+ ## Architecture
35
+
36
+ ### Three-Step Process
37
+
38
+ 1. **Parse** (`parser.ts`) - Convert OpenAPI JSON to internal IR
39
+ 2. **Generate** (`generator.ts`) - Produce TypeScript source files
40
+ 3. **Emit** (`index.ts`) - Write files to disk
41
+
42
+ ### Generated Structure
43
+
44
+ ```
45
+ generated/
46
+ ├── index.ts # Main exports
47
+ ├── client.ts # ApiClient main class
48
+ ├── base-client.ts # BaseClient with fetch wrapper
49
+ ├── types.ts # Type definitions from schemas
50
+ ├── users-resource.ts # UsersResource class
51
+ ├── tasks-resource.ts # TasksResource class
52
+ └── stream-resource.ts # StreamResource class
53
+ ```
54
+
55
+ ### Usage Pattern
56
+
57
+ ```typescript
58
+ const client = new ApiClient({
59
+ baseUrl: 'http://localhost:3000',
60
+ getAccessToken: async () => getToken(),
61
+ });
62
+
63
+ // Resource-based API
64
+ await client.users.getUser({ id: '123' });
65
+ await client.tasks.create({ body: { title: 'Task' } });
66
+
67
+ // Streaming
68
+ for await (const event of client.stream.events({ count: 10 })) {
69
+ console.log(event);
70
+ }
71
+
72
+ // AbortSignal support
73
+ const controller = new AbortController();
74
+ await client.users.list({ signal: controller.signal });
75
+ ```
76
+
77
+ ## Key Features
78
+
79
+ ### Instance-Based Auth
80
+
81
+ ```typescript
82
+ const client1 = new ApiClient({ baseUrl: 'http://api1.com', getAccessToken: () => token1 });
83
+ const client2 = new ApiClient({ baseUrl: 'http://api2.com', getAccessToken: () => token2 });
84
+ ```
85
+
86
+ No global state, no singleton issues.
87
+
88
+ ### Resource Grouping
89
+
90
+ Operations are grouped by OpenAPI tags into resource classes:
91
+ - `client.users.*` - All user operations
92
+ - `client.tasks.*` - All task operations
93
+ - `client.stream.*` - All streaming operations
94
+
95
+ ### Streaming Support
96
+
97
+ SSE endpoints automatically detected and generated as async generators:
98
+
99
+ ```typescript
100
+ async *streamEvents(params): AsyncIterable<Event> {
101
+ yield* this.streamEvents('/stream/events', { params });
102
+ }
103
+ ```
104
+
105
+ ### Type Safety
106
+
107
+ - Request bodies typed from `requestBody` schema
108
+ - Path/query params typed from `parameters`
109
+ - Response types from `responses[200].content`
110
+ - Optional fields handled correctly
111
+ - Enums generated as union types
112
+
113
+ ## Testing
114
+
115
+ The test suite is fully automated and self-contained under `test/`.
116
+
117
+ ```bash
118
+ npm test
119
+ ```
120
+
121
+ This runs the full pipeline:
122
+ 1. **Clean** - Remove previously generated client
123
+ 2. **Generate spec** - Start the test API in spec-generation mode, write `openapi.json`, exit
124
+ 3. **Generate client** - Run the generator against the spec
125
+ 4. **Run tests** - Start the test API server, run the test SDK against it, shut down
126
+
127
+ ### Test API (`test/api/`)
128
+
129
+ NestJS app with endpoints exercising OpenAPI features:
130
+ - GET with path/query params
131
+ - POST/PUT/PATCH with JSON body
132
+ - DELETE with no content response
133
+ - Nested objects and arrays
134
+ - Optional fields
135
+ - SSE streaming (`/stream/events`)
136
+ - Auth headers and custom header parameters
137
+ - Query parameters with special characters
138
+
139
+ The test API generates `openapi.json` via `--generate-spec` mode (starts, writes file, exits immediately — no server needed).
140
+
141
+ ### Test Client (`test/client/`)
142
+
143
+ `test-sdk.ts` imports the generated SDK and makes real HTTP calls to the test API, validating both types and runtime behavior including streaming and AbortSignal.
144
+
145
+ ## Tradeoffs
146
+
147
+ ### What We Gain
148
+
149
+ ✅ No post-processing — generate correct code directly
150
+ ✅ Cleaner code — no regex hacks on generated output
151
+ ✅ Instance config — no global singleton
152
+ ✅ Better DX — resource grouping, clear method names
153
+ ✅ Streaming — first-class AsyncIterable support
154
+ ✅ Maintainability — simple generator, easy to understand
155
+
156
+ ### What We Lose
157
+
158
+ ❌ More code — custom generator vs off-the-shelf tool
159
+ ❌ Edge cases — may not handle all OpenAPI features yet
160
+ ❌ Battle-tested — new code vs mature library
161
+
162
+ ### Comparison to Current Approach
163
+
164
+ | Aspect | Current (`openapi-typescript-codegen`) | New Generator |
165
+ |--------|---------------------------------------|---------------|
166
+ | Post-processing | 2 scripts (fix-esm, make-instantiable) | None |
167
+ | Auth pattern | Global with per-call override | Instance-based |
168
+ | Resource grouping | Service classes | Resource classes |
169
+ | Streaming | Not supported | AsyncIterable |
170
+ | Maintainability | Fragile regex transforms | Transparent generation |
171
+
172
+ ## Limitations
173
+
174
+ Current implementation doesn't handle:
175
+ - Complex schema compositions (allOf, oneOf, anyOf)
176
+ - Custom media types beyond JSON
177
+ - File uploads
178
+ - Cookie parameters
179
+ - Complex security schemes
180
+
181
+ ## File Structure
182
+
183
+ ```
184
+ packages/openapi-sdkgen/
185
+ ├── src/
186
+ │ ├── types.ts # Internal IR types
187
+ │ ├── parser.ts # OpenAPI -> IR
188
+ │ ├── generator.ts # IR -> TypeScript
189
+ │ ├── index.ts # Main API
190
+ │ └── cli.ts # CLI tool
191
+ ├── test/
192
+ │ ├── api/ # NestJS test API (spec source)
193
+ │ │ └── src/
194
+ │ │ ├── main.ts
195
+ │ │ ├── app.module.ts
196
+ │ │ ├── users.controller.ts
197
+ │ │ ├── tasks.controller.ts
198
+ │ │ └── stream.controller.ts
199
+ │ ├── client/ # Test SDK + generated output
200
+ │ │ ├── generated/ # Generated SDK (gitignored)
201
+ │ │ └── test-sdk.ts
202
+ │ └── run.mjs # Test orchestrator
203
+ ├── package.json
204
+ ├── tsconfig.json
205
+ └── README.md
206
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { generate } from './index.js';
3
+ const args = process.argv.slice(2);
4
+ if (args.length < 2) {
5
+ console.error('Usage: openapi-sdkgen <input-spec.json> <output-dir>');
6
+ process.exit(1);
7
+ }
8
+ const [inputPath, outputDir] = args;
9
+ generate({ inputPath, outputDir })
10
+ .then(() => {
11
+ console.log('✓ Client generation complete');
12
+ })
13
+ .catch((error) => {
14
+ console.error('Error generating client:', error);
15
+ process.exit(1);
16
+ });
@@ -0,0 +1,2 @@
1
+ import type { ParsedSpec } from './types.js';
2
+ export declare function generateClient(spec: ParsedSpec): Map<string, string>;
@@ -0,0 +1,546 @@
1
+ export function generateClient(spec) {
2
+ const files = new Map();
3
+ // Generate types file
4
+ files.set('types.ts', generateTypes(spec));
5
+ // Generate base client
6
+ files.set('base-client.ts', generateBaseClient());
7
+ // Generate resource classes
8
+ for (const resource of spec.resources) {
9
+ files.set(`${toKebabCase(resource.tag)}-resource.ts`, generateResource(resource));
10
+ }
11
+ // Generate main client class
12
+ files.set('client.ts', generateMainClient(spec));
13
+ // Generate index
14
+ files.set('index.ts', generateIndex(spec));
15
+ return files;
16
+ }
17
+ function generateTypes(spec) {
18
+ const lines = [
19
+ '// Auto-generated types from OpenAPI spec',
20
+ '// Do not edit manually',
21
+ '',
22
+ ];
23
+ for (const [name, schema] of spec.schemas) {
24
+ lines.push(generateTypeDefinition(name, schema));
25
+ lines.push('');
26
+ }
27
+ return lines.join('\n');
28
+ }
29
+ function generateTypeDefinition(name, schema) {
30
+ if (schema.type === 'object' && schema.properties) {
31
+ const props = [];
32
+ for (const [propName, propSchema] of schema.properties) {
33
+ const optional = !schema.required?.includes(propName);
34
+ const propType = schemaToTypeScript(propSchema);
35
+ props.push(` ${propName}${optional ? '?' : ''}: ${propType};`);
36
+ }
37
+ return `export interface ${name} {\n${props.join('\n')}\n}`;
38
+ }
39
+ if (schema.enum) {
40
+ const values = schema.enum.map((v) => `'${v}'`).join(' | ');
41
+ return `export type ${name} = ${values};`;
42
+ }
43
+ return `export type ${name} = ${schemaToTypeScript(schema)};`;
44
+ }
45
+ function schemaToTypeScript(schema) {
46
+ if (schema.ref) {
47
+ return schema.ref;
48
+ }
49
+ if (schema.enum) {
50
+ return schema.enum.map((v) => `'${v}'`).join(' | ');
51
+ }
52
+ let baseType;
53
+ switch (schema.type) {
54
+ case 'string':
55
+ baseType = 'string';
56
+ break;
57
+ case 'number':
58
+ case 'integer':
59
+ baseType = 'number';
60
+ break;
61
+ case 'boolean':
62
+ baseType = 'boolean';
63
+ break;
64
+ case 'array':
65
+ baseType = schema.items ? `${schemaToTypeScript(schema.items)}[]` : 'any[]';
66
+ break;
67
+ case 'object':
68
+ if (schema.properties) {
69
+ const props = [];
70
+ for (const [propName, propSchema] of schema.properties) {
71
+ const optional = !schema.required?.includes(propName);
72
+ props.push(`${propName}${optional ? '?' : ''}: ${schemaToTypeScript(propSchema)}`);
73
+ }
74
+ baseType = `{ ${props.join('; ')} }`;
75
+ }
76
+ else {
77
+ baseType = 'Record<string, any>';
78
+ }
79
+ break;
80
+ default:
81
+ baseType = 'any';
82
+ }
83
+ return schema.nullable ? `${baseType} | null` : baseType;
84
+ }
85
+ function generateBaseClient() {
86
+ return `// Base client with fetch wrapper
87
+ export interface ClientConfig {
88
+ baseUrl: string;
89
+ /** Bearer token auth: sets Authorization: Bearer <token> */
90
+ getAccessToken?: () => string | Promise<string>;
91
+ /** Cookie auth for Node.js: sets Cookie header directly */
92
+ getCookies?: () => string | Promise<string>;
93
+ /** Cookie auth for browsers: passed as fetch credentials option (e.g. 'include') */
94
+ credentials?: 'include' | 'same-origin' | 'omit';
95
+ headers?: Record<string, string>;
96
+ }
97
+
98
+ export class BaseClient {
99
+ constructor(protected config: ClientConfig) {}
100
+
101
+ private async buildAuthHeaders(): Promise<Record<string, string>> {
102
+ const headers: Record<string, string> = {};
103
+ if (this.config.getAccessToken) {
104
+ const token = await this.config.getAccessToken();
105
+ headers['Authorization'] = \`Bearer \${token}\`;
106
+ }
107
+ if (this.config.getCookies) {
108
+ headers['Cookie'] = await this.config.getCookies();
109
+ }
110
+ return headers;
111
+ }
112
+
113
+ protected async request<T>(
114
+ method: string,
115
+ path: string,
116
+ options?: {
117
+ params?: Record<string, any>;
118
+ body?: any;
119
+ headers?: Record<string, string>;
120
+ signal?: AbortSignal;
121
+ }
122
+ ): Promise<T> {
123
+ const url = new URL(path, this.config.baseUrl);
124
+
125
+ // Add query parameters
126
+ if (options?.params) {
127
+ for (const [key, value] of Object.entries(options.params)) {
128
+ if (value !== undefined && value !== null) {
129
+ url.searchParams.append(key, String(value));
130
+ }
131
+ }
132
+ }
133
+
134
+ // Build headers (filter out undefined values)
135
+ const headers: Record<string, string> = {};
136
+
137
+ // Add config headers
138
+ if (this.config.headers) {
139
+ for (const [key, value] of Object.entries(this.config.headers)) {
140
+ if (value !== undefined && value !== null) {
141
+ headers[key] = String(value);
142
+ }
143
+ }
144
+ }
145
+
146
+ // Add request-specific headers
147
+ if (options?.headers) {
148
+ for (const [key, value] of Object.entries(options.headers)) {
149
+ if (value !== undefined && value !== null) {
150
+ headers[key] = String(value);
151
+ }
152
+ }
153
+ }
154
+
155
+ // Add auth headers
156
+ Object.assign(headers, await this.buildAuthHeaders());
157
+
158
+ // Add content type for body requests
159
+ if (options?.body && !headers['Content-Type']) {
160
+ headers['Content-Type'] = 'application/json';
161
+ }
162
+
163
+ const response = await fetch(url.toString(), {
164
+ method,
165
+ headers,
166
+ body: options?.body ? JSON.stringify(options.body) : undefined,
167
+ signal: options?.signal,
168
+ credentials: this.config.credentials,
169
+ });
170
+
171
+ if (!response.ok) {
172
+ throw new Error(\`HTTP \${response.status}: \${response.statusText}\`);
173
+ }
174
+
175
+ // Handle no content responses
176
+ if (response.status === 204 || response.headers.get('content-length') === '0') {
177
+ return undefined as T;
178
+ }
179
+
180
+ const contentType = response.headers.get('content-type');
181
+ if (contentType?.includes('application/json')) {
182
+ return response.json() as Promise<T>;
183
+ }
184
+
185
+ return response.text() as any;
186
+ }
187
+
188
+ protected async *streamEvents<T = any>(
189
+ path: string,
190
+ options?: {
191
+ params?: Record<string, any>;
192
+ headers?: Record<string, string>;
193
+ signal?: AbortSignal;
194
+ }
195
+ ): AsyncIterable<T> {
196
+ const url = new URL(path, this.config.baseUrl);
197
+
198
+ if (options?.params) {
199
+ for (const [key, value] of Object.entries(options.params)) {
200
+ if (value !== undefined && value !== null) {
201
+ url.searchParams.append(key, String(value));
202
+ }
203
+ }
204
+ }
205
+
206
+ // Build headers (filter out undefined values)
207
+ const headers: Record<string, string> = {
208
+ 'Accept': 'text/event-stream',
209
+ };
210
+
211
+ // Add config headers
212
+ if (this.config.headers) {
213
+ for (const [key, value] of Object.entries(this.config.headers)) {
214
+ if (value !== undefined && value !== null) {
215
+ headers[key] = String(value);
216
+ }
217
+ }
218
+ }
219
+
220
+ // Add request-specific headers
221
+ if (options?.headers) {
222
+ for (const [key, value] of Object.entries(options.headers)) {
223
+ if (value !== undefined && value !== null) {
224
+ headers[key] = String(value);
225
+ }
226
+ }
227
+ }
228
+
229
+ // Add auth headers
230
+ Object.assign(headers, await this.buildAuthHeaders());
231
+
232
+ const response = await fetch(url.toString(), {
233
+ method: 'GET',
234
+ headers,
235
+ signal: options?.signal,
236
+ credentials: this.config.credentials,
237
+ });
238
+
239
+ if (!response.ok) {
240
+ throw new Error(\`HTTP \${response.status}: \${response.statusText}\`);
241
+ }
242
+
243
+ if (!response.body) {
244
+ throw new Error('Response body is null');
245
+ }
246
+
247
+ const reader = response.body.getReader();
248
+ const decoder = new TextDecoder();
249
+ let buffer = '';
250
+
251
+ try {
252
+ while (true) {
253
+ const { done, value } = await reader.read();
254
+ if (done) break;
255
+
256
+ buffer += decoder.decode(value, { stream: true });
257
+ const lines = buffer.split('\\n');
258
+ buffer = lines.pop() || '';
259
+
260
+ for (const line of lines) {
261
+ if (line.startsWith('data: ')) {
262
+ const data = line.slice(6);
263
+ try {
264
+ yield JSON.parse(data) as T;
265
+ } catch {
266
+ yield data as T;
267
+ }
268
+ }
269
+ }
270
+ }
271
+ } finally {
272
+ reader.releaseLock();
273
+ }
274
+ }
275
+ }
276
+ `;
277
+ }
278
+ function generateResource(resource) {
279
+ // Collect all type references used in this resource
280
+ const typeRefs = new Set();
281
+ for (const op of resource.operations) {
282
+ // Collect types from parameters
283
+ for (const param of op.parameters) {
284
+ collectTypeRefs(param.schema, typeRefs);
285
+ }
286
+ // Collect types from request body
287
+ if (op.requestBody?.content) {
288
+ for (const [, mediaType] of op.requestBody.content) {
289
+ collectTypeRefs(mediaType.schema, typeRefs);
290
+ }
291
+ }
292
+ // Collect types from responses
293
+ for (const [, response] of op.responses) {
294
+ if (response.content) {
295
+ for (const [, mediaType] of response.content) {
296
+ collectTypeRefs(mediaType.schema, typeRefs);
297
+ }
298
+ }
299
+ }
300
+ }
301
+ const lines = [
302
+ `import { BaseClient, ClientConfig } from './base-client.js';`,
303
+ ];
304
+ // Add type imports if any types are referenced
305
+ if (typeRefs.size > 0) {
306
+ const sortedTypes = Array.from(typeRefs).sort();
307
+ lines.push(`import type { ${sortedTypes.join(', ')} } from './types.js';`);
308
+ }
309
+ lines.push('');
310
+ lines.push(`export class ${resource.name} extends BaseClient {`);
311
+ lines.push(` constructor(config: ClientConfig) {`);
312
+ lines.push(` super(config);`);
313
+ lines.push(` }`);
314
+ lines.push('');
315
+ for (const op of resource.operations) {
316
+ lines.push(generateOperation(op));
317
+ lines.push('');
318
+ }
319
+ lines.push('}');
320
+ return lines.join('\n');
321
+ }
322
+ function collectTypeRefs(schema, refs) {
323
+ if (schema.ref) {
324
+ refs.add(schema.ref);
325
+ return;
326
+ }
327
+ if (schema.type === 'array' && schema.items) {
328
+ collectTypeRefs(schema.items, refs);
329
+ }
330
+ if (schema.type === 'object' && schema.properties) {
331
+ for (const [, propSchema] of schema.properties) {
332
+ collectTypeRefs(propSchema, refs);
333
+ }
334
+ }
335
+ }
336
+ function generateOperation(op) {
337
+ const lines = [];
338
+ // Determine if this is a streaming endpoint by checking response content-type
339
+ const isStreaming = isStreamingEndpoint(op);
340
+ // Build method signature
341
+ // Use operationId directly as method name (already in desired format from OpenAPI)
342
+ const methodName = op.operationId;
343
+ const { params, isParamsOptional } = buildMethodParams(op);
344
+ const returnType = inferReturnType(op, isStreaming);
345
+ // Add JSDoc comment
346
+ if (op.summary) {
347
+ lines.push(` /** ${op.summary} */`);
348
+ }
349
+ lines.push(` async ${isStreaming ? '*' : ''}${methodName}(${params}): ${returnType} {`);
350
+ // Build path with path parameters
351
+ const pathParams = op.parameters.filter((p) => p.in === 'path');
352
+ let pathExpr;
353
+ if (pathParams.length > 0) {
354
+ // Use template literal for paths with parameters
355
+ let path = op.path;
356
+ for (const param of pathParams) {
357
+ const accessor = needsQuoting(param.name) ? `['${param.name}']` : `.${param.name}`;
358
+ const paramRef = isParamsOptional ? `params?${accessor}` : `params${accessor}`;
359
+ path = path.replace(`{${param.name}}`, `\${${paramRef}}`);
360
+ }
361
+ pathExpr = `\`${path}\``;
362
+ }
363
+ else {
364
+ pathExpr = `'${op.path}'`;
365
+ }
366
+ // Build query parameters
367
+ const queryParams = op.parameters.filter((p) => p.in === 'query');
368
+ const hasQueryParams = queryParams.length > 0;
369
+ // Build header parameters
370
+ const headerParams = op.parameters.filter((p) => p.in === 'header');
371
+ const hasHeaderParams = headerParams.length > 0;
372
+ // Build request options
373
+ const requestOptions = [];
374
+ const paramsPrefix = isParamsOptional ? 'params?.' : 'params.';
375
+ if (hasQueryParams) {
376
+ const queryObj = queryParams
377
+ .map((p) => {
378
+ const needsQuote = needsQuoting(p.name);
379
+ const quotedKey = quoteIfNeeded(p.name);
380
+ let paramRef;
381
+ if (isParamsOptional) {
382
+ paramRef = needsQuote ? `params?.['${p.name}']` : `params?.${p.name}`;
383
+ }
384
+ else {
385
+ paramRef = needsQuote ? `params['${p.name}']` : `params.${p.name}`;
386
+ }
387
+ return `${quotedKey}: ${paramRef}`;
388
+ })
389
+ .join(', ');
390
+ requestOptions.push(`params: { ${queryObj} }`);
391
+ }
392
+ if (hasHeaderParams) {
393
+ const headersObj = headerParams
394
+ .map((p) => {
395
+ const needsQuote = needsQuoting(p.name);
396
+ let paramRef;
397
+ if (isParamsOptional) {
398
+ paramRef = needsQuote ? `params?.['${p.name}']` : `params?.${p.name}`;
399
+ }
400
+ else {
401
+ paramRef = needsQuote ? `params['${p.name}']` : `params.${p.name}`;
402
+ }
403
+ return `'${p.name}': ${paramRef}`;
404
+ })
405
+ .join(', ');
406
+ requestOptions.push(`headers: { ${headersObj} }`);
407
+ }
408
+ if (op.requestBody) {
409
+ requestOptions.push(`body: ${paramsPrefix}body`);
410
+ }
411
+ if (params.includes('signal')) {
412
+ requestOptions.push('signal: params?.signal');
413
+ }
414
+ if (isStreaming) {
415
+ lines.push(` yield* this.streamEvents(${pathExpr}${requestOptions.length > 0 ? `, { ${requestOptions.join(', ')} }` : ''});`);
416
+ }
417
+ else {
418
+ lines.push(` return this.request('${op.method}', ${pathExpr}${requestOptions.length > 0 ? `, { ${requestOptions.join(', ')} }` : ''});`);
419
+ }
420
+ lines.push(' }');
421
+ return lines.join('\n');
422
+ }
423
+ function isStreamingEndpoint(op) {
424
+ // Check if any successful response has text/event-stream content type
425
+ for (const [status, response] of op.responses.entries()) {
426
+ if (status >= 200 && status < 300 && response.content) {
427
+ for (const contentType of response.content.keys()) {
428
+ if (contentType.includes('text/event-stream')) {
429
+ return true;
430
+ }
431
+ }
432
+ }
433
+ }
434
+ return false;
435
+ }
436
+ function buildMethodParams(op) {
437
+ const params = [];
438
+ // Collect all parameters
439
+ const pathParams = op.parameters.filter((p) => p.in === 'path');
440
+ const queryParams = op.parameters.filter((p) => p.in === 'query');
441
+ const headerParams = op.parameters.filter((p) => p.in === 'header');
442
+ let isParamsOptional = false;
443
+ if (pathParams.length > 0 || queryParams.length > 0 || headerParams.length > 0 || op.requestBody) {
444
+ const paramFields = [];
445
+ for (const param of [...pathParams, ...queryParams, ...headerParams]) {
446
+ const optional = !param.required;
447
+ const quotedName = quoteIfNeeded(param.name);
448
+ paramFields.push(`${quotedName}${optional ? '?' : ''}: ${schemaToTypeScript(param.schema)}`);
449
+ }
450
+ if (op.requestBody) {
451
+ const bodySchema = op.requestBody.content.get('application/json');
452
+ if (bodySchema) {
453
+ const optional = !op.requestBody.required;
454
+ paramFields.push(`body${optional ? '?' : ''}: ${schemaToTypeScript(bodySchema.schema)}`);
455
+ }
456
+ }
457
+ paramFields.push('signal?: AbortSignal');
458
+ // Check if params object should be optional (all fields are optional)
459
+ const hasRequiredParams = pathParams.some(p => p.required) ||
460
+ queryParams.some(p => p.required) ||
461
+ headerParams.some(p => p.required) ||
462
+ (op.requestBody?.required ?? false);
463
+ isParamsOptional = !hasRequiredParams;
464
+ const paramsOptional = isParamsOptional ? '?' : '';
465
+ params.push(`params${paramsOptional}: { ${paramFields.join('; ')} }`);
466
+ }
467
+ else {
468
+ params.push('params?: { signal?: AbortSignal }');
469
+ isParamsOptional = true;
470
+ }
471
+ return { params: params.join(', '), isParamsOptional };
472
+ }
473
+ function inferReturnType(op, isStreaming) {
474
+ const successResponse = op.responses.get(200) || op.responses.get(201);
475
+ if (!successResponse?.content) {
476
+ return isStreaming ? 'AsyncIterable<any>' : 'Promise<void>';
477
+ }
478
+ const jsonContent = successResponse.content.get('application/json');
479
+ if (!jsonContent) {
480
+ return isStreaming ? 'AsyncIterable<any>' : 'Promise<any>';
481
+ }
482
+ const tsType = schemaToTypeScript(jsonContent.schema);
483
+ if (isStreaming) {
484
+ return `AsyncIterable<${tsType}>`;
485
+ }
486
+ return `Promise<${tsType}>`;
487
+ }
488
+ function generateMainClient(spec) {
489
+ const lines = [
490
+ `import { ClientConfig } from './base-client.js';`,
491
+ ];
492
+ for (const resource of spec.resources) {
493
+ lines.push(`import { ${resource.name} } from './${toKebabCase(resource.tag)}-resource.js';`);
494
+ }
495
+ lines.push('');
496
+ lines.push(`export class ApiClient {`);
497
+ for (const resource of spec.resources) {
498
+ lines.push(` public readonly ${toCamelCase(resource.tag)}: ${resource.name};`);
499
+ }
500
+ lines.push('');
501
+ lines.push(` constructor(config: ClientConfig) {`);
502
+ for (const resource of spec.resources) {
503
+ lines.push(` this.${toCamelCase(resource.tag)} = new ${resource.name}(config);`);
504
+ }
505
+ lines.push(' }');
506
+ lines.push('}');
507
+ return lines.join('\n');
508
+ }
509
+ function generateIndex(spec) {
510
+ const lines = [
511
+ `export { ApiClient } from './client.js';`,
512
+ `export type { ClientConfig } from './base-client.js';`,
513
+ `export * from './types.js';`,
514
+ ];
515
+ return lines.join('\n');
516
+ }
517
+ function toCamelCase(str) {
518
+ // Convert to camelCase by:
519
+ // 1. Replace special chars and separators with spaces
520
+ // 2. Split on whitespace/separators
521
+ // 3. Lowercase first word, capitalize rest
522
+ // 4. Join without spaces
523
+ const words = str
524
+ .replace(/[-_]/g, ' ') // Replace hyphens and underscores with spaces
525
+ .split(/\s+/) // Split on whitespace
526
+ .filter(word => word.length > 0); // Remove empty strings
527
+ if (words.length === 0)
528
+ return str;
529
+ return words[0].toLowerCase() +
530
+ words.slice(1)
531
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
532
+ .join('');
533
+ }
534
+ function toKebabCase(str) {
535
+ return str
536
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
537
+ .replace(/[\s_]+/g, '-')
538
+ .toLowerCase();
539
+ }
540
+ function needsQuoting(identifier) {
541
+ // Check if identifier needs quoting (contains hyphens or other special chars)
542
+ return !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(identifier);
543
+ }
544
+ function quoteIfNeeded(identifier) {
545
+ return needsQuoting(identifier) ? `'${identifier}'` : identifier;
546
+ }
@@ -0,0 +1,5 @@
1
+ import type { GeneratorConfig } from './types.js';
2
+ export declare function generate(config: GeneratorConfig): Promise<void>;
3
+ export * from './types.js';
4
+ export { parseOpenAPISpec } from './parser.js';
5
+ export { generateClient } from './generator.js';
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ import { readFileSync, mkdirSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { parseOpenAPISpec } from './parser.js';
4
+ import { generateClient } from './generator.js';
5
+ export async function generate(config) {
6
+ // Read OpenAPI spec
7
+ const specContent = readFileSync(config.inputPath, 'utf-8');
8
+ const spec = JSON.parse(specContent);
9
+ // Parse spec
10
+ const parsed = parseOpenAPISpec(spec);
11
+ // Generate files
12
+ const files = generateClient(parsed);
13
+ // Write files
14
+ mkdirSync(config.outputDir, { recursive: true });
15
+ for (const [filename, content] of files) {
16
+ const filePath = join(config.outputDir, filename);
17
+ writeFileSync(filePath, content, 'utf-8');
18
+ console.log(`Generated: ${filePath}`);
19
+ }
20
+ console.log(`✓ Generated ${files.size} files in ${config.outputDir}`);
21
+ }
22
+ export * from './types.js';
23
+ export { parseOpenAPISpec } from './parser.js';
24
+ export { generateClient } from './generator.js';
@@ -0,0 +1,2 @@
1
+ import type { ParsedSpec } from './types.js';
2
+ export declare function parseOpenAPISpec(spec: any): ParsedSpec;
package/dist/parser.js ADDED
@@ -0,0 +1,171 @@
1
+ export function parseOpenAPISpec(spec) {
2
+ const schemas = parseSchemas(spec.components?.schemas || {});
3
+ const operations = parseOperations(spec.paths || {});
4
+ // Group operations by tag
5
+ const resourceMap = new Map();
6
+ for (const op of operations) {
7
+ const tag = op.tags[0] || 'default';
8
+ if (!resourceMap.has(tag)) {
9
+ resourceMap.set(tag, []);
10
+ }
11
+ resourceMap.get(tag).push(op);
12
+ }
13
+ const resources = Array.from(resourceMap.entries()).map(([tag, ops]) => ({
14
+ name: toPascalCase(tag) + 'Resource',
15
+ tag,
16
+ operations: ops,
17
+ }));
18
+ return {
19
+ info: {
20
+ title: spec.info?.title || 'API Client',
21
+ version: spec.info?.version || '1.0.0',
22
+ },
23
+ resources,
24
+ schemas,
25
+ };
26
+ }
27
+ function parseOperations(paths) {
28
+ const operations = [];
29
+ for (const [path, pathItem] of Object.entries(paths)) {
30
+ // Parse path-level parameters (common to all operations on this path)
31
+ const pathLevelParams = parseParameters(pathItem.parameters || []);
32
+ for (const [method, operation] of Object.entries(pathItem)) {
33
+ if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
34
+ continue;
35
+ }
36
+ const op = operation;
37
+ // Merge path-level and operation-level parameters (operation takes precedence)
38
+ const opParams = parseParameters(op.parameters || []);
39
+ const params = [...pathLevelParams];
40
+ // Add operation params, overriding path-level params with same name/in combination
41
+ for (const opParam of opParams) {
42
+ const existingIndex = params.findIndex(p => p.name === opParam.name && p.in === opParam.in);
43
+ if (existingIndex >= 0) {
44
+ params[existingIndex] = opParam; // Operation-level takes precedence
45
+ }
46
+ else {
47
+ params.push(opParam);
48
+ }
49
+ }
50
+ // Extract path parameters from the path string if not already in parameters
51
+ const pathParamMatches = path.matchAll(/\{([^}]+)\}/g);
52
+ for (const match of pathParamMatches) {
53
+ const paramName = match[1];
54
+ // Only add if not already present
55
+ if (!params.find(p => p.name === paramName && p.in === 'path')) {
56
+ params.push({
57
+ name: paramName,
58
+ in: 'path',
59
+ required: true,
60
+ schema: { type: 'string' },
61
+ });
62
+ }
63
+ }
64
+ operations.push({
65
+ operationId: op.operationId || `${method}${path.replace(/\//g, '_')}`,
66
+ method: method.toUpperCase(),
67
+ path,
68
+ summary: op.summary,
69
+ parameters: params,
70
+ requestBody: op.requestBody ? parseRequestBody(op.requestBody) : undefined,
71
+ responses: parseResponses(op.responses || {}),
72
+ tags: op.tags || [],
73
+ });
74
+ }
75
+ }
76
+ return operations;
77
+ }
78
+ function parseParameters(parameters) {
79
+ return parameters.map((param) => ({
80
+ name: param.name,
81
+ in: param.in,
82
+ required: param.required || false,
83
+ schema: parseSchema(param.schema),
84
+ description: param.description,
85
+ }));
86
+ }
87
+ function parseRequestBody(requestBody) {
88
+ const content = new Map();
89
+ for (const [contentType, mediaType] of Object.entries(requestBody.content || {})) {
90
+ content.set(contentType, {
91
+ schema: parseSchema(mediaType.schema),
92
+ });
93
+ }
94
+ return {
95
+ required: requestBody.required || false,
96
+ content,
97
+ };
98
+ }
99
+ function parseResponses(responses) {
100
+ const result = new Map();
101
+ for (const [status, response] of Object.entries(responses)) {
102
+ const resp = response;
103
+ const content = new Map();
104
+ if (resp.content) {
105
+ for (const [contentType, mediaType] of Object.entries(resp.content)) {
106
+ content.set(contentType, {
107
+ schema: parseSchema(mediaType.schema),
108
+ });
109
+ }
110
+ }
111
+ result.set(Number(status), {
112
+ description: resp.description || '',
113
+ content: content.size > 0 ? content : undefined,
114
+ });
115
+ }
116
+ return result;
117
+ }
118
+ function parseSchema(schema) {
119
+ if (!schema) {
120
+ return { type: 'any' };
121
+ }
122
+ if (schema.$ref) {
123
+ return {
124
+ type: 'ref',
125
+ ref: schema.$ref.replace('#/components/schemas/', ''),
126
+ };
127
+ }
128
+ const info = {
129
+ type: schema.type || 'any',
130
+ format: schema.format,
131
+ nullable: schema.nullable,
132
+ };
133
+ if (schema.enum) {
134
+ info.enum = schema.enum;
135
+ }
136
+ if (schema.type === 'array' && schema.items) {
137
+ info.items = parseSchema(schema.items);
138
+ }
139
+ if (schema.type === 'object' && schema.properties) {
140
+ const props = new Map();
141
+ for (const [propName, propSchema] of Object.entries(schema.properties)) {
142
+ props.set(propName, parseSchema(propSchema));
143
+ }
144
+ info.properties = props;
145
+ info.required = schema.required || [];
146
+ }
147
+ return info;
148
+ }
149
+ function parseSchemas(schemas) {
150
+ const result = new Map();
151
+ for (const [name, schema] of Object.entries(schemas)) {
152
+ result.set(name, parseSchema(schema));
153
+ }
154
+ return result;
155
+ }
156
+ function capitalize(str) {
157
+ return str.charAt(0).toUpperCase() + str.slice(1);
158
+ }
159
+ function toPascalCase(str) {
160
+ // Convert to PascalCase by:
161
+ // 1. Replace special chars and separators with spaces
162
+ // 2. Split on whitespace/separators
163
+ // 3. Capitalize first letter of each word
164
+ // 4. Join without spaces
165
+ return str
166
+ .replace(/[-_]/g, ' ') // Replace hyphens and underscores with spaces
167
+ .split(/\s+/) // Split on whitespace
168
+ .filter(word => word.length > 0) // Remove empty strings
169
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
170
+ .join('');
171
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Internal representation of OpenAPI spec for code generation
3
+ */
4
+ export interface ParsedSpec {
5
+ info: {
6
+ title: string;
7
+ version: string;
8
+ };
9
+ resources: Resource[];
10
+ schemas: Map<string, SchemaInfo>;
11
+ }
12
+ export interface Resource {
13
+ name: string;
14
+ tag: string;
15
+ operations: Operation[];
16
+ }
17
+ export interface Operation {
18
+ operationId: string;
19
+ method: string;
20
+ path: string;
21
+ summary?: string;
22
+ parameters: Parameter[];
23
+ requestBody?: RequestBody;
24
+ responses: Map<number, Response>;
25
+ tags: string[];
26
+ }
27
+ export interface Parameter {
28
+ name: string;
29
+ in: 'path' | 'query' | 'header';
30
+ required: boolean;
31
+ schema: SchemaInfo;
32
+ description?: string;
33
+ }
34
+ export interface RequestBody {
35
+ required: boolean;
36
+ content: Map<string, MediaType>;
37
+ }
38
+ export interface MediaType {
39
+ schema: SchemaInfo;
40
+ }
41
+ export interface Response {
42
+ description: string;
43
+ content?: Map<string, MediaType>;
44
+ }
45
+ export interface SchemaInfo {
46
+ type: string;
47
+ format?: string;
48
+ items?: SchemaInfo;
49
+ properties?: Map<string, SchemaInfo>;
50
+ required?: string[];
51
+ enum?: string[];
52
+ ref?: string;
53
+ nullable?: boolean;
54
+ }
55
+ export interface GeneratorConfig {
56
+ inputPath: string;
57
+ outputDir: string;
58
+ clientClassName?: string;
59
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Internal representation of OpenAPI spec for code generation
3
+ */
4
+ export {};
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@taico/openapi-sdkgen",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "private": false,
6
+ "license": "MIT",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "package.json"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "bin": {
23
+ "openapi-sdkgen": "./dist/cli.js"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "dev": "tsc --watch",
28
+ "test:api": "tsx --tsconfig test/api/tsconfig.json test/api/src/main.ts",
29
+ "test:client": "tsx test/client/test-sdk.ts",
30
+ "generate:spec": "tsx --tsconfig test/api/tsconfig.json test/api/src/main.ts --generate-spec",
31
+ "clean": "rm -rf test/client/generated",
32
+ "generate": "tsx src/cli.ts test/api/openapi.json test/client/generated",
33
+ "lint:generated": "tsc --noEmit --project test/client/tsconfig.json",
34
+ "test": "npm run clean && npm run generate:spec && npm run generate && npm run lint:generated && node test/run.mjs",
35
+ "pack": "npm run build && npm pack",
36
+ "release": "npm run build && npm publish --access public"
37
+ },
38
+ "devDependencies": {
39
+ "@nestjs/common": "^11.0.0",
40
+ "@nestjs/core": "^11.0.0",
41
+ "@nestjs/platform-express": "^11.0.0",
42
+ "@nestjs/swagger": "^11.2.1",
43
+ "@types/node": "^20.0.0",
44
+ "reflect-metadata": "^0.2.0",
45
+ "rxjs": "^7.8.1",
46
+ "tsx": "^4.7.0",
47
+ "typescript": "^5.6.3"
48
+ }
49
+ }