apollo-http-client 0.2.0

HTTP client for Apollo platform
Documentation
//! HTTP client for Apollo platform services.
//!
//! [`HttpClient`] is a Tower [`Service`]: HTTP/1.1 and HTTP/2 over TCP and Unix
//! domain sockets, with ALPN version negotiation on TCP. Configure via
//! [`HttpClientConfig`].
//!
//! # Metrics
//!
//! Every request emits the following OpenTelemetry metrics:
//!
//! | Metric | Description | Key attributes |
//! |---|---|---|
//! | `http.client.active_requests` | In-flight request count | method, server.address, server.port |
//! | `http.client.request.duration` | Request latency | method, server.address, server.port, protocol.version, status or error.type |
//! | `http.client.request.body.size` | Bytes sent (opt-in) | method, server.address, server.port, protocol.version, status |
//! | `http.client.response.body.size` | Bytes received (opt-in) | method, server.address, server.port, status |
//! | `http.client.open_connections` | Open connection gauge | server.address, server.port, protocol.version, http.connection.state |
//! | `http.client.connection.duration` | Connection lifetime | server.address, server.port, protocol.version |
//!
//! For `unix://` requests `server.address` carries the socket path and
//! `server.port` is omitted from every metric.
//!
//! Connection-state metrics (`http.client.open_connections`,
//! `http.client.connection.duration`) also include `network.peer.address` and
//! `network.peer.port` when a proxy is configured — set to the proxy host and
//! port. They are omitted when no proxy is in the path.
//!
//! All metrics also include `url.scheme` when `telemetry.metrics.url_scheme` is enabled.
//!
//! # Span attributes
//!
//! Follows the [OTel HTTP client span semantic conventions](https://opentelemetry.io/docs/specs/semconv/http/http-spans/).
//!
//! **Always recorded:**
//!
//! - `http.request.method` — `_OTHER` for non-standard methods, with the original verb
//!   preserved in `http.request.method_original`
//! - `url.full`
//! - `server.address`
//! - `server.port` — absent for `unix://`
//! - `http.response.status_code`
//! - `network.protocol.version`
//! - `error.type` — 4xx/5xx/transport
//!
//! **Opt-in** (disabled by default, configure under `telemetry.spans`):
//!
//! | Attribute | Setting |
//! |-----------|---------|
//! | `url.scheme` | `url_scheme` |
//! | `http.request.body.size` | `request_body_size` |
//! | `http.response.body.size` | `response_body_size` |
//! | `user_agent.original` | `user_agent` |
//! | `network.transport` | `network_transport` (always `"tcp"`) |
//! | `network.local.address` | `network_local_address` (static value, set at startup) |
//! | `network.local.port` | `network_local_port` (static value, set at startup) |
//! | `http.request.header.<name>` | `request_headers` |
//! | `http.response.header.<name>` | `response_headers` |
//!
//! **Not supported:** `url.template`, `http.request.size`, `http.response.size`,
//! `network.peer.address`, `network.peer.port`, `user_agent.synthetic.type`.
//!
//! # TLS
//!
//! Configure trust anchors and client identity under `tls`. The example below
//! trusts a private CA bundle in addition to the OS native store and presents
//! a client certificate for mutual TLS:
//!
//! ```yaml
//! tls:
//!   # PEM bundle of CA certificates to trust. Multiple certificates may be
//!   # concatenated. Loaded from disk via apollo-configuration file expansion.
//!   certificate_authorities: ${file:./certs/internal-ca.pem}
//!
//!   # Trust the OS native certificate store in addition to the supplied CAs.
//!   # Set false to trust only the supplied CAs (air-gapped environments).
//!   use_native_certificate_store: true
//!
//!   client_authentication:
//!     # PEM chain: leaf certificate followed by intermediates.
//!     certificate_chain: ${file:./certs/client.pem}
//!     # PEM-encoded unencrypted private key (PKCS#1, PKCS#8, or SEC1).
//!     # Encrypted keys are not supported.
//!     key: ${file:./certs/client.key}
//! ```
//!
//! Invalid PEM, empty bundles, encrypted keys, and configurations with no
//! trust anchors all fail at [`HttpClient::new`] with a dedicated
//! [`HttpClientError`] variant naming the offending field.
//!
//! To supply a pre-built [`rustls::ClientConfig`] instead, for example, a
//! FIPS-compliant config or one backed by a custom certificate store, use
//! [`HttpClient::builder`]:
//!
//! ```rust,no_run
//! use std::sync::Arc;
//! use apollo_http_client::{HttpClient, HttpClientConfig};
//!
//! # fn main() -> Result<(), apollo_http_client::HttpClientError> {
//! let tls: Arc<rustls::ClientConfig> = todo!("build your ClientConfig");
//! let client = HttpClient::builder(HttpClientConfig::default())
//!     .with_tls_config(tls)
//!     .build()?;
//! # Ok(())
//! # }
//! ```
//!
//! # Unix domain sockets
//!
//! On Unix targets, `unix://` URIs dispatch over Unix domain sockets. Build the
//! request URI with [`unix_uri`]:
//!
//! ```rust,no_run
//! # #[cfg(not(unix))]
//! # fn main() {}
//! # #[cfg(unix)]
//! # #[tokio::main]
//! # async fn main() {
//! use apollo_http_client::{HttpClient, HttpClientConfig, unix_uri};
//! use http_body_util::Empty;
//! use bytes::Bytes;
//! use tower::ServiceExt;
//!
//! let client = HttpClient::new(&HttpClientConfig::default()).expect("valid config");
//!
//! let uri = unix_uri("/var/run/app.sock")
//!     .expect("valid path")
//!     .path_and_query("/health")
//!     .build()
//!     .expect("uri builds");
//!
//! let req = http::Request::builder().uri(uri).body(Empty::<Bytes>::new()).unwrap();
//!
//! let _resp = client.oneshot(req).await.expect("request ok");
//! # }
//! ```
//!
//! `HttpClientConfig::protocol` selects HTTP/1.1 or HTTP/2 over the socket the
//! same way it does over TCP. ALPN has no TLS to negotiate over and falls back
//! to HTTP/1.1. Proxy configuration applies only to TCP.
//!
//! On non-Unix targets, [`HttpClient::new`] still builds; sending a `unix://`
//! request returns [`HttpClientError::UnsupportedScheme`].
//!
//! # Example
//! ```rust,no_run
//! use apollo_http_client::{HttpClient, HttpClientConfig};
//! use apollo_configuration::parse_yaml;
//! use http_body_util::Full;
//! use bytes::Bytes;
//! use tower::ServiceExt;
//!
//! # #[tokio::main]
//! # async fn main() {
//! let config: HttpClientConfig = parse_yaml(
//!     "protocol: alpn\nconnect_timeout: 5s",
//!     &Default::default(),
//! ).expect("valid config");
//! let client = HttpClient::new(&config).expect("valid config");
//!
//! let req = http::Request::builder()
//!     .method("POST")
//!     .uri("https://api.example.com/")
//!     .body(Full::new(Bytes::from_static(b"payload")))
//!     .unwrap();
//!
//! let _resp = client.oneshot(req).await.expect("request ok");
//! # }
//! ```
//!
//! [`Service`]: tower::Service

#![forbid(unsafe_code)]
#![warn(missing_docs)]

mod builder;
mod client;
mod config;
mod error;
mod metrics;
mod protocol;
mod proxy;
mod spans;
mod tls;

pub use apollo_configuration::types::HeaderName;
pub use builder::HttpClientBuilder;
pub use client::HttpClient;
pub use config::{
    ClientAuthentication, Http1Config, Http2Config, HttpClientConfig, MetricsConfig, Protocol,
    ProxyConfig, ProxyUrl, SpansConfig, TcpConfig, TelemetryConfig, TlsConfig,
};
pub use error::HttpClientError;
pub use protocol::HttpBody;
#[cfg(unix)]
pub use protocol::unix::unix_uri;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn config_parses_from_yaml() {
        let config: HttpClientConfig = apollo_configuration::parse_yaml(
            r#"
            connect_timeout: 5s
            http1:
              pool_max_idle_per_host: 20
              pool_idle_timeout: 90s
            http2:
              connection_idle_timeout: 5m
              keep_alive_interval: 30s
              keep_alive_while_idle: true
            tcp:
              nodelay: false
              keepalive: 30s
            tls:
              danger_accept_invalid_certs: false
            "#,
            &Default::default(),
        )
        .expect("should parse");

        assert_eq!(*config.connect_timeout, std::time::Duration::from_secs(5));
        assert_eq!(config.http1.pool_max_idle_per_host.get(), 20);
        assert_eq!(
            *config.http1.pool_idle_timeout,
            std::time::Duration::from_secs(90)
        );
        assert_eq!(
            *config.http2.connection_idle_timeout,
            std::time::Duration::from_secs(300)
        );
        assert_eq!(
            *config.http2.keep_alive_interval,
            std::time::Duration::from_secs(30)
        );
        assert!(config.http2.keep_alive_while_idle);
        assert!(!config.tcp.nodelay);
        assert_eq!(*config.tcp.keepalive, std::time::Duration::from_secs(30));
        assert!(!config.tls.danger_accept_invalid_certs);
    }

    #[test]
    fn config_defaults_are_sensible() {
        let config = HttpClientConfig::default();
        assert_eq!(*config.connect_timeout, std::time::Duration::from_secs(10));
        assert_eq!(config.http1.pool_max_idle_per_host.get(), 10);
        assert_eq!(
            *config.http1.pool_idle_timeout,
            std::time::Duration::from_secs(90)
        );
        assert_eq!(
            *config.http2.connection_idle_timeout,
            std::time::Duration::from_secs(300)
        );
        assert_eq!(
            *config.http2.keep_alive_interval,
            std::time::Duration::from_secs(30)
        );
        assert!(config.http2.keep_alive_while_idle);
        assert!(config.tcp.nodelay);
        assert_eq!(*config.tcp.keepalive, std::time::Duration::from_secs(60));
        assert!(!config.tls.danger_accept_invalid_certs);
    }

    #[test]
    fn certificate_authorities_is_redacted_in_debug_output() {
        let pem = "-----BEGIN CERTIFICATE-----\nSUPERSECRETPEMCONTENTS\n-----END CERTIFICATE-----";
        let yaml = format!(
            "tls:\n  certificate_authorities: |\n    {}\n",
            pem.replace('\n', "\n    "),
        );
        let config: HttpClientConfig =
            apollo_configuration::parse_yaml(&yaml, &Default::default()).expect("valid config");

        let debug = format!("{:?}", config.tls.certificate_authorities);
        assert!(
            !debug.contains("SUPERSECRETPEMCONTENTS"),
            "raw PEM leaked through Debug: {debug}"
        );
        assert!(debug.contains("REDACTED"), "expected REDACTED in {debug}");
    }

    #[test]
    fn client_authentication_fields_are_redacted_in_debug_output() {
        let yaml = "\
tls:
  client_authentication:
    certificate_chain: |
      -----BEGIN CERTIFICATE-----
      CLIENTCERTSECRET
      -----END CERTIFICATE-----
    key: |
      -----BEGIN PRIVATE KEY-----
      CLIENTKEYSECRET
      -----END PRIVATE KEY-----
";
        let config: HttpClientConfig =
            apollo_configuration::parse_yaml(yaml, &Default::default()).expect("valid config");

        let auth = config
            .tls
            .client_authentication
            .as_ref()
            .expect("client_authentication should parse");
        let debug = format!("{:?}", auth);
        assert!(
            !debug.contains("CLIENTCERTSECRET"),
            "raw cert leaked through Debug: {debug}"
        );
        assert!(
            !debug.contains("CLIENTKEYSECRET"),
            "raw key leaked through Debug: {debug}"
        );
        assert!(debug.contains("REDACTED"), "expected REDACTED in {debug}");
    }

    #[test]
    fn json_schema_snapshot() {
        insta::assert_json_snapshot!(
            apollo_configuration::export_json_schema::<HttpClientConfig>()
        );
    }

    #[test]
    fn proxy_config_rejects_unknown_fields() {
        let err = apollo_configuration::parse_yaml::<HttpClientConfig>(
            r#"
            proxy:
              url: http://proxy.example.com:3128
              typo_field: value
            "#,
            &Default::default(),
        )
        .expect_err("unknown fields under `proxy` should be rejected");
        let rendered = format!("{err:?}");
        assert!(
            rendered.contains("typo_field"),
            "error should mention the unknown field, got: {rendered}"
        );
    }

    #[test]
    fn proxy_config_rejects_invalid_url() {
        apollo_configuration::parse_yaml::<HttpClientConfig>(
            r#"
            proxy:
              url: "not-a-url"
            "#,
            &Default::default(),
        )
        .expect_err("malformed URL should fail to parse");
    }

    #[test]
    fn proxy_config_rejects_unsupported_scheme() {
        let err = apollo_configuration::parse_yaml::<HttpClientConfig>(
            r#"
            proxy:
              url: "socks5://proxy.corp.example.com:1080"
            "#,
            &Default::default(),
        )
        .expect_err("non-http(s) proxy scheme should fail validation");
        let rendered = format!("{err:?}");
        assert!(
            rendered.contains("socks5") || rendered.contains("http or https"),
            "error should explain the unsupported scheme, got: {rendered}"
        );
    }

    #[test]
    fn proxy_config_accepts_credentials_without_exposing_password() {
        let config: HttpClientConfig = apollo_configuration::parse_yaml(
            r#"
            proxy:
              url: "http://alice:s3cr3t@proxy.corp.example.com:3128"
            "#,
            &Default::default(),
        )
        .expect("valid config");

        let proxy = config.proxy.as_ref().expect("proxy set");
        let debug = format!("{:?}", proxy.url);
        assert!(
            !debug.contains("s3cr3t"),
            "password must not appear in Debug output: {debug}"
        );
        assert!(
            debug.contains("alice"),
            "username is visible in Debug output: {debug}"
        );
    }
}