Skip to main content

this/server/exposure/rest/
mod.rs

1//! REST API exposure for the framework
2//!
3//! This module provides REST-specific routing and handlers.
4//! It is isolated from the core framework logic and can be replaced
5//! or extended with other protocols (GraphQL, gRPC, etc.)
6//!
7//! The REST exposure consumes a `ServerHost` and produces an Axum `Router`.
8
9pub mod notifications;
10pub mod sse;
11
12use super::super::host::ServerHost;
13use crate::links::handlers::AppState;
14use crate::server::router::build_link_routes;
15use anyhow::Result;
16use axum::{Json, Router, routing::get};
17use serde_json::{Value, json};
18use std::sync::Arc;
19
20/// REST API exposure implementation
21///
22/// This struct encapsulates all REST-specific logic for exposing the API.
23/// It is completely separate from the framework core and can be replaced
24/// with other exposure types (GraphQL, gRPC, etc.).
25pub struct RestExposure;
26
27impl RestExposure {
28    /// Build the REST router from a host
29    ///
30    /// This method takes a `ServerHost` (which is transport-agnostic) and
31    /// builds an Axum router with all REST endpoints.
32    ///
33    /// # Arguments
34    ///
35    /// * `host` - The server host containing all framework state
36    /// * `custom_routes` - Additional custom routes to merge
37    ///
38    /// # Returns
39    ///
40    /// Returns a fully configured Axum router with:
41    /// - Health check routes
42    /// - Entity CRUD routes
43    /// - Link routes
44    /// - Custom routes
45    pub fn build_router(host: Arc<ServerHost>, custom_routes: Vec<Router>) -> Result<Router> {
46        // Create link app state from host
47        let link_state = AppState {
48            link_service: host.link_service.clone(),
49            config: host.config.clone(),
50            registry: host.registry.clone(),
51            entity_fetchers: host.entity_fetchers.clone(),
52            entity_creators: host.entity_creators.clone(),
53            event_bus: host.event_bus.clone(),
54        };
55
56        // Build all routes
57        let health_routes = Self::health_routes();
58        let entity_routes = host.entity_registry.build_routes();
59        let link_routes = build_link_routes(link_state.clone());
60
61        // Merge everything
62        let mut app = health_routes.merge(entity_routes);
63
64        for custom_router in custom_routes {
65            app = app.merge(custom_router);
66        }
67
68        app = app.merge(link_routes);
69
70        // SSE event stream — only if EventBus is configured
71        if let Some(event_bus) = &host.event_bus {
72            let sse_routes = Router::new()
73                .route("/events/stream", get(sse::sse_handler))
74                .with_state(event_bus.clone());
75            app = app.merge(sse_routes);
76        }
77
78        // Notification, preferences, and device token endpoints
79        // — only if notification_store is configured
80        if let (Some(notification_store), Some(preferences_store), Some(device_token_store)) = (
81            &host.notification_store,
82            &host.preferences_store,
83            &host.device_token_store,
84        ) {
85            let notif_state = notifications::NotificationState {
86                notification_store: notification_store.clone(),
87                preferences_store: preferences_store.clone(),
88                device_token_store: device_token_store.clone(),
89            };
90            app = app.merge(notifications::notification_routes(notif_state));
91        }
92
93        Ok(app)
94    }
95
96    /// Build health check routes
97    fn health_routes() -> Router {
98        Router::new()
99            .route("/health", get(Self::health_check))
100            .route("/healthz", get(Self::health_check))
101    }
102
103    /// Health check endpoint handler
104    async fn health_check() -> Json<Value> {
105        Json(json!({
106            "status": "ok",
107            "service": "this-rs"
108        }))
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::config::LinksConfig;
116    use crate::server::entity_registry::EntityRegistry;
117    use crate::server::host::ServerHost;
118    use crate::storage::InMemoryLinkService;
119    use axum::body::Body;
120    use axum::http::{Request, StatusCode};
121    use std::collections::HashMap;
122    use tower::ServiceExt;
123
124    /// Build a minimal ServerHost for testing
125    fn test_host() -> Arc<ServerHost> {
126        let host = ServerHost::from_builder_components(
127            Arc::new(InMemoryLinkService::new()),
128            LinksConfig::default_config(),
129            EntityRegistry::new(),
130            HashMap::new(),
131            HashMap::new(),
132        )
133        .expect("should build host");
134        Arc::new(host)
135    }
136
137    #[test]
138    fn test_health_routes_builds_router() {
139        let router = RestExposure::health_routes();
140        let _ = router;
141    }
142
143    #[tokio::test]
144    async fn test_health_endpoint_returns_ok() {
145        let router = RestExposure::health_routes();
146        let response = router
147            .oneshot(
148                Request::builder()
149                    .uri("/health")
150                    .body(Body::empty())
151                    .expect("request should build"),
152            )
153            .await
154            .expect("response should succeed");
155        assert_eq!(response.status(), StatusCode::OK);
156
157        let body = axum::body::to_bytes(response.into_body(), 1024)
158            .await
159            .expect("body should read");
160        let json: serde_json::Value =
161            serde_json::from_slice(&body).expect("body should be valid JSON");
162        assert_eq!(json["status"], "ok");
163        assert_eq!(json["service"], "this-rs");
164    }
165
166    #[tokio::test]
167    async fn test_healthz_endpoint_returns_ok() {
168        let router = RestExposure::health_routes();
169        let response = router
170            .oneshot(
171                Request::builder()
172                    .uri("/healthz")
173                    .body(Body::empty())
174                    .expect("request should build"),
175            )
176            .await
177            .expect("response should succeed");
178        assert_eq!(response.status(), StatusCode::OK);
179
180        let body = axum::body::to_bytes(response.into_body(), 1024)
181            .await
182            .expect("body should read");
183        let json: serde_json::Value =
184            serde_json::from_slice(&body).expect("body should be valid JSON");
185        assert_eq!(json["status"], "ok");
186    }
187
188    #[test]
189    fn test_build_router_succeeds_with_host() {
190        let host = test_host();
191        let router = RestExposure::build_router(host, vec![]);
192        assert!(router.is_ok());
193    }
194
195    #[test]
196    fn test_build_router_with_custom_routes() {
197        use axum::routing::get;
198
199        let host = test_host();
200        let custom = Router::new().route("/custom", get(|| async { "custom" }));
201        let router = RestExposure::build_router(host, vec![custom]);
202        assert!(router.is_ok());
203    }
204
205    #[tokio::test]
206    async fn test_build_router_health_endpoint_reachable() {
207        let host = test_host();
208        let router = RestExposure::build_router(host, vec![]).expect("build should succeed");
209        let response = router
210            .oneshot(
211                Request::builder()
212                    .uri("/health")
213                    .body(Body::empty())
214                    .expect("request should build"),
215            )
216            .await
217            .expect("response should succeed");
218        assert_eq!(response.status(), StatusCode::OK);
219    }
220}