diff --git a/CHANGELOG.md b/CHANGELOG.md index 2939f30..be8f572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - Add shared Dashboard Client/Harness setup profiles for Claude Code, Qwen Code, and the OpenAI SDK, while explicitly blocking Codex CLI until the Responses ingress exists. +- Compose the HTTP application from domain-owned routers and verify one + complete method/path/domain inventory without changing the public API. All notable ModelPort changes are recorded here. The project follows [Semantic Versioning](https://semver.org/) once a version is published. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f166122..c479ea7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,9 @@ ordinary pull requests should prefer mock-backed checks. - Preserve module boundaries described in [Architecture](docs/ARCHITECTURE.md#backend-boundaries). +- Register every new HTTP capability through its domain-owned router and extend + the route-contract inventory; keep the root router limited to domain + composition and global middleware. - Keep protocol conversion in adapters and provider quirks in explicit provider configuration. - Add tests for split SSE frames, errors after headers, Tool Use causality, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c6b910e..41e42df 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -181,8 +181,12 @@ Tool Use verification evidence is maintained separately in the Provider rendering, and cross-protocol response mapping. - `src/stream_lifecycle.rs`: shared upstream terminal state and normalized streaming usage evidence. -- `src/routes.rs` and `src/routes/`: router assembly, security helpers, public - client routes, operations routes, and control-plane endpoints. +- `src/routes.rs`: application-state types, shared HTTP policy helpers, legacy + handlers that have not yet moved, domain-router composition, and global + middleware applied exactly once. +- `src/routes/`: domain-owned route registration plus public client, + operations, identity, Provider, governance, control, and evidence handlers + and views. - `src/providers/`: Anthropic pass-through and OpenAI-compatible request, response, and SSE conversion. - `src/http.rs`: the upstream HTTP client, bounded response reading, SSE frame @@ -199,6 +203,27 @@ Tool Use verification evidence is maintained separately in the - `dashboard/`: the browser control plane. It consumes `/admin/*`; it is not a second source of routing truth. +### HTTP route ownership + +The single-process server is composed from ten explicit HTTP domains: system, +client API, internal operations, admin authentication, governance, admin +operations, Providers, control, evidence, and identity. Each domain module owns +its Axum method/path registration. `routes::router` merges those routers and +then applies request IDs, tracing, concurrency, response headers, and the global +body limit once; domain-local middleware such as login body and cache policy +remains next to the owned route. + +Tests maintain one complete method/path/domain inventory for the 68 current +route registrations. They reject duplicate method ownership and probe the +composed application so a missing path or method fails independently of handler +authorization or resource lookup results. A new Compute, Deployment, protocol, +or admin capability must extend its domain router and this inventory rather +than adding another registration to the root composition function. + +This is an internal modular-monolith boundary. It does not create another +process, public discovery endpoint, authorization source, or API version, and +reverting the composition requires no data or protocol migration. + ## Request Lifecycle For `POST /v1/messages` and `POST /v1/chat/completions`, the current order is: diff --git a/src/routes.rs b/src/routes.rs index 021fe2b..61ce66f 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -19,7 +19,6 @@ use axum::{ }, middleware, response::{IntoResponse, Response}, - routing::{delete, get, post, put}, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -57,6 +56,10 @@ use crate::{ }; mod admin_api_keys; +mod admin_auth; +mod admin_control; +mod admin_evidence; +mod admin_identity; mod admin_providers; mod admin_users; mod client_api; @@ -67,6 +70,8 @@ mod logs_view; mod ops; mod ops_agent; mod provider_view; +#[cfg(test)] +mod route_contract; mod settings_view; use dashboard_view::{DashboardQuery, dashboard_body}; @@ -578,199 +583,16 @@ pub fn router(state: AppState) -> Router { let max_concurrent_requests = config.max_concurrent_requests; Router::new() - .route("/livez", get(ops::livez)) - .route("/readyz", get(ops::readyz)) - .route("/health", get(ops::health)) - .route("/metrics", get(ops::metrics)) - .route("/v1/models", get(client_api::models)) - .route("/v1/messages", post(client_api::messages)) - .route("/v1/messages/count_tokens", post(client_api::count_tokens)) - .route("/v1/chat/completions", post(client_api::chat_completions)) - .route("/v1/effective-policy", get(client_api::effective_policy)) - .route("/internal/ops/v1/snapshot", get(ops_agent::snapshot)) - .route( - "/internal/ops/v1/observations", - post(ops_agent::submit_observation), - ) - .route("/internal/ops/v1/heartbeats", post(ops_agent::heartbeat)) - .route( - "/admin/auth/login", - post(admin_login) - .layer(DefaultBodyLimit::max(16 * 1024)) - .layer(middleware::from_fn(add_no_store_header)), - ) - .route( - "/admin/auth/methods", - get(admin_auth_methods).layer(middleware::from_fn(add_no_store_header)), - ) - .route( - "/admin/auth/oidc/start", - get(admin_oidc_start).layer(middleware::from_fn(add_no_store_header)), - ) - .route( - "/admin/auth/oidc/callback", - get(admin_oidc_callback).layer(middleware::from_fn(add_no_store_header)), - ) - .route("/admin/auth/logout", post(admin_logout)) - .route("/admin/auth/me", get(admin_me)) - .route( - "/admin/self-service/governance", - get(governance_routes::self_service_governance), - ) - .route( - "/admin/governance", - get(governance_routes::admin_governance_overview), - ) - .route( - "/admin/governance/change-requests", - get(governance_routes::admin_change_requests) - .post(governance_routes::admin_create_change_request), - ) - .route( - "/admin/governance/change-requests/{change_id}/approve", - post(governance_routes::admin_approve_change_request), - ) - .route( - "/admin/governance/change-requests/{change_id}/apply", - post(governance_routes::admin_apply_change_request), - ) - .route("/admin/dashboard", get(admin_dashboard)) - .route("/admin/ops/incidents", get(ops_agent::admin_incidents)) - .route( - "/admin/ops/configuration", - get(ops_agent::admin_configuration).put(ops_agent::admin_update_configuration), - ) - .route( - "/admin/ops/incidents/{incident_id}", - get(ops_agent::admin_incident_detail), - ) - .route( - "/admin/ops/incidents/{incident_id}/status", - post(ops_agent::admin_update_incident_status), - ) - .route( - "/admin/ops/incidents/{incident_id}/feedback", - post(ops_agent::admin_record_incident_feedback), - ) - .route( - "/admin/providers", - get(admin_providers::admin_providers).post(admin_providers::admin_create_provider), - ) - .route( - "/admin/providers/{provider_id}", - put(admin_providers::admin_update_provider) - .delete(admin_providers::admin_delete_provider), - ) - .route( - "/admin/providers/{provider_id}/disable", - post(admin_providers::admin_set_provider_disabled), - ) - .route( - "/admin/providers/{provider_id}/models", - post(admin_providers::admin_provider_models) - .put(admin_providers::admin_upsert_provider_model) - .delete(admin_providers::admin_delete_provider_model), - ) - .route( - "/admin/providers/{provider_id}/balance", - post(admin_providers::admin_provider_balance), - ) - .route( - "/admin/providers/{provider_id}/credentials", - post(admin_providers::admin_create_provider_credential), - ) - .route( - "/admin/providers/{provider_id}/credential-pool", - put(admin_providers::admin_set_provider_credential_pool_mode), - ) - .route( - "/admin/providers/{provider_id}/credentials/{credential_id}", - put(admin_providers::admin_update_provider_credential) - .delete(admin_providers::admin_delete_provider_credential), - ) - .route( - "/admin/providers/{provider_id}/credentials/{credential_id}/select", - post(admin_providers::admin_select_provider_credential), - ) - .route( - "/admin/aliases", - get(admin_aliases).post(admin_create_alias), - ) - .route("/admin/aliases/{alias}", delete(admin_delete_alias)) - .route( - "/admin/settings", - get(admin_settings).put(admin_update_settings), - ) - .route("/admin/settings/reload-config", post(admin_reload_config)) - .route("/admin/settings/test-provider", post(admin_test_provider)) - .route("/admin/audit", get(admin_audit)) - .route("/admin/backup", post(admin_backup)) - .route("/admin/retention/run", post(admin_run_retention)) - .route("/admin/logs", get(admin_logs)) - .route("/admin/logs/{log_id}", get(admin_log_by_id)) - .route("/admin/latency", get(admin_latency)) - .route("/admin/router/status", get(admin_router_status)) - .route("/admin/enterprise/overview", get(admin_enterprise_overview)) - .route( - "/admin/enterprise/budget", - get(admin_enterprise_budget).put(admin_update_enterprise_budget), - ) - .route( - "/admin/enterprise/budget/adjustments", - post(admin_adjust_enterprise_budget), - ) - .route("/admin/enterprise/requests", get(admin_enterprise_requests)) - .route( - "/admin/enterprise/requests/{ledger_id}", - get(admin_enterprise_request_detail), - ) - .route("/admin/teams", get(admin_teams).post(admin_upsert_team)) - .route( - "/admin/teams/{team_id}", - put(admin_update_team).delete(admin_delete_team), - ) - .route( - "/admin/users", - get(admin_users::admin_users).post(admin_users::admin_create_user), - ) - .route( - "/admin/users/{user_id}", - put(admin_users::admin_update_user).delete(admin_users::admin_delete_user), - ) - .route( - "/admin/api-keys", - get(admin_api_keys::admin_api_keys).post(admin_api_keys::admin_create_api_key), - ) - .route( - "/admin/api-keys/{key_id}/disable", - post(admin_api_keys::admin_revoke_api_key), - ) - .route( - "/admin/api-keys/{key_id}/rotate", - post(admin_api_keys::admin_rotate_api_key), - ) - .route( - "/admin/api-keys/{key_id}/rotate/{replacement_id}", - post(admin_api_keys::admin_confirm_api_key_rotation) - .delete(admin_api_keys::admin_cancel_api_key_rotation), - ) - .route( - "/admin/users/{user_id}/api-keys", - get(admin_api_keys::admin_user_api_keys).post(admin_api_keys::admin_create_api_key), - ) - .route( - "/admin/api-keys/{key_id}", - put(admin_api_keys::admin_update_api_key).delete(admin_api_keys::admin_delete_api_key), - ) - .route( - "/admin/api-keys/{key_id}/scope", - put(admin_api_keys::admin_bind_api_key_scope), - ) - .route("/admin/quotas", get(admin_quotas).post(admin_create_quota)) - .route( - "/admin/quotas/{quota_id}", - put(admin_update_quota).delete(admin_delete_quota), - ) + .merge(ops::router()) + .merge(client_api::router()) + .merge(ops_agent::internal_router()) + .merge(admin_auth::router()) + .merge(governance_routes::router()) + .merge(ops_agent::admin_router()) + .merge(admin_providers::router()) + .merge(admin_control::router()) + .merge(admin_evidence::router()) + .merge(admin_identity::router()) .layer( ServiceBuilder::new() .layer(SetRequestIdLayer::new( @@ -2841,9 +2663,10 @@ mod tests { body::{Body, to_bytes}, extract::connect_info::ConnectInfo, http::{ - Request, StatusCode, + Method, Request, StatusCode, header::{CONTENT_TYPE, COOKIE, HOST, HeaderValue, ORIGIN, SET_COOKIE}, }, + routing::{get, post}, }; use serde_json::{Value, json}; use tokio::net::TcpListener; @@ -2863,6 +2686,64 @@ mod tests { const CLIENT_TOKEN: &str = "client-token"; + #[tokio::test] + async fn composed_router_matches_the_complete_route_inventory() { + let app = router(test_state("http://127.0.0.1:9".to_owned(), 1024)); + + for contract in route_contract::all() { + let path = concrete_contract_path(contract.path); + let unowned_method = contract_response(&app, Method::TRACE, &path).await; + assert_eq!( + unowned_method, + StatusCode::METHOD_NOT_ALLOWED, + "{} does not resolve to its declared {} domain", + contract.path, + contract.domain, + ); + + for method in contract.methods { + let method = Method::from_bytes(method.as_bytes()).unwrap(); + let status = contract_response(&app, method.clone(), &path).await; + assert_ne!( + status, + StatusCode::METHOD_NOT_ALLOWED, + "{} {} is missing from the composed router", + method, + contract.path, + ); + } + } + } + + async fn contract_response(app: &Router, method: Method, path: &str) -> StatusCode { + app.clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap() + .status() + } + + fn concrete_contract_path(path: &str) -> String { + let mut concrete = String::with_capacity(path.len()); + let mut remaining = path; + while let Some(start) = remaining.find('{') { + concrete.push_str(&remaining[..start]); + let parameter = &remaining[start..]; + let end = parameter.find('}').expect("route parameter closes"); + concrete.push_str("route-contract"); + remaining = ¶meter[end + 1..]; + } + concrete.push_str(remaining); + concrete + } + #[tokio::test] async fn public_auth_methods_report_disabled_oidc_without_cache() { let app = router(test_state("http://127.0.0.1:9".to_owned(), 1024)); diff --git a/src/routes/admin_auth.rs b/src/routes/admin_auth.rs new file mode 100644 index 0000000..f7f88bb --- /dev/null +++ b/src/routes/admin_auth.rs @@ -0,0 +1,48 @@ +use axum::{ + Router, + extract::DefaultBodyLimit, + middleware, + routing::{get, post}, +}; + +use super::{ + AppState, add_no_store_header, admin_auth_methods, admin_login, admin_logout, admin_me, + admin_oidc_callback, admin_oidc_start, +}; + +#[cfg(test)] +use super::route_contract::RouteContract; + +#[cfg(test)] +pub(super) const ROUTES: &[RouteContract] = &[ + RouteContract::new("admin-auth", "/admin/auth/login", &["POST"]), + RouteContract::new("admin-auth", "/admin/auth/methods", &["GET"]), + RouteContract::new("admin-auth", "/admin/auth/oidc/start", &["GET"]), + RouteContract::new("admin-auth", "/admin/auth/oidc/callback", &["GET"]), + RouteContract::new("admin-auth", "/admin/auth/logout", &["POST"]), + RouteContract::new("admin-auth", "/admin/auth/me", &["GET"]), +]; + +pub(super) fn router() -> Router { + Router::new() + .route( + "/admin/auth/login", + post(admin_login) + .layer(DefaultBodyLimit::max(16 * 1024)) + .layer(middleware::from_fn(add_no_store_header)), + ) + .route( + "/admin/auth/methods", + get(admin_auth_methods).layer(middleware::from_fn(add_no_store_header)), + ) + .route( + "/admin/auth/oidc/start", + get(admin_oidc_start).layer(middleware::from_fn(add_no_store_header)), + ) + .route( + "/admin/auth/oidc/callback", + get(admin_oidc_callback).layer(middleware::from_fn(add_no_store_header)), + ) + .route("/admin/auth/logout", post(admin_logout)) + .route("/admin/auth/me", get(admin_me)) +} diff --git a/src/routes/admin_control.rs b/src/routes/admin_control.rs new file mode 100644 index 0000000..1fd696d --- /dev/null +++ b/src/routes/admin_control.rs @@ -0,0 +1,38 @@ +use axum::{ + Router, + routing::{delete, get, post}, +}; + +use super::{ + AppState, admin_aliases, admin_create_alias, admin_delete_alias, admin_reload_config, + admin_router_status, admin_settings, admin_test_provider, admin_update_settings, +}; + +#[cfg(test)] +use super::route_contract::RouteContract; + +#[cfg(test)] +pub(super) const ROUTES: &[RouteContract] = &[ + RouteContract::new("admin-control", "/admin/aliases", &["GET", "POST"]), + RouteContract::new("admin-control", "/admin/aliases/{alias}", &["DELETE"]), + RouteContract::new("admin-control", "/admin/settings", &["GET", "PUT"]), + RouteContract::new("admin-control", "/admin/settings/reload-config", &["POST"]), + RouteContract::new("admin-control", "/admin/settings/test-provider", &["POST"]), + RouteContract::new("admin-control", "/admin/router/status", &["GET"]), +]; + +pub(super) fn router() -> Router { + Router::new() + .route( + "/admin/aliases", + get(admin_aliases).post(admin_create_alias), + ) + .route("/admin/aliases/{alias}", delete(admin_delete_alias)) + .route( + "/admin/settings", + get(admin_settings).put(admin_update_settings), + ) + .route("/admin/settings/reload-config", post(admin_reload_config)) + .route("/admin/settings/test-provider", post(admin_test_provider)) + .route("/admin/router/status", get(admin_router_status)) +} diff --git a/src/routes/admin_evidence.rs b/src/routes/admin_evidence.rs new file mode 100644 index 0000000..48e50da --- /dev/null +++ b/src/routes/admin_evidence.rs @@ -0,0 +1,67 @@ +use axum::{ + Router, + routing::{get, post}, +}; + +use super::{ + AppState, admin_adjust_enterprise_budget, admin_audit, admin_backup, admin_dashboard, + admin_enterprise_budget, admin_enterprise_overview, admin_enterprise_request_detail, + admin_enterprise_requests, admin_latency, admin_log_by_id, admin_logs, admin_run_retention, + admin_update_enterprise_budget, +}; + +#[cfg(test)] +use super::route_contract::RouteContract; + +#[cfg(test)] +pub(super) const ROUTES: &[RouteContract] = &[ + RouteContract::new("admin-evidence", "/admin/dashboard", &["GET"]), + RouteContract::new("admin-evidence", "/admin/audit", &["GET"]), + RouteContract::new("admin-evidence", "/admin/backup", &["POST"]), + RouteContract::new("admin-evidence", "/admin/retention/run", &["POST"]), + RouteContract::new("admin-evidence", "/admin/logs", &["GET"]), + RouteContract::new("admin-evidence", "/admin/logs/{log_id}", &["GET"]), + RouteContract::new("admin-evidence", "/admin/latency", &["GET"]), + RouteContract::new("admin-evidence", "/admin/enterprise/overview", &["GET"]), + RouteContract::new( + "admin-evidence", + "/admin/enterprise/budget", + &["GET", "PUT"], + ), + RouteContract::new( + "admin-evidence", + "/admin/enterprise/budget/adjustments", + &["POST"], + ), + RouteContract::new("admin-evidence", "/admin/enterprise/requests", &["GET"]), + RouteContract::new( + "admin-evidence", + "/admin/enterprise/requests/{ledger_id}", + &["GET"], + ), +]; + +pub(super) fn router() -> Router { + Router::new() + .route("/admin/dashboard", get(admin_dashboard)) + .route("/admin/audit", get(admin_audit)) + .route("/admin/backup", post(admin_backup)) + .route("/admin/retention/run", post(admin_run_retention)) + .route("/admin/logs", get(admin_logs)) + .route("/admin/logs/{log_id}", get(admin_log_by_id)) + .route("/admin/latency", get(admin_latency)) + .route("/admin/enterprise/overview", get(admin_enterprise_overview)) + .route( + "/admin/enterprise/budget", + get(admin_enterprise_budget).put(admin_update_enterprise_budget), + ) + .route( + "/admin/enterprise/budget/adjustments", + post(admin_adjust_enterprise_budget), + ) + .route("/admin/enterprise/requests", get(admin_enterprise_requests)) + .route( + "/admin/enterprise/requests/{ledger_id}", + get(admin_enterprise_request_detail), + ) +} diff --git a/src/routes/admin_identity.rs b/src/routes/admin_identity.rs new file mode 100644 index 0000000..447233d --- /dev/null +++ b/src/routes/admin_identity.rs @@ -0,0 +1,113 @@ +use axum::{ + Router, + routing::{get, post, put}, +}; + +use super::{ + AppState, admin_api_keys, admin_create_quota, admin_delete_quota, admin_delete_team, + admin_quotas, admin_teams, admin_update_quota, admin_update_team, admin_upsert_team, + admin_users, +}; + +#[cfg(test)] +use super::route_contract::RouteContract; + +#[cfg(test)] +pub(super) const ROUTES: &[RouteContract] = &[ + RouteContract::new("admin-identity", "/admin/teams", &["GET", "POST"]), + RouteContract::new( + "admin-identity", + "/admin/teams/{team_id}", + &["PUT", "DELETE"], + ), + RouteContract::new("admin-identity", "/admin/users", &["GET", "POST"]), + RouteContract::new( + "admin-identity", + "/admin/users/{user_id}", + &["PUT", "DELETE"], + ), + RouteContract::new("admin-identity", "/admin/api-keys", &["GET", "POST"]), + RouteContract::new( + "admin-identity", + "/admin/api-keys/{key_id}/disable", + &["POST"], + ), + RouteContract::new( + "admin-identity", + "/admin/api-keys/{key_id}/rotate", + &["POST"], + ), + RouteContract::new( + "admin-identity", + "/admin/api-keys/{key_id}/rotate/{replacement_id}", + &["POST", "DELETE"], + ), + RouteContract::new( + "admin-identity", + "/admin/users/{user_id}/api-keys", + &["GET", "POST"], + ), + RouteContract::new( + "admin-identity", + "/admin/api-keys/{key_id}", + &["PUT", "DELETE"], + ), + RouteContract::new("admin-identity", "/admin/api-keys/{key_id}/scope", &["PUT"]), + RouteContract::new("admin-identity", "/admin/quotas", &["GET", "POST"]), + RouteContract::new( + "admin-identity", + "/admin/quotas/{quota_id}", + &["PUT", "DELETE"], + ), +]; + +pub(super) fn router() -> Router { + Router::new() + .route("/admin/teams", get(admin_teams).post(admin_upsert_team)) + .route( + "/admin/teams/{team_id}", + put(admin_update_team).delete(admin_delete_team), + ) + .route( + "/admin/users", + get(admin_users::admin_users).post(admin_users::admin_create_user), + ) + .route( + "/admin/users/{user_id}", + put(admin_users::admin_update_user).delete(admin_users::admin_delete_user), + ) + .route( + "/admin/api-keys", + get(admin_api_keys::admin_api_keys).post(admin_api_keys::admin_create_api_key), + ) + .route( + "/admin/api-keys/{key_id}/disable", + post(admin_api_keys::admin_revoke_api_key), + ) + .route( + "/admin/api-keys/{key_id}/rotate", + post(admin_api_keys::admin_rotate_api_key), + ) + .route( + "/admin/api-keys/{key_id}/rotate/{replacement_id}", + post(admin_api_keys::admin_confirm_api_key_rotation) + .delete(admin_api_keys::admin_cancel_api_key_rotation), + ) + .route( + "/admin/users/{user_id}/api-keys", + get(admin_api_keys::admin_user_api_keys).post(admin_api_keys::admin_create_api_key), + ) + .route( + "/admin/api-keys/{key_id}", + put(admin_api_keys::admin_update_api_key).delete(admin_api_keys::admin_delete_api_key), + ) + .route( + "/admin/api-keys/{key_id}/scope", + put(admin_api_keys::admin_bind_api_key_scope), + ) + .route("/admin/quotas", get(admin_quotas).post(admin_create_quota)) + .route( + "/admin/quotas/{quota_id}", + put(admin_update_quota).delete(admin_delete_quota), + ) +} diff --git a/src/routes/admin_providers.rs b/src/routes/admin_providers.rs index b17aab8..50aff29 100644 --- a/src/routes/admin_providers.rs +++ b/src/routes/admin_providers.rs @@ -1,14 +1,105 @@ use axum::{ - Json, + Json, Router, extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, + routing::{get, post, put}, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use super::*; +#[cfg(test)] +use super::route_contract::RouteContract; + +#[cfg(test)] +pub(super) const ROUTES: &[RouteContract] = &[ + RouteContract::new("admin-providers", "/admin/providers", &["GET", "POST"]), + RouteContract::new( + "admin-providers", + "/admin/providers/{provider_id}", + &["PUT", "DELETE"], + ), + RouteContract::new( + "admin-providers", + "/admin/providers/{provider_id}/disable", + &["POST"], + ), + RouteContract::new( + "admin-providers", + "/admin/providers/{provider_id}/models", + &["POST", "PUT", "DELETE"], + ), + RouteContract::new( + "admin-providers", + "/admin/providers/{provider_id}/balance", + &["POST"], + ), + RouteContract::new( + "admin-providers", + "/admin/providers/{provider_id}/credentials", + &["POST"], + ), + RouteContract::new( + "admin-providers", + "/admin/providers/{provider_id}/credential-pool", + &["PUT"], + ), + RouteContract::new( + "admin-providers", + "/admin/providers/{provider_id}/credentials/{credential_id}", + &["PUT", "DELETE"], + ), + RouteContract::new( + "admin-providers", + "/admin/providers/{provider_id}/credentials/{credential_id}/select", + &["POST"], + ), +]; + +pub(super) fn router() -> Router { + Router::new() + .route( + "/admin/providers", + get(admin_providers).post(admin_create_provider), + ) + .route( + "/admin/providers/{provider_id}", + put(admin_update_provider).delete(admin_delete_provider), + ) + .route( + "/admin/providers/{provider_id}/disable", + post(admin_set_provider_disabled), + ) + .route( + "/admin/providers/{provider_id}/models", + post(admin_provider_models) + .put(admin_upsert_provider_model) + .delete(admin_delete_provider_model), + ) + .route( + "/admin/providers/{provider_id}/balance", + post(admin_provider_balance), + ) + .route( + "/admin/providers/{provider_id}/credentials", + post(admin_create_provider_credential), + ) + .route( + "/admin/providers/{provider_id}/credential-pool", + put(admin_set_provider_credential_pool_mode), + ) + .route( + "/admin/providers/{provider_id}/credentials/{credential_id}", + put(admin_update_provider_credential).delete(admin_delete_provider_credential), + ) + .route( + "/admin/providers/{provider_id}/credentials/{credential_id}/select", + post(admin_select_provider_credential), + ) +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct DeleteProviderQuery { diff --git a/src/routes/client_api.rs b/src/routes/client_api.rs index 4b9ef15..c11a286 100644 --- a/src/routes/client_api.rs +++ b/src/routes/client_api.rs @@ -4,11 +4,12 @@ use std::{ }; use axum::{ - Json, + Json, Router, body::Body, extract::{State, connect_info::ConnectInfo}, http::HeaderMap, response::{IntoResponse, Response}, + routing::{get, post}, }; use futures_util::StreamExt; use rand_core::{OsRng, RngCore}; @@ -44,6 +45,27 @@ use crate::{ use super::*; +#[cfg(test)] +use super::route_contract::RouteContract; + +#[cfg(test)] +pub(super) const ROUTES: &[RouteContract] = &[ + RouteContract::new("client-api", "/v1/models", &["GET"]), + RouteContract::new("client-api", "/v1/messages", &["POST"]), + RouteContract::new("client-api", "/v1/messages/count_tokens", &["POST"]), + RouteContract::new("client-api", "/v1/chat/completions", &["POST"]), + RouteContract::new("client-api", "/v1/effective-policy", &["GET"]), +]; + +pub(super) fn router() -> Router { + Router::new() + .route("/v1/models", get(models)) + .route("/v1/messages", post(messages)) + .route("/v1/messages/count_tokens", post(count_tokens)) + .route("/v1/chat/completions", post(chat_completions)) + .route("/v1/effective-policy", get(effective_policy)) +} + pub(super) async fn models( State(state): State, ConnectInfo(peer_addr): ConnectInfo, diff --git a/src/routes/governance.rs b/src/routes/governance.rs index 177856b..e8ca9d5 100644 --- a/src/routes/governance.rs +++ b/src/routes/governance.rs @@ -1,7 +1,8 @@ use axum::{ - Json, + Json, Router, extract::{Path, State}, http::HeaderMap, + routing::{get, post}, }; use serde_json::{Value, json}; @@ -9,6 +10,55 @@ use crate::{domain::TenantScope, governance::ChangeRequestInput}; use super::*; +#[cfg(test)] +use super::route_contract::RouteContract; + +#[cfg(test)] +pub(super) const ROUTES: &[RouteContract] = &[ + RouteContract::new( + "admin-governance", + "/admin/self-service/governance", + &["GET"], + ), + RouteContract::new("admin-governance", "/admin/governance", &["GET"]), + RouteContract::new( + "admin-governance", + "/admin/governance/change-requests", + &["GET", "POST"], + ), + RouteContract::new( + "admin-governance", + "/admin/governance/change-requests/{change_id}/approve", + &["POST"], + ), + RouteContract::new( + "admin-governance", + "/admin/governance/change-requests/{change_id}/apply", + &["POST"], + ), +]; + +pub(super) fn router() -> Router { + Router::new() + .route( + "/admin/self-service/governance", + get(self_service_governance), + ) + .route("/admin/governance", get(admin_governance_overview)) + .route( + "/admin/governance/change-requests", + get(admin_change_requests).post(admin_create_change_request), + ) + .route( + "/admin/governance/change-requests/{change_id}/approve", + post(admin_approve_change_request), + ) + .route( + "/admin/governance/change-requests/{change_id}/apply", + post(admin_apply_change_request), + ) +} + pub(super) async fn self_service_governance( State(state): State, headers: HeaderMap, diff --git a/src/routes/ops.rs b/src/routes/ops.rs index f88677f..a3e6d88 100644 --- a/src/routes/ops.rs +++ b/src/routes/ops.rs @@ -1,15 +1,35 @@ use axum::{ - Json, + Json, Router, extract::State, http::{HeaderMap, header::CONTENT_TYPE}, response::IntoResponse, + routing::get, }; use serde_json::json; use super::*; +#[cfg(test)] +use super::route_contract::RouteContract; + const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; +#[cfg(test)] +pub(super) const ROUTES: &[RouteContract] = &[ + RouteContract::new("system", "/livez", &["GET"]), + RouteContract::new("system", "/readyz", &["GET"]), + RouteContract::new("system", "/health", &["GET"]), + RouteContract::new("system", "/metrics", &["GET"]), +]; + +pub(super) fn router() -> Router { + Router::new() + .route("/livez", get(livez)) + .route("/readyz", get(readyz)) + .route("/health", get(health)) + .route("/metrics", get(metrics)) +} + pub(super) async fn livez(State(state): State) -> Json { let started = Instant::now(); state.metrics.record_route("livez", true, started.elapsed()); diff --git a/src/routes/ops_agent.rs b/src/routes/ops_agent.rs index aa109b4..577b3be 100644 --- a/src/routes/ops_agent.rs +++ b/src/routes/ops_agent.rs @@ -1,7 +1,8 @@ use axum::{ - Json, + Json, Router, extract::{Path, Query, State}, http::HeaderMap, + routing::{get, post}, }; use modelport_ops_protocol::{ OpsAgentConfiguration, OpsAgentConfigurationUpdate, OpsAgentConfigurationView, OpsHeartbeat, @@ -14,6 +15,69 @@ use serde_json::{Value, json}; use super::*; use crate::control::OpsAgentConfigRecord; +#[cfg(test)] +use super::route_contract::RouteContract; + +#[cfg(test)] +pub(super) const INTERNAL_ROUTES: &[RouteContract] = &[ + RouteContract::new("internal-ops", "/internal/ops/v1/snapshot", &["GET"]), + RouteContract::new("internal-ops", "/internal/ops/v1/observations", &["POST"]), + RouteContract::new("internal-ops", "/internal/ops/v1/heartbeats", &["POST"]), +]; + +#[cfg(test)] +pub(super) const ADMIN_ROUTES: &[RouteContract] = &[ + RouteContract::new("admin-operations", "/admin/ops/incidents", &["GET"]), + RouteContract::new( + "admin-operations", + "/admin/ops/configuration", + &["GET", "PUT"], + ), + RouteContract::new( + "admin-operations", + "/admin/ops/incidents/{incident_id}", + &["GET"], + ), + RouteContract::new( + "admin-operations", + "/admin/ops/incidents/{incident_id}/status", + &["POST"], + ), + RouteContract::new( + "admin-operations", + "/admin/ops/incidents/{incident_id}/feedback", + &["POST"], + ), +]; + +pub(super) fn internal_router() -> Router { + Router::new() + .route("/internal/ops/v1/snapshot", get(snapshot)) + .route("/internal/ops/v1/observations", post(submit_observation)) + .route("/internal/ops/v1/heartbeats", post(heartbeat)) +} + +pub(super) fn admin_router() -> Router { + Router::new() + .route("/admin/ops/incidents", get(admin_incidents)) + .route( + "/admin/ops/configuration", + get(admin_configuration).put(admin_update_configuration), + ) + .route( + "/admin/ops/incidents/{incident_id}", + get(admin_incident_detail), + ) + .route( + "/admin/ops/incidents/{incident_id}/status", + post(admin_update_incident_status), + ) + .route( + "/admin/ops/incidents/{incident_id}/feedback", + post(admin_record_incident_feedback), + ) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(super) struct IncidentListQuery { diff --git a/src/routes/route_contract.rs b/src/routes/route_contract.rs new file mode 100644 index 0000000..e609841 --- /dev/null +++ b/src/routes/route_contract.rs @@ -0,0 +1,95 @@ +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct RouteContract { + pub(super) domain: &'static str, + pub(super) path: &'static str, + pub(super) methods: &'static [&'static str], +} + +impl RouteContract { + pub(super) const fn new( + domain: &'static str, + path: &'static str, + methods: &'static [&'static str], + ) -> Self { + Self { + domain, + path, + methods, + } + } +} + +pub(super) fn all() -> Vec { + [ + super::ops::ROUTES, + super::client_api::ROUTES, + super::ops_agent::INTERNAL_ROUTES, + super::admin_auth::ROUTES, + super::governance_routes::ROUTES, + super::ops_agent::ADMIN_ROUTES, + super::admin_providers::ROUTES, + super::admin_control::ROUTES, + super::admin_evidence::ROUTES, + super::admin_identity::ROUTES, + ] + .into_iter() + .flatten() + .copied() + .collect() +} + +#[test] +fn inventory_has_complete_unique_method_ownership() { + let contracts = all(); + assert_eq!(contracts.len(), 68, "update the reviewed route inventory"); + + let domain_sources = [ + include_str!("ops.rs"), + include_str!("client_api.rs"), + include_str!("ops_agent.rs"), + include_str!("admin_auth.rs"), + include_str!("governance.rs"), + include_str!("admin_providers.rs"), + include_str!("admin_control.rs"), + include_str!("admin_evidence.rs"), + include_str!("admin_identity.rs"), + ]; + let registration_count = domain_sources + .iter() + .map(|source| source.matches(".route(").count()) + .sum::(); + assert_eq!( + registration_count, + contracts.len(), + "every domain registration must have one route contract", + ); + + let root_source = include_str!("../routes.rs"); + let production_root = root_source + .split("#[cfg(test)]\nmod tests") + .next() + .expect("production route module"); + assert!( + !production_root.contains(".route("), + "the root router may compose domains but may not own routes", + ); + + let mut owners = BTreeMap::new(); + for contract in contracts { + assert!(contract.path.starts_with('/')); + assert!(!contract.domain.is_empty()); + assert!(!contract.methods.is_empty()); + for method in contract.methods { + let previous = owners.insert((method, contract.path), contract.domain); + assert!( + previous.is_none(), + "{method} {} is owned by both {} and {}", + contract.path, + previous.unwrap_or("unknown"), + contract.domain, + ); + } + } +}