Use thiserror + axum-extra TypedHeader in api.rs
ApiError now derives thiserror::Error with typed variants (Db,
App, Secret) and #[from] where the mapping is direct; the manual
From<rusqlite::Error> stays to map QueryReturnedNoRows → NotFound.

Bearer token extraction uses TypedHeader<Authorization<Bearer>>
instead of manual header parsing.
change
commit 32662e9a151f928108adf545cd14ab905cadaf5d
author Claude <noreply@anthropic.com>
date
parent 1660772c
diff --git a/quire-server/Cargo.toml b/quire-server/Cargo.toml
index fd36998..267b5ae 100644
--- a/quire-server/Cargo.toml
+++ b/quire-server/Cargo.toml
@@ -32,6 +32,7 @@ tracing = { workspace = true }
 
 askama = "*"
 axum = "*"
+axum-extra = { version = "*", features = ["typed-header"] }
 clap = { workspace = true }
 clap_complete = "*"
 rand = "*"
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index f064845..d989c09 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -5,8 +5,11 @@
 //! is created and scoped to that run's ID.
 
 use axum::extract::{Path as AxumPath, State};
-use axum::http::{HeaderMap, StatusCode};
+use axum::http::StatusCode;
 use axum::response::{IntoResponse, Response};
+use axum_extra::TypedHeader;
+use axum_extra::headers::Authorization;
+use axum_extra::headers::authorization::Bearer;
 
 use crate::Quire;
 
@@ -21,11 +24,27 @@ pub fn router(quire: Quire) -> axum::Router {
         .with_state(quire)
 }
 
-#[derive(Debug)]
+#[derive(Debug, thiserror::Error)]
 enum ApiError {
+    #[error("not found")]
     NotFound,
+    #[error("unauthorized")]
     Unauthorized,
-    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
+    #[error(transparent)]
+    Db(rusqlite::Error),
+    #[error(transparent)]
+    App(#[from] crate::Error),
+    #[error(transparent)]
+    Secret(#[from] quire_core::secret::Error),
+}
+
+impl From<rusqlite::Error> for ApiError {
+    fn from(e: rusqlite::Error) -> Self {
+        match e {
+            rusqlite::Error::QueryReturnedNoRows => ApiError::NotFound,
+            _ => ApiError::Db(e),
+        }
+    }
 }
 
 impl IntoResponse for ApiError {
@@ -33,7 +52,7 @@ impl IntoResponse for ApiError {
         match self {
             ApiError::NotFound => StatusCode::NOT_FOUND.into_response(),
             ApiError::Unauthorized => StatusCode::UNAUTHORIZED.into_response(),
-            ApiError::Internal(e) => {
+            e => {
                 tracing::error!(error = %e, "api error");
                 StatusCode::INTERNAL_SERVER_ERROR.into_response()
             }
@@ -41,15 +60,6 @@ impl IntoResponse for ApiError {
     }
 }
 
-impl From<rusqlite::Error> for ApiError {
-    fn from(e: rusqlite::Error) -> Self {
-        match e {
-            rusqlite::Error::QueryReturnedNoRows => ApiError::NotFound,
-            _ => ApiError::Internal(Box::new(e)),
-        }
-    }
-}
-
 /// Verify the bearer token against the stored `auth_token` for `run_id`.
 /// Returns `Err(NotFound)` if the run doesn't exist, `Err(Unauthorized)` if
 /// the token doesn't match (including a null token for filesystem-mode runs).
@@ -75,24 +85,19 @@ fn verify_token(db: &rusqlite::Connection, run_id: &str, token: &str) -> Result<
 async fn get_secret(
     State(quire): State<Quire>,
     AxumPath((run_id, name)): AxumPath<(String, String)>,
-    headers: HeaderMap,
+    bearer: Option<TypedHeader<Authorization<Bearer>>>,
 ) -> Response {
-    let Some(token) = extract_bearer_token(&headers) else {
+    let Some(TypedHeader(Authorization(bearer))) = bearer else {
         return ApiError::Unauthorized.into_response();
     };
+    let token = bearer.token().to_string();
 
-    let result = tokio::task::spawn_blocking(move || {
-        let db = crate::db::open(&quire.db_path())
-            .map_err(|e| ApiError::Internal(Box::new(e)))?;
+    let result = tokio::task::spawn_blocking(move || -> Result<String, ApiError> {
+        let db = crate::db::open(&quire.db_path())?;
         verify_token(&db, &run_id, &token)?;
-        let config = quire
-            .global_config()
-            .map_err(|e| ApiError::Internal(Box::new(e)))?;
+        let config = quire.global_config()?;
         match config.secrets.get(&name) {
-            Some(s) => s
-                .reveal()
-                .map(|v| v.to_string())
-                .map_err(|e| ApiError::Internal(Box::new(e))),
+            Some(s) => Ok(s.reveal()?.to_string()),
             None => Err(ApiError::NotFound),
         }
     })
@@ -105,20 +110,6 @@ async fn get_secret(
     }
 }
 
-/// Extract `Bearer <token>` from an `Authorization` header.
-fn extract_bearer_token(headers: &HeaderMap) -> Option<String> {
-    let value = headers
-        .get(axum::http::header::AUTHORIZATION)?
-        .to_str()
-        .ok()?;
-    let token = value.strip_prefix("Bearer ")?;
-    if token.is_empty() {
-        None
-    } else {
-        Some(token.to_string())
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use axum::body::Body;