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}