Skip to content

Fix: Headers not forwarded to clients, incorrect Content-Type#31

Open
KevinPayravi wants to merge 3 commits into
mainfrom
fix-headers
Open

Fix: Headers not forwarded to clients, incorrect Content-Type#31
KevinPayravi wants to merge 3 commits into
mainfrom
fix-headers

Conversation

@KevinPayravi
Copy link
Copy Markdown
Member

@KevinPayravi KevinPayravi commented May 9, 2026

This PR resolves two issues related to incorrect thumbnail headers.

Header Forwarding

When opening a thumbnail (dp.la/thumb/...), I noticed that the browser prompts me to download the file instead of displaying it. Looking closer, I noticed the thumbnails are missing a Content-Type header (which is one of the two headers we intend to send alongside Last-Modified).

ResponseHelper.getHeadersFromTarget was returning a Map<string, string>, but Express's res.set() requires a plain object. Passing a Map caused Object.keys() to return an empty array, so no headers were ever set on the response. So our intended Content-Type and Last-Modified headers would be dropped.

This is fixed by changing getHeadersFromTarget to return Record<string, string> instead of Map<string, string>. I've also updated the corresponding tests, which masked the issue because they called .get() directly on the returned Map (which works), and the ThumbnailApi tests used a jest-express mock for res.set() that doesn't enforce Express's plain-object requirement.

Incorrect Content-Type

With the above fix, our thumbnail API started providing Content-Type, but it was application/octet-stream instead of image/jpeg. The issue is that our thumbnails are stored in S3 without ContentType set, so they default to application/octet-stream. I've opened a PR in our thumbnailer lambda to fix this: dpla/thumbnailer-lambda#12

As a stopgap, this PR overrides the Content-Type from S3 with value image/jpeg.

Testing

Thumbnail headers before:

HTTP/1.1 200 OK
Date: Sat, 09 May 2026 03:06:49 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

Thumbnail headers after:

HTTP/1.1 200 OK
Content-Type: image/jpeg
Last-Modified: Wed, 20 Nov 2024 14:03:09 GMT
Date: Sat, 09 May 2026 03:05:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

Overview

This PR fixes two header-related issues in the thumbnail API:

  1. Headers not being forwarded to clients: ResponseHelper.getHeadersFromTarget() was returning a Map<string, string>, but Express's res.set() requires a plain object. When passed a Map, Object.keys() returned an empty array, causing no headers to be set. The method now returns Record<string, string> instead.

  2. Incorrect Content-Type for S3 thumbnails: Thumbnails stored in S3 lack ContentType metadata and S3 returns application/octet-stream. As a stopgap (pending a fix in the thumbnailer lambda), the API now overrides the S3-derived Content-Type to image/jpeg before responding to clients.

Changes

  • ResponseHelper.ts: Updated getHeadersFromTarget() to return a plain object instead of a Map, with logic refactored to build the object by copying only Content-Type and Last-Modified headers when present.
  • ThumbnailApi.ts: serveItemFromS3() now explicitly sets Content-Type to image/jpeg before forwarding headers to the client.
  • Tests: Updated unit tests for both files to reflect the return type change from Map to plain object, ensuring tests verify correct header presence.

API Response Changes

This PR changes the public-facing API response by fixing header forwarding—responses will now include Content-Type: image/jpeg and Last-Modified headers that were previously not being sent to clients due to the Map/plain-object incompatibility bug.

Notes

  • The Content-Type override is marked as a temporary measure; a permanent fix depends on Add ContentType to s3.send thumbnailer-lambda#12.
  • No environment variables, infrastructure configuration, database migrations, or new endpoints are included.
  • No manual deployment steps or pipeline triggers are required beyond normal CI/CD processes.

Review Change Stack

All thumbnails in S3 are stored as .jpg, but may not be stored with the correct content type, causing S3 to return application/octet-stream as the default value. This overrides the Content-Type to ensure clients always receive type image/jpeg.
@KevinPayravi KevinPayravi requested a review from DominicBM May 9, 2026 03:13
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 9, 2026

Walkthrough

ResponseHelper.getHeadersFromTarget is refactored to return a plain object instead of a Map, selecting only Content-Type and Last-Modified headers. ThumbnailApi.serveItemFromS3 now enforces Content-Type as image/jpeg when serving S3 thumbnails. All related unit tests are updated to match the new return type.

Changes

Header Type Refactoring

Layer / File(s) Summary
Return Type Contract
src/ResponseHelper.ts
getHeadersFromTarget return type changed from Map<string, string> to Record<string, string>, with implementation filtering only Content-Type and Last-Modified headers into a plain object.
Content-Type Enforcement
src/ThumbnailApi.ts
serveItemFromS3 extracts headers via getHeadersFromTarget, then explicitly sets Content-Type to image/jpeg before applying response headers.
Test Updates
test/unit/ResponseHelper.test.ts, test/unit/ThumbnailApi.test.ts
ResponseHelper unit tests verify bracket-notation access to header values; ThumbnailApi test mocks updated to return plain objects instead of Maps across error-path and async test cases.

Possibly related PRs

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly addresses the two main issues fixed: headers not being forwarded to clients and incorrect Content-Type, which align with the primary changes in ResponseHelper and ThumbnailApi.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-headers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/ResponseHelper.ts (1)

98-106: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Change getCacheHeaders return type from Map<string, string> to Record<string, string>.

getCacheHeaders is passed to expressResponse.set() at lines 80 and 144 in src/ThumbnailApi.ts. Express's res.set() expects a plain object and uses Object.keys() / enumerable properties internally, which doesn't work with Maps. This matches the pattern already established by getHeadersFromTarget, which returns Record<string, string> for the same purpose.

🔧 Proposed fix
-getCacheHeaders(seconds: number): Map<string, string> {
+getCacheHeaders(seconds: number): Record<string, string> {
   const now = Date.now();
   const expirationDateString = new Date(now + 1000 * seconds).toUTCString();
   const cacheControl = `public, max-age=${String(seconds)}`;
-  return new Map([
-    ["Cache-Control", cacheControl],
-    ["Expires", expirationDateString],
-  ]);
+  return {
+    "Cache-Control": cacheControl,
+    "Expires": expirationDateString,
+  };
 }

And update the test in test/unit/ResponseHelper.test.ts:

 test("getCacheHeaders", () => {
   const result = responseHelper.getCacheHeaders(123);
-  expect(result.get("Cache-Control")).toBe("public, max-age=123");
-  const expires = result.get("Expires");
+  expect(result["Cache-Control"]).toBe("public, max-age=123");
+  const expires = result["Expires"];
   expect(expires).toBeDefined();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ResponseHelper.ts` around lines 98 - 106, The getCacheHeaders function
currently returns a Map which is incompatible with expressResponse.set (used
from ThumbnailApi at the places where getCacheHeaders is passed); change
getCacheHeaders's return type from Map<string, string> to Record<string,
string>, build and return a plain object with the same keys ("Cache-Control" and
"Expires") instead of a Map, and update the corresponding unit test in
test/unit/ResponseHelper.test.ts to expect a Record<string,string> (plain
object) instead of a Map; ensure callers that pass the result to
expressResponse.set still work without changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/ThumbnailApi.ts`:
- Around line 159-165: Add a unit test that verifies
ThumbnailApi.serveItemFromS3 overrides Content-Type to image/jpeg: mock
ResponseHelper.getRemoteImagePromise to return a Response with headers
containing "application/octet-stream", mock ResponseHelper.getHeadersFromTarget
to return {"Content-Type": "application/octet-stream"}, and mock
ResponseHelper.pipe; construct ThumbnailApi with the mocked ResponseHelper and
call serveItemFromS3 with a fake express response, then assert that the express
response.set (or setHeaders) was called with an object containing
"Content-Type": "image/jpeg" (reference ThumbnailApi.serveItemFromS3,
ResponseHelper.getRemoteImagePromise, ResponseHelper.getHeadersFromTarget, and
the express response.set call).

---

Outside diff comments:
In `@src/ResponseHelper.ts`:
- Around line 98-106: The getCacheHeaders function currently returns a Map which
is incompatible with expressResponse.set (used from ThumbnailApi at the places
where getCacheHeaders is passed); change getCacheHeaders's return type from
Map<string, string> to Record<string, string>, build and return a plain object
with the same keys ("Cache-Control" and "Expires") instead of a Map, and update
the corresponding unit test in test/unit/ResponseHelper.test.ts to expect a
Record<string,string> (plain object) instead of a Map; ensure callers that pass
the result to expressResponse.set still work without changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3b37000c-91b4-44ee-ac99-23fbe0d771ee

📥 Commits

Reviewing files that changed from the base of the PR and between 44eb681 and dc26160.

📒 Files selected for processing (4)
  • src/ResponseHelper.ts
  • src/ThumbnailApi.ts
  • test/unit/ResponseHelper.test.ts
  • test/unit/ThumbnailApi.test.ts

Comment thread src/ThumbnailApi.ts
Comment on lines +159 to +165
const headers = this.responseHelper.getHeadersFromTarget(response.headers);
// All thumbnails in S3 are stored as .jpg,
// but may not be stored with the correct content type,
// causing S3 to return application/octet-stream as the default value.
// This overrides the Content-Type to ensure clients always receive type image/jpeg.
headers["Content-Type"] = "image/jpeg";
expressResponse.set(headers);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add test coverage for the Content-Type override.

The Content-Type override is a key fix in this PR but lacks explicit test verification. The existing test at line 142 ("serveItemFromS3: success") only verifies that set is called, but doesn't confirm that Content-Type: image/jpeg is actually set regardless of the S3 response headers.

📋 Suggested test

Add a test case in test/unit/ThumbnailApi.test.ts that mocks S3 returning application/octet-stream and verifies the override:

test("serveItemFromS3: overrides Content-Type to image/jpeg", async () => {
  const responseHelper = new ResponseHelper();
  
  responseHelper.getRemoteImagePromise = jest.fn(() =>
    Promise.resolve({
      status: 200,
      body: new ReadableStream(),
      headers: new Headers([["content-type", "application/octet-stream"]]),
    } as Response),
  );
  
  // Mock should return what S3 actually sends
  responseHelper.getHeadersFromTarget = jest.fn(() => ({
    "Content-Type": "application/octet-stream"
  }));
  
  responseHelper.pipe = jest.fn();
  
  const thumbnailApi = new ThumbnailApi(
    dplaApi as unknown as DplaApi,
    thumbnailStorage,
    thumbnailCacheQueue,
    responseHelper,
  );
  
  const mockResponse = new ExpressResponse();
  await thumbnailApi.serveItemFromS3(
    itemId,
    mockResponse as unknown as express.Response,
  );
  
  // Verify that the override happened
  expect(mockResponse.set).toHaveBeenCalledWith(
    expect.objectContaining({ "Content-Type": "image/jpeg" })
  );
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ThumbnailApi.ts` around lines 159 - 165, Add a unit test that verifies
ThumbnailApi.serveItemFromS3 overrides Content-Type to image/jpeg: mock
ResponseHelper.getRemoteImagePromise to return a Response with headers
containing "application/octet-stream", mock ResponseHelper.getHeadersFromTarget
to return {"Content-Type": "application/octet-stream"}, and mock
ResponseHelper.pipe; construct ThumbnailApi with the mocked ResponseHelper and
call serveItemFromS3 with a fake express response, then assert that the express
response.set (or setHeaders) was called with an object containing
"Content-Type": "image/jpeg" (reference ThumbnailApi.serveItemFromS3,
ResponseHelper.getRemoteImagePromise, ResponseHelper.getHeadersFromTarget, and
the express response.set call).

Copy link
Copy Markdown
Contributor

@DominicBM DominicBM left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not familiar enough with the code to give a detailed review, but it looks worthwhile. My Claude flags CodeReview's getCacheHeaders suggestion above as useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants