use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::{InterfaceRole, ModuleId, VersionNumber};
use super::{Converted, Downgrade, Lossy, Upgrade};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Payload {
Request,
Response,
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum BridgeError {
#[error("this build cannot translate OCPI {from} to OCPI {to}")]
Unsupported {
from: VersionNumber,
to: VersionNumber,
},
#[error("the document is not a valid OCPI {version} {kind}: {message}")]
Decode {
version: VersionNumber,
kind: ObjectKind,
message: String,
},
}
#[must_use]
pub fn bridgeable(from: &VersionNumber, to: &VersionNumber) -> bool {
if from == to {
return true;
}
matches!(
(from, to),
(VersionNumber::V2_2_1, VersionNumber::V2_3_0) | (VersionNumber::V2_3_0, VersionNumber::V2_2_1)
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ObjectKind {
Location,
Evse,
Connector,
Session,
Cdr,
Tariff,
Token,
AuthorizationInfo,
Credentials,
ClientInfo,
StartSession,
ReserveNow,
}
impl core::fmt::Display for ObjectKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(match self {
Self::Location => "Location",
Self::Evse => "EVSE",
Self::Connector => "Connector",
Self::Session => "Session",
Self::Cdr => "CDR",
Self::Tariff => "Tariff",
Self::Token => "Token",
Self::AuthorizationInfo => "AuthorizationInfo",
Self::Credentials => "Credentials",
Self::ClientInfo => "ClientInfo",
Self::StartSession => "StartSession",
Self::ReserveNow => "ReserveNow",
})
}
}
macro_rules! bridge_kinds {
($($kind:ident => $old:path, $new:path;)*) => {
impl ObjectKind {
fn bridge_one(
self,
from: &VersionNumber,
to: &VersionNumber,
value: Value,
) -> Result<Converted<Value>, BridgeError> {
use VersionNumber::{V2_2_1, V2_3_0};
match (from, to) {
(V2_2_1, V2_3_0) => match self {
$(Self::$kind => up::<$old, $new>(self, value),)*
},
(V2_3_0, V2_2_1) => match self {
$(Self::$kind => down::<$new, $old>(self, value),)*
},
_ => Err(BridgeError::Unsupported { from: from.clone(), to: to.clone() }),
}
}
}
};
}
bridge_kinds! {
Location => crate::v2_2_1::locations::Location, crate::v2_3_0::locations::Location;
Evse => crate::v2_2_1::locations::Evse, crate::v2_3_0::locations::Evse;
Connector => crate::v2_2_1::locations::Connector, crate::v2_3_0::locations::Connector;
Session => crate::v2_2_1::sessions::Session, crate::v2_3_0::sessions::Session;
Cdr => crate::v2_2_1::cdrs::Cdr, crate::v2_3_0::cdrs::Cdr;
Tariff => crate::v2_2_1::tariffs::Tariff, crate::v2_3_0::tariffs::Tariff;
Token => crate::v2_2_1::tokens::Token, crate::v2_3_0::tokens::Token;
AuthorizationInfo =>
crate::v2_2_1::tokens::AuthorizationInfo, crate::v2_3_0::tokens::AuthorizationInfo;
Credentials => crate::v2_2_1::credentials::Credentials, crate::v2_3_0::credentials::Credentials;
ClientInfo =>
crate::v2_2_1::hub_client_info::ClientInfo, crate::v2_3_0::hub_client_info::ClientInfo;
StartSession => crate::v2_2_1::commands::StartSession, crate::v2_3_0::commands::StartSession;
ReserveNow => crate::v2_2_1::commands::ReserveNow, crate::v2_3_0::commands::ReserveNow;
}
fn up<O, N>(kind: ObjectKind, value: Value) -> Result<Converted<Value>, BridgeError>
where
O: DeserializeOwned + Upgrade<N>,
N: Serialize,
{
let old: O = serde_json::from_value(value).map_err(|e| BridgeError::Decode {
version: VersionNumber::V2_2_1,
kind,
message: e.to_string(),
})?;
Ok(reserialise(kind, VersionNumber::V2_3_0, old.upgrade()))
}
fn down<N, O>(kind: ObjectKind, value: Value) -> Result<Converted<Value>, BridgeError>
where
N: DeserializeOwned + Downgrade<O>,
O: Serialize,
{
let new: N = serde_json::from_value(value).map_err(|e| BridgeError::Decode {
version: VersionNumber::V2_3_0,
kind,
message: e.to_string(),
})?;
Ok(reserialise(kind, VersionNumber::V2_2_1, new.downgrade()))
}
fn reserialise<T: Serialize>(
kind: ObjectKind,
into: VersionNumber,
converted: Converted<T>,
) -> Converted<Value> {
let value = serde_json::to_value(&converted.value).unwrap_or(Value::Null);
debug_assert!(!value.is_null(), "a bridged {kind} serialised to null on the way to {into}");
Converted::new(value, converted.lossy)
}
impl ObjectKind {
pub fn bridge(
self,
from: &VersionNumber,
to: &VersionNumber,
value: Value,
) -> Result<Converted<Value>, BridgeError> {
if from == to {
return Ok(Converted::lossless(value));
}
match value {
Value::Null => Ok(Converted::lossless(Value::Null)),
Value::Array(items) => {
let mut out = Vec::with_capacity(items.len());
let mut lossy = Lossy::none();
for (index, item) in items.into_iter().enumerate() {
let converted = self.bridge_one(from, to, item)?;
lossy.absorb(&format!("/{index}"), converted.lossy);
out.push(converted.value);
}
Ok(Converted::new(Value::Array(out), lossy))
}
other => self.bridge_one(from, to, other),
}
}
#[must_use]
pub const fn divergent_fields(self) -> &'static [&'static str] {
match self {
Self::Location => &["evses", "parking_places", "help_phone"],
Self::Evse => &["connectors", "parking", "accepted_service_providers"],
Self::Connector => &["capabilities"],
Self::Session => &["total_cost"],
Self::Cdr => &[
"tariffs",
"booking_id",
"total_cost",
"total_fixed_cost",
"total_energy_cost",
"total_time_cost",
"total_parking_cost",
"total_reservation_cost",
],
Self::Tariff => &["min_price", "max_price", "tax_included", "preauthorize_amount"],
Self::Credentials => &["roles", "hub_party_id"],
Self::ClientInfo => &["role"],
Self::Token | Self::AuthorizationInfo | Self::StartSession | Self::ReserveNow => &[],
}
}
#[must_use]
pub fn patch_crosses_unchanged(self, fields: &[&str]) -> bool {
let divergent = self.divergent_fields();
!fields.iter().any(|f| divergent.contains(f))
}
#[must_use]
pub fn for_endpoint(
module: &ModuleId,
interface: InterfaceRole,
path: &str,
payload: Payload,
) -> Option<Self> {
let segments: Vec<&str> =
path.split('?').next().unwrap_or("").split('/').filter(|s| !s.is_empty()).collect();
let owned = interface == InterfaceRole::Receiver;
match module {
ModuleId::Locations => match (segments.len(), owned) {
(0 | 1, false) | (3, true) => Some(Self::Location),
(2, false) | (4, true) => Some(Self::Evse),
(3, false) | (5, true) => Some(Self::Connector),
_ => None,
},
ModuleId::Sessions => match (segments.len(), owned) {
(0, false) | (3, true) => Some(Self::Session),
_ => None,
},
ModuleId::Cdrs => (segments.len() <= 1).then_some(Self::Cdr),
ModuleId::Tariffs => match (segments.len(), owned) {
(0, false) | (3, true) => Some(Self::Tariff),
_ => None,
},
ModuleId::Tokens => match (segments.last(), owned) {
(Some(&"authorize"), false) => {
(payload == Payload::Response).then_some(Self::AuthorizationInfo)
}
_ => match (segments.len(), owned) {
(0, false) | (3, true) => Some(Self::Token),
_ => None,
},
},
ModuleId::Commands if payload == Payload::Request => match segments.first() {
Some(&"START_SESSION") => Some(Self::StartSession),
Some(&"RESERVE_NOW") => Some(Self::ReserveNow),
_ => None,
},
ModuleId::Credentials => segments.is_empty().then_some(Self::Credentials),
ModuleId::HubClientInfo => match (segments.len(), owned) {
(0, false) | (2, true) => Some(Self::ClientInfo),
_ => None,
},
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn location_2_2_1() -> Value {
json!({
"country_code": "BE", "party_id": "BEC", "id": "LOC1", "publish": true,
"address": "F.Rooseveltlaan 3A", "city": "Gent", "country": "BEL",
"coordinates": {"latitude": "51.047599", "longitude": "3.729944"},
"time_zone": "Europe/Brussels", "last_updated": "2019-06-24T12:39:09Z"
})
}
#[test]
fn a_page_reports_each_objects_losses_under_its_own_index() {
let mut location = location_2_2_1();
let page = Value::Array(vec![location.clone(), location.clone()]);
let up = ObjectKind::Location.bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0, page).unwrap();
assert!(up.lossy.is_empty(), "2.2.1 → 2.3.0 adds fields, it does not drop them");
location["help_phone"] = json!("+3212345678");
let up = ObjectKind::Location
.bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0, location_2_2_1())
.unwrap();
let mut with_phone = up.value.clone();
with_phone["help_phone"] = json!("+3212345678");
let page = Value::Array(vec![up.value, with_phone]);
let down = ObjectKind::Location.bridge(&VersionNumber::V2_3_0, &VersionNumber::V2_2_1, page).unwrap();
assert_eq!(down.lossy.len(), 1);
assert_eq!(down.lossy.as_slice()[0].pointer, "/1/help_phone");
}
#[test]
fn a_version_to_itself_is_the_identity_and_costs_nothing() {
let value = location_2_2_1();
let same = ObjectKind::Location
.bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_2_1, value.clone())
.unwrap();
assert_eq!(same.value, value);
assert!(same.lossy.is_empty());
}
#[test]
fn a_crossing_this_build_cannot_make_is_refused_rather_than_guessed_at() {
let error = ObjectKind::Location
.bridge(&VersionNumber::V2_1_1, &VersionNumber::V2_3_0, location_2_2_1())
.unwrap_err();
assert!(matches!(error, BridgeError::Unsupported { .. }), "{error}");
assert!(!bridgeable(&VersionNumber::V2_1_1, &VersionNumber::V2_3_0));
assert!(bridgeable(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0));
assert!(bridgeable(&VersionNumber::V2_1_1, &VersionNumber::V2_1_1));
}
#[test]
fn a_document_that_is_not_the_object_the_endpoint_carries_is_named_as_such() {
let error = ObjectKind::Tariff
.bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0, json!({"id": "1"}))
.unwrap_err();
match error {
BridgeError::Decode { kind, version, .. } => {
assert_eq!(kind, ObjectKind::Tariff);
assert_eq!(version, VersionNumber::V2_2_1);
}
other => panic!("{other}"),
}
}
#[test]
fn an_absent_data_field_survives() {
let out =
ObjectKind::Cdr.bridge(&VersionNumber::V2_3_0, &VersionNumber::V2_2_1, Value::Null).unwrap();
assert_eq!(out.value, Value::Null);
}
#[test]
fn the_locations_url_shapes_name_the_object_they_carry() {
let sender = |p: &str| {
ObjectKind::for_endpoint(&ModuleId::Locations, InterfaceRole::Sender, p, Payload::Response)
};
assert_eq!(sender(""), Some(ObjectKind::Location));
assert_eq!(sender("LOC1"), Some(ObjectKind::Location));
assert_eq!(sender("LOC1/3256"), Some(ObjectKind::Evse));
assert_eq!(sender("/LOC1/3256/1/"), Some(ObjectKind::Connector));
let receiver = |p: &str| {
ObjectKind::for_endpoint(&ModuleId::Locations, InterfaceRole::Receiver, p, Payload::Request)
};
assert_eq!(receiver("NL/TNM/LOC1"), Some(ObjectKind::Location));
assert_eq!(receiver("NL/TNM/LOC1/3256"), Some(ObjectKind::Evse));
assert_eq!(receiver("NL/TNM/LOC1/3256/1"), Some(ObjectKind::Connector));
}
#[test]
fn the_two_endpoints_whose_halves_differ_are_told_apart() {
let authorize = |payload| {
ObjectKind::for_endpoint(&ModuleId::Tokens, InterfaceRole::Sender, "012345/authorize", payload)
};
assert_eq!(authorize(Payload::Request), None);
assert_eq!(authorize(Payload::Response), Some(ObjectKind::AuthorizationInfo));
let command = |name: &str, payload| {
ObjectKind::for_endpoint(&ModuleId::Commands, InterfaceRole::Receiver, name, payload)
};
assert_eq!(command("START_SESSION", Payload::Request), Some(ObjectKind::StartSession));
assert_eq!(command("RESERVE_NOW", Payload::Request), Some(ObjectKind::ReserveNow));
assert_eq!(command("STOP_SESSION", Payload::Request), None);
assert_eq!(command("START_SESSION", Payload::Response), None);
}
#[test]
fn an_endpoint_whose_object_did_not_change_asks_for_no_work() {
let query = |module| ObjectKind::for_endpoint(module, InterfaceRole::Sender, "", Payload::Response);
assert_eq!(query(&ModuleId::ChargingProfiles), None);
assert_eq!(query(&ModuleId::Payments), None);
assert_eq!(query(&ModuleId::Versions), None);
assert_eq!(
ObjectKind::for_endpoint(
&ModuleId::Sessions,
InterfaceRole::Sender,
"SESS1/charging_preferences",
Payload::Request,
),
None,
);
}
}