diff --git a/src/servers/src/http/result/json_result.rs b/src/servers/src/http/result/json_result.rs index 25408b28364..4a7858fa899 100644 --- a/src/servers/src/http/result/json_result.rs +++ b/src/servers/src/http/result/json_result.rs @@ -105,11 +105,11 @@ impl IntoResponse for JsonResponse { }) .collect(); - json!({ - "data": data, - "execution_time_ms": execution_time, - }) - .to_string() + let data = Value::Array(data.into_iter().map(Value::Object).collect()); + let mut payload = Map::new(); + payload.insert("data".to_string(), data); + payload.insert("execution_time_ms".to_string(), Value::from(execution_time)); + Value::Object(payload).to_string() } }; @@ -133,3 +133,122 @@ impl IntoResponse for JsonResponse { .into_response() } } + +#[cfg(test)] +mod tests { + use axum::body::to_bytes; + use axum::http::{StatusCode, header}; + use serde_json::{Map, Value, json}; + + use super::*; + use crate::http::{ColumnSchema, HttpRecordsOutput, OutputSchema}; + + fn records(column_names: &[&str], rows: Vec>) -> HttpRecordsOutput { + let total_rows = rows.len(); + HttpRecordsOutput { + schema: OutputSchema::new( + column_names + .iter() + .map(|name| ColumnSchema::new((*name).to_string(), "Json".to_string())) + .collect(), + ), + rows, + total_rows, + metrics: Default::default(), + } + } + + fn old_records_payload(records: &HttpRecordsOutput, execution_time: u64) -> String { + let schema = records.schema(); + let data: Vec> = records + .rows() + .iter() + .map(|row| { + schema + .column_schemas + .iter() + .enumerate() + .map(|(i, col)| (col.name.clone(), row[i].clone())) + .collect() + }) + .collect(); + + json!({ + "data": data, + "execution_time_ms": execution_time, + }) + .to_string() + } + + #[tokio::test] + async fn test_records_response_matches_old_json_representation() { + let records = records( + &["z", "a", "z", "nested", "escaped", "nullable"], + vec![ + vec![ + json!(1), + json!("first"), + json!(2), + json!({"outer": {"items": [null, "value"]}}), + json!("line\nwith \"quotes\" and \\slashes"), + Value::Null, + ], + vec![ + json!(3), + json!("second"), + json!(4), + json!({"array": [true, {"key": "value"}]}), + json!("\tescaped"), + Value::Null, + ], + ], + ); + let execution_time = 42; + let expected = old_records_payload(&records, execution_time); + + let response = JsonResponse { + output: vec![GreptimeQueryOutput::Records(records)], + execution_time_ms: execution_time, + } + .into_response(); + + assert_eq!(StatusCode::OK, response.status()); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(expected.as_bytes(), body.as_ref()); + } + + #[tokio::test] + async fn test_empty_records_response_matches_old_json_representation_and_headers() { + let records = records(&[], vec![]); + let execution_time = 42; + let expected = old_records_payload(&records, execution_time); + + let response = JsonResponse { + output: vec![GreptimeQueryOutput::Records(records)], + execution_time_ms: execution_time, + } + .into_response(); + + assert_eq!(StatusCode::OK, response.status()); + assert_eq!( + mime::APPLICATION_JSON.as_ref(), + response.headers().get(header::CONTENT_TYPE).unwrap() + ); + assert_eq!( + ResponseFormat::Json.as_str(), + response.headers().get(&GREPTIME_DB_HEADER_FORMAT).unwrap() + ); + assert_eq!( + execution_time.to_string(), + response + .headers() + .get(&GREPTIME_DB_HEADER_EXECUTION_TIME) + .unwrap() + .to_str() + .unwrap() + ); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(expected.as_bytes(), body.as_ref()); + } +}