hypixel-sdk 0.2.0

Async client for the Hypixel Public API with typed models and SkyBlock helpers
Documentation
use hypixel::util::market;
use hypixel::{Error, HypixelClient};
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

const STATUS: &str = include_str!("fixtures/status.json");
const BAZAAR: &str = include_str!("fixtures/bazaar.json");
const AUCTIONS: &str = include_str!("fixtures/auctions.json");

async fn keyed(server: &MockServer) -> HypixelClient {
    HypixelClient::builder()
        .api_key("test-key")
        .base_url(server.uri())
        .build()
}

#[tokio::test]
async fn status_parses_session_and_rate_limit() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v2/status"))
        .and(query_param("uuid", "abc"))
        .and(header("API-Key", "test-key"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("RateLimit-Limit", "120")
                .insert_header("RateLimit-Remaining", "119")
                .insert_header("RateLimit-Reset", "42")
                .set_body_string(STATUS),
        )
        .mount(&server)
        .await;

    let client = keyed(&server).await;
    let session = client.status("abc").await.unwrap();
    assert!(session.online);
    assert_eq!(session.game_type.as_deref(), Some("SKYBLOCK"));

    let rl = client.rate_limit();
    assert_eq!(rl.limit, Some(120));
    assert_eq!(rl.remaining, Some(119));
    assert_eq!(rl.reset, Some(42));
}

#[tokio::test]
async fn bazaar_is_keyless_and_spread_computes() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v2/skyblock/bazaar"))
        .respond_with(ResponseTemplate::new(200).set_body_string(BAZAAR))
        .mount(&server)
        .await;

    let client = HypixelClient::builder().base_url(server.uri()).build();
    let bazaar = client.skyblock_bazaar().await.unwrap();
    let spreads = market::bazaar_spreads(&bazaar);
    let spread = spreads.get("INK_SACK:3").unwrap();
    assert_eq!(spread.sell_order_price, 4.2);
    assert_eq!(spread.buy_order_price, 4.8);
}

#[tokio::test]
async fn auctions_lowest_bin_aggregates() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v2/skyblock/auctions"))
        .respond_with(ResponseTemplate::new(200).set_body_string(AUCTIONS))
        .mount(&server)
        .await;

    let client = HypixelClient::builder().base_url(server.uri()).build();
    let page = client.skyblock_auctions(0).await.unwrap();
    assert_eq!(page.total_auctions, 2);
    let lowest = market::lowest_bin(&page.auctions);
    assert_eq!(lowest.get("Hyperion"), Some(&850_000_000));
}

#[tokio::test]
async fn invalid_key_maps_to_error() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v2/player"))
        .respond_with(ResponseTemplate::new(403).set_body_string(r#"{"success":false}"#))
        .mount(&server)
        .await;

    let client = keyed(&server).await;
    let err = client.player("abc").await.unwrap_err();
    assert!(matches!(err, Error::InvalidApiKey));
}

#[tokio::test]
async fn rate_limited_maps_to_error_with_retry_after() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v2/player"))
        .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "30"))
        .mount(&server)
        .await;

    let client = keyed(&server).await;
    let err = client.player("abc").await.unwrap_err();
    match err {
        Error::RateLimited { retry_after } => {
            assert_eq!(retry_after.map(|d| d.as_secs()), Some(30));
        }
        other => panic!("expected RateLimited, got {other:?}"),
    }
}

#[tokio::test]
async fn keyed_endpoint_without_key_fails_fast() {
    let client = HypixelClient::unauthenticated();
    let err = client.player("abc").await.unwrap_err();
    assert!(matches!(err, Error::MissingApiKey));
}

#[tokio::test]
async fn auctions_all_walks_every_page() {
    let server = MockServer::start().await;
    for page in 0..3 {
        Mock::given(method("GET"))
            .and(path("/v2/skyblock/auctions"))
            .and(query_param("page", page.to_string()))
            .respond_with(ResponseTemplate::new(200).set_body_string(format!(
                r#"{{"success":true,"page":{page},"totalPages":3,"totalAuctions":3,
                    "auctions":[{{"uuid":"a{page}","item_name":"Item {page}","bin":true,"starting_bid":{}}}]}}"#,
                (page + 1) * 100
            )))
            .expect(1)
            .mount(&server)
            .await;
    }

    let client = HypixelClient::builder().base_url(server.uri()).build();
    let auctions = client.skyblock_auctions_all().await.unwrap();
    assert_eq!(auctions.len(), 3);
    let mut names: Vec<_> = auctions.iter().map(|a| a.item_name.as_str()).collect();
    names.sort();
    assert_eq!(names, vec!["Item 0", "Item 1", "Item 2"]);
}

#[tokio::test]
async fn rate_limited_request_retries_when_configured() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v2/player"))
        .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
        .up_to_n_times(1)
        .expect(1)
        .mount(&server)
        .await;
    Mock::given(method("GET"))
        .and(path("/v2/player"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_string(r#"{"success":true,"player":{"uuid":"abc"}}"#),
        )
        .expect(1)
        .mount(&server)
        .await;

    let client = HypixelClient::builder()
        .api_key("test-key")
        .base_url(server.uri())
        .retry_on_rate_limit(2)
        .build();
    let player = client.player("abc").await.unwrap();
    assert!(player.is_some());
}

#[tokio::test]
async fn cached_responses_skip_the_network() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v2/skyblock/bazaar"))
        .respond_with(ResponseTemplate::new(200).set_body_string(BAZAAR))
        .expect(1)
        .mount(&server)
        .await;

    let client = HypixelClient::builder()
        .base_url(server.uri())
        .cache_ttl(std::time::Duration::from_secs(60))
        .build();
    let first = client.skyblock_bazaar().await.unwrap();
    let second = client.skyblock_bazaar().await.unwrap();
    assert_eq!(first.products.len(), second.products.len());
}