Skip to main content

ocpi_kit/transport/
headers.rs

1//! The HTTP headers OCPI defines, as typed values.
2//!
3//! > *NOTE: HTTP header names are case-insensitive*
4//!
5//! Every name here is a [`http::HeaderName`] constant, so case is handled by the `http` crate and
6//! never by string comparison.
7
8use core::fmt;
9
10use http::{HeaderMap, HeaderName, HeaderValue};
11
12use crate::types::{PartyRef, Url};
13
14/// `Authorization` — the credentials token. See [`CredentialsToken`](super::CredentialsToken).
15pub const AUTHORIZATION: HeaderName = HeaderName::from_static("authorization");
16
17/// `X-Request-ID` — unique per request; the response repeats it.
18///
19/// > *Every request SHALL contain a unique request ID, the response to this request SHALL contain
20/// > the same ID.*
21pub const X_REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id");
22
23/// `X-Correlation-ID` — unique per logical exchange; survives hub forwarding.
24///
25/// > *Every request/response SHALL contain a unique correlation ID, every response to this
26/// > request SHALL contain the same ID.*
27pub const X_CORRELATION_ID: HeaderName = HeaderName::from_static("x-correlation-id");
28
29/// `OCPI-to-party-id` — party ID of the connected party this message is to be sent to.
30pub const OCPI_TO_PARTY_ID: HeaderName = HeaderName::from_static("ocpi-to-party-id");
31/// `OCPI-to-country-code` — country code of the party this message is to be sent to.
32pub const OCPI_TO_COUNTRY_CODE: HeaderName = HeaderName::from_static("ocpi-to-country-code");
33/// `OCPI-from-party-id` — party ID of the party this message is sent from.
34pub const OCPI_FROM_PARTY_ID: HeaderName = HeaderName::from_static("ocpi-from-party-id");
35/// `OCPI-from-country-code` — country code of the party this message is sent from.
36pub const OCPI_FROM_COUNTRY_CODE: HeaderName = HeaderName::from_static("ocpi-from-country-code");
37
38/// `Link` — the link to the next page of a paginated GET.
39pub const LINK: HeaderName = HeaderName::from_static("link");
40/// `X-Total-Count` — the total number of objects matching a paginated query.
41pub const X_TOTAL_COUNT: HeaderName = HeaderName::from_static("x-total-count");
42/// `X-Limit` — the maximum number of objects the server will return per page.
43pub const X_LIMIT: HeaderName = HeaderName::from_static("x-limit");
44
45/// `Location` — where a newly POSTed CDR can be retrieved.
46pub const LOCATION: HeaderName = HeaderName::from_static("location");
47
48/// The `Content-Type` OCPI bodies use.
49///
50/// > *The HTTP header: Content-Type SHALL be set to `application/json` for any request that
51/// > contains a message body: POST, PUT and PATCH.*
52pub const APPLICATION_JSON: &str = "application/json";
53
54/// The pair of IDs every OCPI request and response carries.
55///
56/// > *For debugging issues, OCPI implementations are required to include unique IDs via HTTP
57/// > headers in every request/response.*
58///
59/// The distinction matters at a hub:
60///
61/// > *When a Hub forwards a request to a party, the request to this party SHALL contain a **new**
62/// > unique value in the X-Request-ID HTTP header, not a copy … the request SHALL contain the
63/// > **same** X-Correlation-ID HTTP header.*
64///
65/// [`RequestIds::forwarded`] does exactly that, so a hub cannot get it backwards.
66///
67/// ```
68/// use ocpi_kit::transport::RequestIds;
69///
70/// let incoming = RequestIds::generate();
71/// let forwarded = incoming.forwarded();
72/// assert_ne!(incoming.request_id, forwarded.request_id);
73/// assert_eq!(incoming.correlation_id, forwarded.correlation_id);
74/// ```
75///
76/// Spec: 2.3.0 §transport_and_format_unique_messageg_ids
77#[derive(Clone, Debug, PartialEq, Eq, Hash)]
78pub struct RequestIds {
79    /// Unique per request hop.
80    pub request_id: String,
81    /// Unique per logical exchange, preserved across hub hops.
82    pub correlation_id: String,
83}
84
85impl RequestIds {
86    /// A fresh pair of UUIDs.
87    ///
88    /// > *It is advised to used GUID/UUID as values for X-Request-ID and X-Correlation-ID.*
89    #[must_use]
90    pub fn generate() -> Self {
91        Self {
92            request_id: uuid::Uuid::new_v4().to_string(),
93            correlation_id: uuid::Uuid::new_v4().to_string(),
94        }
95    }
96
97    /// The IDs a hub must use when forwarding this request: a new request ID, the same
98    /// correlation ID.
99    #[must_use]
100    pub fn forwarded(&self) -> Self {
101        Self { request_id: uuid::Uuid::new_v4().to_string(), correlation_id: self.correlation_id.clone() }
102    }
103
104    /// Reads the pair from a header map, generating whichever half is missing.
105    ///
106    /// A missing ID is a spec violation on the peer's side, but refusing the request over it
107    /// would be worse than carrying on with a generated one; the server echoes what it used.
108    #[must_use]
109    pub fn from_headers_or_generate(headers: &HeaderMap) -> Self {
110        Self {
111            request_id: header_str(headers, &X_REQUEST_ID)
112                .map_or_else(|| uuid::Uuid::new_v4().to_string(), ToOwned::to_owned),
113            correlation_id: header_str(headers, &X_CORRELATION_ID)
114                .map_or_else(|| uuid::Uuid::new_v4().to_string(), ToOwned::to_owned),
115        }
116    }
117
118    /// Reads the pair from a header map, or `None` if either is absent.
119    #[must_use]
120    pub fn from_headers(headers: &HeaderMap) -> Option<Self> {
121        Some(Self {
122            request_id: header_str(headers, &X_REQUEST_ID)?.to_owned(),
123            correlation_id: header_str(headers, &X_CORRELATION_ID)?.to_owned(),
124        })
125    }
126
127    /// Writes both headers into `headers`, replacing any existing values.
128    pub fn write_to(&self, headers: &mut HeaderMap) {
129        if let Ok(v) = HeaderValue::from_str(&self.request_id) {
130            headers.insert(X_REQUEST_ID, v);
131        }
132        if let Ok(v) = HeaderValue::from_str(&self.correlation_id) {
133            headers.insert(X_CORRELATION_ID, v);
134        }
135    }
136}
137
138impl fmt::Display for RequestIds {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        write!(f, "request={} correlation={}", self.request_id, self.correlation_id)
141    }
142}
143
144/// Reads a header as a string, ignoring values that are not valid UTF-8.
145#[must_use]
146pub fn header_str<'a>(headers: &'a HeaderMap, name: &HeaderName) -> Option<&'a str> {
147    headers.get(name)?.to_str().ok()
148}
149
150/// Reads a header as an integer.
151#[must_use]
152pub fn header_u64(headers: &HeaderMap, name: &HeaderName) -> Option<u64> {
153    header_str(headers, name)?.trim().parse().ok()
154}
155
156/// Reads the `country_code`/`party_id` pair under `country_header` and `party_header`.
157///
158/// Returns `None` unless both are present and well-formed, which is what an
159/// [Open Routing Request](super::routing) looks like on the `to` side.
160#[must_use]
161pub fn header_party(
162    headers: &HeaderMap,
163    country_header: &HeaderName,
164    party_header: &HeaderName,
165) -> Option<PartyRef> {
166    let country = header_str(headers, country_header)?;
167    let party = header_str(headers, party_header)?;
168    PartyRef::new(country, party).ok()
169}
170
171/// Builds a `Link: <url>; rel="next"` header value.
172///
173/// Spec: 2.3.0 §transport_and_format_pagination_examples
174#[must_use]
175pub fn link_next(url: &Url) -> String {
176    format!("<{}>; rel=\"next\"", url.as_str())
177}
178
179/// Extracts the `rel="next"` URL from a `Link` header value.
180///
181/// Handles a header carrying several links, and tolerates the unquoted `rel=next` form that some
182/// implementations emit.
183///
184/// ```
185/// use ocpi_kit::transport::parse_link_next;
186///
187/// let header = r#"<https://e.com/cdrs/?offset=150&limit=50>; rel="next""#;
188/// assert_eq!(parse_link_next(header).unwrap().as_str(), "https://e.com/cdrs/?offset=150&limit=50");
189/// assert!(parse_link_next(r#"<https://e.com/a>; rel="prev""#).is_none());
190/// ```
191#[must_use]
192pub fn parse_link_next(value: &str) -> Option<Url> {
193    for entry in split_link_entries(value) {
194        let mut parts = entry.split(';');
195        let target = parts.next()?.trim();
196        let url = target.strip_prefix('<')?.strip_suffix('>')?;
197        for param in parts {
198            let param = param.trim();
199            let Some((key, val)) = param.split_once('=') else { continue };
200            if key.trim().eq_ignore_ascii_case("rel") {
201                let val = val.trim().trim_matches('"');
202                if val.eq_ignore_ascii_case("next") {
203                    return Some(Url::new_lenient(url));
204                }
205            }
206        }
207    }
208    None
209}
210
211/// Splits a `Link` header on the commas that separate entries, ignoring commas inside `<...>`.
212fn split_link_entries(value: &str) -> Vec<&str> {
213    let mut out = Vec::new();
214    let mut depth = 0usize;
215    let mut start = 0usize;
216    for (i, ch) in value.char_indices() {
217        match ch {
218            '<' => depth += 1,
219            '>' => depth = depth.saturating_sub(1),
220            ',' if depth == 0 => {
221                out.push(value[start..i].trim());
222                start = i + 1;
223            }
224            _ => {}
225        }
226    }
227    out.push(value[start..].trim());
228    out
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn a_hub_renews_the_request_id_and_keeps_the_correlation_id() {
237        let incoming = RequestIds::generate();
238        let forwarded = incoming.forwarded();
239        assert_ne!(incoming.request_id, forwarded.request_id);
240        assert_eq!(incoming.correlation_id, forwarded.correlation_id);
241    }
242
243    #[test]
244    fn headers_round_trip_through_a_header_map() {
245        let ids = RequestIds::generate();
246        let mut headers = HeaderMap::new();
247        ids.write_to(&mut headers);
248        assert_eq!(RequestIds::from_headers(&headers), Some(ids));
249    }
250
251    #[test]
252    fn missing_ids_are_generated_rather_than_refused() {
253        let ids = RequestIds::from_headers_or_generate(&HeaderMap::new());
254        assert!(!ids.request_id.is_empty() && !ids.correlation_id.is_empty());
255        assert_ne!(ids.request_id, ids.correlation_id);
256    }
257
258    #[test]
259    fn link_headers_round_trip_and_tolerate_the_unquoted_form() {
260        let url = Url::new("https://www.server.com/ocpi/cpo/2.3.0/cdrs/?offset=150&limit=50").unwrap();
261        let header = link_next(&url);
262        assert_eq!(
263            header,
264            r#"<https://www.server.com/ocpi/cpo/2.3.0/cdrs/?offset=150&limit=50>; rel="next""#
265        );
266        assert_eq!(parse_link_next(&header), Some(url.clone()));
267        assert_eq!(
268            parse_link_next(r"<https://e.com/a>; rel=next"),
269            Some(Url::new_lenient("https://e.com/a"))
270        );
271    }
272
273    #[test]
274    fn link_parsing_picks_next_out_of_several_entries() {
275        let header = r#"<https://e.com/a?x=1,2>; rel="prev", <https://e.com/b>; rel="next""#;
276        assert_eq!(parse_link_next(header), Some(Url::new_lenient("https://e.com/b")));
277        assert_eq!(parse_link_next("garbage"), None);
278    }
279
280    #[test]
281    fn party_headers_need_both_halves() {
282        let mut headers = HeaderMap::new();
283        headers.insert(OCPI_TO_COUNTRY_CODE, HeaderValue::from_static("NL"));
284        assert_eq!(header_party(&headers, &OCPI_TO_COUNTRY_CODE, &OCPI_TO_PARTY_ID), None);
285        headers.insert(OCPI_TO_PARTY_ID, HeaderValue::from_static("TNM"));
286        assert_eq!(
287            header_party(&headers, &OCPI_TO_COUNTRY_CODE, &OCPI_TO_PARTY_ID),
288            Some(PartyRef::new("NL", "TNM").unwrap())
289        );
290    }
291}