Use axum_extra TypedPath for route path parameters
Replaces manual AxumPath<T> extractors with typed path structs derived
via TypedPath, letting the route patterns live with the types rather
than duplicated in both the router and the handler signature.

https://claude.ai/code/session_01MjKXG2ecfR7DzFLHJ8tBbK
change
commit b76b7024676c03d4eeaf31d13eced49ceab91327
author Claude <noreply@anthropic.com>
date
parent 18a99e40
diff --git a/Cargo.lock b/Cargo.lock
index b1c823c..d521a56 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -417,7 +417,9 @@ checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970"
 dependencies = [
  "axum",
  "axum-core",
+ "axum-macros",
  "bytes",
+ "form_urlencoded",
  "futures-core",
  "futures-util",
  "headers",
@@ -425,12 +427,27 @@ dependencies = [
  "http-body",
  "http-body-util",
  "mime",
+ "percent-encoding",
  "pin-project-lite",
+ "rustversion",
+ "serde_core",
+ "serde_html_form",
  "tower-layer",
  "tower-service",
  "tracing",
 ]
 
+[[package]]
+name = "axum-macros"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "backtrace"
 version = "0.3.76"
@@ -3198,6 +3215,19 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "serde_html_form"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
+dependencies = [
+ "form_urlencoded",
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
 [[package]]
 name = "serde_ignored"
 version = "0.1.14"
diff --git a/Cargo.toml b/Cargo.toml
index 815c387..acaca84 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ resolver = "3"
 
 [workspace.dependencies]
 axum = "*"
-axum-extra = { version = "*", features = ["typed-header"] }
+axum-extra = { version = "*", features = ["typed-header", "typed-routing"] }
 tower-http = { version = "*", features = ["trace"] }
 clap = { version = "*", features = ["derive", "env"] }
 base64 = "*"
diff --git a/quire-server/Cargo.toml b/quire-server/Cargo.toml
index b37126b..1711b73 100644
--- a/quire-server/Cargo.toml
+++ b/quire-server/Cargo.toml
@@ -35,7 +35,7 @@ tracing-opentelemetry = { workspace = true }
 
 askama = "*"
 axum = "*"
-axum-extra = { version = "*", features = ["typed-header"] }
+axum-extra = { version = "*", features = ["typed-header", "typed-routing"] }
 tower-http = { workspace = true }
 clap = { workspace = true }
 clap_complete = "*"
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index b639ce7..79695a0 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -9,13 +9,14 @@ use std::path::PathBuf;
 
 use serde::Deserialize;
 
-use axum::extract::{FromRequestParts, Path as AxumPath, State};
+use axum::extract::{FromRequestParts, State};
 use axum::http::StatusCode;
 use axum::middleware::Next;
 use axum::response::{IntoResponse, Response, Result};
 use axum_extra::TypedHeader;
 use axum_extra::headers::Authorization;
 use axum_extra::headers::authorization::Bearer;
+use axum_extra::routing::{RouterExt, TypedPath};
 use quire_core::ci::bootstrap::Bootstrap;
 use quire_core::ci::run::RunMeta;
 
@@ -28,7 +29,7 @@ use crate::Quire;
 pub fn router(quire: Quire) -> axum::Router {
     let run_routes = axum::Router::new()
         .route("/bootstrap", axum::routing::get(get_bootstrap))
-        .route("/secrets/{name}", axum::routing::get(get_secret))
+        .typed_get(get_secret)
         .layer(axum::middleware::from_fn_with_state(
             quire.clone(),
             verify_run_token,
@@ -206,14 +207,15 @@ async fn get_bootstrap(
 /// Returns the plain-text value of a named secret from the global config.
 /// Auth is handled by [`verify_run_token`] middleware.
 /// Returns 404 if the secret is not declared in config.
-#[derive(serde::Deserialize)]
+#[derive(TypedPath, serde::Deserialize)]
+#[typed_path("/secrets/{name}")]
 struct SecretPath {
     name: String,
 }
 
 async fn get_secret(
+    SecretPath { name }: SecretPath,
     State(quire): State<Quire>,
-    AxumPath(SecretPath { name }): AxumPath<SecretPath>,
 ) -> Result<axum::Json<serde_json::Value>, ApiError> {
     let value = tokio::task::spawn_blocking(move || -> std::result::Result<String, ApiError> {
         Ok(quire
diff --git a/quire-server/src/quire/web/handlers/ci.rs b/quire-server/src/quire/web/handlers/ci.rs
index fc815c6..ec35274 100644
--- a/quire-server/src/quire/web/handlers/ci.rs
+++ b/quire-server/src/quire/web/handlers/ci.rs
@@ -1,6 +1,6 @@
 //! Handlers for CI run list and run detail pages.
 
-use axum::extract::{Path as AxumPath, State};
+use axum::extract::State;
 use axum::http::StatusCode;
 use axum::response::{IntoResponse, Response};
 
@@ -12,8 +12,9 @@ use super::super::templates::{
 use super::git::RepoView;
 use super::{render, render_error};
 use crate::Quire;
+use crate::quire::web::paths::{RunDetailPath, RunListPath};
 
-pub async fn run_list(State(quire): State<Quire>, AxumPath(repo): AxumPath<String>) -> Response {
+pub async fn run_list(RunListPath { repo }: RunListPath, State(quire): State<Quire>) -> Response {
     let repo_display = repo.trim_end_matches(".git").to_string();
     let repo_name = db::resolve_repo_name(&repo);
     let git_repo = match quire.repo(&repo_name) {
@@ -72,8 +73,8 @@ pub async fn run_list(State(quire): State<Quire>, AxumPath(repo): AxumPath<Strin
 }
 
 pub async fn run_detail(
+    RunDetailPath { repo, run_id }: RunDetailPath,
     State(quire): State<Quire>,
-    AxumPath((repo, run_id)): AxumPath<(String, String)>,
 ) -> Response {
     let repo_display = repo.trim_end_matches(".git").to_string();
     let repo_name = db::resolve_repo_name(&repo);
diff --git a/quire-server/src/quire/web/handlers/repo.rs b/quire-server/src/quire/web/handlers/repo.rs
index 7a25707..4223024 100644
--- a/quire-server/src/quire/web/handlers/repo.rs
+++ b/quire-server/src/quire/web/handlers/repo.rs
@@ -1,6 +1,6 @@
 //! Handler for the repository home page.
 
-use axum::extract::{Path as AxumPath, State};
+use axum::extract::State;
 use axum::http::StatusCode;
 use axum::response::IntoResponse;
 use axum::response::Response;
@@ -11,11 +11,12 @@ use super::super::templates::{RepoHomeTemplate, RunListRow, nav_sections};
 use super::git::RepoView;
 use super::render;
 use crate::Quire;
+use crate::quire::web::paths::RepoPath;
 
 pub async fn repo_home(
+    RepoPath { repo }: RepoPath,
     State(quire): State<Quire>,
     auth: Auth,
-    AxumPath(repo): AxumPath<String>,
 ) -> Response {
     let repo_display = repo.trim_end_matches(".git").to_string();
     let repo_name = db::resolve_repo_name(&repo);
diff --git a/quire-server/src/quire/web/handlers/tree.rs b/quire-server/src/quire/web/handlers/tree.rs
index 5c7d146..f2401e3 100644
--- a/quire-server/src/quire/web/handlers/tree.rs
+++ b/quire-server/src/quire/web/handlers/tree.rs
@@ -1,6 +1,6 @@
 //! Handler for the repository tree browser and file (blob) view.
 
-use axum::extract::{Path as AxumPath, State};
+use axum::extract::State;
 use axum::http::StatusCode;
 use axum::response::{IntoResponse, Response};
 
@@ -12,19 +12,20 @@ use super::super::templates::{
 use super::git::RepoView;
 use super::render;
 use crate::Quire;
+use crate::quire::web::paths::{TreePath, TreeRootPath};
 
 pub async fn tree_view(
+    TreeRootPath { repo }: TreeRootPath,
     State(quire): State<Quire>,
     auth: Auth,
-    AxumPath(repo): AxumPath<String>,
 ) -> Response {
     tree_or_file_at_path(quire, repo, String::new(), auth.is_authenticated()).await
 }
 
 pub async fn tree_view_path(
+    TreePath { repo, path }: TreePath,
     State(quire): State<Quire>,
     auth: Auth,
-    AxumPath((repo, path)): AxumPath<(String, String)>,
 ) -> Response {
     tree_or_file_at_path(quire, repo, path, auth.is_authenticated()).await
 }
diff --git a/quire-server/src/quire/web/mod.rs b/quire-server/src/quire/web/mod.rs
index 2467138..ed8c57c 100644
--- a/quire-server/src/quire/web/mod.rs
+++ b/quire-server/src/quire/web/mod.rs
@@ -14,6 +14,7 @@ pub mod handlers;
 pub mod templates;
 
 use axum::{Router, routing::get};
+use axum_extra::routing::RouterExt;
 
 use crate::{
     Quire,
@@ -22,13 +23,52 @@ use crate::{
     },
 };
 
+pub use paths::{RepoPath, RunDetailPath, RunListPath, TreePath, TreeRootPath};
+
+pub mod paths {
+    use axum_extra::routing::TypedPath;
+    use serde::Deserialize;
+
+    #[derive(TypedPath, Deserialize)]
+    #[typed_path("/{repo}")]
+    pub struct RepoPath {
+        pub repo: String,
+    }
+
+    #[derive(TypedPath, Deserialize)]
+    #[typed_path("/{repo}/ci")]
+    pub struct RunListPath {
+        pub repo: String,
+    }
+
+    #[derive(TypedPath, Deserialize)]
+    #[typed_path("/{repo}/ci/{run_id}")]
+    pub struct RunDetailPath {
+        pub repo: String,
+        pub run_id: String,
+    }
+
+    #[derive(TypedPath, Deserialize)]
+    #[typed_path("/{repo}/tree")]
+    pub struct TreeRootPath {
+        pub repo: String,
+    }
+
+    #[derive(TypedPath, Deserialize)]
+    #[typed_path("/{repo}/tree/{*path}")]
+    pub struct TreePath {
+        pub repo: String,
+        pub path: String,
+    }
+}
+
 /// Routes that require authentication.
 ///
 /// Currently only the CI views: run list and run detail pages.
 pub fn ci_router(quire: Quire) -> Router {
     Router::new()
-        .route("/{repo}/ci", get(run_list))
-        .route("/{repo}/ci/{run_id}", get(run_detail))
+        .typed_get(run_list)
+        .typed_get(run_detail)
         .with_state(quire)
 }
 
@@ -36,9 +76,9 @@ pub fn ci_router(quire: Quire) -> Router {
 pub fn public_router(quire: Quire) -> Router {
     Router::new()
         .route("/style.css", get(stylesheet))
-        .route("/{repo}", get(repo_home))
-        .route("/{repo}/tree", get(tree_view))
-        .route("/{repo}/tree/{*path}", get(tree_view_path))
+        .typed_get(repo_home)
+        .typed_get(tree_view)
+        .typed_get(tree_view_path)
         .route("/config", get(config))
         .with_state(quire)
 }