@@ -37,13 +37,15 @@ use crate::utils::json::flatten::{
3737} ;
3838use 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} ;
4343use arrow_json:: reader:: infer_json_schema_from_iterator;
44+ use arrow_schema:: { DataType , Field , Schema , UnionFields } ;
4445use bytes:: Bytes ;
4546use chrono:: Utc ;
4647use itertools:: Itertools ;
48+ use serde:: Deserialize ;
4749use serde_json:: { Value , json} ;
4850use std:: fs;
4951use 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\t type\t nullable\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.
174289pub 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) ]
771890mod 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\t name\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\t type\t nullable\n \
1027+ nested\t Struct(\" inner\" : non-null Int64)\t true\n \
1028+ list\t List(non-null Utf8)\t false\n \
1029+ dictionary\t Dictionary(Int32, Utf8)\t true\n \
1030+ dictionary_struct\t Dictionary(Int32, Struct(\" dictionary_inner\" : Utf8))\t false\n \
1031+ timestamp\t Timestamp(ns, \" UTC\" )\t false\n \
1032+ awk\\ \\ ward\\ tname\\ r\\ n\t Utf8\t true\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\t type\t nullable\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