From ac5d8971e3e5128669b4b3bb5415a7f90770094e Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Mon, 28 Sep 2026 16:50:01 +0200 Subject: [PATCH 1/4] Add source folder variable to Python Client (#24980) Currently the Python client generator generates a "flat layout", where the generated Python module is directly below the project root. This is a problem for editable installs as tools like `uv` have to generate a complicated "finder" to load the modules. Several tools like `mypy` and `ty` fail to use it as they implement their own "import mechanism" incompatible with the generated finder. This can be mitigated by switching to the "src layout", where all files live in the sub-directory "src". Tools like `uv` then only have to place the absolute path in a `.pth` file, which also works with `mypy` and `ty`. Similar to https://github.com/OpenAPITools/openapi-generator/pull/12119 but in the reverse add a new configuration option "sourceFolder" to make the base folder configurable. For backward compatibility it is unset by default, which generates the previous "flat layout". Set it to "src" to generate the "src layout". Link: https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/ Signed-off-by: Philipp Hahn Co-authored-by: Philipp Hahn --- bin/configs/python.yaml | 1 + docs/generators/python.md | 1 + .../languages/PythonClientCodegen.java | 57 +++++++++++++------ .../main/resources/python/pyproject.mustache | 9 ++- .../src/main/resources/python/setup.mustache | 5 +- .../python/PythonClientCodegenTest.java | 22 +++++++ 6 files changed, 74 insertions(+), 21 deletions(-) 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/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\","); + } } From 9a5824a8da40bd99cbb3b22146e23fc9f87acb06 Mon Sep 17 00:00:00 2001 From: William Cheng Date: Mon, 28 Sep 2026 22:59:07 +0800 Subject: [PATCH 2/4] update rust samples --- .../src/apis/configuration.rs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) 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 { From 64d94d31cf0199cd752d8fd91b498dee968bd251 Mon Sep 17 00:00:00 2001 From: AntoineDuComptoirDesPharmacies <104082030+AntoineDuComptoirDesPharmacies@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:29:50 +0200 Subject: [PATCH 3/4] [typescript-fetch] BlobApiResponse returns an HttpFile, keeps a non-native Blob intact, tokenizes Content-Disposition (#24974) * [typescript-fetch] BlobApiResponse returns a NamedBlob, keeps a non-native Blob intact, tokenizes Content-Disposition Follow-up to #24957 (#24956). - `BlobApiResponse.value()` wrapped whatever `raw.blob()` returned in `new File([blob])`: a Blob that is not the native one, as returned by an injected fetch implementation such as node-fetch, was stringified into a 13-byte "[object Blob]" body. The File is now built only from a native Blob where File exists and can be constructed; otherwise the Blob itself is given the name. - `value()` is typed `Promise` (`Blob & { name: string }`) instead of `Promise`, which the fallback branches did not honour. Operations with a file response are typed `runtime.NamedBlob` through the new `apisReturnType.mustache` partial, so callers reach `.name` without a cast. - Content-Disposition parameters are tokenized by a single global regex that consumes one `name=value` at a time, replacing the character loop: a quoted-string is taken whole, junk after a closing quote and a trailing backslash are tolerated. - Unit tests for the injected-fetch Blob, a non-constructible File and malformed quoted-strings. * [typescript-fetch] name the file response type HttpFile, as the typescript generator does Same name and shape (`Blob & { readonly name: string }`) as the browser HttpFile of the `typescript` generator, so the two TypeScript generators agree on what a file response is. * [typescript-fetch] drop the tautological instanceOf assertions from the BlobApiResponse fallback tests * [typescript-fetch] align the codegen test with the HttpFile response type * [typescript-fetch] HttpFile doc comment: the equivalence with the typescript generator holds for its browser variant * [typescript-fetch] BlobApiResponse: guard only the File construction with the try --- .../resources/typescript-fetch/apis.mustache | 24 ++-- .../typescript-fetch/apisReturnType.mustache | 1 + .../typescript-fetch/runtime.mustache | 103 ++++++++---------- .../TypeScriptFetchClientCodegenTest.java | 4 +- .../runtime.ts | 103 ++++++++---------- .../infinite-recursion-issue/runtime.ts | 103 ++++++++---------- .../multipart-file-array/runtime.ts | 103 ++++++++---------- .../self-import-issue/runtime.ts | 103 ++++++++---------- .../builds/allOf-nullable/runtime.ts | 103 ++++++++---------- .../builds/allOf-readonly/runtime.ts | 103 ++++++++---------- .../builds/date-library-date/src/runtime.ts | 103 ++++++++---------- .../builds/date-library-string/src/runtime.ts | 103 ++++++++---------- .../builds/default-v3.0/runtime.ts | 103 ++++++++---------- .../builds/default/runtime.ts | 103 ++++++++---------- .../typescript-fetch/builds/enum/runtime.ts | 103 ++++++++---------- .../builds/es6-target/src/runtime.ts | 103 ++++++++---------- .../builds/kebab-case/runtime.ts | 103 ++++++++---------- .../builds/multiple-parameters/runtime.ts | 103 ++++++++---------- .../typescript-fetch/builds/oneOf/runtime.ts | 103 ++++++++---------- .../src/runtime.ts | 103 ++++++++---------- .../builds/sagas-and-records/src/runtime.ts | 103 ++++++++---------- .../builds/snakecase-discriminator/runtime.ts | 103 ++++++++---------- .../split-by-content-type/package-lock.json | 29 +++++ .../src/apis/FilesApi.ts | 8 +- .../src/apis/OrderApi.ts | 8 +- .../src/apis/ReportApi.ts | 16 +-- .../split-by-content-type/src/runtime.ts | 103 ++++++++---------- .../builds/validation-attributes/runtime.ts | 103 ++++++++---------- .../builds/with-interfaces/runtime.ts | 103 ++++++++---------- .../builds/with-npm-version/src/runtime.ts | 103 ++++++++---------- .../builds/with-string-enums/runtime.ts | 103 ++++++++---------- .../without-runtime-checks/src/runtime.ts | 103 ++++++++---------- .../tests/default/test/BlobApiResponse.ts | 61 ++++++++--- 33 files changed, 1258 insertions(+), 1468 deletions(-) create mode 100644 modules/openapi-generator/src/main/resources/typescript-fetch/apisReturnType.mustache create mode 100644 samples/client/petstore/typescript-fetch/builds/split-by-content-type/package-lock.json 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/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/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; From 92d5b73589222769dc54a29e7b5146d88cb8a427 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Mon, 28 Sep 2026 17:31:00 +0200 Subject: [PATCH 4/4] [rust] fix: map-typed query parameters do not compile (.to_string() on a HashMap) (#24896) * [rust] fix: map-typed query parameters called .to_string() on a HashMap HashMap implements neither Display nor ToString, so the generated client did not compile (E0599) wherever a map-typed query parameter fell through to the scalar path: a required non-nullable map in both reqwest and reqwest-trait, and the nullable/optional non-deepObject fallthroughs in reqwest-trait. Serialize the map as one json-encoded parameter with serde_json::to_string, exactly how reqwest's optional branch already handles non-primitive parameters. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GcwZ1arjLZNpetHz2a3TJz * [rust] test: pin the required map-query assertion to its parameter `serde_json::to_string(&` matched any call in the file; match on the `labels` key instead, which both libraries emit. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Fable 5 --- .../resources/rust/reqwest-trait/api.mustache | 17 +++++++++++++ .../main/resources/rust/reqwest/api.mustache | 5 ++++ .../codegen/rust/RustClientCodegenTest.java | 22 ++++++++++++++++ .../resources/3_0/rust/map-query-params.yaml | 25 +++++++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 modules/openapi-generator/src/test/resources/3_0/rust/map-query-params.yaml 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/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/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