diff --git a/CHANGELOG.md b/CHANGELOG.md index 655c03d3..87239e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ finalized in place with a date — no renaming/migration step needed. - **TUI stack majors: ratatui 0.30, crossterm 0.29.** Zero call-site changes — the serve TUI sits on stable surface (`CrosstermBackend`, `Terminal`, `TableState`, `Paragraph`, `Layout`). This removes the last lru 0.12.5 path (ratatui's chain now carries lru 0.18.4; tantivy stays on the 0.16.4 that upstream 0.26 pins). +- **axum 0.7 → 0.8.** The only breaking surface hit: path parameters changed syntax from `:param` to `{param}` — all route registrations (`/repos/:alias*`, `/chunk/:id`) and the shared `CHUNK_PATH` constant move to brace syntax, including the federation client's URL templating that derives from the same constant. Extractors, middleware and `axum::serve` compile unchanged; the serve + federation test suites exercise the rebuilt router end-to-end. + - **thiserror 1.0 → 2.0 (direct).** Drop-in for all error enums (`#[error]`, `#[from]` unchanged); tantivy's chain still carries thiserror 1.x transitively until upstream moves. ## [1.3.19] diff --git a/Cargo.lock b/Cargo.lock index 4d4440e1..7c31081d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,13 +298,13 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.9" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "async-trait", "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -317,8 +317,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -332,19 +331,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -2986,9 +2983,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "matrixmultiply" diff --git a/Cargo.toml b/Cargo.toml index be5287c9..d339faf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,7 +71,7 @@ tantivy = "0.26" regex = "1.12" # Server -axum = "0.7" +axum = "0.8" tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace"] } diff --git a/src/constants.rs b/src/constants.rs index 73f27b8f..f0bd457e 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -265,8 +265,8 @@ pub fn resolve_serve_host() -> String { } /// Environment variable to set the admin API key for management endpoints. -/// When set, all management routes (`POST /repos`, `DELETE /repos/:alias`, -/// `POST /repos/:alias/reindex`, `POST /reload`) require this key. +/// When set, all management routes (`POST /repos`, `DELETE /repos/{alias}`, +/// `POST /repos/{alias}/reindex`, `POST /reload`) require this key. /// When unset or empty, management routes are unauthenticated (backward compatible). /// The key is validated against `Authorization: Bearer ` or `X-API-Key: ` headers. pub const SERVE_API_KEY_ENV: &str = "CODESEARCH_SERVE_API_KEY"; @@ -387,8 +387,8 @@ pub const FIND_PATH: &str = "/find"; pub const EXPLORE_PATH: &str = "/explore"; /// REST get-chunk endpoint (HTTP mirror of the `get_chunk` MCP tool). -/// GET `/chunk/:id?context_lines=&project=&group=`. -pub const CHUNK_PATH: &str = "/chunk/:id"; +/// GET `/chunk/{id}?context_lines=&project=&group=`. +pub const CHUNK_PATH: &str = "/chunk/{id}"; /// REST find-impact endpoint (HTTP mirror of the `find_impact` MCP tool). /// POST a `FindImpactRequest` body; returns the tool's JSON payload @@ -570,7 +570,7 @@ pub const DB_DELETE_RETRY_BACKOFF_CAP_MS: u64 = 2000; /// an actually-unlocked directory instead of burning attempts blind. pub const DB_DELETE_ENV_RELEASE_POLL_MS: u64 = 100; -/// Unallocated margin (seconds) the CLI's delegated `DELETE /repos/:alias` +/// Unallocated margin (seconds) the CLI's delegated `DELETE /repos/{alias}` /// request adds on top of serve's legitimate worst-case removal time — /// `DB_DELETE_RETRY_BUDGET_SECS` plus one `BG_TASK_COOPERATIVE_TIMEOUT_SECS` /// per cooperative join (FSW task + index task) — so the CLI receives diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 897b4152..394cdcbb 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -184,7 +184,7 @@ pub struct RemoteRepoStatus { pub tool_call_count: Option, } -/// `GET /repos/:alias/info` payload — on-disk index stats for one repo on the +/// `GET /repos/{alias}/info` payload — on-disk index stats for one repo on the /// peer. Only the fields the TUI mount-info overlay renders are typed; every /// field is optional/defaulted so an older/newer remote still parses. #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -216,7 +216,7 @@ pub struct RemoteRepoAdded { pub message: Option, } -/// `DELETE /repos/:alias` success payload (HTTP 200). +/// `DELETE /repos/{alias}` success payload (HTTP 200). #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct RemoteRepoRemoved { #[serde(default)] @@ -229,7 +229,7 @@ pub struct RemoteRepoRemoved { pub message: Option, } -/// `POST /repos/:alias/reindex` success payload (HTTP 202). +/// `POST /repos/{alias}/reindex` success payload (HTTP 202). #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct RemoteReindexResult { #[serde(default)] @@ -382,7 +382,7 @@ impl FederationClient { unreachable!("retry loop always returns on its final iteration") } - /// Fetch a single chunk from a remote peer's `/chunk/:id` endpoint. + /// Fetch a single chunk from a remote peer's `/chunk/{id}` endpoint. /// /// Scoping mirrors [`Self::search_project`]: /// - When `remote_alias` is `Some`, the lookup is scoped to that single @@ -402,7 +402,7 @@ impl FederationClient { ) -> Outcome { let mut url = Self::peer_url( peer, - &crate::constants::CHUNK_PATH.replace(":id", &chunk_id.to_string()), + &crate::constants::CHUNK_PATH.replace("{id", &chunk_id.to_string()), ); // Scope the lookup: prefer a single-project scope (`project=`) // so the multi-repo peer can disambiguate the chunk_id; fall back to @@ -476,7 +476,7 @@ impl FederationClient { } /// Shared request/response handling for the management endpoints - /// (`/status`, `/repos`, `/repos/:alias`, `/repos/:alias/reindex`). + /// (`/status`, `/repos`, `/repos/{alias}`, `/repos/{alias}/reindex`). /// /// Distinguishes three failure modes (see [`ManagementOutcome`]): /// transport failure → `Unreachable`; non-2xx → `HttpError` with the peer's @@ -577,7 +577,7 @@ impl FederationClient { .await } - /// `DELETE /repos/:alias` — unregister a repo on the peer and delete its DB. + /// `DELETE /repos/{alias}` — unregister a repo on the peer and delete its DB. /// `alias` is the peer's repo alias (NOT a local path). pub async fn remove_repo( &self, @@ -591,7 +591,7 @@ impl FederationClient { .await } - /// `GET /repos/:alias/info` — fetch on-disk index stats (chunks/files/db + /// `GET /repos/{alias}/info` — fetch on-disk index stats (chunks/files/db /// size/model) for one repo on the peer. `alias` is the peer's repo alias. pub async fn repo_info( &self, @@ -608,7 +608,7 @@ impl FederationClient { .await } - /// `POST /repos/:alias/reindex[?force=true]` — trigger a background + /// `POST /repos/{alias}/reindex[?force=true]` — trigger a background /// incremental (or forced full) reindex of a repo on the peer. pub async fn reindex( &self, @@ -866,7 +866,7 @@ mod tests { // `group`) for a namespaced lookup — the fix for `ambiguous_chunk_id` // on a multi-repo peer. let app = axum::Router::new().route( - "/chunk/:id", + "/chunk/{id}", axum::routing::get( |axum::extract::Query(params): axum::extract::Query< std::collections::HashMap, @@ -918,7 +918,7 @@ mod tests { // lookup must then fall back to the peer's group scope and NOT send a // `project` param — preserving pre-fix behaviour for old refs. let app = axum::Router::new().route( - "/chunk/:id", + "/chunk/{id}", axum::routing::get( |axum::extract::Query(params): axum::extract::Query< std::collections::HashMap, @@ -1041,7 +1041,7 @@ mod tests { async fn remove_repo_targets_alias_in_url() { // Echo the captured alias back to prove it landed in the DELETE path. let app = axum::Router::new().route( - "/repos/:alias", + "/repos/{alias}", axum::routing::delete( |axum::extract::Path(alias): axum::extract::Path| async move { axum::Json(serde_json::json!({ @@ -1072,7 +1072,7 @@ mod tests { async fn reindex_posts_to_alias_reindex_path() { // Capture the alias from the path to prove the reindex URL was built. let app = axum::Router::new().route( - "/repos/:alias/reindex", + "/repos/{alias}/reindex", axum::routing::post( |axum::extract::Path(alias): axum::extract::Path| async move { axum::Json(serde_json::json!({ @@ -1106,7 +1106,7 @@ mod tests { async fn reindex_with_force_appends_force_query() { // Capture the query string to prove ?force=true was forwarded. let app = axum::Router::new().route( - "/repos/:alias/reindex", + "/repos/{alias}/reindex", axum::routing::post( |axum::extract::Query(params): axum::extract::Query< std::collections::HashMap, @@ -1140,7 +1140,7 @@ mod tests { // An alias with a space must be percent-encoded on the wire and decoded // back by axum — proves the encoding round-trips through the HTTP layer. let app = axum::Router::new().route( - "/repos/:alias", + "/repos/{alias}", axum::routing::delete( |axum::extract::Path(alias): axum::extract::Path| async move { axum::Json(serde_json::json!({ @@ -1219,7 +1219,7 @@ mod tests { // Transient-status retry (502/503/504 — scale-to-zero cold starts, todo #58) // ========================================================================= - /// Helper: an axum route answering `/chunk/:id` that returns 503 (with a + /// Helper: an axum route answering `/chunk/{id}` that returns 503 (with a /// non-JSON body, like a real cold-starting gateway) for the first /// `fail_first` calls, then a valid 200 JSON chunk payload. Counts every /// hit so tests can assert exactly how many attempts were made. @@ -1230,7 +1230,7 @@ mod tests { let hits = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); let hits_clone = hits.clone(); let router = axum::Router::new().route( - "/chunk/:id", + "/chunk/{id}", axum::routing::get(move || { let hits = hits_clone.clone(); async move { @@ -1340,7 +1340,7 @@ mod tests { let hits = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); let hits_clone = hits.clone(); let router = axum::Router::new().route( - "/chunk/:id", + "/chunk/{id}", axum::routing::get(move || { let hits = hits_clone.clone(); async move { diff --git a/src/index/mod.rs b/src/index/mod.rs index 1855f832..148e47f2 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -1962,8 +1962,8 @@ const SERVE_HEALTH_RETRY_SLEEP: std::time::Duration = std::time::Duration::from_ /// makes. This is required: /// - when serve is bound to a non-localhost address (the `require_auth_for_network` /// middleware guards ALL endpoints, including `/health`), and -/// - for management endpoints (`POST /repos`, `DELETE /repos/:alias`, -/// `POST /repos/:alias/reindex`, `POST /reload`) when serve is bound to +/// - for management endpoints (`POST /repos`, `DELETE /repos/{alias}`, +/// `POST /repos/{alias}/reindex`, `POST /reload`) when serve is bound to /// localhost with the key set. /// /// Without this, delegation to a network-bound serve returns 401 and falls back @@ -2386,7 +2386,7 @@ pub(crate) async fn try_delegate_add_to_serve( } /// The outcome a running serve instance reported for a delegated `index rm` -/// — the parsed `DELETE /repos/:alias` success payload. +/// — the parsed `DELETE /repos/{alias}` success payload. /// /// `db_deleted == false` means the repo is functionally removed (FSW stopped, /// evicted from memory, unregistered from repos.json) but the database @@ -2472,7 +2472,7 @@ pub(crate) async fn try_delegate_rm_to_serve( .map(|(a, _)| a.clone()) .ok_or_else(|| format!("path '{}' not found in repos.json", project_path.display()))?; - // 3. DELETE /repos/:alias + // 3. DELETE /repos/{alias} // // The DELETE needs its OWN client with a timeout that covers serve's // legitimate worst-case removal time: `remove_repo` can spend up to @@ -3154,7 +3154,7 @@ mod remove_order_tests { }), ) .route( - "/repos/:alias", + "/repos/{alias}", axum::routing::delete( |axum::extract::Path(alias): axum::extract::Path| async move { axum::Json(serde_json::json!({ @@ -3232,7 +3232,7 @@ mod remove_order_tests { }), ) .route( - "/repos/:alias", + "/repos/{alias}", axum::routing::delete( |axum::extract::Path(alias): axum::extract::Path| async move { tokio::time::sleep(std::time::Duration::from_secs(4)).await; diff --git a/src/serve/mod.rs b/src/serve/mod.rs index 1bba9d93..ea27db53 100644 --- a/src/serve/mod.rs +++ b/src/serve/mod.rs @@ -3,8 +3,8 @@ //! Binds on `{host}:{port}` (default `127.0.0.1:39725`) and serves: //! - `GET /health` → JSON health check //! - `POST /repos` → register + index + warmup a new repo -//! - `DELETE /repos/:alias` → stop FSW + evict + unregister + delete DB -//! - `POST /repos/:alias/reindex` → trigger incremental or force reindex +//! - `DELETE /repos/{alias}` → stop FSW + evict + unregister + delete DB +//! - `POST /repos/{alias}/reindex` → trigger incremental or force reindex //! - MCP streamable HTTP at `/mcp` via rmcp tower service //! //! Holds a `DashMap>` keyed by repo alias. @@ -1372,7 +1372,7 @@ impl ServeState { /// Remove a repo: stop FSW, evict from memory, unregister from config, delete DB. /// - /// This is the shared logic used by both the HTTP `DELETE /repos/:alias` handler + /// This is the shared logic used by both the HTTP `DELETE /repos/{alias}` handler /// and the TUI confirmation flow. pub(crate) async fn remove_repo(&self, alias: &str) -> Result { // 1. Resolve project path from config @@ -4319,7 +4319,7 @@ pub(crate) struct RepoRemovalOutcome { pub db_delete_error: Option, } -/// Remove-repo handler: DELETE /repos/:alias +/// Remove-repo handler: DELETE /repos/{alias} /// /// Stops the FSW, evicts the repo from memory, unregisters from repos.json, /// and deletes the database directory. Returns 200 on success (status is @@ -4500,8 +4500,8 @@ fn request_has_valid_api_key(headers: &axum::http::HeaderMap, configured: &str) /// /// When the env var is unset or empty, all requests pass through (backward compatible). /// -/// Management endpoints are: `POST /repos`, `DELETE /repos/:alias`, -/// `POST /repos/:alias/reindex`, `POST /reload`. +/// Management endpoints are: `POST /repos`, `DELETE /repos/{alias}`, +/// `POST /repos/{alias}/reindex`, `POST /reload`. /// All other routes (health, status, MCP) are always unauthenticated. /// /// Key comparison is constant-time (see `api_key_matches`). @@ -5136,24 +5136,24 @@ pub async fn run_serve( // /remotes is a status-like read-only observability endpoint (lists the // configured federation peers). It is NOT in require_admin_auth's // `is_management` set, so it inherits exactly the same auth policy as - // /status, /repos/:alias/info and /repos/:alias/doctor: reachable + // /status, /repos/{alias}/info and /repos/{alias}/doctor: reachable // without the admin key on localhost, protected by // require_auth_for_network on network binds. See REMOTES_PATH doc. .route(REMOTES_PATH, axum::routing::get(remotes_handler)) .route("/repos", axum::routing::post(add_repo_handler)) - .route("/repos/:alias", axum::routing::delete(remove_repo_handler)) + .route("/repos/{alias}", axum::routing::delete(remove_repo_handler)) .route("/reload", axum::routing::post(reload_handler)) .route( - "/repos/:alias/reindex", + "/repos/{alias}/reindex", axum::routing::post(reindex_handler), ) - .route("/repos/:alias/info", axum::routing::get(info_handler)) + .route("/repos/{alias}/info", axum::routing::get(info_handler)) // /doctor is a POST but is intentionally read-only (diagnostics only, no // --fix path), so like /info and /status it is NOT in require_admin_auth's // management set — reachable without the admin key on localhost, and still // protected by require_auth_for_network on network binds. If doctor ever // gains a mutating mode, add it to `is_management` in require_admin_auth. - .route("/repos/:alias/doctor", axum::routing::post(doctor_handler)) + .route("/repos/{alias}/doctor", axum::routing::post(doctor_handler)) // REST endpoints — federation-friendly HTTP+JSON mirror of the read-only // MCP tools (search/find/explore/get_chunk). Lets a remote codesearch // serve be queried WITHOUT an MCP session. Same auth layers as /mcp & diff --git a/src/serve/tests.rs b/src/serve/tests.rs index 6a7c20b1..8518a033 100644 --- a/src/serve/tests.rs +++ b/src/serve/tests.rs @@ -990,7 +990,7 @@ fn config_reload_no_spurious_reload() { assert_eq!(after_second, after_first); } -/// Verify that the /repos/:alias/reindex route is registered and reachable. +/// Verify that the /repos/{alias}/reindex route is registered and reachable. /// This test starts a real axum server on a random port and sends a POST request. /// /// `#[serial]` + env reset — same allowed-roots race guard as the add_repo @@ -1019,7 +1019,7 @@ async fn reindex_route_is_registered() { axum::routing::get(health_handler), ) .route( - "/repos/:alias/reindex", + "/repos/{alias}/reindex", axum::routing::post(reindex_handler), ) .with_state(state); @@ -1106,7 +1106,7 @@ async fn reindex_refused_for_read_only_repo_even_with_force() { axum::routing::get(health_handler), ) .route( - "/repos/:alias/reindex", + "/repos/{alias}/reindex", axum::routing::post(reindex_handler), ) .with_state(state); @@ -1196,7 +1196,7 @@ async fn healthz_is_unauthenticated_on_network_bind() { ); } -/// Verify that the /repos/:alias/info and /repos/:alias/doctor routes are +/// Verify that the /repos/{alias}/info and /repos/{alias}/doctor routes are /// registered and reachable. Starts a real axum server on a random port and /// asserts that an unknown alias yields our handler's 404 (not axum's 404). #[tokio::test] @@ -1220,8 +1220,8 @@ async fn info_doctor_routes_registered() { crate::constants::HEALTH_PATH, axum::routing::get(health_handler), ) - .route("/repos/:alias/info", axum::routing::get(info_handler)) - .route("/repos/:alias/doctor", axum::routing::post(doctor_handler)) + .route("/repos/{alias}/info", axum::routing::get(info_handler)) + .route("/repos/{alias}/doctor", axum::routing::post(doctor_handler)) .with_state(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -1309,7 +1309,7 @@ async fn info_doctor_routes_registered() { } /// Verify that the federation REST endpoints (/search, /find, /explore, -/// /chunk/:id) are registered and reachable. Each must dispatch to OUR +/// /chunk/{id}) are registered and reachable. Each must dispatch to OUR /// handler (returning a JSON body) rather than axum's built-in empty 404. /// Starts a real axum server on a random port. #[tokio::test] @@ -1512,7 +1512,7 @@ async fn concurrent_reindex_returns_conflict() { let app = axum::Router::new() .route( - "/repos/:alias/reindex", + "/repos/{alias}/reindex", axum::routing::post(reindex_handler), ) .with_state(state); @@ -2137,7 +2137,7 @@ async fn indexing_route_answers_json() { /// Shared envelope for the Layer-2 e2e tests below: pin the delegation env /// vars to a fresh temp repos.json, seed it via `seed`, spawn a REAL serve /// (the two routes the CLI `index rm` delegation touches: `GET /health` and -/// the real `remove_repo_handler` at `DELETE /repos/:alias`) sharing that +/// the real `remove_repo_handler` at `DELETE /repos/{alias}`) sharing that /// config, and wait (bounded, panicking on timeout) for it to accept. /// /// Every step here is trap-sensitive, which is why it is a helper and not @@ -2188,7 +2188,7 @@ where crate::constants::HEALTH_PATH, axum::routing::get(health_handler), ) - .route("/repos/:alias", axum::routing::delete(remove_repo_handler)) + .route("/repos/{alias}", axum::routing::delete(remove_repo_handler)) .with_state(state.clone()); tokio::spawn(async move { let _ = axum::serve(listener, app).await; @@ -2212,7 +2212,7 @@ where /// The full Layer-2 acceptance path: a running serve instance holds the /// repo's registration; `remove_from_index` (the CLI code path) must -/// DELEGATE to it (health probe → DELETE /repos/:alias), serve must stop +/// DELEGATE to it (health probe → DELETE /repos/{alias}), serve must stop /// holders and delete the DB directory WITHOUT being stopped, repos.json /// must lose the entry, and a later query for the alias must be a clean /// "Unknown alias" (no zombie stores) — all without stopping serve.