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#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn a_hub_party_id_of_the_wrong_length_says_so() {
238        let round_trip = PartyRef::new("NL", "TNM").unwrap();
239        assert_eq!(round_trip.to_hub_party_id().as_str(), "NLTNM");
240        assert_eq!(PartyRef::from_hub_party_id(&round_trip.to_hub_party_id()).unwrap(), round_trip);
241
242        // A peer that sends a short value gets a message about the length it should have been,
243        // not one claiming a two-character string is too long for five.
244        let short = CiString::<5>::new_lenient("NL");
245        let error = PartyRef::from_hub_party_id(&short).unwrap_err();
246        assert!(error.to_string().contains("exactly 5 characters"), "{error}");
247        assert!(!error.is_too_long());
248    }
249
250    #[test]
251    fn party_refs_compare_case_insensitively() {
252        assert_eq!(PartyRef::new("NL", "TNM").unwrap(), PartyRef::new("nl", "tnm").unwrap());
253        assert_eq!("NL/TNM".parse::<PartyRef>().unwrap(), PartyRef::new("NL", "TNM").unwrap());
254        assert!("NLTNM".parse::<PartyRef>().is_err());
255    }
256
257    #[test]
258    fn hub_party_id_is_the_concatenation() {
259        let p = PartyRef::new("NL", "TNM").unwrap();
260        let hub = p.to_hub_party_id();
261        assert_eq!(hub.as_str(), "NLTNM");
262        assert_eq!(PartyRef::from_hub_party_id(&hub).unwrap(), p);
263    }
264
265    #[test]
266    fn evse_id_parsing_accepts_both_forms_and_declines_others() {
267        let sep = EvseIdParts::parse("NL*TNM*E1234").unwrap();
268        assert_eq!(sep.to_separated(), "NL*TNM*E1234");
269        assert_eq!(EvseIdParts::parse("NLTNME1234").unwrap(), sep);
270        assert_eq!(sep.party().unwrap(), PartyRef::new("NL", "TNM").unwrap());
271        for other in ["", "short", "12*TNM*E1", "NL*TNM*X1234"] {
272            assert!(EvseIdParts::parse(other).is_none(), "{other} should not parse");
273        }
274    }
275
276    #[test]
277    fn country_code_shape_check_is_advisory() {
278        assert!(CountryCode::new("NL").unwrap().is_iso_shaped());
279        assert!(!CountryCode::new("N1").unwrap().is_iso_shaped());
280    }
281}