Skip to main content

ocpi_kit/convert/
wire.rs

1//! Translating a JSON document from one OCPI version to another.
2//!
3//! [`Upgrade`] and [`Downgrade`] work on typed objects. A hub, a client talking to a peer on an
4//! older version and a server answering one face the problem one step earlier: they hold *bytes*
5//! and know the endpoint those bytes came from, not the Rust type they will become.
6//!
7//! [`ObjectKind`] names the objects whose wire format changed between OCPI 2.2.1 and 2.3.0, says
8//! which one an endpoint carries, and translates a [`serde_json::Value`] — one object or a whole
9//! page — keeping the [`Lossy`] report.
10//!
11//! ```
12//! use ocpi_kit::convert::wire::{ObjectKind, Payload};
13//! use ocpi_kit::{InterfaceRole, ModuleId};
14//!
15//! // On a Locations Sender interface, `/{location_id}/{evse_uid}` is an EVSE.
16//! let kind = ObjectKind::for_endpoint(
17//!     &ModuleId::Locations,
18//!     InterfaceRole::Sender,
19//!     "LOC1/3256",
20//!     Payload::Response,
21//! );
22//! assert_eq!(kind, Some(ObjectKind::Evse));
23//! ```
24//!
25//! Only the 2.2.1 ↔ 2.3.0 crossing exists. OCPI 2.1.1 is modelled and deliberately not bridged:
26//! it has no owner fields on objects, no routing and no `Price`, so carrying an object across that
27//! boundary is a decision about a deployment rather than a translation. [`bridgeable`] is the
28//! single answer to "can this build make this crossing".
29
30use serde::Serialize;
31use serde::de::DeserializeOwned;
32use serde_json::Value;
33
34use crate::{InterfaceRole, ModuleId, VersionNumber};
35
36use super::{Converted, Downgrade, Lossy, Upgrade};
37
38/// Which half of an exchange a document is, for the two endpoints whose request and response
39/// carry different objects.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Payload {
42    /// The request body.
43    Request,
44    /// The `data` of the response envelope.
45    Response,
46}
47
48/// Why a document could not be carried between two versions.
49#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
50#[non_exhaustive]
51pub enum BridgeError {
52    /// This build has no conversions between the two versions. See [`bridgeable`].
53    #[error("this build cannot translate OCPI {from} to OCPI {to}")]
54    Unsupported {
55        /// The version the document is written in.
56        from: VersionNumber,
57        /// The version it was to be translated to.
58        to: VersionNumber,
59    },
60    /// The document is not the object the endpoint is supposed to carry.
61    #[error("the document is not a valid OCPI {version} {kind}: {message}")]
62    Decode {
63        /// The version the document claimed to be written in.
64        version: VersionNumber,
65        /// The object it was expected to be.
66        kind: ObjectKind,
67        /// What `serde` said.
68        message: String,
69    },
70}
71
72/// Whether this build can translate documents between two OCPI versions.
73///
74/// Today that is exactly the 2.2.1 ↔ 2.3.0 crossing, in both directions, plus the trivial case of
75/// a version to itself. It is a function rather than a constant because the answer depends on the
76/// Cargo features the crate was built with.
77#[must_use]
78pub fn bridgeable(from: &VersionNumber, to: &VersionNumber) -> bool {
79    if from == to {
80        return true;
81    }
82    matches!(
83        (from, to),
84        (VersionNumber::V2_2_1, VersionNumber::V2_3_0) | (VersionNumber::V2_3_0, VersionNumber::V2_2_1)
85    )
86}
87
88/// An OCPI object whose wire format changed between the versions this crate bridges.
89///
90/// Objects that are byte-identical across versions are deliberately absent: there is nothing to
91/// do to them, and [`ObjectKind::for_endpoint`] returns `None` for the endpoints that carry them
92/// so a caller can forward the bytes untouched.
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
94#[non_exhaustive]
95pub enum ObjectKind {
96    /// `Location`, which gained `parking_places` and `help_phone` in 2.3.0.
97    Location,
98    /// `EVSE`, which gained `parking` and `accepted_service_providers`.
99    Evse,
100    /// `Connector`, which gained `capabilities`.
101    Connector,
102    /// `Session`, whose `total_cost` is a `Price`.
103    Session,
104    /// `CDR`, whose costs are `Price`s and whose `tariffs` are `Tariff`s.
105    Cdr,
106    /// `Tariff`, which gained `tax_included` and replaced `Price` limits with `PriceLimit`.
107    Tariff,
108    /// `Token`, whose `TokenType` was opened and gained `EMAID`.
109    Token,
110    /// `AuthorizationInfo`, which embeds a `Token`.
111    AuthorizationInfo,
112    /// `Credentials`, which gained `hub_party_id` and lost the `HUB` role.
113    Credentials,
114    /// `ClientInfo`, which carries a `Role`.
115    ClientInfo,
116    /// The `START_SESSION` command body, which embeds a `Token`.
117    StartSession,
118    /// The `RESERVE_NOW` command body, which embeds a `Token`.
119    ReserveNow,
120}
121
122impl core::fmt::Display for ObjectKind {
123    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
124        f.write_str(match self {
125            Self::Location => "Location",
126            Self::Evse => "EVSE",
127            Self::Connector => "Connector",
128            Self::Session => "Session",
129            Self::Cdr => "CDR",
130            Self::Tariff => "Tariff",
131            Self::Token => "Token",
132            Self::AuthorizationInfo => "AuthorizationInfo",
133            Self::Credentials => "Credentials",
134            Self::ClientInfo => "ClientInfo",
135            Self::StartSession => "StartSession",
136            Self::ReserveNow => "ReserveNow",
137        })
138    }
139}
140
141/// Generates the per-kind translation from the typed [`Upgrade`]/[`Downgrade`] impls.
142macro_rules! bridge_kinds {
143    ($($kind:ident => $old:path, $new:path;)*) => {
144        impl ObjectKind {
145            fn bridge_one(
146                self,
147                from: &VersionNumber,
148                to: &VersionNumber,
149                value: Value,
150            ) -> Result<Converted<Value>, BridgeError> {
151                use VersionNumber::{V2_2_1, V2_3_0};
152                match (from, to) {
153                    (V2_2_1, V2_3_0) => match self {
154                        $(Self::$kind => up::<$old, $new>(self, value),)*
155                    },
156                    (V2_3_0, V2_2_1) => match self {
157                        $(Self::$kind => down::<$new, $old>(self, value),)*
158                    },
159                    _ => Err(BridgeError::Unsupported { from: from.clone(), to: to.clone() }),
160                }
161            }
162        }
163    };
164}
165
166bridge_kinds! {
167    Location => crate::v2_2_1::locations::Location, crate::v2_3_0::locations::Location;
168    Evse => crate::v2_2_1::locations::Evse, crate::v2_3_0::locations::Evse;
169    Connector => crate::v2_2_1::locations::Connector, crate::v2_3_0::locations::Connector;
170    Session => crate::v2_2_1::sessions::Session, crate::v2_3_0::sessions::Session;
171    Cdr => crate::v2_2_1::cdrs::Cdr, crate::v2_3_0::cdrs::Cdr;
172    Tariff => crate::v2_2_1::tariffs::Tariff, crate::v2_3_0::tariffs::Tariff;
173    Token => crate::v2_2_1::tokens::Token, crate::v2_3_0::tokens::Token;
174    AuthorizationInfo =>
175        crate::v2_2_1::tokens::AuthorizationInfo, crate::v2_3_0::tokens::AuthorizationInfo;
176    Credentials => crate::v2_2_1::credentials::Credentials, crate::v2_3_0::credentials::Credentials;
177    ClientInfo =>
178        crate::v2_2_1::hub_client_info::ClientInfo, crate::v2_3_0::hub_client_info::ClientInfo;
179    StartSession => crate::v2_2_1::commands::StartSession, crate::v2_3_0::commands::StartSession;
180    ReserveNow => crate::v2_2_1::commands::ReserveNow, crate::v2_3_0::commands::ReserveNow;
181}
182
183fn up<O, N>(kind: ObjectKind, value: Value) -> Result<Converted<Value>, BridgeError>
184where
185    O: DeserializeOwned + Upgrade<N>,
186    N: Serialize,
187{
188    let old: O = serde_json::from_value(value).map_err(|e| BridgeError::Decode {
189        version: VersionNumber::V2_2_1,
190        kind,
191        message: e.to_string(),
192    })?;
193    Ok(reserialise(kind, VersionNumber::V2_3_0, old.upgrade()))
194}
195
196fn down<N, O>(kind: ObjectKind, value: Value) -> Result<Converted<Value>, BridgeError>
197where
198    N: DeserializeOwned + Downgrade<O>,
199    O: Serialize,
200{
201    let new: N = serde_json::from_value(value).map_err(|e| BridgeError::Decode {
202        version: VersionNumber::V2_3_0,
203        kind,
204        message: e.to_string(),
205    })?;
206    Ok(reserialise(kind, VersionNumber::V2_2_1, new.downgrade()))
207}
208
209fn reserialise<T: Serialize>(
210    kind: ObjectKind,
211    into: VersionNumber,
212    converted: Converted<T>,
213) -> Converted<Value> {
214    // Serialising a wire struct this crate defines cannot fail: every field is a JSON-shaped type
215    // and no map has non-string keys. Falling back to `null` rather than unwrapping keeps the
216    // whole hub path panic-free even if that ever stopped being true.
217    let value = serde_json::to_value(&converted.value).unwrap_or(Value::Null);
218    debug_assert!(!value.is_null(), "a bridged {kind} serialised to null on the way to {into}");
219    Converted::new(value, converted.lossy)
220}
221
222impl ObjectKind {
223    /// Translates one object, or a whole page of them, from `from` to `to`.
224    ///
225    /// An array is translated element by element with each element's losses reported under its own
226    /// index (`/17/help_phone`). A `null` — an envelope with no `data` — is returned unchanged.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`BridgeError::Unsupported`] when this build has no conversions between the two
231    /// versions, and [`BridgeError::Decode`] when the document is not the object the endpoint is
232    /// supposed to carry.
233    pub fn bridge(
234        self,
235        from: &VersionNumber,
236        to: &VersionNumber,
237        value: Value,
238    ) -> Result<Converted<Value>, BridgeError> {
239        if from == to {
240            return Ok(Converted::lossless(value));
241        }
242        match value {
243            Value::Null => Ok(Converted::lossless(Value::Null)),
244            Value::Array(items) => {
245                let mut out = Vec::with_capacity(items.len());
246                let mut lossy = Lossy::none();
247                for (index, item) in items.into_iter().enumerate() {
248                    let converted = self.bridge_one(from, to, item)?;
249                    lossy.absorb(&format!("/{index}"), converted.lossy);
250                    out.push(converted.value);
251                }
252                Ok(Converted::new(Value::Array(out), lossy))
253            }
254            other => self.bridge_one(from, to, other),
255        }
256    }
257
258    /// The top-level fields of this object whose shape or presence differs between the versions.
259    ///
260    /// Everything else is byte-identical, which is what makes a **merge patch** translatable: a
261    /// patch is not an object, so it cannot go through [`bridge`](Self::bridge), but one writing
262    /// only fields outside this list means the same thing in both versions and crosses unchanged.
263    ///
264    /// Checked against the fixture corpus: every 2.2.1 spec example is carried to 2.3.0 and back,
265    /// and no field outside its object's list may move.
266    #[must_use]
267    pub const fn divergent_fields(self) -> &'static [&'static str] {
268        match self {
269            Self::Location => &["evses", "parking_places", "help_phone"],
270            Self::Evse => &["connectors", "parking", "accepted_service_providers"],
271            Self::Connector => &["capabilities"],
272            Self::Session => &["total_cost"],
273            Self::Cdr => &[
274                "tariffs",
275                "booking_id",
276                "total_cost",
277                "total_fixed_cost",
278                "total_energy_cost",
279                "total_time_cost",
280                "total_parking_cost",
281                "total_reservation_cost",
282            ],
283            Self::Tariff => &["min_price", "max_price", "tax_included", "preauthorize_amount"],
284            Self::Credentials => &["roles", "hub_party_id"],
285            Self::ClientInfo => &["role"],
286            // A `TokenType` that 2.2.1 does not know keeps its text in a `Custom` variant, so the
287            // string on the wire is the same in both versions and nothing here moves.
288            Self::Token | Self::AuthorizationInfo | Self::StartSession | Self::ReserveNow => &[],
289        }
290    }
291
292    /// Whether a merge patch written against one version means the same thing in the other.
293    ///
294    /// See [`divergent_fields`](Self::divergent_fields).
295    #[must_use]
296    pub fn patch_crosses_unchanged(self, fields: &[&str]) -> bool {
297        let divergent = self.divergent_fields();
298        !fields.iter().any(|f| divergent.contains(f))
299    }
300
301    /// The object an endpoint carries, or `None` when it is the same in both versions.
302    ///
303    /// `path` is what follows the module's endpoint URL, with or without surrounding slashes and
304    /// without a query string — `LOC1/3256` on a Locations Sender interface, `NL/TNM/LOC1` on the
305    /// Receiver one. `None` means "nothing to do": either the endpoint carries an object that did
306    /// not change between the versions, or it carries no object at all.
307    ///
308    /// Spec: 2.3.0 §mod_locations, §mod_sessions, §mod_cdrs, §mod_tariffs, §mod_tokens,
309    /// §mod_commands, §credentials, §mod_hub_client_info
310    #[must_use]
311    pub fn for_endpoint(
312        module: &ModuleId,
313        interface: InterfaceRole,
314        path: &str,
315        payload: Payload,
316    ) -> Option<Self> {
317        let segments: Vec<&str> =
318            path.split('?').next().unwrap_or("").split('/').filter(|s| !s.is_empty()).collect();
319        // A Receiver interface addresses a client-owned object, so its path starts with the two
320        // owner segments the Sender interface does not have.
321        let owned = interface == InterfaceRole::Receiver;
322        match module {
323            ModuleId::Locations => match (segments.len(), owned) {
324                (0 | 1, false) | (3, true) => Some(Self::Location),
325                (2, false) | (4, true) => Some(Self::Evse),
326                (3, false) | (5, true) => Some(Self::Connector),
327                _ => None,
328            },
329            ModuleId::Sessions => match (segments.len(), owned) {
330                (0, false) | (3, true) => Some(Self::Session),
331                // `{session_id}/charging_preferences` carries a ChargingPreferences, unchanged.
332                _ => None,
333            },
334            // The Receiver interface takes a POST of one CDR and a GET of one by id; the Sender
335            // interface lists them.
336            ModuleId::Cdrs => (segments.len() <= 1).then_some(Self::Cdr),
337            ModuleId::Tariffs => match (segments.len(), owned) {
338                (0, false) | (3, true) => Some(Self::Tariff),
339                _ => None,
340            },
341            ModuleId::Tokens => match (segments.last(), owned) {
342                // The request is a `LocationReferences`, unchanged; the response is the decision.
343                (Some(&"authorize"), false) => {
344                    (payload == Payload::Response).then_some(Self::AuthorizationInfo)
345                }
346                _ => match (segments.len(), owned) {
347                    (0, false) | (3, true) => Some(Self::Token),
348                    _ => None,
349                },
350            },
351            // `CommandResponse` and `CommandResult` are unchanged; only two of the five request
352            // bodies carry a Token.
353            ModuleId::Commands if payload == Payload::Request => match segments.first() {
354                Some(&"START_SESSION") => Some(Self::StartSession),
355                Some(&"RESERVE_NOW") => Some(Self::ReserveNow),
356                _ => None,
357            },
358            ModuleId::Credentials => segments.is_empty().then_some(Self::Credentials),
359            ModuleId::HubClientInfo => match (segments.len(), owned) {
360                (0, false) | (2, true) => Some(Self::ClientInfo),
361                _ => None,
362            },
363            _ => None,
364        }
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use serde_json::json;
372
373    fn location_2_2_1() -> Value {
374        json!({
375            "country_code": "BE", "party_id": "BEC", "id": "LOC1", "publish": true,
376            "address": "F.Rooseveltlaan 3A", "city": "Gent", "country": "BEL",
377            "coordinates": {"latitude": "51.047599", "longitude": "3.729944"},
378            "time_zone": "Europe/Brussels", "last_updated": "2019-06-24T12:39:09Z"
379        })
380    }
381
382    #[test]
383    fn a_page_reports_each_objects_losses_under_its_own_index() {
384        let mut location = location_2_2_1();
385        let page = Value::Array(vec![location.clone(), location.clone()]);
386        let up = ObjectKind::Location.bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0, page).unwrap();
387        assert!(up.lossy.is_empty(), "2.2.1 → 2.3.0 adds fields, it does not drop them");
388
389        // Give the second one something 2.2.1 cannot hold, and carry the page back.
390        location["help_phone"] = json!("+3212345678");
391        let up = ObjectKind::Location
392            .bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0, location_2_2_1())
393            .unwrap();
394        let mut with_phone = up.value.clone();
395        with_phone["help_phone"] = json!("+3212345678");
396        let page = Value::Array(vec![up.value, with_phone]);
397        let down = ObjectKind::Location.bridge(&VersionNumber::V2_3_0, &VersionNumber::V2_2_1, page).unwrap();
398        assert_eq!(down.lossy.len(), 1);
399        assert_eq!(down.lossy.as_slice()[0].pointer, "/1/help_phone");
400    }
401
402    #[test]
403    fn a_version_to_itself_is_the_identity_and_costs_nothing() {
404        let value = location_2_2_1();
405        let same = ObjectKind::Location
406            .bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_2_1, value.clone())
407            .unwrap();
408        assert_eq!(same.value, value);
409        assert!(same.lossy.is_empty());
410    }
411
412    #[test]
413    fn a_crossing_this_build_cannot_make_is_refused_rather_than_guessed_at() {
414        let error = ObjectKind::Location
415            .bridge(&VersionNumber::V2_1_1, &VersionNumber::V2_3_0, location_2_2_1())
416            .unwrap_err();
417        assert!(matches!(error, BridgeError::Unsupported { .. }), "{error}");
418        assert!(!bridgeable(&VersionNumber::V2_1_1, &VersionNumber::V2_3_0));
419        assert!(bridgeable(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0));
420        assert!(bridgeable(&VersionNumber::V2_1_1, &VersionNumber::V2_1_1));
421    }
422
423    #[test]
424    fn a_document_that_is_not_the_object_the_endpoint_carries_is_named_as_such() {
425        let error = ObjectKind::Tariff
426            .bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0, json!({"id": "1"}))
427            .unwrap_err();
428        match error {
429            BridgeError::Decode { kind, version, .. } => {
430                assert_eq!(kind, ObjectKind::Tariff);
431                assert_eq!(version, VersionNumber::V2_2_1);
432            }
433            other => panic!("{other}"),
434        }
435    }
436
437    #[test]
438    fn an_absent_data_field_survives() {
439        let out =
440            ObjectKind::Cdr.bridge(&VersionNumber::V2_3_0, &VersionNumber::V2_2_1, Value::Null).unwrap();
441        assert_eq!(out.value, Value::Null);
442    }
443
444    #[test]
445    fn the_locations_url_shapes_name_the_object_they_carry() {
446        let sender = |p: &str| {
447            ObjectKind::for_endpoint(&ModuleId::Locations, InterfaceRole::Sender, p, Payload::Response)
448        };
449        assert_eq!(sender(""), Some(ObjectKind::Location));
450        assert_eq!(sender("LOC1"), Some(ObjectKind::Location));
451        assert_eq!(sender("LOC1/3256"), Some(ObjectKind::Evse));
452        assert_eq!(sender("/LOC1/3256/1/"), Some(ObjectKind::Connector));
453
454        let receiver = |p: &str| {
455            ObjectKind::for_endpoint(&ModuleId::Locations, InterfaceRole::Receiver, p, Payload::Request)
456        };
457        assert_eq!(receiver("NL/TNM/LOC1"), Some(ObjectKind::Location));
458        assert_eq!(receiver("NL/TNM/LOC1/3256"), Some(ObjectKind::Evse));
459        assert_eq!(receiver("NL/TNM/LOC1/3256/1"), Some(ObjectKind::Connector));
460    }
461
462    #[test]
463    fn the_two_endpoints_whose_halves_differ_are_told_apart() {
464        // `POST {tokens}/{uid}/authorize` sends a LocationReferences and gets a decision back.
465        let authorize = |payload| {
466            ObjectKind::for_endpoint(&ModuleId::Tokens, InterfaceRole::Sender, "012345/authorize", payload)
467        };
468        assert_eq!(authorize(Payload::Request), None);
469        assert_eq!(authorize(Payload::Response), Some(ObjectKind::AuthorizationInfo));
470
471        // A command's response is a `CommandResponse`, which is unchanged.
472        let command = |name: &str, payload| {
473            ObjectKind::for_endpoint(&ModuleId::Commands, InterfaceRole::Receiver, name, payload)
474        };
475        assert_eq!(command("START_SESSION", Payload::Request), Some(ObjectKind::StartSession));
476        assert_eq!(command("RESERVE_NOW", Payload::Request), Some(ObjectKind::ReserveNow));
477        assert_eq!(command("STOP_SESSION", Payload::Request), None);
478        assert_eq!(command("START_SESSION", Payload::Response), None);
479    }
480
481    #[test]
482    fn an_endpoint_whose_object_did_not_change_asks_for_no_work() {
483        let query = |module| ObjectKind::for_endpoint(module, InterfaceRole::Sender, "", Payload::Response);
484        assert_eq!(query(&ModuleId::ChargingProfiles), None);
485        assert_eq!(query(&ModuleId::Payments), None);
486        assert_eq!(query(&ModuleId::Versions), None);
487        assert_eq!(
488            ObjectKind::for_endpoint(
489                &ModuleId::Sessions,
490                InterfaceRole::Sender,
491                "SESS1/charging_preferences",
492                Payload::Request,
493            ),
494            None,
495        );
496    }
497}