diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache index ec58f037d84e..ea9560de2cdc 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache @@ -535,11 +535,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts b/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts index 1d2ddace09ec..e938f71a92b9 100644 --- a/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts b/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts index 1d2ddace09ec..e938f71a92b9 100644 --- a/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts b/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts index e93ee831ab96..0f2a7cc53e43 100644 --- a/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts +++ b/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/others/typescript-fetch/self-import-issue/runtime.ts b/samples/client/others/typescript-fetch/self-import-issue/runtime.ts index 0562a357d812..8fc35f778705 100644 --- a/samples/client/others/typescript-fetch/self-import-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/self-import-issue/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts b/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts index 21ac6b88d7ff..21a63f57d6d1 100644 --- a/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts b/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts index 21ac6b88d7ff..21a63f57d6d1 100644 --- a/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts index 59aa9338bf8d..5c3911a68825 100644 --- a/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts index 2535518eb0ca..f3b6f5b34a85 100644 --- a/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts @@ -452,11 +452,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts b/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts index 9300aaa0f8d6..b26dcead9923 100644 --- a/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/default/runtime.ts b/samples/client/petstore/typescript-fetch/builds/default/runtime.ts index b71d0834f8cf..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/default/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/default/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts b/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts index 200d6e694abf..fde0f53ecd3a 100644 --- a/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts index b71d0834f8cf..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts b/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts index 9300aaa0f8d6..b26dcead9923 100644 --- a/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts b/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts index b71d0834f8cf..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts b/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts index 9ad5703db110..b433ba535991 100644 --- a/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts index b71d0834f8cf..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts index b71d0834f8cf..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts b/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts index 9300aaa0f8d6..b26dcead9923 100644 --- a/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts index ac813429c9ec..14201ca4c56f 100644 --- a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts @@ -504,11 +504,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts b/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts index b71d0834f8cf..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts index b71d0834f8cf..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts index b71d0834f8cf..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts index 200d6e694abf..fde0f53ecd3a 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts @@ -491,11 +491,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts index 56e2e516c01a..f084e16504a1 100644 --- a/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts @@ -440,11 +440,89 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. + */ + async value(): Promise { + const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } + } + } + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } + } + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts b/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts new file mode 100644 index 000000000000..5d0ad7af51ff --- /dev/null +++ b/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts @@ -0,0 +1,106 @@ +import { expect } from 'chai'; +import { BlobApiResponse, parseContentDispositionFilename } from '@swagger/typescript-fetch-petstore'; + +describe('parseContentDispositionFilename', () => { + + function nameOf(contentDisposition?: string): string | undefined { + const headers = new Headers(); + if (contentDisposition !== undefined) { + headers.set('Content-Disposition', contentDisposition); + } + return parseContentDispositionFilename(headers); + } + + it('should return undefined without a header or without a filename', () => { + expect(nameOf()).to.be.undefined; + expect(nameOf('inline')).to.be.undefined; + expect(nameOf('attachment; name="field"')).to.be.undefined; + }); + + it('should read a token and a quoted-string filename', () => { + expect(nameOf('attachment; filename=report.pdf')).to.equal('report.pdf'); + expect(nameOf('attachment; filename="report 2024.pdf"')).to.equal('report 2024.pdf'); + expect(nameOf('attachment;filename="tight.pdf"')).to.equal('tight.pdf'); + }); + + it('should keep semicolons and escaped quotes inside a quoted-string', () => { + expect(nameOf('attachment; filename="report;2024.pdf"; size=12')).to.equal('report;2024.pdf'); + expect(nameOf('attachment; filename="say \\"hi\\".txt"')).to.equal('say "hi".txt'); + }); + + it('should prefer the RFC 5987 encoded form and decode it', () => { + expect(nameOf("attachment; filename=\"fallback.pdf\"; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf")) + .to.equal('résumé.pdf'); + expect(nameOf("attachment; filename*=utf-8'en'plain.pdf")).to.equal('plain.pdf'); + }); + + it('should fall back to the plain form when the encoded one is malformed', () => { + expect(nameOf("attachment; filename*=UTF-8''%E0%A4%A; filename=\"fallback.pdf\"")).to.equal('fallback.pdf'); + }); + + it('should match parameter names case-insensitively', () => { + expect(nameOf('Attachment; Filename="mixed.pdf"')).to.equal('mixed.pdf'); + expect(nameOf("attachment; FILENAME*=utf-8''upper.pdf")).to.equal('upper.pdf'); + }); + + it('should only match filename as a parameter name', () => { + expect(nameOf('attachment; xfilename="nope.pdf"')).to.be.undefined; + expect(nameOf('attachment; name="filename*=UTF-8\'\'nope.pdf"; filename="real.pdf"')).to.equal('real.pdf'); + }); + + it('should not split parameters on a semicolon inside a quoted-string', () => { + expect(nameOf('attachment; name="a; filename*=UTF-8\'\'evil.pdf"; filename="real.pdf"')).to.equal('real.pdf'); + expect(nameOf('attachment; name="y; filename=evil"; filename="real.pdf"')).to.equal('real.pdf'); + }); + + it('should accept a quoted encoded form and ignore a charset other than UTF-8', () => { + expect(nameOf("attachment; filename*=\"UTF-8''quoted.pdf\"")).to.equal('quoted.pdf'); + expect(nameOf("attachment; filename*=iso-8859-1''latin.pdf; filename=\"plain.pdf\"")).to.equal('plain.pdf'); + }); + + it('should keep the first occurrence of a repeated parameter', () => { + expect(nameOf('attachment; filename="first.pdf"; filename="second.pdf"')).to.equal('first.pdf'); + }); + + it('should drop any directory part of the name', () => { + expect(nameOf('attachment; filename="../../etc/passwd"')).to.equal('passwd'); + expect(nameOf("attachment; filename*=UTF-8''..%2F..%2Fetc%2Fpasswd")).to.equal('passwd'); + expect(nameOf('attachment; filename=C:\\Users\\me\\report.pdf')).to.equal('report.pdf'); + expect(nameOf('attachment; filename=".."')).to.be.undefined; + expect(nameOf('attachment; filename="/"')).to.be.undefined; + expect(nameOf('attachment; filename=""')).to.be.undefined; + }); +}); + +describe('BlobApiResponse', () => { + + it('should name the file after the Content-Disposition header', async () => { + const response = new Response('content', { + headers: { 'Content-Type': 'text/plain', 'Content-Disposition': 'attachment; filename="named.txt"' }, + }); + const file = await new BlobApiResponse(response).value(); + expect(file).to.be.an.instanceOf(Blob); + expect(file.name).to.equal('named.txt'); + expect(file.type).to.equal('text/plain'); + expect(await file.text()).to.equal('content'); + }); + + it('should return an unnamed file without the header', async () => { + const file = await new BlobApiResponse(new Response('content')).value(); + expect(file.name).to.equal(''); + }); + + it('should keep returning the bare Blob where File is not a global (Node.js before 20)', async () => { + const globals = global as any; + const nativeFile = globals.File; + globals.File = undefined; + try { + const value = await new BlobApiResponse(new Response('content')).value(); + expect(value).to.be.an.instanceOf(Blob); + expect((value as any).name).to.be.undefined; + expect(await value.text()).to.equal('content'); + } finally { + globals.File = nativeFile; + } + }); +}); diff --git a/samples/client/petstore/typescript-fetch/tests/default/test/index.ts b/samples/client/petstore/typescript-fetch/tests/default/test/index.ts index 134fa030435a..2a8136ffc029 100644 --- a/samples/client/petstore/typescript-fetch/tests/default/test/index.ts +++ b/samples/client/petstore/typescript-fetch/tests/default/test/index.ts @@ -1,2 +1,3 @@ import './PetApi'; import './StoreApi'; +import './BlobApiResponse';