use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::types::validate_fields;
use crate::types::{
CiString, CountryCode, Extensions, OcpiString, PartyId, PartyRef, Url, Validate, Validator, ViolationCode,
};
use super::locations::BusinessDetails;
use super::types::Role;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Credentials {
pub token: OcpiString<64>,
pub url: Url,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hub_party_id: Option<CiString<5>>,
pub roles: Vec<CredentialsRole>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Credentials {
pub fn parties(&self) -> impl Iterator<Item = PartyRef> + '_ {
self.roles.iter().map(CredentialsRole::party)
}
#[must_use]
pub fn hosts(&self, party: &PartyRef) -> bool {
self.roles.iter().any(|r| &r.party() == party)
}
#[must_use]
pub fn hub_party(&self) -> Option<PartyRef> {
self.hub_party_id.as_ref().and_then(|id| PartyRef::from_hub_party_id(id).ok())
}
#[must_use]
pub fn is_routing_platform(&self) -> bool {
self.hub_party_id.is_some()
}
}
impl Validate for Credentials {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, token, url, hub_party_id, roles);
if self.roles.is_empty() {
v.report_at(
"roles",
ViolationCode::EmptyRequiredList,
"Credentials has cardinality `+` roles: at least one is required",
);
}
let mut seen: Vec<(Role, PartyRef)> = Vec::new();
for (i, role) in self.roles.iter().enumerate() {
let key = (role.role, role.party());
if seen.contains(&key) {
v.enter("roles");
v.enter(&i.to_string());
v.report(
ViolationCode::Inconsistent,
format!(
"the combination {} / {} appears more than once; every role needs a \
unique combination of role, party_id and country_code",
key.0, key.1
),
);
v.leave();
v.leave();
}
seen.push(key);
}
if let Some(bad) = self.token.as_str().chars().find(|c| !matches!(c, '!'..='~')) {
v.report_at(
"token",
ViolationCode::IllegalCharacter,
format!("a credentials token may only contain U+0021..U+007E; found U+{:04X}", bad as u32),
);
}
if self.token.is_empty() {
v.report_at("token", ViolationCode::IllegalCharacter, "a credentials token cannot be empty");
}
if let Some(hub) = self.hub_party_id.as_ref()
&& hub.len() != 5
{
v.report_at(
"hub_party_id",
ViolationCode::Inconsistent,
"must be exactly five characters: a two-letter country code followed by a \
three-character party ID",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct CredentialsRole {
pub role: Role,
pub business_details: BusinessDetails,
pub party_id: PartyId,
pub country_code: CountryCode,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl CredentialsRole {
#[must_use]
pub fn party(&self) -> PartyRef {
PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
}
}
impl Validate for CredentialsRole {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, role, business_details, party_id, country_code);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn role(role: Role, country: &str, party: &str) -> CredentialsRole {
CredentialsRole::builder()
.role(role)
.business_details(BusinessDetails::builder().name("Example Operations").build())
.party_id(party)
.country_code(country)
.build()
}
fn credentials(roles: Vec<CredentialsRole>) -> Credentials {
Credentials::builder()
.token("ebf3b399-779f-4497-9b9d-ac6ad3cc44d2")
.url(Url::new("https://example.com/ocpi/versions").unwrap())
.roles(roles)
.build()
}
#[test]
fn role_combinations_must_be_unique() {
let ok = credentials(vec![role(Role::Cpo, "NL", "TNM"), role(Role::Emsp, "NL", "TNM")]);
assert!(ok.validate().is_ok(), "the same party in two roles is allowed");
let dup = credentials(vec![role(Role::Cpo, "NL", "TNM"), role(Role::Cpo, "nl", "tnm")]);
let err = dup.validate().unwrap_err();
assert_eq!(err.as_slice()[0].pointer, "/roles/1", "party ids compare case-insensitively");
}
#[test]
fn white_label_platforms_may_repeat_a_role() {
let c = credentials(vec![
role(Role::Cpo, "NL", "TNM"),
role(Role::Cpo, "NL", "ABC"),
role(Role::Cpo, "DE", "TNM"),
]);
assert!(c.validate().is_ok());
assert_eq!(c.parties().count(), 3);
assert!(c.hosts(&PartyRef::new("de", "tnm").unwrap()));
}
#[test]
fn the_token_charset_is_narrower_than_cistring() {
let mut c = credentials(vec![role(Role::Cpo, "NL", "TNM")]);
c.token = OcpiString::new("has a space").unwrap();
let err = c.validate().unwrap_err();
assert_eq!(err.as_slice()[0].code, ViolationCode::IllegalCharacter);
}
#[test]
fn a_hub_is_recognised_by_hub_party_id_not_by_a_role() {
let mut c = credentials(vec![role(Role::Cpo, "NL", "TNM")]);
assert!(!c.is_routing_platform());
c.hub_party_id = Some(CiString::new("NLHUB").unwrap());
assert!(c.is_routing_platform());
assert_eq!(c.hub_party(), Some(PartyRef::new("NL", "HUB").unwrap()));
assert!(c.validate().is_ok());
}
#[test]
fn round_trips_the_spec_example() {
let json = r#"{"token":"ebf3b399-779f-4497-9b9d-ac6ad3cc44d2","url":"https://example.com/ocpi/versions","roles":[{"role":"CPO","business_details":{"name":"Example Operator"},"party_id":"EXA","country_code":"NL"}]}"#;
let c: Credentials = serde_json::from_str(json).unwrap();
assert_eq!(c.roles[0].role, Role::Cpo);
assert_eq!(serde_json::to_string(&c).unwrap(), json);
}
}