Skip to main content

brainos_httpadapter/
handlers.rs

1//! HTTP route handlers for the Brain REST API.
2
3use std::{collections::HashMap, sync::atomic::Ordering, sync::Arc, time::Instant};
4
5use axum::{
6    body::Bytes,
7    extract::{Path, Query, State},
8    http::{HeaderMap, Response, StatusCode},
9    response::{
10        sse::{Event, KeepAlive, Sse},
11        IntoResponse, Json,
12    },
13};
14use uuid::Uuid;
15
16use crate::auth;
17use crate::state::AppState;
18use crate::types::*;
19
20// ─── Embedded assets ─────────────────────────────────────────────────────────
21
22const UI_HTML: &str = include_str!("../assets/ui.html");
23static OPENAPI_JSON: &str = include_str!("../assets/openapi.json");
24const SWAGGER_UI_HTML: &str = include_str!("../assets/swagger.html");
25
26// ─── Public endpoints (no auth) ──────────────────────────────────────────────
27
28/// GET /health
29pub async fn health_handler() -> Json<HealthResponse> {
30    Json(HealthResponse {
31        status: "ok",
32        version: env!("CARGO_PKG_VERSION"),
33    })
34}
35
36/// GET /metrics — Prometheus text format
37pub async fn metrics_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
38    state.metrics.refresh_gauges(&state.processor);
39    let body = state.metrics.render(state.processor.metrics());
40    (
41        [("content-type", "text/plain; version=0.0.4; charset=utf-8")],
42        body,
43    )
44}
45
46/// GET /ui — embedded single-page memory explorer
47pub async fn ui_handler() -> impl IntoResponse {
48    ([("content-type", "text/html; charset=utf-8")], UI_HTML)
49}
50
51/// GET / — soft redirect to the diagnostic UI so the bare host:port lands somewhere.
52pub async fn root_handler() -> axum::response::Redirect {
53    axum::response::Redirect::temporary("/ui")
54}
55
56/// GET /openapi.json — OpenAPI 3.0 specification
57pub async fn openapi_handler() -> impl IntoResponse {
58    let spec = OPENAPI_JSON.replace("{{VERSION}}", env!("CARGO_PKG_VERSION"));
59    ([("content-type", "application/json")], spec)
60}
61
62/// GET /api — Swagger UI for interactive API exploration
63pub async fn swagger_ui_handler() -> impl IntoResponse {
64    (
65        [("content-type", "text/html; charset=utf-8")],
66        SWAGGER_UI_HTML,
67    )
68}
69
70// ─── Protected /v1/* endpoints ───────────────────────────────────────────────
71
72/// POST /v1/signals — requires write permission
73pub async fn post_signal_handler(
74    State(state): State<Arc<AppState>>,
75    headers: HeaderMap,
76    Json(body): Json<SignalRequest>,
77) -> Result<Json<signal::SignalResponse>, (StatusCode, String)> {
78    auth::check_auth(&state, &headers, "write")?;
79
80    let t0 = Instant::now();
81    state.metrics.signals_total.fetch_add(1, Ordering::Relaxed);
82
83    let source = signal::SignalSource::parse(body.source.as_deref(), signal::SignalSource::Http);
84    let sig = signal::Signal::from_adapter_request(signal::AdapterRequest {
85        source,
86        content: body.content,
87        channel: body.channel,
88        sender: body.sender,
89        metadata: body.metadata,
90        namespace: body.namespace,
91        agent: body.agent,
92        session_id: body.session_id,
93        default_channel: "http".to_string(),
94        default_sender: "apiclient".to_string(),
95    });
96
97    let signal_id = sig.id;
98    let result = state.processor.process(sig).await;
99
100    let elapsed_ms = t0.elapsed().as_millis() as u64;
101    state
102        .metrics
103        .signals_latency_ms_total
104        .fetch_add(elapsed_ms, Ordering::Relaxed);
105
106    let response = match result {
107        Ok(r) => {
108            state.metrics.signals_ok.fetch_add(1, Ordering::Relaxed);
109            tracing::info!(
110                signal_id = %signal_id,
111                latency_ms = elapsed_ms,
112                "signal processed"
113            );
114            r
115        }
116        Err(e) => {
117            state.metrics.signals_error.fetch_add(1, Ordering::Relaxed);
118            tracing::error!(
119                signal_id = %signal_id,
120                latency_ms = elapsed_ms,
121                error = %e,
122                "signal processing failed"
123            );
124            return Err((
125                StatusCode::INTERNAL_SERVER_ERROR,
126                serde_json::json!({
127                    "error": "Signal processing failed",
128                    "details": e.to_string()
129                })
130                .to_string(),
131            ));
132        }
133    };
134
135    state.cache.lock().await.put(signal_id, response.clone());
136    Ok(Json(response))
137}
138
139/// GET /v1/signals/:id — requires read permission
140pub async fn get_signal_handler(
141    State(state): State<Arc<AppState>>,
142    headers: HeaderMap,
143    Path(id): Path<String>,
144) -> Result<Json<signal::SignalResponse>, (StatusCode, String)> {
145    auth::check_auth(&state, &headers, "read")?;
146
147    let uuid = Uuid::parse_str(&id)
148        .map_err(|_| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {id}")))?;
149
150    let mut cache = state.cache.lock().await;
151    match cache.get(&uuid) {
152        Some(resp) => Ok(Json(resp.clone())),
153        None => Err((
154            StatusCode::NOT_FOUND,
155            format!("Signal {uuid} not found in cache"),
156        )),
157    }
158}
159
160/// POST /v1/memory/search — requires read permission
161pub async fn search_memory_handler(
162    State(state): State<Arc<AppState>>,
163    headers: HeaderMap,
164    Json(body): Json<SearchRequest>,
165) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
166    auth::check_auth(&state, &headers, "read")?;
167
168    state.metrics.search_total.fetch_add(1, Ordering::Relaxed);
169    let t0 = Instant::now();
170    let top_k = body.top_k.unwrap_or(10);
171    let namespace = body.namespace.as_deref();
172    let results = state
173        .processor
174        .search_facts(&body.query, top_k, namespace)
175        .await;
176    tracing::debug!(latency_ms = t0.elapsed().as_millis() as u64, query = %body.query, "memory search");
177
178    let facts = results
179        .into_iter()
180        .map(|r| FactJson {
181            id: r.fact.id,
182            namespace: r.fact.namespace,
183            category: r.fact.category,
184            subject: r.fact.subject,
185            predicate: r.fact.predicate,
186            object: r.fact.object,
187            confidence: r.fact.confidence,
188            distance: Some(r.distance),
189        })
190        .collect();
191
192    Ok(Json(facts))
193}
194
195/// GET /v1/memory/facts — requires read permission
196pub async fn get_facts_handler(
197    State(state): State<Arc<AppState>>,
198    headers: HeaderMap,
199    Query(params): Query<HashMap<String, String>>,
200) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
201    auth::check_auth(&state, &headers, "read")?;
202
203    state.metrics.facts_total.fetch_add(1, Ordering::Relaxed);
204    let namespace = params.get("namespace").map(|s| s.as_str());
205    let facts = state
206        .processor
207        .list_facts(namespace)
208        .into_iter()
209        .map(|f| FactJson {
210            id: f.id,
211            namespace: f.namespace,
212            category: f.category,
213            subject: f.subject,
214            predicate: f.predicate,
215            object: f.object,
216            confidence: f.confidence,
217            distance: None,
218        })
219        .collect();
220
221    Ok(Json(facts))
222}
223
224/// GET /v1/memory/namespaces — requires read permission
225pub async fn get_namespaces_handler(
226    State(state): State<Arc<AppState>>,
227    headers: HeaderMap,
228) -> Result<Json<Vec<NamespaceJson>>, (StatusCode, String)> {
229    auth::check_auth(&state, &headers, "read")?;
230
231    let namespaces = state
232        .processor
233        .list_namespaces()
234        .into_iter()
235        .map(|n| NamespaceJson {
236            namespace: n.namespace,
237            fact_count: n.fact_count,
238            episode_count: n.episode_count,
239        })
240        .collect();
241
242    Ok(Json(namespaces))
243}
244
245/// GET /v1/memory/export — requires read permission
246pub async fn export_memory_handler(
247    State(state): State<Arc<AppState>>,
248    headers: HeaderMap,
249) -> Result<Json<ExportJson>, (StatusCode, String)> {
250    auth::check_auth(&state, &headers, "read")?;
251
252    let facts = state.processor.export_facts().map_err(|e| {
253        (
254            StatusCode::INTERNAL_SERVER_ERROR,
255            format!("Export failed: {e}"),
256        )
257    })?;
258    let episodes = state.processor.export_episodes().map_err(|e| {
259        (
260            StatusCode::INTERNAL_SERVER_ERROR,
261            format!("Export failed: {e}"),
262        )
263    })?;
264
265    Ok(Json(ExportJson {
266        version: env!("CARGO_PKG_VERSION").to_string(),
267        exported_at: chrono::Utc::now().to_rfc3339(),
268        facts,
269        episodes,
270    }))
271}
272
273/// POST /v1/memory/import — requires write permission
274pub async fn import_memory_handler(
275    State(state): State<Arc<AppState>>,
276    headers: HeaderMap,
277    Json(body): Json<ImportRequest>,
278) -> Result<Json<ImportResponse>, (StatusCode, String)> {
279    auth::check_auth(&state, &headers, "write")?;
280
281    if body.dry_run {
282        return Ok(Json(ImportResponse {
283            facts_imported: body.facts.len(),
284            episodes_imported: body.episodes.len(),
285            facts_already_existed: 0,
286            episodes_already_existed: 0,
287            embedded: 0,
288            embed_failed: 0,
289        }));
290    }
291
292    let (facts_imported, new_fact_indices) =
293        state.processor.import_facts(&body.facts).map_err(|e| {
294            (
295                StatusCode::INTERNAL_SERVER_ERROR,
296                format!("Import failed: {e}"),
297            )
298        })?;
299    let episodes_imported = state
300        .processor
301        .import_episodes(&body.episodes)
302        .map_err(|e| {
303            (
304                StatusCode::INTERNAL_SERVER_ERROR,
305                format!("Import failed: {e}"),
306            )
307        })?;
308
309    let mut embedded = 0;
310    let mut embed_failed = 0;
311    if !new_fact_indices.is_empty() {
312        let new_facts: Vec<signal::ExportedFact> = new_fact_indices
313            .iter()
314            .map(|&idx| body.facts[idx].clone())
315            .collect();
316        let (e, f) = state.processor.reembed_facts(&new_facts).await;
317        embedded = e;
318        embed_failed = f;
319    }
320
321    Ok(Json(ImportResponse {
322        facts_imported,
323        episodes_imported,
324        facts_already_existed: body.facts.len() - facts_imported,
325        episodes_already_existed: body.episodes.len() - episodes_imported,
326        embedded,
327        embed_failed,
328    }))
329}
330
331/// GET /v1/schedules — list scheduled intents (requires read)
332pub async fn list_schedules_handler(
333    State(state): State<Arc<AppState>>,
334    headers: HeaderMap,
335    Query(params): Query<HashMap<String, String>>,
336) -> Result<Json<Vec<serde_json::Value>>, (StatusCode, String)> {
337    auth::check_auth(&state, &headers, "read")?;
338
339    let namespace = params.get("namespace").map(|s| s.as_str());
340    let intents = state
341        .processor
342        .list_scheduled_intents(namespace)
343        .map_err(|e| {
344            (
345                StatusCode::INTERNAL_SERVER_ERROR,
346                format!("Failed to list schedules: {e}"),
347            )
348        })?;
349
350    let json: Vec<serde_json::Value> = intents
351        .iter()
352        .map(|i| {
353            serde_json::json!({
354                "id": i.id,
355                "description": i.description,
356                "cron": i.cron,
357                "namespace": i.namespace,
358                "created_at": i.created_at,
359                "status": i.status,
360            })
361        })
362        .collect();
363
364    Ok(Json(json))
365}
366
367/// DELETE /v1/schedules/:id — cancel a scheduled intent (requires write)
368pub async fn cancel_schedule_handler(
369    State(state): State<Arc<AppState>>,
370    headers: HeaderMap,
371    Path(id): Path<String>,
372) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
373    auth::check_auth(&state, &headers, "write")?;
374
375    let cancelled = state.processor.cancel_scheduled_intent(&id).map_err(|e| {
376        (
377            StatusCode::INTERNAL_SERVER_ERROR,
378            format!("Failed to cancel schedule: {e}"),
379        )
380    })?;
381
382    if cancelled {
383        Ok(Json(serde_json::json!({"cancelled": true, "id": id})))
384    } else {
385        Err((
386            StatusCode::NOT_FOUND,
387            format!("No scheduled intent found with ID: {id}"),
388        ))
389    }
390}
391
392/// `GET /v1/events` — Server-Sent Events stream of proactive notifications.
393pub async fn sse_events_handler(
394    State(state): State<Arc<AppState>>,
395    headers: HeaderMap,
396) -> Result<
397    Sse<impl futures_core::Stream<Item = Result<Event, std::convert::Infallible>>>,
398    (StatusCode, String),
399> {
400    auth::check_auth(&state, &headers, "read")?;
401
402    let mut signal_rx = state.processor.subscribe_events();
403    let mut notif_rx = state.processor.notification_router().map(|r| r.subscribe());
404
405    let stream = async_stream::stream! {
406        loop {
407            tokio::select! {
408                result = signal_rx.recv() => {
409                    match result {
410                        Ok(event) => {
411                            let payload = serde_json::json!({
412                                "type": "signal",
413                                "signal_id": event.signal_id.to_string(),
414                                "source": format!("{:?}", event.source),
415                                "status": format!("{:?}", event.status),
416                                "response": event.response,
417                                "facts_used": event.facts_used,
418                                "episodes_used": event.episodes_used,
419                            });
420                            yield Ok(Event::default()
421                                .event("signal")
422                                .json_data(payload)
423                                .unwrap_or_else(|_| Event::default().data("{}")));
424                        }
425                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
426                            tracing::warn!(skipped = n, "SSE signal stream lagged");
427                            yield Ok(Event::default()
428                                .event("error")
429                                .data(format!("{{\"lagged\":{n}}}")));
430                        }
431                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
432                    }
433                }
434                result = async {
435                    match notif_rx.as_mut() {
436                        Some(rx) => rx.recv().await,
437                        None => std::future::pending().await,
438                    }
439                } => {
440                    match result {
441                        Ok(notification) => {
442                            let payload = serde_json::json!({
443                                "type": "proactive",
444                                "content": notification.content,
445                                "triggered_by": notification.triggered_by,
446                                "priority": notification.priority,
447                                "agent": notification.agent,
448                            });
449                            yield Ok(Event::default()
450                                .event("notification")
451                                .json_data(payload)
452                                .unwrap_or_else(|_| Event::default().data("{}")));
453                        }
454                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
455                            tracing::warn!(skipped = n, "SSE notification stream lagged");
456                            yield Ok(Event::default()
457                                .event("error")
458                                .data(format!("{{\"lagged\":{n}}}")));
459                        }
460                        Err(tokio::sync::broadcast::error::RecvError::Closed) => {
461                            notif_rx = None;
462                        }
463                    }
464                }
465            }
466        }
467    };
468
469    Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
470}
471
472// ─── Webhook handlers ────────────────────────────────────────────────────────
473
474/// POST /v1/webhooks/:id — ingest inbound messages from external platforms.
475///
476/// This endpoint finds the registered `WebhookInboundTransport` for the given
477/// ID and delegates signature verification and message extraction to it.
478pub async fn post_webhook_handler(
479    State(state): State<Arc<AppState>>,
480    Path(id): Path<String>,
481    headers: HeaderMap,
482    body: Bytes,
483) -> impl IntoResponse {
484    let transport = match state.webhook_handlers.get(&id) {
485        Some(t) => t,
486        None => {
487            return Response::builder()
488                .status(StatusCode::NOT_FOUND)
489                .body(format!("Unknown transport ID: {id}"))
490                .unwrap()
491                .into_response();
492        }
493    };
494
495    let resp = transport.handle_request(&headers, &body).await;
496
497    Response::builder()
498        .status(StatusCode::from_u16(resp.status).unwrap_or(StatusCode::OK))
499        .header("content-type", resp.content_type)
500        .body(resp.body)
501        .unwrap()
502        .into_response()
503}