energy_api/transport/http.rs
1//! HTTP client factory with mTLS and retry support.
2//!
3//! All EDI-Energy API-Webdienste **must** use mutual TLS with an EMT.API
4//! certificate issued by SM-PKI. This module provides a typed configuration
5//! struct and a builder function that produce a properly configured
6//! [`reqwest::Client`].
7//!
8//! For local / integration testing, [`TlsConfig::insecure`] creates a client
9//! that skips certificate verification — **never use in production**.
10
11#[cfg(feature = "client")]
12use reqwest::Client;
13
14#[cfg(feature = "client")]
15use crate::error::Error;
16
17// ── TLS configuration ─────────────────────────────────────────────────────────
18
19/// TLS configuration for the `reqwest::Client`.
20#[derive(Debug, Default, Clone)]
21pub struct TlsConfig {
22 /// PEM-encoded PKCS#8 private key for the client (mTLS identity).
23 pub client_key_pem: Option<String>,
24 /// PEM-encoded X.509 certificate chain for the client (mTLS identity).
25 pub client_cert_pem: Option<String>,
26 /// Additional PEM-encoded root CA certificates to trust (e.g. SM-PKI CA).
27 pub root_ca_pems: Vec<String>,
28 /// When `true`, accept any server certificate regardless of validity.
29 ///
30 /// **Testing only.** Setting this in production is a security vulnerability.
31 pub accept_invalid_certs: bool,
32}
33
34impl TlsConfig {
35 /// Create a configuration with no certificates — for local / mock testing.
36 pub fn insecure() -> Self {
37 Self {
38 accept_invalid_certs: true,
39 ..Default::default()
40 }
41 }
42
43 /// Create a configuration from PEM strings.
44 ///
45 /// `root_ca_pems` must contain the SM-PKI sub-CA certificates used to
46 /// validate server certificates in the energy market.
47 pub fn from_pem(
48 client_cert_pem: impl Into<String>,
49 client_key_pem: impl Into<String>,
50 root_ca_pems: Vec<String>,
51 ) -> Self {
52 Self {
53 client_cert_pem: Some(client_cert_pem.into()),
54 client_key_pem: Some(client_key_pem.into()),
55 root_ca_pems,
56 accept_invalid_certs: false,
57 }
58 }
59}
60
61// ── Retry configuration ───────────────────────────────────────────────────────
62
63/// Retry policy for idempotent HTTP requests.
64///
65/// The spec mandates that all electricity API services are idempotent
66/// (`initialTransactionId` is the idempotency key). Retries should use
67/// exponential back-off with jitter.
68#[derive(Debug, Clone)]
69pub struct RetryPolicy {
70 /// Maximum number of retry attempts (excluding the initial attempt).
71 pub max_retries: u32,
72 /// Base delay between retries in milliseconds.
73 pub base_delay_ms: u64,
74}
75
76impl Default for RetryPolicy {
77 fn default() -> Self {
78 Self {
79 max_retries: 3,
80 base_delay_ms: 500,
81 }
82 }
83}
84
85// ── Builder ───────────────────────────────────────────────────────────────────
86
87/// Build a [`reqwest::Client`] from the given [`TlsConfig`].
88///
89/// The resulting client uses `rustls` and enables HTTP/1.1 + HTTP/2.
90///
91/// # Errors
92///
93/// Returns [`Error::Transport`] if:
94/// - The mTLS identity PEM bytes are malformed.
95/// - A root CA PEM is invalid.
96/// - The `reqwest` builder fails to initialise.
97#[cfg(feature = "client")]
98pub fn build_client(config: &TlsConfig) -> Result<Client, Error> {
99 let mut builder = Client::builder()
100 .use_rustls_tls()
101 .danger_accept_invalid_certs(config.accept_invalid_certs)
102 // Respect the 10 s service-response timeout mandated by the spec.
103 .timeout(std::time::Duration::from_secs(10));
104
105 // Add custom root CAs (SM-PKI trust anchors).
106 for pem in &config.root_ca_pems {
107 let cert = reqwest::Certificate::from_pem(pem.as_bytes())
108 .map_err(|e| Error::Transport(format!("root CA PEM: {e}")))?;
109 builder = builder.add_root_certificate(cert);
110 }
111
112 // Attach mTLS client identity.
113 if let (Some(cert_pem), Some(key_pem)) = (&config.client_cert_pem, &config.client_key_pem) {
114 // reqwest::Identity::from_pem accepts a combined PEM buffer
115 // (certificate chain followed by private key, or vice versa).
116 let combined = format!("{cert_pem}\n{key_pem}");
117 let identity = reqwest::Identity::from_pem(combined.as_bytes())
118 .map_err(|e| Error::Transport(format!("mTLS identity PEM: {e}")))?;
119 builder = builder.identity(identity);
120 }
121
122 builder.build().map_err(|e| Error::Transport(e.to_string()))
123}