ocpi_kit/hub/mod.rs
1//! A roaming hub: routing, broadcast push, open routing, GET All, and version bridging.
2//!
3//! # The four routing arrangements
4//!
5//! A hub can tell which one it is looking at from the headers and the method alone, which is what
6//! [`Forwardable::scenario`] does:
7//!
8//! | The `OCPI-to-*` headers say | Method | It is |
9//! |---|---|---|
10//! | another party | any | [`Direct`](crate::transport::RoutingScenario::Direct) — relay it |
11//! | the hub itself | `GET` on a Sender interface | [`GetAllViaHub`](crate::transport::RoutingScenario::GetAllViaHub) — merge every party's objects |
12//! | the hub itself | a write on a Receiver interface | [`BroadcastPush`](crate::transport::RoutingScenario::BroadcastPush) — fan out to the opposite roles |
13//! | nothing | any | [`OpenRoutingRequest`](crate::transport::RoutingScenario::OpenRoutingRequest) — decide from the content |
14//! | the hub itself | anything else | refused: [`OcpiError::NotRoutable`](crate::transport::OcpiError::NotRoutable), a `2001` |
15//!
16//! Addressing the hub is the one ambiguous case, and two of its four combinations are not
17//! scenarios at all: a `GET` on a Receiver interface is not a Broadcast Push, because *"GET SHALL
18//! NOT be used in combination with Broadcast Push"*, and a write on a Sender interface is neither
19//! a push to the connected parties nor a read to merge. Guessing the nearest scenario would mean
20//! a hub quietly broadcasting a read, so [`Forwardable::scenario`] refuses, with the advice the
21//! specification itself gives: omit the `OCPI-to-` headers and make it an Open Routing Request.
22//!
23//! # The rules the hub must not break
24//!
25//! * **A new `X-Request-ID`, the same `X-Correlation-ID`.** [`RequestIds::forwarded`](crate::transport::RequestIds::forwarded).
26//! * **`last_updated` is never touched.** *"When OCPI Objects are sent via Hubs, the
27//! `last_updated` fields SHALL NOT be updated by the Hub."* Nothing in this module writes it.
28//! * **`GET` is never broadcast.** *"GET SHALL NOT be used in combination with Broadcast Push."*
29//! * **Configuration modules are never routed.** `credentials`, `versions` and `hubclientinfo`
30//! are platform-to-hub conversations.
31//! * **Vendor data survives.** Because every object keeps its unknown fields in
32//! [`Extensions`](crate::types::Extensions) and every `OpenEnum` keeps values it does not know,
33//! a hub built on this crate forwards an extension it has never seen without damaging it. That
34//! is what OCPI 2.3.0's extensibility chapter asks for, and it is the single most common way a
35//! hub loses data.
36//!
37//! # Version bridging
38//!
39//! A 2.2.1 CPO and a 2.3.0 eMSP talk through the hub without either of them knowing: [`Forwarder`]
40//! translates the request body into the version the receiving platform speaks, translates the
41//! response back, and appends what the crossing cost to the `status_message` so the requesting
42//! party can see it. That is [`convert`](crate::convert) doing the work and
43//! [`Lossy`](crate::convert::Lossy) doing the reporting; no object is ever handed to a party in a
44//! version it did not ask for, and nothing is dropped silently.
45//!
46//! Only the 2.2.1 ↔ 2.3.0 crossing has conversions today. A message between two versions this
47//! build cannot translate is **refused** by default rather than relayed — see [`Unbridgeable`] for
48//! the reasoning and for how to relay it anyway. [`bridge`] exposes the same classification for a
49//! hub that wants to translate an object itself.
50//!
51//! Spec: 2.3.0 §transport_and_format_message_routing, §status_codes_4xxx_hub_errors
52
53mod forwarder;
54mod routing_table;
55
56pub use forwarder::{
57 AggregatePolicy, BodyOwnerRouter, Forwardable, Forwarder, OpenRouter, Relayed, Unbridgeable, aggregate,
58};
59pub use routing_table::{ConnectedPlatform, RoutingTable};
60
61use crate::VersionNumber;
62
63/// What a hub must do to a message crossing between two OCPI versions.
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum Bridge {
67 /// Both sides speak the same version; forward the bytes unchanged.
68 ///
69 /// This is the fast path, and the one that cannot lose anything.
70 Passthrough,
71 /// The receiver speaks a newer version; upgrade.
72 Upgrade,
73 /// The receiver speaks an older version; downgrade, and report what did not fit.
74 Downgrade,
75 /// This build has no conversions between the two versions, so nothing can be translated.
76 ///
77 /// Today that is any crossing involving OCPI 2.1.1 — which has no owner fields, no routing
78 /// and no `Price`, so carrying an object across it is a decision about the deployment rather
79 /// than a translation — or a version this crate does not model at all.
80 ///
81 /// Relaying the bytes unchanged is still an option, and for two parties that understand each
82 /// other by some arrangement outside this crate it is the right one; the decision belongs to
83 /// the operator, so [`Forwarder`] takes it as [`Unbridgeable`] rather than assuming.
84 Unsupported,
85}
86
87/// What the hub must do to carry a message from `sender` to `receiver`.
88///
89/// ```
90/// use ocpi_kit::hub::{bridge, Bridge};
91/// use ocpi_kit::VersionNumber;
92///
93/// assert_eq!(bridge(&VersionNumber::V2_3_0, &VersionNumber::V2_3_0), Bridge::Passthrough);
94/// assert_eq!(bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0), Bridge::Upgrade);
95/// assert_eq!(bridge(&VersionNumber::V2_3_0, &VersionNumber::V2_2_1), Bridge::Downgrade);
96/// assert_eq!(bridge(&VersionNumber::V2_3_0, &"3.0".into()), Bridge::Unsupported);
97/// ```
98#[must_use]
99pub fn bridge(sender: &VersionNumber, receiver: &VersionNumber) -> Bridge {
100 if sender == receiver {
101 return Bridge::Passthrough;
102 }
103 // Whether the *models* exist is not the question; whether the *conversions* do is. A build
104 // with `v2_1_1` on models 2.1.1 perfectly well and still cannot carry an object out of it.
105 if !crate::convert::wire::bridgeable(sender, receiver) {
106 return Bridge::Unsupported;
107 }
108 if receiver.release_rank() > sender.release_rank() { Bridge::Upgrade } else { Bridge::Downgrade }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn the_same_version_is_a_passthrough() {
117 assert_eq!(bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_2_1), Bridge::Passthrough);
118 // Even for a version this build does not model: two 3.0 parties can still talk.
119 let future: VersionNumber = "3.0".into();
120 assert_eq!(bridge(&future, &future), Bridge::Passthrough);
121 }
122
123 #[test]
124 fn direction_follows_the_release_order() {
125 assert_eq!(bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0), Bridge::Upgrade);
126 assert_eq!(bridge(&VersionNumber::V2_3_0, &VersionNumber::V2_2_1), Bridge::Downgrade);
127 }
128
129 #[test]
130 fn a_crossing_with_no_conversions_is_surfaced_rather_than_guessed_at() {
131 assert_eq!(bridge(&"3.0".into(), &VersionNumber::V2_3_0), Bridge::Unsupported);
132 assert_eq!(bridge(&VersionNumber::V2_0, &VersionNumber::V2_3_0), Bridge::Unsupported);
133 // 2.1.1 is modelled by this crate and still has no conversions: claiming an `Upgrade`
134 // here would promise a translation that does not exist.
135 assert_eq!(bridge(&VersionNumber::V2_1_1, &VersionNumber::V2_3_0), Bridge::Unsupported);
136 assert_eq!(bridge(&VersionNumber::V2_3_0, &VersionNumber::V2_1_1), Bridge::Unsupported);
137 }
138}