Skip to main content

brainos_httpadapter/
lib.rs

1//! # Brain HTTP REST API Adapter
2//!
3//! Exposes Brain's signal processing pipeline over HTTP using axum.
4//!
5//! ## Routes
6//! - `GET  /health`             — health check (no auth required)
7//! - `GET  /metrics`            — Prometheus-format counters (no auth required)
8//! - `GET  /ui`                 — embedded memory explorer web UI (no auth required)
9//! - `GET  /openapi.json`       — OpenAPI 3.0 specification (no auth required)
10//! - `GET  /api`                 — Swagger UI (no auth required)
11//! - `POST /v1/signals`         — submit a signal (requires write)
12//! - `GET  /v1/signals/:id`     — retrieve cached signal response (requires read)
13//! - `POST /v1/memory/search`   — semantic search over stored facts (requires read)
14//! - `GET  /v1/memory/facts`    — list all semantic facts (requires read)
15//! - `GET  /v1/events`          — SSE stream of signal events + proactive notifications (requires read)
16//!
17//! ## Authentication
18//! All `/v1/*` routes require `Authorization: Bearer <api-key>` header.
19//! A random key is generated on `brain init` and printed to stdout.
20
21pub mod auth;
22pub mod handlers;
23pub mod metrics;
24pub mod server;
25pub mod state;
26pub mod types;
27
28// Re-export primary public types for convenience.
29pub use server::{create_router, serve};
30pub use state::AppState;
31pub use types::HttpAdapterError;
32
33// ─── Tests ───────────────────────────────────────────────────────────────────
34
35#[cfg(test)]
36mod tests {
37    use std::{num::NonZeroUsize, sync::Arc};
38
39    use axum::{
40        body::Body,
41        http::{self, Request},
42        routing::{get, post},
43        Router,
44    };
45    use tokio::sync::Mutex;
46    use tower::util::ServiceExt;
47    use uuid::Uuid;
48
49    use super::handlers::*;
50    use super::metrics::Metrics;
51    use super::state::{AppState, CACHE_CAPACITY};
52    use super::types::*;
53
54    /// Build a test router with the API key pre-loaded.
55    async fn make_router() -> (Router, tempfile::TempDir, String) {
56        let temp = tempfile::tempdir().unwrap();
57        let mut config = brain_core::BrainConfig::default();
58        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
59        let api_key = config.access.api_keys.first().unwrap().key.clone();
60        let api_keys = config.access.api_keys.clone();
61        let processor = signal::SignalProcessor::new(config).await.unwrap();
62        let router = crate::create_router(
63            Arc::new(processor),
64            std::collections::HashMap::new(),
65            api_keys,
66            true,
67        );
68        (router, temp, api_key)
69    }
70
71    #[test]
72    fn test_parse_source_defaults_to_http() {
73        assert_eq!(
74            signal::SignalSource::parse(None, signal::SignalSource::Http),
75            signal::SignalSource::Http
76        );
77        assert_eq!(
78            signal::SignalSource::parse(Some("http"), signal::SignalSource::Http),
79            signal::SignalSource::Http
80        );
81    }
82
83    #[test]
84    fn test_parse_source_all_variants() {
85        assert_eq!(
86            signal::SignalSource::parse(Some("cli"), signal::SignalSource::Http),
87            signal::SignalSource::Cli
88        );
89        assert_eq!(
90            signal::SignalSource::parse(Some("ws"), signal::SignalSource::Http),
91            signal::SignalSource::WebSocket
92        );
93        assert_eq!(
94            signal::SignalSource::parse(Some("mcp"), signal::SignalSource::Http),
95            signal::SignalSource::Mcp
96        );
97        assert_eq!(
98            signal::SignalSource::parse(Some("grpc"), signal::SignalSource::Http),
99            signal::SignalSource::Grpc
100        );
101    }
102
103    #[test]
104    fn test_health_response_serializes() {
105        let h = HealthResponse {
106            status: "ok",
107            version: "1.0.0",
108        };
109        let json = serde_json::to_string(&h).unwrap();
110        assert!(json.contains("\"status\":\"ok\""));
111        assert!(json.contains("\"version\""));
112    }
113
114    #[test]
115    fn test_fact_json_serializes() {
116        let f = FactJson {
117            id: "abc".into(),
118            namespace: "personal".into(),
119            category: "personal".into(),
120            subject: "user".into(),
121            predicate: "likes".into(),
122            object: "Rust".into(),
123            confidence: 0.9,
124            distance: Some(0.05),
125        };
126        let json = serde_json::to_string(&f).unwrap();
127        assert!(json.contains("\"subject\":\"user\""));
128        assert!(json.contains("\"namespace\":\"personal\""));
129        assert!(json.contains("\"distance\":0.05"));
130    }
131
132    /// GET /openapi.json — no auth required, returns valid OpenAPI spec.
133    #[tokio::test]
134    async fn test_openapi_endpoint() {
135        let (router, _tmp, _api_key) = make_router().await;
136
137        let request = Request::builder()
138            .method(http::Method::GET)
139            .uri("/openapi.json")
140            .body(Body::empty())
141            .unwrap();
142
143        let response = router.oneshot(request).await.unwrap();
144        assert_eq!(response.status(), http::StatusCode::OK);
145
146        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
147            .await
148            .unwrap();
149        let spec: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
150        assert_eq!(spec["openapi"], "3.0.3");
151        assert!(
152            spec["paths"]["/v1/signals"].is_object(),
153            "missing /v1/signals path"
154        );
155        assert!(
156            spec["components"]["schemas"]["FactJson"].is_object(),
157            "missing FactJson schema"
158        );
159    }
160
161    /// GET /api — no auth required, returns Swagger UI HTML.
162    #[tokio::test]
163    async fn test_swagger_ui_endpoint() {
164        let (router, _tmp, _api_key) = make_router().await;
165
166        let request = Request::builder()
167            .method(http::Method::GET)
168            .uri("/api")
169            .body(Body::empty())
170            .unwrap();
171
172        let response = router.oneshot(request).await.unwrap();
173        assert_eq!(response.status(), http::StatusCode::OK);
174
175        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
176            .await
177            .unwrap();
178        let body = std::str::from_utf8(&bytes).unwrap();
179        assert!(body.contains("swagger-ui"), "missing Swagger UI element");
180        assert!(body.contains("/openapi.json"), "missing spec URL reference");
181    }
182
183    /// GET /ui — no auth required, returns HTML page.
184    #[tokio::test]
185    async fn test_ui_endpoint() {
186        let (router, _tmp, _api_key) = make_router().await;
187
188        let request = Request::builder()
189            .method(http::Method::GET)
190            .uri("/ui")
191            .body(Body::empty())
192            .unwrap();
193
194        let response = router.oneshot(request).await.unwrap();
195        assert_eq!(response.status(), http::StatusCode::OK);
196
197        let ct = response
198            .headers()
199            .get("content-type")
200            .unwrap()
201            .to_str()
202            .unwrap();
203        assert!(ct.contains("text/html"), "expected text/html, got: {ct}");
204
205        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
206            .await
207            .unwrap();
208        let body = std::str::from_utf8(&bytes).unwrap();
209        assert!(body.contains("Brain Memory Explorer"), "missing page title");
210        assert!(
211            body.contains("/v1/memory/search"),
212            "missing API endpoint reference"
213        );
214    }
215
216    /// GET /metrics — no auth required, returns Prometheus text.
217    #[tokio::test]
218    async fn test_metrics_endpoint() {
219        let (router, _tmp, _api_key) = make_router().await;
220
221        let request = Request::builder()
222            .method(http::Method::GET)
223            .uri("/metrics")
224            .body(Body::empty())
225            .unwrap();
226
227        let response = router.oneshot(request).await.unwrap();
228        assert_eq!(response.status(), http::StatusCode::OK);
229
230        let ct = response
231            .headers()
232            .get("content-type")
233            .unwrap()
234            .to_str()
235            .unwrap();
236        assert!(ct.contains("text/plain"), "expected text/plain, got: {ct}");
237
238        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
239            .await
240            .unwrap();
241        let body = std::str::from_utf8(&bytes).unwrap();
242        assert!(
243            body.contains("brain_signals_total"),
244            "missing counter in metrics output"
245        );
246        assert!(
247            body.contains("brain_search_total"),
248            "missing search counter"
249        );
250    }
251
252    /// GET /health — no auth required, always returns 200.
253    #[tokio::test]
254    async fn test_health_endpoint() {
255        let (router, _tmp, _api_key) = make_router().await;
256
257        let request = Request::builder()
258            .method(http::Method::GET)
259            .uri("/health")
260            .body(Body::empty())
261            .unwrap();
262
263        let response = router.oneshot(request).await.unwrap();
264        assert_eq!(response.status(), http::StatusCode::OK);
265
266        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
267            .await
268            .unwrap();
269        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
270        assert_eq!(body["status"], "ok");
271    }
272
273    /// POST /v1/signals without auth → 401.
274    #[tokio::test]
275    async fn test_post_signal_no_auth_returns_401() {
276        let (router, _tmp, _api_key) = make_router().await;
277
278        let payload = serde_json::json!({"content": "Remember Rust is fast"});
279        let request = Request::builder()
280            .method(http::Method::POST)
281            .uri("/v1/signals")
282            .header("content-type", "application/json")
283            .body(Body::from(serde_json::to_string(&payload).unwrap()))
284            .unwrap();
285
286        let response = router.oneshot(request).await.unwrap();
287        assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED);
288    }
289
290    /// POST /v1/signals with invalid key → 401.
291    #[tokio::test]
292    async fn test_post_signal_invalid_key_returns_401() {
293        let (router, _tmp, _api_key) = make_router().await;
294
295        let payload = serde_json::json!({"content": "Remember Rust is fast"});
296        let request = Request::builder()
297            .method(http::Method::POST)
298            .uri("/v1/signals")
299            .header("content-type", "application/json")
300            .header("authorization", "Bearer wrong-key")
301            .body(Body::from(serde_json::to_string(&payload).unwrap()))
302            .unwrap();
303
304        let response = router.oneshot(request).await.unwrap();
305        assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED);
306    }
307
308    /// POST /v1/signals with valid API key → 200.
309    #[tokio::test]
310    async fn test_post_signal_store_fact_with_auth() {
311        let (router, _tmp, api_key) = make_router().await;
312
313        let payload = serde_json::json!({"content": "Remember that Rust is fast"});
314        let request = Request::builder()
315            .method(http::Method::POST)
316            .uri("/v1/signals")
317            .header("content-type", "application/json")
318            .header("authorization", format!("Bearer {api_key}"))
319            .body(Body::from(serde_json::to_string(&payload).unwrap()))
320            .unwrap();
321
322        let response = router.oneshot(request).await.unwrap();
323        assert_eq!(response.status(), http::StatusCode::OK);
324
325        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
326            .await
327            .unwrap();
328        let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
329        assert_eq!(resp["status"], "Ok");
330    }
331
332    /// GET /v1/memory/facts with no auth → 401.
333    #[tokio::test]
334    async fn test_get_facts_no_auth_returns_401() {
335        let (router, _tmp, _api_key) = make_router().await;
336
337        let request = Request::builder()
338            .method(http::Method::GET)
339            .uri("/v1/memory/facts")
340            .body(Body::empty())
341            .unwrap();
342
343        let response = router.oneshot(request).await.unwrap();
344        assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED);
345    }
346
347    /// GET /v1/memory/facts with valid API key → 200.
348    #[tokio::test]
349    async fn test_get_facts_endpoint_with_auth() {
350        let (router, _tmp, api_key) = make_router().await;
351
352        let request = Request::builder()
353            .method(http::Method::GET)
354            .uri("/v1/memory/facts")
355            .header("authorization", format!("Bearer {api_key}"))
356            .body(Body::empty())
357            .unwrap();
358
359        let response = router.oneshot(request).await.unwrap();
360        assert_eq!(response.status(), http::StatusCode::OK);
361
362        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
363            .await
364            .unwrap();
365        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
366        assert!(body.is_array());
367    }
368
369    /// POST /v1/memory/search with valid read-only key → 200.
370    #[tokio::test]
371    async fn test_search_with_read_only_key() {
372        let temp = tempfile::tempdir().unwrap();
373        let mut config = brain_core::BrainConfig::default();
374        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
375        config.access.api_keys.push(brain_core::ApiKeyConfig {
376            key: "read-only-key".to_string(),
377            name: "Read Only".to_string(),
378            permissions: vec!["read".to_string()],
379        });
380        let api_keys = config.access.api_keys.clone();
381        let processor = signal::SignalProcessor::new(config).await.unwrap();
382        let router = crate::create_router(
383            Arc::new(processor),
384            std::collections::HashMap::new(),
385            api_keys,
386            true,
387        );
388
389        let payload = serde_json::json!({"query": "Rust", "top_k": 5});
390        let request = Request::builder()
391            .method(http::Method::POST)
392            .uri("/v1/memory/search")
393            .header("content-type", "application/json")
394            .header("authorization", "Bearer read-only-key")
395            .body(Body::from(serde_json::to_string(&payload).unwrap()))
396            .unwrap();
397
398        let response = router.oneshot(request).await.unwrap();
399        assert_eq!(response.status(), http::StatusCode::OK);
400    }
401
402    /// POST /v1/signals with read-only key → 401 (missing write permission).
403    #[tokio::test]
404    async fn test_post_signal_read_only_key_returns_401() {
405        let temp = tempfile::tempdir().unwrap();
406        let mut config = brain_core::BrainConfig::default();
407        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
408        config.access.api_keys.push(brain_core::ApiKeyConfig {
409            key: "read-only-key".to_string(),
410            name: "Read Only".to_string(),
411            permissions: vec!["read".to_string()],
412        });
413        let api_keys = config.access.api_keys.clone();
414        let processor = signal::SignalProcessor::new(config).await.unwrap();
415        let router = crate::create_router(
416            Arc::new(processor),
417            std::collections::HashMap::new(),
418            api_keys,
419            true,
420        );
421
422        let payload = serde_json::json!({"content": "Remember something"});
423        let request = Request::builder()
424            .method(http::Method::POST)
425            .uri("/v1/signals")
426            .header("content-type", "application/json")
427            .header("authorization", "Bearer read-only-key")
428            .body(Body::from(serde_json::to_string(&payload).unwrap()))
429            .unwrap();
430
431        let response = router.oneshot(request).await.unwrap();
432        assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED);
433    }
434
435    /// Integration test: HTTP POST /v1/signals (store intent) → fact persisted in DB.
436    #[tokio::test]
437    async fn test_http_store_signal_fact_persisted_in_db() {
438        let temp = tempfile::tempdir().unwrap();
439        let mut config = brain_core::BrainConfig::default();
440        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
441        let api_key = config.access.api_keys.first().unwrap().key.clone();
442        let api_keys = config.access.api_keys.clone();
443        let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
444        let state = Arc::new(AppState {
445            processor,
446            webhook_handlers: std::collections::HashMap::new(),
447            cache: Mutex::new(lru::LruCache::new(
448                NonZeroUsize::new(CACHE_CAPACITY).unwrap(),
449            )),
450            api_keys,
451            metrics: Arc::new(Metrics::default()),
452        });
453
454        let payload = serde_json::json!({"content": "Remember that Rust is fast"});
455        let post_req = Request::builder()
456            .method(http::Method::POST)
457            .uri("/v1/signals")
458            .header("content-type", "application/json")
459            .header("authorization", format!("Bearer {api_key}"))
460            .body(Body::from(serde_json::to_string(&payload).unwrap()))
461            .unwrap();
462
463        let router = Router::new()
464            .route("/v1/signals", post(post_signal_handler))
465            .route("/v1/memory/facts", get(get_facts_handler))
466            .with_state(state.clone());
467
468        let post_resp = router.clone().oneshot(post_req).await.unwrap();
469        assert_eq!(post_resp.status(), http::StatusCode::OK);
470
471        let bytes = axum::body::to_bytes(post_resp.into_body(), usize::MAX)
472            .await
473            .unwrap();
474        let resp_json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
475        assert_eq!(resp_json["status"], "Ok");
476        assert!(resp_json["memory_context"].is_object());
477
478        let get_req = Request::builder()
479            .method(http::Method::GET)
480            .uri("/v1/memory/facts")
481            .header("authorization", format!("Bearer {api_key}"))
482            .body(Body::empty())
483            .unwrap();
484
485        let get_resp = router.oneshot(get_req).await.unwrap();
486        assert_eq!(get_resp.status(), http::StatusCode::OK);
487
488        let bytes = axum::body::to_bytes(get_resp.into_body(), usize::MAX)
489            .await
490            .unwrap();
491        let facts: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
492        assert!(facts.is_array(), "Expected array of facts");
493        assert!(
494            !facts.as_array().unwrap().is_empty(),
495            "Stored fact should appear in GET /v1/memory/facts"
496        );
497    }
498
499    /// Integration test: HTTP POST /v1/memory/search → returns relevant fact.
500    #[tokio::test]
501    async fn test_http_memory_search_returns_stored_fact() {
502        let temp = tempfile::tempdir().unwrap();
503        let mut config = brain_core::BrainConfig::default();
504        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
505        let api_key = config.access.api_keys.first().unwrap().key.clone();
506        let api_keys = config.access.api_keys.clone();
507        let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
508
509        let _ = processor
510            .store_fact_direct("personal", "test", "Ferris", "is", "the Rust mascot", None)
511            .await
512            .unwrap();
513
514        let state = Arc::new(AppState {
515            processor,
516            webhook_handlers: std::collections::HashMap::new(),
517            cache: Mutex::new(lru::LruCache::new(
518                NonZeroUsize::new(CACHE_CAPACITY).unwrap(),
519            )),
520            api_keys,
521            metrics: Arc::new(Metrics::default()),
522        });
523
524        let router = Router::new()
525            .route("/v1/memory/search", post(search_memory_handler))
526            .with_state(state);
527
528        let payload = serde_json::json!({"query": "Ferris Rust mascot", "top_k": 5});
529        let request = Request::builder()
530            .method(http::Method::POST)
531            .uri("/v1/memory/search")
532            .header("content-type", "application/json")
533            .header("authorization", format!("Bearer {api_key}"))
534            .body(Body::from(serde_json::to_string(&payload).unwrap()))
535            .unwrap();
536
537        let response = router.oneshot(request).await.unwrap();
538        assert_eq!(response.status(), http::StatusCode::OK);
539
540        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
541            .await
542            .unwrap();
543        let results: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
544        assert!(results.is_array(), "Expected array of search results");
545    }
546
547    /// Integration test: cached signal can be retrieved by GET /v1/signals/:id.
548    #[tokio::test]
549    async fn test_get_cached_signal_with_auth() {
550        let temp = tempfile::tempdir().unwrap();
551        let mut config = brain_core::BrainConfig::default();
552        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
553        let api_key = config.access.api_keys.first().unwrap().key.clone();
554        let api_keys = config.access.api_keys.clone();
555        let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
556        let state = Arc::new(AppState {
557            processor,
558            webhook_handlers: std::collections::HashMap::new(),
559            cache: Mutex::new(lru::LruCache::new(
560                NonZeroUsize::new(CACHE_CAPACITY).unwrap(),
561            )),
562            api_keys,
563            metrics: Arc::new(Metrics::default()),
564        });
565
566        let id = Uuid::new_v4();
567        let fake_resp = signal::SignalResponse::ok(id, "test response");
568        state.cache.lock().await.put(id, fake_resp);
569
570        let router = Router::new()
571            .route("/v1/signals/:id", get(get_signal_handler))
572            .with_state(state);
573
574        let request = Request::builder()
575            .method(http::Method::GET)
576            .uri(format!("/v1/signals/{id}"))
577            .header("authorization", format!("Bearer {api_key}"))
578            .body(Body::empty())
579            .unwrap();
580
581        let response = router.oneshot(request).await.unwrap();
582        assert_eq!(response.status(), http::StatusCode::OK);
583    }
584}