ocpi_kit/types/ids.rs
1//! The identifier types OCPI reuses across every module.
2
3use core::fmt;
4use core::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8use super::cistring::CiString;
9use super::string::OcpiString;
10use super::text::InvalidString;
11use super::validate::{Validate, Validator};
12use super::validate_fields;
13
14/// ISO-3166 alpha-2 country code of the party that owns an object.
15///
16/// The spec types this as `CiString(2)`, *not* as "a valid ISO 3166 code", so this crate does not
17/// reject an unassigned code. [`CountryCode::is_iso_shaped`] answers the stricter question.
18///
19/// Spec: 2.3.0 §credentials_credentials_role_class
20pub type CountryCode = CiString<2>;
21
22/// CPO, eMSP or other role ID of a party, following ISO-15118.
23///
24/// Spec: 2.3.0 §credentials_credentials_role_class
25pub type PartyId = CiString<3>;
26
27/// ISO-4217 currency code.
28///
29/// Spec: 2.3.0 §mod_cdrs_cdr_object — `currency`
30pub type Currency = OcpiString<3>;
31
32/// An EVSE ID in the eMI3/IDACS format, as used in `EVSE.evse_id`.
33///
34/// Spec: 2.3.0 §mod_locations_evse_object
35pub type EvseId = CiString<48>;
36
37/// A contract ID (eMA ID) identifying an EV driver's contract at an eMSP.
38///
39/// Spec: 2.3.0 §mod_tokens_token_object — `contract_id`
40pub type ContractId = CiString<36>;
41
42/// Additional checks for [`CountryCode`] values.
43pub trait CountryCodeExt {
44 /// Whether the value has the shape of an ISO-3166 alpha-2 code: two ASCII letters.
45 ///
46 /// This does not check the code against the ISO register, which changes over time and which
47 /// the OCPI spec does not require a party to know.
48 fn is_iso_shaped(&self) -> bool;
49}
50
51impl CountryCodeExt for CountryCode {
52 fn is_iso_shaped(&self) -> bool {
53 self.len() == 2 && self.as_str().bytes().all(|b| b.is_ascii_alphabetic())
54 }
55}
56
57/// The `country_code` + `party_id` pair that identifies one OCPI party.
58///
59/// This is the pair that appears in the `OCPI-to-*` and `OCPI-from-*` routing headers, in the
60/// URL of every client-owned object, and in the `roles` of a `Credentials` object. Because
61/// [`CiString`] compares case-insensitively, `NL/TNM` and `nl/tnm` are the same party.
62///
63/// ```
64/// use ocpi_kit::types::PartyRef;
65///
66/// let a: PartyRef = "NL/TNM".parse().unwrap();
67/// let b = PartyRef::new("nl", "tnm").unwrap();
68/// assert_eq!(a, b);
69/// assert_eq!(a.to_string(), "NL/TNM");
70/// ```
71///
72/// Spec: 2.3.0 §transport_and_format_message_routing
73#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75pub struct PartyRef {
76 /// ISO-3166 alpha-2 country code of the party.
77 pub country_code: CountryCode,
78 /// The party's ID.
79 pub party_id: PartyId,
80}
81
82impl PartyRef {
83 /// Creates a party reference.
84 ///
85 /// # Errors
86 ///
87 /// Returns [`InvalidString`] if either part is not printable ASCII of the right length.
88 pub fn new(country_code: impl Into<String>, party_id: impl Into<String>) -> Result<Self, InvalidString> {
89 Ok(Self { country_code: CiString::new(country_code)?, party_id: CiString::new(party_id)? })
90 }
91
92 /// The five-character concatenation used by `Credentials.hub_party_id`.
93 ///
94 /// Spec: 2.3.0 §credentials_credentials_object — *"The two-letter country code and
95 /// three-character party ID are concatenated together in this field as one five-character
96 /// string."*
97 #[must_use]
98 pub fn to_hub_party_id(&self) -> CiString<5> {
99 CiString::new_lenient(format!("{}{}", self.country_code, self.party_id))
100 }
101
102 /// Splits a five-character `hub_party_id` back into its country code and party ID.
103 ///
104 /// # Errors
105 ///
106 /// Returns [`InvalidString`] if the value is not exactly five printable ASCII characters.
107 pub fn from_hub_party_id(value: &CiString<5>) -> Result<Self, InvalidString> {
108 let text = value.as_str();
109 if text.len() != 5 {
110 return Err(InvalidString::wrong_length(text.len(), 5, super::text::StringKind::Ci));
111 }
112 Self::new(&text[..2], &text[2..])
113 }
114}
115
116impl fmt::Display for PartyRef {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 write!(f, "{}/{}", self.country_code, self.party_id)
119 }
120}
121
122impl FromStr for PartyRef {
123 type Err = InvalidPartyRef;
124 fn from_str(s: &str) -> Result<Self, Self::Err> {
125 let (country, party) = s
126 .split_once(['/', '*'])
127 .ok_or_else(|| InvalidPartyRef(format!("{s:?} is not \"<country>/<party>\"")))?;
128 Self::new(country, party).map_err(|e| InvalidPartyRef(e.to_string()))
129 }
130}
131
132impl Validate for PartyRef {
133 fn validate_in(&self, v: &mut Validator) {
134 validate_fields!(self, v, country_code, party_id);
135 }
136}
137
138/// Why a string is not a `country_code`/`party_id` pair.
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub struct InvalidPartyRef(String);
141
142impl fmt::Display for InvalidPartyRef {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 write!(f, "invalid party reference: {}", self.0)
145 }
146}
147impl std::error::Error for InvalidPartyRef {}
148
149/// The parts of an EVSE ID that follows the eMI3/IDACS format.
150///
151/// > *Compliant with the following specification for EVSE ID: "E-mobility ID-codes: the purpose
152/// > of IDs, ID usage and ID format".*
153///
154/// The format is `<country code><spot operator><'E'><power outlet id>`, with `*` optionally
155/// separating the parts: `NL*TNM*E1234` and `NLTNME1234` are the same EVSE.
156///
157/// The `evse_id` field is only *recommended* to follow this format, so parsing is a query, never
158/// a requirement: [`EvseIdParts::parse`] returns `None` for an ID in any other shape and the
159/// crate carries on.
160///
161/// ```
162/// use ocpi_kit::types::EvseIdParts;
163///
164/// let parts = EvseIdParts::parse("NL*TNM*E1234").unwrap();
165/// assert_eq!(parts.country_code, "NL");
166/// assert_eq!(parts.spot_operator, "TNM");
167/// assert_eq!(parts.power_outlet_id, "1234");
168/// assert_eq!(EvseIdParts::parse("NLTNME1234").unwrap(), parts);
169/// assert!(EvseIdParts::parse("some-internal-id").is_none());
170/// ```
171#[derive(Clone, Debug, PartialEq, Eq)]
172pub struct EvseIdParts {
173 /// The two-letter country code of the spot operator.
174 pub country_code: String,
175 /// The three-character spot operator ID.
176 pub spot_operator: String,
177 /// The power outlet ID: the part after the `E` type marker.
178 pub power_outlet_id: String,
179}
180
181impl EvseIdParts {
182 /// Parses an eMI3/IDACS EVSE ID, or returns `None` if `id` is in another shape.
183 #[must_use]
184 pub fn parse(id: &str) -> Option<Self> {
185 let stripped: String = id.chars().filter(|c| *c != '*').collect();
186 // <2 country><3 operator><'E'><1+ outlet>
187 if stripped.len() < 7 {
188 return None;
189 }
190 let bytes = stripped.as_bytes();
191 if !bytes[..5].iter().all(u8::is_ascii_alphanumeric) {
192 return None;
193 }
194 if !bytes[..2].iter().all(u8::is_ascii_alphabetic) {
195 return None;
196 }
197 if !bytes[5].eq_ignore_ascii_case(&b'E') {
198 return None;
199 }
200 let outlet = &stripped[6..];
201 if outlet.is_empty() || !outlet.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'*' || b == b'-') {
202 return None;
203 }
204 Some(Self {
205 country_code: stripped[..2].to_owned(),
206 spot_operator: stripped[2..5].to_owned(),
207 power_outlet_id: outlet.to_owned(),
208 })
209 }
210
211 /// The party that operates this EVSE, according to the ID.
212 ///
213 /// The spec warns that this need not be the OCPI `party_id` that pushed the object:
214 /// *"A party implementing OCPI MAY push EVSE IDs with an eMI3/IDACS spot operator different
215 /// from the OCPI party_id."*
216 ///
217 /// # Errors
218 ///
219 /// Returns [`InvalidString`] if the parts are not valid `CiString`s, which cannot happen for
220 /// a value that came out of [`EvseIdParts::parse`].
221 pub fn party(&self) -> Result<PartyRef, InvalidString> {
222 PartyRef::new(self.country_code.clone(), self.spot_operator.clone())
223 }
224
225 /// Renders the ID in the separated form, `NL*TNM*E1234`.
226 #[must_use]
227 pub fn to_separated(&self) -> String {
228 format!("{}*{}*E{}", self.country_code, self.spot_operator, self.power_outlet_id)
229 }
230}
231
232/// The parts of an eMI3/IDACS **Contract ID** (eMAID), when it follows the recommended format.
233///
234/// > *Recommended to follow the specification for eMA ID from "E-mobility ID-codes: the purpose
235/// > of IDs, ID usage and ID format".*
236///
237/// The format is `<country><provider><instance>[<check>]`: two letters, three alphanumerics, nine
238/// alphanumerics, and an optional check character. A hyphen may separate all three positions —
239/// *"if the hyphenated representation is chosen, the separators must be set at all three
240/// places"* — and is for human reading only; the IDACS white paper advises against sending it
241/// between systems.
242///
243/// **This is what makes a whitelist match.** `contract_id` is a [`CiString`], so case already does
244/// not matter, but `DE-8AA-CA2B3C4D5-N` and `DE8AACA2B3C4D5N` are the same contract written two
245/// ways, and comparing the strings says they are not. [`normalise`](Self::normalise) folds both to
246/// one key.
247///
248/// Like [`EvseIdParts`], parsing is a query rather than a requirement: the format is *recommended*
249/// by OCPI, not mandated, so an id in any other shape returns `None` and the crate carries on.
250///
251/// # What this cannot tell you
252///
253/// The format has no marker to match on — it is *"two letters, then twelve or thirteen
254/// alphanumerics"* — so any id of that shape parses, including one that is not an eMAID at all.
255/// `some-internal-id` is fourteen characters once the hyphens go, and comes back as a contract in
256/// `SO` issued by provider `MEI`.
257///
258/// That costs a whitelist nothing, because [`normalise`](Self::normalise) is a *function*: the
259/// same id always folds to the same key, whether or not it was really an eMAID. It does mean
260/// [`party`](Self::party) is only meaningful for an id you already know follows the format. The
261/// instance conventionally begins with `C` — *"strongly recommended to use the type-ID C as first
262/// character"* — which is a useful signal, but a recommendation is not something this crate will
263/// reject a conformant id over.
264///
265/// ```
266/// use ocpi_kit::types::ContractIdParts;
267///
268/// let parts = ContractIdParts::parse("DE-8AA-CA2B3C4D5-N").unwrap();
269/// assert_eq!(parts.country_code, "DE");
270/// assert_eq!(parts.provider_id, "8AA");
271/// assert_eq!(parts.instance, "CA2B3C4D5");
272/// assert_eq!(parts.check_digit, Some('N'));
273///
274/// // The same contract, written three ways, folds to one key.
275/// let key = ContractIdParts::normalise("DE-8AA-CA2B3C4D5-N").unwrap();
276/// assert_eq!(key, "DE8AACA2B3C4D5N");
277/// assert_eq!(ContractIdParts::normalise("de8aaca2b3c4d5n").unwrap(), key);
278/// assert_eq!(ContractIdParts::normalise("DE*8AA*CA2B3C4D5*N").unwrap(), key);
279///
280/// // Anything not of that shape is `None`, and so is anything too short or too long.
281/// assert!(ContractIdParts::parse("DE8AA").is_none());
282/// assert!(ContractIdParts::parse("12-8AA-CA2B3C4D5-N").is_none()); // country is not letters
283/// ```
284///
285/// Spec: 2.3.0 §mod_cdrs_cdr_token_class, §mod_tokens_token_object
286#[derive(Clone, Debug, PartialEq, Eq)]
287pub struct ContractIdParts {
288 /// The two-letter ISO 3166-1 alpha-2 country code of the provider.
289 pub country_code: String,
290 /// The three-character provider ID, assigned by the eMI3 group.
291 pub provider_id: String,
292 /// The nine-character instance, whose first character is conventionally `C`.
293 pub instance: String,
294 /// The check character, which the format marks optional.
295 pub check_digit: Option<char>,
296}
297
298impl ContractIdParts {
299 /// Parses an eMI3/IDACS contract ID, or returns `None` if `id` is in another shape.
300 ///
301 /// Both separators seen in the field are accepted — `-`, which the contract-ID format
302 /// specifies, and `*`, which the EVSE-ID format uses and which some platforms carry over.
303 #[must_use]
304 pub fn parse(id: &str) -> Option<Self> {
305 let stripped: String = id.chars().filter(|c| *c != '-' && *c != '*').collect();
306 // <2 country><3 provider><9 instance>[<1 check>]
307 if !(stripped.len() == 14 || stripped.len() == 15) {
308 return None;
309 }
310 if !stripped.bytes().all(|b| b.is_ascii_alphanumeric()) {
311 return None;
312 }
313 if !stripped.as_bytes()[..2].iter().all(u8::is_ascii_alphabetic) {
314 return None;
315 }
316 Some(Self {
317 country_code: stripped[..2].to_owned(),
318 provider_id: stripped[2..5].to_owned(),
319 instance: stripped[5..14].to_owned(),
320 check_digit: stripped[14..].chars().next(),
321 })
322 }
323
324 /// The one key two spellings of the same contract share: upper case, no separators.
325 ///
326 /// This is the form to key a whitelist on. Returns `None` for an id that does not follow the
327 /// format, which a caller should treat as "not comparable" rather than as "no match" — an
328 /// eMSP is free to use its own scheme.
329 #[must_use]
330 pub fn normalise(id: &str) -> Option<String> {
331 Self::parse(id).map(|p| p.to_compact())
332 }
333
334 /// The provider that issued this contract, according to the ID.
335 ///
336 /// As with [`EvseIdParts::party`], this need not be the OCPI `party_id` that pushed the
337 /// object: *"The `party_id` and `country_code` given here have no direct link with the
338 /// eMI3/IDACS format EVSE IDs and Contract IDs."*
339 ///
340 /// # Errors
341 ///
342 /// Returns [`InvalidString`] if the parts are not valid `CiString`s, which cannot happen for
343 /// a value that came out of [`ContractIdParts::parse`].
344 pub fn party(&self) -> Result<PartyRef, InvalidString> {
345 PartyRef::new(self.country_code.clone(), self.provider_id.clone())
346 }
347
348 /// The ID with no separators and in upper case: `DE8AACA2B3C4D5N`.
349 ///
350 /// > *Companies are strongly advised NOT to use the optional separators between IT systems as
351 /// > they are meant for visibility only.*
352 #[must_use]
353 pub fn to_compact(&self) -> String {
354 let mut out = String::with_capacity(15);
355 out.push_str(&self.country_code);
356 out.push_str(&self.provider_id);
357 out.push_str(&self.instance);
358 if let Some(check) = self.check_digit {
359 out.push(check);
360 }
361 out.make_ascii_uppercase();
362 out
363 }
364
365 /// The ID in the hyphenated form a human reads: `DE-8AA-CA2B3C4D5-N`.
366 #[must_use]
367 pub fn to_separated(&self) -> String {
368 let compact = self.to_compact();
369 let mut out = format!("{}-{}-{}", &compact[..2], &compact[2..5], &compact[5..14]);
370 if let Some(check) = compact[14..].chars().next() {
371 out.push('-');
372 out.push(check);
373 }
374 out
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn a_hub_party_id_of_the_wrong_length_says_so() {
384 let round_trip = PartyRef::new("NL", "TNM").unwrap();
385 assert_eq!(round_trip.to_hub_party_id().as_str(), "NLTNM");
386 assert_eq!(PartyRef::from_hub_party_id(&round_trip.to_hub_party_id()).unwrap(), round_trip);
387
388 // A peer that sends a short value gets a message about the length it should have been,
389 // not one claiming a two-character string is too long for five.
390 let short = CiString::<5>::new_lenient("NL");
391 let error = PartyRef::from_hub_party_id(&short).unwrap_err();
392 assert!(error.to_string().contains("exactly 5 characters"), "{error}");
393 assert!(!error.is_too_long());
394 }
395
396 #[test]
397 fn party_refs_compare_case_insensitively() {
398 assert_eq!(PartyRef::new("NL", "TNM").unwrap(), PartyRef::new("nl", "tnm").unwrap());
399 assert_eq!("NL/TNM".parse::<PartyRef>().unwrap(), PartyRef::new("NL", "TNM").unwrap());
400 assert!("NLTNM".parse::<PartyRef>().is_err());
401 }
402
403 #[test]
404 fn hub_party_id_is_the_concatenation() {
405 let p = PartyRef::new("NL", "TNM").unwrap();
406 let hub = p.to_hub_party_id();
407 assert_eq!(hub.as_str(), "NLTNM");
408 assert_eq!(PartyRef::from_hub_party_id(&hub).unwrap(), p);
409 }
410
411 #[test]
412 fn evse_id_parsing_accepts_both_forms_and_declines_others() {
413 let sep = EvseIdParts::parse("NL*TNM*E1234").unwrap();
414 assert_eq!(sep.to_separated(), "NL*TNM*E1234");
415 assert_eq!(EvseIdParts::parse("NLTNME1234").unwrap(), sep);
416 assert_eq!(sep.party().unwrap(), PartyRef::new("NL", "TNM").unwrap());
417 for other in ["", "short", "12*TNM*E1", "NL*TNM*X1234"] {
418 assert!(EvseIdParts::parse(other).is_none(), "{other} should not parse");
419 }
420 }
421
422 #[test]
423 fn country_code_shape_check_is_advisory() {
424 assert!(CountryCode::new("NL").unwrap().is_iso_shaped());
425 assert!(!CountryCode::new("N1").unwrap().is_iso_shaped());
426 }
427}