• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

divviup / divviup-api / 24482858125

15 Apr 2026 11:03PM UTC coverage: 58.092% (+0.6%) from 57.469%
24482858125

push

github

web-flow
Migrate from Trillium [part 3]: Axum middleware and error handling (#2199)

Build the Axum-side middleware stack so that migrated routes (part 5+)
will behave identically to their Trillium counterparts. No routes are
migrated in this change -- all existing tests pass unchanged.

- Add `IntoResponse` impl for the `Error` enum, mirroring the Trillium
  `ErrorHandler::before_send` status-code mapping (403, 404, 406, 400,
  500).
- Add `TowerSessionStore` implementing `tower_sessions_core::SessionStore`
  against the same `session` database table (wired in part 6 when auth
  routes migrate).
- Add `axum_cors_layer()` returning a `tower_http::cors::CorsLayer`
  matching the existing Trillium CORS behavior.
- Add `ReplaceMimeTypesLayer` / `ReplaceMimeTypesService` Tower
  middleware replicating the custom MIME type negotiation (wired onto the
  API sub-router in part 7).
- Wire compression, cache-control, and CORS layers onto the Axum router
  via `ServiceBuilder`.
- Add unit tests for error response mapping and MIME type middleware.

187 of 239 new or added lines in 5 files covered. (78.24%)

1 existing line in 1 file now uncovered.

4336 of 7464 relevant lines covered (58.09%)

60.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.0
/src/handler/error.rs
1
use crate::clients::ClientError;
2
use axum::http::StatusCode;
3
use axum::response::{IntoResponse, Response};
4
use axum::Json as AxumJson;
5
use sea_orm::DbErr;
6
use serde_json::json;
7
use std::{backtrace::Backtrace, sync::Arc};
8
use trillium::{async_trait, Conn, Handler, Status};
9
use trillium_api::{ApiConnExt, Error as ApiError};
10
use validator::ValidationErrors;
11

12
pub struct ErrorHandler;
13
#[async_trait]
14
impl Handler for ErrorHandler {
15
    async fn run(&self, conn: Conn) -> Conn {
352✔
16
        conn
176✔
17
    }
352✔
18

19
    async fn before_send(&self, mut conn: Conn) -> Conn {
558✔
20
        if let Some(error) = conn.take_state::<ApiError>() {
279✔
21
            conn.insert_state(Error::from(error));
×
22
        };
279✔
23

24
        let Some(error) = conn.state().cloned() else {
279✔
25
            return conn;
259✔
26
        };
27

28
        match error {
×
29
            Error::AccessDenied => conn.with_status(Status::Forbidden).with_body(""),
2✔
30

31
            Error::NotFound => conn.with_status(Status::NotFound).with_body(""),
6✔
32

33
            Error::Json(e @ ApiError::UnsupportedMimeType { .. }) => conn
×
34
                .with_status(Status::NotAcceptable)
×
35
                .with_body(e.to_string()),
×
36

37
            Error::Json(ApiError::ParseError { path, message }) => conn
×
38
                .with_status(Status::BadRequest)
×
39
                .with_json(&json!({"path": path, "message": message})),
×
40

41
            Error::Validation(e) => conn.with_status(Status::BadRequest).with_json(&e),
12✔
42

43
            e => {
×
44
                let string = e.to_string();
×
45
                log::error!("{e}");
×
46
                let mut conn = conn.with_status(Status::InternalServerError);
×
47
                if cfg!(debug_assertions) {
×
48
                    conn.with_body(string)
×
49
                } else {
50
                    conn.inner_mut().take_response_body();
×
51
                    conn
×
52
                }
53
            }
54
        }
55
    }
558✔
56
}
57

58
#[derive(thiserror::Error, Debug, Clone)]
59
#[non_exhaustive]
60
pub enum Error {
61
    #[error("Access denied")]
62
    AccessDenied,
63
    #[error(transparent)]
64
    Database(#[from] Arc<DbErr>),
65
    #[error("Not found")]
66
    NotFound,
67
    #[error(transparent)]
68
    Json(#[from] ApiError),
69
    #[error(transparent)]
70
    Validation(#[from] ValidationErrors),
71
    #[error(transparent)]
72
    Client(#[from] Arc<ClientError>),
73
    #[error(transparent)]
74
    Other(#[from] Arc<dyn std::error::Error + Send + Sync>),
75
    #[error(transparent)]
76
    UrlParse(#[from] url::ParseError),
77
    #[error(transparent)]
78
    NumericConversion(#[from] std::num::TryFromIntError),
79
    #[error(transparent)]
80
    TimeComponentRange(#[from] time::error::ComponentRange),
81
    #[error(transparent)]
82
    TaskProvisioning(#[from] crate::entity::task::TaskProvisioningError),
83
    #[error(transparent)]
84
    Uuid(#[from] uuid::Error),
85
    #[error("encryption error")]
86
    Encryption,
87
    #[error(transparent)]
88
    Utf8Error(#[from] std::string::FromUtf8Error),
89
    #[error("{0}")]
90
    String(&'static str),
91
    #[error(transparent)]
92
    Codec(Arc<janus_messages::codec::CodecError>),
93
}
94

95
impl From<janus_messages::codec::CodecError> for Error {
96
    fn from(error: janus_messages::codec::CodecError) -> Self {
×
97
        Self::Codec(Arc::new(error))
×
98
    }
×
99
}
100

101
impl From<aes_gcm::Error> for Error {
102
    fn from(_: aes_gcm::Error) -> Self {
×
103
        Self::Encryption
×
104
    }
×
105
}
106

107
impl From<Box<dyn std::error::Error + Send + Sync>> for Error {
108
    fn from(value: Box<dyn std::error::Error + Send + Sync>) -> Self {
×
109
        Self::Other(Arc::from(value))
×
110
    }
×
111
}
112

113
impl From<serde_json::Error> for Error {
114
    fn from(value: serde_json::Error) -> Self {
×
115
        ApiError::from(value).into()
×
116
    }
×
117
}
118

119
impl From<DbErr> for Error {
120
    fn from(value: DbErr) -> Self {
×
121
        Self::Database(Arc::new(value))
×
122
    }
×
123
}
124

125
impl From<ClientError> for Error {
126
    fn from(value: ClientError) -> Self {
2✔
127
        Self::Client(Arc::new(value))
2✔
128
    }
2✔
129
}
130

131
#[async_trait]
132
impl Handler for Error {
133
    async fn run(&self, conn: Conn) -> Conn {
40✔
134
        conn.with_state(self.clone())
20✔
135
            .with_state(Backtrace::capture())
20✔
136
    }
40✔
137
}
138

139
/// Axum-side error-to-response conversion, mirroring the Trillium
140
/// [`ErrorHandler::before_send`] logic above.
141
impl IntoResponse for Error {
142
    fn into_response(self) -> Response {
6✔
143
        match self {
2✔
144
            Error::AccessDenied => StatusCode::FORBIDDEN.into_response(),
1✔
145

146
            Error::NotFound => StatusCode::NOT_FOUND.into_response(),
1✔
147

148
            Error::Json(ApiError::UnsupportedMimeType { .. }) => {
149
                StatusCode::NOT_ACCEPTABLE.into_response()
1✔
150
            }
151

152
            Error::Json(ApiError::ParseError { path, message }) => (
1✔
153
                StatusCode::BAD_REQUEST,
1✔
154
                AxumJson(json!({"path": path, "message": message})),
1✔
155
            )
1✔
156
                .into_response(),
1✔
157

158
            Error::Validation(e) => (StatusCode::BAD_REQUEST, AxumJson(e)).into_response(),
1✔
159

160
            e => {
1✔
161
                log::error!("{e}");
1✔
162
                if cfg!(debug_assertions) {
1✔
163
                    (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
1✔
164
                } else {
NEW
165
                    StatusCode::INTERNAL_SERVER_ERROR.into_response()
×
166
                }
167
            }
168
        }
169
    }
6✔
170
}
171

172
#[cfg(test)]
173
mod tests {
174
    use super::*;
175
    use axum::response::IntoResponse;
176

177
    #[test]
178
    fn access_denied_is_403() {
1✔
179
        let resp = Error::AccessDenied.into_response();
1✔
180
        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1✔
181
    }
1✔
182

183
    #[test]
184
    fn not_found_is_404() {
1✔
185
        let resp = Error::NotFound.into_response();
1✔
186
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1✔
187
    }
1✔
188

189
    #[test]
190
    fn unsupported_mime_type_is_406() {
1✔
191
        let err = Error::Json(ApiError::UnsupportedMimeType {
1✔
192
            mime_type: "text/plain".into(),
1✔
193
        });
1✔
194
        let resp = err.into_response();
1✔
195
        assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
1✔
196
    }
1✔
197

198
    #[test]
199
    fn parse_error_is_400() {
1✔
200
        let err = Error::Json(ApiError::ParseError {
1✔
201
            path: ".field".into(),
1✔
202
            message: "expected string".into(),
1✔
203
        });
1✔
204
        let resp = err.into_response();
1✔
205
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1✔
206
    }
1✔
207

208
    #[test]
209
    fn validation_error_is_400() {
1✔
210
        let err = Error::Validation(ValidationErrors::new());
1✔
211
        let resp = err.into_response();
1✔
212
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1✔
213
    }
1✔
214

215
    #[test]
216
    fn other_error_is_500() {
1✔
217
        let err = Error::String("something broke");
1✔
218
        let resp = err.into_response();
1✔
219
        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1✔
220
    }
1✔
221
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc