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 #[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 #[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 #[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 #[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 #[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 #[test]
352 fn json_error_classification() {
353 let cases: &[(&[u8], http::StatusCode)] = &[
354 (b"not json", http::StatusCode::BAD_REQUEST),
356 (b"{} trailing", http::StatusCode::UNPROCESSABLE_ENTITY),
359 (b"", http::StatusCode::BAD_REQUEST),
360 (b"{truncated", http::StatusCode::BAD_REQUEST),
361 (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 #[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 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 #[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 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 use s2_common::maybe::Maybe;
468
469 use crate::v1::config::{StorageClass, TimestampingMode, TimestampingReconfiguration};
470
471 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 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 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}