fork of hey-api/openapi-ts because I need some additional things

chore: infer response parse method

Lubos e4bd96db b213fb5c

+158 -103
+1 -2
package.json
··· 55 55 "typescript": "5.4.5", 56 56 "typescript-eslint": "7.8.0", 57 57 "vitest": "1.6.0" 58 - }, 59 - "packageManager": "pnpm@8.15.7" 58 + } 60 59 }
+18 -3
packages/client-fetch/package.json
··· 26 26 "typescript", 27 27 "vue" 28 28 ], 29 + "types": "dist/node/index.d.ts", 30 + "main": "dist/node/index.cjs", 31 + "module": "dist/node/index.mjs", 29 32 "exports": { 30 - "import": "./dist/node/index.mjs", 31 - "require": "./dist/node/index.cjs", 32 - "types": "./dist/node/index.d.ts" 33 + ".": { 34 + "import": { 35 + "types": "./dist/node/index.d.ts", 36 + "default": "./dist/node/index.mjs" 37 + }, 38 + "require": { 39 + "types": "./dist/node/index.d.ts", 40 + "default": "./dist/node/index.cjs" 41 + } 42 + }, 43 + "./package.json": "./package.json" 33 44 }, 45 + "files": [ 46 + "dist", 47 + "src" 48 + ], 34 49 "scripts": { 35 50 "build-bundle": "rollup --config rollup.config.ts --configPlugin typescript", 36 51 "build-types-check": "tsc --project tsconfig.check.json",
+7 -63
packages/client-fetch/src/index.ts
··· 3 3 createDefaultConfig, 4 4 createInterceptors, 5 5 createQuerySerializer, 6 + getParseAs, 6 7 getUrl, 7 8 mergeHeaders, 8 9 } from './utils'; 9 10 10 - // const getHeaders = async ( 11 - // config: OpenAPIConfig, 12 - // options: ApiRequestOptions, 13 - // ): Promise<Headers> => { 14 - // const [token, username, password, additionalHeaders] = await Promise.all([ 15 - // resolve(options, config.TOKEN), 16 - // resolve(options, config.USERNAME), 17 - // resolve(options, config.PASSWORD), 18 - // resolve(options, config.HEADERS), 19 - // ]); 20 - 21 - // const headers = Object.entries({ 22 - // Accept: 'application/json', 23 - // ...additionalHeaders, 24 - // ...options.headers, 25 - // }) 26 - // .filter(([, value]) => value !== undefined && value !== null) 27 - // .reduce( 28 - // (headers, [key, value]) => ({ 29 - // ...headers, 30 - // [key]: String(value), 31 - // }), 32 - // {} as Record<string, string>, 33 - // ); 34 - 35 - // if (isStringWithValue(token)) { 36 - // headers['Authorization'] = `Bearer ${token}`; 37 - // } 38 - 39 - // if (isStringWithValue(username) && isStringWithValue(password)) { 40 - // const credentials = base64(`${username}:${password}`); 41 - // headers['Authorization'] = `Basic ${credentials}`; 42 - // } 43 - 11 + // const getHeaders = async () => { 44 12 // if (options.body !== undefined) { 45 13 // if (options.mediaType) { 46 14 // headers['Content-Type'] = options.mediaType; ··· 50 18 // headers['Content-Type'] = 'text/plain'; 51 19 // } else if (!isFormData(options.body)) { 52 20 // headers['Content-Type'] = 'application/json'; 53 - // } 54 - // } 55 - 56 - // return new Headers(headers); 57 - // }; 58 - 59 - // const getResponseBody = async (response: Response): Promise<unknown> => { 60 - // const contentType = response.headers.get('Content-Type'); 61 - // if (contentType) { 62 - // const binaryTypes = [ 63 - // 'application/octet-stream', 64 - // 'application/pdf', 65 - // 'application/zip', 66 - // 'audio/', 67 - // 'image/', 68 - // 'video/', 69 - // ]; 70 - // if ( 71 - // contentType.includes('application/json') || 72 - // contentType.includes('+json') 73 - // ) { 74 - // return await response.json(); 75 - // } else if (binaryTypes.some((type) => contentType.includes(type))) { 76 - // return await response.blob(); 77 - // } else if (contentType.includes('multipart/form-data')) { 78 - // return await response.formData(); 79 - // } else if (contentType.includes('text/')) { 80 - // return await response.text(); 81 21 // } 82 22 // } 83 23 // }; ··· 189 129 ...result, 190 130 }; 191 131 } 132 + const parseAs = 133 + opts.parseAs === 'auto' 134 + ? getParseAs(response.headers.get('Content-Type')) 135 + : opts.parseAs; 192 136 return { 193 - data: await response[opts.parseAs ?? 'json'](), 137 + data: await response[parseAs ?? 'json'](), 194 138 ...result, 195 139 }; 196 140 }
+1
packages/client-fetch/src/node/index.ts
··· 1 1 export { client, createClient } from '../'; 2 2 export type { Options } from '../types'; 3 + export { formDataBodySerializer, jsonBodySerializer } from '../utils';
+6 -5
packages/client-fetch/src/types.ts
··· 70 70 | 'PUT' 71 71 | 'TRACE'; 72 72 /** 73 - * Return the response data parsed in a specified format. Any of the 74 - * {@link Body} methods are allowed. By default, {@link Body.json()} will be 75 - * used. Select `stream` if you don't want to parse response data. 76 - * @default 'json' 73 + * Return the response data parsed in a specified format. By default, `auto` 74 + * will infer the appropriate method from the `Content-Type` response header. 75 + * You can override this behavior with any of the {@link Body} methods. 76 + * Select `stream` if you don't want to parse response data at all. 77 + * @default 'auto' 77 78 */ 78 - parseAs?: Exclude<keyof Body, 'body' | 'bodyUsed'> | 'stream'; 79 + parseAs?: Exclude<keyof Body, 'body' | 'bodyUsed'> | 'auto' | 'stream'; 79 80 /** 80 81 * A function for serializing request query parameters. By default, arrays 81 82 * will be exploded in form style, objects will be exploded in deepObject
+78 -20
packages/client-fetch/src/utils.ts
··· 42 42 object?: SerializerOptions<ObjectStyle>; 43 43 } 44 44 45 - function serializePrimitiveParam({ 45 + const serializePrimitiveParam = ({ 46 46 allowReserved, 47 47 name, 48 48 value, 49 - }: SerializePrimitiveParam) { 49 + }: SerializePrimitiveParam) => { 50 50 if (value === undefined || value === null) { 51 51 return ''; 52 52 } ··· 58 58 } 59 59 60 60 return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; 61 - } 61 + }; 62 62 63 63 const separatorArrayExplode = (style: ArraySeparatorStyle) => { 64 64 switch (style) { ··· 99 99 } 100 100 }; 101 101 102 - function serializeArrayParam({ 102 + const serializeArrayParam = ({ 103 103 allowReserved, 104 104 explode, 105 105 name, ··· 107 107 value, 108 108 }: SerializeOptions<ArraySeparatorStyle> & { 109 109 value: unknown[]; 110 - }) { 110 + }) => { 111 111 if (!explode) { 112 112 const final = ( 113 113 allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) ··· 139 139 }) 140 140 .join(separator); 141 141 return style === 'label' || style === 'matrix' ? separator + final : final; 142 - } 142 + }; 143 143 144 144 const serializeObjectParam = ({ 145 145 allowReserved, ··· 184 184 return style === 'label' || style === 'matrix' ? separator + final : final; 185 185 }; 186 186 187 - function defaultPathSerializer({ path, url: _url }: PathSerializer) { 187 + const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { 188 188 let url = _url; 189 189 const matches = _url.match(PATH_PARAM_RE); 190 190 if (matches) { ··· 251 251 } 252 252 } 253 253 return url; 254 - } 254 + }; 255 255 256 256 export const createQuerySerializer = <T = unknown>({ 257 257 allowReserved, ··· 310 310 return querySerializer; 311 311 }; 312 312 313 - export function getUrl({ 313 + /** 314 + * Infers parseAs value from provided Content-Type header. 315 + */ 316 + export const getParseAs = ( 317 + content: string | null, 318 + ): Exclude<Config['parseAs'], 'auto' | 'stream'> => { 319 + if (!content) { 320 + return; 321 + } 322 + 323 + if (content === 'application/json' || content.endsWith('+json')) { 324 + return 'json'; 325 + } 326 + 327 + if (content === 'multipart/form-data') { 328 + return 'formData'; 329 + } 330 + 331 + if ( 332 + [ 333 + 'application/octet-stream', 334 + 'application/pdf', 335 + 'application/zip', 336 + 'audio/', 337 + 'image/', 338 + 'video/', 339 + ].some((type) => content.includes(type)) 340 + ) { 341 + return 'blob'; 342 + } 343 + 344 + if (content.includes('text/')) { 345 + return 'text'; 346 + } 347 + }; 348 + 349 + export const getUrl = ({ 314 350 baseUrl, 315 351 path, 316 352 query, ··· 322 358 query?: Record<string, unknown>; 323 359 querySerializer: QuerySerializer; 324 360 url: string; 325 - }) { 361 + }) => { 326 362 const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; 327 363 let url = baseUrl + pathUrl; 328 364 if (path) { ··· 336 372 url += `?${search}`; 337 373 } 338 374 return url; 339 - } 375 + }; 340 376 341 377 export const mergeHeaders = ( 342 378 ...headers: Array<Required<Config>['headers'] | undefined> ··· 411 447 response: new Interceptors<ResInterceptor<Res, Req, Options>>(), 412 448 }); 413 449 414 - export const formDataBodySerializer = <T extends Record<string, any>>( 415 - body: T, 450 + const serializeFormDataPair = ( 451 + formData: FormData, 452 + key: string, 453 + value: unknown, 416 454 ) => { 417 - const formData = new FormData(); 418 - for (const key in body) { 419 - formData.append(key, body[key]); 455 + if (typeof value === 'string' || value instanceof Blob) { 456 + formData.append(key, value); 457 + } else { 458 + formData.append(key, JSON.stringify(value)); 420 459 } 421 - return formData; 460 + }; 461 + 462 + export const formDataBodySerializer = { 463 + bodySerializer: <T extends Record<string, any>>(body: T) => { 464 + const formData = new FormData(); 465 + 466 + Object.entries(body).forEach(([key, value]) => { 467 + if (value === undefined || value === null) { 468 + return; 469 + } 470 + if (Array.isArray(value)) { 471 + value.forEach((v) => serializeFormDataPair(formData, key, v)); 472 + } else { 473 + serializeFormDataPair(formData, key, value); 474 + } 475 + }); 476 + 477 + return formData; 478 + }, 422 479 }; 423 480 424 - export const jsonBodySerializer = <T>(body: T) => JSON.stringify(body); 481 + export const jsonBodySerializer = { 482 + bodySerializer: <T>(body: T) => JSON.stringify(body), 483 + }; 425 484 426 485 const defaultQuerySerializer = createQuerySerializer({ 427 486 allowReserved: false, ··· 440 499 }; 441 500 442 501 export const createDefaultConfig = (): Config => ({ 502 + ...jsonBodySerializer, 443 503 baseUrl: '', 444 - bodySerializer: jsonBodySerializer, 445 504 fetch: globalThis.fetch, 446 505 global: true, 447 506 headers: defaultHeaders, 448 - parseAs: 'json', 449 507 querySerializer: defaultQuerySerializer, 450 508 });
+3 -2
packages/openapi-ts/src/utils/write/client.ts
··· 4 4 import { TypeScriptFile } from '../../compiler'; 5 5 import type { OpenApi } from '../../openApi'; 6 6 import type { Client } from '../../types/client'; 7 - import { getConfig } from '../config'; 7 + import { getConfig, isStandaloneClient } from '../config'; 8 8 import type { Templates } from '../handlebars'; 9 9 import { writeClientClass } from './class'; 10 10 import { writeCore } from './core'; ··· 26 26 ): Promise<void> => { 27 27 const config = getConfig(); 28 28 29 - if (config.services.include) { 29 + // only legacy clients have class-based services 30 + if (config.services.include && !isStandaloneClient(config)) { 30 31 const regexp = new RegExp(config.services.include); 31 32 client.services = client.services.filter((service) => 32 33 regexp.test(service.name),
+44 -8
packages/openapi-ts/src/utils/write/services.ts
··· 23 23 import { uniqueTypeName } from './type'; 24 24 25 25 type OnNode = (node: Node) => void; 26 - type OnImport = (importedType: string) => void; 26 + type OnImport = (name: string) => void; 27 27 28 28 const generateImport = ({ 29 29 meta, ··· 200 200 return comment; 201 201 }; 202 202 203 - const toRequestOptions = (operation: Operation) => { 203 + const toRequestOptions = (operation: Operation, onClientImport?: OnImport) => { 204 204 const config = getConfig(); 205 205 206 206 if (isStandaloneClient(config)) { 207 - const obj: ObjectValue[] = [ 207 + let obj: ObjectValue[] = [ 208 208 { 209 209 spread: 'options', 210 210 }, 211 + ]; 212 + 213 + const bodyParameters = operation.parameters.filter( 214 + (parameter) => parameter.in === 'body' || parameter.in === 'formData', 215 + ); 216 + const contents = bodyParameters 217 + .map((parameter) => parameter.mediaType) 218 + .filter(Boolean) 219 + .filter(unique); 220 + if (contents.length === 1 && contents[0] === 'multipart/form-data') { 221 + obj = [ 222 + ...obj, 223 + { 224 + spread: 'formDataBodySerializer', 225 + }, 226 + ]; 227 + onClientImport?.('formDataBodySerializer'); 228 + } 229 + 230 + // TODO: set parseAs to skip inference if every result has the same 231 + // content type. currently impossible because results do not contain 232 + // header information 233 + 234 + obj = [ 235 + ...obj, 211 236 { 212 237 key: 'url', 213 238 value: operation.path, 214 239 }, 215 240 ]; 241 + 216 242 return compiler.types.object({ 217 243 obj, 218 244 }); ··· 300 326 }); 301 327 }; 302 328 303 - const toOperationStatements = (client: Client, operation: Operation) => { 329 + const toOperationStatements = ( 330 + client: Client, 331 + operation: Operation, 332 + onClientImport?: OnImport, 333 + ) => { 304 334 const config = getConfig(); 305 335 306 - const options = toRequestOptions(operation); 336 + const options = toRequestOptions(operation, onClientImport); 307 337 308 338 if (isStandaloneClient(config)) { 309 339 const errorType = uniqueTypeName({ ··· 375 405 service: Service, 376 406 onNode: OnNode, 377 407 onImport: OnImport, 408 + onClientImport: OnImport, 378 409 ) => { 379 410 const config = getConfig(); 380 411 ··· 427 458 service.operations.forEach((operation) => { 428 459 const expression = compiler.types.function({ 429 460 parameters: toOperationParamType(client, operation), 430 - statements: toOperationStatements(client, operation), 461 + statements: toOperationStatements(client, operation, onClientImport), 431 462 }); 432 463 const statement = compiler.export.const({ 433 464 comment: toOperationComment(operation), ··· 508 539 const config = getConfig(); 509 540 510 541 let imports: string[] = []; 542 + let clientImports: string[] = []; 511 543 512 544 for (const service of client.services) { 513 545 processService( ··· 516 548 (node) => { 517 549 files.services?.add(node); 518 550 }, 519 - (importedType) => { 520 - imports = [...imports, importedType]; 551 + (imported) => { 552 + imports = [...imports, imported]; 553 + }, 554 + (imported) => { 555 + clientImports = [...clientImports, imported]; 521 556 }, 522 557 ); 523 558 } ··· 531 566 asType: true, 532 567 name: 'Options', 533 568 }, 569 + ...clientImports.filter(unique), 534 570 ], 535 571 config.client, 536 572 );