ocpi_kit/version.rs
1//! Protocol versions, module identifiers and interface roles.
2//!
3//! These three enums are version-neutral on purpose: version *discovery* has to be able to name
4//! a version or a module that this crate does not implement — that is what the `/versions`
5//! endpoint is for — so all three are `OpenEnum`-shaped and keep values they do not know.
6
7use crate::ocpi_enum;
8use crate::ocpi_open_enum;
9
10ocpi_open_enum! {
11 /// A version of the OCPI protocol, as it appears in `/versions` and in version details.
12 ///
13 /// Listed as an `OpenEnum` so that discovery against a peer that speaks a version this crate
14 /// has never heard of — a future 3.0, or a version the peer invented — lists it and moves on
15 /// instead of failing. [`VersionNumber::is_supported`] says which ones this build can
16 /// actually talk.
17 ///
18 /// Spec: 2.3.0 §version_information_endpoint_versionnumber_enum
19 pub enum VersionNumber {
20 /// OCPI version 2.0. Not modelled by this crate; recognised so discovery can skip it.
21 V2_0 = "2.0",
22 /// OCPI version 2.1. **Deprecated by the spec**: *"do not use, use 2.1.1 instead"*.
23 V2_1 = "2.1",
24 /// OCPI version 2.1.1.
25 V2_1_1 = "2.1.1",
26 /// OCPI version 2.2. **Deprecated by the spec**: *"do not use, use 2.2.1 instead"*.
27 V2_2 = "2.2",
28 /// OCPI version 2.2.1. Still the most widely deployed version.
29 V2_2_1 = "2.2.1",
30 /// OCPI version 2.3.0. The canonical model of this crate.
31 V2_3_0 = "2.3.0",
32 }
33}
34
35impl VersionNumber {
36 /// Whether this build of `ocpi-kit` has a wire model for this version.
37 ///
38 /// Depends on the enabled cargo features.
39 #[must_use]
40 pub fn is_supported(&self) -> bool {
41 match self {
42 Self::V2_3_0 => cfg!(feature = "v2_3_0"),
43 Self::V2_2_1 => cfg!(feature = "v2_2_1"),
44 Self::V2_1_1 => cfg!(feature = "v2_1_1"),
45 Self::V2_0 | Self::V2_1 | Self::V2_2 | Self::Custom(_) => false,
46 }
47 }
48
49 /// Whether the specification marks this version as deprecated.
50 ///
51 /// Spec: 2.3.0 §version_information_endpoint_versionnumber_enum
52 #[must_use]
53 pub const fn is_deprecated(&self) -> bool {
54 matches!(self, Self::V2_1 | Self::V2_2)
55 }
56
57 /// Every version this build can talk, newest first.
58 ///
59 /// This is the preference order used when negotiating with a peer.
60 #[must_use]
61 #[allow(unused_mut, clippy::vec_init_then_push)]
62 pub fn supported() -> Vec<Self> {
63 let mut out = Vec::new();
64 #[cfg(feature = "v2_3_0")]
65 out.push(Self::V2_3_0);
66 #[cfg(feature = "v2_2_1")]
67 out.push(Self::V2_2_1);
68 #[cfg(feature = "v2_1_1")]
69 out.push(Self::V2_1_1);
70 out
71 }
72
73 /// Where this version sits in the release order, oldest first.
74 ///
75 /// A version this crate does not know ranks last. This is deliberately *not* the [`Ord`]
76 /// impl, which sorts by wire value so that `VersionNumber` behaves predictably as a map key
77 /// — and note that sorting by wire value is wrong for versions, since `"2.10" < "2.2"`
78 /// lexically. Use [`VersionNumber::cmp_by_release`] to order them.
79 #[must_use]
80 pub const fn release_rank(&self) -> u8 {
81 match self {
82 Self::V2_0 => 0,
83 Self::V2_1 => 1,
84 Self::V2_1_1 => 2,
85 Self::V2_2 => 3,
86 Self::V2_2_1 => 4,
87 Self::V2_3_0 => 5,
88 Self::Custom(_) => u8::MAX,
89 }
90 }
91
92 /// Orders two versions by release, oldest first; unknown versions sort last, by their text.
93 ///
94 /// ```
95 /// use ocpi_kit::VersionNumber;
96 /// let mut vs = vec![VersionNumber::V2_3_0, VersionNumber::V2_1_1, VersionNumber::V2_2_1];
97 /// vs.sort_by(VersionNumber::cmp_by_release);
98 /// assert_eq!(vs.first(), Some(&VersionNumber::V2_1_1));
99 /// ```
100 #[must_use]
101 pub fn cmp_by_release(&self, other: &Self) -> core::cmp::Ordering {
102 self.release_rank().cmp(&other.release_rank()).then_with(|| self.as_str().cmp(other.as_str()))
103 }
104
105 /// Whether the version uses the `OCPI-to-*`/`OCPI-from-*` message routing headers.
106 ///
107 /// Routing headers were introduced in OCPI 2.2; 2.1.1 and older have no such thing.
108 ///
109 /// Spec: 2.3.0 §transport_and_format_message_routing
110 #[must_use]
111 pub fn has_routing_headers(&self) -> bool {
112 matches!(self.release_rank(), 3..=5)
113 }
114
115 /// Whether the version splits `Credentials` into a list of `roles`.
116 ///
117 /// OCPI 2.1.1 puts `party_id`, `country_code` and `business_details` directly on the
118 /// credentials object; 2.2 and later moved them into `CredentialsRole` entries.
119 #[must_use]
120 pub fn has_credentials_roles(&self) -> bool {
121 matches!(self.release_rank(), 3..=5)
122 }
123}
124
125ocpi_enum! {
126 /// Which side of a module's data flow an endpoint implements.
127 ///
128 /// > *SENDER: Interface implemented by the owner of data, so the Receiver can Pull
129 /// > information from the data Sender/owner.*
130 /// >
131 /// > *RECEIVER: Interface implemented by the receiver of data, so the Sender/owner can Push
132 /// > information to the Receiver.*
133 ///
134 /// Spec: 2.3.0 §version_information_endpoint_interface_role_enum
135 pub enum InterfaceRole {
136 /// The data owner's interface, which the other party pulls from.
137 Sender = "SENDER",
138 /// The interface the data owner pushes to.
139 Receiver = "RECEIVER",
140 }
141}
142
143impl InterfaceRole {
144 /// The other side of the same module.
145 #[must_use]
146 pub const fn opposite(self) -> Self {
147 match self {
148 Self::Sender => Self::Receiver,
149 Self::Receiver => Self::Sender,
150 }
151 }
152}
153
154ocpi_open_enum! {
155 /// The identifier of an OCPI module, as used in `Endpoint.identifier`.
156 ///
157 /// > *Parties are allowed to create custom modules or customized versions of the existing
158 /// > modules. To do so, the ModuleID enum can be extended with additional custom moduleIDs.
159 /// > … It is advised to use a prefix (e.g. country-code + party-id) for any custom moduleID.*
160 ///
161 /// So `ModuleId::Custom("nltnm-tokens")` is a legitimate value, not an error.
162 ///
163 /// Spec: 2.3.0 §version_information_endpoint_moduleid_enum
164 pub enum ModuleId {
165 /// Charge Detail Records. Sender: CPO.
166 Cdrs = "cdrs",
167 /// Smart charging profiles.
168 ChargingProfiles = "chargingprofiles",
169 /// Remote commands: start, stop, reserve, unlock.
170 Commands = "commands",
171 /// Credentials and registration. Required for all implementations.
172 Credentials = "credentials",
173 /// Hub client info: which parties a hub has connected.
174 HubClientInfo = "hubclientinfo",
175 /// Charging locations, EVSEs and connectors. Sender: CPO.
176 Locations = "locations",
177 /// Payment terminals and financial advice confirmations. Sender: PTP.
178 ///
179 /// Added to the protocol in OCPI 2.3.0.
180 ///
181 /// **Spec erratum.** The Payments module chapter gives *"Module Identifier: `payments`"*,
182 /// but the module is missing from the `ModuleID` table in
183 /// §version_information_endpoint_moduleid_enum of the same release. The chapter is
184 /// normative for its own identifier, so `payments` is treated as a known module here.
185 Payments = "payments",
186 /// Charging sessions. Sender: CPO.
187 Sessions = "sessions",
188 /// Tariffs. Sender: CPO.
189 Tariffs = "tariffs",
190 /// Driver tokens and real-time authorization. Sender: eMSP.
191 Tokens = "tokens",
192 /// Versions and version details. Every implementation has this, but it is not listed as
193 /// an endpoint inside version details.
194 Versions = "versions",
195 /// Bookings, from the OCPI 2.3.0 `bookings` release branch.
196 ///
197 /// **Spec quirk.** The identifier really is `Booking`: singular, and the only module ID
198 /// in OCPI that is not lower-case. Every other module uses a lower-case plural. It is
199 /// also absent from that branch's `ModuleID` table. Peers that guessed `bookings`
200 /// exist; see [`ModuleId::matches`].
201 Booking = "Booking",
202 /// Invoice reconciliation, from the OCPI 2.3.0 `payments` release branch.
203 InvoiceReconciliation = "invoicereconciliation",
204 }
205}
206
207impl ModuleId {
208 /// Whether this module carries the message routing headers.
209 ///
210 /// > *Only requests/responses from Function Modules … SHALL be routed, so need the routing
211 /// > headers. The requests/responses to/from Configuration Modules: Credentials, Versions and
212 /// > Hub Client Info are not to be routed … Thus routing headers SHALL NOT be used with these
213 /// > modules.*
214 ///
215 /// A custom module is assumed to be functional, since that is what a party would define one
216 /// for.
217 ///
218 /// Spec: 2.3.0 §transport_and_format_message_routing
219 #[must_use]
220 pub const fn is_functional(&self) -> bool {
221 !self.is_configuration()
222 }
223
224 /// Whether this is one of the three configuration modules, which are never routed.
225 ///
226 /// Spec: 2.3.0 §transport_and_format_message_routing
227 #[must_use]
228 pub const fn is_configuration(&self) -> bool {
229 matches!(self, Self::Credentials | Self::Versions | Self::HubClientInfo)
230 }
231
232 /// Compares module identifiers the way a tolerant peer would.
233 ///
234 /// Two accommodations, both narrow and both deliberate:
235 ///
236 /// * ASCII case is ignored. Module identifiers are lower-case everywhere except `Booking`, so
237 /// a peer that lower-cased the lot is still understood.
238 /// * `Booking` and `bookings` are treated as the same module. The Bookings chapter gives
239 /// *"Module Identifier: `Booking`"* — singular, and the only mixed-case identifier in OCPI
240 /// — while implementations that assumed the lower-case plural exist. Getting this wrong
241 /// means silently not discovering the module, which is worse than accepting both.
242 ///
243 /// Use this when reading a peer's version details; use `==` when the exact value matters.
244 #[must_use]
245 pub fn matches(&self, other: &Self) -> bool {
246 if self.as_str().eq_ignore_ascii_case(other.as_str()) {
247 return true;
248 }
249 let is_bookings = |m: &Self| {
250 m.as_str().eq_ignore_ascii_case("Booking") || m.as_str().eq_ignore_ascii_case("bookings")
251 };
252 is_bookings(self) && is_bookings(other)
253 }
254
255 /// Whether this build of `ocpi-kit` has a wire model for this module.
256 #[must_use]
257 pub fn is_supported(&self) -> bool {
258 // Not a `matches!`: under some feature sets the `cfg!`s collapse to the same constant
259 // and under others they do not, so the arms must stay separate.
260 #[allow(clippy::match_like_matches_macro)]
261 match self {
262 Self::Booking => cfg!(feature = "bookings"),
263 Self::InvoiceReconciliation => cfg!(feature = "invoice-reconciliation"),
264 Self::Custom(_) => false,
265 _ => cfg!(any(feature = "v2_3_0", feature = "v2_2_1", feature = "v2_1_1")),
266 }
267 }
268
269 /// Whether the module exists in `version`.
270 ///
271 /// Spec: 2.2.1 and 2.3.0 §version_information_endpoint_moduleid_enum; 2.1.1
272 /// §version_information_endpoint (which has neither `hubclientinfo` nor `chargingprofiles`).
273 #[must_use]
274 pub fn exists_in(&self, version: &VersionNumber) -> bool {
275 match self {
276 // A `Custom` module is by definition not in any published table, so it is left to
277 // the peer that advertised it: assume it exists wherever it was offered.
278 Self::Credentials
279 | Self::Versions
280 | Self::Locations
281 | Self::Sessions
282 | Self::Cdrs
283 | Self::Tariffs
284 | Self::Tokens
285 | Self::Commands
286 | Self::Custom(_) => true,
287 Self::HubClientInfo | Self::ChargingProfiles => version.release_rank() >= 3,
288 Self::Payments | Self::Booking | Self::InvoiceReconciliation => *version == VersionNumber::V2_3_0,
289 }
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn unknown_versions_survive_discovery() {
299 let v: VersionNumber = "3.0".into();
300 assert!(!v.is_known());
301 assert!(!v.is_supported());
302 assert_eq!(v.as_str(), "3.0");
303 }
304
305 #[test]
306 fn release_order_sorts_by_release_not_by_text() {
307 let mut all =
308 vec![VersionNumber::V2_3_0, VersionNumber::V2_1_1, VersionNumber::V2_2_1, VersionNumber::V2_0];
309 all.sort_by(VersionNumber::cmp_by_release);
310 assert_eq!(
311 all,
312 vec![VersionNumber::V2_0, VersionNumber::V2_1_1, VersionNumber::V2_2_1, VersionNumber::V2_3_0]
313 );
314 assert!(VersionNumber::V2_3_0.release_rank() > VersionNumber::V2_2_1.release_rank());
315 // An unknown version sorts after every known one.
316 let future: VersionNumber = "3.0".into();
317 assert_eq!(future.cmp_by_release(&VersionNumber::V2_3_0), core::cmp::Ordering::Greater);
318 }
319
320 #[test]
321 fn configuration_modules_are_never_routed() {
322 for m in [ModuleId::Credentials, ModuleId::Versions, ModuleId::HubClientInfo] {
323 assert!(m.is_configuration() && !m.is_functional(), "{m} must not be routed");
324 }
325 for m in [ModuleId::Locations, ModuleId::Cdrs, ModuleId::Tokens, ModuleId::Payments] {
326 assert!(m.is_functional(), "{m} must be routed");
327 }
328 assert!(ModuleId::Custom("nltnm-tokens".into()).is_functional());
329 }
330
331 #[test]
332 fn booking_module_id_matches_case_insensitively() {
333 let lower: ModuleId = "bookings".into();
334 let spec: ModuleId = "Booking".into();
335 assert_eq!(spec, ModuleId::Booking);
336 assert!(!lower.is_known(), "\"bookings\" is not the identifier the spec gives");
337 assert!(ModuleId::Booking.matches(&"BOOKING".into()), "case is ignored");
338 assert!(ModuleId::Booking.matches(&lower), "the lower-case plural is accepted too");
339 assert!(!ModuleId::Booking.matches(&ModuleId::Cdrs));
340 assert_ne!(ModuleId::Booking, lower, "but they are still different values");
341 }
342
343 #[test]
344 fn module_availability_follows_the_version() {
345 assert!(!ModuleId::ChargingProfiles.exists_in(&VersionNumber::V2_1_1));
346 assert!(ModuleId::ChargingProfiles.exists_in(&VersionNumber::V2_2_1));
347 assert!(!ModuleId::Payments.exists_in(&VersionNumber::V2_2_1));
348 assert!(ModuleId::Payments.exists_in(&VersionNumber::V2_3_0));
349 }
350
351 #[test]
352 fn interface_roles_are_opposites() {
353 assert_eq!(InterfaceRole::Sender.opposite(), InterfaceRole::Receiver);
354 assert_eq!(serde_json::to_string(&InterfaceRole::Sender).unwrap(), "\"SENDER\"");
355 }
356}