• 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

80.3
/src/handler/proxy.rs
1
//! Temporary reverse proxy handler that forwards unmatched Trillium requests to
2
//! the local Axum server. This exists only during the incremental migration and
3
//! will be removed once all routes have been moved to Axum.
4

5
use reqwest::header::{HeaderName, CONNECTION, HOST, TE, TRAILER, TRANSFER_ENCODING};
6
use std::net::SocketAddr;
7
use trillium::{Conn, Handler, Status};
8

9
/// A Trillium [`Handler`] that proxies unhalted requests to a local Axum server.
10
#[derive(Debug)]
11
pub struct AxumProxy {
12
    upstream: String,
13
    client: reqwest::Client,
14
}
15

16
impl AxumProxy {
17
    pub fn new(addr: SocketAddr) -> Self {
278✔
18
        Self {
278✔
19
            upstream: format!("http://[::1]:{}", addr.port()),
278✔
20
            client: reqwest::Client::builder()
278✔
21
                .no_proxy()
278✔
22
                .build()
278✔
23
                .expect("failed to build proxy HTTP client"),
278✔
24
        }
278✔
25
    }
278✔
26
}
27

28
/// Hop-by-hop headers that should not be forwarded through the proxy (RFC 7230 ยง6.1).
29
const UNPROXYABLE_HEADERS: [HeaderName; 5] = [HOST, TRANSFER_ENCODING, CONNECTION, TE, TRAILER];
30

31
#[trillium::async_trait]
32
impl Handler for AxumProxy {
33
    async fn run(&self, mut conn: Conn) -> Conn {
522✔
34
        // Only proxy requests that haven't been handled by earlier handlers.
35
        if conn.status().is_some() || conn.is_halted() {
261✔
36
            return conn;
176✔
37
        }
85✔
38

39
        let method = conn.method();
85✔
40
        let path = conn.path();
85✔
41
        let querystring = conn.querystring();
85✔
42

43
        let url = if querystring.is_empty() {
85✔
44
            format!("{}{}", self.upstream, path)
85✔
45
        } else {
NEW
46
            format!("{}{}?{}", self.upstream, path, querystring)
×
47
        };
48

49
        let reqwest_method = match reqwest::Method::from_bytes(method.as_ref().as_bytes()) {
85✔
50
            Ok(m) => m,
85✔
NEW
51
            Err(_) => return conn.with_status(Status::BadRequest).halt(),
×
52
        };
53

54
        let mut builder = self.client.request(reqwest_method, &url);
85✔
55

56
        // Forward request headers, filtering out hop-by-hop headers.
57
        for (name, values) in conn.request_headers() {
363✔
58
            let header_name = match HeaderName::from_bytes(name.as_ref().as_bytes()) {
363✔
59
                Ok(h) => h,
363✔
NEW
60
                Err(_) => continue,
×
61
            };
62
            if UNPROXYABLE_HEADERS.contains(&header_name) {
363✔
63
                continue;
85✔
64
            }
278✔
65
            for value in values.iter() {
278✔
66
                if let Some(s) = value.as_str() {
278✔
67
                    builder = builder.header(&header_name, s);
278✔
68
                }
278✔
69
            }
70
        }
71

72
        // Forward the request body. Note: no size limit is enforced here;
73
        // the Trillium API layer (trillium-api) enforces a 1 MiB limit before
74
        // requests reach this handler, so it's fine for the migration window.
75
        let body = conn.request_body().await.read_bytes().await;
85✔
76
        match body {
85✔
77
            Ok(bytes) if !bytes.is_empty() => {
85✔
78
                builder = builder.body(bytes);
28✔
79
            }
28✔
NEW
80
            Err(e) => {
×
NEW
81
                log::error!("axum proxy error reading request body: {e}");
×
NEW
82
                return conn.with_status(Status::BadRequest).halt();
×
83
            }
84
            _ => {}
57✔
85
        }
86

87
        let resp = match builder.send().await {
85✔
88
            Ok(resp) => resp,
85✔
NEW
89
            Err(e) => {
×
NEW
90
                log::error!("axum proxy error: {e}");
×
NEW
91
                return conn.with_status(Status::BadGateway).halt();
×
92
            }
93
        };
94

95
        let status = resp.status().as_u16();
85✔
96
        let resp_headers = resp.headers().clone();
85✔
97
        let body = match resp.bytes().await {
85✔
98
            Ok(b) => b,
85✔
NEW
99
            Err(e) => {
×
NEW
100
                log::error!("axum proxy error reading response: {e}");
×
NEW
101
                return conn.with_status(Status::BadGateway).halt();
×
102
            }
103
        };
104

105
        let mut conn = conn.with_status(status).halt();
85✔
106

107
        for (name, value) in resp_headers.iter() {
171✔
108
            if UNPROXYABLE_HEADERS.contains(name) {
171✔
NEW
109
                continue;
×
110
            }
171✔
111
            if let Ok(v) = value.to_str() {
171✔
112
                conn.response_headers_mut()
171✔
113
                    .append(name.as_str().to_owned(), v.to_owned());
171✔
114
            }
171✔
115
        }
116

117
        // This copies the response body; we could avoid it by streaming via
118
        // resp.into_body() + Body::new_streaming, but it's not worth the extra
119
        // plumbing for a temporary shim.
120
        conn.with_body(body.to_vec())
85✔
121
    }
522✔
122
}
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