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}