Skip to main content

ocpi_kit/transport/
routing.rs

1//! Message routing: the four `OCPI-*` headers and the scenarios that decide what goes in them.
2//!
3//! The spec gives five tables of who goes in `to` and who goes in `from`, one per scenario, and
4//! getting them wrong is the classic hub bug. [`RoutingScenario`] encodes all five, so an
5//! integrator picks a scenario rather than filling headers by hand.
6//!
7//! Spec: 2.3.0 §transport_and_format_message_routing
8
9use core::fmt;
10
11use http::{HeaderMap, HeaderValue};
12
13use crate::ModuleId;
14use crate::types::PartyRef;
15
16use super::headers::{
17    OCPI_FROM_COUNTRY_CODE, OCPI_FROM_PARTY_ID, OCPI_TO_COUNTRY_CODE, OCPI_TO_PARTY_ID, header_party,
18};
19
20/// The `OCPI-to-*` and `OCPI-from-*` headers of one message.
21///
22/// > *When implementing OCPI these four headers SHALL be implemented for any request/response
23/// > to/from a Functional Module. This does not mean they have to be present in all request.*
24///
25/// `to` is `None` for an [Open Routing Request](RoutingScenario::OpenRoutingRequest), where the
26/// hub decides the destination from the content.
27///
28/// Spec: 2.3.0 §transport_and_format_message_routing
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct RoutingHeaders {
31    /// The party this message is to be sent to, absent for an Open Routing Request.
32    pub to: Option<PartyRef>,
33    /// The party this message is sent from.
34    pub from: PartyRef,
35}
36
37impl RoutingHeaders {
38    /// Headers addressed from `from` to `to`.
39    #[must_use]
40    pub fn new(from: PartyRef, to: PartyRef) -> Self {
41        Self { to: Some(to), from }
42    }
43
44    /// Headers with no `to`, for an Open Routing Request.
45    ///
46    /// > *For an Open Routing Request, the TO headers in the request from the requesting party to
47    /// > the Hub MUST be omitted.*
48    #[must_use]
49    pub fn open(from: PartyRef) -> Self {
50        Self { to: None, from }
51    }
52
53    /// The headers of the response to this request, which swap `to` and `from`.
54    ///
55    /// For an Open Routing Request the responder is the receiving party, which the requester did
56    /// not know; pass it as `responder`.
57    #[must_use]
58    pub fn response_from(&self, responder: PartyRef) -> Self {
59        Self { to: Some(self.from.clone()), from: responder }
60    }
61
62    /// Reads the routing headers from a header map.
63    ///
64    /// Returns `None` when the `from` pair is absent, which is either a spec violation or a
65    /// configuration module — see [`RoutingHeaders::applies_to`].
66    #[must_use]
67    pub fn from_headers(headers: &HeaderMap) -> Option<Self> {
68        Some(Self {
69            to: header_party(headers, &OCPI_TO_COUNTRY_CODE, &OCPI_TO_PARTY_ID),
70            from: header_party(headers, &OCPI_FROM_COUNTRY_CODE, &OCPI_FROM_PARTY_ID)?,
71        })
72    }
73
74    /// Writes the routing headers into a header map.
75    pub fn write_to(&self, headers: &mut HeaderMap) {
76        if let Some(to) = &self.to {
77            insert_party(headers, &OCPI_TO_COUNTRY_CODE, &OCPI_TO_PARTY_ID, to);
78        } else {
79            headers.remove(OCPI_TO_COUNTRY_CODE);
80            headers.remove(OCPI_TO_PARTY_ID);
81        }
82        insert_party(headers, &OCPI_FROM_COUNTRY_CODE, &OCPI_FROM_PARTY_ID, &self.from);
83    }
84
85    /// Whether these headers belong on a request to `module`.
86    ///
87    /// > *The requests/responses to/from Configuration Modules: Credentials, Versions and Hub
88    /// > Client Info are not to be routed, and are for Platform-to-Platform or Platform-to-Hub
89    /// > communication. Thus routing headers SHALL NOT be used with these modules.*
90    #[must_use]
91    pub fn applies_to(module: &ModuleId) -> bool {
92        module.is_functional()
93    }
94}
95
96fn insert_party(
97    headers: &mut HeaderMap,
98    country_header: &http::HeaderName,
99    party_header: &http::HeaderName,
100    party: &PartyRef,
101) {
102    if let Ok(v) = HeaderValue::from_str(party.country_code.as_str()) {
103        headers.insert(country_header.clone(), v);
104    }
105    if let Ok(v) = HeaderValue::from_str(party.party_id.as_str()) {
106        headers.insert(party_header.clone(), v);
107    }
108}
109
110impl fmt::Display for RoutingHeaders {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match &self.to {
113            Some(to) => write!(f, "{} -> {to}", self.from),
114            None => write!(f, "{} -> (open)", self.from),
115        }
116    }
117}
118
119/// One of the five routing arrangements the specification tabulates.
120///
121/// Each variant knows how to fill the headers for both the request and the response, in both
122/// directions across a hub. This is the whole of §transport_and_format_message_routing's
123/// "Overview of required/optional routing headers for different scenarios" in one type.
124///
125/// ```
126/// use ocpi_kit::transport::RoutingScenario;
127/// use ocpi_kit::types::PartyRef;
128///
129/// let cpo = PartyRef::new("NL", "TNM").unwrap();
130/// let msp = PartyRef::new("DE", "ABC").unwrap();
131/// let hub = PartyRef::new("NL", "HUB").unwrap();
132///
133/// // A broadcast push addresses the hub, not the eventual receivers.
134/// let scenario = RoutingScenario::BroadcastPush { hub: hub.clone() };
135/// let request = scenario.request_headers(&cpo, None);
136/// assert_eq!(request.to, Some(hub));
137/// assert_eq!(request.from, cpo);
138/// ```
139///
140/// Spec: 2.3.0 §transport_and_format_message_routing
141#[derive(Clone, Debug, PartialEq, Eq)]
142#[non_exhaustive]
143pub enum RoutingScenario {
144    /// Requesting platform to receiving platform, directly or through a hub that just relays.
145    ///
146    /// | | TO | FROM |
147    /// |---|---|---|
148    /// | request | receiving party | requesting party |
149    /// | response | requesting party | receiving party |
150    Direct,
151
152    /// A push to every connected party with the opposite role, fanned out by the hub.
153    ///
154    /// | | TO | FROM |
155    /// |---|---|---|
156    /// | requester → hub | **hub** | requesting party |
157    /// | hub → requester | requesting party | **hub** |
158    /// | hub → receiver | receiving party | **hub** |
159    /// | receiver → hub | **hub** | receiving party |
160    ///
161    /// > *GET SHALL NOT be used in combination with Broadcast Push.*
162    BroadcastPush {
163        /// The hub that fans the message out.
164        hub: PartyRef,
165    },
166
167    /// The requester does not know the destination; the hub decides from the content.
168    ///
169    /// | | TO | FROM |
170    /// |---|---|---|
171    /// | requester → hub | *omitted* | requesting party |
172    /// | hub → receiver | receiving party | requesting party |
173    /// | receiver → hub | requesting party | receiving party |
174    /// | hub → requester | requesting party | receiving party |
175    ///
176    /// > *Open Routing Requests are possible for GET (Not GET ALL), POST, PUT, PATCH and DELETE.*
177    OpenRoutingRequest,
178
179    /// A GET on a Sender interface implemented by the hub, merging several parties' objects.
180    ///
181    /// | | TO | FROM |
182    /// |---|---|---|
183    /// | requester → hub | **hub** | requesting party |
184    /// | hub → requester | requesting party | **hub** |
185    GetAllViaHub {
186        /// The hub that merges the objects.
187        hub: PartyRef,
188    },
189}
190
191impl RoutingScenario {
192    /// The headers of the request the requesting party sends.
193    ///
194    /// `receiver` is the destination party; it is ignored by the scenarios that address the hub
195    /// or omit the `to` headers entirely, and may be `None` there.
196    #[must_use]
197    pub fn request_headers(&self, requester: &PartyRef, receiver: Option<&PartyRef>) -> RoutingHeaders {
198        match self {
199            Self::Direct => RoutingHeaders { to: receiver.cloned(), from: requester.clone() },
200            Self::BroadcastPush { hub } | Self::GetAllViaHub { hub } => {
201                RoutingHeaders { to: Some(hub.clone()), from: requester.clone() }
202            }
203            Self::OpenRoutingRequest => RoutingHeaders::open(requester.clone()),
204        }
205    }
206
207    /// The headers of the response the requesting party will receive.
208    ///
209    /// `receiver` is the party that actually answered, which the hub knows even when the
210    /// requester did not.
211    #[must_use]
212    pub fn response_headers(&self, requester: &PartyRef, receiver: Option<&PartyRef>) -> RoutingHeaders {
213        match self {
214            Self::Direct | Self::OpenRoutingRequest => RoutingHeaders {
215                to: Some(requester.clone()),
216                from: receiver.cloned().unwrap_or_else(|| requester.clone()),
217            },
218            Self::BroadcastPush { hub } | Self::GetAllViaHub { hub } => {
219                RoutingHeaders { to: Some(requester.clone()), from: hub.clone() }
220            }
221        }
222    }
223
224    /// The headers the hub puts on the request it forwards to the receiving party.
225    ///
226    /// Returns `None` for [`GetAllViaHub`](Self::GetAllViaHub), where the hub answers from its
227    /// own merged view and forwards nothing verbatim.
228    #[must_use]
229    pub fn forwarded_request_headers(
230        &self,
231        requester: &PartyRef,
232        receiver: &PartyRef,
233    ) -> Option<RoutingHeaders> {
234        match self {
235            // "Direct request | Hub to receiving platform | Receiving-party | Requesting-party"
236            Self::Direct | Self::OpenRoutingRequest => {
237                Some(RoutingHeaders::new(requester.clone(), receiver.clone()))
238            }
239            // "Broadcast request | Hub to receiving platform | Receiving-party | Hub"
240            Self::BroadcastPush { hub } => Some(RoutingHeaders::new(hub.clone(), receiver.clone())),
241            Self::GetAllViaHub { .. } => None,
242        }
243    }
244
245    /// Whether a GET may use this scenario.
246    ///
247    /// > *GET SHALL NOT be used in combination with Broadcast Push. If the requesting party wants
248    /// > to GET information of which it does not know the receiving party, an Open Routing
249    /// > Request MUST be used.*
250    #[must_use]
251    pub const fn allows_get(&self) -> bool {
252        !matches!(self, Self::BroadcastPush { .. })
253    }
254
255    /// Whether a write (POST, PUT, PATCH, DELETE) may use this scenario.
256    ///
257    /// A GET All is by definition a read.
258    #[must_use]
259    pub const fn allows_write(&self) -> bool {
260        !matches!(self, Self::GetAllViaHub { .. })
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn cpo() -> PartyRef {
269        PartyRef::new("NL", "TNM").unwrap()
270    }
271    fn msp() -> PartyRef {
272        PartyRef::new("DE", "ABC").unwrap()
273    }
274    fn hub() -> PartyRef {
275        PartyRef::new("NL", "HUB").unwrap()
276    }
277
278    #[test]
279    fn direct_request_and_response_swap_the_parties() {
280        let s = RoutingScenario::Direct;
281        let req = s.request_headers(&cpo(), Some(&msp()));
282        assert_eq!(req.to, Some(msp()));
283        assert_eq!(req.from, cpo());
284        let resp = s.response_headers(&cpo(), Some(&msp()));
285        assert_eq!(resp.to, Some(cpo()));
286        assert_eq!(resp.from, msp());
287    }
288
289    #[test]
290    fn broadcast_push_addresses_the_hub_then_the_hub_speaks_for_itself() {
291        let s = RoutingScenario::BroadcastPush { hub: hub() };
292        // "Broadcast request | Requesting platform to Hub | Hub | Requesting-party"
293        let req = s.request_headers(&cpo(), None);
294        assert_eq!((req.to, req.from), (Some(hub()), cpo()));
295        // "Broadcast response | Hub to requesting platform | Requesting-party | Hub"
296        let resp = s.response_headers(&cpo(), None);
297        assert_eq!((resp.to, resp.from), (Some(cpo()), hub()));
298        // "Broadcast request | Hub to receiving platform | Receiving-party | Hub"
299        let fwd = s.forwarded_request_headers(&cpo(), &msp()).unwrap();
300        assert_eq!((fwd.to, fwd.from), (Some(msp()), hub()));
301        assert!(!s.allows_get(), "GET SHALL NOT be used with Broadcast Push");
302    }
303
304    #[test]
305    fn open_routing_omits_the_to_headers_only_on_the_first_hop() {
306        let s = RoutingScenario::OpenRoutingRequest;
307        let req = s.request_headers(&cpo(), None);
308        assert_eq!(req.to, None, "the TO headers MUST be omitted");
309        // "Open request | Hub to receiving platform | Receiving-party | Requesting-party"
310        let fwd = s.forwarded_request_headers(&cpo(), &msp()).unwrap();
311        assert_eq!((fwd.to, fwd.from), (Some(msp()), cpo()));
312        assert!(s.allows_get() && s.allows_write());
313    }
314
315    #[test]
316    fn get_all_via_hub_is_answered_by_the_hub_itself() {
317        let s = RoutingScenario::GetAllViaHub { hub: hub() };
318        let req = s.request_headers(&msp(), None);
319        assert_eq!((req.to, req.from), (Some(hub()), msp()));
320        let resp = s.response_headers(&msp(), None);
321        assert_eq!((resp.to, resp.from), (Some(msp()), hub()));
322        assert_eq!(s.forwarded_request_headers(&msp(), &cpo()), None);
323        assert!(!s.allows_write(), "a GET All is a read");
324    }
325
326    #[test]
327    fn configuration_modules_are_never_routed() {
328        assert!(!RoutingHeaders::applies_to(&ModuleId::Credentials));
329        assert!(!RoutingHeaders::applies_to(&ModuleId::Versions));
330        assert!(!RoutingHeaders::applies_to(&ModuleId::HubClientInfo));
331        assert!(RoutingHeaders::applies_to(&ModuleId::Locations));
332    }
333
334    #[test]
335    fn headers_round_trip_and_an_open_request_has_no_to() {
336        let mut headers = HeaderMap::new();
337        RoutingHeaders::new(cpo(), msp()).write_to(&mut headers);
338        assert_eq!(RoutingHeaders::from_headers(&headers), Some(RoutingHeaders::new(cpo(), msp())));
339
340        RoutingHeaders::open(cpo()).write_to(&mut headers);
341        let parsed = RoutingHeaders::from_headers(&headers).unwrap();
342        assert_eq!(parsed.to, None, "writing an open request clears any previous TO headers");
343        assert_eq!(parsed.from, cpo());
344    }
345}