Skip to main content

ocpi_kit/client/
mod.rs

1//! An async OCPI client: registration handshake, typed module clients, paginated crawls.
2//!
3//! ```no_run
4//! use ocpi_kit::client::{OcpiClient, Registration};
5//! use ocpi_kit::transport::{CredentialsToken, PageQuery};
6//! use ocpi_kit::types::{PartyRef, Url};
7//! use ocpi_kit::{InterfaceRole, ModuleId};
8//!
9//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
10//! let client = OcpiClient::new()?;
11//! let me = PartyRef::new("NL", "TNM")?;
12//!
13//! // The registration handshake, in the order the specification defines it.
14//! let peer = Registration::new(
15//!         Url::new("https://cpo.example.com/ocpi/versions")?,
16//!         CredentialsToken::new("token-a-received-out-of-band")?,
17//!     )
18//!     .discover(client.transport()).await?
19//!     .select_best(client.transport()).await?;
20//!
21//! // Refuse to register with a peer that does not implement what we need — before POSTing.
22//! peer.require(&[(ModuleId::Locations, InterfaceRole::Sender)])?;
23//!
24//! let peer = peer.register(client.transport(), &my_credentials()).await?;
25//!
26//! // Then pull, following every `Link: rel="next"`.
27//! let mut locations = peer.locations(client.transport(), me).list(PageQuery::new())?;
28//! while let Some(location) = locations.next().await? {
29//!     println!("{} {}", location.id, location.name.as_deref().unwrap_or(""));
30//! }
31//! # Ok(())
32//! # }
33//! # fn my_credentials() -> ocpi_kit::v2_3_0::credentials::Credentials { unimplemented!() }
34//! ```
35//!
36//! # What this client does that a hand-rolled one usually does not
37//!
38//! * **It refuses to call a URL it should not.** Every request is checked against a
39//!   [`UrlPolicy`] that says no to plain HTTP, loopback and private
40//!   addresses by default. `Credentials.url`, `Endpoint.url` and every `response_url` are
41//!   attacker-influenced inputs; a client that fetches them unconditionally is an SSRF proxy.
42//! * **It validates what it sends.** [`ClientConfig::validate_outgoing`] is on by default, so a
43//!   non-conformant object is caught here rather than at the partner's support desk.
44//! * **It only retries what it may.** *"OCPI messages SHOULD NOT be queued. When a client does a
45//!   POST, PUT or PATCH request and that request fails or times out, the client should not queue
46//!   the message and retry."* Only `GET` is retried.
47//! * **It never logs the token.** The `tracing` spans carry the request and correlation IDs and
48//!   the routing parties; [`CredentialsToken`](crate::transport::CredentialsToken) redacts
49//!   itself in any case.
50
51mod conformance;
52mod http;
53mod modules;
54mod paging;
55mod peer;
56mod registration;
57mod resync;
58
59pub use conformance::{Check, Conformance, Outcome, Report};
60pub use http::{OcpiRequest, Transport, check_outgoing};
61pub use modules::{
62    CdrsClient, ChargingProfilesClient, CommandsClient, HubClientInfoClient, LocationsReceiver,
63    LocationsSender, ModuleClient, PaymentsClient, SessionsReceiver, SessionsSender, TariffsReceiver,
64    TariffsSender, TokensReceiver, TokensSender, correlated_ids,
65};
66pub use paging::{DEFAULT_MAX_PAGES, PageStream};
67pub use peer::{Peer, PeerBuilder};
68pub use registration::{Discovered, PeerState, Registration, Selected};
69pub use resync::{Resync, ResyncPlan};
70
71use std::time::Duration;
72
73use crate::types::UrlPolicy;
74
75/// How the client behaves.
76#[derive(Clone, Debug)]
77#[non_exhaustive]
78pub struct ClientConfig {
79    /// What this client is willing to send a request to. Defaults to HTTPS, no private networks.
80    pub url_policy: UrlPolicy,
81    /// How long one request may take.
82    pub timeout: Duration,
83    /// How `GET` requests are retried. Writes are never retried.
84    pub retry: RetryPolicy,
85    /// Whether objects are validated before being sent. Defaults to `true`.
86    ///
87    /// This is what makes "construct strictly" hold in practice: the infallible `From<&str>`
88    /// conversions the builders use are lenient, so the guarantee lives here, at the wire, where
89    /// it also catches the cross-field rules no constructor could.
90    pub validate_outgoing: bool,
91}
92
93impl Default for ClientConfig {
94    fn default() -> Self {
95        Self {
96            url_policy: UrlPolicy::default(),
97            timeout: Duration::from_secs(30),
98            retry: RetryPolicy::default(),
99            validate_outgoing: true,
100        }
101    }
102}
103
104impl ClientConfig {
105    /// A configuration for talking to a peer on localhost, as an integration test does.
106    #[must_use]
107    pub fn for_testing() -> Self {
108        Self { url_policy: UrlPolicy::permissive(), ..Self::default() }
109    }
110
111    /// Sets the URL policy.
112    #[must_use]
113    pub fn with_url_policy(mut self, policy: UrlPolicy) -> Self {
114        self.url_policy = policy;
115        self
116    }
117
118    /// Sets the per-request timeout.
119    #[must_use]
120    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
121        self.timeout = timeout;
122        self
123    }
124
125    /// Turns off validation of outgoing objects.
126    ///
127    /// Only reasonable when a peer is known to require something non-conformant, and then it is
128    /// better to record the reason next to the call.
129    #[must_use]
130    pub const fn without_outgoing_validation(mut self) -> Self {
131        self.validate_outgoing = false;
132        self
133    }
134}
135
136/// How a failed `GET` is retried.
137///
138/// Writes are never retried; see [`OcpiRequest::is_retryable`].
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140#[non_exhaustive]
141pub struct RetryPolicy {
142    /// Total attempts, including the first. `1` disables retrying.
143    pub max_attempts: u32,
144    /// The delay before the first retry.
145    pub initial_delay: Duration,
146    /// The cap on the exponentially growing delay.
147    pub max_delay: Duration,
148}
149
150impl Default for RetryPolicy {
151    fn default() -> Self {
152        Self {
153            max_attempts: 3,
154            initial_delay: Duration::from_millis(250),
155            max_delay: Duration::from_secs(10),
156        }
157    }
158}
159
160impl RetryPolicy {
161    /// A policy that never retries.
162    #[must_use]
163    pub const fn none() -> Self {
164        Self { max_attempts: 1, initial_delay: Duration::from_millis(0), max_delay: Duration::from_millis(0) }
165    }
166}
167
168/// The entry point: an HTTP client plus the configuration every request uses.
169#[derive(Clone, Debug)]
170pub struct OcpiClient {
171    transport: Transport,
172}
173
174impl OcpiClient {
175    /// A client with the default configuration.
176    ///
177    /// # Errors
178    ///
179    /// Returns the `reqwest` error if the HTTP client cannot be built, which happens when the
180    /// platform has no usable TLS backend.
181    pub fn new() -> Result<Self, reqwest::Error> {
182        Self::with_config(ClientConfig::default())
183    }
184
185    /// A client with a specific configuration.
186    ///
187    /// # Errors
188    ///
189    /// As [`OcpiClient::new`].
190    pub fn with_config(config: ClientConfig) -> Result<Self, reqwest::Error> {
191        let http =
192            reqwest::Client::builder().user_agent(concat!("ocpi-kit/", env!("CARGO_PKG_VERSION"))).build()?;
193        Ok(Self { transport: Transport::new(http, config) })
194    }
195
196    /// A client over an existing `reqwest` client, for sharing a connection pool.
197    #[must_use]
198    pub fn from_http(http: reqwest::Client, config: ClientConfig) -> Self {
199        Self { transport: Transport::new(http, config) }
200    }
201
202    /// The request executor, which the handshake and the module clients take.
203    #[must_use]
204    pub const fn transport(&self) -> &Transport {
205        &self.transport
206    }
207
208    /// The configuration in use.
209    #[must_use]
210    pub const fn config(&self) -> &ClientConfig {
211        self.transport.config()
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn the_default_configuration_is_the_careful_one() {
221        let config = ClientConfig::default();
222        assert!(config.validate_outgoing, "a non-conformant object should not reach a partner");
223        assert!(
224            config.url_policy.check(&crate::types::Url::new("http://e.com/a").unwrap()).is_err(),
225            "plain HTTP is refused by default"
226        );
227        assert_eq!(config.retry.max_attempts, 3);
228    }
229
230    #[test]
231    fn the_testing_configuration_allows_localhost() {
232        let config = ClientConfig::for_testing();
233        assert!(
234            config.url_policy.check(&crate::types::Url::new("http://127.0.0.1:8080/ocpi").unwrap()).is_ok()
235        );
236    }
237
238    #[test]
239    fn retrying_can_be_switched_off() {
240        assert_eq!(RetryPolicy::none().max_attempts, 1);
241    }
242}