at_jet/dual_format.rs
1//! Dual-format support for AT-Jet
2//!
3//! Provides request/response types that support both Protobuf and JSON formats.
4//! - Protobuf: Production format (efficient, compact, with evolution guarantees)
5//! - JSON: Debug/testing format (human-readable, requires debug header)
6//!
7//! ## Why require a debug header for JSON?
8//!
9//! Protobuf provides schema evolution guarantees that JSON lacks:
10//! - Field numbers enable backward/forward compatibility
11//! - Unknown fields are preserved
12//! - Optional fields have well-defined defaults
13//!
14//! If clients accidentally use JSON in production, they lose these guarantees
15//! and may break when the schema evolves. The debug header requirement ensures
16//! JSON is only used intentionally for debugging/testing.
17//!
18//! ## Format selection:
19//!
20//! - Request: Based on `Content-Type` header (JSON requires debug header)
21//! - Response: Based on `Accept` header (JSON requires debug header)
22//! - Debug header: `X-Debug-Format: <configured-secret>` (empty string = no secret required)
23//!
24//! ## Example:
25//!
26//! ```bash
27//! # JSON request (requires debug header)
28//! curl -X POST http://localhost:8080/api/quests/my \
29//! -H "Content-Type: application/json" \
30//! -H "Accept: application/json" \
31//! -H "X-Debug-Format: debug-secret-123" \
32//! -d '{"ucid": "user123"}'
33//!
34//! # Protobuf request (no debug header needed)
35//! curl -X POST http://localhost:8080/api/quests/my \
36//! -H "Content-Type: application/x-protobuf" \
37//! --data-binary @request.pb
38//! ```
39
40use {crate::{content_types::{APPLICATION_JSON,
41 APPLICATION_PROTOBUF},
42 error::JetError},
43 axum::{async_trait,
44 body::Bytes,
45 extract::{FromRequest,
46 FromRequestParts,
47 Request},
48 http::{StatusCode,
49 header::{ACCEPT,
50 CONTENT_TYPE},
51 request::Parts},
52 response::{IntoResponse,
53 Response}},
54 prost::Message,
55 serde::{Serialize,
56 de::DeserializeOwned},
57 std::sync::OnceLock};
58
59// ============================================================================
60// Debug Header Configuration
61// ============================================================================
62
63/// Header name for enabling JSON debug format
64pub const DEBUG_FORMAT_HEADER: &str = "x-debug-format";
65
66/// Global list of authorized debug keys
67static DEBUG_KEYS: OnceLock<Vec<String>> = OnceLock::new();
68
69/// Configure the authorized debug keys for JSON format.
70///
71/// Call this once at application startup before handling any requests.
72///
73/// - Non-empty list: JSON requires `X-Debug-Format: <key>` header with one of the authorized keys
74/// - Empty list: JSON format is completely disabled
75///
76/// If not configured, defaults to empty (JSON disabled).
77///
78/// # Example
79///
80/// ```rust,ignore
81/// // In main.rs - enable JSON for specific keys
82/// at_jet::dual_format::configure_debug_keys(vec![
83/// "alice-dev-key".to_string(),
84/// "bob-dev-key".to_string(),
85/// "qa-team-key".to_string(),
86/// ]);
87///
88/// // Or disable JSON completely
89/// at_jet::dual_format::configure_debug_keys(vec![]);
90/// ```
91pub fn configure_debug_keys(keys: Vec<String>) {
92 DEBUG_KEYS.set(keys).ok(); // Ignore if already set
93}
94
95/// Check if JSON format is allowed based on the debug header value
96fn is_json_allowed(debug_header_value: Option<&str>) -> bool {
97 match (DEBUG_KEYS.get(), debug_header_value) {
98 | (Some(keys), Some(provided_key)) if !keys.is_empty() => {
99 // Check if provided key is in the authorized list
100 keys.iter().any(|k| k == provided_key)
101 }
102 | _ => {
103 // No keys configured, empty list, or no header provided
104 false
105 }
106 }
107}
108
109// ============================================================================
110// Response Format
111// ============================================================================
112
113/// Response format preference
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub enum ResponseFormat {
116 /// Protobuf format (default, production)
117 #[default]
118 Protobuf,
119 /// JSON format (debug/testing, requires valid debug header)
120 Json,
121}
122
123impl ResponseFormat {
124 /// Parse from Accept header, considering debug header authorization
125 ///
126 /// JSON format is only returned if:
127 /// 1. Accept header contains "application/json"
128 /// 2. Debug header is valid (checked via `is_json_allowed`)
129 ///
130 /// Otherwise, Protobuf is returned (safe default for production).
131 pub fn from_headers(accept: Option<&str>, debug_header: Option<&str>) -> Self {
132 let wants_json = accept.map(|s| s.contains("application/json")).unwrap_or(false);
133
134 if wants_json && is_json_allowed(debug_header) {
135 ResponseFormat::Json
136 } else {
137 ResponseFormat::Protobuf
138 }
139 }
140
141 /// Check if this format is JSON
142 pub fn is_json(&self) -> bool {
143 matches!(self, ResponseFormat::Json)
144 }
145}
146
147// ============================================================================
148// Format Preference Extractor
149// ============================================================================
150
151/// Extractor for response format preference from Accept header
152///
153/// JSON format requires a valid debug header (`X-Debug-Format: <secret>`).
154/// If debug header is missing or invalid, Protobuf is returned even if
155/// Accept header requests JSON.
156///
157/// # Example
158///
159/// ```rust,ignore
160/// async fn handler(format: AcceptFormat) -> ApiResponse<MyMessage> {
161/// ApiResponse::ok(format.0, message)
162/// }
163/// ```
164pub struct AcceptFormat(pub ResponseFormat);
165
166#[async_trait]
167impl<S> FromRequestParts<S> for AcceptFormat
168where
169 S: Send + Sync,
170{
171 type Rejection = std::convert::Infallible;
172
173 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
174 let accept = parts.headers.get(ACCEPT).and_then(|v| v.to_str().ok());
175 let debug_header = parts.headers.get(DEBUG_FORMAT_HEADER).and_then(|v| v.to_str().ok());
176 Ok(AcceptFormat(ResponseFormat::from_headers(accept, debug_header)))
177 }
178}
179
180// ============================================================================
181// Dual-Format Request Extractor
182// ============================================================================
183
184/// Maximum request body size (10MB default)
185const MAX_BODY_SIZE: usize = 10 * 1024 * 1024;
186
187/// Dual-format request extractor
188///
189/// Extracts request body from either Protobuf or JSON format based on Content-Type.
190/// Also captures the response format preference from Accept header.
191///
192/// # Example
193///
194/// ```rust,ignore
195/// async fn create_user(
196/// ApiRequest { body, format }: ApiRequest<CreateUserRequest>
197/// ) -> ApiResponse<User> {
198/// let user = process(body);
199/// ApiResponse::ok(format, user)
200/// }
201/// ```
202pub struct ApiRequest<T> {
203 /// The decoded request body
204 pub body: T,
205 /// The preferred response format (from Accept header)
206 pub format: ResponseFormat,
207}
208
209impl<T> ApiRequest<T> {
210 /// Create an OK response with the captured format preference
211 pub fn ok<R>(self, response: R) -> ApiResponse<R>
212 where
213 R: Message + Serialize, {
214 ApiResponse::ok(self.format, response)
215 }
216
217 /// Create a response with custom status and the captured format preference
218 pub fn respond<R>(self, status: StatusCode, response: R) -> ApiResponse<R>
219 where
220 R: Message + Serialize, {
221 ApiResponse::new(self.format, status, response)
222 }
223
224 /// Create a Created (201) response with the captured format preference
225 pub fn created<R>(self, response: R) -> ApiResponse<R>
226 where
227 R: Message + Serialize, {
228 ApiResponse::created(self.format, response)
229 }
230}
231
232#[async_trait]
233impl<S, T> FromRequest<S> for ApiRequest<T>
234where
235 S: Send + Sync,
236 T: Message + Default + DeserializeOwned,
237{
238 type Rejection = JetError;
239
240 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
241 // Extract headers before consuming the request
242 let accept = req
243 .headers()
244 .get(ACCEPT)
245 .and_then(|v| v.to_str().ok())
246 .map(|s| s.to_string());
247
248 let debug_header = req
249 .headers()
250 .get(DEBUG_FORMAT_HEADER)
251 .and_then(|v| v.to_str().ok())
252 .map(|s| s.to_string());
253
254 let content_type = req
255 .headers()
256 .get(CONTENT_TYPE)
257 .and_then(|v| v.to_str().ok())
258 .unwrap_or("")
259 .to_string();
260
261 // Determine response format (considers debug header)
262 let format = ResponseFormat::from_headers(accept.as_deref(), debug_header.as_deref());
263
264 // Check if JSON input is allowed
265 let json_allowed = is_json_allowed(debug_header.as_deref());
266 let wants_json_input = content_type.contains("application/json");
267
268 // Extract body bytes (consumes req)
269 let bytes = Bytes::from_request(req, state)
270 .await
271 .map_err(|e| JetError::BadRequest(format!("Failed to read body: {}", e)))?;
272
273 // Check size
274 if bytes.len() > MAX_BODY_SIZE {
275 return Err(JetError::BodyTooLarge {
276 size: bytes.len(),
277 max: MAX_BODY_SIZE,
278 });
279 }
280
281 // Decode based on Content-Type
282 let body = if wants_json_input {
283 if !json_allowed {
284 return Err(JetError::InvalidContentType {
285 expected: APPLICATION_PROTOBUF.to_string(),
286 actual: "application/json (requires valid X-Debug-Format header)".to_string(),
287 });
288 }
289 serde_json::from_slice(&bytes).map_err(|e| JetError::BadRequest(format!("Invalid JSON: {}", e)))?
290 } else if content_type.contains(APPLICATION_PROTOBUF) || bytes.is_empty() {
291 // Default to protobuf, also handle empty body (for GET-like requests)
292 if bytes.is_empty() {
293 T::default()
294 } else {
295 T::decode(bytes)?
296 }
297 } else {
298 // Unknown content type - try protobuf (production default)
299 T::decode(bytes)?
300 };
301
302 Ok(ApiRequest { body, format })
303 }
304}
305
306// ============================================================================
307// Dual-Format Response
308// ============================================================================
309
310/// Dual-format response type
311///
312/// Returns either Protobuf or JSON based on the format preference.
313///
314/// # Example
315///
316/// ```rust,ignore
317/// async fn get_user(
318/// AcceptFormat(format): AcceptFormat,
319/// Path(id): Path<i32>,
320/// ) -> ApiResponse<User> {
321/// let user = fetch_user(id);
322/// ApiResponse::ok(format, user)
323/// }
324/// ```
325pub struct ApiResponse<T>
326where
327 T: Message + Serialize, {
328 format: ResponseFormat,
329 status: StatusCode,
330 message: T,
331}
332
333impl<T> ApiResponse<T>
334where
335 T: Message + Serialize,
336{
337 /// Create a new response with specified format and status
338 pub fn new(format: ResponseFormat, status: StatusCode, message: T) -> Self {
339 Self {
340 format,
341 status,
342 message,
343 }
344 }
345
346 /// Create a 200 OK response
347 pub fn ok(format: ResponseFormat, message: T) -> Self {
348 Self::new(format, StatusCode::OK, message)
349 }
350
351 /// Create a 201 Created response
352 pub fn created(format: ResponseFormat, message: T) -> Self {
353 Self::new(format, StatusCode::CREATED, message)
354 }
355
356 /// Create a 202 Accepted response
357 pub fn accepted(format: ResponseFormat, message: T) -> Self {
358 Self::new(format, StatusCode::ACCEPTED, message)
359 }
360}
361
362impl<T> IntoResponse for ApiResponse<T>
363where
364 T: Message + Serialize,
365{
366 fn into_response(self) -> Response {
367 match self.format {
368 | ResponseFormat::Json => {
369 match serde_json::to_vec(&self.message) {
370 | Ok(bytes) => (self.status, [(CONTENT_TYPE, APPLICATION_JSON)], bytes).into_response(),
371 | Err(e) => {
372 // Fallback to error response if JSON serialization fails
373 (
374 StatusCode::INTERNAL_SERVER_ERROR,
375 [(CONTENT_TYPE, APPLICATION_JSON)],
376 format!("{{\"error\": \"JSON serialization failed: {}\"}}", e),
377 )
378 .into_response()
379 }
380 }
381 }
382 | ResponseFormat::Protobuf => {
383 let bytes = self.message.encode_to_vec();
384 (self.status, [(CONTENT_TYPE, APPLICATION_PROTOBUF)], bytes).into_response()
385 }
386 }
387 }
388}
389
390// ============================================================================
391// Result Type
392// ============================================================================
393
394/// Result type for dual-format responses
395pub type ApiResult<T> = Result<ApiResponse<T>, JetError>;
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn test_response_format_without_debug_keys() {
403 // Without configuring debug keys, JSON should be disabled
404 // (default state - OnceLock not set returns None)
405
406 // All requests should get Protobuf when debug keys not configured
407 assert_eq!(ResponseFormat::from_headers(None, None), ResponseFormat::Protobuf);
408 assert_eq!(
409 ResponseFormat::from_headers(Some("application/x-protobuf"), None),
410 ResponseFormat::Protobuf
411 );
412 // Even if Accept is JSON, without debug header it should be Protobuf
413 assert_eq!(
414 ResponseFormat::from_headers(Some("application/json"), None),
415 ResponseFormat::Protobuf
416 );
417 assert_eq!(
418 ResponseFormat::from_headers(Some("application/json"), Some("any-key")),
419 ResponseFormat::Protobuf
420 );
421 }
422
423 #[test]
424 fn test_is_json_allowed_not_configured() {
425 // When not configured, JSON should be disabled
426 assert!(!is_json_allowed(None));
427 assert!(!is_json_allowed(Some("any-value")));
428 }
429
430 #[test]
431 fn test_response_format_is_json() {
432 assert!(!ResponseFormat::Protobuf.is_json());
433 assert!(ResponseFormat::Json.is_json());
434 }
435}