ocpi_kit/v2_3_0/tokens.rs
1//! The *Tokens* module of OCPI 2.3.0: which drivers may charge, and real-time authorization.
2//!
3//! *Module Identifier: `tokens`* — Data owner: eMSP.
4//!
5//! Spec: 2.3.0 §mod_tokens_tokens_module
6
7use bon::Builder;
8use serde::{Deserialize, Serialize};
9
10use crate::ocpi_enum;
11use crate::ocpi_open_enum;
12use crate::types::validate_fields;
13use crate::types::{
14 CiString, ContractId, CountryCode, DateTime, DisplayText, Extensions, OcpiString, PartyId, PartyRef,
15 Validate, Validator, ViolationCode,
16};
17
18use super::sessions::ProfileType;
19
20/// A token an EV driver uses to authorize charging.
21///
22/// > *The combination of `uid` and `type` should be unique for every token within the eMSP's
23/// > system.*
24///
25/// Spec: 2.3.0 §mod_tokens_token_object
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[builder(on(_, into))]
29pub struct Token {
30 /// ISO-3166 alpha-2 country code of the MSP that 'owns' this Token.
31 pub country_code: CountryCode,
32 /// ID of the eMSP that 'owns' this Token.
33 pub party_id: PartyId,
34 /// Unique ID by which this Token, combined with its type, can be identified.
35 ///
36 /// > *This is the field used by CPO system (RFID reader on the Charge Point) to identify
37 /// > this token. … This field is named `uid` instead of `id` to prevent confusion with
38 /// > `contract_id`.*
39 pub uid: CiString<36>,
40 /// Type of the token.
41 #[serde(rename = "type")]
42 pub token_type: TokenType,
43 /// Uniquely identifies the EV driver contract token within the eMSP's platform.
44 pub contract_id: ContractId,
45 /// Visual readable number/identification as printed on the Token.
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub visual_number: Option<OcpiString<64>>,
48 /// Issuing company, most of the time the name printed on the token.
49 pub issuer: OcpiString<64>,
50 /// Groups a couple of tokens so a session started with one can be stopped with another.
51 ///
52 /// > *Beware that OCPP 1.5/1.6 only support group_ids (parentId in OCPP 1.5/1.6) with a
53 /// > maximum length of 20.*
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub group_id: Option<CiString<36>>,
56 /// Whether this Token is valid.
57 pub valid: bool,
58 /// What type of white-listing is allowed.
59 ///
60 /// > *NOTE: The eMSP is RECOMMENDED to push Tokens with type `AD_HOC_USER` or `APP_USER`
61 /// > with `whitelist` set to `NEVER`. Whitelists are very useful for RFID type Tokens, but
62 /// > the `AD_HOC_USER`/`APP_USER` Tokens are used to start Sessions from an App etc. so
63 /// > whitelisting them has no advantages.*
64 ///
65 /// That is a recommendation, not a rule — the specification's own `APP_USER` example uses
66 /// `ALLOWED` — so [`Validate`] does not report it. Ask
67 /// [`Token::follows_whitelist_recommendation`] when you want to check it.
68 pub whitelist: WhitelistType,
69 /// Language Code ISO 639-1: the Token owner's preferred interface language.
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub language: Option<OcpiString<2>>,
72 /// The default Charging Preference profile type.
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub default_profile_type: Option<ProfileType>,
75 /// The driver's own energy supplier/contract, where the Charge Point supports using it.
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub energy_contract: Option<EnergyContract>,
78 /// Timestamp when this Token was last updated (or created).
79 pub last_updated: DateTime,
80 /// Undocumented JSON fields, preserved verbatim.
81 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
82 #[builder(default)]
83 pub extensions: Extensions,
84}
85
86impl Token {
87 /// The eMSP that owns this Token.
88 #[must_use]
89 pub fn owner_party(&self) -> PartyRef {
90 PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
91 }
92
93 /// Whether this Token follows the specification's advice on whitelisting.
94 ///
95 /// > *The eMSP is RECOMMENDED to push Tokens with type `AD_HOC_USER` or `APP_USER` with
96 /// > `whitelist` set to `NEVER`.*
97 ///
98 /// A recommendation, not a rule: the spec's own `APP_USER` example does not follow it, so
99 /// this is a query rather than a [`Validate`] violation.
100 ///
101 /// Spec: 2.3.0 §mod_tokens_tokentype_enum
102 #[must_use]
103 pub fn follows_whitelist_recommendation(&self) -> bool {
104 !matches!(self.token_type, TokenType::AdHocUser | TokenType::AppUser)
105 || self.whitelist == WhitelistType::Never
106 }
107
108 /// What a CPO should do with this Token when a driver presents it.
109 ///
110 /// This turns the `whitelist` field plus the CPO's current connectivity into the one decision
111 /// a charging backend actually has to make. See [`AuthorizationDecision`].
112 ///
113 /// > *The validity of a Token has no influence on this. If a Token is `valid = false`, when
114 /// > the `whitelist` field requires real-time authorization, the CPO SHALL do a real-time
115 /// > authorization, the state of the Token might have changed.*
116 ///
117 /// Spec: 2.3.0 §mod_tokens_whitelisttype_enum
118 #[must_use]
119 pub fn authorization_decision(&self, online: bool) -> AuthorizationDecision {
120 match self.whitelist {
121 // "CPO shall always allow any use of this Token."
122 WhitelistType::Always => AuthorizationDecision::AllowFromCache,
123 // "The CPO may choose which version of authorization to use."
124 WhitelistType::Allowed => {
125 if online {
126 AuthorizationDecision::AuthorizeRealtime
127 } else if self.valid {
128 AuthorizationDecision::AllowFromCache
129 } else {
130 AuthorizationDecision::Deny
131 }
132 }
133 // "In normal situations realtime authorization shall be used. But when the CPO cannot
134 // get a response from the eMSP … the CPO shall allow this Token to be used."
135 WhitelistType::AllowedOffline => {
136 if online {
137 AuthorizationDecision::AuthorizeRealtime
138 } else {
139 AuthorizationDecision::AllowFromCache
140 }
141 }
142 // "Whitelisting is forbidden, only realtime authorization is allowed."
143 WhitelistType::Never => {
144 if online {
145 AuthorizationDecision::AuthorizeRealtime
146 } else {
147 AuthorizationDecision::Deny
148 }
149 }
150 }
151 }
152}
153
154impl Validate for Token {
155 fn validate_in(&self, v: &mut Validator) {
156 validate_fields!(
157 self, v, country_code, party_id, uid, token_type as "type", contract_id,
158 visual_number, issuer, group_id, whitelist, language, default_profile_type,
159 energy_contract, last_updated,
160 );
161 if self.group_id.as_ref().is_some_and(|g| g.len() > 20) {
162 v.report_at(
163 "group_id",
164 ViolationCode::Inconsistent,
165 "OCPP 1.5/1.6 only supports group IDs up to 20 characters; the spec advises \
166 staying within that as long as drivers may charge at such a Charge Point",
167 );
168 }
169 }
170}
171
172/// What a CPO should do with a Token, given its whitelist setting and the current connectivity.
173///
174/// See [`Token::authorization_decision`].
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
176pub enum AuthorizationDecision {
177 /// Authorize from the locally cached Token, without contacting the eMSP.
178 AllowFromCache,
179 /// Perform a real-time authorization against the eMSP's Tokens Sender interface.
180 AuthorizeRealtime,
181 /// Refuse: whitelisting is forbidden for this Token and the eMSP cannot be reached.
182 Deny,
183}
184
185/// The response to a real-time authorization request.
186///
187/// Spec: 2.3.0 §mod_tokens_authorizationinfo_object
188#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190#[builder(on(_, into))]
191pub struct AuthorizationInfo {
192 /// Status of the Token, and whether charging is allowed at the optionally given location.
193 pub allowed: AllowedType,
194 /// The complete Token object for which this authorization was requested.
195 pub token: Token,
196 /// The location, if it was in the request and the driver may charge there.
197 ///
198 /// > *Only the EVSEs the EV driver is allowed to charge at are returned.*
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub location: Option<LocationReferences>,
201 /// Reference to the authorization, echoed later in the relevant Session and CDR.
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub authorization_reference: Option<CiString<36>>,
204 /// Additional information to display to the EV driver.
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub info: Option<DisplayText>,
207 /// Undocumented JSON fields, preserved verbatim.
208 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
209 #[builder(default)]
210 pub extensions: Extensions,
211}
212
213impl Validate for AuthorizationInfo {
214 fn validate_in(&self, v: &mut Validator) {
215 validate_fields!(self, v, allowed, token, location, authorization_reference, info);
216 if self.allowed != AllowedType::Allowed && self.location.is_some() {
217 v.report_at(
218 "location",
219 ViolationCode::Inconsistent,
220 "a location is only returned when the driver is allowed to charge there",
221 );
222 }
223 }
224}
225
226/// References to a location and the EVSEs within it.
227///
228/// Spec: 2.3.0 §mod_tokens_locationreferences_class
229#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
230#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
231pub struct LocationReferences {
232 /// Unique identifier for the location.
233 pub location_id: CiString<36>,
234 /// Unique identifiers for EVSEs within the given location.
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub evse_uids: Vec<CiString<36>>,
237 /// Undocumented JSON fields, preserved verbatim.
238 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
239 pub extensions: Extensions,
240}
241
242impl Validate for LocationReferences {
243 fn validate_in(&self, v: &mut Validator) {
244 validate_fields!(self, v, location_id, evse_uids);
245 }
246}
247
248/// A driver's own energy contract, for Charge Points that support using it.
249///
250/// > *NOTE: In a lot of countries it is currently not allowed/possible to use a driver's own
251/// > energy supplier/contract at a Charge Point.*
252///
253/// Spec: 2.3.0 §mod_tokens_energy_contract
254#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
255#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
256pub struct EnergyContract {
257 /// Name of the energy supplier for this token.
258 pub supplier_name: OcpiString<64>,
259 /// Contract ID at the energy supplier, belonging to the owner of this token.
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub contract_id: Option<OcpiString<64>>,
262 /// Undocumented JSON fields, preserved verbatim.
263 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
264 pub extensions: Extensions,
265}
266
267impl Validate for EnergyContract {
268 fn validate_in(&self, v: &mut Validator) {
269 validate_fields!(self, v, supplier_name, contract_id);
270 }
271}
272
273ocpi_enum! {
274 /// The outcome of a real-time authorization.
275 ///
276 /// Spec: 2.3.0 §mod_tokens_allowed_enum
277 pub enum AllowedType {
278 /// This Token is allowed to charge (at this location).
279 Allowed = "ALLOWED",
280 /// This Token is blocked.
281 Blocked = "BLOCKED",
282 /// This Token has expired.
283 Expired = "EXPIRED",
284 /// The account has not enough credits to charge (at the given location).
285 NoCredit = "NO_CREDIT",
286 /// Token is valid, but is not allowed to charge at the given location.
287 NotAllowed = "NOT_ALLOWED",
288 }
289}
290
291ocpi_open_enum! {
292 /// The type of a Token.
293 ///
294 /// Became an `OpenEnum` in OCPI 2.3.0, which also added `EMAID` for ISO 15118 Plug & Charge.
295 ///
296 /// > *NOTE: The eMSP is RECOMMENDED to not push Tokens with type `EMAID` at all. Exchanging
297 /// > Token objects for EMAID Tokens is not necessary because the CPO already learns which
298 /// > Party issued the Token from the Charging Station.*
299 ///
300 /// Spec: 2.3.0 §mod_tokens_tokentype_enum
301 pub enum TokenType {
302 /// One-time-use Token ID generated by a server or app.
303 AdHocUser = "AD_HOC_USER",
304 /// Token ID generated by a server or app to identify a user of an app.
305 AppUser = "APP_USER",
306 /// An EMAID, used when the Charging Station and vehicle speak ISO 15118.
307 Emaid = "EMAID",
308 /// Other type of token.
309 Other = "OTHER",
310 /// RFID Token.
311 Rfid = "RFID",
312 }
313}
314
315ocpi_enum! {
316 /// When authorization of a Token by the CPO is allowed without asking the eMSP.
317 ///
318 /// Spec: 2.3.0 §mod_tokens_whitelisttype_enum
319 pub enum WhitelistType {
320 /// Token always has to be whitelisted; real-time authorization is not possible.
321 Always = "ALWAYS",
322 /// Whitelisting is allowed and so is real-time authorization; the CPO chooses.
323 Allowed = "ALLOWED",
324 /// Real-time authorization normally, whitelist only when the eMSP cannot be reached.
325 AllowedOffline = "ALLOWED_OFFLINE",
326 /// Whitelisting is forbidden; only real-time authorization is allowed.
327 Never = "NEVER",
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 fn token(whitelist: WhitelistType, valid: bool) -> Token {
336 Token::builder()
337 .country_code("NL")
338 .party_id("TNM")
339 .uid("012345678")
340 .token_type(TokenType::Rfid)
341 .contract_id("NL-TNM-C12345678-X")
342 .issuer("TheNewMotion")
343 .valid(valid)
344 .whitelist(whitelist)
345 .last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
346 .build()
347 }
348
349 #[test]
350 fn whitelist_semantics_become_one_decision() {
351 use AuthorizationDecision::{AllowFromCache, AuthorizeRealtime, Deny};
352 // ALWAYS: "CPO shall always allow any use of this Token", online or not, valid or not.
353 for online in [true, false] {
354 assert_eq!(token(WhitelistType::Always, false).authorization_decision(online), AllowFromCache);
355 }
356 // NEVER: only real-time; offline means no charging.
357 assert_eq!(token(WhitelistType::Never, true).authorization_decision(true), AuthorizeRealtime);
358 assert_eq!(token(WhitelistType::Never, true).authorization_decision(false), Deny);
359 // ALLOWED_OFFLINE: real-time when possible, cache when not.
360 assert_eq!(token(WhitelistType::AllowedOffline, false).authorization_decision(false), AllowFromCache);
361 // ALLOWED: the CPO chooses; offline it falls back to the cached validity.
362 assert_eq!(token(WhitelistType::Allowed, false).authorization_decision(false), Deny);
363 assert_eq!(token(WhitelistType::Allowed, true).authorization_decision(false), AllowFromCache);
364 }
365
366 #[test]
367 fn the_whitelist_recommendation_is_a_query_not_a_violation() {
368 let mut t = token(WhitelistType::Allowed, true);
369 t.token_type = TokenType::AppUser;
370 assert!(!t.follows_whitelist_recommendation());
371 // The spec's own APP_USER example uses ALLOWED, so this must not be a violation.
372 assert!(t.validate().is_ok());
373 t.whitelist = WhitelistType::Never;
374 assert!(t.follows_whitelist_recommendation());
375 assert!(token(WhitelistType::Always, true).follows_whitelist_recommendation());
376 }
377
378 #[test]
379 fn long_group_ids_are_flagged_for_ocpp_compatibility() {
380 let mut t = token(WhitelistType::Allowed, true);
381 t.group_id = Some(CiString::new("G".repeat(21)).unwrap());
382 assert!(t.validate().unwrap_err().as_slice().iter().any(|x| x.pointer == "/group_id"));
383 t.group_id = Some(CiString::new("G".repeat(20)).unwrap());
384 assert!(t.validate().is_ok());
385 }
386
387 #[test]
388 fn round_trips_with_the_spec_field_names() {
389 let json = r#"{"country_code":"NL","party_id":"TNM","uid":"012345678","type":"RFID","contract_id":"NL-TNM-C12345678-X","issuer":"TheNewMotion","valid":true,"whitelist":"ALWAYS","last_updated":"2018-12-10T17:16:15Z"}"#;
390 let t: Token = serde_json::from_str(json).unwrap();
391 assert_eq!(t.token_type, TokenType::Rfid);
392 assert_eq!(serde_json::to_string(&t).unwrap(), json);
393 }
394}