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

divviup / divviup-api / 24687304870

20 Apr 2026 07:57PM UTC coverage: 57.188% (-0.9%) from 58.079%
24687304870

push

github

web-flow
Migrate from Trillium [part 4]: Axum extractors (#2216)

Add `FromRequestParts` impls alongside every existing `FromConn` impl so
that Axum handlers (migrated in Parts 5-7) can extract the same types.
Both impls coexist on the same types; no routes migrate in this PR.

Extractors added:
- Db, Auth0Client, Crypter, FeatureFlags: via `FromRef<AxumAppState>`
- AccountBearerToken: from `Authorization: Bearer` header + DB lookup,
  cached in request extensions
- User: from tower-sessions Session (n.b. this is dead until Part 6 wires
  up `SessionManagerLayer`)
- PermissionsActor: tries bearer token, falls back to session user +
  memberships query, cached in request extensions
- Account, Task, Aggregator, ApiToken, CollectorCredential: via a shared
  `extract_entity` helper that generically handles path param parsing,
  DB lookup, and permission checking

Other changes:
- Expand AxumAppState with auth0_client, crypter, feature_flags fields
- Add `is_allowed_http`/`if_allowed_http` methods on PermissionsActor
  for `http::Method` (this is refactored to share logic with the Trillium
  variants via `check_permission`)
- Enable `axum-core` feature on tower-sessions-core for Session's
  `FromRequestParts` impl

8 of 134 new or added lines in 10 files covered. (5.97%)

1 existing line in 1 file now uncovered.

4344 of 7596 relevant lines covered (57.19%)

60.93 hits per line

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

80.65
/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 misc;
10
pub(crate) mod oauth2;
11
pub(crate) mod opentelemetry;
12
pub(crate) mod origin_router;
13
pub(crate) mod session_store;
14

15
pub(crate) mod proxy;
16

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

19
use axum::extract::{DefaultBodyLimit, FromRef};
20
use axum::http::{header, HeaderValue};
21
use cors::{axum_cors_layer, cors_headers};
22
use error::ErrorHandler;
23
use logger::logger;
24
use proxy::AxumProxy;
25
use session_store::SessionStore;
26
use std::{borrow::Cow, net::Ipv6Addr, net::SocketAddr, sync::Arc};
27
use tokio::net::TcpListener;
28
use tower::ServiceBuilder;
29
use tower_http::compression::CompressionLayer;
30
use tower_http::set_header::SetResponseHeaderLayer;
31
use tower_http::trace::TraceLayer;
32
use trillium::{state, Handler, Info};
33
use trillium_caching_headers::{
34
    cache_control, caching_headers,
35
    CacheControlDirective::{MustRevalidate, Private},
36
};
37
use trillium_compression::compression;
38
use trillium_conn_id::conn_id;
39
use trillium_cookies::cookies;
40
use trillium_forwarding::Forwarding;
41
use trillium_macros::Handler;
42
use trillium_sessions::sessions;
43

44
pub(crate) use custom_mime_types::ReplaceMimeTypes;
45
pub(crate) use misc::*;
46

47
pub use error::Error;
48
pub use origin_router::origin_router;
49

50
use self::opentelemetry::opentelemetry;
51

52
#[cfg(feature = "otlp-trace")]
53
use trillium_opentelemetry::global::instrument_handler;
54
#[cfg(not(feature = "otlp-trace"))]
55
fn instrument_handler(handler: impl Handler) -> impl Handler {
1,946✔
56
    handler
1,946✔
57
}
1,946✔
58

59
/// Shared state for the Axum side of the application during migration.
60
#[derive(Clone, Debug)]
61
pub struct AxumAppState {
62
    pub(crate) db: Db,
63
    pub(crate) config: Arc<Config>,
64
    pub(crate) auth0_client: Auth0Client,
65
    pub(crate) crypter: Crypter,
66
    pub(crate) feature_flags: FeatureFlags,
67
}
68

69
impl FromRef<AxumAppState> for Db {
NEW
70
    fn from_ref(state: &AxumAppState) -> Self {
×
NEW
71
        state.db.clone()
×
NEW
72
    }
×
73
}
74

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

81
impl FromRef<AxumAppState> for Auth0Client {
NEW
82
    fn from_ref(state: &AxumAppState) -> Self {
×
NEW
83
        state.auth0_client.clone()
×
NEW
84
    }
×
85
}
86

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

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

99
#[derive(Handler, Debug)]
100
pub struct DivviupApi {
101
    #[handler(except = init)]
102
    handler: Box<dyn Handler>,
103
    db: Db,
104
    config: Arc<Config>,
105
    axum_addr: SocketAddr,
106
}
107

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

118
    pub async fn new(config: Config) -> Self {
278✔
119
        let config = Arc::new(config);
278✔
120
        let db = Db::connect(config.database_url.as_ref()).await;
278✔
121

122
        // Spawn the Axum server on an ephemeral port. Routes will be migrated
123
        // here incrementally.
124
        let axum_state = AxumAppState {
278✔
125
            db: db.clone(),
278✔
126
            config: config.clone(),
278✔
127
            auth0_client: Auth0Client::new(&config),
278✔
128
            crypter: config.crypter.clone(),
278✔
129
            feature_flags: config.feature_flags(),
278✔
130
        };
278✔
131
        // Middleware stack in logical order (outermost first), matching the
132
        // Trillium api() handler chain.
133
        //
134
        // TODO(Part 7): ReplaceMimeTypesLayer goes on the /api/* sub-router.
135
        // TODO(Part 6): SessionManagerLayer goes here once auth routes migrate.
136
        let middleware = ServiceBuilder::new()
278✔
137
            .layer(TraceLayer::new_for_http())
278✔
138
            .layer(DefaultBodyLimit::max(1024 * 1024))
278✔
139
            .layer(CompressionLayer::new())
278✔
140
            .layer(SetResponseHeaderLayer::if_not_present(
278✔
141
                header::CACHE_CONTROL,
278✔
142
                HeaderValue::from_static("private, must-revalidate"),
278✔
143
            ))
144
            .layer(axum_cors_layer(&config));
278✔
145

146
        let axum_router = axum::Router::new()
278✔
147
            // Temporary test endpoint to verify the proxy bridge works.
148
            // TODO: Remove once a real endpoint has been migrated.
149
            .route(
278✔
150
                "/internal/test/axum_ready",
278✔
151
                axum::routing::get(|| async { "axum OK" }),
278✔
152
            )
153
            .layer(middleware)
278✔
154
            .with_state(axum_state);
278✔
155
        let axum_listener = TcpListener::bind((Ipv6Addr::LOCALHOST, 0))
278✔
156
            .await
278✔
157
            .expect("failed to bind Axum listener on IPv6 loopback");
278✔
158
        let axum_addr = axum_listener
278✔
159
            .local_addr()
278✔
160
            .expect("failed to get Axum listener address");
278✔
161
        // TODO: Wire graceful shutdown into axum::serve(...).with_graceful_shutdown()
162
        // so that in-flight requests are drained when the Trillium server stops.
163
        tokio::spawn(async move {
278✔
164
            if let Err(e) = axum::serve(axum_listener, axum_router).await {
278✔
165
                log::error!("axum server error: {e}");
×
166
            }
×
167
        });
×
168

169
        let proxy = AxumProxy::new(axum_addr);
278✔
170

171
        Self {
278✔
172
            handler: Box::new((
278✔
173
                conn_id(),
278✔
174
                routes::health_check(&db),
278✔
175
                Forwarding::trust_always(),
278✔
176
                opentelemetry(),
278✔
177
                caching_headers(),
278✔
178
                logger(),
278✔
179
                #[cfg(assets)]
278✔
180
                instrument_handler(assets::static_assets(&config)),
278✔
181
                instrument_handler(api(&db, &config)),
278✔
182
                proxy,
278✔
183
                ErrorHandler,
278✔
184
            )),
278✔
185
            db,
278✔
186
            config,
278✔
187
            axum_addr,
278✔
188
        }
278✔
189
    }
278✔
190

191
    pub fn db(&self) -> &Db {
1,515✔
192
        &self.db
1,515✔
193
    }
1,515✔
194

195
    pub fn config(&self) -> &Config {
21✔
196
        &self.config
21✔
197
    }
21✔
198

199
    pub fn crypter(&self) -> &crate::Crypter {
231✔
200
        &self.config.crypter
231✔
201
    }
231✔
202

203
    #[expect(dead_code)] // Scaffolded for later migration parts.
204
    pub(crate) fn axum_addr(&self) -> SocketAddr {
×
205
        self.axum_addr
×
206
    }
×
207
}
208

209
impl AsRef<Db> for DivviupApi {
210
    fn as_ref(&self) -> &Db {
×
211
        &self.db
×
212
    }
×
213
}
214

215
#[derive(Handler, Debug, Clone)]
216
pub struct NamedHandler<H>(#[handler(except = name)] H, Cow<'static, str>);
217
impl<H: Handler> NamedHandler<H> {
218
    fn name(&self) -> Cow<'static, str> {
637✔
219
        self.1.clone()
637✔
220
    }
637✔
221

222
    pub fn new(name: impl Into<Cow<'static, str>>, handler: H) -> Self {
278✔
223
        Self(handler, name.into())
278✔
224
    }
278✔
225
}
226

227
fn api(db: &Db, config: &Config) -> impl Handler {
278✔
228
    NamedHandler::new(
278✔
229
        "api",
230
        (
278✔
231
            instrument_handler(compression()),
278✔
232
            #[cfg(feature = "integration-testing")]
278✔
233
            state(crate::User::for_integration_testing()),
278✔
234
            instrument_handler(cookies()),
278✔
235
            instrument_handler(
278✔
236
                sessions(
278✔
237
                    SessionStore::new(db.clone()),
278✔
238
                    &config.session_secrets.current,
278✔
239
                )
278✔
240
                .with_cookie_name("divviup.sid")
278✔
241
                .with_older_secrets(&config.session_secrets.older),
278✔
242
            ),
278✔
243
            state(config.client.clone()),
278✔
244
            state(config.crypter.clone()),
278✔
245
            state(config.feature_flags()),
278✔
246
            instrument_handler(cors_headers(config)),
278✔
247
            cache_control([Private, MustRevalidate]),
278✔
248
            db.clone(),
278✔
249
            instrument_handler(routes(config)),
278✔
250
        ),
278✔
251
    )
252
}
278✔
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