parse_rust_server/response.rs
1//! Error response envelopes.
2//!
3//! Parse has **two different error bodies**, and they are not interchangeable. Getting this
4//! wrong is invisible in a browser and breaks SDKs, because clients branch on the presence of
5//! `code`.
6//!
7//! | Source | Status | Body |
8//! |---|---|---|
9//! | A `Parse.Error` | 400, or 404 for `OBJECT_NOT_FOUND`, or 500 for `INTERNAL_SERVER_ERROR` | `{"code":N,"error":"..."}` |
10//! | An HTTP-level rejection | as given, e.g. 403 | `{"error":"..."}` with **no `code`** |
11//!
12//! Upstream: `handleParseErrors` (`middlewares.js:596-645`). The `code`-less shape comes from
13//! the `err.status && err.message` branch at `:629-631`.
14
15use axum::response::{IntoResponse, Response};
16use http::StatusCode;
17use parse_rust_core::{ErrorCode, ParseError};
18
19/// An HTTP-level rejection: a status and a message, with no Parse error code.
20///
21/// Kept as a distinct type from `ParseError` rather than a variant of it, so that a route
22/// cannot accidentally emit one envelope where the other is required.
23#[derive(Debug, Clone)]
24pub struct HttpError {
25 pub status: StatusCode,
26 pub message: String,
27}
28
29impl HttpError {
30 /// The master-key gate's rejection.
31 ///
32 /// `promiseEnforceMasterKeyAccess` builds this through `createSanitizedHttpError`
33 /// (`Error.js:32-43`), which logs the detailed reason server-side and sends a generic one to
34 /// the client when `enableSanitizedErrorResponse` is true, which is the default.
35 ///
36 /// The detailed message is `unauthorized: master key is required`; the client sees
37 /// `Permission denied`. Both strings are asserted by `spec/features.spec.js`, the first via
38 /// a logger spy and the second in the response body.
39 pub fn master_key_required(sanitized: bool) -> Self {
40 Self {
41 status: StatusCode::FORBIDDEN,
42 message: if sanitized {
43 "Permission denied".to_string()
44 } else {
45 "unauthorized: master key is required".to_string()
46 },
47 }
48 }
49
50 /// The header layer's rejection. Upstream's `invalidRequest` (`middlewares.js:829-832`).
51 ///
52 /// Note it is **not** sanitization-dependent and the message is lowercase `unauthorized`,
53 /// unlike the master-key gate above. Two similar-looking 403s with different bodies.
54 pub fn unauthorized() -> Self {
55 Self {
56 status: StatusCode::FORBIDDEN,
57 message: "unauthorized".to_string(),
58 }
59 }
60}
61
62impl IntoResponse for HttpError {
63 fn into_response(self) -> Response {
64 let body = format!("{{\"error\":{}}}", json_string(&self.message));
65 (
66 self.status,
67 [(
68 http::header::CONTENT_TYPE,
69 "application/json; charset=utf-8",
70 )],
71 body,
72 )
73 .into_response()
74 }
75}
76
77/// A `Parse.Error`, with upstream's status mapping.
78pub struct ParseErrorResponse(pub ParseError);
79
80impl IntoResponse for ParseErrorResponse {
81 fn into_response(self) -> Response {
82 // `handleParseErrors` maps exactly two codes and defaults everything else to 400.
83 // The upstream comment on that switch is a literal "TODO: fill out this mapping", so
84 // the sparseness is the contract rather than an oversight to improve on.
85 let status = match self.0.code {
86 ErrorCode::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
87 ErrorCode::ObjectNotFound => StatusCode::NOT_FOUND,
88 _ => StatusCode::BAD_REQUEST,
89 };
90 let body = format!(
91 "{{\"code\":{},\"error\":{}}}",
92 self.0.code.as_i32(),
93 json_string(&self.0.message)
94 );
95 (
96 status,
97 [(
98 http::header::CONTENT_TYPE,
99 "application/json; charset=utf-8",
100 )],
101 body,
102 )
103 .into_response()
104 }
105}
106
107/// Minimal JSON string escaping for error messages, which are server-authored and short.
108fn json_string(s: &str) -> String {
109 let mut out = String::with_capacity(s.len() + 2);
110 out.push('"');
111 for c in s.chars() {
112 match c {
113 '"' => out.push_str("\\\""),
114 '\\' => out.push_str("\\\\"),
115 '\n' => out.push_str("\\n"),
116 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
117 c => out.push(c),
118 }
119 }
120 out.push('"');
121 out
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn the_two_envelopes_are_distinguishable() {
130 // An HTTP rejection has no `code` key at all. A client branching on it must not find one.
131 let http = HttpError::master_key_required(true);
132 assert_eq!(http.message, "Permission denied");
133 assert_eq!(http.status, StatusCode::FORBIDDEN);
134
135 // The header layer's is a different string.
136 assert_eq!(HttpError::unauthorized().message, "unauthorized");
137 }
138
139 #[test]
140 fn sanitization_toggles_only_the_master_key_message() {
141 assert_eq!(
142 HttpError::master_key_required(false).message,
143 "unauthorized: master key is required"
144 );
145 // The header rejection does not participate in sanitization.
146 assert_eq!(HttpError::unauthorized().message, "unauthorized");
147 }
148
149 #[test]
150 fn escaping_is_applied_to_messages() {
151 assert_eq!(json_string(r#"a"b"#), r#""a\"b""#);
152 assert_eq!(json_string("a\nb"), r#""a\nb""#);
153 }
154}