Repository navigation
Conversation
sserrata
left a comment
There was a problem hiding this comment.
Thanks. Using the server filename is useful, but several common cases are still not covered. Please address the inline items.
Other items (outside the diff):
packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/Request/makeRequest.ts: A download starts only for 11 hard-coded types.attachmentwithtext/csvor.xlsxdoes not download, and the binary goes into the text pane. Start a download when the disposition type isattachment.packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/Request/makeRequest.ts: Existing bug, but this feature makes it visible: the body is read withblob(), thenhandleResponsecallsres.text()on the same Response. That throws, and the explorer shows "Connection failed" after a successful download.packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/Request/makeRequest.ts: The object URL is never revoked. CallURL.revokeObjectURL(url)in thesetTimeout.
| link.setAttribute( | ||
| "download", | ||
| getDownloadFilename( | ||
| response.headers.get("content-disposition"), |
There was a problem hiding this comment.
Content-Disposition is not a CORS-safelisted header. For cross-origin APIs, headers.get returns null unless the server sends Access-Control-Expose-Headers: Content-Disposition, so most explorer users do not get the fix. Please document this requirement.
| link.href = url; | ||
| // Now the file name includes the extension | ||
| link.setAttribute("download", `file${fileExtension}`); | ||
| link.setAttribute( |
There was a problem hiding this comment.
If the server filename has no extension (filename=report), the .pdf from Content-Type is lost. Add fileExtension when the parsed name has no extension.
| ): string { | ||
| if (!header) return fallback; | ||
| try { | ||
| const filename = parse(header).parameters.filename; |
There was a problem hiding this comment.
parse() throws for the full header when one parameter is bad. Then a valid filename=report.pdf is discarded when filename* is malformed. RFC 6266 says to fall back to filename.
| try { | ||
| const filename = parse(header).parameters.filename; | ||
| // eslint-disable-next-line no-control-regex | ||
| if (!filename || /[\u0000-\u001f\u007f]/.test(filename)) return fallback; |
There was a problem hiding this comment.
Only C0 control characters and DEL are removed. Bidi overrides (U+202E, U+2066–2069) get through, so invoice\u202Efdp.exe can show as invoiceexe.pdf. Also remove \p{Cf}.
| "allof-merge": "^0.6.6", | ||
| "buffer": "^6.0.3", | ||
| "clsx": "^2.1.1", | ||
| "content-disposition": "^0.5.4", |
There was a problem hiding this comment.
content-disposition@0.5.4 needs safe-buffer and path, so the client bundle gets polyfills to parse one header. Use a small browser-native parser, or 1.x.
sserrata
left a comment
There was a problem hiding this comment.
Thanks for the update. Attachments of any type now download, and the object URL is revoked. Some new problems must be fixed before merge:
| const extension = | ||
| fileExtension || attachmentExtensions[contentType ?? ""] || ""; | ||
| // Keep the original response readable by the explorer's response handler. | ||
| const blob = await response.clone().blob(); |
There was a problem hiding this comment.
Regression: with response.clone(), handleResponse now calls res.text() on every binary download and puts the decoded bytes in Redux and the response pane. A 200 MB video or ZIP is stored two times and decoded as text, which can freeze the tab. Return a short placeholder for downloaded bodies, and do not decode them. Also, blob() never resolves for a streaming response (text/event-stream or a chunked stream) that has attachment, so the explorer stays on "Fetching...". Exclude streams.
| }; | ||
| const fileExtension = binaryExtensions[contentType ?? ""] ?? ""; | ||
|
|
||
| if (fileExtension || attachment) { |
There was a problem hiding this comment.
Responses that are not OK (401, 500) that have attachment are also downloaded, so an error body can look like a successful download. Check response.ok first.
| } else { | ||
| return response; | ||
| } | ||
| setTimeout(() => { |
There was a problem hiding this comment.
Revoking at 0 ms can cancel the download in Firefox and Safari, mostly for large files. Wait longer (for example, about 1 s).
| while (index < header.length) { | ||
| parameter.lastIndex = index; | ||
| const match = parameter.exec(header); | ||
| if (!match) return fallback; |
There was a problem hiding this comment.
A trailing ; (filename=report.pdf;) or a token that is not strictly valid (report (1).pdf, résumé.pdf) makes the full parse fail, and the file is named file.pdf. Browsers accept these headers. Parse leniently.
|
|
||
| link.click(); | ||
| const contentType = response.headers | ||
| .get("content-type") |
There was a problem hiding this comment.
The exact essence match does not match a duplicated header that a proxy merged (application/pdf, application/pdf), but includes matched it before. Use the first item of the list.
| .pop() | ||
| ?.trim(); | ||
| if (!basename || basename === "." || basename === "..") return fallback; | ||
| return /\.[^.]+$/.test(basename) ? basename : basename + extension; |
There was a problem hiding this comment.
/\.[^.]+$/ treats report.v2 or export.2024-10 as having an extension, so the Content-Type extension is not added.
| ]) | ||
| ); | ||
| type TestDom = { window: Window & typeof globalThis }; | ||
| const { JSDOM } = jest.requireActual("jsdom") as { |
There was a problem hiding this comment.
jsdom is not a declared dependency. It comes only from jest-environment-jsdom@27. Use the test environment's document, or add the dependency.
| .toLowerCase(); | ||
| const disposition = response.headers.get("content-disposition"); | ||
| const attachment = isAttachment(disposition); | ||
| const binaryExtensions: Record<string, string> = { |
There was a problem hiding this comment.
Please put the MIME-to-extension maps in one map at module level. Also, please revert the formatting changes that are not related to the fix (RequestErrorType, the quotes in demo/docs/advanced.mdx).
sserrata
left a comment
There was a problem hiding this comment.
Thanks. All items from the last review are fixed. One small item remains before merge.
| return ( | ||
| response.body !== null && | ||
| (getContentType(response) === "text/event-stream" || | ||
| (response.headers |
There was a problem hiding this comment.
Possible regression: any response with a readable Transfer-Encoding: chunked header is treated as a stream. A chunked binary response (for example, a generated PDF over HTTP/1.1 through the proxy, or same-origin) then goes to handleEventStream, which decodes it into the response pane. On main, it downloads. Please detect streams by text/event-stream only, or check chunked only when the response is not a download.
|
This PR now conflicts with |
Fixes #733.
Use the browser-visible Content-Disposition filename for successful attachment downloads, including lenient quoted/plain names, extended encodings and versioned basenames. Keep path/control-character protection and MIME-based defaults. The filename parser adds no dependency or Node polyfill.
Consume a downloaded body once as a Blob and display a short placeholder instead of decoding binary bytes into the response pane/Redux. Error responses stay visible. Only readable text/event-stream responses use the incremental reader; Transfer-Encoding: chunked does not identify a stream, so chunked PDF responses still download. Keep object URLs alive for 1 second after the click. Cross-origin filename headers must still be exposed by the API.
Include the main-branch changes through #1677. With the same final 79-case Request fixture, the previous stream guard fails the two chunked-PDF cases and passes 77 controls; the fixed guard passes all 79, including genuine SSE cases. Full
yarn testpasses 386 tests across 17 suites, with 35 snapshots on Node 26.5.0/macOS. All three packages build without cache; strict changed-file ESLint, Prettier and diff checks pass.Required full
yarn lintstill exits 2 at the existing Cypresscypress/globalsconfiguration error. Tests use native Response bodies and Jest DOM, with fetch delivery, object URLs and clicks substituted; no browser/CORS or hosted CI matrix was run.