Skip to content

Commit ac26bbb

Browse files
committed
feat: omit Content-Type header for empty responses
1 parent 31dd663 commit ac26bbb

File tree

4 files changed

+47
-23
lines changed

4 files changed

+47
-23
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Prefer calm, matter-of-fact technical tone.
2626
## Code Quality
2727

2828
Always run `./scripts/check.sh` before committing. Use `cargo fmt` to fix
29-
formatting issues.
29+
formatting issues. Use ASCII characters only in code, comments, and documentation.
3030

3131
## Release Process
3232

README.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,14 @@ Content-type is determined in the following order of precedence:
254254

255255
2. Pipeline metadata content-type (e.g., from `to yaml` or
256256
`metadata set --content-type`)
257-
3. For Record values with no content-type, defaults to `application/json`
258-
4. For lists or streams of records, defaults to `application/x-ndjson` (JSONL)
259-
5. For binary values or byte streams, defaults to `application/octet-stream`
260-
6. Otherwise defaults to `text/html; charset=utf-8`
257+
258+
3. Inferred from value type:
259+
- Record -> `application/json`
260+
- List or stream of records -> `application/x-ndjson` (JSONL)
261+
- Binary or byte stream -> `application/octet-stream`
262+
- Empty (null) -> no Content-Type header
263+
264+
4. Default: `text/html; charset=utf-8`
261265

262266
Examples:
263267

@@ -268,16 +272,13 @@ Examples:
268272
# 2. Pipeline metadata
269273
{|req| ls | to yaml } # Returns as application/x-yaml
270274
271-
# 3. Record auto-converts to JSON
272-
{|req| {foo: "bar"} } # Returns as application/json
273-
274-
# 4. List of records auto-converts to JSONL (newline-delimited JSON)
275-
{|req| [{a: 1}, {b: 2}, {c: 3}] } # Returns as application/x-ndjson
276-
277-
# 5. Binary data
278-
{|req| 0x[deadbeef] } # Returns as application/octet-stream
275+
# 3. Inferred from value type
276+
{|req| {foo: "bar"} } # Record -> application/json
277+
{|req| [{a: 1}, {b: 2}, {c: 3}] } # List of records -> application/x-ndjson
278+
{|req| 0x[deadbeef] } # Binary -> application/octet-stream
279+
{|req| null } # Empty -> no Content-Type header
279280
280-
# 6. Default
281+
# 4. Default
281282
{|req| "Hello" } # Returns as text/html; charset=utf-8
282283
```
283284

src/handler.rs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -330,9 +330,10 @@ async fn build_normal_response(
330330
let mut header_map = hyper::header::HeaderMap::new();
331331

332332
// Content-type precedence:
333-
// 1. Explicit in http.response headers (highest priority)
334-
// 2. Pipeline metadata content_type (from `to json`, etc.)
335-
// 3. Auto-detection default
333+
// 1. Explicit in http.response headers
334+
// 2. Pipeline metadata (from `to json`, etc.)
335+
// 3. Inferred: record->json, binary->octet-stream, list/stream of records->ndjson, empty->None
336+
// 4. Default: text/html
336337
let content_type = http_meta
337338
.headers
338339
.get("content-type")
@@ -342,12 +343,20 @@ async fn build_normal_response(
342343
crate::response::HeaderValue::Multiple(v) => v.first().cloned(),
343344
})
344345
.or(inferred_content_type)
345-
.unwrap_or("text/html; charset=utf-8".to_string());
346+
.or_else(|| {
347+
if matches!(body, ResponseTransport::Empty) {
348+
None
349+
} else {
350+
Some("text/html; charset=utf-8".to_string())
351+
}
352+
});
346353

347-
header_map.insert(
348-
hyper::header::CONTENT_TYPE,
349-
hyper::header::HeaderValue::from_str(&content_type)?,
350-
);
354+
if let Some(ref ct) = content_type {
355+
header_map.insert(
356+
hyper::header::CONTENT_TYPE,
357+
hyper::header::HeaderValue::from_str(ct)?,
358+
);
359+
}
351360

352361
// Add compression headers if using brotli
353362
if use_brotli {
@@ -362,7 +371,7 @@ async fn build_normal_response(
362371
}
363372

364373
// Add SSE-required headers for event streams
365-
let is_sse = content_type == "text/event-stream";
374+
let is_sse = content_type.as_deref() == Some("text/event-stream");
366375
if is_sse {
367376
header_map.insert(
368377
hyper::header::CACHE_CONTROL,

src/test_handler.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,20 @@ async fn test_content_type_precedence() {
224224
.await
225225
.unwrap();
226226
assert_eq!(resp4.headers()["content-type"], "text/html; charset=utf-8");
227+
228+
// 5. Empty body has no Content-Type header
229+
let req5 = Request::builder()
230+
.uri("/")
231+
.body(Empty::<Bytes>::new())
232+
.unwrap();
233+
let engine = Arc::new(ArcSwap::from_pointee(test_engine(r#"{|req| null}"#)));
234+
let resp5 = handle(engine.clone(), None, no_trusted_proxies(), req5)
235+
.await
236+
.unwrap();
237+
assert!(
238+
resp5.headers().get("content-type").is_none(),
239+
"Empty body should not have Content-Type header"
240+
);
227241
}
228242

229243
#[tokio::test]

0 commit comments

Comments
 (0)