@taico/openapi-sdkgen 0.1.2 → 0.2.12

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.
Files changed (2) hide show
  1. package/dist/generator.js +197 -24
  2. package/package.json +1 -1
package/dist/generator.js CHANGED
@@ -53,7 +53,7 @@ function schemaToTypeScript(schema) {
53
53
  let baseType;
54
54
  switch (schema.type) {
55
55
  case 'string':
56
- baseType = 'string';
56
+ baseType = schema.format === 'binary' ? 'Blob' : 'string';
57
57
  break;
58
58
  case 'number':
59
59
  case 'integer':
@@ -104,9 +104,113 @@ export interface ClientConfig {
104
104
  headers?: Record<string, string>;
105
105
  }
106
106
 
107
+ function isRecord(value: unknown): value is Record<string, unknown> {
108
+ return typeof value === 'object' && value !== null;
109
+ }
110
+
111
+ function getErrorMessageFromBody(body: unknown): string | undefined {
112
+ if (!isRecord(body)) {
113
+ return undefined;
114
+ }
115
+
116
+ const detail = body.detail;
117
+ if (typeof detail === 'string' && detail.length > 0) {
118
+ return detail;
119
+ }
120
+
121
+ const message = body.message;
122
+ if (typeof message === 'string' && message.length > 0) {
123
+ return message;
124
+ }
125
+
126
+ const title = body.title;
127
+ if (typeof title === 'string' && title.length > 0) {
128
+ return title;
129
+ }
130
+
131
+ const errorDescription = body.error_description;
132
+ if (typeof errorDescription === 'string' && errorDescription.length > 0) {
133
+ return errorDescription;
134
+ }
135
+
136
+ return undefined;
137
+ }
138
+
139
+ export class ApiError extends Error {
140
+ public readonly status: number;
141
+ public readonly statusText: string;
142
+ public readonly body: unknown;
143
+
144
+ constructor(response: Response, body: unknown) {
145
+ const message = getErrorMessageFromBody(body) ?? \`HTTP \${response.status}: \${response.statusText}\`;
146
+ super(message);
147
+ this.name = 'ApiError';
148
+ this.status = response.status;
149
+ this.statusText = response.statusText;
150
+ this.body = body;
151
+ }
152
+ }
153
+
107
154
  export class BaseClient {
108
155
  constructor(protected config: ClientConfig) {}
109
156
 
157
+ private async parseResponseBody(
158
+ response: Response,
159
+ responseType: 'auto' | 'json' | 'text' | 'arrayBuffer' | 'void' = 'auto'
160
+ ): Promise<unknown> {
161
+ if (responseType === 'void') {
162
+ return undefined;
163
+ }
164
+
165
+ if (response.status === 204 || response.headers.get('content-length') === '0') {
166
+ return undefined;
167
+ }
168
+
169
+ if (responseType === 'json') {
170
+ try {
171
+ return await response.json();
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ }
176
+
177
+ if (responseType === 'text') {
178
+ const text = await response.text();
179
+ return text.length > 0 ? text : undefined;
180
+ }
181
+
182
+ if (responseType === 'arrayBuffer') {
183
+ const arrayBuffer = await response.arrayBuffer();
184
+ return arrayBuffer.byteLength > 0 ? arrayBuffer : undefined;
185
+ }
186
+
187
+ const contentType = response.headers.get('content-type');
188
+ if (contentType?.includes('application/json')) {
189
+ try {
190
+ return await response.json();
191
+ } catch {
192
+ return undefined;
193
+ }
194
+ }
195
+
196
+ const text = await response.text();
197
+ return text.length > 0 ? text : undefined;
198
+ }
199
+
200
+ private hasHeader(headers: Record<string, string>, headerName: string): boolean {
201
+ const target = headerName.toLowerCase();
202
+ return Object.keys(headers).some((key) => key.toLowerCase() === target);
203
+ }
204
+
205
+ private removeHeader(headers: Record<string, string>, headerName: string): void {
206
+ const target = headerName.toLowerCase();
207
+ for (const key of Object.keys(headers)) {
208
+ if (key.toLowerCase() === target) {
209
+ delete headers[key];
210
+ }
211
+ }
212
+ }
213
+
110
214
  private async buildAuthHeaders(): Promise<Record<string, string>> {
111
215
  const headers: Record<string, string> = {};
112
216
  if (this.config.getAccessToken) {
@@ -125,6 +229,8 @@ export class BaseClient {
125
229
  options?: {
126
230
  params?: Record<string, any>;
127
231
  body?: any;
232
+ bodyType?: 'json' | 'form-data' | 'raw';
233
+ responseType?: 'auto' | 'json' | 'text' | 'arrayBuffer' | 'void';
128
234
  headers?: Record<string, string>;
129
235
  signal?: AbortSignal;
130
236
  }
@@ -164,34 +270,37 @@ export class BaseClient {
164
270
  // Add auth headers
165
271
  Object.assign(headers, await this.buildAuthHeaders());
166
272
 
167
- // Add content type for body requests
168
- if (options?.body && !headers['Content-Type']) {
169
- headers['Content-Type'] = 'application/json';
273
+ let requestBody: any;
274
+ if (options?.body !== undefined) {
275
+ const bodyType = options.bodyType ?? 'json';
276
+
277
+ if (bodyType === 'form-data') {
278
+ requestBody = options.body;
279
+ this.removeHeader(headers, 'Content-Type');
280
+ } else if (bodyType === 'raw') {
281
+ requestBody = options.body;
282
+ } else {
283
+ if (!this.hasHeader(headers, 'Content-Type')) {
284
+ headers['Content-Type'] = 'application/json';
285
+ }
286
+ requestBody = JSON.stringify(options.body);
287
+ }
170
288
  }
171
289
 
172
290
  const response = await fetch(url.toString(), {
173
291
  method,
174
292
  headers,
175
- body: options?.body ? JSON.stringify(options.body) : undefined,
293
+ body: requestBody,
176
294
  signal: options?.signal,
177
295
  credentials: this.config.credentials,
178
296
  });
179
297
 
180
298
  if (!response.ok) {
181
- throw new Error(\`HTTP \${response.status}: \${response.statusText}\`);
299
+ const errorBody = await this.parseResponseBody(response, 'auto');
300
+ throw new ApiError(response, errorBody);
182
301
  }
183
302
 
184
- // Handle no content responses
185
- if (response.status === 204 || response.headers.get('content-length') === '0') {
186
- return undefined as T;
187
- }
188
-
189
- const contentType = response.headers.get('content-type');
190
- if (contentType?.includes('application/json')) {
191
- return response.json() as Promise<T>;
192
- }
193
-
194
- return response.text() as any;
303
+ return await this.parseResponseBody(response, options?.responseType ?? 'auto') as T;
195
304
  }
196
305
 
197
306
  protected async *streamEvents<T = any>(
@@ -246,7 +355,8 @@ export class BaseClient {
246
355
  });
247
356
 
248
357
  if (!response.ok) {
249
- throw new Error(\`HTTP \${response.status}: \${response.statusText}\`);
358
+ const errorBody = await this.parseResponseBody(response);
359
+ throw new ApiError(response, errorBody);
250
360
  }
251
361
 
252
362
  if (!response.body) {
@@ -346,11 +456,12 @@ function generateOperation(op) {
346
456
  const lines = [];
347
457
  // Determine if this is a streaming endpoint by checking response content-type
348
458
  const isStreaming = isStreamingEndpoint(op);
459
+ const responseMode = inferResponseMode(op);
349
460
  // Build method signature
350
461
  // Use operationId directly as method name (already in desired format from OpenAPI)
351
462
  const methodName = op.operationId;
352
463
  const { params, isParamsOptional } = buildMethodParams(op);
353
- const returnType = inferReturnType(op, isStreaming);
464
+ const returnType = inferReturnType(op, isStreaming, responseMode);
354
465
  // Add JSDoc comment
355
466
  if (op.summary) {
356
467
  lines.push(` /** ${op.summary} */`);
@@ -416,6 +527,16 @@ function generateOperation(op) {
416
527
  }
417
528
  if (op.requestBody) {
418
529
  requestOptions.push(`body: ${paramsPrefix}body`);
530
+ const requestBodyContentType = getPreferredRequestBodyContentType(op);
531
+ if (requestBodyContentType === 'multipart/form-data') {
532
+ requestOptions.push(`bodyType: 'form-data'`);
533
+ }
534
+ else if (requestBodyContentType && requestBodyContentType !== 'application/json') {
535
+ requestOptions.push(`bodyType: 'raw'`);
536
+ }
537
+ }
538
+ if (!isStreaming && responseMode !== 'auto') {
539
+ requestOptions.push(`responseType: '${responseMode}'`);
419
540
  }
420
541
  if (params.includes('signal')) {
421
542
  requestOptions.push('signal: params?.signal');
@@ -457,12 +578,18 @@ function buildMethodParams(op) {
457
578
  paramFields.push(`${quotedName}${optional ? '?' : ''}: ${schemaToTypeScript(param.schema)}`);
458
579
  }
459
580
  if (op.requestBody) {
460
- // Try to get the body schema from any content type (prefer application/json)
461
- const bodySchema = op.requestBody.content.get('application/json') ||
462
- Array.from(op.requestBody.content.values())[0];
581
+ const requestBodyContentType = getPreferredRequestBodyContentType(op);
582
+ const bodySchema = (requestBodyContentType
583
+ ? op.requestBody.content.get(requestBodyContentType)
584
+ : undefined)
585
+ || op.requestBody.content.get('application/json')
586
+ || Array.from(op.requestBody.content.values())[0];
463
587
  if (bodySchema) {
464
588
  const optional = !op.requestBody.required;
465
- paramFields.push(`body${optional ? '?' : ''}: ${schemaToTypeScript(bodySchema.schema)}`);
589
+ const bodyType = requestBodyContentType === 'multipart/form-data'
590
+ ? 'FormData'
591
+ : schemaToTypeScript(bodySchema.schema);
592
+ paramFields.push(`body${optional ? '?' : ''}: ${bodyType}`);
466
593
  }
467
594
  }
468
595
  paramFields.push('signal?: AbortSignal');
@@ -481,11 +608,17 @@ function buildMethodParams(op) {
481
608
  }
482
609
  return { params: params.join(', '), isParamsOptional };
483
610
  }
484
- function inferReturnType(op, isStreaming) {
611
+ function inferReturnType(op, isStreaming, responseMode) {
485
612
  const successResponse = op.responses.get(200) || op.responses.get(201);
486
613
  if (!successResponse?.content) {
487
614
  return isStreaming ? 'AsyncIterable<any>' : 'Promise<void>';
488
615
  }
616
+ if (responseMode === 'arrayBuffer') {
617
+ return isStreaming ? 'AsyncIterable<any>' : 'Promise<ArrayBuffer>';
618
+ }
619
+ if (responseMode === 'text') {
620
+ return isStreaming ? 'AsyncIterable<any>' : 'Promise<string>';
621
+ }
489
622
  const jsonContent = successResponse.content.get('application/json');
490
623
  if (!jsonContent) {
491
624
  return isStreaming ? 'AsyncIterable<any>' : 'Promise<any>';
@@ -496,6 +629,46 @@ function inferReturnType(op, isStreaming) {
496
629
  }
497
630
  return `Promise<${tsType}>`;
498
631
  }
632
+ function getPreferredRequestBodyContentType(op) {
633
+ if (!op.requestBody) {
634
+ return undefined;
635
+ }
636
+ if (op.requestBody.content.has('application/json')) {
637
+ return 'application/json';
638
+ }
639
+ if (op.requestBody.content.has('multipart/form-data')) {
640
+ return 'multipart/form-data';
641
+ }
642
+ if (op.requestBody.content.has('application/x-www-form-urlencoded')) {
643
+ return 'application/x-www-form-urlencoded';
644
+ }
645
+ return op.requestBody.content.keys().next().value;
646
+ }
647
+ function inferResponseMode(op) {
648
+ const successResponse = op.responses.get(200) || op.responses.get(201);
649
+ if (!successResponse?.content) {
650
+ return 'void';
651
+ }
652
+ for (const contentType of successResponse.content.keys()) {
653
+ const lowered = contentType.toLowerCase();
654
+ if (lowered.includes('application/json')) {
655
+ return 'auto';
656
+ }
657
+ }
658
+ for (const contentType of successResponse.content.keys()) {
659
+ const lowered = contentType.toLowerCase();
660
+ if (lowered.startsWith('text/')) {
661
+ return 'text';
662
+ }
663
+ }
664
+ for (const contentType of successResponse.content.keys()) {
665
+ const lowered = contentType.toLowerCase();
666
+ if (lowered.includes('zip') || lowered.includes('octet-stream')) {
667
+ return 'arrayBuffer';
668
+ }
669
+ }
670
+ return 'auto';
671
+ }
499
672
  function generateMainClient(spec) {
500
673
  const lines = [
501
674
  `import { ClientConfig } from './base-client.js';`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taico/openapi-sdkgen",
3
- "version": "0.1.2",
3
+ "version": "0.2.12",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "license": "MIT",