1use crate::types::{PartyRef, Url};
14
15use super::pagination::PageQuery;
16
17#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct SenderEndpoint {
25 base: Url,
26}
27
28impl SenderEndpoint {
29 #[must_use]
31 pub fn new(base: Url) -> Self {
32 Self { base }
33 }
34
35 #[must_use]
37 pub const fn base(&self) -> &Url {
38 &self.base
39 }
40
41 #[must_use]
43 pub fn list(&self, query: &PageQuery) -> Url {
44 query.apply_to(&self.base)
45 }
46
47 #[must_use]
49 pub fn object(&self, id: &str) -> Url {
50 self.base.join(id)
51 }
52
53 #[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 #[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 #[must_use]
84 pub fn charging_preferences(&self, session_id: &str) -> Url {
85 self.base.join(session_id).join("charging_preferences")
86 }
87
88 #[must_use]
92 pub fn command(&self, command: &str) -> Url {
93 self.base.join(command)
94 }
95
96 #[must_use]
116 pub fn payments_terminals(&self) -> Self {
117 Self::new(sub_path(&self.base, "terminals"))
118 }
119
120 #[must_use]
124 pub fn payments_financial_advice_confirmations(&self) -> Self {
125 Self::new(sub_path(&self.base, "financial-advice-confirmations"))
126 }
127
128 #[must_use]
132 pub fn terminal(&self, terminal_id: &str) -> Url {
133 self.base.join(terminal_id)
134 }
135
136 #[must_use]
138 pub fn terminal_deactivate(&self, terminal_id: &str) -> Url {
139 self.base.join(terminal_id).join("deactivate")
140 }
141
142 #[must_use]
144 pub fn terminal_activate(&self) -> Url {
145 self.base.join("activate")
146 }
147}
148
149fn 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#[derive(Clone, Debug, PartialEq, Eq)]
172pub struct ReceiverEndpoint {
173 base: Url,
174}
175
176impl ReceiverEndpoint {
177 #[must_use]
179 pub fn new(base: Url) -> Self {
180 Self { base }
181 }
182
183 #[must_use]
185 pub const fn base(&self) -> &Url {
186 &self.base
187 }
188
189 #[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 #[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 #[must_use]
222 pub fn charging_profile(&self, session_id: &str) -> Url {
223 self.base.join(session_id)
224 }
225
226 #[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 #[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 #[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 #[must_use]
266 pub fn payments_terminals(&self) -> Self {
267 Self::new(sub_path(&self.base, "terminals"))
268 }
269
270 #[must_use]
272 pub fn payments_financial_advice_confirmations(&self) -> Self {
273 Self::new(sub_path(&self.base, "financial-advice-confirmations"))
274 }
275}
276
277fn 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 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 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 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 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}