Skip to main content

qualia_client_core/
qdp_server.rs

1//! **QDP profile renderer** — HTTP content-negotiation for the `/.well-known/QDP` profile.
2//!
3//! This module is **pure**: it turns a [`crate::front_door::FrontDoorRecord`] plus an HTTP `Accept`
4//! header value into a [`QdpResponse`] (content-type + body bytes). It does **not** bind a socket,
5//! spawn a listener, or perform any I/O — the transport layer (daemon, Worker, Solid POD) owns the
6//! socket and calls [`render_profile`] to produce the payload for the matched route.
7//!
8//! Content negotiation follows QDP §3.1 (the rich profile is served in Turtle + JSON-LD + CBOR-LD):
9//! - `application/ld+json` → JSON-LD (`to_json_ld`)
10//! - `application/cbor`     → CBOR-LD (`to_cbor_ld`)
11//! - anything else, incl. `text/turtle` and `*/*` → Turtle (`to_turtle`, the default)
12
13use crate::front_door::FrontDoorRecord;
14
15/// The well-known path the rich QDP profile is served at (QDP §3.1).
16pub const WELL_KNOWN_QDP_PATH: &str = "/.well-known/QDP";
17
18/// A rendered QDP profile response: the negotiated content type and the serialized body bytes.
19#[derive(Debug, Clone)]
20pub struct QdpResponse {
21    /// The `Content-Type` header value for the negotiated representation.
22    pub content_type: String,
23    /// The serialized profile body.
24    pub body: Vec<u8>,
25}
26
27/// Render a [`FrontDoorRecord`] as a QDP profile, negotiating the representation from an HTTP
28/// `Accept` header value.
29///
30/// Matching is a simple substring test against `accept` (sufficient for the QDP media types):
31/// - contains `"application/ld+json"` → JSON-LD, content-type `application/ld+json`
32/// - else contains `"application/cbor"` → CBOR-LD, content-type `application/cbor`
33/// - else (default, incl. `text/turtle` and `*/*`) → Turtle, content-type `text/turtle`
34///
35/// Returns `Err` only if serialization fails (JSON-LD or CBOR-LD encoding).
36pub fn render_profile(rec: &FrontDoorRecord, accept: &str) -> Result<QdpResponse, String> {
37    if accept.contains("application/ld+json") {
38        let body = serde_json::to_vec(&rec.to_json_ld()).map_err(|e| e.to_string())?;
39        Ok(QdpResponse {
40            content_type: "application/ld+json".to_string(),
41            body,
42        })
43    } else if accept.contains("application/cbor") {
44        let body = rec.to_cbor_ld()?;
45        Ok(QdpResponse {
46            content_type: "application/cbor".to_string(),
47            body,
48        })
49    } else {
50        let body = rec.to_turtle().into_bytes();
51        Ok(QdpResponse {
52            content_type: "text/turtle".to_string(),
53            body,
54        })
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::domains::AgentType;
62    use crate::front_door::FrontDoorRecord;
63
64    /// A minimal record for the pure content-negotiation tests.
65    fn minimal_record() -> FrontDoorRecord {
66        FrontDoorRecord {
67            domain: "a.example".to_string(),
68            agent_type: AgentType::NaturalPerson,
69            front_door_did: "did:qdp:a".to_string(),
70            name: None,
71            webid: None,
72            services: vec![],
73            identity_pubkey_hex: None,
74            wireguard_pubkey_hex: None,
75            overlay_addr: None,
76            profile_url: None,
77        }
78    }
79
80    #[test]
81    fn well_known_path_is_stable() {
82        assert_eq!(WELL_KNOWN_QDP_PATH, "/.well-known/QDP");
83    }
84
85    #[test]
86    fn turtle_is_the_default() {
87        let rec = minimal_record();
88        let resp = render_profile(&rec, "text/turtle").expect("turtle renders");
89        assert_eq!(resp.content_type, "text/turtle");
90        assert!(!resp.body.is_empty());
91
92        // `*/*` and unknown accept values also fall through to Turtle.
93        let any = render_profile(&rec, "*/*").expect("wildcard renders");
94        assert_eq!(any.content_type, "text/turtle");
95        assert!(!any.body.is_empty());
96    }
97
98    #[test]
99    fn json_ld_negotiation_roundtrips() {
100        let rec = minimal_record();
101        let resp = render_profile(&rec, "application/ld+json").expect("json-ld renders");
102        assert_eq!(resp.content_type, "application/ld+json");
103
104        let parsed: serde_json::Value =
105            serde_json::from_slice(&resp.body).expect("body is valid JSON");
106        assert!(
107            parsed.get("@type").is_some(),
108            "JSON-LD node carries an @type"
109        );
110    }
111
112    #[test]
113    fn cbor_negotiation_produces_bytes() {
114        let rec = minimal_record();
115        let resp = render_profile(&rec, "application/cbor").expect("cbor renders");
116        assert_eq!(resp.content_type, "application/cbor");
117        assert!(!resp.body.is_empty());
118    }
119}