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

divviup / divviup-api / 24410952231

14 Apr 2026 04:32PM UTC coverage: 57.463% (+0.3%) from 57.169%
24410952231

push

github

web-flow
Migrate from Trillium [part 2]: Axum scaffold and proxy fallback (#2197)

Establish dual-server infrastructure for incremental route migration.
Trillium remains the primary listener; a proxy handler at the end of
the handler chain forwards unmatched requests to a local Axum server.

- Add axum, reqwest, and tower-http dependencies to the main crate
- Define AxumAppState struct (Db + Arc<Config>) for the Axum side
- Spawn an Axum server on an ephemeral loopback port in DivviupApi::new()
  with a TraceLayer for request-level tracing
- Add AxumProxy handler that forwards unhalted Trillium requests to
  the local Axum server via reqwest (trillium-proxy is incompatible
  with trillium 0.2.x, so we use a custom handler like Janus did)
- Wire the proxy into the handler chain just before ErrorHandler
- Add /internal/test/axum_ready endpoint and integration test to
  verify the proxy bridge works end-to-end

76 of 95 new or added lines in 2 files covered. (80.0%)

4154 of 7229 relevant lines covered (57.46%)

60.91 hits per line

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

90.91
/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 logger;
8
pub(crate) mod misc;
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::{routes, Config, Db};
17

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

39
pub(crate) use custom_mime_types::ReplaceMimeTypes;
40
pub(crate) use misc::*;
41

42
pub use error::Error;
43
pub use origin_router::origin_router;
44

45
use self::opentelemetry::opentelemetry;
46

47
#[cfg(feature = "otlp-trace")]
48
use trillium_opentelemetry::global::instrument_handler;
49
#[cfg(not(feature = "otlp-trace"))]
50
fn instrument_handler(handler: impl Handler) -> impl Handler {
1,946✔
51
    handler
1,946✔
52
}
1,946✔
53

54
/// Shared state for the Axum side of the application during migration.
55
#[derive(Clone, Debug)]
56
pub struct AxumAppState {
57
    pub db: Db,
58
    pub config: Arc<Config>,
59
}
60

61
#[derive(Handler, Debug)]
62
pub struct DivviupApi {
63
    #[handler(except = init)]
64
    handler: Box<dyn Handler>,
65
    db: Db,
66
    config: Arc<Config>,
67
    axum_addr: SocketAddr,
68
}
69

70
impl DivviupApi {
71
    pub async fn init(&mut self, info: &mut Info) {
278✔
72
        *info.server_description_mut() = format!("divviup-api {}", env!("CARGO_PKG_VERSION"));
278✔
73
        *info.listener_description_mut() = format!(
278✔
74
            "api url: {}\n             app url: {}\n             axum: {}\n",
75
            self.config.api_url, self.config.app_url, self.axum_addr,
278✔
76
        );
77
        self.handler.init(info).await
278✔
78
    }
278✔
79

80
    pub async fn new(config: Config) -> Self {
278✔
81
        let config = Arc::new(config);
278✔
82
        let db = Db::connect(config.database_url.as_ref()).await;
278✔
83

84
        // Spawn the Axum server on an ephemeral port. Routes will be migrated
85
        // here incrementally.
86
        let axum_state = AxumAppState {
278✔
87
            db: db.clone(),
278✔
88
            config: config.clone(),
278✔
89
        };
278✔
90
        let axum_router = axum::Router::new()
278✔
91
            // Temporary test endpoint to verify the proxy bridge works.
92
            // TODO: Remove once a real endpoint has been migrated.
93
            .route(
278✔
94
                "/internal/test/axum_ready",
278✔
95
                axum::routing::get(|| async { "axum OK" }),
278✔
96
            )
97
            .layer(DefaultBodyLimit::max(1024 * 1024))
278✔
98
            // Basic request tracing only for now; full telemetry (metrics,
99
            // OpenTelemetry, structured logging) will be added in Part 4.
100
            .layer(TraceLayer::new_for_http())
278✔
101
            .with_state(axum_state);
278✔
102
        let axum_listener = TcpListener::bind((Ipv6Addr::LOCALHOST, 0))
278✔
103
            .await
278✔
104
            .expect("failed to bind Axum listener on IPv6 loopback");
278✔
105
        let axum_addr = axum_listener
278✔
106
            .local_addr()
278✔
107
            .expect("failed to get Axum listener address");
278✔
108
        // TODO: Wire graceful shutdown into axum::serve(...).with_graceful_shutdown()
109
        // so that in-flight requests are drained when the Trillium server stops.
110
        tokio::spawn(async move {
278✔
111
            if let Err(e) = axum::serve(axum_listener, axum_router).await {
278✔
NEW
112
                log::error!("axum server error: {e}");
×
NEW
113
            }
×
NEW
114
        });
×
115

116
        let proxy = AxumProxy::new(axum_addr);
278✔
117

118
        Self {
278✔
119
            handler: Box::new((
278✔
120
                conn_id(),
278✔
121
                routes::health_check(&db),
278✔
122
                Forwarding::trust_always(),
278✔
123
                opentelemetry(),
278✔
124
                caching_headers(),
278✔
125
                logger(),
278✔
126
                #[cfg(assets)]
278✔
127
                instrument_handler(assets::static_assets(&config)),
278✔
128
                instrument_handler(api(&db, &config)),
278✔
129
                proxy,
278✔
130
                ErrorHandler,
278✔
131
            )),
278✔
132
            db,
278✔
133
            config,
278✔
134
            axum_addr,
278✔
135
        }
278✔
136
    }
278✔
137

138
    pub fn db(&self) -> &Db {
1,515✔
139
        &self.db
1,515✔
140
    }
1,515✔
141

142
    pub fn config(&self) -> &Config {
21✔
143
        &self.config
21✔
144
    }
21✔
145

146
    pub fn crypter(&self) -> &crate::Crypter {
231✔
147
        &self.config.crypter
231✔
148
    }
231✔
149

150
    #[expect(dead_code)] // Scaffolded for later migration parts.
NEW
151
    pub(crate) fn axum_addr(&self) -> SocketAddr {
×
NEW
152
        self.axum_addr
×
NEW
153
    }
×
154
}
155

156
impl AsRef<Db> for DivviupApi {
157
    fn as_ref(&self) -> &Db {
×
158
        &self.db
×
159
    }
×
160
}
161

162
#[derive(Handler, Debug, Clone)]
163
pub struct NamedHandler<H>(#[handler(except = name)] H, Cow<'static, str>);
164
impl<H: Handler> NamedHandler<H> {
165
    fn name(&self) -> Cow<'static, str> {
637✔
166
        self.1.clone()
637✔
167
    }
637✔
168

169
    pub fn new(name: impl Into<Cow<'static, str>>, handler: H) -> Self {
278✔
170
        Self(handler, name.into())
278✔
171
    }
278✔
172
}
173

174
fn api(db: &Db, config: &Config) -> impl Handler {
278✔
175
    NamedHandler::new(
278✔
176
        "api",
177
        (
278✔
178
            instrument_handler(compression()),
278✔
179
            #[cfg(feature = "integration-testing")]
278✔
180
            state(crate::User::for_integration_testing()),
278✔
181
            instrument_handler(cookies()),
278✔
182
            instrument_handler(
278✔
183
                sessions(
278✔
184
                    SessionStore::new(db.clone()),
278✔
185
                    &config.session_secrets.current,
278✔
186
                )
278✔
187
                .with_cookie_name("divviup.sid")
278✔
188
                .with_older_secrets(&config.session_secrets.older),
278✔
189
            ),
278✔
190
            state(config.client.clone()),
278✔
191
            state(config.crypter.clone()),
278✔
192
            state(config.feature_flags()),
278✔
193
            instrument_handler(cors_headers(config)),
278✔
194
            cache_control([Private, MustRevalidate]),
278✔
195
            db.clone(),
278✔
196
            instrument_handler(routes(config)),
278✔
197
        ),
278✔
198
    )
199
}
278✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc