openmeteo-rs 1.0.0

Rust client for the Open-Meteo weather API.
Documentation
use std::time::Duration;

use openmeteo_rs::{Client, Error, ForecastBuilder, HourlyVar, Result};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn forecast_error_request(client: &Client) -> ForecastBuilder<'_> {
    client
        .forecast(52.52, 13.41)
        .hourly([HourlyVar::Temperature2m])
        .forecast_days(1)
}

#[tokio::test]
async fn http_400_api_error_body_maps_to_api_error() -> Result<()> {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/forecast"))
        .and(query_param("latitude", "52.52"))
        .and(query_param("longitude", "13.41"))
        .and(query_param("hourly", "temperature_2m"))
        .and(query_param("forecast_days", "1"))
        .respond_with(ResponseTemplate::new(400).set_body_raw(
            r#"{"error":true,"reason":"invalid latitude"}"#,
            "application/json",
        ))
        .mount(&server)
        .await;

    let client = Client::builder().forecast_base_url(server.uri())?.build()?;
    let err = forecast_error_request(&client).send().await.unwrap_err();

    match err {
        Error::Api { status, reason } => {
            assert_eq!(status, 400);
            assert_eq!(reason, "invalid latitude");
        }
        other => panic!("expected API error, got {other:?}"),
    }

    Ok(())
}

#[tokio::test]
async fn http_400_empty_api_error_reason_falls_back_to_body_text() -> Result<()> {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/forecast"))
        .respond_with(
            ResponseTemplate::new(400)
                .set_body_raw(r#"{"error":true,"reason":""}"#, "application/json"),
        )
        .mount(&server)
        .await;

    let client = Client::builder().forecast_base_url(server.uri())?.build()?;
    let err = forecast_error_request(&client).send().await.unwrap_err();

    match err {
        Error::Api { status, reason } => {
            assert_eq!(status, 400);
            assert_eq!(reason, r#"{"error":true,"reason":""}"#);
        }
        other => panic!("expected API error, got {other:?}"),
    }

    Ok(())
}

#[tokio::test]
async fn elevation_http_400_maps_before_sanitizing_success_body() -> Result<()> {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/elevation"))
        .respond_with(ResponseTemplate::new(400).set_body_raw(
            r#"{"error":true,"reason":"bad coordinate nan"}"#,
            "application/json",
        ))
        .mount(&server)
        .await;

    let client = Client::builder()
        .elevation_base_url(server.uri())?
        .build()?;
    let err = client.elevation([(52.52, 13.41)]).await.unwrap_err();

    match err {
        Error::Api { status, reason } => {
            assert_eq!(status, 400);
            assert_eq!(reason, "bad coordinate nan");
        }
        other => panic!("expected API error, got {other:?}"),
    }

    Ok(())
}

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

    let client = Client::builder().forecast_base_url(server.uri())?.build()?;
    let err = forecast_error_request(&client).send().await.unwrap_err();

    match err {
        Error::RateLimited { retry_after } => {
            assert_eq!(retry_after, Some(Duration::from_secs(30)));
        }
        other => panic!("expected rate limit error, got {other:?}"),
    }

    Ok(())
}

#[tokio::test]
async fn http_429_without_retry_after_maps_to_rate_limited_without_duration() -> Result<()> {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/forecast"))
        .respond_with(ResponseTemplate::new(429))
        .mount(&server)
        .await;

    let client = Client::builder().forecast_base_url(server.uri())?.build()?;
    let err = forecast_error_request(&client).send().await.unwrap_err();

    match err {
        Error::RateLimited { retry_after } => {
            assert_eq!(retry_after, None);
        }
        other => panic!("expected rate limit error, got {other:?}"),
    }

    Ok(())
}

#[tokio::test]
async fn transport_timeout_maps_to_timeout_error() -> Result<()> {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/forecast"))
        .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_millis(100)))
        .mount(&server)
        .await;

    let client = Client::builder()
        .forecast_base_url(server.uri())?
        .timeout(Duration::from_millis(10))
        .build()?;
    let err = forecast_error_request(&client).send().await.unwrap_err();

    match err {
        Error::Timeout(duration) => {
            assert_eq!(duration, Duration::from_millis(10));
        }
        other => panic!("expected timeout error, got {other:?}"),
    }

    Ok(())
}