Skip to main content

ocpi_kit/v2_3_0/
credentials.rs

1//! The *Credentials* module of OCPI 2.3.0: the registration handshake.
2//!
3//! *Module Identifier: `credentials`* — required for all implementations.
4//!
5//! This module is symmetric: every platform both calls it and answers it. The token exchange it
6//! performs — `CREDENTIALS_TOKEN_A` out of band, `B` in the POST, `C` in the response — is
7//! modelled as a typestate in [`Registration`](crate::client::Registration), which makes the classic mistakes
8//! (using `TOKEN_A` after registration, POSTing twice) unrepresentable.
9//!
10//! Spec: 2.3.0 §credentials_credentials_endpoint
11
12use bon::Builder;
13use serde::{Deserialize, Serialize};
14
15use crate::types::validate_fields;
16use crate::types::{
17    CiString, CountryCode, Extensions, OcpiString, PartyId, PartyRef, Url, Validate, Validator, ViolationCode,
18};
19
20use super::locations::BusinessDetails;
21use super::types::Role;
22
23/// The credentials one platform gives another.
24///
25/// Spec: 2.3.0 §credentials_credentials_object
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[builder(on(_, into))]
29pub struct Credentials {
30    /// The credentials token for the other party to authenticate in your system.
31    ///
32    /// > *It should only contain printable non-whitespace ASCII characters, that is, characters
33    /// > with Unicode code points from the range of U+0021 up to and including U+007E.*
34    ///
35    /// This is the token in cleartext — the value that goes, Base64-encoded, into the peer's
36    /// `Authorization` header. [`CredentialsToken`](crate::transport::CredentialsToken) is the
37    /// type to hold it in once it leaves this object: it redacts itself in `Debug`, compares in
38    /// constant time and is zeroised on drop.
39    pub token: OcpiString<64>,
40    /// The URL to your API versions endpoint.
41    pub url: Url,
42    /// The Hub party of this platform, as a five-character `<country><party>` string.
43    ///
44    /// > *A Platform that supports Hub functionality with the Message routing headers SHALL give
45    /// > the country code and party ID of the Hub in the `hub_party_id` field.*
46    ///
47    /// New in OCPI 2.3.0. Use [`PartyRef::from_hub_party_id`] to split it.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub hub_party_id: Option<CiString<5>>,
50    /// The roles this platform provides. Cardinality `+`.
51    ///
52    /// > *NOTE: In OCPI 2.3.0, unlike in OCPI 2.2 or 2.2.1, Roaming Hubs' platforms are expected
53    /// > to include the parties that are reachable through the Roaming Hub in the list in
54    /// > `roles`.*
55    pub roles: Vec<CredentialsRole>,
56    /// Undocumented JSON fields, preserved verbatim.
57    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
58    #[builder(default)]
59    pub extensions: Extensions,
60}
61
62impl Credentials {
63    /// Every party this platform speaks for.
64    pub fn parties(&self) -> impl Iterator<Item = PartyRef> + '_ {
65        self.roles.iter().map(CredentialsRole::party)
66    }
67
68    /// Whether this platform hosts the given party.
69    #[must_use]
70    pub fn hosts(&self, party: &PartyRef) -> bool {
71        self.roles.iter().any(|r| &r.party() == party)
72    }
73
74    /// The hub this platform routes through, if it declared one.
75    #[must_use]
76    pub fn hub_party(&self) -> Option<PartyRef> {
77        self.hub_party_id.as_ref().and_then(|id| PartyRef::from_hub_party_id(id).ok())
78    }
79
80    /// Whether this platform advertises itself as a routing platform.
81    ///
82    /// A hub is recognised by the presence of `hub_party_id`, not by a role: OCPI 2.3.0 removed
83    /// 2.2.1's `HUB` role value.
84    #[must_use]
85    pub fn is_routing_platform(&self) -> bool {
86        self.hub_party_id.is_some()
87    }
88}
89
90impl Validate for Credentials {
91    fn validate_in(&self, v: &mut Validator) {
92        validate_fields!(self, v, token, url, hub_party_id, roles);
93
94        if self.roles.is_empty() {
95            v.report_at(
96                "roles",
97                ViolationCode::EmptyRequiredList,
98                "Credentials has cardinality `+` roles: at least one is required",
99            );
100        }
101
102        // "Every role needs a unique combination of: role, party_id and country_code."
103        let mut seen: Vec<(Role, PartyRef)> = Vec::new();
104        for (i, role) in self.roles.iter().enumerate() {
105            let key = (role.role, role.party());
106            if seen.contains(&key) {
107                v.enter("roles");
108                v.enter(&i.to_string());
109                v.report(
110                    ViolationCode::Inconsistent,
111                    format!(
112                        "the combination {} / {} appears more than once; every role needs a \
113                         unique combination of role, party_id and country_code",
114                        key.0, key.1
115                    ),
116                );
117                v.leave();
118                v.leave();
119            }
120            seen.push(key);
121        }
122
123        // "It should only contain printable non-whitespace ASCII characters, U+0021..U+007E."
124        if let Some(bad) = self.token.as_str().chars().find(|c| !matches!(c, '!'..='~')) {
125            v.report_at(
126                "token",
127                ViolationCode::IllegalCharacter,
128                format!("a credentials token may only contain U+0021..U+007E; found U+{:04X}", bad as u32),
129            );
130        }
131        if self.token.is_empty() {
132            v.report_at("token", ViolationCode::IllegalCharacter, "a credentials token cannot be empty");
133        }
134
135        if let Some(hub) = self.hub_party_id.as_ref()
136            && hub.len() != 5
137        {
138            v.report_at(
139                "hub_party_id",
140                ViolationCode::Inconsistent,
141                "must be exactly five characters: a two-letter country code followed by a \
142                     three-character party ID",
143            );
144        }
145    }
146}
147
148/// One role a platform provides, with the party that fills it.
149///
150/// > *A platform can have the same role more than once, each with its own unique `party_id` and
151/// > `country_code`, for example when a CPO provides 'white-label' services for 'virtual' CPOs.*
152///
153/// Spec: 2.3.0 §credentials_credentials_role_class
154#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156#[builder(on(_, into))]
157pub struct CredentialsRole {
158    /// Type of role.
159    pub role: Role,
160    /// Details of this party.
161    pub business_details: BusinessDetails,
162    /// CPO, eMSP (or other role) ID of this party.
163    pub party_id: PartyId,
164    /// ISO-3166 alpha-2 country code of the country this party is operating in.
165    pub country_code: CountryCode,
166    /// Undocumented JSON fields, preserved verbatim.
167    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
168    #[builder(default)]
169    pub extensions: Extensions,
170}
171
172impl CredentialsRole {
173    /// The party filling this role.
174    #[must_use]
175    pub fn party(&self) -> PartyRef {
176        PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
177    }
178}
179
180impl Validate for CredentialsRole {
181    fn validate_in(&self, v: &mut Validator) {
182        validate_fields!(self, v, role, business_details, party_id, country_code);
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn role(role: Role, country: &str, party: &str) -> CredentialsRole {
191        CredentialsRole::builder()
192            .role(role)
193            .business_details(BusinessDetails::builder().name("Example Operations").build())
194            .party_id(party)
195            .country_code(country)
196            .build()
197    }
198
199    fn credentials(roles: Vec<CredentialsRole>) -> Credentials {
200        Credentials::builder()
201            .token("ebf3b399-779f-4497-9b9d-ac6ad3cc44d2")
202            .url(Url::new("https://example.com/ocpi/versions").unwrap())
203            .roles(roles)
204            .build()
205    }
206
207    #[test]
208    fn role_combinations_must_be_unique() {
209        let ok = credentials(vec![role(Role::Cpo, "NL", "TNM"), role(Role::Emsp, "NL", "TNM")]);
210        assert!(ok.validate().is_ok(), "the same party in two roles is allowed");
211
212        let dup = credentials(vec![role(Role::Cpo, "NL", "TNM"), role(Role::Cpo, "nl", "tnm")]);
213        let err = dup.validate().unwrap_err();
214        assert_eq!(err.as_slice()[0].pointer, "/roles/1", "party ids compare case-insensitively");
215    }
216
217    #[test]
218    fn white_label_platforms_may_repeat_a_role() {
219        let c = credentials(vec![
220            role(Role::Cpo, "NL", "TNM"),
221            role(Role::Cpo, "NL", "ABC"),
222            role(Role::Cpo, "DE", "TNM"),
223        ]);
224        assert!(c.validate().is_ok());
225        assert_eq!(c.parties().count(), 3);
226        assert!(c.hosts(&PartyRef::new("de", "tnm").unwrap()));
227    }
228
229    #[test]
230    fn the_token_charset_is_narrower_than_cistring() {
231        let mut c = credentials(vec![role(Role::Cpo, "NL", "TNM")]);
232        c.token = OcpiString::new("has a space").unwrap();
233        let err = c.validate().unwrap_err();
234        assert_eq!(err.as_slice()[0].code, ViolationCode::IllegalCharacter);
235    }
236
237    #[test]
238    fn a_hub_is_recognised_by_hub_party_id_not_by_a_role() {
239        let mut c = credentials(vec![role(Role::Cpo, "NL", "TNM")]);
240        assert!(!c.is_routing_platform());
241        c.hub_party_id = Some(CiString::new("NLHUB").unwrap());
242        assert!(c.is_routing_platform());
243        assert_eq!(c.hub_party(), Some(PartyRef::new("NL", "HUB").unwrap()));
244        assert!(c.validate().is_ok());
245    }
246
247    #[test]
248    fn round_trips_the_spec_example() {
249        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"}]}"#;
250        let c: Credentials = serde_json::from_str(json).unwrap();
251        assert_eq!(c.roles[0].role, Role::Cpo);
252        assert_eq!(serde_json::to_string(&c).unwrap(), json);
253    }
254}