1use std::{
22 collections::HashMap,
23 net::SocketAddr,
24 sync::{
25 atomic::{AtomicU64, Ordering},
26 Arc,
27 },
28 time::Instant,
29};
30
31use axum::{
32 extract::{Path, State},
33 http::{HeaderMap, StatusCode},
34 response::{
35 sse::{Event, KeepAlive, Sse},
36 IntoResponse, Json,
37 },
38 routing::{get, post},
39 Router,
40};
41use brain_core::ApiKeyConfig;
42use serde::{Deserialize, Serialize};
43use tokio::sync::Mutex;
44use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
45use uuid::Uuid;
46
47use signal::{Signal, SignalResponse, SignalSource};
48
49#[derive(Debug, thiserror::Error)]
52pub enum HttpAdapterError {
53 #[error("Server error: {0}")]
54 Server(String),
55}
56
57#[derive(Debug, Deserialize)]
61pub struct SignalRequest {
62 pub source: Option<String>,
63 pub channel: Option<String>,
64 pub sender: Option<String>,
65 pub content: String,
66 pub metadata: Option<HashMap<String, String>>,
67 pub namespace: Option<String>,
69 pub agent: Option<String>,
71}
72
73#[derive(Debug, Deserialize)]
75pub struct SearchRequest {
76 pub query: String,
77 pub top_k: Option<usize>,
78 pub namespace: Option<String>,
80}
81
82#[derive(Debug, Serialize)]
84pub struct NamespaceJson {
85 pub namespace: String,
86 pub fact_count: i64,
87 pub episode_count: i64,
88}
89
90#[derive(Debug, Serialize)]
92pub struct FactJson {
93 pub id: String,
94 pub namespace: String,
95 pub category: String,
96 pub subject: String,
97 pub predicate: String,
98 pub object: String,
99 pub confidence: f64,
100 pub distance: Option<f32>,
101}
102
103#[derive(Debug, Serialize)]
105pub struct HealthResponse {
106 pub status: &'static str,
107 pub version: &'static str,
108}
109
110#[derive(Default)]
114pub struct Metrics {
115 pub signals_total: AtomicU64,
117 pub signals_ok: AtomicU64,
119 pub signals_error: AtomicU64,
121 pub search_total: AtomicU64,
123 pub facts_total: AtomicU64,
125 pub signals_latency_ms_total: AtomicU64,
127}
128
129impl Metrics {
130 pub fn render(&self) -> String {
132 let signals_total = self.signals_total.load(Ordering::Relaxed);
133 let signals_ok = self.signals_ok.load(Ordering::Relaxed);
134 let signals_error = self.signals_error.load(Ordering::Relaxed);
135 let search_total = self.search_total.load(Ordering::Relaxed);
136 let facts_total = self.facts_total.load(Ordering::Relaxed);
137 let latency_ms = self.signals_latency_ms_total.load(Ordering::Relaxed);
138
139 format!(
140 "# HELP brain_signals_total Total signal requests received.\n\
141 # TYPE brain_signals_total counter\n\
142 brain_signals_total {signals_total}\n\
143 # HELP brain_signals_ok_total Successful signal requests.\n\
144 # TYPE brain_signals_ok_total counter\n\
145 brain_signals_ok_total {signals_ok}\n\
146 # HELP brain_signals_error_total Failed signal requests (5xx).\n\
147 # TYPE brain_signals_error_total counter\n\
148 brain_signals_error_total {signals_error}\n\
149 # HELP brain_search_total Total memory search requests.\n\
150 # TYPE brain_search_total counter\n\
151 brain_search_total {search_total}\n\
152 # HELP brain_facts_total Total memory facts requests.\n\
153 # TYPE brain_facts_total counter\n\
154 brain_facts_total {facts_total}\n\
155 # HELP brain_signals_latency_ms_total Cumulative signal processing latency in ms.\n\
156 # TYPE brain_signals_latency_ms_total counter\n\
157 brain_signals_latency_ms_total {latency_ms}\n"
158 )
159 }
160}
161
162pub struct AppState {
166 processor: Arc<signal::SignalProcessor>,
167 cache: Mutex<HashMap<Uuid, SignalResponse>>,
169 api_keys: Vec<ApiKeyConfig>,
171 metrics: Arc<Metrics>,
173}
174
175fn extract_bearer(headers: &HeaderMap) -> Option<&str> {
179 headers
180 .get("authorization")
181 .and_then(|v| v.to_str().ok())
182 .and_then(|s| s.strip_prefix("Bearer "))
183}
184
185fn check_auth(
188 state: &AppState,
189 headers: &HeaderMap,
190 permission: &str,
191) -> Result<(), (StatusCode, String)> {
192 let raw_key = extract_bearer(headers).ok_or_else(|| {
193 (
194 StatusCode::UNAUTHORIZED,
195 "Missing Authorization: Bearer <key> header".to_string(),
196 )
197 })?;
198
199 match state.api_keys.iter().find(|k| k.key == raw_key) {
200 None => Err((StatusCode::UNAUTHORIZED, "Invalid API key".to_string())),
201 Some(k) if !k.has_permission(permission) => Err((
202 StatusCode::UNAUTHORIZED,
203 format!("API key does not have '{}' permission", permission),
204 )),
205 Some(_) => Ok(()),
206 }
207}
208
209fn localhost_cors() -> CorsLayer {
214 CorsLayer::new()
215 .allow_origin(AllowOrigin::predicate(|origin, _req| {
216 let bytes = origin.as_bytes();
217 bytes.starts_with(b"http://127.0.0.1")
218 || bytes.starts_with(b"http://localhost")
219 || bytes.starts_with(b"https://127.0.0.1")
220 || bytes.starts_with(b"https://localhost")
221 }))
222 .allow_methods(AllowMethods::any())
223 .allow_headers(AllowHeaders::any())
224}
225
226pub fn create_router(
230 processor: Arc<signal::SignalProcessor>,
231 api_keys: Vec<ApiKeyConfig>,
232) -> Router {
233 let state = Arc::new(AppState {
234 processor,
235 cache: Mutex::new(HashMap::new()),
236 api_keys,
237 metrics: Arc::new(Metrics::default()),
238 });
239
240 Router::new()
241 .route("/health", get(health_handler))
242 .route("/metrics", get(metrics_handler))
243 .route("/ui", get(ui_handler))
244 .route("/openapi.json", get(openapi_handler))
245 .route("/api", get(swagger_ui_handler))
246 .route("/v1/signals", post(post_signal_handler))
247 .route("/v1/signals/:id", get(get_signal_handler))
248 .route("/v1/memory/search", post(search_memory_handler))
249 .route("/v1/memory/facts", get(get_facts_handler))
250 .route("/v1/memory/namespaces", get(get_namespaces_handler))
251 .route("/v1/events", get(sse_events_handler))
252 .with_state(state)
253 .layer(localhost_cors())
254}
255
256pub async fn serve(
260 processor: Arc<signal::SignalProcessor>,
261 host: &str,
262 port: u16,
263) -> anyhow::Result<()> {
264 let api_keys = processor.config().access.api_keys.clone();
265 let router = create_router(processor, api_keys);
266 let addr: SocketAddr = format!("{host}:{port}").parse()?;
267 tracing::info!("Synapse HTTP online at http://{addr}");
268 let listener = tokio::net::TcpListener::bind(addr).await?;
269 axum::serve(listener, router).await?;
270 Ok(())
271}
272
273async fn health_handler() -> Json<HealthResponse> {
277 Json(HealthResponse {
278 status: "ok",
279 version: env!("CARGO_PKG_VERSION"),
280 })
281}
282
283async fn metrics_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
285 (
286 [("content-type", "text/plain; version=0.0.4; charset=utf-8")],
287 state.metrics.render(),
288 )
289}
290
291const UI_HTML: &str = r#"<!DOCTYPE html>
293<html lang="en">
294<head>
295<meta charset="UTF-8">
296<meta name="viewport" content="width=device-width, initial-scale=1.0">
297<title>Brain Memory Explorer</title>
298<style>
299 *{box-sizing:border-box;margin:0;padding:0}
300 body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
301 header{background:#1e293b;border-bottom:1px solid #334155;padding:1rem 2rem;display:flex;align-items:center;gap:1rem}
302 header h1{font-size:1.25rem;font-weight:700;color:#38bdf8}
303 header span{color:#94a3b8;font-size:.875rem}
304 main{max-width:1200px;margin:0 auto;padding:2rem}
305 .search-bar{display:flex;gap:.5rem;margin-bottom:2rem}
306 .search-bar input{flex:1;background:#1e293b;border:1px solid #334155;border-radius:.5rem;padding:.75rem 1rem;color:#e2e8f0;font-size:1rem;outline:none}
307 .search-bar input:focus{border-color:#38bdf8}
308 .search-bar button{background:#0ea5e9;border:none;border-radius:.5rem;padding:.75rem 1.5rem;color:#fff;font-size:1rem;cursor:pointer}
309 .search-bar button:hover{background:#0284c7}
310 .tabs{display:flex;gap:.5rem;margin-bottom:1.5rem}
311 .tab{background:#1e293b;border:1px solid #334155;border-radius:.5rem;padding:.5rem 1rem;cursor:pointer;font-size:.875rem;color:#94a3b8}
312 .tab.active{background:#0ea5e9;border-color:#0ea5e9;color:#fff}
313 #status{color:#94a3b8;font-size:.875rem;margin-bottom:1rem;min-height:1.25rem}
314 #status.error{color:#f87171}
315 .grid{display:grid;gap:1rem}
316 .card{background:#1e293b;border:1px solid #334155;border-radius:.75rem;padding:1.25rem}
317 .card .meta{font-size:.75rem;color:#64748b;margin-bottom:.5rem}
318 .card .subject{font-weight:600;color:#38bdf8;margin-bottom:.25rem}
319 .card .predicate{color:#94a3b8;font-size:.875rem;margin-bottom:.25rem}
320 .card .object{color:#e2e8f0}
321 .card .badge{display:inline-block;background:#0f172a;border:1px solid #334155;border-radius:.25rem;padding:.15rem .4rem;font-size:.7rem;color:#64748b;margin-right:.25rem}
322 .card .conf{color:#4ade80;font-size:.75rem}
323 .empty{color:#475569;text-align:center;padding:3rem;font-size:.9rem}
324 .key-bar{display:flex;align-items:center;gap:.5rem;margin-left:auto}
325 .key-bar label{font-size:.75rem;color:#64748b}
326 .key-bar input{background:#0f172a;border:1px solid #334155;border-radius:.25rem;padding:.35rem .5rem;color:#94a3b8;font-size:.75rem;width:10rem;outline:none}
327 .key-bar input:focus{border-color:#38bdf8}
328</style>
329</head>
330<body>
331<header>
332 <h1>🧠 Brain</h1>
333 <span>Memory Explorer</span>
334 <div class="key-bar">
335 <label for="api-key">API Key</label>
336 <input id="api-key" type="password" placeholder="Enter API key">
337 <span id="api-status" style="font-size:.75rem"></span>
338 </div>
339</header>
340<main>
341 <div class="search-bar">
342 <input id="q" type="search" placeholder="Search memory… (e.g. Rust, project goals)" autofocus>
343 <button onclick="search()">Search</button>
344 </div>
345 <div class="tabs">
346 <div class="tab active" id="tab-search" onclick="switchTab('search')">Search Results</div>
347 <div class="tab" id="tab-facts" onclick="switchTab('facts')">All Facts</div>
348 </div>
349 <div id="status"></div>
350 <div id="results" class="grid"></div>
351</main>
352<script>
353const API = '';
354let apiKey = localStorage.getItem('brain_api_key') || '';
355
356const keyInput = document.getElementById('api-key');
357keyInput.value = apiKey;
358function applyKey(){
359 apiKey = keyInput.value.trim();
360 localStorage.setItem('brain_api_key', apiKey);
361 keyInput.style.borderColor='';
362 loadFacts();
363}
364keyInput.addEventListener('change', applyKey);
365keyInput.addEventListener('keydown', function(e){ if(e.key==='Enter') applyKey(); });
366
367function hdr(){ return {'Authorization':'Bearer '+apiKey,'Content-Type':'application/json'} }
368
369function flagKeyInput(msg){
370 keyInput.style.borderColor='#f87171';
371 keyInput.focus();
372 setStatus(msg || 'Invalid API key — enter your key above',true);
373}
374
375async function checkHealth(){
376 try{
377 const r=await fetch(API+'/health');
378 const d=await r.json();
379 document.getElementById('api-status').textContent='● '+d.status+' v'+d.version;
380 document.getElementById('api-status').style.color='#4ade80';
381 }catch(e){
382 document.getElementById('api-status').textContent='● unreachable';
383 document.getElementById('api-status').style.color='#f87171';
384 }
385}
386
387function setStatus(msg,err){
388 const el=document.getElementById('status');
389 el.textContent=msg;
390 el.className=err?'error':'';
391}
392
393function renderFact(f){
394 const dist=f.distance!=null?' · dist '+f.distance.toFixed(3):'';
395 return `<div class="card">
396 <div class="meta"><span class="badge">${f.namespace}</span><span class="badge">${f.category}</span><span class="conf">conf ${f.confidence.toFixed(2)}</span>${dist}</div>
397 <div class="subject">${esc(f.subject)}</div>
398 <div class="predicate">${esc(f.predicate)}</div>
399 <div class="object">${esc(f.object)}</div>
400 </div>`;
401}
402
403function esc(s){ const d=document.createElement('div');d.textContent=s;return d.innerHTML; }
404
405async function search(){
406 const q=document.getElementById('q').value.trim();
407 if(!q){await loadFacts();return;}
408 if(!apiKey){flagKeyInput('Enter your API key above to search');return;}
409 setStatus('Searching…');
410 try{
411 const r=await fetch(API+'/v1/memory/search',{method:'POST',headers:hdr(),body:JSON.stringify({query:q,top_k:20})});
412 if(r.status===401){flagKeyInput();return;}
413 if(!r.ok){setStatus('Search failed: '+r.status,true);return;}
414 const facts=await r.json();
415 setStatus(facts.length+' result'+(facts.length!==1?'s':''));
416 document.getElementById('results').innerHTML=facts.length?facts.map(renderFact).join(''):'<p class="empty">No matching facts found.</p>';
417 }catch(e){setStatus('Error: '+e.message,true);}
418}
419
420async function loadFacts(){
421 if(!apiKey){flagKeyInput('Enter your API key above to browse memories');return;}
422 setStatus('Loading…');
423 try{
424 const r=await fetch(API+'/v1/memory/facts',{headers:hdr()});
425 if(r.status===401){flagKeyInput();return;}
426 if(!r.ok){setStatus('Failed to load facts: '+r.status,true);return;}
427 const facts=await r.json();
428 keyInput.style.borderColor='#4ade80';
429 setStatus(facts.length+' stored fact'+(facts.length!==1?'s':''));
430 document.getElementById('results').innerHTML=facts.length?facts.map(renderFact).join(''):'<p class="empty">No facts stored yet. Send a "Remember…" signal to add some.</p>';
431 }catch(e){setStatus('Error: '+e.message,true);}
432}
433
434function switchTab(t){
435 document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));
436 document.getElementById('tab-'+t).classList.add('active');
437 if(t==='facts')loadFacts();
438 else{document.getElementById('results').innerHTML='';setStatus('');}
439}
440
441document.getElementById('q').addEventListener('keydown',e=>{if(e.key==='Enter')search();});
442checkHealth();
443loadFacts();
444</script>
445</body>
446</html>"#;
447
448async fn ui_handler() -> impl IntoResponse {
450 ([("content-type", "text/html; charset=utf-8")], UI_HTML)
451}
452
453fn build_openapi() -> serde_json::Value {
457 serde_json::json!({
458 "openapi": "3.0.3",
459 "info": {
460 "title": "Brain OS — Synapse HTTP API",
461 "description": "Your AI's long-term memory — signal processing, semantic search, and episodic recall.",
462 "version": env!("CARGO_PKG_VERSION"),
463 "contact": { "name": "Brain OS", "url": "https://github.com/keshavashiya/brain" }
464 },
465 "servers": [{ "url": "/", "description": "Local Brain instance" }],
466 "components": {
467 "securitySchemes": {
468 "BearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "APIKey" }
469 },
470 "schemas": {
471 "HealthResponse": {
472 "type": "object", "required": ["status","version"],
473 "properties": {
474 "status": { "type": "string", "example": "ok" },
475 "version": { "type": "string", "example": "0.1.0" }
476 }
477 },
478 "SignalRequest": {
479 "type": "object", "required": ["content"],
480 "properties": {
481 "content": { "type": "string", "example": "Remember that Rust is memory-safe" },
482 "source": { "type": "string", "enum": ["http","cli","ws","mcp","grpc"] },
483 "channel": { "type": "string" },
484 "sender": { "type": "string" },
485 "namespace": { "type": "string", "default": "personal" },
486 "metadata": { "type": "object", "additionalProperties": { "type": "string" } }
487 }
488 },
489 "SignalResponse": {
490 "type": "object", "required": ["signal_id","status","response","memory_context"],
491 "properties": {
492 "signal_id": { "type": "string", "format": "uuid" },
493 "status": { "type": "string", "enum": ["Ok","Error"] },
494 "response": {
495 "type": "object",
496 "properties": {
497 "type": { "type": "string", "enum": ["Text","Json","Error"] },
498 "value": {}
499 }
500 },
501 "memory_context": {
502 "type": "object",
503 "properties": {
504 "facts_used": { "type": "integer" },
505 "episodes_used": { "type": "integer" }
506 }
507 }
508 }
509 },
510 "SearchRequest": {
511 "type": "object", "required": ["query"],
512 "properties": {
513 "query": { "type": "string", "example": "Rust programming" },
514 "top_k": { "type": "integer", "default": 10 },
515 "namespace": { "type": "string" }
516 }
517 },
518 "FactJson": {
519 "type": "object",
520 "properties": {
521 "id": { "type": "string" },
522 "namespace": { "type": "string" },
523 "category": { "type": "string" },
524 "subject": { "type": "string" },
525 "predicate": { "type": "string" },
526 "object": { "type": "string" },
527 "confidence": { "type": "number", "format": "double" },
528 "distance": { "type": "number", "format": "float", "nullable": true }
529 }
530 },
531 "NamespaceJson": {
532 "type": "object",
533 "properties": {
534 "namespace": { "type": "string" },
535 "fact_count": { "type": "integer" },
536 "episode_count": { "type": "integer" }
537 }
538 }
539 }
540 },
541 "paths": {
542 "/health": {
543 "get": {
544 "summary": "Health check",
545 "operationId": "getHealth",
546 "responses": {
547 "200": { "description": "Service is healthy", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HealthResponse" } } } }
548 }
549 }
550 },
551 "/metrics": {
552 "get": {
553 "summary": "Prometheus metrics",
554 "operationId": "getMetrics",
555 "responses": {
556 "200": { "description": "Prometheus text format metrics", "content": { "text/plain": { "schema": { "type": "string" } } } }
557 }
558 }
559 },
560 "/v1/signals": {
561 "post": {
562 "summary": "Submit a signal for processing",
563 "operationId": "postSignal",
564 "security": [{ "BearerAuth": [] }],
565 "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignalRequest" } } } },
566 "responses": {
567 "200": { "description": "Signal processed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignalResponse" } } } },
568 "401": { "description": "Unauthorized — missing or invalid API key" },
569 "500": { "description": "Internal server error" }
570 }
571 }
572 },
573 "/v1/signals/{id}": {
574 "get": {
575 "summary": "Retrieve a cached signal response by ID",
576 "operationId": "getSignalById",
577 "security": [{ "BearerAuth": [] }],
578 "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
579 "responses": {
580 "200": { "description": "Signal response found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignalResponse" } } } },
581 "401": { "description": "Unauthorized" },
582 "404": { "description": "Signal not found in cache" }
583 }
584 }
585 },
586 "/v1/memory/search": {
587 "post": {
588 "summary": "Semantic search over stored facts",
589 "operationId": "searchMemory",
590 "security": [{ "BearerAuth": [] }],
591 "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchRequest" } } } },
592 "responses": {
593 "200": { "description": "Matching facts", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactJson" } } } } },
594 "401": { "description": "Unauthorized" }
595 }
596 }
597 },
598 "/v1/memory/facts": {
599 "get": {
600 "summary": "List all stored semantic facts",
601 "operationId": "listFacts",
602 "security": [{ "BearerAuth": [] }],
603 "parameters": [{ "name": "namespace", "in": "query", "schema": { "type": "string" } }],
604 "responses": {
605 "200": { "description": "List of facts", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactJson" } } } } },
606 "401": { "description": "Unauthorized" }
607 }
608 }
609 },
610 "/v1/memory/namespaces": {
611 "get": {
612 "summary": "List memory namespaces with statistics",
613 "operationId": "listNamespaces",
614 "security": [{ "BearerAuth": [] }],
615 "responses": {
616 "200": { "description": "List of namespaces", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/NamespaceJson" } } } } },
617 "401": { "description": "Unauthorized" }
618 }
619 }
620 }
621 }
622 })
623}
624
625async fn openapi_handler() -> impl IntoResponse {
627 (
628 [("content-type", "application/json")],
629 build_openapi().to_string(),
630 )
631}
632
633const SWAGGER_UI_HTML: &str = r#"<!DOCTYPE html>
635<html lang="en">
636<head>
637 <meta charset="UTF-8">
638 <title>Brain OS API — Swagger UI</title>
639 <meta name="viewport" content="width=device-width, initial-scale=1">
640 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
641</head>
642<body>
643 <div id="swagger-ui"></div>
644 <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
645 <script>
646 SwaggerUIBundle({
647 url: '/openapi.json',
648 dom_id: '#swagger-ui',
649 presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
650 layout: 'StandaloneLayout'
651 });
652 </script>
653</body>
654</html>"#;
655
656async fn swagger_ui_handler() -> impl IntoResponse {
658 (
659 [("content-type", "text/html; charset=utf-8")],
660 SWAGGER_UI_HTML,
661 )
662}
663
664async fn post_signal_handler(
666 State(state): State<Arc<AppState>>,
667 headers: HeaderMap,
668 Json(body): Json<SignalRequest>,
669) -> Result<Json<SignalResponse>, (StatusCode, String)> {
670 check_auth(&state, &headers, "write")?;
671
672 let t0 = Instant::now();
673 state.metrics.signals_total.fetch_add(1, Ordering::Relaxed);
674
675 let source = parse_source(body.source.as_deref());
676 let signal = Signal::new(
677 source,
678 body.channel.unwrap_or_else(|| "http".to_string()),
679 body.sender.unwrap_or_else(|| "apiclient".to_string()),
680 body.content,
681 )
682 .with_metadata(body.metadata.unwrap_or_default())
683 .with_namespace_opt(body.namespace)
684 .with_agent_opt(body.agent);
685
686 let signal_id = signal.id;
687 let result = state.processor.process(signal).await;
688
689 let elapsed_ms = t0.elapsed().as_millis() as u64;
690 state
691 .metrics
692 .signals_latency_ms_total
693 .fetch_add(elapsed_ms, Ordering::Relaxed);
694
695 let response = match result {
696 Ok(r) => {
697 state.metrics.signals_ok.fetch_add(1, Ordering::Relaxed);
698 tracing::info!(
699 signal_id = %signal_id,
700 latency_ms = elapsed_ms,
701 "signal processed"
702 );
703 r
704 }
705 Err(e) => {
706 state.metrics.signals_error.fetch_add(1, Ordering::Relaxed);
707 tracing::error!(
708 signal_id = %signal_id,
709 latency_ms = elapsed_ms,
710 error = %e,
711 "signal processing failed"
712 );
713 return Err((
715 StatusCode::INTERNAL_SERVER_ERROR,
716 "Signal processing failed. Check server logs for details.".to_string(),
717 ));
718 }
719 };
720
721 state.cache.lock().await.insert(signal_id, response.clone());
723
724 Ok(Json(response))
725}
726
727async fn get_signal_handler(
729 State(state): State<Arc<AppState>>,
730 headers: HeaderMap,
731 Path(id): Path<String>,
732) -> Result<Json<SignalResponse>, (StatusCode, String)> {
733 check_auth(&state, &headers, "read")?;
734
735 let uuid = Uuid::parse_str(&id)
736 .map_err(|_| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {id}")))?;
737
738 let cache = state.cache.lock().await;
739 match cache.get(&uuid) {
740 Some(resp) => Ok(Json(resp.clone())),
741 None => Err((
742 StatusCode::NOT_FOUND,
743 format!("Signal {uuid} not found in cache"),
744 )),
745 }
746}
747
748async fn search_memory_handler(
750 State(state): State<Arc<AppState>>,
751 headers: HeaderMap,
752 Json(body): Json<SearchRequest>,
753) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
754 check_auth(&state, &headers, "read")?;
755
756 state.metrics.search_total.fetch_add(1, Ordering::Relaxed);
757 let t0 = Instant::now();
758 let top_k = body.top_k.unwrap_or(10);
759 let namespace = body.namespace.as_deref();
760 let results = state
761 .processor
762 .search_facts(&body.query, top_k, namespace)
763 .await;
764 tracing::debug!(latency_ms = t0.elapsed().as_millis() as u64, query = %body.query, "memory search");
765
766 let facts = results
767 .into_iter()
768 .map(|r| FactJson {
769 id: r.fact.id,
770 namespace: r.fact.namespace,
771 category: r.fact.category,
772 subject: r.fact.subject,
773 predicate: r.fact.predicate,
774 object: r.fact.object,
775 confidence: r.fact.confidence,
776 distance: Some(r.distance),
777 })
778 .collect();
779
780 Ok(Json(facts))
781}
782
783async fn get_facts_handler(
787 State(state): State<Arc<AppState>>,
788 headers: HeaderMap,
789 axum::extract::Query(params): axum::extract::Query<HashMap<String, String>>,
790) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
791 check_auth(&state, &headers, "read")?;
792
793 state.metrics.facts_total.fetch_add(1, Ordering::Relaxed);
794 let namespace = params.get("namespace").map(|s| s.as_str());
795 let facts = state
796 .processor
797 .list_facts(namespace)
798 .into_iter()
799 .map(|f| FactJson {
800 id: f.id,
801 namespace: f.namespace,
802 category: f.category,
803 subject: f.subject,
804 predicate: f.predicate,
805 object: f.object,
806 confidence: f.confidence,
807 distance: None,
808 })
809 .collect();
810
811 Ok(Json(facts))
812}
813
814async fn get_namespaces_handler(
816 State(state): State<Arc<AppState>>,
817 headers: HeaderMap,
818) -> Result<Json<Vec<NamespaceJson>>, (StatusCode, String)> {
819 check_auth(&state, &headers, "read")?;
820
821 let namespaces = state
822 .processor
823 .list_namespaces()
824 .into_iter()
825 .map(|n| NamespaceJson {
826 namespace: n.namespace,
827 fact_count: n.fact_count,
828 episode_count: n.episode_count,
829 })
830 .collect();
831
832 Ok(Json(namespaces))
833}
834
835async fn sse_events_handler(
843 State(state): State<Arc<AppState>>,
844 headers: HeaderMap,
845) -> Result<
846 Sse<impl futures_core::Stream<Item = Result<Event, std::convert::Infallible>>>,
847 (StatusCode, String),
848> {
849 check_auth(&state, &headers, "read")?;
850
851 let router = state.processor.notification_router().ok_or_else(|| {
852 (
853 StatusCode::SERVICE_UNAVAILABLE,
854 "Proactive notifications not configured".to_string(),
855 )
856 })?;
857
858 let mut rx = router.subscribe();
859
860 let stream = async_stream::stream! {
861 loop {
862 match rx.recv().await {
863 Ok(notification) => {
864 let payload = serde_json::json!({
865 "type": "proactive",
866 "content": notification.content,
867 "triggered_by": notification.triggered_by,
868 "priority": notification.priority,
869 "agent": notification.agent,
870 });
871 yield Ok(Event::default()
872 .event("notification")
873 .json_data(payload)
874 .unwrap_or_else(|_| Event::default().data("{}")));
875 }
876 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
877 tracing::warn!(skipped = n, "SSE client lagged behind");
878 yield Ok(Event::default()
879 .event("error")
880 .data(format!("{{\"lagged\":{n}}}")));
881 }
882 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
883 }
884 }
885 };
886
887 Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
888}
889
890fn parse_source(s: Option<&str>) -> SignalSource {
893 match s {
894 Some("http") | None => SignalSource::Http,
895 Some("cli") => SignalSource::Cli,
896 Some("ws") | Some("websocket") => SignalSource::WebSocket,
897 Some("mcp") => SignalSource::Mcp,
898 Some("grpc") => SignalSource::Grpc,
899 _ => SignalSource::Http,
900 }
901}
902
903#[cfg(test)]
906mod tests {
907 use super::*;
908
909 async fn make_router() -> (Router, tempfile::TempDir) {
911 let temp = tempfile::tempdir().unwrap();
912 let mut config = brain_core::BrainConfig::default();
913 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
914 let api_keys = config.access.api_keys.clone();
915 let processor = signal::SignalProcessor::new(config).await.unwrap();
916 let router = create_router(Arc::new(processor), api_keys);
917 (router, temp)
918 }
919
920 #[test]
921 fn test_parse_source_defaults_to_http() {
922 assert_eq!(parse_source(None), SignalSource::Http);
923 assert_eq!(parse_source(Some("http")), SignalSource::Http);
924 }
925
926 #[test]
927 fn test_parse_source_all_variants() {
928 assert_eq!(parse_source(Some("cli")), SignalSource::Cli);
929 assert_eq!(parse_source(Some("ws")), SignalSource::WebSocket);
930 assert_eq!(parse_source(Some("mcp")), SignalSource::Mcp);
931 assert_eq!(parse_source(Some("grpc")), SignalSource::Grpc);
932 }
933
934 #[test]
935 fn test_health_response_serializes() {
936 let h = HealthResponse {
937 status: "ok",
938 version: "1.0.0",
939 };
940 let json = serde_json::to_string(&h).unwrap();
941 assert!(json.contains("\"status\":\"ok\""));
942 assert!(json.contains("\"version\""));
943 }
944
945 #[test]
946 fn test_fact_json_serializes() {
947 let f = FactJson {
948 id: "abc".into(),
949 namespace: "personal".into(),
950 category: "personal".into(),
951 subject: "user".into(),
952 predicate: "likes".into(),
953 object: "Rust".into(),
954 confidence: 0.9,
955 distance: Some(0.05),
956 };
957 let json = serde_json::to_string(&f).unwrap();
958 assert!(json.contains("\"subject\":\"user\""));
959 assert!(json.contains("\"namespace\":\"personal\""));
960 assert!(json.contains("\"distance\":0.05"));
961 }
962
963 #[tokio::test]
965 async fn test_openapi_endpoint() {
966 use axum::body::Body;
967 use axum::http::{self, Request};
968 use tower::util::ServiceExt;
969
970 let (router, _tmp) = make_router().await;
971
972 let request = Request::builder()
973 .method(http::Method::GET)
974 .uri("/openapi.json")
975 .body(Body::empty())
976 .unwrap();
977
978 let response = router.oneshot(request).await.unwrap();
979 assert_eq!(response.status(), StatusCode::OK);
980
981 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
982 .await
983 .unwrap();
984 let spec: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
985 assert_eq!(spec["openapi"], "3.0.3");
986 assert!(
987 spec["paths"]["/v1/signals"].is_object(),
988 "missing /v1/signals path"
989 );
990 assert!(
991 spec["components"]["schemas"]["FactJson"].is_object(),
992 "missing FactJson schema"
993 );
994 }
995
996 #[tokio::test]
998 async fn test_swagger_ui_endpoint() {
999 use axum::body::Body;
1000 use axum::http::{self, Request};
1001 use tower::util::ServiceExt;
1002
1003 let (router, _tmp) = make_router().await;
1004
1005 let request = Request::builder()
1006 .method(http::Method::GET)
1007 .uri("/api")
1008 .body(Body::empty())
1009 .unwrap();
1010
1011 let response = router.oneshot(request).await.unwrap();
1012 assert_eq!(response.status(), StatusCode::OK);
1013
1014 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1015 .await
1016 .unwrap();
1017 let body = std::str::from_utf8(&bytes).unwrap();
1018 assert!(body.contains("swagger-ui"), "missing Swagger UI element");
1019 assert!(body.contains("/openapi.json"), "missing spec URL reference");
1020 }
1021
1022 #[tokio::test]
1024 async fn test_ui_endpoint() {
1025 use axum::body::Body;
1026 use axum::http::{self, Request};
1027 use tower::util::ServiceExt;
1028
1029 let (router, _tmp) = make_router().await;
1030
1031 let request = Request::builder()
1032 .method(http::Method::GET)
1033 .uri("/ui")
1034 .body(Body::empty())
1035 .unwrap();
1036
1037 let response = router.oneshot(request).await.unwrap();
1038 assert_eq!(response.status(), StatusCode::OK);
1039
1040 let ct = response
1041 .headers()
1042 .get("content-type")
1043 .unwrap()
1044 .to_str()
1045 .unwrap();
1046 assert!(ct.contains("text/html"), "expected text/html, got: {ct}");
1047
1048 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1049 .await
1050 .unwrap();
1051 let body = std::str::from_utf8(&bytes).unwrap();
1052 assert!(body.contains("Brain Memory Explorer"), "missing page title");
1053 assert!(
1054 body.contains("/v1/memory/search"),
1055 "missing API endpoint reference"
1056 );
1057 }
1058
1059 #[tokio::test]
1061 async fn test_metrics_endpoint() {
1062 use axum::body::Body;
1063 use axum::http::{self, Request};
1064 use tower::util::ServiceExt;
1065
1066 let (router, _tmp) = make_router().await;
1067
1068 let request = Request::builder()
1069 .method(http::Method::GET)
1070 .uri("/metrics")
1071 .body(Body::empty())
1072 .unwrap();
1073
1074 let response = router.oneshot(request).await.unwrap();
1075 assert_eq!(response.status(), StatusCode::OK);
1076
1077 let ct = response
1078 .headers()
1079 .get("content-type")
1080 .unwrap()
1081 .to_str()
1082 .unwrap();
1083 assert!(ct.contains("text/plain"), "expected text/plain, got: {ct}");
1084
1085 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1086 .await
1087 .unwrap();
1088 let body = std::str::from_utf8(&bytes).unwrap();
1089 assert!(
1090 body.contains("brain_signals_total"),
1091 "missing counter in metrics output"
1092 );
1093 assert!(
1094 body.contains("brain_search_total"),
1095 "missing search counter"
1096 );
1097 }
1098
1099 #[tokio::test]
1101 async fn test_health_endpoint() {
1102 use axum::body::Body;
1103 use axum::http::{self, Request};
1104 use tower::util::ServiceExt;
1105
1106 let (router, _tmp) = make_router().await;
1107
1108 let request = Request::builder()
1109 .method(http::Method::GET)
1110 .uri("/health")
1111 .body(Body::empty())
1112 .unwrap();
1113
1114 let response = router.oneshot(request).await.unwrap();
1115 assert_eq!(response.status(), StatusCode::OK);
1116
1117 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1118 .await
1119 .unwrap();
1120 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1121 assert_eq!(body["status"], "ok");
1122 }
1123
1124 #[tokio::test]
1126 async fn test_post_signal_no_auth_returns_401() {
1127 use axum::body::Body;
1128 use axum::http::{self, Request};
1129 use tower::util::ServiceExt;
1130
1131 let (router, _tmp) = make_router().await;
1132
1133 let payload = serde_json::json!({"content": "Remember Rust is fast"});
1134 let request = Request::builder()
1135 .method(http::Method::POST)
1136 .uri("/v1/signals")
1137 .header("content-type", "application/json")
1138 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1139 .unwrap();
1140
1141 let response = router.oneshot(request).await.unwrap();
1142 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1143 }
1144
1145 #[tokio::test]
1147 async fn test_post_signal_invalid_key_returns_401() {
1148 use axum::body::Body;
1149 use axum::http::{self, Request};
1150 use tower::util::ServiceExt;
1151
1152 let (router, _tmp) = make_router().await;
1153
1154 let payload = serde_json::json!({"content": "Remember Rust is fast"});
1155 let request = Request::builder()
1156 .method(http::Method::POST)
1157 .uri("/v1/signals")
1158 .header("content-type", "application/json")
1159 .header("authorization", "Bearer wrong-key")
1160 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1161 .unwrap();
1162
1163 let response = router.oneshot(request).await.unwrap();
1164 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1165 }
1166
1167 #[tokio::test]
1169 async fn test_post_signal_store_fact_with_auth() {
1170 use axum::body::Body;
1171 use axum::http::{self, Request};
1172 use tower::util::ServiceExt;
1173
1174 let (router, _tmp) = make_router().await;
1175
1176 let payload = serde_json::json!({"content": "Remember that Rust is fast"});
1177 let request = Request::builder()
1178 .method(http::Method::POST)
1179 .uri("/v1/signals")
1180 .header("content-type", "application/json")
1181 .header("authorization", "Bearer demokey123")
1182 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1183 .unwrap();
1184
1185 let response = router.oneshot(request).await.unwrap();
1186 assert_eq!(response.status(), StatusCode::OK);
1187
1188 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1189 .await
1190 .unwrap();
1191 let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1192 assert_eq!(resp["status"], "Ok");
1193 }
1194
1195 #[tokio::test]
1197 async fn test_get_facts_no_auth_returns_401() {
1198 use axum::body::Body;
1199 use axum::http::{self, Request};
1200 use tower::util::ServiceExt;
1201
1202 let (router, _tmp) = make_router().await;
1203
1204 let request = Request::builder()
1205 .method(http::Method::GET)
1206 .uri("/v1/memory/facts")
1207 .body(Body::empty())
1208 .unwrap();
1209
1210 let response = router.oneshot(request).await.unwrap();
1211 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1212 }
1213
1214 #[tokio::test]
1216 async fn test_get_facts_endpoint_with_auth() {
1217 use axum::body::Body;
1218 use axum::http::{self, Request};
1219 use tower::util::ServiceExt;
1220
1221 let (router, _tmp) = make_router().await;
1222
1223 let request = Request::builder()
1224 .method(http::Method::GET)
1225 .uri("/v1/memory/facts")
1226 .header("authorization", "Bearer demokey123")
1227 .body(Body::empty())
1228 .unwrap();
1229
1230 let response = router.oneshot(request).await.unwrap();
1231 assert_eq!(response.status(), StatusCode::OK);
1232
1233 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1234 .await
1235 .unwrap();
1236 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1237 assert!(body.is_array());
1238 }
1239
1240 #[tokio::test]
1242 async fn test_search_with_read_only_key() {
1243 use axum::body::Body;
1244 use axum::http::{self, Request};
1245 use tower::util::ServiceExt;
1246
1247 let temp = tempfile::tempdir().unwrap();
1248 let mut config = brain_core::BrainConfig::default();
1249 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1250 config.access.api_keys.push(ApiKeyConfig {
1252 key: "read-only-key".to_string(),
1253 name: "Read Only".to_string(),
1254 permissions: vec!["read".to_string()],
1255 });
1256 let api_keys = config.access.api_keys.clone();
1257 let processor = signal::SignalProcessor::new(config).await.unwrap();
1258 let router = create_router(Arc::new(processor), api_keys);
1259
1260 let payload = serde_json::json!({"query": "Rust", "top_k": 5});
1261 let request = Request::builder()
1262 .method(http::Method::POST)
1263 .uri("/v1/memory/search")
1264 .header("content-type", "application/json")
1265 .header("authorization", "Bearer read-only-key")
1266 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1267 .unwrap();
1268
1269 let response = router.oneshot(request).await.unwrap();
1270 assert_eq!(response.status(), StatusCode::OK);
1271 }
1272
1273 #[tokio::test]
1275 async fn test_post_signal_read_only_key_returns_401() {
1276 use axum::body::Body;
1277 use axum::http::{self, Request};
1278 use tower::util::ServiceExt;
1279
1280 let temp = tempfile::tempdir().unwrap();
1281 let mut config = brain_core::BrainConfig::default();
1282 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1283 config.access.api_keys.push(ApiKeyConfig {
1284 key: "read-only-key".to_string(),
1285 name: "Read Only".to_string(),
1286 permissions: vec!["read".to_string()],
1287 });
1288 let api_keys = config.access.api_keys.clone();
1289 let processor = signal::SignalProcessor::new(config).await.unwrap();
1290 let router = create_router(Arc::new(processor), api_keys);
1291
1292 let payload = serde_json::json!({"content": "Remember something"});
1293 let request = Request::builder()
1294 .method(http::Method::POST)
1295 .uri("/v1/signals")
1296 .header("content-type", "application/json")
1297 .header("authorization", "Bearer read-only-key")
1298 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1299 .unwrap();
1300
1301 let response = router.oneshot(request).await.unwrap();
1302 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1303 }
1304
1305 #[tokio::test]
1311 async fn test_http_store_signal_fact_persisted_in_db() {
1312 use axum::body::Body;
1313 use axum::http::{self, Request};
1314 use tower::util::ServiceExt;
1315
1316 let temp = tempfile::tempdir().unwrap();
1317 let mut config = brain_core::BrainConfig::default();
1318 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1319 let api_keys = config.access.api_keys.clone();
1320 let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1321 let state = Arc::new(AppState {
1322 processor,
1323 cache: Mutex::new(HashMap::new()),
1324 api_keys,
1325 metrics: Arc::new(Metrics::default()),
1326 });
1327
1328 let payload = serde_json::json!({"content": "Remember that Rust is fast"});
1330 let post_req = Request::builder()
1331 .method(http::Method::POST)
1332 .uri("/v1/signals")
1333 .header("content-type", "application/json")
1334 .header("authorization", "Bearer demokey123")
1335 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1336 .unwrap();
1337
1338 let router = Router::new()
1339 .route("/v1/signals", post(post_signal_handler))
1340 .route("/v1/memory/facts", get(get_facts_handler))
1341 .with_state(state.clone());
1342
1343 let post_resp = router.clone().oneshot(post_req).await.unwrap();
1344 assert_eq!(post_resp.status(), StatusCode::OK);
1345
1346 let bytes = axum::body::to_bytes(post_resp.into_body(), usize::MAX)
1347 .await
1348 .unwrap();
1349 let resp_json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1350 assert_eq!(resp_json["status"], "Ok");
1351 assert!(resp_json["memory_context"].is_object());
1353
1354 let get_req = Request::builder()
1356 .method(http::Method::GET)
1357 .uri("/v1/memory/facts")
1358 .header("authorization", "Bearer demokey123")
1359 .body(Body::empty())
1360 .unwrap();
1361
1362 let get_resp = router.oneshot(get_req).await.unwrap();
1363 assert_eq!(get_resp.status(), StatusCode::OK);
1364
1365 let bytes = axum::body::to_bytes(get_resp.into_body(), usize::MAX)
1366 .await
1367 .unwrap();
1368 let facts: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1369 assert!(facts.is_array(), "Expected array of facts");
1370 assert!(
1371 !facts.as_array().unwrap().is_empty(),
1372 "Stored fact should appear in GET /v1/memory/facts"
1373 );
1374 }
1375
1376 #[tokio::test]
1381 async fn test_http_memory_search_returns_stored_fact() {
1382 use axum::body::Body;
1383 use axum::http::{self, Request};
1384 use tower::util::ServiceExt;
1385
1386 let temp = tempfile::tempdir().unwrap();
1387 let mut config = brain_core::BrainConfig::default();
1388 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1389 let api_keys = config.access.api_keys.clone();
1390 let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1391
1392 let _ = processor
1394 .store_fact_direct("personal", "test", "Ferris", "is", "the Rust mascot", None)
1395 .await
1396 .unwrap();
1397
1398 let state = Arc::new(AppState {
1399 processor,
1400 cache: Mutex::new(HashMap::new()),
1401 api_keys,
1402 metrics: Arc::new(Metrics::default()),
1403 });
1404
1405 let router = Router::new()
1406 .route("/v1/memory/search", post(search_memory_handler))
1407 .with_state(state);
1408
1409 let payload = serde_json::json!({"query": "Ferris Rust mascot", "top_k": 5});
1411 let request = Request::builder()
1412 .method(http::Method::POST)
1413 .uri("/v1/memory/search")
1414 .header("content-type", "application/json")
1415 .header("authorization", "Bearer demokey123")
1416 .body(Body::from(serde_json::to_string(&payload).unwrap()))
1417 .unwrap();
1418
1419 let response = router.oneshot(request).await.unwrap();
1420 assert_eq!(response.status(), StatusCode::OK);
1421
1422 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1423 .await
1424 .unwrap();
1425 let results: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1426 assert!(results.is_array(), "Expected array of search results");
1429 }
1430
1431 #[tokio::test]
1433 async fn test_get_cached_signal_with_auth() {
1434 use axum::body::Body;
1435 use axum::http::{self, Request};
1436 use tower::util::ServiceExt;
1437
1438 let temp = tempfile::tempdir().unwrap();
1439 let mut config = brain_core::BrainConfig::default();
1440 config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1441 let api_keys = config.access.api_keys.clone();
1442 let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1443 let state = Arc::new(AppState {
1444 processor,
1445 cache: Mutex::new(HashMap::new()),
1446 api_keys,
1447 metrics: Arc::new(Metrics::default()),
1448 });
1449
1450 let id = Uuid::new_v4();
1452 let fake_resp = SignalResponse::ok(id, "test response");
1453 state.cache.lock().await.insert(id, fake_resp);
1454
1455 let router = Router::new()
1456 .route("/v1/signals/:id", get(get_signal_handler))
1457 .with_state(state);
1458
1459 let request = Request::builder()
1460 .method(http::Method::GET)
1461 .uri(format!("/v1/signals/{id}"))
1462 .header("authorization", "Bearer demokey123")
1463 .body(Body::empty())
1464 .unwrap();
1465
1466 let response = router.oneshot(request).await.unwrap();
1467 assert_eq!(response.status(), StatusCode::OK);
1468 }
1469}