Skip to main content

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!("{}\n{}", cert_pem, 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}