Skip to main content

ocpi_kit/transport/
endpoints.rs

1//! Endpoint URL builders, one per documented URL shape.
2//!
3//! > *The URLs of the endpoints in this document are descriptive only. The exact URL can be found
4//! > by fetching the endpoint information from the API info endpoint.*
5//!
6//! So every builder here takes the **discovered** base URL of a module and appends only the parts
7//! the specification does define: the client-owned object path, the nested Location path, the
8//! command name, and so on. Nothing here invents a base path.
9//!
10//! Spec: 2.3.0 §transport_and_format_interface_endpoints,
11//! §transport_and_format_client_owned_object_push
12
13use crate::types::{PartyRef, Url};
14
15use super::pagination::PageQuery;
16
17/// URLs on a module's **Sender** interface: the data owner's own objects.
18///
19/// A Sender interface is addressed by object id alone, because the owner is the party being
20/// called.
21///
22/// Spec: 2.3.0 §mod_locations_cpo_interface and the equivalent section of each module
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct SenderEndpoint {
25    base: Url,
26}
27
28impl SenderEndpoint {
29    /// Wraps a discovered Sender endpoint URL.
30    #[must_use]
31    pub fn new(base: Url) -> Self {
32        Self { base }
33    }
34
35    /// The base URL as discovered.
36    #[must_use]
37    pub const fn base(&self) -> &Url {
38        &self.base
39    }
40
41    /// `GET {base}?[date_from]&[date_to]&[offset]&[limit]` — the paginated list.
42    #[must_use]
43    pub fn list(&self, query: &PageQuery) -> Url {
44        query.apply_to(&self.base)
45    }
46
47    /// `GET {base}/{id}` — one object.
48    #[must_use]
49    pub fn object(&self, id: &str) -> Url {
50        self.base.join(id)
51    }
52
53    /// `GET {locations}/{location_id}[/{evse_uid}[/{connector_id}]]`.
54    ///
55    /// Spec: 2.3.0 §mod_locations_get_object_request_parameters
56    #[must_use]
57    pub fn location(&self, location_id: &str, evse_uid: Option<&str>, connector_id: Option<&str>) -> Url {
58        let mut url = self.base.join(location_id);
59        if let Some(evse) = evse_uid {
60            url = url.join(evse);
61            if let Some(connector) = connector_id {
62                url = url.join(connector);
63            }
64        }
65        url
66    }
67
68    /// `POST {tokens}/{token_uid}/authorize[?type={type}]` — real-time authorization.
69    ///
70    /// Spec: 2.3.0 §mod_tokens_real-time_authorization
71    #[must_use]
72    pub fn token_authorize(&self, token_uid: &str, token_type: Option<&str>) -> Url {
73        let url = self.base.join(token_uid).join("authorize");
74        match token_type {
75            Some(t) => url.with_query(&format!("type={t}")),
76            None => url,
77        }
78    }
79
80    /// `PUT {sessions}/{session_id}/charging_preferences`.
81    ///
82    /// Spec: 2.3.0 §mod_sessions_set_charging_preferences
83    #[must_use]
84    pub fn charging_preferences(&self, session_id: &str) -> Url {
85        self.base.join(session_id).join("charging_preferences")
86    }
87
88    /// `POST {commands}/{command}` — a command request on the Receiver's Sender-side endpoint.
89    ///
90    /// Spec: 2.3.0 §mod_commands_commands_module
91    #[must_use]
92    pub fn command(&self, command: &str) -> Url {
93        self.base.join(command)
94    }
95
96    /// The Payments **terminals** sub-interface, `{payments}/terminals`.
97    ///
98    /// # Spec gap: one `ModuleID`, two endpoint URLs
99    ///
100    /// The Payments chapter declares *"Module Identifier: `payments`"* and then addresses its
101    /// two interfaces through two different variables,
102    /// `{payments_terminals_endpoint_url}` and
103    /// `{payments_financial_advice_confirmation_endpoint_url}`. Version discovery cannot express
104    /// that: an [`Endpoint`](crate::v2_3_0::versions::Endpoint) is keyed by `identifier` **and**
105    /// `role`, so one module and one interface role has exactly one URL. A PTP that advertised
106    /// both would have to publish two `payments`/`SENDER` endpoints, and a client reading them
107    /// would have no way to tell which was which.
108    ///
109    /// The reading this crate takes — and which the specification's own examples support, since
110    /// they are `…/payments/terminals/` and `…/payments/financial-advice-confirmations/` — is
111    /// that the discovered `payments` endpoint is the **base** the two hang off. This has been
112    /// reported upstream; until it is resolved, [`SenderEndpoint::payments_terminals`] and
113    /// [`SenderEndpoint::payments_financial_advice_confirmations`] also **tolerate** a peer that
114    /// advertised one of the sub-paths directly, so either reading interoperates.
115    #[must_use]
116    pub fn payments_terminals(&self) -> Self {
117        Self::new(sub_path(&self.base, "terminals"))
118    }
119
120    /// The Payments **financial advice confirmations** sub-interface. See
121    /// [`payments_terminals`](Self::payments_terminals) for why this is derived rather than
122    /// discovered.
123    #[must_use]
124    pub fn payments_financial_advice_confirmations(&self) -> Self {
125        Self::new(sub_path(&self.base, "financial-advice-confirmations"))
126    }
127
128    /// `{payments}/terminals/{terminal_id}` — one payment terminal.
129    ///
130    /// Call this on the endpoint from [`payments_terminals`](Self::payments_terminals).
131    #[must_use]
132    pub fn terminal(&self, terminal_id: &str) -> Url {
133        self.base.join(terminal_id)
134    }
135
136    /// `POST {payments}/terminals/{terminal_id}/deactivate`.
137    #[must_use]
138    pub fn terminal_deactivate(&self, terminal_id: &str) -> Url {
139        self.base.join(terminal_id).join("deactivate")
140    }
141
142    /// `POST {payments}/terminals/activate`.
143    #[must_use]
144    pub fn terminal_activate(&self) -> Url {
145        self.base.join("activate")
146    }
147}
148
149/// Appends `segment` to `base`, unless the peer already advertised it there.
150///
151/// The tolerance is deliberate: see [`SenderEndpoint::payments_terminals`]. It compares the last
152/// **path segment** rather than a suffix of the text, so an endpoint published at
153/// `…/payments/my-terminals` still gets `/terminals` appended instead of being mistaken for it.
154fn sub_path(base: &Url, segment: &str) -> Url {
155    let trimmed = base.as_str().trim_end_matches('/');
156    if trimmed.rsplit('/').next() == Some(segment) {
157        return Url::new_lenient(trimmed);
158    }
159    base.join(segment)
160}
161
162/// URLs on a module's **Receiver** interface: objects owned by the *client*.
163///
164/// > *Client Owned Object URL definition: `{base-ocpi-url}/{end-point}/{country-code}/{party-id}/
165/// > {object-id}`*
166/// >
167/// > *POST is not supported for these kinds of modules. PUT is used to send new objects to the
168/// > servers.*
169///
170/// Spec: 2.3.0 §transport_and_format_client_owned_object_push
171#[derive(Clone, Debug, PartialEq, Eq)]
172pub struct ReceiverEndpoint {
173    base: Url,
174}
175
176impl ReceiverEndpoint {
177    /// Wraps a discovered Receiver endpoint URL.
178    #[must_use]
179    pub fn new(base: Url) -> Self {
180        Self { base }
181    }
182
183    /// The base URL as discovered.
184    #[must_use]
185    pub const fn base(&self) -> &Url {
186        &self.base
187    }
188
189    /// `{base}/{country_code}/{party_id}/{object_id}`.
190    #[must_use]
191    pub fn object(&self, owner: &PartyRef, object_id: &str) -> Url {
192        self.base.join(owner.country_code.as_str()).join(owner.party_id.as_str()).join(object_id)
193    }
194
195    /// `{locations}/{country_code}/{party_id}/{location_id}[/{evse_uid}[/{connector_id}]]`.
196    ///
197    /// Spec: 2.3.0 §mod_locations_request_parameters_msp
198    #[must_use]
199    pub fn location(
200        &self,
201        owner: &PartyRef,
202        location_id: &str,
203        evse_uid: Option<&str>,
204        connector_id: Option<&str>,
205    ) -> Url {
206        let mut url = self.object(owner, location_id);
207        if let Some(evse) = evse_uid {
208            url = url.join(evse);
209            if let Some(connector) = connector_id {
210                url = url.join(connector);
211            }
212        }
213        url
214    }
215
216    /// `{chargingprofiles}/{session_id}[?duration=&response_url=]`.
217    ///
218    /// The Charging Profiles Receiver interface is keyed by session, not by owning party.
219    ///
220    /// Spec: 2.3.0 §mod_charging_profiles_module
221    #[must_use]
222    pub fn charging_profile(&self, session_id: &str) -> Url {
223        self.base.join(session_id)
224    }
225
226    /// `GET {chargingprofiles}/{session_id}?duration={duration}&response_url={response_url}`.
227    #[must_use]
228    pub fn active_charging_profile(
229        &self,
230        session_id: &str,
231        duration_seconds: u64,
232        response_url: &Url,
233    ) -> Url {
234        self.base.join(session_id).with_query(&format!(
235            "duration={duration_seconds}&response_url={}",
236            percent_encode(response_url.as_str())
237        ))
238    }
239
240    /// `DELETE {chargingprofiles}/{session_id}?response_url={response_url}`.
241    #[must_use]
242    pub fn clear_charging_profile(&self, session_id: &str, response_url: &Url) -> Url {
243        self.base
244            .join(session_id)
245            .with_query(&format!("response_url={}", percent_encode(response_url.as_str())))
246    }
247
248    /// `{tokens}/{country_code}/{party_id}/{token_uid}[?type={type}]`.
249    ///
250    /// > *`type`: Token.type of the Token of the Token object to retrieve. Default if omitted:
251    /// > RFID*
252    ///
253    /// Spec: 2.3.0 §mod_tokens_cpo_interface
254    #[must_use]
255    pub fn token(&self, owner: &PartyRef, token_uid: &str, token_type: Option<&str>) -> Url {
256        let url = self.object(owner, token_uid);
257        match token_type {
258            Some(t) => url.with_query(&format!("type={t}")),
259            None => url,
260        }
261    }
262
263    /// `{payments}/terminals` on a CPO's Receiver interface. See
264    /// [`SenderEndpoint::payments_terminals`] for why this is derived rather than discovered.
265    #[must_use]
266    pub fn payments_terminals(&self) -> Self {
267        Self::new(sub_path(&self.base, "terminals"))
268    }
269
270    /// `{payments}/financial-advice-confirmations` on a CPO's Receiver interface.
271    #[must_use]
272    pub fn payments_financial_advice_confirmations(&self) -> Self {
273        Self::new(sub_path(&self.base, "financial-advice-confirmations"))
274    }
275}
276
277/// Percent-encodes a URL so it can be carried as a query parameter value.
278fn percent_encode(value: &str) -> String {
279    let mut out = String::with_capacity(value.len() + 16);
280    for byte in value.bytes() {
281        match byte {
282            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
283                out.push(byte as char);
284            }
285            _ => {
286                use core::fmt::Write as _;
287                let _ = write!(out, "%{byte:02X}");
288            }
289        }
290    }
291    out
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn sender(path: &str) -> SenderEndpoint {
299        SenderEndpoint::new(Url::new(format!("https://e.com/ocpi/cpo/2.3.0/{path}")).unwrap())
300    }
301    fn receiver(path: &str) -> ReceiverEndpoint {
302        ReceiverEndpoint::new(Url::new(format!("https://e.com/ocpi/emsp/2.3.0/{path}")).unwrap())
303    }
304
305    #[test]
306    fn client_owned_object_urls_match_the_spec_example() {
307        // "https://www.server.com/ocpi/cpo/2.2.1/tariffs/NL/TNM/14"
308        let e = ReceiverEndpoint::new(Url::new("https://www.server.com/ocpi/cpo/2.2.1/tariffs").unwrap());
309        assert_eq!(
310            e.object(&PartyRef::new("NL", "TNM").unwrap(), "14").as_str(),
311            "https://www.server.com/ocpi/cpo/2.2.1/tariffs/NL/TNM/14"
312        );
313    }
314
315    #[test]
316    fn a_trailing_slash_on_the_discovered_url_does_not_double_up() {
317        let e = ReceiverEndpoint::new(Url::new("https://e.com/ocpi/emsp/2.3.0/tariffs/").unwrap());
318        assert_eq!(
319            e.object(&PartyRef::new("NL", "TNM").unwrap(), "14").as_str(),
320            "https://e.com/ocpi/emsp/2.3.0/tariffs/NL/TNM/14"
321        );
322    }
323
324    #[test]
325    fn nested_location_urls_stop_where_the_caller_stops() {
326        let s = sender("locations");
327        assert_eq!(s.location("LOC1", None, None).as_str(), "https://e.com/ocpi/cpo/2.3.0/locations/LOC1");
328        assert_eq!(
329            s.location("LOC1", Some("3256"), None).as_str(),
330            "https://e.com/ocpi/cpo/2.3.0/locations/LOC1/3256"
331        );
332        assert_eq!(
333            s.location("LOC1", Some("3256"), Some("1")).as_str(),
334            "https://e.com/ocpi/cpo/2.3.0/locations/LOC1/3256/1"
335        );
336        // A connector without an EVSE is not addressable; the EVSE segment wins.
337        assert_eq!(
338            s.location("LOC1", None, Some("1")).as_str(),
339            "https://e.com/ocpi/cpo/2.3.0/locations/LOC1"
340        );
341    }
342
343    #[test]
344    fn token_authorization_carries_the_type_as_a_query_parameter() {
345        let s = sender("tokens");
346        assert_eq!(
347            s.token_authorize("012345678", Some("RFID")).as_str(),
348            "https://e.com/ocpi/cpo/2.3.0/tokens/012345678/authorize?type=RFID"
349        );
350        assert_eq!(
351            s.token_authorize("012345678", None).as_str(),
352            "https://e.com/ocpi/cpo/2.3.0/tokens/012345678/authorize"
353        );
354    }
355
356    #[test]
357    fn a_response_url_is_percent_encoded_into_the_query() {
358        let r = receiver("chargingprofiles");
359        let response = Url::new("https://msp.example.com/cb?id=1").unwrap();
360        let url = r.active_charging_profile("101", 900, &response);
361        assert_eq!(
362            url.as_str(),
363            "https://e.com/ocpi/emsp/2.3.0/chargingprofiles/101\
364             ?duration=900&response_url=https%3A%2F%2Fmsp.example.com%2Fcb%3Fid%3D1"
365        );
366    }
367
368    #[test]
369    fn payment_terminal_urls_hang_off_the_discovered_payments_endpoint() {
370        let s = sender("payments").payments_terminals();
371        assert_eq!(s.terminal("TERM1").as_str(), "https://e.com/ocpi/cpo/2.3.0/payments/terminals/TERM1");
372        assert_eq!(
373            s.terminal_deactivate("TERM1").as_str(),
374            "https://e.com/ocpi/cpo/2.3.0/payments/terminals/TERM1/deactivate"
375        );
376        assert_eq!(
377            s.terminal_activate().as_str(),
378            "https://e.com/ocpi/cpo/2.3.0/payments/terminals/activate"
379        );
380        assert_eq!(
381            sender("payments").payments_financial_advice_confirmations().base().as_str(),
382            "https://e.com/ocpi/cpo/2.3.0/payments/financial-advice-confirmations"
383        );
384    }
385
386    #[test]
387    fn a_peer_that_advertised_a_payments_sub_path_directly_still_works() {
388        // The module has one ModuleID and two endpoint URLs, so peers differ on what they
389        // publish. Both readings have to reach the same place.
390        for advertised in ["payments", "payments/terminals", "payments/terminals/"] {
391            assert_eq!(
392                sender(advertised).payments_terminals().terminal("TERM1").as_str(),
393                "https://e.com/ocpi/cpo/2.3.0/payments/terminals/TERM1",
394                "advertised as {advertised}"
395            );
396        }
397        // …but the tolerance compares a path segment, not a suffix of the text, so an endpoint
398        // that merely ends in those letters is not mistaken for the sub-path itself.
399        assert_eq!(
400            sender("payments/my-terminals").payments_terminals().base().as_str(),
401            "https://e.com/ocpi/cpo/2.3.0/payments/my-terminals/terminals"
402        );
403    }
404
405    #[test]
406    fn a_token_url_carries_the_owner_and_the_type() {
407        let r = receiver("tokens");
408        let owner = PartyRef::new("NL", "TNM").unwrap();
409        assert_eq!(
410            r.token(&owner, "012345678", Some("APP_USER")).as_str(),
411            "https://e.com/ocpi/emsp/2.3.0/tokens/NL/TNM/012345678?type=APP_USER"
412        );
413        assert_eq!(
414            r.token(&owner, "012345678", None).as_str(),
415            "https://e.com/ocpi/emsp/2.3.0/tokens/NL/TNM/012345678"
416        );
417    }
418
419    #[test]
420    fn clearing_a_charging_profile_carries_the_response_url() {
421        let r = receiver("chargingprofiles");
422        let response = Url::new("https://msp.example.com/cb?id=1").unwrap();
423        assert_eq!(
424            r.clear_charging_profile("101", &response).as_str(),
425            "https://e.com/ocpi/emsp/2.3.0/chargingprofiles/101\
426             ?response_url=https%3A%2F%2Fmsp.example.com%2Fcb%3Fid%3D1"
427        );
428    }
429
430    #[test]
431    fn a_paginated_list_url_carries_the_filters() {
432        let s = sender("cdrs");
433        let q = PageQuery::new().with_offset(150).with_limit(50);
434        assert_eq!(s.list(&q).as_str(), "https://e.com/ocpi/cpo/2.3.0/cdrs?offset=150&limit=50");
435    }
436}