Skip to content

Commit

Permalink
feat: improve content-type handling precedence and clarify documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
cablehead committed Feb 3, 2025
1 parent 33f794e commit 9977891
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 39 deletions.
42 changes: 31 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,22 +92,42 @@ sorry, eh

### Content-Type Inference

- The default Content-Type is `text/html; charset=utf-8`. (TBD / make
configurable?)
- If you return a Record value, the Content-Type will be `application/json` and
the Value is serialized to JSON.
- If you return a pipeline which has Content-Type set in the pipeline's
metadata, that Content-Type will be used. e.g.
Content-type is determined in the following order of precedence:

1. Headers set via `.response` command:
```nushell
.response {
headers: {
"Content-Type": "text/plain"
}
}
```

2. Pipeline metadata content-type (e.g., from `to yaml`)
3. For Record values with no content-type, defaults to `application/json`
4. Otherwise defaults to `text/html; charset=utf-8`

Examples:

```nushell
{|req|
ls | to yaml # sets Content-Type to application/x-yaml
}
# 1. Explicit header takes precedence
{|req| .response {headers: {"Content-Type": "text/plain"}}; {foo: "bar"} } # Returns as text/plain
# 2. Pipeline metadata
{|req| ls | to yaml } # Returns as application/x-yaml
# 3. Record auto-converts to JSON
{|req| {foo: "bar"} } # Returns as application/json
# 4. Default
{|req| "Hello" } # Returns as text/html; charset=utf-8
```

### Streaming responses

Streaming pipelines will be streamed to the client using chunked transfer encoding, as Value's are produced.
Values returned by streaming pipelines (like `generate`) are sent to the client
immediately as HTTP chunks. This allows real-time data transmission without
waiting for the entire response to be ready.

```bash
$ http-nu :3001 '{|req|
Expand All @@ -124,7 +144,7 @@ Fri, 31 Jan 2025 03:48:01 -0500 (now)
Fri, 31 Jan 2025 03:48:02 -0500 (now)
Fri, 31 Jan 2025 03:48:03 -0500 (now)
...
````
```

### [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)

Expand Down
66 changes: 38 additions & 28 deletions src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ where
B::Error: Into<BoxError> + Send,
{
// Create channels for response metadata
let (meta_tx, mut meta_rx) = tokio::sync::oneshot::channel();
let (meta_tx, meta_rx) = tokio::sync::oneshot::channel();

// Add .response command to engine
engine.add_commands(vec![Box::new(ResponseStartCommand::new(meta_tx))])?;
Expand Down Expand Up @@ -134,7 +134,7 @@ where
.unwrap_or_else(HashMap::new),
};

let mut bridged_body = {
let bridged_body = {
let (body_tx, body_rx) = tokio::sync::oneshot::channel();

std::thread::spawn(move || -> Result<(), BoxError> {
Expand Down Expand Up @@ -190,38 +190,48 @@ where
body_rx
};

// Get response metadata and body type. We use a select here to avoid blocking for metadata, if
// the closure returns a pipeline without call .response
let (meta, inferred_content_type, body) = tokio::select! {
meta = &mut meta_rx => {
let meta = meta.unwrap_or(crate::Response { status: 200, headers: HashMap::new() });
let (ct, body) = bridged_body.await?;
(meta, ct, body)
// Wait for both:
// 1. Metadata - either from .response or default values when closure skips .response
// 2. Body pipeline to start (but not necessarily complete as it may stream)
let (meta, body_result) = tokio::join!(
async {
meta_rx.await.unwrap_or(crate::Response {
status: 200,
headers: HashMap::new(),
})
},
body = &mut bridged_body => {
let (ct, resp) = body?;
let meta = meta_rx.await.unwrap_or(crate::Response { status: 200, headers: HashMap::new() });
(meta, ct, resp)
}
};
bridged_body
);
let (inferred_content_type, body) = body_result?;

// Build response with appropriate headers
let mut builder = hyper::Response::builder().status(meta.status);

// Convert custom headers to HeaderMap
let mut header_map = hyper::header::HeaderMap::new();
for (k, v) in meta.headers {
header_map.insert(
hyper::header::HeaderName::from_bytes(k.as_bytes())?,
hyper::header::HeaderValue::from_str(&v)?,
);
}

if let Some(ct) = inferred_content_type {
header_map.insert(
hyper::header::CONTENT_TYPE,
hyper::header::HeaderValue::from_str(&ct)?,
);
// First apply content-type from .response headers if present
let content_type = meta
.headers
.get("content-type")
.or(meta.headers.get("Content-Type"))
.cloned()
// Then pipeline metadata
.or(inferred_content_type)
// Finally default
.unwrap_or("text/html; charset=utf-8".to_string());

header_map.insert(
hyper::header::CONTENT_TYPE,
hyper::header::HeaderValue::from_str(&content_type)?,
);

// Add rest of custom headers
for (k, v) in meta.headers {
if k.to_lowercase() != "content-type" {
header_map.insert(
hyper::header::HeaderName::from_bytes(k.as_bytes())?,
hyper::header::HeaderValue::from_str(&v)?,
);
}
}

*builder.headers_mut().unwrap() = header_map;
Expand Down
86 changes: 86 additions & 0 deletions src/test_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,89 @@ fn assert_timing_sequence(timings: &[(String, Duration)]) {
);
}
}

#[tokio::test]
async fn test_content_type_precedence() {
let engine = crate::Engine::new().unwrap();

// 1. Explicit header should take precedence
let req1 = Request::builder()
.uri("/")
.body(Empty::<Bytes>::new())
.unwrap();

let resp1 = handle(
engine.clone(),
r#"{|req|
.response {headers: {"Content-Type": "text/plain"}}
{foo: "bar"}
}"#
.into(),
None,
req1,
)
.await
.unwrap();

assert_eq!(resp1.headers()["content-type"], "text/plain");

// 2. Pipeline metadata
let req2 = Request::builder()
.uri("/")
.body(Empty::<Bytes>::new())
.unwrap();

let resp2 = handle(
engine.clone(),
r#"{|req|
ls | to yaml
}"#
.into(),
None,
req2,
)
.await
.unwrap();

assert_eq!(resp2.headers()["content-type"], "application/yaml");

// 3. Record defaults to JSON
let req3 = Request::builder()
.uri("/")
.body(Empty::<Bytes>::new())
.unwrap();

let resp3 = handle(
engine.clone(),
r#"{|req|
{foo: "bar"}
}"#
.into(),
None,
req3,
)
.await
.unwrap();

assert_eq!(resp3.headers()["content-type"], "application/json");

// 4. Plain text defaults to text/html
let req4 = Request::builder()
.uri("/")
.body(Empty::<Bytes>::new())
.unwrap();

let resp4 = handle(
engine.clone(),
r#"{|req|
"Hello World"
}"#
.into(),
None,
req4,
)
.await
.unwrap();

assert_eq!(resp4.headers()["content-type"], "text/html; charset=utf-8");
}

0 comments on commit 9977891

Please sign in to comment.