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
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)
}