aviso-validators 0.11.1

Validation primitives used by aviso-server for identifier and payload checks.
Documentation
// (C) Copyright 2024- ECMWF and individual contributors.
//
// This software is licensed under the terms of the Apache Licence Version 2.0
// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
// In applying this licence, ECMWF does not waive the privileges and immunities
// granted to it by virtue of its status as an intergovernmental organisation nor
// does it submit to any jurisdiction.

use crate::coordinate::{Coordinate, coordinate_to_json, parse_coordinate, validate_coordinate};
use anyhow::{Result, bail};
use serde_json::Value;
use tracing::debug;

/// Point coordinate validator.
///
/// Accepts `[lat,lon]`, `lat,lon`, and `(lat,lon)` input.
pub struct PointHandler;

impl PointHandler {
    pub fn validate_and_canonicalize(value: &str, field_name: &str) -> Result<String> {
        let (lat, lon) = Self::parse_point_coordinates(value)?;
        debug!(
            field = field_name,
            lat = lat,
            lon = lon,
            "Point validated successfully"
        );
        Ok(format!("{lat},{lon}"))
    }

    /// Validate a JSON point value and return canonical `[lat,lon]` JSON.
    pub fn validate_json_and_canonicalize(value: &Value, field_name: &str) -> Result<Value> {
        let coordinate = Self::parse_point_value(value).map_err(|error| {
            anyhow::anyhow!("field '{}' must be a valid point: {}", field_name, error)
        })?;
        coordinate_to_json(coordinate).map_err(Into::into)
    }

    /// Parse a JSON point array or a compatible legacy point string.
    pub fn parse_point_value(value: &Value) -> Result<Coordinate> {
        match value {
            Value::Array(_) => parse_coordinate(value).map_err(Into::into),
            Value::String(value) => Self::parse_point_coordinates(value),
            _ => bail!("point must be a two-element array [lat,lon] or a compatible string"),
        }
    }

    pub fn parse_point_coordinates(value: &str) -> Result<(f64, f64)> {
        let trimmed = value.trim();
        if trimmed.is_empty() {
            bail!("Point coordinate string cannot be empty");
        }

        if trimmed.starts_with('[') {
            let value: Value = serde_json::from_str(trimmed)
                .map_err(|error| anyhow::anyhow!("invalid point JSON array: {}", error))?;
            return Self::parse_point_value(&value);
        }

        let inner = trimmed
            .strip_prefix('(')
            .and_then(|s| s.strip_suffix(')'))
            .unwrap_or(trimmed)
            .trim();

        let mut parts = inner.split(',').map(str::trim);
        let lat_str = parts
            .next()
            .ok_or_else(|| anyhow::anyhow!("Point must be in 'lat,lon' format"))?;
        let lon_str = parts
            .next()
            .ok_or_else(|| anyhow::anyhow!("Point must be in 'lat,lon' format"))?;

        if parts.next().is_some() {
            bail!("Point must contain exactly two values: 'lat,lon'");
        }

        let lat: f64 = lat_str
            .parse()
            .map_err(|_| anyhow::anyhow!("Invalid latitude value: {}", lat_str))?;
        let lon: f64 = lon_str
            .parse()
            .map_err(|_| anyhow::anyhow!("Invalid longitude value: {}", lon_str))?;

        validate_coordinate(lat, lon).map_err(Into::into)
    }
}

#[cfg(test)]
mod tests {
    use super::PointHandler;

    #[test]
    fn parse_point_coordinates_accepts_basic_format() {
        let (lat, lon) = PointHandler::parse_point_coordinates("52.55,13.5").unwrap();
        assert_eq!(lat, 52.55);
        assert_eq!(lon, 13.5);
    }

    #[test]
    fn parse_point_coordinates_accepts_parenthesized_format() {
        let (lat, lon) = PointHandler::parse_point_coordinates("(52.55,13.5)").unwrap();
        assert_eq!(lat, 52.55);
        assert_eq!(lon, 13.5);
    }

    #[test]
    fn parse_point_coordinates_accepts_canonical_json_string() {
        let point = PointHandler::parse_point_coordinates("[52.55,13.5]").unwrap();
        assert_eq!(point, (52.55, 13.5));
    }

    #[test]
    fn parse_point_coordinates_rejects_bad_format() {
        assert!(PointHandler::parse_point_coordinates("52.55").is_err());
        assert!(PointHandler::parse_point_coordinates("52.55,13.5,1.0").is_err());
    }

    #[test]
    fn parse_point_coordinates_rejects_out_of_range() {
        assert!(PointHandler::parse_point_coordinates("91,13.5").is_err());
        assert!(PointHandler::parse_point_coordinates("52.5,181").is_err());
    }

    #[test]
    fn validate_and_canonicalize_returns_canonical_form() {
        let canonical = PointHandler::validate_and_canonicalize("(52.5500,13.5000)", "p").unwrap();
        assert_eq!(canonical, "52.55,13.5");
    }

    #[test]
    fn json_array_is_canonical() {
        let canonical = PointHandler::validate_json_and_canonicalize(
            &serde_json::json!([52.5500, 13.5000]),
            "point",
        )
        .unwrap();
        assert_eq!(canonical, serde_json::json!([52.55, 13.5]));
    }
}