diff --git a/bin/configs/python.yaml b/bin/configs/python.yaml index cf4ee06b4327..9c387d881c3f 100644 --- a/bin/configs/python.yaml +++ b/bin/configs/python.yaml @@ -2,6 +2,7 @@ generatorName: python outputDir: samples/openapi3/client/petstore/python inputSpec: modules/openapi-generator/src/test/resources/3_0/python/petstore-with-fake-endpoints-models-for-testing.yaml templateDir: modules/openapi-generator/src/main/resources/python +# sourceFolder: "src" additionalProperties: packageName: petstore_api useOneOfDiscriminatorLookup: "true" diff --git a/docs/generators/python.md b/docs/generators/python.md index e4fb73ff8fae..828b94f83cfc 100644 --- a/docs/generators/python.md +++ b/docs/generators/python.md @@ -36,6 +36,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |projectName|python project name in setup.py (e.g. petstore-api).| |null| |recursionLimit|Set the recursion limit. If not set, use the system default value.| |null| |setEnsureAsciiToFalse|When set to true, add `ensure_ascii=False` in json.dumps when creating the HTTP request body.| |false| +|sourceFolder|directory for generated python source code; 'src' for _src layout_, unset (or '.') for _flat layout_.| |null| |supportHttpxSync|Generate synchronous '_sync' variants of each API method (httpx and httpx2 libraries only). Each '_sync' method simply calls the corresponding async method and waits for its completion, so both synchronous and asynchronous methods are available from the same API class.| |false| |useIndependentImplicitClients|Give API instances without an explicit or registered default ApiClient an owned client with a copied Configuration.| |false| |useOneOfDiscriminatorLookup|Use the discriminator's mapping in oneOf to speed up the model lookup. IMPORTANT: Validation (e.g. one and only one match in oneOf's schemas) will be skipped.| |false| diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java index ce8ca4bcef37..61936225db59 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java @@ -120,6 +120,7 @@ public class PythonClientCodegen extends AbstractPythonCodegen implements Codege @Setter protected boolean useIndependentImplicitClients = false; @Setter protected boolean compatibleWithPythonLegacy = false; + private String sourceFolder; private String testFolder; public PythonClientCodegen() { @@ -198,6 +199,7 @@ public PythonClientCodegen() { modelDocTemplateFiles.put("model_doc.mustache", ".md"); apiDocTemplateFiles.put("api_doc.mustache", ".md"); + sourceFolder = ""; testFolder = "test"; // default HIDE_GENERATION_TIMESTAMP to true @@ -227,6 +229,7 @@ public PythonClientCodegen() { cliOptions.add(new CliOption(POETRY1_FALLBACK, "Fallback to formatting pyproject.toml to Poetry 1.x format.")); cliOptions.add(new CliOption(LAZY_IMPORTS, "Enable lazy imports.").defaultValue(Boolean.FALSE.toString())); cliOptions.add(new CliOption(BUILD_SYSTEM, "Build system to use in pyproject.toml (setuptools, hatchling).").defaultValue("setuptools")); + cliOptions.add(new CliOption(CodegenConstants.SOURCE_FOLDER, "directory for generated python source code; 'src' for _src layout_, unset (or '.') for _flat layout_.")); cliOptions.add(CliOption.newBoolean(SUPPORT_HTTPX_SYNC, "Generate synchronous '_sync' variants of each API method (httpx and httpx2 libraries only). " + "Each '_sync' method simply calls the corresponding async method and waits for its completion, " + "so both synchronous and asynchronous methods are available from the same API class.").defaultValue(Boolean.FALSE.toString())); @@ -296,6 +299,9 @@ public void processOpts() { if (additionalProperties.containsKey(CodegenConstants.PACKAGE_VERSION)) { setPackageVersion((String) additionalProperties.get(CodegenConstants.PACKAGE_VERSION)); } + if (additionalProperties.containsKey(CodegenConstants.SOURCE_FOLDER)) { + this.sourceFolder = (String) additionalProperties.get(CodegenConstants.SOURCE_FOLDER); + } additionalProperties.put(CodegenConstants.PROJECT_NAME, projectName); additionalProperties.put(CodegenConstants.PACKAGE_NAME, packageName); @@ -314,8 +320,8 @@ public void processOpts() { // tests in /test testFolder = packagePath() + File.separatorChar + testFolder; // api/model docs in /docs - apiDocPath = packagePath() + "/" + apiDocPath; - modelDocPath = packagePath() + "/" + modelDocPath; + apiDocPath = packagePath() + File.separatorChar + apiDocPath; + modelDocPath = packagePath() + File.separatorChar + modelDocPath; } // make api and model doc path available in mustache template additionalProperties.put("apiDocPath", apiDocPath); @@ -392,8 +398,9 @@ public void processOpts() { USE_INDEPENDENT_IMPLICIT_CLIENTS, useIndependentImplicitClients); } - String modelPath = packagePath() + File.separatorChar + modelPackage.replace('.', File.separatorChar); - String apiPath = packagePath() + File.separatorChar + apiPackage.replace('.', File.separatorChar); + String srcPath = sourceFolder.isEmpty() ? packagePath() : (sourceFolder + File.separatorChar + packagePath()); + String modelPath = srcPath + File.separatorChar + modelPackage.replace('.', File.separatorChar); + String apiPath = srcPath + File.separatorChar + apiPackage.replace('.', File.separatorChar); String readmePath = "README.md"; String readmeTemplate = "README.mustache"; @@ -416,10 +423,10 @@ public void processOpts() { supportingFiles.add(new SupportingFile("gitlab-ci.mustache", "", ".gitlab-ci.yml")); supportingFiles.add(new SupportingFile("setup.mustache", "", "setup.py")); supportingFiles.add(new SupportingFile("pyproject.mustache", "", "pyproject.toml")); - supportingFiles.add(new SupportingFile("py.typed.mustache", packagePath(), "py.typed")); + supportingFiles.add(new SupportingFile("py.typed.mustache", srcPath, "py.typed")); } - supportingFiles.add(new SupportingFile("configuration.mustache", packagePath(), "configuration.py")); - supportingFiles.add(new SupportingFile("__init__package.mustache", packagePath(), "__init__.py")); + supportingFiles.add(new SupportingFile("configuration.mustache", srcPath, "configuration.py")); + supportingFiles.add(new SupportingFile("__init__package.mustache", srcPath, "__init__.py")); supportingFiles.add(new SupportingFile("__init__model.mustache", modelPath, "__init__.py")); supportingFiles.add(new SupportingFile("__init__api.mustache", apiPath, "__init__.py")); // Generate the 'signing.py' module, but only if the 'HTTP signature' security scheme is specified in the OAS. @@ -427,12 +434,12 @@ public void processOpts() { (openAPI.getComponents() != null ? openAPI.getComponents().getSecuritySchemes() : null) : null; List authMethods = fromSecurity(securitySchemeMap); if (ProcessUtils.hasHttpSignatureMethods(authMethods)) { - supportingFiles.add(new SupportingFile("signing.mustache", packagePath(), "signing.py")); + supportingFiles.add(new SupportingFile("signing.mustache", srcPath, "signing.py")); } // If the package name consists of dots(openapi.client), then we need to create the directory structure like openapi/client with __init__ files. String[] packageNameSplits = packageName.split("\\."); - String currentPackagePath = ""; + String currentPackagePath = sourceFolder.isEmpty() ? "" : (sourceFolder + File.separatorChar); for (int i = 0; i < packageNameSplits.length - 1; i++) { if (i > 0) { currentPackagePath = currentPackagePath + File.separatorChar; @@ -441,21 +448,21 @@ public void processOpts() { supportingFiles.add(new SupportingFile("__init__.mustache", currentPackagePath, "__init__.py")); } - supportingFiles.add(new SupportingFile("exceptions.mustache", packagePath(), "exceptions.py")); + supportingFiles.add(new SupportingFile("exceptions.mustache", srcPath, "exceptions.py")); if (Boolean.FALSE.equals(excludeTests)) { supportingFiles.add(new SupportingFile("__init__.mustache", testFolder, "__init__.py")); } - supportingFiles.add(new SupportingFile("api_client.mustache", packagePath(), "api_client.py")); - supportingFiles.add(new SupportingFile("api_response.mustache", packagePath(), "api_response.py")); + supportingFiles.add(new SupportingFile("api_client.mustache", srcPath, "api_client.py")); + supportingFiles.add(new SupportingFile("api_response.mustache", srcPath, "api_response.py")); if ("asyncio".equals(getLibrary())) { - supportingFiles.add(new SupportingFile("asyncio/rest.mustache", packagePath(), "rest.py")); + supportingFiles.add(new SupportingFile("asyncio/rest.mustache", srcPath, "rest.py")); additionalProperties.put("async", "true"); additionalProperties.put("asyncio", "true"); } else if (isHttpxLibrary()) { - supportingFiles.add(new SupportingFile("httpx/rest.mustache", packagePath(), "rest.py")); + supportingFiles.add(new SupportingFile("httpx/rest.mustache", srcPath, "rest.py")); additionalProperties.put("async", "true"); additionalProperties.put(getLibrary(), "true"); additionalProperties.put("httpxLibrary", true); @@ -463,12 +470,12 @@ public void processOpts() { if (Boolean.parseBoolean(String.valueOf(additionalProperties.get(SUPPORT_HTTPX_SYNC)))) { // generate synchronous '_sync' method variants alongside the async ones additionalProperties.put(SUPPORT_HTTPX_SYNC, true); - supportingFiles.add(new SupportingFile("httpx/sync_helper.mustache", packagePath(), "sync_helper.py")); + supportingFiles.add(new SupportingFile("httpx/sync_helper.mustache", srcPath, "sync_helper.py")); } else { additionalProperties.remove(SUPPORT_HTTPX_SYNC); } } else { - supportingFiles.add(new SupportingFile("rest.mustache", packagePath(), "rest.py")); + supportingFiles.add(new SupportingFile("rest.mustache", srcPath, "rest.py")); } // 'supportHttpxSync' only makes sense for the (async) httpx and httpx2 libraries @@ -545,12 +552,26 @@ public String toApiDocFilename(String name) { @Override public String apiFileFolder() { - return outputFolder + File.separatorChar + apiPackage().replace('.', File.separatorChar); + StringBuffer sb = new StringBuffer(outputFolder); + sb.append(File.separatorChar); + if (!sourceFolder.isEmpty()) { + sb.append(sourceFolder); + sb.append(File.separatorChar); + } + sb.append(apiPackage().replace('.', File.separatorChar)); + return sb.toString(); } @Override public String modelFileFolder() { - return outputFolder + File.separatorChar + modelPackage().replace('.', File.separatorChar); + StringBuffer sb = new StringBuffer(outputFolder); + sb.append(File.separatorChar); + if (!sourceFolder.isEmpty()) { + sb.append(sourceFolder); + sb.append(File.separatorChar); + } + sb.append(modelPackage().replace('.', File.separatorChar)); + return sb.toString(); } @Override diff --git a/modules/openapi-generator/src/main/resources/python/pyproject.mustache b/modules/openapi-generator/src/main/resources/python/pyproject.mustache index 629f16727dfc..96b7113fa667 100644 --- a/modules/openapi-generator/src/main/resources/python/pyproject.mustache +++ b/modules/openapi-generator/src/main/resources/python/pyproject.mustache @@ -29,7 +29,12 @@ repository = "https://{{{gitHost}}}/{{{gitUserId}}}/{{{gitRepoId}}}" {{/poetry1}} keywords = ["OpenAPI", "OpenAPI-Generator", "{{{appName}}}"] {{#poetry1}} -include = ["{{packageName}}/py.typed"] +{{#sourceFolder}} +packages = [ + { include = "{{{packageName}}}", from = "{{{sourceFolder}}}" }, +] +{{/sourceFolder}} +include = ["{{#sourceFolder}}{{{sourceFolder}}}/{{/sourceFolder}}{{packageName}}/py.typed"] [tool.poetry.dependencies] python = "^3.10" @@ -122,7 +127,7 @@ extension-pkg-whitelist = "pydantic" [tool.mypy] files = [ - "{{{packageName}}}", + "{{#sourceFolder}}{{{sourceFolder}}}/{{/sourceFolder}}{{{packageName}}}", #"test", # auto-generated tests "tests", # hand-written tests ] diff --git a/modules/openapi-generator/src/main/resources/python/setup.mustache b/modules/openapi-generator/src/main/resources/python/setup.mustache index 4167134da47f..53763437f7ac 100644 --- a/modules/openapi-generator/src/main/resources/python/setup.mustache +++ b/modules/openapi-generator/src/main/resources/python/setup.mustache @@ -43,7 +43,10 @@ setup( url="{{packageUrl}}", keywords=["OpenAPI", "OpenAPI-Generator", "{{{appName}}}"], install_requires=REQUIRES, - packages=find_packages(exclude=["test", "tests"]), +{{#sourceFolder}} + package_dir={"": "{{{sourceFolder}}}"}, +{{/sourceFolder}} + packages=find_packages({{#sourceFolder}}where="{{{sourceFolder}}}", {{/sourceFolder}}exclude=["test", "tests"]), include_package_data=True, {{#licenseInfo}}license="{{.}}", {{/licenseInfo}}long_description_content_type='text/markdown', diff --git a/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache b/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache index c11fed0ac474..ea9bfd485808 100644 --- a/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache +++ b/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache @@ -188,7 +188,12 @@ impl {{classname}} for {{classname}}Client { {{/isArray}} {{^isArray}} {{^isNullable}} + {{#isMap}} + local_var_req_builder = local_var_req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(&{{{paramName}}})?)]); + {{/isMap}} + {{^isMap}} local_var_req_builder = local_var_req_builder.query(&[("{{{baseName}}}", &{{{paramName}}}.to_string())]); + {{/isMap}} {{/isNullable}} {{#isNullable}} {{#isDeepObject}} @@ -225,9 +230,16 @@ impl {{classname}} for {{classname}}Client { {{/isModel}} {{^isObject}} {{^isModel}} + {{#isMap}} + if let Some(ref param_value) = {{{paramName}}} { + local_var_req_builder = local_var_req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]); + }; + {{/isMap}} + {{^isMap}} if let Some(ref param_value) = {{{paramName}}} { local_var_req_builder = local_var_req_builder.query(&[("{{{baseName}}}", ¶m_value.to_string())]); }; + {{/isMap}} {{/isModel}} {{/isObject}} {{/isDeepObject}} @@ -267,7 +279,12 @@ impl {{classname}} for {{classname}}Client { {{/isModel}} {{^isObject}} {{^isModel}} + {{#isMap}} + local_var_req_builder = local_var_req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]); + {{/isMap}} + {{^isMap}} local_var_req_builder = local_var_req_builder.query(&[("{{{baseName}}}", ¶m_value.to_string())]); + {{/isMap}} {{/isModel}} {{/isObject}} {{/isDeepObject}} diff --git a/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache b/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache index 14e99143c12b..b052f452744a 100644 --- a/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache +++ b/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache @@ -166,7 +166,12 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration: {{/isArray}} {{^isArray}} {{^isNullable}} + {{#isMap}} + req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(&{{{vendorExtensions.x-rust-param-identifier}}})?)]); + {{/isMap}} + {{^isMap}} req_builder = req_builder.query(&[("{{{baseName}}}", &{{{vendorExtensions.x-rust-param-identifier}}}.to_string())]); + {{/isMap}} {{/isNullable}} {{#isNullable}} {{#isDeepObject}} diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/apis.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/apis.mustache index 0980e870e58a..8d939f5e29db 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/apis.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/apis.mustache @@ -94,11 +94,11 @@ export interface {{classname}}Interface { */ {{#hasContentTypeResponseVariants}} {{#contentTypeResponseVariants}} - {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): PromiseapisReturnType}}{{^returnType}}void{{/returnType}}>>; {{/contentTypeResponseVariants}} {{/hasContentTypeResponseVariants}} {{^hasContentTypeResponseVariants}} - {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): PromiseapisReturnType}}{{^returnType}}void{{/returnType}}>>; {{/hasContentTypeResponseVariants}} /** @@ -113,16 +113,16 @@ export interface {{classname}}Interface { {{/isDeprecated}} */ {{^useSingleRequestParameter}} - {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; + {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{>apisReturnType}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; {{/useSingleRequestParameter}} {{#useSingleRequestParameter}} {{#hasContentTypeResponseVariants}} {{#contentTypeResponseVariants}} - {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#returnType}}{{{returnType}}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; + {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#returnType}}{{>apisReturnType}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; {{/contentTypeResponseVariants}} {{/hasContentTypeResponseVariants}} {{^hasContentTypeResponseVariants}} - {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; + {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{>apisReturnType}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; {{/hasContentTypeResponseVariants}} {{/useSingleRequestParameter}} @@ -411,9 +411,9 @@ export class {{classname}} extends runtime.BaseAPI { {{! one overload per response content-type: `accept` selects the return type. The implementation signature unions them and is not callable from outside. }} {{#contentTypeResponseVariants}} - async {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): PromiseapisReturnType}}{{^returnType}}void{{/returnType}}>>; {{/contentTypeResponseVariants}} - async {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + async {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): PromiseapisReturnType}}{{^returnType}}void{{/returnType}}{{^-last}} | {{/-last}}{{/contentTypeResponseVariants}}>> { const requestOptions = await this.{{nickname}}RequestOpts(requestParameters); const response = await this.request(requestOptions, initOverrides); @@ -441,7 +441,7 @@ export class {{classname}} extends runtime.BaseAPI { } {{/hasContentTypeResponseVariants}} {{^hasContentTypeResponseVariants}} - async {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + async {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): PromiseapisReturnType}}{{^returnType}}void{{/returnType}}>> { const requestOptions = await this.{{nickname}}RequestOpts({{#allParams.0}}requestParameters{{/allParams.0}}); const response = await this.request(requestOptions, initOverrides); @@ -498,7 +498,7 @@ export class {{classname}} extends runtime.BaseAPI { {{/isDeprecated}} */ {{^useSingleRequestParameter}} - async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> { + async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{>apisReturnType}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> { {{#returnType}} const response = await this.{{nickname}}Raw({{#allParams.0}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }, {{/allParams.0}}initOverrides); {{#isResponseOptional}} @@ -525,9 +525,9 @@ export class {{classname}} extends runtime.BaseAPI { {{#useSingleRequestParameter}} {{#hasContentTypeResponseVariants}} {{#contentTypeResponseVariants}} - async {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#returnType}}{{{returnType}}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; + async {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#returnType}}{{>apisReturnType}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; {{/contentTypeResponseVariants}} - async {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#contentTypeResponseVariants}}{{{returnType}}}{{^returnType}}void{{/returnType}}{{^-last}} | {{/-last}}{{/contentTypeResponseVariants}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}> { + async {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#contentTypeResponseVariants}}{{>apisReturnType}}{{^returnType}}void{{/returnType}}{{^-last}} | {{/-last}}{{/contentTypeResponseVariants}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}> { {{! `as any`: from the outside only the overloads of ...Raw are visible, and the loose `accept` accepted here matches none of them }} const response = await this.{{nickname}}Raw(requestParameters as any, initOverrides); @@ -551,7 +551,7 @@ export class {{classname}} extends runtime.BaseAPI { } {{/hasContentTypeResponseVariants}} {{^hasContentTypeResponseVariants}} - async {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> { + async {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{>apisReturnType}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> { {{#returnType}} const response = await this.{{nickname}}Raw({{#allParams.0}}requestParameters, {{/allParams.0}}initOverrides); {{#isResponseOptional}} diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/apisReturnType.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/apisReturnType.mustache new file mode 100644 index 000000000000..163a7fea4791 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/apisReturnType.mustache @@ -0,0 +1 @@ +{{! The declared type of an operation's response. A file response is handed back by BlobApiResponse as a HttpFile, which the type mapping alone (file -> Blob, shared with request bodies) cannot express. Inline partial: no newline. }}{{#isResponseFile}}runtime.HttpFile{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}} \ No newline at end of file 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 ea9560de2cdc..58ca2968a407 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache @@ -532,90 +532,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java index 5b7f8c33b8c6..7ae88fae560b 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java @@ -139,6 +139,7 @@ public void testInitialConfigValues() throws Exception { PythonClientCodegen.USE_INDEPENDENT_IMPLICIT_CLIENTS), Boolean.FALSE); Assert.assertEquals(codegen.isHideGenerationTimestamp(), true); + Assert.assertNull(codegen.additionalProperties().get(CodegenConstants.SOURCE_FOLDER)); } @Test @@ -1685,4 +1686,25 @@ public void testIndependentImplicitClientsPreserveDefaultsWhenDisabled() throws "def close(self) -> None:"); TestUtils.assertFileNotContains(rest, "def close(self) -> None:"); } + + @Test( description = "test src-layout with setuptools" ) + public void testSrcLayoutSetuptools() throws IOException { + PythonClientCodegen codegen = new PythonClientCodegen(); + codegen.additionalProperties().put(CodegenConstants.SOURCE_FOLDER, "src"); + final String output = generateFiles(codegen, "src/test/resources/3_0/generic.yaml"); + + final Path setup = Paths.get(output, "setup.py"); + assertFileContains(setup, "package_dir={\"\": \"src\"}", "packages=find_packages(where=\"src\", exclude="); + } + + @Test( description = "test src-layout with poetry1" ) + public void testSrcLayoutPoetry() throws IOException { + PythonClientCodegen codegen = new PythonClientCodegen(); + codegen.additionalProperties().put(CodegenConstants.SOURCE_FOLDER, "src"); + codegen.additionalProperties().put("poetry1", true); + final String output = generateFiles(codegen, "src/test/resources/3_0/generic.yaml"); + + final Path pyproject = Paths.get(output, "pyproject.toml"); + assertFileContains(pyproject, "{ include = \"openapi_client\", from = \"src\" },", "include = [\"src/openapi_client/py.typed\"]", "\"src/openapi_client\","); + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java index 9397d3f9e4a9..5309bd644fd3 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java @@ -449,4 +449,26 @@ private Path generateReqwestConfiguration(String library, boolean supportTokenSo TestUtils.assertFileExists(outputPath); return outputPath; } + + @Test + public void testMapQueryParamsSerializeAsJson() throws IOException { + // HashMap has no Display: map-typed query params must serialize via serde_json::to_string + for (String library : new String[] {"reqwest", "reqwest-trait"}) { + Path target = Files.createTempDirectory("test"); + target.toFile().deleteOnExit(); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust") + .setLibrary(library) + .setInputSpec("src/test/resources/3_0/rust/map-query-params.yaml") + .setSkipOverwrite(false) + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + Path outputPath = Path.of(target.toString(), "/src/apis/default_api.rs"); + TestUtils.assertFileExists(outputPath); + TestUtils.assertFileContains(outputPath, "(\"labels\", &serde_json::to_string(&"); + TestUtils.assertFileContains(outputPath, "(\"counts\", &serde_json::to_string(param_value)?)"); + TestUtils.assertFileNotContains(outputPath, "labels.to_string()"); + TestUtils.assertFileNotContains(outputPath, "param_value.to_string()"); + } + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java index 521196b16af0..bd92f9d4617d 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java @@ -124,7 +124,7 @@ public void testMergesContentTypeVariantsIntoOneMethod() throws IOException { TestUtils.assertFileContains(api, "async createReportRaw(requestParameters: CreateReportRequest & { accept?: 'application/json' }", "async createReportRaw(requestParameters: CreateReportRequest & { accept: 'application/pdf' }", - "Promise>"); + "Promise>"); // Content-Type is set inside the branch that builds the body, Accept defaults to the response // content-type declared first, and deserialisation dispatches on what the server actually returned @@ -218,7 +218,7 @@ public void testMergedOperationKeepsTheOptionalResponseBehaviour() throws IOExce Path api = Paths.get(output + "/apis/ReportApi.ts"); TestUtils.assertFileContains(api, "async getReport(requestParameters: GetReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise;", - "async getReport(requestParameters: GetReportRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise;", + "async getReport(requestParameters: GetReportRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise;", "switch (response.raw.status) {", " case 204:", " return null;"); diff --git a/modules/openapi-generator/src/test/resources/3_0/rust/map-query-params.yaml b/modules/openapi-generator/src/test/resources/3_0/rust/map-query-params.yaml new file mode 100644 index 000000000000..804b62d63775 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/rust/map-query-params.yaml @@ -0,0 +1,25 @@ +openapi: 3.0.3 +info: + title: map query params + version: 1.0.0 +paths: + /items: + get: + operationId: listItems + parameters: + - name: labels + in: query + required: true + schema: + type: object + additionalProperties: + type: string + - name: counts + in: query + schema: + type: object + additionalProperties: + type: integer + responses: + '200': + description: ok diff --git a/samples/client/others/rust/reqwest-deep-object-free-form-query-param/src/apis/configuration.rs b/samples/client/others/rust/reqwest-deep-object-free-form-query-param/src/apis/configuration.rs index d9be07bb2c4d..3ad82372c2ba 100644 --- a/samples/client/others/rust/reqwest-deep-object-free-form-query-param/src/apis/configuration.rs +++ b/samples/client/others/rust/reqwest-deep-object-free-form-query-param/src/apis/configuration.rs @@ -11,7 +11,7 @@ pub use reqwest::blocking::Client; -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct Configuration { pub base_path: String, pub user_agent: Option, @@ -24,12 +24,37 @@ pub struct Configuration { pub type BasicAuth = (String, Option); -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ApiKey { pub prefix: Option, pub key: String, } +impl std::fmt::Debug for ApiKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ApiKey") + .field("prefix", &self.prefix) + .field("key", &"[REDACTED]") + .finish() + } +} + + +impl std::fmt::Debug for Configuration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut debug = f.debug_struct("Configuration"); + debug + .field("base_path", &self.base_path) + .field("user_agent", &self.user_agent) + .field("client", &self.client); + debug + .field("basic_auth", &self.basic_auth.as_ref().map(|(username, password)| (username, password.as_ref().map(|_| "[REDACTED]")))) + .field("oauth_access_token", &self.oauth_access_token.as_ref().map(|_| "[REDACTED]")) + .field("bearer_access_token", &self.bearer_access_token.as_ref().map(|_| "[REDACTED]")) + .field("api_key", &self.api_key); + debug.finish() + } +} impl Configuration { pub fn new() -> Configuration { 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 e938f71a92b9..c6ed251b425d 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 @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 e938f71a92b9..c6ed251b425d 100644 --- a/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 0f2a7cc53e43..039f421fbeaa 100644 --- a/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts +++ b/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 8fc35f778705..225a831e41aa 100644 --- a/samples/client/others/typescript-fetch/self-import-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/self-import-issue/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 21a63f57d6d1..fd193cf50c97 100644 --- a/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 21a63f57d6d1..fd193cf50c97 100644 --- a/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 5c3911a68825..7c839525ff4f 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 @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 f3b6f5b34a85..61c0f2f8f9b4 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 @@ -449,90 +449,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 b26dcead9923..bf23039d1303 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 @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/default/runtime.ts b/samples/client/petstore/typescript-fetch/builds/default/runtime.ts index a767667e3760..b78ab39fffb6 100644 --- a/samples/client/petstore/typescript-fetch/builds/default/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/default/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts b/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts index fde0f53ecd3a..bad055c741cb 100644 --- a/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 a767667e3760..b78ab39fffb6 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 @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 b26dcead9923..bf23039d1303 100644 --- a/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 a767667e3760..b78ab39fffb6 100644 --- a/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts b/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts index b433ba535991..efa90986847c 100644 --- a/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 a767667e3760..b78ab39fffb6 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 @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 a767667e3760..b78ab39fffb6 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 @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 b26dcead9923..bf23039d1303 100644 --- a/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/package-lock.json b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/package-lock.json new file mode 100644 index 000000000000..d382eade40c5 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "@openapitools/typescript-fetch-split-by-content-type", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openapitools/typescript-fetch-split-by-content-type", + "version": "1.0.0", + "devDependencies": { + "typescript": "^4.0 || ^5.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/FilesApi.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/FilesApi.ts index 0c786c5dcd5c..d625a89664ff 100644 --- a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/FilesApi.ts +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/FilesApi.ts @@ -151,8 +151,8 @@ export class FilesApi extends runtime.BaseAPI { /** */ async uploadRaw(requestParameters: UploadRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; - async uploadRaw(requestParameters: UploadRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; - async uploadRaw(requestParameters: UploadRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + async uploadRaw(requestParameters: UploadRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async uploadRaw(requestParameters: UploadRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.uploadRequestOpts(requestParameters); const response = await this.request(requestOptions, initOverrides); @@ -167,8 +167,8 @@ export class FilesApi extends runtime.BaseAPI { /** */ async upload(requestParameters: UploadRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; - async upload(requestParameters: UploadRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; - async upload(requestParameters: UploadRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + async upload(requestParameters: UploadRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async upload(requestParameters: UploadRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { const response = await this.uploadRaw(requestParameters as any, initOverrides); return await response.value(); } diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/OrderApi.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/OrderApi.ts index 2b5fc1bda49e..0f5b195b9b0b 100644 --- a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/OrderApi.ts +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/OrderApi.ts @@ -56,8 +56,8 @@ export class OrderApi extends runtime.BaseAPI { /** */ async getOrdersRaw(requestParameters: GetOrdersRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; - async getOrdersRaw(requestParameters: GetOrdersRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; - async getOrdersRaw(requestParameters: GetOrdersRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + async getOrdersRaw(requestParameters: GetOrdersRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async getOrdersRaw(requestParameters: GetOrdersRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.getOrdersRequestOpts(requestParameters); const response = await this.request(requestOptions, initOverrides); @@ -72,8 +72,8 @@ export class OrderApi extends runtime.BaseAPI { /** */ async getOrders(requestParameters: GetOrdersRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; - async getOrders(requestParameters: GetOrdersRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; - async getOrders(requestParameters: GetOrdersRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + async getOrders(requestParameters: GetOrdersRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async getOrders(requestParameters: GetOrdersRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { const response = await this.getOrdersRaw(requestParameters as any, initOverrides); return await response.value(); } diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/ReportApi.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/ReportApi.ts index 0cb4e28ae8b4..c553224c0c10 100644 --- a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/ReportApi.ts +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/ReportApi.ts @@ -81,8 +81,8 @@ export class ReportApi extends runtime.BaseAPI { /** */ async createReportRaw(requestParameters: CreateReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; - async createReportRaw(requestParameters: CreateReportRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; - async createReportRaw(requestParameters: CreateReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + async createReportRaw(requestParameters: CreateReportRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async createReportRaw(requestParameters: CreateReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.createReportRequestOpts(requestParameters); const response = await this.request(requestOptions, initOverrides); @@ -97,8 +97,8 @@ export class ReportApi extends runtime.BaseAPI { /** */ async createReport(requestParameters: CreateReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; - async createReport(requestParameters: CreateReportRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; - async createReport(requestParameters: CreateReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + async createReport(requestParameters: CreateReportRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async createReport(requestParameters: CreateReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { const response = await this.createReportRaw(requestParameters as any, initOverrides); return await response.value(); } @@ -135,8 +135,8 @@ export class ReportApi extends runtime.BaseAPI { /** */ async getReportRaw(requestParameters: GetReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; - async getReportRaw(requestParameters: GetReportRequest & { accept: 'application/directlog' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; - async getReportRaw(requestParameters: GetReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + async getReportRaw(requestParameters: GetReportRequest & { accept: 'application/directlog' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async getReportRaw(requestParameters: GetReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.getReportRequestOpts(requestParameters); const response = await this.request(requestOptions, initOverrides); @@ -151,8 +151,8 @@ export class ReportApi extends runtime.BaseAPI { /** */ async getReport(requestParameters: GetReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; - async getReport(requestParameters: GetReportRequest & { accept: 'application/directlog' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; - async getReport(requestParameters: GetReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + async getReport(requestParameters: GetReportRequest & { accept: 'application/directlog' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async getReport(requestParameters: GetReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { const response = await this.getReportRaw(requestParameters as any, initOverrides); return await response.value(); } 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 14201ca4c56f..021b6580e001 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 @@ -501,90 +501,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 a767667e3760..b78ab39fffb6 100644 --- a/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 a767667e3760..b78ab39fffb6 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 a767667e3760..b78ab39fffb6 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 @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 fde0f53ecd3a..bad055c741cb 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 @@ -488,90 +488,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { 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 f084e16504a1..5ae2ac53347a 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 @@ -437,90 +437,79 @@ export class VoidApiResponse { } } +/** + * A Blob carrying the file name the server advertised, empty when it did not send one. It is a File + * wherever one can be built, and the bare Blob given a name otherwise. Same name and shape as the + * `typescript` generator's browser HttpFile. + */ +export type HttpFile = Blob & { readonly name: string }; + export class BlobApiResponse { constructor(public raw: Response) {} /** - * 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. + * The body named after the Content-Disposition header, so that a download keeps the name the + * server gave it. A File is built from a native Blob; the Blob itself is named where there is no + * global File (Node.js before 20), where File cannot be constructed, or where the Blob is not the + * native one, as returned by an injected fetch implementation such as node-fetch, which a File + * would stringify instead of wrapping. */ - async value(): Promise { + async value(): Promise { const blob = await this.raw.blob(); - if (typeof File === 'undefined') { - return blob as File; + const name = parseContentDispositionFilename(this.raw.headers) ?? ''; + if (typeof File !== 'undefined' && blob instanceof Blob) { + try { + return new File([blob], name, { type: blob.type }); + } catch { + // File cannot be constructed: name the Blob itself + } } - return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); + return Object.assign(blob, { name }); }; } /** * 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. + * `filename*` parameter first, falling back to the plain `filename` when it is malformed, not UTF-8 + * or has no usable name. Any directory part is dropped; undefined when no usable name is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { - const value = headers.get('Content-Disposition'); - if (!value) { + const params = parseHeaderParameters(headers.get('Content-Disposition') ?? ''); + return basename(decodeExtValue(params.get('filename*'))) ?? basename(params.get('filename')); +} + +/** The decoded RFC 5987 ext-value, or undefined when it is not UTF-8 or its percent-encoding is malformed. */ +function decodeExtValue(value: string | undefined): string | undefined { + const match = value === undefined ? null : /^utf-8'[^']*'(.*)$/i.exec(value); + try { + return match ? decodeURIComponent(match[1]) : undefined; + } catch { 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. + * The `name=value` parameters of a header value, consumed one at a time so that a quoted-string is + * taken whole, `;` included. 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++; + const parameter = /(?:^|;)\s*([^=;]+?)\s*=\s*(?:"((?:[^"\\]|\\.)*)"?|([^;]*))/g; + let match: RegExpExecArray | null; + while ((match = parameter.exec(value)) !== null) { + const name = match[1].toLowerCase(); + if (!params.has(name)) { + params.set(name, match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].trim()); } } 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; +/** The last `/` or `\` separated segment of a file name, or undefined when it is not a file name. */ +function basename(name: string | undefined): string | undefined { + const base = name?.split(/[\\/]/).pop(); + return !base || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts b/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts index 5d0ad7af51ff..a24eb417ab47 100644 --- a/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts +++ b/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts @@ -1,4 +1,5 @@ import { expect } from 'chai'; +import { Response as NodeFetchResponse } from 'node-fetch'; import { BlobApiResponse, parseContentDispositionFilename } from '@swagger/typescript-fetch-petstore'; describe('parseContentDispositionFilename', () => { @@ -15,6 +16,7 @@ describe('parseContentDispositionFilename', () => { expect(nameOf()).to.be.undefined; expect(nameOf('inline')).to.be.undefined; expect(nameOf('attachment; name="field"')).to.be.undefined; + expect(nameOf('attachment; filename=""')).to.be.undefined; }); it('should read a token and a quoted-string filename', () => { @@ -26,6 +28,13 @@ describe('parseContentDispositionFilename', () => { 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'); + expect(nameOf('attachment; filename=" spaced.pdf "')).to.equal(' spaced.pdf '); + }); + + it('should tolerate a malformed quoted-string', () => { + expect(nameOf('attachment; filename="a.pdf" ignored')).to.equal('a.pdf'); + expect(nameOf('attachment; filename="unterminated.pdf')).to.equal('unterminated.pdf'); + expect(nameOf('attachment; filename="report.pdf\\')).to.equal('report.pdf'); }); it('should prefer the RFC 5987 encoded form and decode it', () => { @@ -34,8 +43,11 @@ describe('parseContentDispositionFilename', () => { 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', () => { + it('should fall back to the plain form when the encoded one is malformed or has no usable name', () => { expect(nameOf("attachment; filename*=UTF-8''%E0%A4%A; filename=\"fallback.pdf\"")).to.equal('fallback.pdf'); + expect(nameOf("attachment; filename*=UTF-8''..%2F; filename=\"fallback.pdf\"")).to.equal('fallback.pdf'); + expect(nameOf("attachment; filename*=UTF-8''; filename=\"fallback.pdf\"")).to.equal('fallback.pdf'); + expect(nameOf("attachment; filename*=UTF-8''..%2F")).to.be.undefined; }); it('should match parameter names case-insensitively', () => { @@ -43,12 +55,9 @@ describe('parseContentDispositionFilename', () => { expect(nameOf("attachment; FILENAME*=utf-8''upper.pdf")).to.equal('upper.pdf'); }); - it('should only match filename as a parameter name', () => { + it('should only match filename as a parameter name, even inside another quoted value', () => { 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'); }); @@ -68,18 +77,20 @@ describe('parseContentDispositionFilename', () => { 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', { + function namedResponse(): Response { + return 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); + } + + it('should return a File named after the Content-Disposition header', async () => { + const file = await new BlobApiResponse(namedResponse()).value(); + expect(file).to.be.an.instanceOf(File); expect(file.name).to.equal('named.txt'); expect(file.type).to.equal('text/plain'); expect(await file.text()).to.equal('content'); @@ -90,14 +101,38 @@ describe('BlobApiResponse', () => { expect(file.name).to.equal(''); }); - it('should keep returning the bare Blob where File is not a global (Node.js before 20)', async () => { + it('should name a Blob that is not the native one without wrapping it (injected fetch implementation)', async () => { + const response = new NodeFetchResponse('content', { + headers: { 'Content-Disposition': 'attachment; filename="named.txt"' }, + }); + const value = await new BlobApiResponse(response as any).value(); + expect(value).not.to.be.an.instanceOf(Blob); + expect(value.name).to.equal('named.txt'); + expect(await value.text()).to.equal('content'); + }); + + it('should name the bare Blob where File cannot be constructed', async () => { + const globals = global as any; + const nativeFile = globals.File; + globals.File = function () { throw new TypeError('Illegal constructor'); }; + try { + const value = await new BlobApiResponse(namedResponse()).value(); + expect(value).to.be.an.instanceOf(Blob); + expect(value.name).to.equal('named.txt'); + expect(await value.text()).to.equal('content'); + } finally { + globals.File = nativeFile; + } + }); + + it('should name 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(); + const value = await new BlobApiResponse(namedResponse()).value(); expect(value).to.be.an.instanceOf(Blob); - expect((value as any).name).to.be.undefined; + expect(value.name).to.equal('named.txt'); expect(await value.text()).to.equal('content'); } finally { globals.File = nativeFile;