Skip to main content

ares_http/
lib.rs

1//! HTTP adapter plugin: Axum router, JWT auth, middleware, Overlay, TOON.
2
3#![allow(missing_docs)]
4#![allow(dead_code)]
5#![allow(unused_imports)]
6#![allow(hidden_glob_reexports)]
7#![allow(ambiguous_glob_reexports)]
8#![allow(private_interfaces)]
9#![allow(clippy::useless_conversion)]
10#![allow(clippy::unnecessary_sort_by)]
11#![allow(clippy::empty_line_after_doc_comments)]
12#![allow(clippy::too_many_arguments)]
13
14use std::sync::Arc;
15
16use axum::routing::get;
17use axum::Router;
18use cordis::{Context, CordisError, Plugin, PluginRegistry, Service};
19
20pub mod config;
21pub mod error;
22pub mod pipeline_hook;
23
24pub mod overlay;
25pub mod toon_config;
26
27#[cfg(feature = "postgres")]
28pub mod api;
29#[cfg(feature = "postgres")]
30pub mod auth;
31#[cfg(feature = "postgres")]
32pub mod middleware;
33
34#[cfg(feature = "postgres")]
35pub mod active_runs;
36#[cfg(feature = "postgres")]
37pub mod observability;
38
39#[cfg(feature = "postgres")]
40pub use ares_agent::skills as skill_engine;
41#[cfg(any(feature = "postgres", feature = "skills"))]
42pub use ares_agent::skills;
43#[cfg(feature = "postgres")]
44pub use ares_agent::trigger as trigger_engine;
45#[cfg(feature = "postgres")]
46pub use ares_agent::workflows;
47pub use ares_agent::EmergencyStop;
48
49pub use ares_llm::ConfigBasedLLMFactory;
50pub use ares_types::{models, types};
51pub use config::{AuthConfig, ServerConfig};
52pub use error::{app_error_into_response, HttpError};
53pub use overlay::{
54    AresConfig, AresConfigManager, ConfigError, Overlay, OverlayConfig, OverlayPlugin,
55};
56#[cfg(feature = "postgres")]
57pub use pipeline_hook::{PipelineFanout, PipelineFanoutHandle, PipelineOrigin};
58pub use toon_config::DynamicConfigManager;
59
60/// Compatibility paths so moved handlers can keep `crate::agents` / `crate::db`.
61pub use ares_agent as agents;
62pub use ares_agent::memory;
63#[cfg(feature = "postgres")]
64pub use ares_agent::research;
65pub mod db {
66    pub use ares_store::*;
67}
68pub mod utils {
69    pub use crate::overlay as toml_config;
70    pub use crate::toon_config;
71}
72
73/// Handler result that maps [`ares_types::AppError`] through [`HttpError`].
74pub type Result<T> = std::result::Result<T, HttpError>;
75
76/// Built Axum router provided by the Http plugin. Bind happens in `run_server`.
77pub struct Http {
78    pub router: Router,
79}
80
81impl Service for Http {
82    fn name(&self) -> &'static str {
83        "http"
84    }
85}
86
87/// Typed installer for [`Http`]. Host/port in [`ServerConfig`] are unused at apply
88/// time; the binary binds `Http.router`.
89pub struct HttpPlugin;
90
91impl Plugin for HttpPlugin {
92    type Config = ServerConfig;
93    type Provides = Http;
94
95    fn apply(
96        &self,
97        ctx: &Arc<Context>,
98        _config: Self::Config,
99    ) -> std::result::Result<Arc<Http>, CordisError> {
100        Ok(Arc::new(Http {
101            router: build_router(Arc::clone(ctx)),
102        }))
103    }
104}
105
106/// Cordis health routes used as the live HTTP base.
107pub fn cordis_routes() -> Router<Arc<Context>> {
108    Router::new()
109        .route("/health", get(|| async { "OK" }))
110        .route("/health/context", get(health_context))
111}
112
113async fn health_context(
114    axum::extract::State(ctx): axum::extract::State<Arc<Context>>,
115) -> impl axum::response::IntoResponse {
116    let _events = ctx.get::<cordis::EventsService>();
117    let _registry = ctx.get::<cordis::RegistryService>();
118    let _exec = ctx.get::<ares_agent::Execute>();
119    "OK"
120}
121
122/// Resolve an abstract model tier to `(provider, model)`.
123#[cfg(feature = "postgres")]
124pub async fn resolve_model_tier(
125    tenant_id: &str,
126    tier_name: &str,
127    pool: &sqlx::PgPool,
128    config: &AresConfig,
129) -> Option<(String, String)> {
130    let store = ares_store::tenant_model_tiers::TenantModelTierStore::new(pool);
131    if let Ok(Some(tier)) = store.get(tenant_id, tier_name).await {
132        return Some((tier.provider_name, tier.model_name));
133    }
134    config
135        .models
136        .get(tier_name)
137        .map(|mc| (mc.provider.clone(), mc.model.clone()))
138}
139
140/// Build the application router from a Cordis context.
141///
142/// Nests `/api` when Auth + TenantDb are on the context. Does not bind a port.
143pub fn build_router(ctx: Arc<Context>) -> Router {
144    let mut app = cordis_routes();
145    #[cfg(feature = "postgres")]
146    {
147        if let (Some(auth), Some(db)) = (
148            ctx.get::<crate::auth::jwt::AuthService>(),
149            ctx.get::<ares_store::TenantDb>(),
150        ) {
151            app = app.nest("/api", crate::api::routes::create_router(auth, db));
152        }
153    }
154    let _ = ctx.get::<cordis::EventsService>();
155    let _ = ctx.get::<cordis::RegistryService>();
156    let _ = ctx.get::<ares_agent::Execute>();
157    let _ = ctx.get::<ares_tools::Tools>();
158    // Copy the root context into request extensions so JWT middleware can
159    // isolate/intercept and fail-closed Store-lookup tenant claims.
160    app = app.layer(axum::middleware::from_fn_with_state(
161        Arc::clone(&ctx),
162        |axum::extract::State(ctx): axum::extract::State<Arc<Context>>,
163         mut req: axum::extract::Request,
164         next: axum::middleware::Next| async move {
165            req.extensions_mut().insert(ctx);
166            next.run(req).await
167        },
168    ));
169    app.with_state(ctx)
170}
171
172fn block_on_plugin<S: Service + 'static>(
173    ctx: &Arc<Context>,
174    svc: S,
175) -> std::result::Result<cordis::FiberId, CordisError> {
176    tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(ctx.plugin(svc)))
177}
178
179#[cfg(feature = "postgres")]
180fn factory_auth(
181    ctx: &Arc<Context>,
182    config: &serde_json::Value,
183) -> std::result::Result<cordis::FiberId, CordisError> {
184    use crate::auth::jwt::AuthService;
185    let auth = if let Some(mgr) = ctx.get::<AresConfigManager>() {
186        let cfg = mgr.config();
187        let jwt_secret = cfg.jwt_secret().map_err(|e| {
188            CordisError::Configuration(format!("JWT_SECRET environment variable must be set: {e}"))
189        })?;
190        AuthService::new(
191            jwt_secret,
192            cfg.auth.jwt_access_expiry,
193            cfg.auth.jwt_refresh_expiry,
194        )
195    } else {
196        let auth_cfg: AuthConfig =
197            if config.is_null() || config.as_object().is_some_and(|o| o.is_empty()) {
198                AuthConfig::default()
199            } else {
200                serde_json::from_value(config.clone()).map_err(|e| {
201                    CordisError::Configuration(format!("invalid AuthService config: {e}"))
202                })?
203            };
204        let jwt_secret = std::env::var(&auth_cfg.jwt_secret_env).map_err(|_| {
205            CordisError::Configuration(format!(
206                "JWT_SECRET environment variable must be set ({})",
207                auth_cfg.jwt_secret_env
208            ))
209        })?;
210        AuthService::new(
211            jwt_secret,
212            auth_cfg.jwt_access_expiry,
213            auth_cfg.jwt_refresh_expiry,
214        )
215    };
216    tracing::info!("Auth service initialized");
217    block_on_plugin(ctx, auth)
218}
219
220fn factory_http(
221    ctx: &Arc<Context>,
222    config: &serde_json::Value,
223) -> std::result::Result<cordis::FiberId, CordisError> {
224    let server_cfg: ServerConfig =
225        if config.is_null() || config.as_object().is_some_and(|o| o.is_empty()) {
226            if let Some(mgr) = ctx.get::<AresConfigManager>() {
227                mgr.config().server.clone()
228            } else {
229                ServerConfig::default()
230            }
231        } else {
232            serde_json::from_value(config.clone())
233                .map_err(|e| CordisError::Configuration(format!("invalid Http config: {e}")))?
234        };
235    let _ = server_cfg;
236    block_on_plugin(
237        ctx,
238        Http {
239            router: build_router(Arc::clone(ctx)),
240        },
241    )
242}
243
244/// Register AuthService and Http loader factories.
245/// Overlay is registered by `ares-server` so config watching stays server-owned.
246pub fn register_plugins(reg: &PluginRegistry) {
247    #[cfg(feature = "postgres")]
248    reg.register("AuthService", Arc::new(factory_auth));
249    reg.register("Http", Arc::new(factory_http));
250}
251
252#[cfg(feature = "inventory")]
253inventory::submit! { cordis::CordisInventory { name: "Http" } }
254#[cfg(all(feature = "inventory", feature = "postgres"))]
255inventory::submit! { cordis::CordisInventory { name: "AuthService" } }
256
257// Factory submits — same gates as the manual registrations above.
258#[cfg(feature = "inventory")]
259inventory::submit! {
260    cordis::CordisPluginFactory { name: "Http", make: factory_http }
261}
262#[cfg(all(feature = "inventory", feature = "postgres"))]
263inventory::submit! {
264    cordis::CordisPluginFactory { name: "AuthService", make: factory_auth }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[tokio::test]
272    async fn http_plugin_serves_health() {
273        let ctx = Context::new_root();
274        let http = HttpPlugin
275            .apply(&ctx, ServerConfig::default())
276            .expect("Http::apply");
277        let server = axum_test::TestServer::new(http.router.clone()).expect("test server");
278        let response = server.get("/health").await;
279        response.assert_status_ok();
280        response.assert_text("OK");
281    }
282
283    #[tokio::test]
284    async fn http_plugin_serves_health_context() {
285        let ctx = Context::new_root();
286        let router = build_router(ctx);
287        let server = axum_test::TestServer::new(router).expect("test server");
288        let response = server.get("/health/context").await;
289        response.assert_status_ok();
290    }
291}
292
293#[cfg(all(test, feature = "inventory"))]
294mod inventory_probe_tests {
295    #[test]
296    fn probe_names_in_lib_target() {
297        let mut v: Vec<&'static str> = inventory::iter::<cordis::CordisInventory>
298            .into_iter()
299            .map(|e| e.name)
300            .collect();
301        v.sort();
302        println!("LIBNAMES: {:?}", v);
303    }
304}