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

divviup / divviup-api / 25566159316

08 May 2026 04:10PM UTC coverage: 57.28% (-1.3%) from 58.564%
25566159316

Pull #2247

github

web-flow
Merge 06d9c50cf into 91680da33
Pull Request #2247: Migrate from Trillium [part 7C]: admin routes and Trillium router removal

44 of 65 new or added lines in 5 files covered. (67.69%)

111 existing lines in 12 files now uncovered.

4351 of 7596 relevant lines covered (57.28%)

64.39 hits per line

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

87.05
/src/handler.rs
1
pub(crate) mod account_bearer_token;
2
#[cfg(assets)]
3
pub(crate) mod assets;
4
pub(crate) mod cors;
5
pub(crate) mod custom_mime_types;
6
pub(crate) mod error;
7
pub(crate) mod extract;
8
pub(crate) mod logger;
9
pub(crate) mod oauth2;
10
pub(crate) mod opentelemetry;
11
pub(crate) mod origin_router;
12
pub(crate) mod session_store;
13

14
pub(crate) mod proxy;
15

16
use crate::{clients::Auth0Client, routes, routes::axum_routes, Config, Crypter, Db, FeatureFlags};
17
use trillium_client::Client;
18

19
use axum::extract::{DefaultBodyLimit, FromRef};
20
use axum::http::{header, HeaderValue};
21
use cors::axum_cors_layer;
22
use error::ErrorHandler;
23
use logger::logger;
24
use oauth2::OauthClient;
25
use proxy::AxumProxy;
26
use session_store::axum_session_layer;
27
use std::{borrow::Cow, net::Ipv6Addr, net::SocketAddr, sync::Arc};
28
use tokio::net::TcpListener;
29
use tower::ServiceBuilder;
30
use tower_http::compression::CompressionLayer;
31
use tower_http::set_header::SetResponseHeaderLayer;
32
use tower_http::trace::TraceLayer;
33
use trillium::{Handler, Info};
34
use trillium_caching_headers::caching_headers;
35
use trillium_conn_id::conn_id;
36
use trillium_forwarding::Forwarding;
37
use trillium_macros::Handler;
38

39
pub use error::Error;
40
pub use origin_router::origin_router;
41

42
use self::opentelemetry::opentelemetry;
43

44
#[cfg(all(assets, feature = "otlp-trace"))]
45
use trillium_opentelemetry::global::instrument_handler;
46
#[cfg(all(assets, not(feature = "otlp-trace")))]
47
fn instrument_handler(handler: impl Handler) -> impl Handler {
278✔
48
    handler
278✔
49
}
278✔
50

51
/// Shared state for the Axum side of the application during migration.
52
#[derive(Clone, Debug)]
53
pub struct AxumAppState {
54
    pub(crate) db: Db,
55
    pub(crate) config: Arc<Config>,
56
    pub(crate) auth0_client: Auth0Client,
57
    pub(crate) oauth_client: OauthClient,
58
    pub(crate) crypter: Crypter,
59
    pub(crate) feature_flags: FeatureFlags,
60
    pub(crate) client: Client,
61
}
62

63
impl FromRef<AxumAppState> for Db {
64
    fn from_ref(state: &AxumAppState) -> Self {
777✔
65
        state.db.clone()
777✔
66
    }
777✔
67
}
68

69
impl FromRef<AxumAppState> for Arc<Config> {
70
    fn from_ref(state: &AxumAppState) -> Self {
3✔
71
        state.config.clone()
3✔
72
    }
3✔
73
}
74

75
impl FromRef<AxumAppState> for Auth0Client {
76
    fn from_ref(state: &AxumAppState) -> Self {
×
77
        state.auth0_client.clone()
×
78
    }
×
79
}
80

81
impl FromRef<AxumAppState> for OauthClient {
82
    fn from_ref(state: &AxumAppState) -> Self {
2✔
83
        state.oauth_client.clone()
2✔
84
    }
2✔
85
}
86

87
impl FromRef<AxumAppState> for Crypter {
88
    fn from_ref(state: &AxumAppState) -> Self {
65✔
89
        state.crypter.clone()
65✔
90
    }
65✔
91
}
92

93
impl FromRef<AxumAppState> for FeatureFlags {
94
    fn from_ref(state: &AxumAppState) -> Self {
20✔
95
        state.feature_flags
20✔
96
    }
20✔
97
}
98

99
impl FromRef<AxumAppState> for Client {
100
    fn from_ref(state: &AxumAppState) -> Self {
65✔
101
        state.client.clone()
65✔
102
    }
65✔
103
}
104

105
#[derive(Handler, Debug)]
106
pub struct DivviupApi {
107
    #[handler(except = init)]
108
    handler: Box<dyn Handler>,
109
    db: Db,
110
    config: Arc<Config>,
111
    axum_addr: SocketAddr,
112
}
113

114
impl DivviupApi {
115
    pub async fn init(&mut self, info: &mut Info) {
278✔
116
        *info.server_description_mut() = format!("divviup-api {}", env!("CARGO_PKG_VERSION"));
278✔
117
        *info.listener_description_mut() = format!(
278✔
118
            "api url: {}\n             app url: {}\n             axum: {}\n",
119
            self.config.api_url, self.config.app_url, self.axum_addr,
278✔
120
        );
121
        self.handler.init(info).await
278✔
122
    }
278✔
123

124
    pub async fn new(config: Config) -> Self {
278✔
125
        let config = Arc::new(config);
278✔
126
        let db = Db::connect(config.database_url.as_ref()).await;
278✔
127

128
        // Spawn the Axum server on an ephemeral port. Routes will be migrated
129
        // here incrementally.
130
        let auth0_client = Auth0Client::new(&config);
278✔
131
        let axum_state = AxumAppState {
278✔
132
            db: db.clone(),
278✔
133
            config: config.clone(),
278✔
134
            auth0_client: auth0_client.clone(),
278✔
135
            oauth_client: OauthClient::new(&config.oauth_config()),
278✔
136
            crypter: config.crypter.clone(),
278✔
137
            feature_flags: config.feature_flags(),
278✔
138
            client: config.client.clone(),
278✔
139
        };
278✔
140
        // Middleware stack in logical order (outermost first), matching the
141
        // Trillium api() handler chain.
142
        let middleware = ServiceBuilder::new()
278✔
143
            .layer(TraceLayer::new_for_http())
278✔
144
            .layer(DefaultBodyLimit::max(1024 * 1024))
278✔
145
            .layer(CompressionLayer::new())
278✔
146
            .layer(SetResponseHeaderLayer::if_not_present(
278✔
147
                header::CACHE_CONTROL,
278✔
148
                HeaderValue::from_static("private, must-revalidate"),
278✔
149
            ))
150
            .layer(axum_cors_layer(&config))
278✔
151
            .layer(axum_session_layer(db.clone(), &config.session_secrets));
278✔
152

153
        #[cfg(feature = "integration-testing")]
154
        let middleware =
155
            middleware.layer(axum::middleware::from_fn(inject_integration_testing_user));
156

157
        #[cfg(feature = "test-header-injection")]
158
        let middleware = middleware.layer(axum::middleware::from_fn(inject_test_header_user));
278✔
159

160
        let axum_router = axum::Router::new()
278✔
161
            // Temporary test endpoint to verify the proxy bridge works.
162
            // TODO: Remove once enough routes have migrated to make it redundant.
163
            .route(
278✔
164
                "/internal/test/axum_ready",
278✔
165
                axum::routing::get(|| async { "axum OK" }),
278✔
166
            )
167
            .route("/health", axum::routing::get(routes::health_check))
278✔
168
            .route("/login", axum::routing::get(oauth2::redirect))
278✔
169
            .route("/logout", axum::routing::get(oauth2::logout))
278✔
170
            .route("/callback", axum::routing::get(oauth2::callback))
278✔
171
            .nest("/api", axum_routes::api_router(&axum_state))
278✔
172
            .layer(middleware)
278✔
173
            .with_state(axum_state);
278✔
174
        let axum_listener = TcpListener::bind((Ipv6Addr::LOCALHOST, 0))
278✔
175
            .await
278✔
176
            .expect("failed to bind Axum listener on IPv6 loopback");
278✔
177
        let axum_addr = axum_listener
278✔
178
            .local_addr()
278✔
179
            .expect("failed to get Axum listener address");
278✔
180
        // TODO: Wire graceful shutdown into axum::serve(...).with_graceful_shutdown()
181
        // so that in-flight requests are drained when the Trillium server stops.
182
        tokio::spawn(async move {
278✔
183
            if let Err(e) = axum::serve(axum_listener, axum_router).await {
278✔
184
                log::error!("axum server error: {e}");
×
185
            }
×
186
        });
×
187

188
        let proxy = AxumProxy::new(axum_addr);
278✔
189

190
        Self {
278✔
191
            handler: Box::new((
278✔
192
                conn_id(),
278✔
193
                Forwarding::trust_always(),
278✔
194
                opentelemetry(),
278✔
195
                caching_headers(),
278✔
196
                logger(),
278✔
197
                #[cfg(assets)]
278✔
198
                instrument_handler(assets::static_assets(&config)),
278✔
199
                #[cfg(feature = "test-header-injection")]
278✔
200
                inject_test_user_trillium,
278✔
201
                proxy,
278✔
202
                ErrorHandler,
278✔
203
            )),
278✔
204
            db,
278✔
205
            config,
278✔
206
            axum_addr,
278✔
207
        }
278✔
208
    }
278✔
209

210
    pub fn db(&self) -> &Db {
1,515✔
211
        &self.db
1,515✔
212
    }
1,515✔
213

214
    pub fn config(&self) -> &Config {
21✔
215
        &self.config
21✔
216
    }
21✔
217

218
    pub fn crypter(&self) -> &crate::Crypter {
231✔
219
        &self.config.crypter
231✔
220
    }
231✔
221

222
    #[expect(dead_code)] // Scaffolded for later migration parts.
223
    pub(crate) fn axum_addr(&self) -> SocketAddr {
×
224
        self.axum_addr
×
225
    }
×
226
}
227

228
impl AsRef<Db> for DivviupApi {
229
    fn as_ref(&self) -> &Db {
×
230
        &self.db
×
231
    }
×
232
}
233

234
/// Trillium-side test shim: if a `User` was injected via `.with_state()`
235
/// (legacy test pattern), serialize it into the `X-Integration-Testing-User`
236
/// header so the proxy forwards it to Axum.
237
// TODO: remove in Part 8 (Trillium removal)
238
#[cfg(feature = "test-header-injection")]
239
async fn inject_test_user_trillium(mut conn: trillium::Conn) -> trillium::Conn {
273✔
240
    if let Some(json) = conn
273✔
241
        .state::<crate::User>()
273✔
242
        .and_then(|u| serde_json::to_string(u).ok())
273✔
243
    {
149✔
244
        conn.request_headers_mut()
149✔
245
            .insert("x-integration-testing-user", json);
149✔
246
    }
149✔
247
    conn
273✔
248
}
273✔
249

250
/// Axum middleware that unconditionally injects an admin
251
/// [`User`](crate::User) into every request. This is the Axum equivalent of
252
/// the Trillium `state(User::for_integration_testing())` that was in the old
253
/// `api()` handler chain.
254
///
255
/// Only compiled under `--features integration-testing` (enabled by
256
/// `compose.dev.override.yaml`). Never compiled into deployed builds.
257
#[cfg(feature = "integration-testing")]
258
async fn inject_integration_testing_user(
259
    mut request: axum::extract::Request,
260
    next: axum::middleware::Next,
261
) -> axum::response::Response {
262
    request
263
        .extensions_mut()
264
        .insert(crate::User::for_integration_testing());
265
    next.run(request).await
266
}
267

268
/// Axum middleware that reads an `X-Integration-Testing-User` header and
269
/// injects the decoded [`User`](crate::User) into request extensions.
270
/// Used by `test-support` to impersonate specific users in tests.
271
///
272
/// Only compiled under `--features test-header-injection` (enabled by
273
/// `test-support`). Never compiled into deployed builds.
274
#[cfg(feature = "test-header-injection")]
275
async fn inject_test_header_user(
273✔
276
    mut request: axum::extract::Request,
273✔
277
    next: axum::middleware::Next,
273✔
278
) -> axum::response::Response {
273✔
279
    if let Some(user) = request
273✔
280
        .headers()
273✔
281
        .get("x-integration-testing-user")
273✔
282
        .and_then(|v| serde_json::from_slice::<crate::User>(v.as_bytes()).ok())
273✔
283
    {
151✔
284
        request.extensions_mut().insert(user);
151✔
285
    }
151✔
286
    next.run(request).await
273✔
287
}
273✔
288

289
#[derive(Handler, Debug, Clone)]
290
pub struct NamedHandler<H>(#[handler(except = name)] H, Cow<'static, str>);
291
impl<H: Handler> NamedHandler<H> {
UNCOV
292
    fn name(&self) -> Cow<'static, str> {
×
UNCOV
293
        self.1.clone()
×
UNCOV
294
    }
×
295

UNCOV
296
    pub fn new(name: impl Into<Cow<'static, str>>, handler: H) -> Self {
×
UNCOV
297
        Self(handler, name.into())
×
UNCOV
298
    }
×
299
}
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