Skip to main content

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}