openmeteo-rs 1.0.0

Rust client for the Open-Meteo weather API.
Documentation
use std::collections::BTreeMap;

use serde::Deserialize;
use serde_json::{Map, Value};
use time::format_description::FormatItem;
use time::macros::format_description;
use time::{Date, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};

use super::{
    CurrentData, DailyData, ForecastResponse, HourlyData, Minutely15Data, MonthlyData,
    SeriesValues, VariableDescriptor, VariableSeries,
};
use crate::json_sanitize::sanitize_open_meteo_floats;
use crate::{Error, Result};

const DATE_TIME_FORMAT: &[FormatItem<'_>] =
    format_description!("[year]-[month]-[day]T[hour]:[minute]");
const DATE_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day]");
pub(crate) fn decode_forecast_json(bytes: &[u8]) -> Result<ForecastResponse> {
    let sanitized = sanitize_open_meteo_floats(bytes);
    let raw: RawForecastResponse = serde_json::from_slice(sanitized.as_ref())?;
    decode_raw_forecast(raw)
}

pub(crate) fn decode_forecast_json_many(bytes: &[u8]) -> Result<Vec<ForecastResponse>> {
    let sanitized = sanitize_open_meteo_floats(bytes);

    if sanitized
        .as_ref()
        .iter()
        .find(|byte| !byte.is_ascii_whitespace())
        == Some(&b'[')
    {
        let raw: Vec<RawForecastResponse> = serde_json::from_slice(sanitized.as_ref())?;
        raw.into_iter().map(decode_raw_forecast).collect()
    } else {
        decode_forecast_json(sanitized.as_ref()).map(|response| vec![response])
    }
}

fn decode_raw_forecast(raw: RawForecastResponse) -> Result<ForecastResponse> {
    let offset_seconds = raw
        .utc_offset_seconds
        .ok_or_else(|| Error::InvalidResponse {
            reason: "response is missing `utc_offset_seconds`".into(),
        })?;

    Ok(ForecastResponse {
        latitude: raw.latitude,
        longitude: raw.longitude,
        elevation: raw.elevation,
        generation_time_ms: raw.generation_time_ms,
        utc_offset_seconds: offset_seconds,
        timezone: raw.timezone,
        timezone_abbreviation: raw.timezone_abbreviation,
        current: decode_current(raw.current, raw.current_units, offset_seconds)?,
        hourly: decode_timed_group(
            raw.hourly,
            raw.hourly_units,
            offset_seconds,
            TimeGroupKind::Instant,
        )?
        .map(|group| HourlyData {
            time: group.time,
            variables: group.variables,
        }),
        minutely_15: decode_timed_group(
            raw.minutely_15,
            raw.minutely_15_units,
            offset_seconds,
            TimeGroupKind::Instant,
        )?
        .map(|group| Minutely15Data {
            time: group.time,
            variables: group.variables,
        }),
        daily: decode_timed_group(
            raw.daily,
            raw.daily_units,
            offset_seconds,
            TimeGroupKind::Calendar,
        )?
        .map(|group| DailyData {
            time: group.time,
            variables: group.variables,
        }),
        monthly: decode_timed_group(
            raw.monthly,
            raw.monthly_units,
            offset_seconds,
            TimeGroupKind::Calendar,
        )?
        .map(|group| MonthlyData {
            time: group.time,
            variables: group.variables,
        }),
    })
}

#[derive(Debug, Deserialize)]
struct RawForecastResponse {
    latitude: f64,
    longitude: f64,
    elevation: Option<f64>,
    #[serde(rename = "generationtime_ms")]
    generation_time_ms: Option<f32>,
    utc_offset_seconds: Option<i32>,
    timezone: Option<String>,
    timezone_abbreviation: Option<String>,
    current_units: Option<BTreeMap<String, String>>,
    current: Option<Map<String, Value>>,
    hourly_units: Option<BTreeMap<String, String>>,
    hourly: Option<Map<String, Value>>,
    minutely_15_units: Option<BTreeMap<String, String>>,
    minutely_15: Option<Map<String, Value>>,
    daily_units: Option<BTreeMap<String, String>>,
    daily: Option<Map<String, Value>>,
    monthly_units: Option<BTreeMap<String, String>>,
    monthly: Option<Map<String, Value>>,
}

struct TimedGroup {
    time: Vec<OffsetDateTime>,
    variables: Vec<VariableSeries>,
}

#[derive(Debug, Clone, Copy)]
enum TimeGroupKind {
    Instant,
    Calendar,
}

fn decode_current(
    current: Option<Map<String, Value>>,
    units: Option<BTreeMap<String, String>>,
    offset_seconds: i32,
) -> Result<Option<CurrentData>> {
    let Some(current) = current else {
        return Ok(None);
    };
    let units = units.unwrap_or_default();
    let time = match current.get("time") {
        Some(value) => Some(parse_time_value(
            value,
            offset_seconds,
            TimeGroupKind::Instant,
        )?),
        None => None,
    };
    let mut variables = Vec::new();

    for (key, value) in current {
        if key == "time" || key == "interval" {
            continue;
        }
        let values = match value {
            Value::Number(number) => {
                let value = number.as_f64().ok_or_else(|| Error::InvalidResponse {
                    reason: format!("current field `{key}` is not representable as f32"),
                })?;
                SeriesValues::F32(vec![Some(value as f32)])
            }
            Value::String(value) => SeriesValues::Strings(vec![Some(value)]),
            Value::Null => SeriesValues::F32(vec![None]),
            _ => continue,
        };
        variables.push(VariableSeries {
            descriptor: VariableDescriptor::from_api_name(&key),
            unit: units.get(&key).cloned(),
            values,
        });
    }

    Ok(Some(CurrentData { time, variables }))
}

fn decode_timed_group(
    group: Option<Map<String, Value>>,
    units: Option<BTreeMap<String, String>>,
    offset_seconds: i32,
    kind: TimeGroupKind,
) -> Result<Option<TimedGroup>> {
    let Some(group) = group else {
        return Ok(None);
    };
    let units = units.unwrap_or_default();
    let Some(time_value) = group.get("time") else {
        return Err(Error::InvalidResponse {
            reason: "time-series group is missing `time`".into(),
        });
    };
    let time = parse_time_array(time_value, offset_seconds, kind)?;
    let mut variables = Vec::new();

    for (key, value) in group {
        if key == "time" {
            continue;
        }
        let values = parse_series_values(&key, &value)?;
        if values.len() != time.len() {
            return Err(Error::InvalidResponse {
                reason: format!(
                    "field `{key}` has {} values but `time` has {}",
                    values.len(),
                    time.len()
                ),
            });
        }
        variables.push(VariableSeries {
            descriptor: VariableDescriptor::from_api_name(&key),
            unit: units.get(&key).cloned(),
            values,
        });
    }

    Ok(Some(TimedGroup { time, variables }))
}

fn parse_series_values(key: &str, value: &Value) -> Result<SeriesValues> {
    let array = value.as_array().ok_or_else(|| Error::InvalidResponse {
        reason: format!("field `{key}` is not an array"),
    })?;

    if array
        .iter()
        .all(|value| value.is_number() || value.is_null())
    {
        let mut values = Vec::with_capacity(array.len());
        for value in array {
            if value.is_null() {
                values.push(None);
            } else {
                let value = value.as_f64().ok_or_else(|| Error::InvalidResponse {
                    reason: format!("field `{key}` contains a non-f32 numeric value"),
                })?;
                values.push(Some(value as f32));
            }
        }
        Ok(SeriesValues::F32(values))
    } else if array
        .iter()
        .all(|value| value.is_string() || value.is_null())
    {
        Ok(SeriesValues::Strings(
            array
                .iter()
                .map(|value| value.as_str().map(str::to_owned))
                .collect(),
        ))
    } else {
        Err(Error::InvalidResponse {
            reason: format!("field `{key}` contains mixed or unsupported values"),
        })
    }
}

fn parse_time_array(
    value: &Value,
    offset_seconds: i32,
    kind: TimeGroupKind,
) -> Result<Vec<OffsetDateTime>> {
    let array = value.as_array().ok_or_else(|| Error::InvalidResponse {
        reason: "`time` is not an array".into(),
    })?;
    array
        .iter()
        .map(|value| parse_time_value(value, offset_seconds, kind))
        .collect()
}

fn parse_time_value(
    value: &Value,
    offset_seconds: i32,
    kind: TimeGroupKind,
) -> Result<OffsetDateTime> {
    match value {
        Value::String(value) => parse_time_str(value, offset_seconds),
        Value::Number(value) => {
            let unix = value.as_i64().ok_or_else(|| Error::InvalidResponse {
                reason: "`time` unix timestamp is not an integer".into(),
            })?;
            parse_unix_time(unix, offset_seconds, kind).map_err(|err| Error::TimeParse {
                value: unix.to_string(),
                reason: err.to_string(),
            })
        }
        _ => Err(Error::InvalidResponse {
            reason: "`time` contains unsupported value".into(),
        }),
    }
}

fn parse_unix_time(
    unix: i64,
    offset_seconds: i32,
    kind: TimeGroupKind,
) -> std::result::Result<OffsetDateTime, time::error::ComponentRange> {
    let offset = UtcOffset::from_whole_seconds(offset_seconds)?;
    let instant = OffsetDateTime::from_unix_timestamp(unix)?.to_offset(offset);

    match kind {
        TimeGroupKind::Instant => Ok(instant),
        TimeGroupKind::Calendar => Ok(instant
            .date()
            .with_time(Time::MIDNIGHT)
            .assume_offset(offset)),
    }
}

fn parse_time_str(value: &str, offset_seconds: i32) -> Result<OffsetDateTime> {
    let offset = UtcOffset::from_whole_seconds(offset_seconds).map_err(|err| Error::TimeParse {
        value: value.to_owned(),
        reason: err.to_string(),
    })?;

    if value.len() == 10 {
        let date = Date::parse(value, DATE_FORMAT).map_err(|err| Error::TimeParse {
            value: value.to_owned(),
            reason: err.to_string(),
        })?;
        return Ok(date.with_time(Time::MIDNIGHT).assume_offset(offset));
    }

    let local =
        PrimitiveDateTime::parse(value, DATE_TIME_FORMAT).map_err(|err| Error::TimeParse {
            value: value.to_owned(),
            reason: err.to_string(),
        })?;
    Ok(local.assume_offset(offset))
}