Skip to main content

parse_rust_server/routes/
dispatch.rs

1//! One route table, two entry points.
2//!
3//! Upstream's `/batch` re-enters its own router: `handleBatch` calls
4//! `router.tryRouteRequest(method, routablePath, request)` (`batch.js:171`) against the same
5//! `PromiseRouter` every HTTP request goes through, so a sub-request and a top-level request are
6//! the same code. That includes the route middlewares, because `PromiseRouter.route` folds them
7//! into the handler (`PromiseRouter.js:66-84`), which is why a `/schemas` sub-request still needs
8//! the master key.
9//!
10//! Reproducing that shape here is what keeps a sub-request from drifting from its top-level twin.
11//! The axum handlers build a [`Route`] from their path extractors; `/batch` builds one from a
12//! string. Both then call [`dispatch`].
13
14use parse_rust_core::ParseError;
15use serde_json::Value as Json;
16
17use crate::auth::Authority;
18use crate::params::Params;
19use crate::request::RequestContext;
20use crate::response::HttpError;
21use crate::routes::{classes, schemas, sessions, users};
22use crate::state::AppState;
23
24/// A resolved route. Every path parse-rust serves has a variant, and anything else is unroutable.
25///
26/// `/roles` and `/sessions` have their own variants rather than folding into [`Route::Classes`],
27/// even though upstream implements them as `ClassesRouter` with `className()` pinned. The reason
28/// is the method set: `POST /sessions` and `PUT /sessions/:objectId` are out of scope for 0.2.0
29/// and have to 404, and a shared variant could not tell them apart from the class route.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Route {
32    Health,
33    ServerInfo,
34    Users,
35    UsersMe,
36    Login,
37    Logout,
38    Classes {
39        class_name: String,
40    },
41    ClassObject {
42        class_name: String,
43        object_id: String,
44    },
45    Roles,
46    RoleObject {
47        object_id: String,
48    },
49    Sessions,
50    SessionsMe,
51    SessionObject {
52        object_id: String,
53    },
54    Schemas,
55    SchemaClass {
56        class_name: String,
57    },
58    Purge {
59        class_name: String,
60    },
61    Batch,
62}
63
64/// What a handler produced.
65pub struct RouteResponse {
66    pub status: http::StatusCode,
67    pub body: Json,
68}
69
70impl RouteResponse {
71    fn ok(body: Json) -> Self {
72        Self {
73            status: http::StatusCode::OK,
74            body,
75        }
76    }
77
78    fn created(body: Json) -> Self {
79        Self {
80            status: http::StatusCode::CREATED,
81            body,
82        }
83    }
84}
85
86/// Why a handler failed.
87///
88/// Two variants because Parse has two error envelopes and they are not interchangeable: a
89/// `Parse.Error` carries a numeric `code`, an HTTP-level rejection does not
90/// (`middlewares.js:596-645`). Collapsing them into one type is how a route ends up emitting the
91/// wrong one.
92pub enum RouteError {
93    Parse(ParseError),
94    Http(HttpError),
95    /// No route serves this method and path.
96    ///
97    /// A third variant because the two entry points answer it differently and both are upstream's.
98    /// Over HTTP an unmatched path falls off the end of the express router and Express answers a
99    /// bare 404; inside a batch it is `tryRouteRequest`'s `INVALID_JSON` `cannot route <M> <p>`
100    /// (`PromiseRouter.js:123-125`). Folding it into `Parse` gave `POST /sessions` a 400 with a
101    /// Parse code, which is neither.
102    NotFound {
103        method: http::Method,
104        path: String,
105    },
106}
107
108impl From<ParseError> for RouteError {
109    fn from(e: ParseError) -> Self {
110        RouteError::Parse(e)
111    }
112}
113
114/// Match a path against the 0.2.0 route table.
115///
116/// The order of the arms is the order upstream registers them, and it is load-bearing in two
117/// places: `PromiseRouter.match` returns the first route whose layer matches
118/// (`PromiseRouter.js:90-105`), so `/users/me` has to precede `/users/:objectId` and
119/// `/sessions/me` has to precede `/sessions/:objectId`. Here the literals are matched before the
120/// parameterized arms for the same reason, spelled out rather than left to declaration order.
121pub fn route_of(path: &str) -> Option<Route> {
122    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
123    Some(match segments.as_slice() {
124        ["health"] => Route::Health,
125        ["serverInfo"] => Route::ServerInfo,
126        ["batch"] => Route::Batch,
127
128        ["users"] => Route::Users,
129        ["users", "me"] => Route::UsersMe,
130        ["login"] => Route::Login,
131        ["logout"] => Route::Logout,
132
133        ["classes", class_name] => Route::Classes {
134            class_name: (*class_name).to_string(),
135        },
136        ["classes", class_name, object_id] => Route::ClassObject {
137            class_name: (*class_name).to_string(),
138            object_id: (*object_id).to_string(),
139        },
140
141        ["roles"] => Route::Roles,
142        ["roles", object_id] => Route::RoleObject {
143            object_id: (*object_id).to_string(),
144        },
145
146        ["sessions"] => Route::Sessions,
147        ["sessions", "me"] => Route::SessionsMe,
148        ["sessions", object_id] => Route::SessionObject {
149            object_id: (*object_id).to_string(),
150        },
151
152        ["schemas"] => Route::Schemas,
153        ["schemas", class_name] => Route::SchemaClass {
154            class_name: (*class_name).to_string(),
155        },
156        ["purge", class_name] => Route::Purge {
157            class_name: (*class_name).to_string(),
158        },
159
160        _ => return None,
161    })
162}
163
164/// One request, as the dispatcher sees it.
165///
166/// A struct rather than a parameter list because both entry points build the same five values and
167/// a positional call of that width is where an argument silently swaps places.
168pub struct Incoming {
169    pub method: http::Method,
170    pub route: Route,
171    /// The routable path. Used only to build the one error message that quotes it
172    /// (`SchemasRouter.js:90`) and the unroutable-path message.
173    pub path: String,
174    pub params: Params,
175    pub body: Option<Json>,
176}
177
178/// Run one route.
179pub async fn dispatch(
180    state: &AppState,
181    rc: &RequestContext,
182    authority: &Authority,
183    incoming: &Incoming,
184) -> Result<RouteResponse, RouteError> {
185    use http::Method as M;
186
187    let Incoming {
188        method,
189        route,
190        path,
191        params,
192        body,
193    } = incoming;
194    let path = path.as_str();
195
196    // The body every write path needs. A route that reaches here with no body is a malformed
197    // request, not an empty write: `Option<Json>` is `None` for an unparsable or oversized body
198    // as well as an absent one, so treating it as `{}` would turn a rejection into a write.
199    let body = || -> Result<&Json, RouteError> {
200        body.as_ref().ok_or_else(|| {
201            RouteError::Parse(ParseError::invalid_json("body must be a JSON object"))
202        })
203    };
204
205    let response = match (route, method) {
206        (Route::Health, &M::GET | &M::POST) => RouteResponse::ok(crate::routes::health::body()),
207
208        (Route::ServerInfo, &M::GET) => {
209            master_only(state, authority)?;
210            RouteResponse::ok(crate::routes::features::server_info_body(state.config()))
211        }
212
213        (Route::Users, &M::POST) => {
214            RouteResponse::created(users::signup_core(state, rc, authority, body()?).await?)
215        }
216        (Route::UsersMe, &M::GET) => RouteResponse::ok(users::me_core(state, rc).await?),
217        (Route::Login, &M::POST) => {
218            RouteResponse::ok(users::login_core(state, rc, authority, body()?).await?)
219        }
220        (Route::Logout, &M::POST) => RouteResponse::ok(users::logout_core(state, rc).await?),
221
222        (Route::Classes { class_name }, &M::GET) => {
223            RouteResponse::ok(classes::find_core(state, rc, authority, class_name, params).await?)
224        }
225        (Route::Classes { class_name }, &M::POST) => RouteResponse::created(
226            classes::create_core(state, rc, authority, class_name, body()?).await?,
227        ),
228        (
229            Route::ClassObject {
230                class_name,
231                object_id,
232            },
233            &M::GET,
234        ) => RouteResponse::ok(
235            classes::get_core(state, rc, authority, class_name, object_id, params).await?,
236        ),
237        (
238            Route::ClassObject {
239                class_name,
240                object_id,
241            },
242            &M::PUT,
243        ) => RouteResponse::ok(
244            classes::update_core(state, rc, authority, class_name, object_id, body()?).await?,
245        ),
246        (
247            Route::ClassObject {
248                class_name,
249                object_id,
250            },
251            &M::DELETE,
252        ) => RouteResponse::ok(
253            classes::delete_core(state, rc, authority, class_name, object_id).await?,
254        ),
255
256        // `RolesRouter` is `ClassesRouter` with `className()` pinned to `_Role` and zero special
257        // handling (`RolesRouter.js:1-27`), so these are the same cores with a fixed class name.
258        // `_Role` requires `name` and `ACL` on write (`SchemaController.js:154-160`), which the
259        // schema crate enforces on the way through.
260        (Route::Roles, &M::GET) => {
261            RouteResponse::ok(classes::find_core(state, rc, authority, ROLE_CLASS, params).await?)
262        }
263        (Route::Roles, &M::POST) => RouteResponse::created(
264            classes::create_core(state, rc, authority, ROLE_CLASS, body()?).await?,
265        ),
266        (Route::RoleObject { object_id }, &M::GET) => RouteResponse::ok(
267            classes::get_core(state, rc, authority, ROLE_CLASS, object_id, params).await?,
268        ),
269        (Route::RoleObject { object_id }, &M::PUT) => RouteResponse::ok(
270            classes::update_core(state, rc, authority, ROLE_CLASS, object_id, body()?).await?,
271        ),
272        (Route::RoleObject { object_id }, &M::DELETE) => RouteResponse::ok(
273            classes::delete_core(state, rc, authority, ROLE_CLASS, object_id).await?,
274        ),
275
276        (Route::SessionsMe, &M::GET) => RouteResponse::ok(sessions::me_core(state, rc).await?),
277        (Route::Sessions, &M::GET) => RouteResponse::ok(
278            classes::find_core(state, rc, authority, classes::SESSION_CLASS, params).await?,
279        ),
280        (Route::SessionObject { object_id }, &M::GET) => RouteResponse::ok(
281            classes::get_core(
282                state,
283                rc,
284                authority,
285                classes::SESSION_CLASS,
286                object_id,
287                params,
288            )
289            .await?,
290        ),
291        (Route::SessionObject { object_id }, &M::DELETE) => {
292            RouteResponse::ok(classes::delete_session_core(state, rc, object_id).await?)
293        }
294
295        (Route::Schemas, &M::GET) => {
296            master_only(state, authority)?;
297            RouteResponse::ok(schemas::get_all(state).await?)
298        }
299        (Route::Schemas, &M::POST) => {
300            master_only(state, authority)?;
301            RouteResponse::ok(schemas::create(state, path, None, body()?).await?)
302        }
303        (Route::SchemaClass { class_name }, &M::GET) => {
304            master_only(state, authority)?;
305            RouteResponse::ok(schemas::get_one(state, class_name).await?)
306        }
307        (Route::SchemaClass { class_name }, &M::POST) => {
308            master_only(state, authority)?;
309            RouteResponse::ok(schemas::create(state, path, Some(class_name), body()?).await?)
310        }
311        (Route::SchemaClass { class_name }, &M::PUT) => {
312            master_only(state, authority)?;
313            RouteResponse::ok(schemas::update(state, class_name, body()?).await?)
314        }
315        (Route::SchemaClass { class_name }, &M::DELETE) => {
316            master_only(state, authority)?;
317            RouteResponse::ok(schemas::delete(state, class_name).await?)
318        }
319        (Route::Purge { class_name }, &M::DELETE) => {
320            master_only(state, authority)?;
321            RouteResponse::ok(schemas::purge(state, class_name).await?)
322        }
323
324        // Everything else, including `/batch`, which is reached only from the HTTP layer: a
325        // nested one is refused before dispatch. See `batch::handle`.
326        _ => return Err(unroutable(method, path)),
327    };
328    Ok(response)
329}
330
331/// `_Role`, pinned by `RolesRouter.className()`.
332const ROLE_CLASS: &str = "_Role";
333
334fn unroutable(method: &http::Method, path: &str) -> RouteError {
335    RouteError::NotFound {
336        method: method.clone(),
337        path: path.to_string(),
338    }
339}
340
341/// `promiseEnforceMasterKeyAccess`. Master only; maintenance does not satisfy it, because upstream
342/// checks `request.auth.isMaster`.
343fn master_only(state: &AppState, authority: &Authority) -> Result<(), RouteError> {
344    if authority.is_master() {
345        return Ok(());
346    }
347    Err(RouteError::Http(HttpError::master_key_required(
348        state.config().error_detail(),
349    )))
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn the_literal_me_routes_win_over_the_parameterized_ones() {
358        // The trap upstream depends on registration order for (`UsersRouter.js:827-832`,
359        // `SessionsRouter.js:113-121`). Here it is spelled out, so it cannot depend on the order
360        // axum happens to try patterns in.
361        assert_eq!(route_of("/users/me"), Some(Route::UsersMe));
362        assert_eq!(route_of("/sessions/me"), Some(Route::SessionsMe));
363        assert_eq!(
364            route_of("/sessions/abc123"),
365            Some(Route::SessionObject {
366                object_id: "abc123".into()
367            })
368        );
369    }
370
371    #[test]
372    fn class_and_object_paths_are_distinguished_by_depth() {
373        assert_eq!(
374            route_of("/classes/Post"),
375            Some(Route::Classes {
376                class_name: "Post".into()
377            })
378        );
379        assert_eq!(
380            route_of("/classes/Post/abc"),
381            Some(Route::ClassObject {
382                class_name: "Post".into(),
383                object_id: "abc".into()
384            })
385        );
386    }
387
388    #[test]
389    fn everything_outside_the_milestone_surface_is_unroutable() {
390        for path in [
391            "/upgradeToRevocableSession",
392            "/functions/foo",
393            "/config",
394            "/hooks/functions",
395            "/aggregate/Post",
396            "/classes",
397            "/classes/Post/a/b",
398            "",
399            "/",
400        ] {
401            assert!(route_of(path).is_none(), "{path} must not route");
402        }
403    }
404
405    #[test]
406    fn a_leading_and_trailing_slash_do_not_change_the_match() {
407        assert_eq!(route_of("/schemas/"), Some(Route::Schemas));
408        assert_eq!(
409            route_of("/purge/Post"),
410            Some(Route::Purge {
411                class_name: "Post".into()
412            })
413        );
414    }
415}