Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions crates/client-api/src/routes/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,7 @@ mod tests {
use super::*;
use crate::auth::JwtAuthProvider;
use crate::routes::subscribe::{HasWebSocketOptions, WebSocketOptions};
use crate::routes::{identity::IdentityRoutes, router_with_root_routes, RootRoutes};
use crate::{
Action, Authorization, ControlStateReadAccess, ControlStateWriteAccess, MaybeMisdirected, Unauthorized,
};
Expand Down Expand Up @@ -2452,4 +2453,79 @@ mod tests {

remove_http_response_size_metric(database_identity);
}

fn root_router(root_routes: RootRoutes<DummyState>) -> axum::Router {
let state = DummyState::new();
router_with_root_routes(
&state,
DatabaseRoutes::default(),
IdentityRoutes::default(),
root_routes,
axum::Router::new(),
)
.with_state(state)
}

fn post_mcp_root(body: &'static str) -> Request<Body> {
Request::builder()
.method(http::Method::POST)
.uri("/v1/mcp")
.header(http::header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.unwrap()
}

#[tokio::test]
async fn default_root_routes_serve_the_real_handlers() {
let app = root_router(RootRoutes::default());

let response = app
.clone()
.oneshot(post_mcp_root(
r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ping"}}"#,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert!(std::str::from_utf8(&body).unwrap().contains("pong"));

let response = app
.oneshot(Request::builder().uri("/v1/ping").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn a_substituted_root_mcp_route_replaces_the_default_handler() {
let app = root_router(RootRoutes {
mcp_post: axum::routing::post(|| async { "substituted" }),
..Default::default()
});

let response = app.oneshot(post_mcp_root("")).await.unwrap();

assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "substituted");
}

#[tokio::test]
async fn the_auth_middleware_runs_before_substituted_root_layers() {
let app = root_router(RootRoutes {
mcp_post: axum::routing::post(|| async { "substituted" }).layer(axum::middleware::from_fn(
|request: axum::extract::Request, next: axum::middleware::Next| async move {
if request.extensions().get::<crate::auth::SpacetimeAuth>().is_none() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
next.run(request).await
},
)),
..Default::default()
});

let response = app.oneshot(post_mcp_root("")).await.unwrap();

assert_eq!(response.status(), StatusCode::OK);
}
}
41 changes: 37 additions & 4 deletions crates/client-api/src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use axum::routing::MethodRouter;
use http::header;
use tower_http::cors;

Expand All @@ -20,7 +21,27 @@ use self::{database::DatabaseRoutes, identity::IdentityRoutes};
/// establish a connection to SpacetimeDB. This API call doesn't actually do anything.
pub async fn ping(_auth: crate::auth::SpacetimeAuthHeader) {}

#[allow(clippy::let_and_return)]
/// Allows the edition to customize the routes directly under `/v1`, as [`DatabaseRoutes`] does for `/database`.
pub struct RootRoutes<S> {
/// GET: /ping
pub ping_get: MethodRouter<S>,
/// POST: /mcp
pub mcp_post: MethodRouter<S>,
}

impl<S> Default for RootRoutes<S>
where
S: NodeDelegate + ControlStateDelegate + Authorization + Clone + 'static,
{
fn default() -> Self {
use axum::routing::{get, post};
Self {
ping_get: get(ping),
mcp_post: post(mcp::mcp_root::<S>),
}
}
}

pub fn router<S>(
ctx: &S,
database_routes: DatabaseRoutes<S>,
Expand All @@ -30,7 +51,19 @@ pub fn router<S>(
where
S: NodeDelegate + ControlStateDelegate + Authorization + Clone + 'static,
{
use axum::routing::{get, post};
router_with_root_routes(ctx, database_routes, identity_routes, RootRoutes::default(), extra)
}

pub fn router_with_root_routes<S>(
ctx: &S,
database_routes: DatabaseRoutes<S>,
identity_routes: IdentityRoutes<S>,
root_routes: RootRoutes<S>,
extra: axum::Router<S>,
) -> axum::Router<S>
where
S: NodeDelegate + ControlStateDelegate + Authorization + Clone + 'static,
{
let router = axum::Router::new()
.nest("/database", database_routes.into_router(ctx.clone()))
.nest("/identity", identity_routes.into_router())
Expand All @@ -40,12 +73,12 @@ where
// the database is named in the request body, so `mcp_root` counts its own egress
.route(
"/mcp",
post(mcp::mcp_root::<S>).route_layer(axum::middleware::from_fn_with_state(
root_routes.mcp_post.route_layer(axum::middleware::from_fn_with_state(
ctx.clone(),
crate::auth::anon_auth_middleware::<S>,
)),
)
.route("/ping", get(ping))
.route("/ping", root_routes.ping_get)
.merge(extra);

let cors = cors::CorsLayer::new()
Expand Down
Loading