use serde::{Deserialize, Serialize};
use crate::types::validate_fields;
use crate::types::{Extensions, Url, Validate, Validator, ViolationCode};
use crate::{InterfaceRole, ModuleId, VersionNumber};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Version {
pub version: VersionNumber,
pub url: Url,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Version {
#[must_use]
pub fn new(version: VersionNumber, url: Url) -> Self {
Self { version, url, extensions: Extensions::new() }
}
}
impl Validate for Version {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, version, url);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct VersionDetails {
pub version: VersionNumber,
pub endpoints: Vec<Endpoint>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl VersionDetails {
#[must_use]
pub fn new(version: VersionNumber, endpoints: Vec<Endpoint>) -> Self {
Self { version, endpoints, extensions: Extensions::new() }
}
#[must_use]
pub fn endpoint(&self, module: &ModuleId, role: InterfaceRole) -> Option<&Endpoint> {
self.endpoints.iter().find(|e| e.identifier.matches(module) && e.role == role)
}
#[must_use]
pub fn url(&self, module: &ModuleId, role: InterfaceRole) -> Option<&Url> {
self.endpoint(module, role).map(|e| &e.url)
}
#[must_use]
pub fn credentials_url(&self) -> Option<&Url> {
self.endpoints.iter().find(|e| e.identifier.matches(&ModuleId::Credentials)).map(|e| &e.url)
}
#[must_use]
pub fn missing(&self, required: &[(ModuleId, InterfaceRole)]) -> Vec<(ModuleId, InterfaceRole)> {
required.iter().filter(|(m, r)| self.endpoint(m, *r).is_none()).cloned().collect()
}
}
impl Validate for VersionDetails {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, version, endpoints);
if self.endpoints.is_empty() {
v.report_at(
"endpoints",
ViolationCode::EmptyRequiredList,
"version details have cardinality `+` endpoints: at least one is required",
);
}
if !self.endpoints.iter().any(|e| e.identifier.matches(&ModuleId::Credentials)) {
v.report_at(
"endpoints",
ViolationCode::MissingConditional,
"the `credentials` module is required for all implementations",
);
}
let mut seen: Vec<(&ModuleId, InterfaceRole)> = Vec::new();
for (i, e) in self.endpoints.iter().enumerate() {
let key = (&e.identifier, e.role);
if seen.contains(&key) {
v.enter("endpoints");
v.enter(&i.to_string());
v.report(
ViolationCode::Inconsistent,
format!("{} / {} is listed more than once", e.identifier, e.role),
);
v.leave();
v.leave();
}
seen.push(key);
if !e.identifier.exists_in(&self.version) {
v.enter("endpoints");
v.enter(&i.to_string());
v.report_at(
"identifier",
ViolationCode::Inconsistent,
format!("the {} module does not exist in OCPI {}", e.identifier, self.version),
);
v.leave();
v.leave();
}
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Endpoint {
pub identifier: ModuleId,
pub role: InterfaceRole,
pub url: Url,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Endpoint {
#[must_use]
pub fn new(identifier: ModuleId, role: InterfaceRole, url: Url) -> Self {
Self { identifier, role, url, extensions: Extensions::new() }
}
}
impl Validate for Endpoint {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, identifier, role, url);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn url(path: &str) -> Url {
Url::new(format!("https://example.com/ocpi/cpo/2.3.0/{path}")).unwrap()
}
fn details(endpoints: Vec<Endpoint>) -> VersionDetails {
VersionDetails::new(VersionNumber::V2_3_0, endpoints)
}
#[test]
fn endpoint_lookup_ignores_the_role_for_credentials() {
let d = details(vec![
Endpoint::new(ModuleId::Credentials, InterfaceRole::Receiver, url("credentials")),
Endpoint::new(ModuleId::Locations, InterfaceRole::Sender, url("locations")),
]);
assert_eq!(d.credentials_url(), Some(&url("credentials")));
assert_eq!(d.url(&ModuleId::Locations, InterfaceRole::Sender), Some(&url("locations")));
assert_eq!(d.url(&ModuleId::Locations, InterfaceRole::Receiver), None);
}
#[test]
fn missing_required_endpoints_are_listed_for_the_handshake() {
let d =
details(vec![Endpoint::new(ModuleId::Credentials, InterfaceRole::Sender, url("credentials"))]);
let missing = d.missing(&[
(ModuleId::Credentials, InterfaceRole::Sender),
(ModuleId::Cdrs, InterfaceRole::Receiver),
]);
assert_eq!(missing, vec![(ModuleId::Cdrs, InterfaceRole::Receiver)]);
}
#[test]
fn a_module_that_does_not_exist_in_the_version_is_reported() {
let d = VersionDetails::new(
VersionNumber::V2_1_1,
vec![
Endpoint::new(ModuleId::Credentials, InterfaceRole::Sender, url("credentials")),
Endpoint::new(ModuleId::ChargingProfiles, InterfaceRole::Receiver, url("cp")),
],
);
let err = d.validate().unwrap_err();
assert!(err.as_slice().iter().any(|x| x.pointer == "/endpoints/1/identifier"), "{err}");
}
#[test]
fn credentials_is_required_in_version_details() {
let d = details(vec![Endpoint::new(ModuleId::Locations, InterfaceRole::Sender, url("locations"))]);
assert!(
d.validate().unwrap_err().as_slice().iter().any(|x| x.code == ViolationCode::MissingConditional)
);
}
#[test]
fn unknown_modules_and_versions_survive_discovery() {
let json = r#"{"version":"3.0","endpoints":[{"identifier":"credentials","role":"SENDER","url":"https://example.com/ocpi/3.0/credentials"},{"identifier":"nltnm-tokens","role":"RECEIVER","url":"https://example.com/ocpi/3.0/x"}]}"#;
let d: VersionDetails = serde_json::from_str(json).unwrap();
assert!(!d.version.is_known());
assert!(!d.endpoints[1].identifier.is_known());
assert_eq!(serde_json::to_string(&d).unwrap(), json);
}
}