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

divviup / divviup-api / 25874010908

14 May 2026 05:09PM UTC coverage: 58.423% (+1.2%) from 57.254%
25874010908

push

github

web-flow
Migrate from Trillium [part 8]: remove Trillium server, serve Axum directly (#2251)

Axum is now the primary HTTP listener. The Trillium server, which was a pure pass-through proxy since Part 7C, is removed from the request path.

Production changes:
- `build_app(Config) -> BuiltApp` replaces `DivviupApi` for production use
- `bin.rs` rewritten: Axum serves directly, monitoring server is a separate Axum router, graceful shutdown via tokio signals + CancellationToken
- `Queue` uses `CancellationToken` instead of Trillium's `Stopper`/`CloneCounterObserver`
- `telemetry.rs` and `trace.rs` handlers converted from Trillium to Axum
- `Config` gains `listen_address` field (from HOST/PORT env vars, default [::]:8080)
- Replace the Trillium-based static asset handler (`trillium-static-compiled` + `OriginRouter`) with an Axum middleware using `tower-http`'s `ServeDir` and `ServeFile`.

The middleware intercepts requests whose `Host` (or `X-Forwarded-Host`) matches the configured `app_url` and serves the React SPA with appropriate `cache-control` headers (`max-age=1year` for `/assets/*`, `no-cache` for everything else). Unmatched hosts pass through to the API routes.

Dead code removal:
- Deleted: `handler/logger.rs`, `handler/opentelemetry.rs`, `axum_proxy` test
- Removed `FromConn` impls from: `Db`, `User`, `PermissionsActor`, `AccountBearerToken`
- Removed Trillium `Handler` impls from: `Db`, `ErrorHandler`, `CorsHeaders`, `ReplaceMimeTypes`, `SessionStore`
- Unified `PermissionsActor::is_allowed`/`if_allowed` to use `http::Method`
- Removed deps: `trillium-compression`, `trillium-conn-id`, `trillium-forwarding`, `trillium-opentelemetry`, `trillium-prometheus`, `trillium-redirect`, `trillium-sessions`, `trillium-cookies`, `async-session`
- Added deps: `cookie` (key-expansion feature, for session key derivation)

`DivviupApi` is kept as a thin test-only shim that spawns Axum on IPv6 Localhost and proxies via the existing `AxumProxy`, preserving test-support co... (continued)

112 of 227 new or added lines in 14 files covered. (49.34%)

8 existing lines in 4 files now uncovered.

4335 of 7420 relevant lines covered (58.42%)

63.45 hits per line

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

75.28
/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::sync::Arc;
8
use trillium_api::Error as ApiError;
9
use validator::ValidationErrors;
10

11
// TODO: remove in Part 9 (test-support rewrite) — ErrorHandler is only kept
12
// in the DivviupApi test shim's handler tuple.
13
pub struct ErrorHandler;
14
#[trillium::async_trait]
15
impl trillium::Handler for ErrorHandler {
NEW
16
    async fn run(&self, conn: trillium::Conn) -> trillium::Conn {
×
17
        conn
×
18
    }
×
19
}
20

21
#[derive(thiserror::Error, Debug, Clone)]
22
#[non_exhaustive]
23
pub enum Error {
24
    #[error("Access denied")]
25
    AccessDenied,
26
    #[error(transparent)]
27
    Database(#[from] Arc<DbErr>),
28
    #[error("Not found")]
29
    NotFound,
30
    #[error(transparent)]
31
    Json(#[from] ApiError),
32
    #[error(transparent)]
33
    Validation(#[from] ValidationErrors),
34
    #[error(transparent)]
35
    Client(#[from] Arc<ClientError>),
36
    #[error(transparent)]
37
    Other(#[from] Arc<dyn std::error::Error + Send + Sync>),
38
    #[error(transparent)]
39
    UrlParse(#[from] url::ParseError),
40
    #[error(transparent)]
41
    NumericConversion(#[from] std::num::TryFromIntError),
42
    #[error(transparent)]
43
    TimeComponentRange(#[from] time::error::ComponentRange),
44
    #[error(transparent)]
45
    TaskProvisioning(#[from] crate::entity::task::TaskProvisioningError),
46
    #[error(transparent)]
47
    Uuid(#[from] uuid::Error),
48
    #[error("payload too large")]
49
    PayloadTooLarge,
50
    #[error("encryption error")]
51
    Encryption,
52
    #[error(transparent)]
53
    Utf8Error(#[from] std::string::FromUtf8Error),
54
    #[error("{0}")]
55
    String(&'static str),
56
    #[error(transparent)]
57
    Codec(Arc<janus_messages::codec::CodecError>),
58
    #[error("csrf mismatch or missing")]
59
    CallbackCsrfMismatch,
60
    #[error("expected pkce verifier in session")]
61
    CallbackMissingPkce,
62
    #[error("expected code query param")]
63
    CallbackMissingCode,
64
}
65

66
impl From<janus_messages::codec::CodecError> for Error {
67
    fn from(error: janus_messages::codec::CodecError) -> Self {
×
68
        Self::Codec(Arc::new(error))
×
69
    }
×
70
}
71

72
impl From<aes_gcm::Error> for Error {
73
    fn from(_: aes_gcm::Error) -> Self {
×
74
        Self::Encryption
×
75
    }
×
76
}
77

78
impl From<Box<dyn std::error::Error + Send + Sync>> for Error {
79
    fn from(value: Box<dyn std::error::Error + Send + Sync>) -> Self {
×
80
        Self::Other(Arc::from(value))
×
81
    }
×
82
}
83

84
impl From<serde_json::Error> for Error {
85
    fn from(value: serde_json::Error) -> Self {
×
86
        ApiError::from(value).into()
×
87
    }
×
88
}
89

90
impl From<tower_sessions::session::Error> for Error {
91
    fn from(value: tower_sessions::session::Error) -> Self {
×
92
        Self::Other(Arc::new(value))
×
93
    }
×
94
}
95

96
impl From<DbErr> for Error {
97
    fn from(value: DbErr) -> Self {
×
98
        Self::Database(Arc::new(value))
×
99
    }
×
100
}
101

102
impl From<ClientError> for Error {
103
    fn from(value: ClientError) -> Self {
2✔
104
        Self::Client(Arc::new(value))
2✔
105
    }
2✔
106
}
107

108
/// Error-to-response conversion for Axum handlers.
109
impl IntoResponse for Error {
110
    fn into_response(self) -> Response {
100✔
111
        match self {
2✔
112
            Error::AccessDenied => StatusCode::FORBIDDEN.into_response(),
62✔
113

114
            Error::CallbackCsrfMismatch
115
            | Error::CallbackMissingPkce
116
            | Error::CallbackMissingCode => {
117
                // Preserve the Trillium-side behavior of 403 with a plain-text
118
                // explanatory body for OAuth callback validation failures.
119
                (StatusCode::FORBIDDEN, self.to_string()).into_response()
3✔
120
            }
121

122
            Error::NotFound => StatusCode::NOT_FOUND.into_response(),
18✔
123

124
            Error::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE.into_response(),
1✔
125

126
            Error::Json(ApiError::UnsupportedMimeType { .. }) => {
127
                StatusCode::NOT_ACCEPTABLE.into_response()
1✔
128
            }
129

130
            Error::Json(ApiError::ParseError { path, message }) => (
1✔
131
                StatusCode::BAD_REQUEST,
1✔
132
                AxumJson(json!({"path": path, "message": message})),
1✔
133
            )
1✔
134
                .into_response(),
1✔
135

136
            Error::Validation(e) => (StatusCode::BAD_REQUEST, AxumJson(e)).into_response(),
13✔
137

138
            e => {
1✔
139
                log::error!("{e}");
1✔
140
                if cfg!(debug_assertions) {
1✔
141
                    (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
1✔
142
                } else {
143
                    StatusCode::INTERNAL_SERVER_ERROR.into_response()
×
144
                }
145
            }
146
        }
147
    }
100✔
148
}
149

150
#[cfg(test)]
151
mod tests {
152
    use super::*;
153
    use axum::response::IntoResponse;
154

155
    #[test]
156
    fn access_denied_is_403() {
1✔
157
        let resp = Error::AccessDenied.into_response();
1✔
158
        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1✔
159
    }
1✔
160

161
    #[test]
162
    fn not_found_is_404() {
1✔
163
        let resp = Error::NotFound.into_response();
1✔
164
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1✔
165
    }
1✔
166

167
    #[test]
168
    fn unsupported_mime_type_is_406() {
1✔
169
        let err = Error::Json(ApiError::UnsupportedMimeType {
1✔
170
            mime_type: "text/plain".into(),
1✔
171
        });
1✔
172
        let resp = err.into_response();
1✔
173
        assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
1✔
174
    }
1✔
175

176
    #[test]
177
    fn parse_error_is_400() {
1✔
178
        let err = Error::Json(ApiError::ParseError {
1✔
179
            path: ".field".into(),
1✔
180
            message: "expected string".into(),
1✔
181
        });
1✔
182
        let resp = err.into_response();
1✔
183
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1✔
184
    }
1✔
185

186
    #[test]
187
    fn validation_error_is_400() {
1✔
188
        let err = Error::Validation(ValidationErrors::new());
1✔
189
        let resp = err.into_response();
1✔
190
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1✔
191
    }
1✔
192

193
    #[test]
194
    fn payload_too_large_is_413() {
1✔
195
        let resp = Error::PayloadTooLarge.into_response();
1✔
196
        assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
1✔
197
    }
1✔
198

199
    #[test]
200
    fn other_error_is_500() {
1✔
201
        let err = Error::String("something broke");
1✔
202
        let resp = err.into_response();
1✔
203
        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1✔
204
    }
1✔
205

206
    #[test]
207
    fn callback_validation_errors_are_403() {
1✔
208
        for err in [
3✔
209
            Error::CallbackCsrfMismatch,
1✔
210
            Error::CallbackMissingPkce,
1✔
211
            Error::CallbackMissingCode,
1✔
212
        ] {
1✔
213
            let resp = err.into_response();
3✔
214
            assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3✔
215
        }
216
    }
1✔
217
}
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