Skip to main content

qualia_client_core/
qdp_http.rs

1//! **QDP-over-HTTP** — serve the rich `/.well-known/QDP` profile over a local HTTP listener.
2//!
3//! This is the transport that fronts the pure [`crate::qdp_server`] renderer: once a domain-agent is
4//! reachable on the WireGuard mesh (or any bound address), it can *self-host* its own front-door
5//! profile instead of relying on external hosting (Cloudflare Worker/R2, Solid POD). No hosting
6//! provider, no lock-in — the agent answers `GET /.well-known/QDP` from its own process.
7//!
8//! The routing decision ([`route`]) is **pure** and testable without binding a socket: it maps
9//! `(method, path, accept)` to an [`HttpReply`]. Only [`serve_blocking`] touches the network, and it
10//! is a thin `tiny_http` accept loop that delegates every request to [`route`].
11//!
12//! Content negotiation itself is delegated to [`crate::qdp_server::render_profile`] (Turtle / JSON-LD /
13//! CBOR-LD per the `Accept` header), so this module never duplicates the serialization logic.
14
15use crate::front_door::FrontDoorRecord;
16
17/// A fully-resolved HTTP reply: status code, `Content-Type`, and the body bytes.
18///
19/// This is the pure result of [`route`]; [`serve_blocking`] turns it into a `tiny_http::Response`.
20#[derive(Debug, Clone)]
21pub struct HttpReply {
22    /// HTTP status code (200 on a rendered profile, 404 for an unmatched route, 500 on render error).
23    pub status: u16,
24    /// The `Content-Type` header value for [`Self::body`].
25    pub content_type: String,
26    /// The response body bytes.
27    pub body: Vec<u8>,
28}
29
30/// Route one HTTP request against a [`FrontDoorRecord`] — **pure**, no I/O.
31///
32/// - `GET` on [`crate::qdp_server::WELL_KNOWN_QDP_PATH`] (query string stripped) →
33///   [`render_profile`](crate::qdp_server::render_profile) with the `Accept` value.
34///   - `Ok` → `200` with the negotiated content-type and body.
35///   - `Err` → `500 text/plain` carrying the error message.
36/// - anything else → `404 text/plain` `not found`.
37///
38/// The method match is ASCII-case-insensitive; the path is compared after stripping any `?query`.
39pub fn route(record: &FrontDoorRecord, method: &str, path: &str, accept: &str) -> HttpReply {
40    // Strip any query string before comparing the path.
41    let path = path.split('?').next().unwrap_or(path);
42
43    if method.eq_ignore_ascii_case("GET") && path == crate::qdp_server::WELL_KNOWN_QDP_PATH {
44        match crate::qdp_server::render_profile(record, accept) {
45            Ok(resp) => HttpReply {
46                status: 200,
47                content_type: resp.content_type,
48                body: resp.body,
49            },
50            Err(err) => HttpReply {
51                status: 500,
52                content_type: "text/plain".to_string(),
53                body: err.into_bytes(),
54            },
55        }
56    } else {
57        HttpReply {
58            status: 404,
59            content_type: "text/plain".to_string(),
60            body: b"not found".to_vec(),
61        }
62    }
63}
64
65/// Serve the QDP profile for `record` on `bind_addr` (e.g. `"[fd00::1]:80"` on the mesh, or
66/// `"127.0.0.1:8080"` locally). **Blocks** the calling thread on the accept loop — the caller is
67/// expected to run this on a dedicated thread.
68///
69/// Native-only (`tiny_http`). Every request is dispatched through the pure [`route`], so the network
70/// path and the tested path share one implementation.
71#[cfg(not(target_arch = "wasm32"))]
72pub fn serve_blocking(record: FrontDoorRecord, bind_addr: &str) -> Result<(), String> {
73    let server = tiny_http::Server::http(bind_addr).map_err(|e| e.to_string())?;
74
75    for req in server.incoming_requests() {
76        let method = req.method().as_str().to_string();
77        let url = req.url().to_string();
78        // Extract the `Accept` header value (case-insensitive field match); default to `*/*`.
79        let accept = req
80            .headers()
81            .iter()
82            .find(|h| h.field.equiv("Accept"))
83            .map(|h| h.value.as_str().to_string())
84            .unwrap_or_else(|| "*/*".to_string());
85
86        let reply = route(&record, &method, &url, &accept);
87
88        let mut resp = tiny_http::Response::from_data(reply.body).with_status_code(reply.status);
89        if let Ok(header) =
90            tiny_http::Header::from_bytes(b"Content-Type", reply.content_type.as_bytes())
91        {
92            resp = resp.with_header(header);
93        }
94        let _ = req.respond(resp);
95    }
96
97    Ok(())
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::front_door::FrontDoorRecord;
104
105    /// A minimal record for the pure routing tests (no socket is bound).
106    fn minimal_record() -> FrontDoorRecord {
107        FrontDoorRecord {
108            domain: "a.example".into(),
109            agent_type: crate::domains::AgentType::NaturalPerson,
110            front_door_did: "did:qdp:a".into(),
111            name: None,
112            webid: None,
113            services: vec![],
114            identity_pubkey_hex: None,
115            wireguard_pubkey_hex: None,
116            overlay_addr: None,
117            profile_url: None,
118        }
119    }
120
121    #[test]
122    fn get_well_known_qdp_turtle_is_200() {
123        let rec = minimal_record();
124        let reply = route(&rec, "GET", "/.well-known/QDP", "text/turtle");
125        assert_eq!(reply.status, 200);
126        assert!(
127            reply.content_type.contains("turtle"),
128            "content_type was {:?}",
129            reply.content_type
130        );
131        assert!(!reply.body.is_empty());
132    }
133
134    #[test]
135    fn query_string_is_stripped_before_matching() {
136        let rec = minimal_record();
137        let reply = route(&rec, "GET", "/.well-known/QDP?x=1", "application/ld+json");
138        assert_eq!(reply.status, 200);
139        assert_eq!(reply.content_type, "application/ld+json");
140        assert!(!reply.body.is_empty());
141    }
142
143    #[test]
144    fn method_match_is_case_insensitive() {
145        let rec = minimal_record();
146        let reply = route(&rec, "get", "/.well-known/QDP", "*/*");
147        assert_eq!(reply.status, 200);
148    }
149
150    #[test]
151    fn unknown_path_is_404() {
152        let rec = minimal_record();
153        let reply = route(&rec, "GET", "/other", "*/*");
154        assert_eq!(reply.status, 404);
155        assert_eq!(reply.content_type, "text/plain");
156        assert_eq!(reply.body, b"not found");
157    }
158
159    #[test]
160    fn non_get_method_is_404() {
161        let rec = minimal_record();
162        let reply = route(&rec, "POST", "/.well-known/QDP", "*/*");
163        assert_eq!(reply.status, 404);
164    }
165}