Skip to content

Commit 35550b7

Browse files
committed
Misc updates
- added query param `?format=compact` to get a compact schema (defaults to JSON) - if severity text/number is missing then the value is derived using the other field if present
1 parent b8fb6ce commit 35550b7

2 files changed

Lines changed: 430 additions & 22 deletions

File tree

‎src/handlers/http/logstream.rs‎

Lines changed: 318 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ use crate::utils::json::flatten::{
3737
};
3838
use crate::{LOCK_EXPECT, stats, validator};
3939

40-
use actix_web::http::StatusCode;
41-
use actix_web::web::{Json, Path};
42-
use actix_web::{HttpRequest, Responder, web};
40+
use actix_web::http::{StatusCode, header::ContentType};
41+
use actix_web::web::{Json, Path, Query};
42+
use actix_web::{HttpRequest, HttpResponse, Responder, web};
4343
use arrow_json::reader::infer_json_schema_from_iterator;
44+
use arrow_schema::{DataType, Field, Schema, UnionFields};
4445
use bytes::Bytes;
4546
use chrono::Utc;
4647
use itertools::Itertools;
48+
use serde::Deserialize;
4749
use serde_json::{Value, json};
4850
use std::fs;
4951
use std::sync::Arc;
@@ -171,14 +173,128 @@ pub async fn detect_schema(Json(json): Json<Value>) -> Result<impl Responder, St
171173
}
172174
}
173175

176+
/// Output format for `GET /logstream/{logstream}/schema`.
177+
#[derive(Clone, Copy, Debug, Default, Deserialize)]
178+
#[serde(rename_all = "lowercase")]
179+
enum SchemaFormat {
180+
/// The existing Arrow JSON schema response.
181+
#[default]
182+
Json,
183+
/// A tab-separated, line-oriented representation of the schema.
184+
Compact,
185+
}
186+
187+
#[derive(Debug, Default, Deserialize)]
188+
pub struct SchemaQuery {
189+
#[serde(default)]
190+
format: SchemaFormat,
191+
}
192+
193+
/// Render a schema response for `GET /logstream/{logstream}/schema`.
194+
///
195+
/// Use `?format=compact` for UTF-8 `text/plain` rows of `name`, `type`, and
196+
/// `nullable`; omitting `format` (or using `json`) preserves the Arrow JSON response.
197+
/// Backslashes, tabs, carriage returns, and newlines in names and type displays are
198+
/// escaped as `\\`, `\t`, `\r`, and `\n` so every field remains one TSV row.
199+
fn schema_response(schema: &Schema, format: SchemaFormat) -> HttpResponse {
200+
match format {
201+
SchemaFormat::Json => HttpResponse::Ok().json(schema),
202+
SchemaFormat::Compact => HttpResponse::Ok()
203+
.insert_header(ContentType::plaintext())
204+
.body(compact_schema(schema)),
205+
}
206+
}
207+
208+
fn compact_schema(schema: &Schema) -> String {
209+
let mut output = String::from("name\ttype\tnullable\n");
210+
for field in schema.fields() {
211+
append_escaped_compact_field(&mut output, field.name());
212+
output.push('\t');
213+
append_escaped_compact_field(
214+
&mut output,
215+
&compact_data_type(field.data_type()).to_string(),
216+
);
217+
output.push('\t');
218+
output.push_str(if field.is_nullable() { "true" } else { "false" });
219+
output.push('\n');
220+
}
221+
output
222+
}
223+
224+
/// Clone nested fields without metadata before using Arrow's type display, preserving type structure.
225+
fn compact_data_type(data_type: &DataType) -> DataType {
226+
match data_type {
227+
DataType::List(field) => DataType::List(compact_field(field)),
228+
DataType::ListView(field) => DataType::ListView(compact_field(field)),
229+
DataType::FixedSizeList(field, size) => {
230+
DataType::FixedSizeList(compact_field(field), *size)
231+
}
232+
DataType::LargeList(field) => DataType::LargeList(compact_field(field)),
233+
DataType::LargeListView(field) => DataType::LargeListView(compact_field(field)),
234+
DataType::Struct(fields) => DataType::Struct(
235+
fields
236+
.iter()
237+
.map(|field| compact_field(field))
238+
.collect::<Vec<_>>()
239+
.into(),
240+
),
241+
DataType::Union(fields, mode) => DataType::Union(
242+
UnionFields::try_new(
243+
fields.iter().map(|(type_id, _)| type_id),
244+
fields.iter().map(|(_, field)| compact_field(field)),
245+
)
246+
.expect("existing union fields are valid"),
247+
*mode,
248+
),
249+
DataType::Dictionary(key, value) => DataType::Dictionary(
250+
Box::new(compact_data_type(key)),
251+
Box::new(compact_data_type(value)),
252+
),
253+
DataType::Map(field, sorted) => DataType::Map(compact_field(field), *sorted),
254+
DataType::RunEndEncoded(run_ends, values) => {
255+
DataType::RunEndEncoded(compact_field(run_ends), compact_field(values))
256+
}
257+
_ => data_type.clone(),
258+
}
259+
}
260+
261+
fn compact_field(field: &Field) -> Arc<Field> {
262+
Arc::new(Field::new(
263+
field.name(),
264+
compact_data_type(field.data_type()),
265+
field.is_nullable(),
266+
))
267+
}
268+
269+
fn append_escaped_compact_field(output: &mut String, value: &str) {
270+
if !value.contains(['\\', '\t', '\r', '\n']) {
271+
output.push_str(value);
272+
return;
273+
}
274+
275+
for character in value.chars() {
276+
match character {
277+
'\\' => output.push_str("\\\\"),
278+
'\t' => output.push_str("\\t"),
279+
'\r' => output.push_str("\\r"),
280+
'\n' => output.push_str("\\n"),
281+
_ => output.push(character),
282+
}
283+
}
284+
}
285+
286+
/// Fetch the full Arrow JSON schema, or request `?format=compact` for a smaller TSV response.
287+
/// Compact output includes all column names, type displays and nullability, but omits
288+
/// schema/field metadata and dictionary IPC attributes; it is not an Arrow interchange format.
174289
pub async fn get_schema(
175290
req: HttpRequest,
176291
logstream: Path<String>,
292+
query: Query<SchemaQuery>,
177293
) -> Result<impl Responder, StreamError> {
178294
let stream_name = logstream.into_inner();
179295
let tenant_id = get_tenant_id_from_request(&req);
180296
let schema = get_schema_internal(&stream_name, &tenant_id).await?;
181-
Ok((web::Json(schema), StatusCode::OK))
297+
Ok(schema_response(&schema, query.format))
182298
}
183299

184300
/// Shared schema implementation for HTTP handlers and in-process callers.
@@ -196,7 +312,10 @@ pub async fn get_schema_internal(
196312
return Err(StreamNotFound(stream_name.to_owned()).into());
197313
}
198314
match update_schema_when_distributed(&vec![stream_name.to_owned()], tenant_id).await {
199-
Ok(_) => Ok(stream.get_schema()),
315+
Ok(_) => {
316+
let schema = stream.get_schema();
317+
Ok(schema)
318+
}
200319
Err(err) => Err(StreamError::Custom {
201320
msg: err.to_string(),
202321
status: StatusCode::EXPECTATION_FAILED,
@@ -769,10 +888,203 @@ pub mod error {
769888

770889
#[cfg(test)]
771890
mod tests {
891+
use super::{SchemaQuery, compact_schema, schema_response};
772892
use crate::{
773893
event::format::LogSource, handlers::http::modal::utils::logstream_utils::PutStreamHeaders,
774894
};
775-
use actix_web::test::TestRequest;
895+
use actix_web::{
896+
App, HttpResponse,
897+
http::{StatusCode, header},
898+
test,
899+
test::TestRequest,
900+
web::{self, Query},
901+
};
902+
use arrow_schema::{DataType, Field, Schema, TimeUnit};
903+
use std::collections::HashMap;
904+
use std::sync::Arc;
905+
906+
async fn schema_test_handler(
907+
query: Query<SchemaQuery>,
908+
schema: web::Data<Schema>,
909+
) -> HttpResponse {
910+
schema_response(schema.get_ref(), query.into_inner().format)
911+
}
912+
913+
fn test_schema() -> Schema {
914+
Schema::new_with_metadata(
915+
vec![
916+
Arc::new(Field::new(
917+
"nested",
918+
DataType::Struct(
919+
vec![Arc::new(
920+
Field::new("inner", DataType::Int64, false).with_metadata(
921+
HashMap::from([(
922+
String::from("inner-metadata"),
923+
String::from("omitted"),
924+
)]),
925+
),
926+
)]
927+
.into(),
928+
),
929+
true,
930+
)),
931+
Arc::new(Field::new_list(
932+
"list",
933+
Arc::new(Field::new_list_field(DataType::Utf8, false)),
934+
false,
935+
)),
936+
Arc::new(Field::new_dictionary(
937+
"dictionary",
938+
DataType::Int32,
939+
DataType::Utf8,
940+
true,
941+
)),
942+
Arc::new(Field::new(
943+
"dictionary_struct",
944+
DataType::Dictionary(
945+
Box::new(DataType::Int32),
946+
Box::new(DataType::Struct(
947+
vec![Arc::new(
948+
Field::new("dictionary_inner", DataType::Utf8, true).with_metadata(
949+
HashMap::from([(
950+
String::from("dictionary-inner-metadata"),
951+
String::from("omitted"),
952+
)]),
953+
),
954+
)]
955+
.into(),
956+
)),
957+
),
958+
false,
959+
)),
960+
Arc::new(Field::new(
961+
"timestamp",
962+
DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
963+
false,
964+
)),
965+
Arc::new(Field::new("awk\\ward\tname\r\n", DataType::Utf8, true)),
966+
],
967+
HashMap::from([(String::from("schema-metadata"), String::from("omitted"))]),
968+
)
969+
}
970+
971+
#[actix_web::test]
972+
async fn schema_format_query_selects_byte_compatible_json_or_compact_tsv() {
973+
let schema = test_schema();
974+
let expected_json = serde_json::to_vec(&schema).unwrap();
975+
let app = test::init_service(
976+
App::new()
977+
.app_data(web::Data::new(schema))
978+
.route("/schema", web::get().to(schema_test_handler)),
979+
)
980+
.await;
981+
982+
let default_response =
983+
test::call_service(&app, TestRequest::get().uri("/schema").to_request()).await;
984+
assert_eq!(default_response.status(), StatusCode::OK);
985+
assert_eq!(
986+
default_response
987+
.headers()
988+
.get(header::CONTENT_TYPE)
989+
.unwrap(),
990+
"application/json"
991+
);
992+
assert_eq!(test::read_body(default_response).await, expected_json);
993+
994+
let json_response = test::call_service(
995+
&app,
996+
TestRequest::get().uri("/schema?format=json").to_request(),
997+
)
998+
.await;
999+
assert_eq!(json_response.status(), StatusCode::OK);
1000+
assert_eq!(
1001+
json_response.headers().get(header::CONTENT_TYPE).unwrap(),
1002+
"application/json"
1003+
);
1004+
assert_eq!(test::read_body(json_response).await, expected_json);
1005+
1006+
let compact_response = test::call_service(
1007+
&app,
1008+
TestRequest::get()
1009+
.uri("/schema?format=compact")
1010+
.to_request(),
1011+
)
1012+
.await;
1013+
assert_eq!(compact_response.status(), StatusCode::OK);
1014+
assert_eq!(
1015+
compact_response
1016+
.headers()
1017+
.get(header::CONTENT_TYPE)
1018+
.unwrap(),
1019+
"text/plain; charset=utf-8"
1020+
);
1021+
let compact = std::str::from_utf8(&test::read_body(compact_response).await)
1022+
.unwrap()
1023+
.to_owned();
1024+
assert_eq!(
1025+
compact,
1026+
"name\ttype\tnullable\n\
1027+
nested\tStruct(\"inner\": non-null Int64)\ttrue\n\
1028+
list\tList(non-null Utf8)\tfalse\n\
1029+
dictionary\tDictionary(Int32, Utf8)\ttrue\n\
1030+
dictionary_struct\tDictionary(Int32, Struct(\"dictionary_inner\": Utf8))\tfalse\n\
1031+
timestamp\tTimestamp(ns, \"UTC\")\tfalse\n\
1032+
awk\\\\ward\\tname\\r\\n\tUtf8\ttrue\n"
1033+
);
1034+
assert!(!compact.contains("schema-metadata"));
1035+
assert!(!compact.contains("inner-metadata"));
1036+
assert!(!compact.contains("dictionary-inner-metadata"));
1037+
}
1038+
1039+
#[actix_web::test]
1040+
async fn schema_format_query_rejects_invalid_format() {
1041+
let app = test::init_service(
1042+
App::new()
1043+
.app_data(web::Data::new(Schema::empty()))
1044+
.route("/schema", web::get().to(schema_test_handler)),
1045+
)
1046+
.await;
1047+
1048+
let response = test::call_service(
1049+
&app,
1050+
TestRequest::get().uri("/schema?format=xml").to_request(),
1051+
)
1052+
.await;
1053+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1054+
}
1055+
1056+
#[core::prelude::v1::test]
1057+
fn compact_schema_handles_empty_schemas_and_reduces_500_column_payloads() {
1058+
assert_eq!(compact_schema(&Schema::empty()), "name\ttype\tnullable\n");
1059+
1060+
let schema = Schema::new(
1061+
(0..500)
1062+
.map(|index| {
1063+
Arc::new(Field::new(
1064+
format!("field_{index:03}"),
1065+
DataType::Utf8,
1066+
index % 2 == 0,
1067+
))
1068+
})
1069+
.collect::<Vec<_>>(),
1070+
);
1071+
let compact_len = compact_schema(&schema).len();
1072+
let arrow_json_len = serde_json::to_vec(&schema).unwrap().len();
1073+
println!("compact schema: {compact_len} bytes vs Arrow JSON: {arrow_json_len} bytes");
1074+
assert_eq!(compact_schema(&schema).lines().count(), 501);
1075+
assert!(compact_len * 4 < arrow_json_len);
1076+
}
1077+
1078+
#[core::prelude::v1::test]
1079+
fn compact_schema_uses_arrow_display_for_complex_types() {
1080+
let schema = test_schema();
1081+
let compact = compact_schema(&schema);
1082+
assert!(compact.contains("Struct(\"inner\": non-null Int64)"));
1083+
assert!(compact.contains("List(non-null Utf8)"));
1084+
assert!(compact.contains("Dictionary(Int32, Utf8)"));
1085+
assert!(compact.contains("Dictionary(Int32, Struct(\"dictionary_inner\": Utf8))"));
1086+
assert!(compact.contains("Timestamp(ns, \"UTC\")"));
1087+
}
7761088

7771089
// TODO: Fix this test with routes
7781090
// #[actix_web::test]

0 commit comments

Comments
 (0)