scconfig-rs 0.1.3

Async Rust client for Spring Cloud Config Server
Documentation
use scconfig_rs::{
    ConfigDocument, EnvironmentFormat, EnvironmentRequest, ResourceRequest, SpringConfigClient,
};
use serde::Deserialize;
use wiremock::{
    Mock, MockServer, ResponseTemplate,
    matchers::{header, method, path, query_param},
};

#[derive(Debug, Deserialize)]
struct AppConfig {
    server: ServerConfig,
    features: FeatureFlags,
    replicas: Vec<Replica>,
}

#[derive(Debug, Deserialize)]
struct ServerConfig {
    port: u16,
}

#[derive(Debug, Deserialize)]
struct FeatureFlags {
    enabled: bool,
}

#[derive(Debug, Deserialize)]
struct Replica {
    id: String,
    url: Option<String>,
}

#[tokio::test]
async fn fetches_environment_and_binds_typed_config() {
    let server = MockServer::start().await;

    let body = serde_json::json!({
        "name": "inventory-service",
        "profiles": ["dev", "aws"],
        "label": "main",
        "propertySources": [
            {
                "name": "inventory-service-dev.yml",
                "source": {
                    "server.port": "9090",
                    "features.enabled": "true",
                    "replicas[0].id": "blue"
                }
            },
            {
                "name": "application.yml",
                "source": {
                    "server.port": "8080",
                    "features.enabled": "false",
                    "replicas[0].url": "https://blue.internal",
                    "replicas[1].id": "green",
                    "replicas[1].url": "https://green.internal"
                }
            }
        ]
    });

    Mock::given(method("GET"))
        .and(path("/inventory-service/dev,aws/main"))
        .and(header(
            "authorization",
            "Basic Y29uZmlnLXVzZXI6Y29uZmlnLXBhc3M=",
        ))
        .respond_with(ResponseTemplate::new(200).set_body_json(body))
        .expect(2)
        .mount(&server)
        .await;

    let client = SpringConfigClient::builder(server.uri())
        .unwrap()
        .basic_auth("config-user", "config-pass")
        .build()
        .unwrap();

    let request = EnvironmentRequest::new("inventory-service", ["dev", "aws"])
        .unwrap()
        .label("main");

    let environment = client.fetch_environment(&request).await.unwrap();
    assert_eq!(environment.name, "inventory-service");

    let typed: AppConfig = client.fetch_typed(&request).await.unwrap();
    assert_eq!(typed.server.port, 9090);
    assert!(typed.features.enabled);
    assert_eq!(typed.replicas[0].id, "blue");
    assert_eq!(
        typed.replicas[0].url.as_deref(),
        Some("https://blue.internal")
    );
    assert_eq!(typed.replicas[1].id, "green");
}

#[tokio::test]
async fn fetches_yaml_environment_with_placeholder_resolution() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/inventory-service/dev/main.yaml"))
        .and(query_param("resolvePlaceholders", "true"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("content-type", "application/x-yaml")
                .set_body_string("server:\n  port: 9090\n"),
        )
        .mount(&server)
        .await;

    let client = SpringConfigClient::builder(server.uri())
        .unwrap()
        .build()
        .unwrap();
    let request = EnvironmentRequest::new("inventory-service", ["dev"])
        .unwrap()
        .label("main")
        .resolve_placeholders(true);

    let yaml = client
        .fetch_environment_as_text(&request, EnvironmentFormat::Yaml)
        .await
        .unwrap();
    assert!(yaml.contains("9090"));

    let document = client
        .fetch_environment_document(&request, EnvironmentFormat::Yaml)
        .await
        .unwrap();

    match document {
        ConfigDocument::Yaml(value) => {
            assert_eq!(value["server"]["port"], 9090);
        }
        other => panic!("unexpected document format: {:?}", other.format()),
    }
}

#[tokio::test]
async fn uses_default_label_query_parameter_for_plain_text_when_label_is_missing() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/inventory-service/dev/nginx.conf"))
        .and(query_param("useDefaultLabel", "true"))
        .and(header("accept", "application/octet-stream"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("content-type", "text/plain")
                .set_body_string("server_name example.com;"),
        )
        .mount(&server)
        .await;

    let client = SpringConfigClient::builder(server.uri())
        .unwrap()
        .build()
        .unwrap();
    let request = ResourceRequest::new("inventory-service", ["dev"], "nginx.conf").unwrap();

    let resource = client.fetch_resource(&request).await.unwrap();
    assert_eq!(resource.text().unwrap(), "server_name example.com;");

    match resource.parse().unwrap() {
        ConfigDocument::Text(text) => assert_eq!(text, "server_name example.com;"),
        other => panic!("unexpected document format: {:?}", other.format()),
    }
}

#[tokio::test]
async fn fetches_binary_resources_and_escapes_labels() {
    let server = MockServer::start().await;
    let bytes = vec![0_u8, 159, 146, 150];

    Mock::given(method("GET"))
        .and(path(
            "/inventory-service/dev/release(_)2026-03/keystore.p12",
        ))
        .and(header("accept", "application/octet-stream"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("content-type", "application/octet-stream")
                .set_body_raw(bytes.clone(), "application/octet-stream"),
        )
        .mount(&server)
        .await;

    let client = SpringConfigClient::builder(server.uri())
        .unwrap()
        .build()
        .unwrap();
    let request = ResourceRequest::new("inventory-service", ["dev"], "keystore.p12")
        .unwrap()
        .label("release/2026-03");

    let resource = client.fetch_resource(&request).await.unwrap();
    assert_eq!(resource.bytes(), bytes.as_slice());

    match resource.parse().unwrap() {
        ConfigDocument::Binary(body) => assert_eq!(body, bytes),
        other => panic!("unexpected document format: {:?}", other.format()),
    }
}

#[tokio::test]
async fn fetches_toml_resources_and_detects_format() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/inventory-service/dev/main/settings.toml"))
        .and(header("accept", "application/octet-stream"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("content-type", "text/plain")
                .set_body_string("[server]\nport = 7070\n"),
        )
        .mount(&server)
        .await;

    let client = SpringConfigClient::builder(server.uri())
        .unwrap()
        .default_label("main")
        .build()
        .unwrap();
    let request = ResourceRequest::new("inventory-service", ["dev"], "settings.toml").unwrap();

    let document = client.fetch_resource_document(&request).await.unwrap();
    match document {
        ConfigDocument::Toml(value) => assert_eq!(value["server"]["port"], 7070),
        other => panic!("unexpected document format: {:?}", other.format()),
    }
}

#[test]
fn builder_accepts_development_tls_overrides() {
    let client = SpringConfigClient::builder("https://localhost:8443")
        .unwrap()
        .danger_accept_invalid_certs(true)
        .danger_accept_invalid_hostnames(true)
        .danger_accept_invalid_tls(true)
        .build()
        .unwrap();

    let _ = client;
}