1use 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[builder(on(_, into))]
29pub struct Credentials {
30 pub token: OcpiString<64>,
40 pub url: Url,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub hub_party_id: Option<CiString<5>>,
50 pub roles: Vec<CredentialsRole>,
56 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
58 #[builder(default)]
59 pub extensions: Extensions,
60}
61
62impl Credentials {
63 pub fn parties(&self) -> impl Iterator<Item = PartyRef> + '_ {
65 self.roles.iter().map(CredentialsRole::party)
66 }
67
68 #[must_use]
70 pub fn hosts(&self, party: &PartyRef) -> bool {
71 self.roles.iter().any(|r| &r.party() == party)
72 }
73
74 #[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 #[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 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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156#[builder(on(_, into))]
157pub struct CredentialsRole {
158 pub role: Role,
160 pub business_details: BusinessDetails,
162 pub party_id: PartyId,
164 pub country_code: CountryCode,
166 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
168 #[builder(default)]
169 pub extensions: Extensions,
170}
171
172impl CredentialsRole {
173 #[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}