Skip to main content

parse_rust_server/routes/
batch.rs

1//! `POST /batch`.
2//!
3//! Upstream: `src/batch.js`. The body is `{requests: [{method, path, body}, ...]}` and the
4//! response is the results **array itself**, not an object wrapping it (`batch.js:194`).
5//!
6//! Sub-requests share one auth, one role expansion and one schema snapshot with the request that
7//! carried them, which is upstream's `request.auth = req.auth` (`batch.js:167`) plus the fact that
8//! everything downstream takes the schema controller it was handed. Twenty writes in one batch
9//! therefore cannot see two different schemas mid-flight.
10//!
11//! **`transaction: true` is refused rather than accepted and ignored.** Upstream opens a real
12//! transactional session for it (`batch.js:155-156`) and rolls the whole batch back on any error.
13//! parse-rust has no transaction support, and a client that asked for all-or-nothing and silently
14//! got per-operation semantics is the failure mode this milestone names by name.
15
16use parse_rust_core::{ErrorCode, ErrorOrigin, ParseError};
17use serde_json::{json, Value as Json};
18
19use crate::auth::Authority;
20use crate::params::Params;
21use crate::request::RequestContext;
22use crate::routes::dispatch::{self, RouteError};
23use crate::state::AppState;
24
25/// `/batch`, as a routable path. The suffix stripped from the request URL to find the API prefix.
26const BATCH_PATH: &str = "/batch";
27
28/// Run a batch.
29///
30/// `mount_path` is the configured mount, which is what upstream derives by removing the trailing
31/// `/batch` from `req.originalUrl` (`batch.js:27-28`). Taking it from config rather than
32/// reconstructing it is the same rule that applies to every other generated path: the mount is a
33/// builder input, never inferred from the request.
34pub async fn handle(
35    state: &AppState,
36    rc: &RequestContext,
37    authority: &Authority,
38    mount_path: &str,
39    body: Option<&Json>,
40) -> Result<Json, ParseError> {
41    let Some(Json::Object(body)) = body else {
42        return Err(ParseError::invalid_json("requests must be an array"));
43    };
44
45    if matches!(body.get("transaction"), Some(Json::Bool(true))) {
46        return Err(ParseError::new(
47            ErrorCode::CommandUnavailable,
48            "Batch transactions are not supported yet. Retry without `transaction: true`; \
49             the sub-requests will be applied independently and reported per operation.",
50        ));
51    }
52
53    let Some(Json::Array(requests)) = body.get("requests") else {
54        return Err(ParseError::invalid_json("requests must be an array"));
55    };
56
57    // `batchRequestLimit` defaults to -1, which disables it. Master and maintenance bypass it
58    // (`batch.js:72-78`).
59    let limit = state.config().batch_request_limit;
60    if limit > -1 && !authority.is_privileged() && requests.len() as i64 > limit {
61        return Err(ParseError::invalid_json(format!(
62            "Batch request contains {} sub-requests, which exceeds the limit of {limit}.",
63            requests.len()
64        )));
65    }
66
67    // Both validation passes run over the whole array before anything executes, so a batch with
68    // one malformed element performs none of the others (`batch.js:79-83`, `:104-108`).
69    let mut parsed = Vec::with_capacity(requests.len());
70    for request in requests {
71        let Json::Object(request) = request else {
72            return Err(ParseError::invalid_json(
73                "batch request path must be a string",
74            ));
75        };
76        let Some(Json::String(path)) = request.get("path") else {
77            return Err(ParseError::invalid_json(
78                "batch request path must be a string",
79            ));
80        };
81        let method = match request.get("method") {
82            Some(Json::String(m)) => m.to_uppercase(),
83            _ => "GET".to_string(),
84        };
85        let routable = routable_path(path, mount_path)?;
86        if method == "POST" && routable == BATCH_PATH {
87            return Err(ParseError::invalid_json(
88                "nested batch requests are not allowed",
89            ));
90        }
91        parsed.push((method, routable, request.get("body").cloned()));
92    }
93
94    let mut results = Vec::with_capacity(parsed.len());
95    for (method, path, body) in parsed {
96        results.push(run_one(state, rc, authority, &method, &path, body.as_ref()).await);
97    }
98    Ok(Json::Array(results))
99}
100
101/// One sub-request, rendered as `{success: ...}` or `{error: {code, error}}` (`batch.js:171-178`).
102async fn run_one(
103    state: &AppState,
104    rc: &RequestContext,
105    authority: &Authority,
106    method: &str,
107    path: &str,
108    body: Option<&Json>,
109) -> Json {
110    let Ok(method) = method.parse::<http::Method>() else {
111        return json!({ "error": {
112            "code": ErrorCode::InvalidJson.as_i32(),
113            "error": format!("cannot route {method} {path}"),
114        }});
115    };
116    let Some(route) = dispatch::route_of(path) else {
117        return json!({ "error": {
118            "code": ErrorCode::InvalidJson.as_i32(),
119            "error": format!("cannot route {method} {path}"),
120        }});
121    };
122
123    // A sub-request has no URL, so its query parameters are its body. That is why upstream's
124    // `handleFind` merges the two before reading either (`ClassesRouter.js:23`).
125    let params = if matches!(method, http::Method::GET | http::Method::DELETE) {
126        Params::from_json(body)
127    } else {
128        Params::default()
129    };
130
131    let incoming = dispatch::Incoming {
132        method,
133        route,
134        path: path.to_string(),
135        params,
136        body: body.cloned(),
137    };
138    match dispatch::dispatch(state, rc, authority, &incoming).await {
139        Ok(response) => json!({ "success": response.body }),
140        // A batch renders whatever was thrown as `{code, error}` and never reaches
141        // `handleParseErrors`, so upstream's third branch does not apply and a bare `Error`
142        // arrives here with `code` undefined (`batch.js:175-177`).
143        //
144        // parse-rust withholds the detail of an internal error on every path, inside a batch as
145        // well as outside one, under the security carve-out. The shape stays upstream's, meaning
146        // no `code` key, and only the message becomes the generic one.
147        Err(RouteError::Parse(e)) if e.origin == ErrorOrigin::Internal => {
148            json!({ "error": { "error": crate::response::INTERNAL_SERVER_ERROR_MESSAGE }})
149        }
150        Err(RouteError::Parse(e)) => json!({ "error": {
151            "code": e.code.as_i32(),
152            "error": e.message,
153        }}),
154        // UPSTREAM-QUIRK: the batch error branch reads `error.code` off whatever was thrown
155        // (`batch.js:176`), and an HTTP-level rejection has none. `JSON.stringify` drops the
156        // resulting `undefined`, so the master-key gate answers a `code`-less error object inside
157        // a batch and a `code`-less body outside one. Reproduced rather than given a code, because
158        // a client branching on the key's presence would see an invented one.
159        Err(RouteError::Http(e)) => json!({ "error": { "error": e.message }}),
160        // Inside a batch an unroutable sub-request is a `Parse.Error`, because that is what
161        // `tryRouteRequest` throws (`PromiseRouter.js:123-125`). Outside one it is a 404.
162        Err(RouteError::NotFound { method, path }) => json!({ "error": {
163            "code": ErrorCode::InvalidJson.as_i32(),
164            "error": format!("cannot route {method} {path}"),
165        }}),
166    }
167}
168
169/// Strip the API prefix from a sub-request path (`batch.js:30-36`).
170///
171/// A path outside the prefix is `INVALID_JSON` `cannot route batch path <path>`. The result is
172/// joined onto `/`, so `/parse` alone becomes `/` rather than the empty string.
173fn routable_path(path: &str, mount_path: &str) -> Result<String, ParseError> {
174    let prefix = mount_path.trim_end_matches('/');
175    let rest = if prefix.is_empty() {
176        Some(path)
177    } else {
178        path.strip_prefix(prefix)
179    };
180    let Some(rest) = rest else {
181        return Err(ParseError::invalid_json(format!(
182            "cannot route batch path {path}"
183        )));
184    };
185    // `path.posix.join('/', x)`: a leading slash is guaranteed and a trailing one is dropped
186    // unless the whole path is `/`.
187    let trimmed = rest.trim_matches('/');
188    if trimmed.is_empty() {
189        return Ok("/".to_string());
190    }
191    Ok(format!("/{trimmed}"))
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn the_prefix_is_the_configured_mount_and_nothing_else() {
200        assert_eq!(
201            routable_path("/parse/classes/Post", "/parse").expect("routes"),
202            "/classes/Post"
203        );
204        assert_eq!(routable_path("/parse", "/parse").expect("routes"), "/");
205        assert_eq!(
206            routable_path("/classes/Post", "/").expect("routes"),
207            "/classes/Post"
208        );
209    }
210
211    #[test]
212    fn a_path_outside_the_prefix_is_refused_by_name() {
213        let e = routable_path("/other/classes/Post", "/parse").unwrap_err();
214        assert_eq!(e.code, ErrorCode::InvalidJson);
215        assert_eq!(e.message, "cannot route batch path /other/classes/Post");
216    }
217
218    /// The prefix is a string prefix upstream, so a mount of `/parse` also accepts `/parsexyz`.
219    /// Reproduced: the routable path then fails to match any route and reports that instead.
220    #[test]
221    fn a_prefix_that_only_looks_like_the_mount_still_fails_to_route() {
222        let routable = routable_path("/parsexyz/classes/Post", "/parse").expect("prefix matches");
223        assert_eq!(routable, "/xyz/classes/Post");
224        assert!(dispatch::route_of(&routable).is_none());
225    }
226}