Skip to content

Commit 64d94d3

Browse files
[typescript-fetch] BlobApiResponse returns an HttpFile, keeps a non-native Blob intact, tokenizes Content-Disposition (#24974)
* [typescript-fetch] BlobApiResponse returns a NamedBlob, keeps a non-native Blob intact, tokenizes Content-Disposition Follow-up to #24957 (#24956). - `BlobApiResponse.value()` wrapped whatever `raw.blob()` returned in `new File([blob])`: a Blob that is not the native one, as returned by an injected fetch implementation such as node-fetch, was stringified into a 13-byte "[object Blob]" body. The File is now built only from a native Blob where File exists and can be constructed; otherwise the Blob itself is given the name. - `value()` is typed `Promise<NamedBlob>` (`Blob & { name: string }`) instead of `Promise<File>`, which the fallback branches did not honour. Operations with a file response are typed `runtime.NamedBlob` through the new `apisReturnType.mustache` partial, so callers reach `.name` without a cast. - Content-Disposition parameters are tokenized by a single global regex that consumes one `name=value` at a time, replacing the character loop: a quoted-string is taken whole, junk after a closing quote and a trailing backslash are tolerated. - Unit tests for the injected-fetch Blob, a non-constructible File and malformed quoted-strings. * [typescript-fetch] name the file response type HttpFile, as the typescript generator does Same name and shape (`Blob & { readonly name: string }`) as the browser HttpFile of the `typescript` generator, so the two TypeScript generators agree on what a file response is. * [typescript-fetch] drop the tautological instanceOf assertions from the BlobApiResponse fallback tests * [typescript-fetch] align the codegen test with the HttpFile response type * [typescript-fetch] HttpFile doc comment: the equivalence with the typescript generator holds for its browser variant * [typescript-fetch] BlobApiResponse: guard only the File construction with the try
1 parent 9a5824a commit 64d94d3

33 files changed

Lines changed: 1258 additions & 1468 deletions

File tree

‎modules/openapi-generator/src/main/resources/typescript-fetch/apis.mustache‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,11 @@ export interface {{classname}}Interface {
9494
*/
9595
{{#hasContentTypeResponseVariants}}
9696
{{#contentTypeResponseVariants}}
97-
{{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<{{{returnType}}}{{^returnType}}void{{/returnType}}>>;
97+
{{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<{{>apisReturnType}}{{^returnType}}void{{/returnType}}>>;
9898
{{/contentTypeResponseVariants}}
9999
{{/hasContentTypeResponseVariants}}
100100
{{^hasContentTypeResponseVariants}}
101-
{{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<{{{returnType}}}{{^returnType}}void{{/returnType}}>>;
101+
{{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<{{>apisReturnType}}{{^returnType}}void{{/returnType}}>>;
102102
{{/hasContentTypeResponseVariants}}
103103
104104
/**
@@ -113,16 +113,16 @@ export interface {{classname}}Interface {
113113
{{/isDeprecated}}
114114
*/
115115
{{^useSingleRequestParameter}}
116-
{{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>;
116+
{{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{>apisReturnType}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>;
117117
{{/useSingleRequestParameter}}
118118
{{#useSingleRequestParameter}}
119119
{{#hasContentTypeResponseVariants}}
120120
{{#contentTypeResponseVariants}}
121-
{{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#returnType}}{{{returnType}}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>;
121+
{{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#returnType}}{{>apisReturnType}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>;
122122
{{/contentTypeResponseVariants}}
123123
{{/hasContentTypeResponseVariants}}
124124
{{^hasContentTypeResponseVariants}}
125-
{{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>;
125+
{{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{>apisReturnType}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>;
126126
{{/hasContentTypeResponseVariants}}
127127
{{/useSingleRequestParameter}}
128128
@@ -411,9 +411,9 @@ export class {{classname}} extends runtime.BaseAPI {
411411
{{! one overload per response content-type: `accept` selects the return type. The implementation
412412
signature unions them and is not callable from outside. }}
413413
{{#contentTypeResponseVariants}}
414-
async {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<{{{returnType}}}{{^returnType}}void{{/returnType}}>>;
414+
async {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<{{>apisReturnType}}{{^returnType}}void{{/returnType}}>>;
415415
{{/contentTypeResponseVariants}}
416-
async {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<{{#contentTypeResponseVariants}}{{{returnType}}}{{^returnType}}void{{/returnType}}{{^-last}} | {{/-last}}{{/contentTypeResponseVariants}}>> {
416+
async {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<{{#contentTypeResponseVariants}}{{>apisReturnType}}{{^returnType}}void{{/returnType}}{{^-last}} | {{/-last}}{{/contentTypeResponseVariants}}>> {
417417
const requestOptions = await this.{{nickname}}RequestOpts(requestParameters);
418418
const response = await this.request(requestOptions, initOverrides);
419419
@@ -441,7 +441,7 @@ export class {{classname}} extends runtime.BaseAPI {
441441
}
442442
{{/hasContentTypeResponseVariants}}
443443
{{^hasContentTypeResponseVariants}}
444-
async {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<{{{returnType}}}{{^returnType}}void{{/returnType}}>> {
444+
async {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<{{>apisReturnType}}{{^returnType}}void{{/returnType}}>> {
445445
const requestOptions = await this.{{nickname}}RequestOpts({{#allParams.0}}requestParameters{{/allParams.0}});
446446
const response = await this.request(requestOptions, initOverrides);
447447
@@ -498,7 +498,7 @@ export class {{classname}} extends runtime.BaseAPI {
498498
{{/isDeprecated}}
499499
*/
500500
{{^useSingleRequestParameter}}
501-
async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> {
501+
async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{>apisReturnType}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> {
502502
{{#returnType}}
503503
const response = await this.{{nickname}}Raw({{#allParams.0}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }, {{/allParams.0}}initOverrides);
504504
{{#isResponseOptional}}
@@ -525,9 +525,9 @@ export class {{classname}} extends runtime.BaseAPI {
525525
{{#useSingleRequestParameter}}
526526
{{#hasContentTypeResponseVariants}}
527527
{{#contentTypeResponseVariants}}
528-
async {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#returnType}}{{{returnType}}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>;
528+
async {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#returnType}}{{>apisReturnType}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>;
529529
{{/contentTypeResponseVariants}}
530-
async {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#contentTypeResponseVariants}}{{{returnType}}}{{^returnType}}void{{/returnType}}{{^-last}} | {{/-last}}{{/contentTypeResponseVariants}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}> {
530+
async {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#contentTypeResponseVariants}}{{>apisReturnType}}{{^returnType}}void{{/returnType}}{{^-last}} | {{/-last}}{{/contentTypeResponseVariants}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}> {
531531
{{! `as any`: from the outside only the overloads of ...Raw are visible, and the loose `accept`
532532
accepted here matches none of them }}
533533
const response = await this.{{nickname}}Raw(requestParameters as any, initOverrides);
@@ -551,7 +551,7 @@ export class {{classname}} extends runtime.BaseAPI {
551551
}
552552
{{/hasContentTypeResponseVariants}}
553553
{{^hasContentTypeResponseVariants}}
554-
async {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> {
554+
async {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{>apisReturnType}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> {
555555
{{#returnType}}
556556
const response = await this.{{nickname}}Raw({{#allParams.0}}requestParameters, {{/allParams.0}}initOverrides);
557557
{{#isResponseOptional}}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{! The declared type of an operation's response. A file response is handed back by BlobApiResponse as a HttpFile, which the type mapping alone (file -> Blob, shared with request bodies) cannot express. Inline partial: no newline. }}{{#isResponseFile}}runtime.HttpFile{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}

‎modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache‎

Lines changed: 46 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -532,90 +532,79 @@ export class VoidApiResponse {
532532
}
533533
}
534534

535+
/**
536+
* A Blob carrying the file name the server advertised, empty when it did not send one. It is a File
537+
* wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the
538+
* `typescript` generator's browser HttpFile.
539+
*/
540+
export type HttpFile = Blob & { readonly name: string };
541+
535542
export class BlobApiResponse {
536543
constructor(public raw: Response) {}
537544

538545
/**
539-
* The body as a File named after the Content-Disposition header, so that a download keeps the
540-
* name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name
541-
* is empty when the server did not send one. Runtimes without a global File (Node.js before 20)
542-
* keep receiving the bare Blob.
546+
* The body named after the Content-Disposition header, so that a download keeps the name the
547+
* server gave it. A File is built from a native Blob; the Blob itself is named where there is no
548+
* global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the
549+
* native one, as returned by an injected fetch implementation such as node-fetch, which a File
550+
* would stringify instead of wrapping.
543551
*/
544-
async value(): Promise<File> {
552+
async value(): Promise<HttpFile> {
545553
const blob = await this.raw.blob();
546-
if (typeof File === 'undefined') {
547-
return blob as File;
554+
const name = parseContentDispositionFilename(this.raw.headers) ?? '';
555+
if (typeof File !== 'undefined' && blob instanceof Blob) {
556+
try {
557+
return new File([blob], name, { type: blob.type });
558+
} catch {
559+
// File cannot be constructed: name the Blob itself
560+
}
548561
}
549-
return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type });
562+
return Object.assign(blob, { name });
550563
};
551564
}
552565

553566
/**
554567
* The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded
555-
* `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters
556-
* are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory
557-
* part is dropped so that the name is safe to write to disk as is; undefined when no usable name
558-
* is advertised.
568+
* `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8
569+
* or has no usable name. Any directory part is dropped; undefined when no usable name is advertised.
559570
*/
560571
export function parseContentDispositionFilename(headers: Headers): string | undefined {
561-
const value = headers.get('Content-Disposition');
562-
if (!value) {
572+
const params = parseHeaderParameters(headers.get('Content-Disposition') ?? '');
573+
return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename'));
574+
}
575+
576+
/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */
577+
function decodeExtValue(value: string | undefined): string | undefined {
578+
const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value);
579+
try {
580+
return match ? decodeURIComponent(match[1]) : undefined;
581+
} catch {
563582
return undefined;
564583
}
565-
const params = parseHeaderParameters(value);
566-
const encoded = params.get('filename*');
567-
if (encoded !== undefined) {
568-
const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded);
569-
if (extValue) {
570-
try {
571-
return basename(decodeURIComponent(extValue[1]));
572-
} catch {
573-
// malformed percent-encoding: fall through to the plain form
574-
}
575-
}
576-
}
577-
const plain = params.get('filename');
578-
return plain === undefined ? undefined : basename(plain);
579584
}
580585
581586
/**
582-
* The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are
583-
* lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the
584-
* first occurrence of a name wins.
587+
* The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is
588+
* taken whole, `;` included. Names are lower-cased, quoted-string values are unquoted with their
589+
* backslash escapes resolved, and the first occurrence of a name wins.
585590
*/
586591
function parseHeaderParameters(value: string): Map<string, string> {
587592
const params = new Map<string, string>();
588-
let start = 0;
589-
let quoted = false;
590-
for (let i = 0; i <= value.length; i++) {
591-
const c = value[i];
592-
if (i === value.length || (c === ';' && !quoted)) {
593-
const part = value.slice(start, i);
594-
const eq = part.indexOf('=');
595-
if (eq !== -1) {
596-
const name = part.slice(0, eq).trim().toLowerCase();
597-
let raw = part.slice(eq + 1).trim();
598-
if (raw.startsWith('"')) {
599-
raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1');
600-
}
601-
if (name && !params.has(name)) {
602-
params.set(name, raw);
603-
}
604-
}
605-
start = i + 1;
606-
} else if (c === '"') {
607-
quoted = !quoted;
608-
} else if (c === '\\' && quoted) {
609-
i++;
593+
const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g;
594+
let match: RegExpExecArray | null;
595+
while ((match = parameter.exec(value)) !== null) {
596+
const name = match[1].toLowerCase();
597+
if (!params.has(name)) {
598+
params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim());
610599
}
611600
}
612601
return params;
613602
}
614603
615-
/** The last path segment of a file name, or undefined when nothing usable is left. */
616-
function basename(name: string): string | undefined {
617-
const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim();
618-
return base === '' || base === '.' || base === '..' ? undefined : base;
604+
/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */
605+
function basename(name: string | undefined): string | undefined {
606+
const base = name?.split(/[\\/]/).pop();
607+
return !base || base === '.' || base === '..' ? undefined : base;
619608
}
620609
621610
export class TextApiResponse {

0 commit comments

Comments
 (0)