reqkey 0.1.0

Official Rust SDK for ReqKey API key validation, credit metering, and analytics
Documentation
//! Direct client integration tests against a local mock HTTP server.

mod common;

use std::{collections::BTreeMap, time::Duration};

use reqkey::{Client, Error, IngestEvent, VerificationReason};
use serde_json::{json, Value};
use wiremock::{
    matchers::{body_json, header, method, path},
    Mock, MockServer, ResponseTemplate,
};

#[tokio::test]
async fn verify_uses_python_compatible_contract_and_normalizes_result() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/key/validate"))
        .and(header("authorization", "Bearer project_test"))
        .and(header("user-agent", "reqkey-rust/0.1.0"))
        .and(body_json(json!({
            "key": "consumer_test",
            "apiId": "api_payments",
            "credits": 3,
            "resource": "/payments"
        })))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("Retry-After", "4.5")
                .set_body_json(json!({
                    "valid": true,
                    "requestId": "request_123",
                    "apiId": "api_payments",
                    "apiName": "Payments",
                    "resource": "/payments",
                    "creditsRemaining": 7,
                    "creditsLimit": 10,
                    "allowedApis": ["api_payments", 42],
                    "rateLimit": {"remaining": 7}
                })),
        )
        .mount(&server)
        .await;

    let decision = common::client(&server)
        .verify("consumer_test")
        .api_id("api_payments")
        .credits(3)
        .resource("/payments")
        .send()
        .await
        .expect("decision");

    assert!(decision.allowed());
    assert_eq!(decision.reason, VerificationReason::Valid);
    assert_eq!(decision.request_id.as_deref(), Some("request_123"));
    assert_eq!(decision.credits_remaining, Some(7));
    assert_eq!(decision.allowed_apis, ["api_payments", "42"]);
    assert_eq!(decision.retry_after, Some(4.5));
    assert_eq!(decision.raw["apiName"], "Payments");
}

#[tokio::test]
async fn denial_statuses_are_results_but_project_401_is_an_error() {
    for (status, expected) in [
        (402, VerificationReason::InsufficientCredits),
        (403, VerificationReason::Forbidden),
        (429, VerificationReason::RateLimited),
    ] {
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/key/validate"))
            .respond_with(ResponseTemplate::new(status).set_body_json(json!({"valid": false})))
            .mount(&server)
            .await;
        let decision = common::client(&server)
            .verify("consumer_test")
            .send()
            .await
            .expect("access decision");
        assert!(!decision.valid);
        assert_eq!(decision.reason, expected);
    }

    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/key/validate"))
        .respond_with(
            ResponseTemplate::new(401).set_body_json(json!({"error": "expired project key"})),
        )
        .mount(&server)
        .await;
    let error = common::client(&server)
        .verify("consumer_test")
        .send()
        .await
        .expect_err("authentication error");
    assert!(matches!(
        error,
        Error::Authentication {
            status: 401,
            ref message,
            ..
        } if message == "expired project key"
    ));
}

#[tokio::test]
async fn invalid_input_and_unexpected_bodies_are_typed_errors() {
    let server = MockServer::start().await;
    let client = common::client(&server);
    assert!(matches!(
        client.verify("  ").send().await,
        Err(Error::Configuration(_))
    ));

    Mock::given(method("POST"))
        .and(path("/key/validate"))
        .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
        .mount(&server)
        .await;
    assert!(matches!(
        client.verify("consumer_test").send().await,
        Err(Error::Api {
            status: 200,
            body: None,
            ..
        })
    ));

    assert!(matches!(
        IngestEvent::builder().method("GET").build(),
        Err(Error::Configuration(_))
    ));
}

#[tokio::test]
async fn ingest_supports_full_metadata_and_truncates_unicode_by_character() {
    let server = MockServer::start().await;
    common::mount_ingest(&server).await;
    let mut query = BTreeMap::new();
    query.insert("page".into(), Value::from(2));
    let long_body = "🦀".repeat(1_050);
    let event = IngestEvent::builder()
        .request_id("request_123")
        .api_id("api_payments")
        .method("POST")
        .endpoint("/payments")
        .path("/payments?page=2")
        .status_code(201)
        .latency_ms(12)
        .client_ip("203.0.113.2")
        .user_agent("integration-test")
        .user_id("user_1")
        .consumer_name("Acme")
        .api_key("consumer_test")
        .consumer_id("consumer_1")
        .query_params(query)
        .request_body(long_body)
        .response_body("created")
        .timestamp("2026-07-23T12:00:00Z")
        .build()
        .expect("event");
    common::client(&server)
        .ingest(&event)
        .await
        .expect("accepted");

    let requests = server.received_requests().await.expect("requests");
    let body: Value = serde_json::from_slice(&requests[0].body).expect("JSON");
    assert_eq!(body["requestBody"].as_str().unwrap().chars().count(), 1_000);
    assert_eq!(body["consumerName"], "Acme");
    assert_eq!(body["queryParams"]["page"], 2);
}

#[tokio::test]
async fn timeout_is_distinct_from_other_transport_errors() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/key/validate"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_delay(Duration::from_millis(100))
                .set_body_json(json!({"valid": true})),
        )
        .mount(&server)
        .await;
    let client = Client::builder()
        .project_key("project_test")
        .base_url(server.uri())
        .timeout(Duration::from_millis(10))
        .build()
        .expect("client");
    assert!(matches!(
        client.verify("consumer_test").send().await,
        Err(Error::Timeout { .. })
    ));
}

#[cfg(feature = "blocking")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocking_client_uses_the_same_contract() {
    let server = MockServer::start().await;
    common::mount_allowed(&server).await;
    let base_url = server.uri();
    let decision = tokio::task::spawn_blocking(move || {
        reqkey::SyncClient::builder()
            .project_key("project_test")
            .base_url(base_url)
            .build()
            .unwrap()
            .verify("consumer_test")
            .api_id("api_payments")
            .resource("/payments")
            .send()
    })
    .await
    .unwrap()
    .unwrap();
    assert_eq!(decision.reason, VerificationReason::Valid);
}