Skip to main content

s2_api/
data.rs

1use std::str::FromStr;
2
3use base64ct::{Base64, Encoding as _};
4use bytes::Bytes;
5use s2_common::ValidationError;
6
7#[derive(Debug)]
8pub struct Json<T>(pub T);
9
10#[cfg(feature = "axum")]
11impl<T> axum::response::IntoResponse for Json<T>
12where
13    T: serde::Serialize,
14{
15    fn into_response(self) -> axum::response::Response {
16        let Self(value) = self;
17        axum::Json(value).into_response()
18    }
19}
20
21#[derive(Debug)]
22pub struct Proto<T>(pub T);
23
24#[cfg(feature = "axum")]
25impl<T> axum::response::IntoResponse for Proto<T>
26where
27    T: prost::Message,
28{
29    fn into_response(self) -> axum::response::Response {
30        let headers = [(
31            http::header::CONTENT_TYPE,
32            http::header::HeaderValue::from_static("application/protobuf"),
33        )];
34        let body = self.0.encode_to_vec();
35        (headers, body).into_response()
36    }
37}
38
39#[rustfmt::skip]
40#[derive(Debug, Default, Clone, Copy)]
41#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
42pub enum Format {
43    #[default]
44    #[cfg_attr(feature = "utoipa", schema(rename = "raw"))]
45    Raw,
46    #[cfg_attr(feature = "utoipa", schema(rename = "base64"))]
47    Base64,
48}
49
50impl s2_common::http::ParseableHeader for Format {
51    fn name() -> &'static http::HeaderName {
52        &FORMAT_HEADER
53    }
54}
55
56impl Format {
57    pub fn encode(self, bytes: &[u8]) -> String {
58        match self {
59            Format::Raw => String::from_utf8_lossy(bytes).into_owned(),
60            Format::Base64 => Base64::encode_string(bytes),
61        }
62    }
63
64    pub fn decode(self, s: String) -> Result<Bytes, ValidationError> {
65        Ok(match self {
66            Format::Raw => s.into_bytes().into(),
67            Format::Base64 => Base64::decode_vec(&s)
68                .map_err(|_| ValidationError("invalid Base64 encoding".to_owned()))?
69                .into(),
70        })
71    }
72}
73
74impl FromStr for Format {
75    type Err = ValidationError;
76
77    fn from_str(s: &str) -> Result<Self, Self::Err> {
78        match s.trim() {
79            "raw" | "json" => Ok(Self::Raw),
80            "base64" | "json-binsafe" => Ok(Self::Base64),
81            _ => Err(ValidationError(s.to_string())),
82        }
83    }
84}
85
86pub static FORMAT_HEADER: http::HeaderName = http::HeaderName::from_static("s2-format");
87
88#[rustfmt::skip]
89#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))]
90#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))]
91pub struct S2FormatHeader {
92    /// Defines the interpretation of record data (header name, header value, and body) with the JSON content type.
93    /// Use `raw` (default) for efficient transmission and storage of Unicode data — storage will be in UTF-8.
94    /// Use `base64` for safe transmission with efficient storage of binary data.
95    #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-format"))]
96    pub s2_format: Format,
97}
98
99#[rustfmt::skip]
100#[derive(Debug)]
101#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))]
102#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))]
103pub struct S2StreamConfigHeader {
104    /// JSON-encoded `StreamConfig` to apply if the stream is created on append or read.
105    /// Unset fields inherit the basin's default stream configuration.
106    /// Ignored if the stream already exists.
107    /// Compact JSON is preferred.
108    #[cfg_attr(feature = "utoipa", param(
109        required = false,
110        rename = "s2-stream-config",
111        content_type = "application/json",
112        value_type = crate::v1::config::StreamConfig,
113        example = json!({"retention_policy":{"age":3600},"delete_on_empty":{"min_age_secs":300}}),
114    ))]
115    pub s2_stream_config: String,
116}
117
118#[rustfmt::skip]
119#[derive(Debug)]
120#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))]
121#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))]
122pub struct S2EncryptionKeyHeader {
123    /// Encryption key material for append and read operations.
124    /// Provide base64-encoded key when stream encryption is enabled.
125    #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-encryption-key", value_type = String))]
126    pub s2_encryption_key: String,
127}
128
129#[cfg(feature = "axum")]
130pub mod extract {
131    use std::borrow::Cow;
132
133    use axum::{
134        extract::{FromRequest, OptionalFromRequest, Request, rejection::BytesRejection},
135        response::{IntoResponse, Response},
136    };
137    use bytes::Bytes;
138    use serde::de::DeserializeOwned;
139
140    /// Rejection type for JSON extraction, owned by s2-api.
141    #[derive(Debug)]
142    #[non_exhaustive]
143    pub enum JsonExtractionRejection {
144        SyntaxError {
145            status: http::StatusCode,
146            message: Cow<'static, str>,
147        },
148        DataError {
149            status: http::StatusCode,
150            message: Cow<'static, str>,
151        },
152        MissingContentType,
153        Other {
154            status: http::StatusCode,
155            message: Cow<'static, str>,
156        },
157    }
158
159    const MISSING_CONTENT_TYPE_MSG: &str = "Expected request with `Content-Type: application/json`";
160
161    impl JsonExtractionRejection {
162        pub fn body_text(&self) -> &str {
163            match self {
164                Self::SyntaxError { message, .. }
165                | Self::DataError { message, .. }
166                | Self::Other { message, .. } => message,
167                Self::MissingContentType => MISSING_CONTENT_TYPE_MSG,
168            }
169        }
170
171        pub fn status(&self) -> http::StatusCode {
172            match self {
173                Self::SyntaxError { status, .. }
174                | Self::DataError { status, .. }
175                | Self::Other { status, .. } => *status,
176                Self::MissingContentType => http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
177            }
178        }
179    }
180
181    impl std::fmt::Display for JsonExtractionRejection {
182        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183            f.write_str(self.body_text())
184        }
185    }
186
187    impl std::error::Error for JsonExtractionRejection {}
188
189    impl IntoResponse for JsonExtractionRejection {
190        fn into_response(self) -> Response {
191            let status = self.status();
192            match self {
193                Self::SyntaxError { message, .. }
194                | Self::DataError { message, .. }
195                | Self::Other { message, .. } => match message {
196                    Cow::Borrowed(s) => (status, s).into_response(),
197                    Cow::Owned(s) => (status, s).into_response(),
198                },
199                Self::MissingContentType => (status, MISSING_CONTENT_TYPE_MSG).into_response(),
200            }
201        }
202    }
203
204    fn classify_json_error(err: serde_json::Error) -> JsonExtractionRejection {
205        use serde_json::error::Category;
206        match err.classify() {
207            Category::Data => JsonExtractionRejection::DataError {
208                status: http::StatusCode::UNPROCESSABLE_ENTITY,
209                message: err.to_string().into(),
210            },
211            Category::Io => JsonExtractionRejection::Other {
212                status: http::StatusCode::INTERNAL_SERVER_ERROR,
213                message: err.to_string().into(),
214            },
215            Category::Syntax | Category::Eof => JsonExtractionRejection::SyntaxError {
216                status: http::StatusCode::BAD_REQUEST,
217                message: err.to_string().into(),
218            },
219        }
220    }
221
222    impl<S, T> FromRequest<S> for super::Json<T>
223    where
224        S: Send + Sync,
225        T: DeserializeOwned,
226    {
227        type Rejection = JsonExtractionRejection;
228
229        async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
230            let Some(ctype) = req.headers().get(http::header::CONTENT_TYPE) else {
231                return Err(JsonExtractionRejection::MissingContentType);
232            };
233            if !crate::mime::parse(ctype)
234                .as_ref()
235                .is_some_and(crate::mime::is_json)
236            {
237                return Err(JsonExtractionRejection::MissingContentType);
238            }
239            let bytes = Bytes::from_request(req, state).await.map_err(|e| {
240                JsonExtractionRejection::Other {
241                    status: e.status(),
242                    message: e.body_text().into(),
243                }
244            })?;
245            serde_json::from_slice(&bytes)
246                .map(Self)
247                .map_err(classify_json_error)
248        }
249    }
250
251    impl<S, T> OptionalFromRequest<S> for super::Json<T>
252    where
253        S: Send + Sync,
254        T: DeserializeOwned,
255    {
256        type Rejection = JsonExtractionRejection;
257
258        async fn from_request(req: Request, state: &S) -> Result<Option<Self>, Self::Rejection> {
259            let Some(ctype) = req.headers().get(http::header::CONTENT_TYPE) else {
260                return Ok(None);
261            };
262            if !crate::mime::parse(ctype)
263                .as_ref()
264                .is_some_and(crate::mime::is_json)
265            {
266                return Err(JsonExtractionRejection::MissingContentType);
267            }
268            let bytes = Bytes::from_request(req, state).await.map_err(|e| {
269                JsonExtractionRejection::Other {
270                    status: e.status(),
271                    message: e.body_text().into(),
272                }
273            })?;
274            if bytes.is_empty() {
275                return Ok(None);
276            }
277            serde_json::from_slice(&bytes)
278                .map(|v| Some(Self(v)))
279                .map_err(classify_json_error)
280        }
281    }
282
283    /// Workaround for https://github.com/tokio-rs/axum/issues/3623
284    #[derive(Debug)]
285    pub struct JsonOpt<T>(pub Option<T>);
286
287    impl<S, T> FromRequest<S> for JsonOpt<T>
288    where
289        S: Send + Sync,
290        T: DeserializeOwned,
291    {
292        type Rejection = JsonExtractionRejection;
293
294        async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
295            match <super::Json<T> as OptionalFromRequest<S>>::from_request(req, state).await {
296                Ok(Some(super::Json(value))) => Ok(Self(Some(value))),
297                Ok(None) => Ok(Self(None)),
298                Err(e) => Err(e),
299            }
300        }
301    }
302
303    #[derive(Debug, thiserror::Error)]
304    pub enum ProtoRejection {
305        #[error(transparent)]
306        BytesRejection(#[from] BytesRejection),
307        #[error(transparent)]
308        Decode(#[from] prost::DecodeError),
309    }
310
311    impl IntoResponse for ProtoRejection {
312        fn into_response(self) -> Response {
313            match self {
314                ProtoRejection::BytesRejection(e) => e.into_response(),
315                ProtoRejection::Decode(e) => (
316                    http::StatusCode::BAD_REQUEST,
317                    format!("Invalid protobuf body: {e}"),
318                )
319                    .into_response(),
320            }
321        }
322    }
323
324    impl<S, T> FromRequest<S> for super::Proto<T>
325    where
326        S: Send + Sync,
327        T: prost::Message + Default,
328    {
329        type Rejection = ProtoRejection;
330
331        async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
332            let bytes = Bytes::from_request(req, state).await?;
333            Ok(super::Proto(T::decode(bytes)?))
334        }
335    }
336
337    #[cfg(test)]
338    mod tests {
339        use super::*;
340        use crate::v1::{
341            config::{BasinReconfiguration, StreamReconfiguration},
342            stream::{AppendInput, AppendRecord, Header},
343        };
344
345        fn parse_json<T: DeserializeOwned>(json: &[u8]) -> Result<T, JsonExtractionRejection> {
346            serde_json::from_slice(json).map_err(classify_json_error)
347        }
348
349        /// Verify that our rejection wrapper preserves axum's status code
350        /// classification for a variety of invalid JSON payloads.
351        #[test]
352        fn json_error_classification() {
353            let cases: &[(&[u8], http::StatusCode)] = &[
354                // Syntax errors → 400
355                (b"not json", http::StatusCode::BAD_REQUEST),
356                // `{}` is valid JSON but missing `records` — the data error is
357                // reported before checking trailing chars.
358                (b"{} trailing", http::StatusCode::UNPROCESSABLE_ENTITY),
359                (b"", http::StatusCode::BAD_REQUEST),
360                (b"{truncated", http::StatusCode::BAD_REQUEST),
361                // Data errors → 422
362                (b"{}", http::StatusCode::UNPROCESSABLE_ENTITY),
363                (
364                    br#"{"records": "nope"}"#,
365                    http::StatusCode::UNPROCESSABLE_ENTITY,
366                ),
367                (
368                    br#"{"records": [{"body": 123}]}"#,
369                    http::StatusCode::UNPROCESSABLE_ENTITY,
370                ),
371            ];
372
373            for (input, expected_status) in cases {
374                let err = parse_json::<AppendInput>(input).expect_err(&format!(
375                    "expected error for {:?}",
376                    String::from_utf8_lossy(input)
377                ));
378                assert_eq!(
379                    err.status(),
380                    *expected_status,
381                    "wrong status for {:?}: got {}, body: {}",
382                    String::from_utf8_lossy(input),
383                    err.status(),
384                    err.body_text(),
385                );
386            }
387        }
388
389        #[test]
390        fn valid_json_parses_successfully() {
391            let input = br#"{"records": [], "match_seq_num": null}"#;
392            let result = parse_json::<AppendInput>(input);
393            assert!(result.is_ok());
394        }
395
396        /// A deeply nested value must never overflow the stack, wherever it
397        /// appears in the document: a wrong-typed value is rejected before it
398        /// is descended into, an unknown field is skipped iteratively, and
399        /// nesting that is actually deserialized hits the recursion limit.
400        #[test]
401        fn deeply_nested_json_does_not_overflow_stack() {
402            const DEPTH: usize = 50_000;
403            let nested = format!("{}{}", "[".repeat(DEPTH), "]".repeat(DEPTH));
404            let cases = [
405                (
406                    format!(r#"{{"records":[{{"body":{nested}}}]}}"#),
407                    Some(http::StatusCode::UNPROCESSABLE_ENTITY),
408                ),
409                (format!(r#"{{"records":[],"unknown":{nested}}}"#), None),
410                (nested.clone(), Some(http::StatusCode::UNPROCESSABLE_ENTITY)),
411            ];
412            // Tokio's default worker stack size; unbounded recursion over
413            // 50k levels overflows it.
414            std::thread::Builder::new()
415                .stack_size(2 * 1024 * 1024)
416                .spawn(move || {
417                    for (input, expected_status) in &cases {
418                        let status = parse_json::<AppendInput>(input.as_bytes())
419                            .err()
420                            .map(|e| e.status());
421                        assert_eq!(status, *expected_status);
422                    }
423                    let err = parse_json::<serde_json::Value>(nested.as_bytes()).unwrap_err();
424                    assert_eq!(err.status(), http::StatusCode::BAD_REQUEST);
425                    assert!(err.body_text().contains("recursion limit exceeded"));
426                })
427                .unwrap()
428                .join()
429                .unwrap();
430        }
431
432        /// Serialize with serde_json and deserialize again, asserting semantic
433        /// equality for shapes with custom (de)serialization.
434        #[test]
435        fn serde_json_roundtrip() {
436            fn assert_roundtrip<T>(input: &T)
437            where
438                T: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug,
439            {
440                let json = serde_json::to_vec(input).unwrap();
441                let parsed: T = parse_json(&json).unwrap();
442                assert_eq!(
443                    format!("{input:?}"),
444                    format!("{parsed:?}"),
445                    "roundtrip mismatch for {}",
446                    String::from_utf8_lossy(&json),
447                );
448            }
449
450            // AppendInput variants
451            assert_roundtrip(&AppendInput {
452                records: vec![],
453                match_seq_num: None,
454                fencing_token: None,
455            });
456            assert_roundtrip(&AppendInput {
457                records: vec![AppendRecord {
458                    timestamp: None,
459                    headers: vec![Header("key".into(), "val".into())],
460                    body: "hello world".into(),
461                }],
462                match_seq_num: Some(42),
463                fencing_token: Some("token".parse().unwrap()),
464            });
465
466            // StreamReconfiguration: exercises Maybe<T> in all three states
467            use s2_common::maybe::Maybe;
468
469            use crate::v1::config::{StorageClass, TimestampingMode, TimestampingReconfiguration};
470
471            // All fields unspecified (empty JSON object)
472            assert_roundtrip(&StreamReconfiguration {
473                storage_class: Maybe::Unspecified,
474                retention_policy: Maybe::Unspecified,
475                timestamping: Maybe::Unspecified,
476                delete_on_empty: Maybe::Unspecified,
477            });
478            // Mix of specified-null and specified-value
479            assert_roundtrip(&StreamReconfiguration {
480                storage_class: Maybe::Specified(Some(StorageClass::Express)),
481                retention_policy: Maybe::Specified(None),
482                timestamping: Maybe::Specified(Some(TimestampingReconfiguration {
483                    mode: Maybe::Specified(Some(TimestampingMode::ClientRequire)),
484                    uncapped: Maybe::Specified(Some(true)),
485                })),
486                delete_on_empty: Maybe::Unspecified,
487            });
488
489            // BasinReconfiguration: nested Maybe<Option<StreamReconfiguration>>
490            assert_roundtrip(&BasinReconfiguration {
491                default_stream_config: Maybe::Specified(None),
492                stream_cipher: Maybe::Unspecified,
493                create_stream_on_append: Maybe::Specified(true),
494                create_stream_on_read: Maybe::Unspecified,
495            });
496        }
497    }
498}