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