Skip to main content

ocpi_kit/hub/
forwarder.rs

1//! Forwarding one request through the hub, and fanning one out.
2
3use http::Method;
4
5use crate::client::Transport;
6use crate::convert::wire::{ObjectKind, Payload, bridgeable};
7use crate::convert::{Converted, Lossy};
8use crate::transport::{
9    OcpiError, OcpiRequest, OcpiResponse, RequestIds, RoutingHeaders, RoutingScenario, StatusCode,
10};
11use crate::types::{PartyRef, Url};
12use crate::{InterfaceRole, ModuleId, VersionNumber};
13
14use super::routing_table::RoutingTable;
15
16/// What the hub was asked to relay.
17#[derive(Debug)]
18pub struct Forwardable {
19    /// The HTTP method of the incoming request.
20    pub method: Method,
21    /// The module it addresses.
22    pub module: ModuleId,
23    /// Which interface of that module.
24    pub interface: InterfaceRole,
25    /// The path below the module endpoint, e.g. `NL/TNM/LOC1`.
26    pub path: String,
27    /// The query string, without the leading `?`.
28    pub query: Option<String>,
29    /// The routing headers as they arrived.
30    pub routing: RoutingHeaders,
31    /// The IDs as they arrived. The hub renews the request ID and keeps the correlation ID.
32    pub ids: RequestIds,
33    /// The request body, if any.
34    pub body: Option<Vec<u8>>,
35    /// The OCPI version the body is written in — the version of the endpoint it arrived on.
36    ///
37    /// A hub is the one place where the two ends of a conversation need not agree on it, so the
38    /// forwarder translates between this and whatever the receiving platform speaks. See
39    /// [`Forwarder::relay`].
40    pub version: VersionNumber,
41}
42
43impl Forwardable {
44    /// The scenario this request is, from its headers and method.
45    ///
46    /// > *To send a Broadcast Push, the client uses the party-id and country-code of the Hub in
47    /// > the 'OCPI-to-' headers.*
48    /// >
49    /// > *When … the requesting party does not know the destination of a request, the 'OCPI-to-'
50    /// > headers can be omitted.*
51    /// >
52    /// > *To request a GET All from a Hub, the client uses the party-id and country-code of the
53    /// > Hub in the 'OCPI-to-' headers, and calls the GET method on the Sender interface.*
54    ///
55    /// # Why this can fail
56    ///
57    /// Addressing the hub itself is the one ambiguous case: it means a Broadcast Push for a
58    /// write and a GET All for a `GET` on a Sender interface, and the two remaining combinations
59    /// are not scenarios at all.
60    ///
61    /// > *GET SHALL NOT be used in combination with Broadcast Push. If the requesting party wants
62    /// > to GET information of which it does not know the receiving party, an Open Routing
63    /// > Request MUST be used.*
64    ///
65    /// A `GET` on a Receiver interface addressed to the hub is therefore refused rather than
66    /// quietly broadcast — the sender is told to omit the `OCPI-to-` headers instead — and so is
67    /// a write addressed to the hub on a Sender interface, which is neither a push to the
68    /// connected parties nor a read to merge.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`OcpiError::NotRoutable`], a `2001`, for those two combinations.
73    ///
74    /// Spec: 2.3.0 §transport_and_format_message_routing
75    pub fn scenario(&self, hub: &PartyRef) -> Result<RoutingScenario, OcpiError> {
76        match &self.routing.to {
77            None => Ok(RoutingScenario::OpenRoutingRequest),
78            Some(to) if to == hub => match (self.method == Method::GET, self.interface) {
79                (true, InterfaceRole::Sender) => Ok(RoutingScenario::GetAllViaHub { hub: hub.clone() }),
80                (false, InterfaceRole::Receiver) => Ok(RoutingScenario::BroadcastPush { hub: hub.clone() }),
81                (true, InterfaceRole::Receiver) => Err(OcpiError::NotRoutable(
82                    "a GET addressed to the hub on a Receiver interface is neither a GET All (which is \
83                     a GET on a Sender interface) nor a Broadcast Push (which SHALL NOT be a \
84                     GET); omit the OCPI-to- headers to make it an Open Routing Request"
85                        .to_owned(),
86                )),
87                (false, InterfaceRole::Sender) => Err(OcpiError::NotRoutable(format!(
88                    "a {} addressed to the hub on a Sender interface is not a scenario the \
89                     specification defines; address the receiving party directly, or omit the \
90                     OCPI-to- headers for an Open Routing Request",
91                    self.method
92                ))),
93            },
94            Some(_) => Ok(RoutingScenario::Direct),
95        }
96    }
97
98    /// The URL this request becomes at `base`, the receiving platform's endpoint for the module.
99    #[must_use]
100    pub fn url_at(&self, base: &Url) -> Url {
101        let url = if self.path.is_empty() { base.clone() } else { base.join(&self.path) };
102        match &self.query {
103            Some(query) if !query.is_empty() => url.with_query(query),
104            _ => url,
105        }
106    }
107}
108
109/// The outcome of relaying one request to one party.
110#[derive(Debug)]
111pub struct Relayed {
112    /// The party the request went to.
113    pub party: PartyRef,
114    /// What came back, or why nothing did.
115    pub outcome: Result<OcpiResponse<serde_json::Value>, OcpiError>,
116}
117
118impl Relayed {
119    /// Whether the party answered with a success status code.
120    #[must_use]
121    pub fn is_success(&self) -> bool {
122        self.outcome.as_ref().is_ok_and(OcpiResponse::is_success)
123    }
124}
125
126/// What a hub does with a message between two versions it has no conversions for — today, anything
127/// involving OCPI 2.1.1 or a version this crate does not model.
128#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
129#[non_exhaustive]
130pub enum Unbridgeable {
131    /// Refuse the message, with a `2001` naming both versions. The default.
132    ///
133    /// Handing a 2.1.1 object to a 2.3.0 party — where a cost is a bare number rather than a
134    /// `Price`, and no object carries its owner — produces a document the receiver misreads rather
135    /// than rejects.
136    #[default]
137    Refuse,
138    /// Relay the bytes unchanged, for a hub that is deliberately a pipe between parties that
139    /// understand each other by some arrangement this crate does not model.
140    RelayVerbatim,
141}
142
143/// Relays requests to the platforms in a [`RoutingTable`].
144#[derive(Debug)]
145pub struct Forwarder<'a> {
146    transport: &'a Transport,
147    table: &'a RoutingTable,
148    hub: PartyRef,
149    unbridgeable: Unbridgeable,
150    report_losses: bool,
151}
152
153impl<'a> Forwarder<'a> {
154    /// A forwarder for the hub party `hub`.
155    #[must_use]
156    pub fn new(transport: &'a Transport, table: &'a RoutingTable, hub: PartyRef) -> Self {
157        Self { transport, table, hub, unbridgeable: Unbridgeable::default(), report_losses: true }
158    }
159
160    /// What to do with a message between two versions this build cannot translate.
161    #[must_use]
162    pub const fn on_unbridgeable(mut self, policy: Unbridgeable) -> Self {
163        self.unbridgeable = policy;
164        self
165    }
166
167    /// Whether a translation's losses are appended to the response's `status_message`. On by
168    /// default; switch it off for a peer that treats `status_message` as machine-readable.
169    #[must_use]
170    pub const fn report_losses(mut self, report: bool) -> Self {
171        self.report_losses = report;
172        self
173    }
174
175    /// The hub's own party reference.
176    #[must_use]
177    pub const fn hub(&self) -> &PartyRef {
178        &self.hub
179    }
180
181    /// Relays one request to the party the `OCPI-to-*` headers name.
182    ///
183    /// The forwarded request gets a **new** `X-Request-ID` and the **same** `X-Correlation-ID`:
184    ///
185    /// > *When a Hub forwards a request to a party, the request to this party SHALL contain a new
186    /// > unique value in the X-Request-ID HTTP header, not a copy … the request SHALL contain the
187    /// > same X-Correlation-ID HTTP header.*
188    ///
189    /// # Errors
190    ///
191    /// Returns the `4xxx` code that fits: `4001` when the destination is unknown, `4003` when it
192    /// is not connected, `4002` on a timeout, `4000` otherwise.
193    ///
194    /// Spec: 2.3.0 §transport_and_format_unique_messageg_ids, §status_codes_4xxx_hub_errors
195    pub async fn relay(&self, request: &Forwardable, to: &PartyRef, routing: RoutingHeaders) -> Relayed {
196        let target = self.table.with_platform(to, |platform| {
197            platform.peer.endpoint_url(&request.module, request.interface).cloned().map(|base| {
198                (
199                    base,
200                    platform.peer.token().clone(),
201                    platform.peer.quirks().clone(),
202                    platform.peer.version().clone(),
203                )
204            })
205        });
206
207        let (base, token, quirks, their_version) = match target {
208            Err(e) => return Relayed { party: to.clone(), outcome: Err(e) },
209            Ok(None) => {
210                return Relayed {
211                    party: to.clone(),
212                    outcome: Err(OcpiError::Remote {
213                        status_code: StatusCode::UNKNOWN_RECEIVER,
214                        status_message: Some(format!(
215                            "{to} does not implement the {} interface of {}",
216                            request.interface, request.module
217                        )),
218                    }),
219                };
220            }
221            Ok(Some(target)) => target,
222        };
223
224        let outgoing_body = match self.carry_request(request, &their_version) {
225            Ok(body) => body,
226            Err(e) => return Relayed { party: to.clone(), outcome: Err(e) },
227        };
228
229        let mut outgoing =
230            OcpiRequest::new(request.method.clone(), request.url_at(&base), request.module.clone())
231                .routed(routing)
232                .with_ids(request.ids.forwarded());
233        outgoing.body = outgoing_body.value;
234
235        let outcome = self
236            .transport
237            .send_with_headers::<serde_json::Value>(&outgoing, &token, &quirks)
238            .await
239            .map(|(response, _)| response)
240            .map_err(map_hub_error);
241
242        let outcome = outcome
243            .and_then(|response| self.carry_response(request, &their_version, response, outgoing_body.lossy));
244
245        Relayed { party: to.clone(), outcome }
246    }
247
248    /// Rewrites a request body into the version the receiving platform speaks.
249    ///
250    /// Costs nothing when the two platforms agree, the request has no body, or the endpoint
251    /// carries an object whose wire format did not change.
252    fn carry_request(
253        &self,
254        request: &Forwardable,
255        their_version: &VersionNumber,
256    ) -> Result<Converted<Option<Vec<u8>>>, OcpiError> {
257        let Some(body) = request.body.as_ref() else { return Ok(Converted::lossless(None)) };
258        if request.version == *their_version {
259            return Ok(Converted::lossless(Some(body.clone())));
260        }
261        let Some(kind) =
262            ObjectKind::for_endpoint(&request.module, request.interface, &request.path, Payload::Request)
263        else {
264            return Ok(Converted::lossless(Some(body.clone())));
265        };
266        if !bridgeable(&request.version, their_version) {
267            return self
268                .refuse_or_relay(&request.version, their_version)
269                .map(|()| Converted::lossless(Some(body.clone())));
270        }
271        let value: serde_json::Value =
272            serde_json::from_slice(body).map_err(|e| OcpiError::MalformedJson(e.to_string()))?;
273        let converted =
274            kind.bridge(&request.version, their_version, value).map_err(|e| OcpiError::Remote {
275                status_code: StatusCode::INVALID_PARAMETERS,
276                status_message: Some(e.to_string()),
277            })?;
278        let bytes =
279            serde_json::to_vec(&converted.value).map_err(|e| OcpiError::MalformedJson(e.to_string()))?;
280        Ok(Converted::new(Some(bytes), converted.lossy))
281    }
282
283    /// Rewrites the `data` of a response back into the version the requesting party speaks.
284    ///
285    /// `lossy` carries what the outbound leg cost: from the requesting party's side the two legs
286    /// are one exchange, so they share one `status_message`.
287    fn carry_response(
288        &self,
289        request: &Forwardable,
290        their_version: &VersionNumber,
291        mut response: OcpiResponse<serde_json::Value>,
292        mut lossy: Lossy,
293    ) -> Result<OcpiResponse<serde_json::Value>, OcpiError> {
294        let data = response.data.take();
295        let Some(data) = data else {
296            return Ok(self.annotate(response, lossy));
297        };
298        let kind = if request.version == *their_version {
299            None
300        } else {
301            ObjectKind::for_endpoint(&request.module, request.interface, &request.path, Payload::Response)
302        };
303        match kind {
304            None => response.data = Some(data),
305            // The outbound leg already applied `Unbridgeable` to this pair; reaching here means it
306            // said to relay verbatim, and the answer travels back the same way.
307            Some(_) if !bridgeable(their_version, &request.version) => {
308                self.refuse_or_relay(their_version, &request.version)?;
309                response.data = Some(data);
310            }
311            Some(kind) => {
312                let converted =
313                    kind.bridge(their_version, &request.version, data).map_err(|e| OcpiError::Remote {
314                        status_code: StatusCode::HUB_ERROR,
315                        status_message: Some(e.to_string()),
316                    })?;
317                lossy.absorb("/data", converted.lossy);
318                response.data = Some(converted.value);
319            }
320        }
321        Ok(self.annotate(response, lossy))
322    }
323
324    /// Appends a translation's losses to the response's `status_message`.
325    fn annotate(
326        &self,
327        mut response: OcpiResponse<serde_json::Value>,
328        lossy: Lossy,
329    ) -> OcpiResponse<serde_json::Value> {
330        if !self.report_losses {
331            return response;
332        }
333        if let Some(note) = lossy.to_status_message() {
334            response.status_message = Some(match response.status_message.take() {
335                Some(existing) if !existing.is_empty() => format!("{existing}; {note}"),
336                _ => note,
337            });
338        }
339        response
340    }
341
342    /// Applies [`Unbridgeable`] to a crossing this build cannot make.
343    fn refuse_or_relay(&self, from: &VersionNumber, to: &VersionNumber) -> Result<(), OcpiError> {
344        match self.unbridgeable {
345            Unbridgeable::RelayVerbatim => Ok(()),
346            Unbridgeable::Refuse => Err(OcpiError::NotRoutable(format!(
347                "this hub has no conversions between OCPI {from} and OCPI {to}, so it will not \
348                 hand one party a document written for the other; set \
349                 Forwarder::on_unbridgeable(Unbridgeable::RelayVerbatim) to relay the bytes \
350                 unchanged instead"
351            ))),
352        }
353    }
354
355    /// Fans a Broadcast Push out to every party with an opposite role.
356    ///
357    /// > *When using Broadcast Push, the Hub broadcasts received information to all connected
358    /// > clients … This means only one request to the Hub will be necessary.*
359    ///
360    /// Every target is attempted; the results are returned in full so the caller decides what to
361    /// tell the sender. [`aggregate`] implements the usual policy.
362    ///
363    /// Spec: 2.3.0 §transport_and_format_message_routing_broadcast_push
364    pub async fn broadcast(
365        &self,
366        request: &Forwardable,
367        sender_role: crate::v2_3_0::types::Role,
368    ) -> Vec<Relayed> {
369        let targets = self.table.broadcast_targets(&request.routing.from, sender_role, &request.module);
370        let mut results = Vec::with_capacity(targets.len());
371        for (_, party) in targets {
372            // "Broadcast request | Hub to receiving platform | Receiving-party | Hub"
373            let routing = RoutingHeaders::new(self.hub.clone(), party.clone());
374            results.push(self.relay(request, &party, routing).await);
375        }
376        results
377    }
378
379    /// Answers an Open Routing Request by asking the router to pick a destination.
380    ///
381    /// > *When a Hub has the intelligence to route messages based on the content of the request …
382    /// > The Hub can then decide to which party a request needs to be routed, or that it needs to
383    /// > be broadcasted if the destination cannot be determined.*
384    ///
385    /// # Errors
386    ///
387    /// Returns `4001` when the router cannot decide.
388    ///
389    /// Spec: 2.3.0 §transport_and_format_message_routing_open_routing_request
390    pub async fn open_route(
391        &self,
392        request: &Forwardable,
393        router: &dyn OpenRouter,
394    ) -> Result<Relayed, OcpiError> {
395        let to = router.destination(request).ok_or_else(|| OcpiError::Remote {
396            status_code: StatusCode::UNKNOWN_RECEIVER,
397            status_message: Some("the hub could not determine a destination from the request".to_owned()),
398        })?;
399        // "Open request | Hub to receiving platform | Receiving-party | Requesting-party"
400        let routing = RoutingHeaders::new(request.routing.from.clone(), to.clone());
401        Ok(self.relay(request, &to, routing).await)
402    }
403
404    /// Answers a GET All by asking every party that implements the Sender interface.
405    ///
406    /// > *The Hub can then combine objects from different connected parties and return them to
407    /// > the client. The client can determine the owner of the objects by looking at the
408    /// > `country_code` and `party_id` in the individual objects returned by the hub.*
409    ///
410    /// Because ownership is carried inside each object, merging is a concatenation; the hub does
411    /// not have to rewrite anything, and — importantly — *"the `last_updated` fields SHALL NOT be
412    /// updated by the Hub"*.
413    ///
414    /// Spec: 2.3.0 §transport_and_format_get_all_via_hubs
415    pub async fn get_all(&self, request: &Forwardable) -> Vec<Relayed> {
416        let sources = self.table.get_all_sources(&request.routing.from, &request.module);
417        let mut results = Vec::with_capacity(sources.len());
418        for (_, party) in sources {
419            // The GET All table covers only the two legs between the requester and the hub
420            // ("Requesting platform to Hub | Hub | Requesting-party"); it says nothing about the
421            // leg from the hub onward, because from the sending party's side that leg is an
422            // ordinary request. So it takes the ordinary relay headers — "Direct request | Hub to
423            // receiving platform | Receiving-party | Requesting-party" — which also means the
424            // answering party can see who actually asked, and authorise accordingly.
425            let routing = RoutingHeaders::new(request.routing.from.clone(), party.clone());
426            results.push(self.relay(request, &party, routing).await);
427        }
428        results
429    }
430}
431
432/// Decides where an Open Routing Request should go, from its content.
433///
434/// The specification leaves this entirely to the hub — *"When a Hub has the intelligence to route
435/// messages based on the content of the request"* — so it is a trait. A typical implementation
436/// looks at the `country_code`/`party_id` inside the body, or at the issuer of a token.
437pub trait OpenRouter: Send + Sync {
438    /// The party this request should be routed to, or `None` to give up with `4001`.
439    fn destination(&self, request: &Forwardable) -> Option<PartyRef>;
440}
441
442/// An [`OpenRouter`] that reads `country_code` and `party_id` out of the request body.
443///
444/// This covers the common case: every client-owned object carries the party that owns it.
445#[derive(Debug, Default)]
446pub struct BodyOwnerRouter;
447
448impl OpenRouter for BodyOwnerRouter {
449    fn destination(&self, request: &Forwardable) -> Option<PartyRef> {
450        let body = request.body.as_ref()?;
451        let value: serde_json::Value = serde_json::from_slice(body).ok()?;
452        let country = value.get("country_code")?.as_str()?;
453        let party = value.get("party_id")?.as_str()?;
454        PartyRef::new(country, party).ok()
455    }
456}
457
458/// How a fan-out's results become one answer for the sender.
459#[derive(Clone, Copy, Debug, PartialEq, Eq)]
460#[non_exhaustive]
461pub enum AggregatePolicy {
462    /// Report the first failure. The safe default: the sender learns something went wrong.
463    FirstErrorWins,
464    /// Report success as long as one party accepted the message.
465    AnySuccess,
466    /// Always report success; the hub owns delivery from here.
467    AlwaysSucceed,
468}
469
470/// Turns the results of a fan-out into the status code the sender is told.
471///
472/// A broadcast that reached nobody is `4003 Connection problem`; one where a party rejected the
473/// object surfaces that party's own code, because the sender needs to see it.
474///
475/// Spec: 2.3.0 §status_codes_4xxx_hub_errors
476#[must_use]
477pub fn aggregate(results: &[Relayed], policy: AggregatePolicy) -> StatusCode {
478    if results.is_empty() {
479        return StatusCode::CONNECTION_PROBLEM;
480    }
481    let succeeded = results.iter().filter(|r| r.is_success()).count();
482    match policy {
483        AggregatePolicy::AlwaysSucceed => StatusCode::SUCCESS,
484        AggregatePolicy::AnySuccess if succeeded > 0 => StatusCode::SUCCESS,
485        _ => {
486            if succeeded == results.len() {
487                return StatusCode::SUCCESS;
488            }
489            results.iter().find(|r| !r.is_success()).map_or(StatusCode::HUB_ERROR, |failed| {
490                match &failed.outcome {
491                    Ok(response) => response.status_code,
492                    Err(error) => error.status_code(),
493                }
494            })
495        }
496    }
497}
498
499/// Maps a transport failure onto the hub error code that describes it.
500fn map_hub_error(error: OcpiError) -> OcpiError {
501    match &error {
502        OcpiError::Transport(message) => {
503            let lower = message.to_ascii_lowercase();
504            let code = if lower.contains("timeout") || lower.contains("timed out") {
505                StatusCode::TIMEOUT_ON_FORWARDED_REQUEST
506            } else {
507                StatusCode::CONNECTION_PROBLEM
508            };
509            OcpiError::Remote { status_code: code, status_message: Some(message.clone()) }
510        }
511        _ => error,
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::types::DateTime;
519
520    fn hub() -> PartyRef {
521        PartyRef::new("NL", "HUB").unwrap()
522    }
523
524    fn request(method: Method, to: Option<PartyRef>, interface: InterfaceRole) -> Forwardable {
525        Forwardable {
526            method,
527            module: ModuleId::Locations,
528            interface,
529            path: String::new(),
530            query: None,
531            routing: RoutingHeaders { to, from: PartyRef::new("NL", "TNM").unwrap() },
532            ids: RequestIds::generate(),
533            body: None,
534            version: VersionNumber::V2_3_0,
535        }
536    }
537
538    #[test]
539    fn the_scenario_is_read_off_the_headers_and_method() {
540        // "the client uses the party-id and country-code of the Hub in the 'OCPI-to-' headers"
541        // plus a GET on a Sender interface is a GET All …
542        assert!(matches!(
543            request(Method::GET, Some(hub()), InterfaceRole::Sender).scenario(&hub()).unwrap(),
544            RoutingScenario::GetAllViaHub { .. }
545        ));
546        // … and the same headers with a write is a Broadcast Push.
547        assert!(matches!(
548            request(Method::PUT, Some(hub()), InterfaceRole::Receiver).scenario(&hub()).unwrap(),
549            RoutingScenario::BroadcastPush { .. }
550        ));
551        // No TO headers at all is an Open Routing Request.
552        assert_eq!(
553            request(Method::PUT, None, InterfaceRole::Receiver).scenario(&hub()).unwrap(),
554            RoutingScenario::OpenRoutingRequest
555        );
556        // Anyone else in the TO headers is a plain relay.
557        assert_eq!(
558            request(Method::GET, Some(PartyRef::new("DE", "ABC").unwrap()), InterfaceRole::Sender)
559                .scenario(&hub())
560                .unwrap(),
561            RoutingScenario::Direct
562        );
563    }
564
565    #[test]
566    fn a_get_addressed_to_the_hub_is_never_silently_broadcast() {
567        // "GET SHALL NOT be used in combination with Broadcast Push." Classifying this as a
568        // Broadcast Push would contradict `RoutingScenario::allows_get`, so it is refused with
569        // the advice the spec itself gives.
570        let error = request(Method::GET, Some(hub()), InterfaceRole::Receiver).scenario(&hub()).unwrap_err();
571        assert_eq!(error.status_code(), StatusCode::INVALID_PARAMETERS);
572        assert!(error.to_string().contains("Open Routing Request"), "{error}");
573
574        // Nor is a write on a Sender interface a scenario at all.
575        let error = request(Method::PUT, Some(hub()), InterfaceRole::Sender).scenario(&hub()).unwrap_err();
576        assert_eq!(error.status_code(), StatusCode::INVALID_PARAMETERS);
577    }
578
579    #[test]
580    fn every_scenario_agrees_with_what_it_says_it_allows() {
581        // The classification and the method rules are two statements of the same spec text;
582        // this pins them together.
583        for (method, interface) in [
584            (Method::GET, InterfaceRole::Sender),
585            (Method::GET, InterfaceRole::Receiver),
586            (Method::PUT, InterfaceRole::Sender),
587            (Method::PUT, InterfaceRole::Receiver),
588            (Method::POST, InterfaceRole::Receiver),
589            (Method::DELETE, InterfaceRole::Receiver),
590        ] {
591            for to in [None, Some(hub()), Some(PartyRef::new("DE", "ABC").unwrap())] {
592                let r = request(method.clone(), to, interface);
593                let Ok(scenario) = r.scenario(&hub()) else { continue };
594                if method == Method::GET {
595                    assert!(scenario.allows_get(), "{scenario:?} classified a GET it forbids");
596                } else {
597                    assert!(scenario.allows_write(), "{scenario:?} classified a write it forbids");
598                }
599            }
600        }
601    }
602
603    #[test]
604    fn the_forwarded_url_keeps_the_path_and_query() {
605        let mut r = request(Method::GET, Some(hub()), InterfaceRole::Sender);
606        r.path = "NL/TNM/LOC1".to_owned();
607        r.query = Some("offset=50&limit=10".to_owned());
608        let base = Url::new("https://msp.example.com/ocpi/emsp/2.3.0/locations").unwrap();
609        assert_eq!(
610            r.url_at(&base).as_str(),
611            "https://msp.example.com/ocpi/emsp/2.3.0/locations/NL/TNM/LOC1?offset=50&limit=10"
612        );
613    }
614
615    #[test]
616    fn the_body_router_reads_the_owner_out_of_the_object() {
617        let mut r = request(Method::PUT, None, InterfaceRole::Receiver);
618        r.body = Some(br#"{"country_code":"DE","party_id":"ABC","id":"LOC1"}"#.to_vec());
619        assert_eq!(BodyOwnerRouter.destination(&r), Some(PartyRef::new("DE", "ABC").unwrap()));
620        r.body = Some(br#"{"id":"LOC1"}"#.to_vec());
621        assert_eq!(BodyOwnerRouter.destination(&r), None);
622    }
623
624    fn relayed(party: &str, status: StatusCode) -> Relayed {
625        Relayed {
626            party: PartyRef::new("DE", party).unwrap(),
627            outcome: Ok(OcpiResponse {
628                data: None,
629                status_code: status,
630                status_message: None,
631                timestamp: DateTime::from_unix_timestamp(0).unwrap(),
632            }),
633        }
634    }
635
636    #[test]
637    fn aggregation_surfaces_the_first_failure_by_default() {
638        let results =
639            vec![relayed("AAA", StatusCode::SUCCESS), relayed("BBB", StatusCode::INVALID_PARAMETERS)];
640        assert_eq!(
641            aggregate(&results, AggregatePolicy::FirstErrorWins),
642            StatusCode::INVALID_PARAMETERS,
643            "the sender needs to see the receiving party's own code"
644        );
645        assert_eq!(aggregate(&results, AggregatePolicy::AnySuccess), StatusCode::SUCCESS);
646        assert_eq!(aggregate(&results, AggregatePolicy::AlwaysSucceed), StatusCode::SUCCESS);
647    }
648
649    #[test]
650    fn a_broadcast_that_reached_nobody_is_a_connection_problem() {
651        assert_eq!(aggregate(&[], AggregatePolicy::AlwaysSucceed), StatusCode::CONNECTION_PROBLEM);
652    }
653
654    #[test]
655    fn a_timeout_becomes_4002_and_a_refused_connection_4003() {
656        let timeout = map_hub_error(OcpiError::Transport("operation timed out".into()));
657        assert_eq!(timeout.status_code(), StatusCode::TIMEOUT_ON_FORWARDED_REQUEST);
658        let refused = map_hub_error(OcpiError::Transport("connection refused".into()));
659        assert_eq!(refused.status_code(), StatusCode::CONNECTION_PROBLEM);
660    }
661}