Skip to content

Commit f13ca56

Browse files
committed
feat: lists serialize as JSON arrays, streams as JSONL
1 parent ac26bbb commit f13ca56

File tree

3 files changed

+27
-41
lines changed

3 files changed

+27
-41
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,8 @@ Content-type is determined in the following order of precedence:
257257

258258
3. Inferred from value type:
259259
- Record -> `application/json`
260-
- List or stream of records -> `application/x-ndjson` (JSONL)
260+
- List -> `application/json` (JSON array)
261+
- Stream of records -> `application/x-ndjson` (JSONL)
261262
- Binary or byte stream -> `application/octet-stream`
262263
- Empty (null) -> no Content-Type header
263264

@@ -274,7 +275,8 @@ Examples:
274275
275276
# 3. Inferred from value type
276277
{|req| {foo: "bar"} } # Record -> application/json
277-
{|req| [{a: 1}, {b: 2}, {c: 3}] } # List of records -> application/x-ndjson
278+
{|req| [{a: 1}, {b: 2}, {c: 3}] } # List -> application/json (array)
279+
{|req| 1..10 | each { {n: $in} } } # Stream of records -> application/x-ndjson
278280
{|req| 0x[deadbeef] } # Binary -> application/octet-stream
279281
{|req| null } # Empty -> no Content-Type header
280282

src/worker.rs

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ pub fn spawn_eval_thread(
5353
let _ = tx.borrow_mut().take(); // This will drop the sender if it wasn't used
5454
});
5555
let output = result?;
56+
57+
// Content-type inference (when pipeline metadata has no content-type):
58+
//
59+
// | Value type | Content-Type | Conversion |
60+
// |------------------|------------------------|---------------------|
61+
// | Record (__html) | text/html | unwrap __html |
62+
// | Record | application/json | JSON object |
63+
// | List | application/json | JSON array |
64+
// | Binary | application/octet-stream | raw bytes |
65+
// | Empty/Nothing | None (no header) | empty |
66+
// | ListStream | application/x-ndjson | JSONL (if records) |
67+
// | Other | text/html (default) | .to_string() |
68+
//
5669
let inferred_content_type = match &output {
5770
PipelineData::Value(Value::Record { val, .. }, meta)
5871
if meta.as_ref().and_then(|m| m.content_type.clone()).is_none() =>
@@ -63,12 +76,10 @@ pub fn spawn_eval_thread(
6376
Some("application/json".to_string())
6477
}
6578
}
66-
PipelineData::Value(Value::List { vals, .. }, meta)
67-
if meta.as_ref().and_then(|m| m.content_type.clone()).is_none()
68-
&& !vals.is_empty()
69-
&& vals.iter().all(is_jsonl_record) =>
79+
PipelineData::Value(Value::List { .. }, meta)
80+
if meta.as_ref().and_then(|m| m.content_type.clone()).is_none() =>
7081
{
71-
Some("application/x-ndjson".to_string())
82+
Some("application/json".to_string())
7283
}
7384
PipelineData::Value(Value::Binary { .. }, meta)
7485
if meta.as_ref().and_then(|m| m.content_type.clone()).is_none() =>
@@ -98,26 +109,6 @@ pub fn spawn_eval_thread(
98109
let working_set = StateWorkingSet::new(&engine.state);
99110
Err(format_cli_error(&working_set, error.as_ref(), None).into())
100111
}
101-
PipelineData::Value(Value::List { vals, .. }, meta)
102-
if !vals.is_empty() && vals.iter().all(is_jsonl_record) =>
103-
{
104-
let http_meta = extract_http_response_meta(meta.as_ref());
105-
// JSONL: each record as JSON line
106-
let jsonl: Vec<u8> = vals
107-
.into_iter()
108-
.flat_map(|v| {
109-
let mut line = serde_json::to_vec(&value_to_json(&v)).unwrap_or_default();
110-
line.push(b'\n');
111-
line
112-
})
113-
.collect();
114-
let _ = body_tx.send((
115-
inferred_content_type,
116-
http_meta,
117-
ResponseTransport::Full(jsonl),
118-
));
119-
Ok(())
120-
}
121112
PipelineData::Value(value, meta) => {
122113
let http_meta = extract_http_response_meta(meta.as_ref());
123114
let _ = body_tx.send((

tests/server_test.rs

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1924,7 +1924,8 @@ async fn test_record_json_content_type() {
19241924
}
19251925

19261926
#[tokio::test]
1927-
async fn test_list_of_records_jsonl_content_type() {
1927+
async fn test_list_of_records_json_content_type() {
1928+
// Lists serialize as JSON arrays (streams serialize as JSONL)
19281929
let server = TestServer::new("127.0.0.1:0", "{|req| [{a: 1}, {b: 2}, {c: 3}]}", false).await;
19291930
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
19301931

@@ -1939,22 +1940,14 @@ async fn test_list_of_records_jsonl_content_type() {
19391940
let response = String::from_utf8_lossy(&output.stdout);
19401941

19411942
assert!(
1942-
response.contains("content-type: application/x-ndjson"),
1943-
"Expected application/x-ndjson content-type, got: {response}"
1943+
response.contains("content-type: application/json"),
1944+
"Expected application/json content-type, got: {response}"
19441945
);
19451946

1946-
// Check JSONL format: each record on its own line
1947-
assert!(
1948-
response.contains(r#"{"a":1}"#),
1949-
"Expected JSONL line with a:1"
1950-
);
1951-
assert!(
1952-
response.contains(r#"{"b":2}"#),
1953-
"Expected JSONL line with b:2"
1954-
);
1947+
// Check JSON array format
19551948
assert!(
1956-
response.contains(r#"{"c":3}"#),
1957-
"Expected JSONL line with c:3"
1949+
response.contains(r#"[{"a":1},{"b":2},{"c":3}]"#),
1950+
"Expected JSON array, got: {response}"
19581951
);
19591952
}
19601953

0 commit comments

Comments
 (0)