anp 0.8.7

Rust SDK for Agent Network Protocol (ANP)
Documentation
mod common;

use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::Duration;

use anp::authentication::{
    create_did_wba_document, AuthMode, DIDWbaAuthHeader, DidDocumentOptions,
};
use reqwest::Client;
use serde_json::Value;
use tempfile::tempdir;

#[tokio::test]
async fn test_rust_http_client_to_python_server() {
    if which_uv().is_none() {
        eprintln!("Skipping test_rust_http_client_to_python_server because uv is unavailable");
        return;
    }

    let bundle = create_did_wba_document(
        "example.com",
        DidDocumentOptions::default().with_path_segments(["user", "rust-http"]),
    )
    .expect("DID creation should succeed");
    let temp = tempdir().expect("temp dir should exist");
    let did_path = temp.path().join("did.json");
    let key_path = temp.path().join("key-1.pem");
    fs::write(&did_path, serde_json::to_vec(&bundle.did_document).unwrap()).unwrap();
    fs::write(&key_path, bundle.private_key_pem("key-1").unwrap()).unwrap();

    let port = pick_free_port();
    let mut server = spawn_python_server(&did_path, port);
    wait_for_health(port).await;

    let result =
        exercise_rust_client_flow(&did_path, &key_path, AuthMode::HttpSignatures, port).await;

    server.kill().ok();
    server.wait().ok();

    let (first_scheme, second_scheme, second_auth_header) = result;
    assert_eq!(first_scheme, "http_signatures");
    assert_eq!(second_scheme, "bearer");
    assert!(second_auth_header.starts_with("Bearer "));
}

#[tokio::test]
async fn test_rust_legacy_client_to_python_server() {
    if which_uv().is_none() {
        eprintln!("Skipping test_rust_legacy_client_to_python_server because uv is unavailable");
        return;
    }

    let bundle = create_did_wba_document(
        "example.com",
        DidDocumentOptions::default()
            .with_profile(anp::authentication::DidProfile::K1)
            .with_path_segments(["user", "rust-legacy"]),
    )
    .expect("DID creation should succeed");
    let temp = tempdir().expect("temp dir should exist");
    let did_path = temp.path().join("did.json");
    let key_path = temp.path().join("key-1.pem");
    fs::write(&did_path, serde_json::to_vec(&bundle.did_document).unwrap()).unwrap();
    fs::write(&key_path, bundle.private_key_pem("key-1").unwrap()).unwrap();

    let port = pick_free_port();
    let mut server = spawn_python_server(&did_path, port);
    wait_for_health(port).await;

    let result =
        exercise_rust_client_flow(&did_path, &key_path, AuthMode::LegacyDidWba, port).await;

    server.kill().ok();
    server.wait().ok();

    let (first_scheme, second_scheme, second_auth_header) = result;
    assert_eq!(first_scheme, "legacy_didwba");
    assert_eq!(second_scheme, "bearer");
    assert!(second_auth_header.starts_with("Bearer "));
}

async fn exercise_rust_client_flow(
    did_path: &Path,
    key_path: &Path,
    auth_mode: AuthMode,
    port: u16,
) -> (String, String, String) {
    let server_url = format!("http://127.0.0.1:{}/auth", port);
    let client = Client::builder()
        .timeout(Duration::from_secs(10))
        .build()
        .unwrap();
    let mut auth = DIDWbaAuthHeader::new(did_path, key_path, auth_mode);

    let first_headers = auth
        .get_auth_header(&server_url, true, "GET", None, None)
        .expect("initial auth headers should be generated");
    let first_response = client
        .get(&server_url)
        .headers(to_header_map(&first_headers))
        .send()
        .await
        .expect("first request should succeed");
    let first_status = first_response.status();
    let first_response_headers = response_headers(&first_response);
    let first_body_text = first_response
        .text()
        .await
        .expect("first body should be readable");
    assert_eq!(
        first_status, 200,
        "unexpected first response body: {}",
        first_body_text
    );
    let first_body: Value =
        serde_json::from_str(&first_body_text).expect("first body should be JSON");

    auth.update_token(&server_url, &first_response_headers);
    let second_headers = auth
        .get_auth_header(&server_url, false, "GET", None, None)
        .expect("bearer auth headers should be generated");
    let second_response = client
        .get(&server_url)
        .headers(to_header_map(&second_headers))
        .send()
        .await
        .expect("second request should succeed");
    assert_eq!(second_response.status(), 200);
    let second_body: Value = second_response
        .json()
        .await
        .expect("second body should be JSON");

    (
        first_body["auth_scheme"]
            .as_str()
            .unwrap_or_default()
            .to_string(),
        second_body["auth_scheme"]
            .as_str()
            .unwrap_or_default()
            .to_string(),
        second_headers
            .get("Authorization")
            .cloned()
            .unwrap_or_default(),
    )
}

fn spawn_python_server(did_path: &Path, port: u16) -> Child {
    let repo_root = repo_root();
    let script_path = repo_root.join("examples/python/rust_interop_examples/python_auth_server.py");
    Command::new("uv")
        .args([
            "run",
            "--python",
            "3.13",
            "--with-editable",
            repo_root.to_str().unwrap(),
            "python",
            script_path.to_str().unwrap(),
            "--did-json",
            did_path.to_str().unwrap(),
            "--port",
            &port.to_string(),
            "--jwt-secret",
            "test-secret",
        ])
        .current_dir(std::env::temp_dir())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("Python server should start")
}

async fn wait_for_health(port: u16) {
    let client = Client::builder()
        .timeout(Duration::from_secs(1))
        .build()
        .unwrap();
    let url = format!("http://127.0.0.1:{}/health", port);
    let deadline = std::time::Instant::now() + Duration::from_secs(30);
    loop {
        if std::time::Instant::now() > deadline {
            panic!("Python server did not become ready in time");
        }
        match client.get(&url).send().await {
            Ok(response) if response.status() == 200 => return,
            _ => tokio::time::sleep(Duration::from_millis(200)).await,
        }
    }
}

fn to_header_map(headers: &BTreeMap<String, String>) -> reqwest::header::HeaderMap {
    let mut result = reqwest::header::HeaderMap::new();
    for (name, value) in headers {
        result.insert(
            reqwest::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
            reqwest::header::HeaderValue::from_str(value).unwrap(),
        );
    }
    result
}

fn response_headers(response: &reqwest::Response) -> BTreeMap<String, String> {
    response
        .headers()
        .iter()
        .map(|(name, value)| {
            (
                name.as_str().to_string(),
                value.to_str().unwrap_or_default().to_string(),
            )
        })
        .collect()
}

fn repo_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("repo root should exist")
        .to_path_buf()
}

fn which_uv() -> Option<String> {
    Command::new("which")
        .arg("uv")
        .output()
        .ok()
        .filter(|output| output.status.success())
        .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn pick_free_port() -> u16 {
    std::net::TcpListener::bind("127.0.0.1:0")
        .expect("ephemeral port should bind")
        .local_addr()
        .expect("local addr should exist")
        .port()
}