use crate::error::CjseqError;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
const DEFAULT_CRS_BASE_URL: &str = "https://www.opengis.net/def/crs";
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Transform {
pub scale: Vec<f64>,
pub translate: Vec<f64>,
}
impl Transform {
pub(crate) fn new() -> Self {
Transform {
scale: vec![1.0, 1.0, 1.0],
translate: vec![0., 0., 0.],
}
}
}
pub type GeographicalExtent = [f64; 6];
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
pub struct Address {
#[serde(flatten)]
pub members: HashMap<String, Value>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct PointOfContact {
#[serde(rename = "contactName")]
pub contact_name: String,
#[serde(rename = "contactType")]
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_type: Option<String>,
#[serde(rename = "role")]
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[serde(rename = "emailAddress")]
pub email_address: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub organization: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<Address>,
#[serde(flatten)]
pub other: HashMap<String, Value>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(try_from = "String", into = "String")]
pub struct ReferenceSystem {
pub base_url: String,
pub segments: Vec<String>,
}
impl ReferenceSystem {
pub fn new(base_url: Option<String>, authority: String, version: String, code: String) -> Self {
ReferenceSystem {
base_url: base_url.unwrap_or_else(|| DEFAULT_CRS_BASE_URL.to_string()),
segments: vec![authority, version, code],
}
}
pub fn authority(&self) -> Option<&str> {
self.segments.first().map(String::as_str)
}
pub fn version(&self) -> Option<&str> {
self.segments.get(1).map(String::as_str)
}
pub fn code(&self) -> Option<&str> {
self.segments.get(2).map(String::as_str)
}
pub fn to_url(&self) -> String {
format!("{}/{}", self.base_url, self.segments.join("/"))
}
pub fn from_url(url: &str) -> crate::error::Result<Self> {
const PREFIXES: [&str; 2] = [
"http://www.opengis.net/def/crs/",
"https://www.opengis.net/def/crs/",
];
let Some(prefix) = PREFIXES.iter().find(|p| url.starts_with(**p)) else {
return Err(CjseqError::Validation(format!(
"not a CRS URL: {url:?} does not match the required prefix \
(http|https)://www.opengis.net/def/crs/"
)));
};
let split = prefix.len();
Ok(ReferenceSystem {
base_url: url[..split - 1].to_string(),
segments: url[split..].split('/').map(str::to_string).collect(),
})
}
}
impl From<ReferenceSystem> for String {
fn from(rs: ReferenceSystem) -> String {
rs.to_url()
}
}
impl TryFrom<String> for ReferenceSystem {
type Error = CjseqError;
fn try_from(url: String) -> crate::error::Result<Self> {
ReferenceSystem::from_url(&url)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Metadata {
#[serde(rename = "geographicalExtent")]
#[serde(skip_serializing_if = "Option::is_none")]
pub geographical_extent: Option<GeographicalExtent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(rename = "pointOfContact")]
#[serde(skip_serializing_if = "Option::is_none")]
pub point_of_contact: Option<PointOfContact>,
#[serde(rename = "referenceDate")]
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_date: Option<String>,
#[serde(rename = "referenceSystem")]
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_system: Option<ReferenceSystem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(flatten)]
pub other: HashMap<String, Value>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transform_with_equal_fields_is_equal() {
let a = Transform::new();
let b = Transform::new();
assert_eq!(a, b);
let mut c = Transform::new();
c.translate = vec![1.0, 0.0, 0.0];
assert_ne!(a, c);
}
#[test]
fn address_with_equal_fields_is_equal() {
let a: Address = serde_json::from_value(serde_json::json!({
"thoroughfareNumber": 1,
"thoroughfareName": "rue de la Patate",
"locality": "Chibougamau",
"postalCode": "H0H 0H0",
"country": "Canada"
}))
.unwrap();
let b = a.clone();
assert_eq!(a, b);
assert_ne!(a, Address::default());
}
#[test]
fn point_of_contact_with_equal_fields_is_equal() {
let a = PointOfContact {
contact_name: "Jane Doe".to_string(),
contact_type: None,
role: None,
phone: None,
email_address: "jane@example.com".to_string(),
website: None,
organization: None,
address: None,
other: HashMap::new(),
};
let b = a.clone();
assert_eq!(a, b);
}
#[test]
fn the_specs_own_point_of_contact_example_parses_and_round_trips() {
let input = serde_json::json!({
"contactName": "Justin Trudeau",
"emailAddress": "justin.trudeau@parl.gc.ca",
"phone": "+1-613-992-4211",
"address": {
"thoroughfareNumber": "24",
"thoroughfareName": "Sussez Drive",
"postcode": "H0H 0H0",
"locality": "Ottawa",
"country": "Canada"
},
"contactType": "individual",
"role": "pointOfContact"
});
let poc: PointOfContact =
serde_json::from_value(input.clone()).expect("the spec's own example must parse");
assert_eq!(
poc.address.as_ref().unwrap().members["thoroughfareNumber"],
serde_json::json!("24")
);
assert_eq!(serde_json::to_value(&poc).unwrap(), input);
}
#[test]
fn an_address_keeps_whatever_members_it_was_given() {
for input in [
serde_json::json!({"thoroughfareNumber": "134", "postcode": "2628BL"}),
serde_json::json!({"thoroughfareNumber": 134, "postalCode": "2628BL"}),
serde_json::json!({"freeform": {"nested": [1, 2]}}),
serde_json::json!({}),
] {
let a: Address = serde_json::from_value(input.clone()).unwrap();
assert_eq!(serde_json::to_value(&a).unwrap(), input);
}
}
#[test]
fn point_of_contact_keeps_organization_and_unnamed_members() {
let input = serde_json::json!({
"contactName": "Jane Doe",
"emailAddress": "jane@example.com",
"organization": "3D geoinformation group, TU Delft",
"somethingElse": 42
});
let poc: PointOfContact = serde_json::from_value(input.clone()).unwrap();
assert_eq!(
poc.organization.as_deref(),
Some("3D geoinformation group, TU Delft")
);
assert_eq!(serde_json::to_value(&poc).unwrap(), input);
}
#[test]
fn point_of_contact_still_requires_the_two_members_the_schema_requires() {
assert!(serde_json::from_value::<PointOfContact>(
serde_json::json!({"emailAddress": "a@b.c"})
)
.is_err());
assert!(
serde_json::from_value::<PointOfContact>(serde_json::json!({"contactName": "A"}))
.is_err()
);
}
#[test]
fn reference_system_with_equal_fields_is_equal() {
let a = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/7415").unwrap();
let b = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/7415").unwrap();
assert_eq!(a, b);
let c = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/4326").unwrap();
assert_ne!(a, c);
}
#[test]
fn reference_system_is_a_string_in_json_not_an_object() {
let url = "https://www.opengis.net/def/crs/EPSG/0/7415";
let rs: ReferenceSystem = serde_json::from_value(serde_json::json!(url)).unwrap();
assert_eq!(rs.authority(), Some("EPSG"));
assert_eq!(rs.version(), Some("0"));
assert_eq!(rs.code(), Some("7415"));
assert_eq!(serde_json::to_value(&rs).unwrap(), serde_json::json!(url));
}
#[test]
fn any_url_matching_the_schemas_prefix_pattern_is_accepted_and_round_trips() {
for url in [
"https://www.opengis.net/def/crs/EPSG/0/4326/extra",
"https://www.opengis.net/def/crs/EPSG/0/7415/",
"https://www.opengis.net/def/crs/EPSG/0",
"https://www.opengis.net/def/crs/EPSG",
"https://www.opengis.net/def/crs/",
"http://www.opengis.net/def/crs/EPSG/0/7415",
"https://www.opengis.net/def/crs/EPSG/0/7415",
] {
let rs = ReferenceSystem::from_url(url)
.unwrap_or_else(|e| panic!("{url} matches the schema pattern: {e}"));
assert_eq!(rs.to_url(), url, "{url} must round-trip byte for byte");
let via_serde: ReferenceSystem =
serde_json::from_value(serde_json::json!(url)).unwrap();
assert_eq!(
serde_json::to_value(&via_serde).unwrap(),
serde_json::json!(url)
);
}
}
#[test]
fn the_ogc_accessors_are_optional() {
let short = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG").unwrap();
assert_eq!(short.authority(), Some("EPSG"));
assert_eq!(short.version(), None);
assert_eq!(short.code(), None);
let long =
ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/4326/extra").unwrap();
assert_eq!(long.code(), Some("4326"));
assert_eq!(long.segments.len(), 4);
}
#[test]
fn a_url_failing_the_prefix_pattern_is_still_rejected() {
for bad in [
"https://example.com/def/crs/EPSG/0/7415",
"https://www.opengis.net/def/EPSG/0/7415",
"https://www.opengis.net/def/crs",
"",
"ftp://www.opengis.net/def/crs/EPSG/0/7415",
"www.opengis.net/def/crs/EPSG/0/7415",
"https://example.com/?redirect=https://www.opengis.net/def/crs/EPSG/0/7415",
] {
assert!(
ReferenceSystem::from_url(bad).is_err(),
"{bad:?} does not match the schema pattern and must be rejected"
);
}
}
#[test]
fn reference_system_round_trips_inside_metadata() {
let input = serde_json::json!({
"referenceSystem": "https://www.opengis.net/def/crs/EPSG/0/7415"
});
let m: Metadata = serde_json::from_value(input.clone()).unwrap();
assert_eq!(serde_json::to_value(&m).unwrap(), input);
}
#[test]
fn a_non_crs_reference_system_is_rejected() {
assert!(serde_json::from_value::<ReferenceSystem>(serde_json::json!("not-a-url")).is_err());
}
#[test]
fn metadata_with_equal_fields_is_equal() {
let a = Metadata {
geographical_extent: Some([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]),
identifier: None,
point_of_contact: None,
reference_date: None,
reference_system: None,
title: Some("dataset".to_string()),
other: HashMap::new(),
};
let b = a.clone();
assert_eq!(a, b);
}
#[test]
fn metadata_keeps_members_the_core_spec_does_not_name() {
let input = serde_json::json!({
"title": "3DBAG",
"fullMetadataUrl": "https://data.3dbag.nl/metadata/v20240420/metadata.json"
});
let m: Metadata = serde_json::from_value(input.clone()).unwrap();
assert_eq!(m.title.as_deref(), Some("3DBAG"));
assert_eq!(
m.other.get("fullMetadataUrl").and_then(|v| v.as_str()),
Some("https://data.3dbag.nl/metadata/v20240420/metadata.json")
);
assert_eq!(serde_json::to_value(&m).unwrap(), input);
}
#[test]
fn a_nested_extra_metadata_member_survives() {
let input = serde_json::json!({
"+metadata-extended": {"lineage": [{"thematicModels": ["Building"]}]}
});
let m: Metadata = serde_json::from_value(input.clone()).unwrap();
assert_eq!(serde_json::to_value(&m).unwrap(), input);
}
#[test]
fn an_empty_other_adds_nothing_on_the_way_out() {
let m: Metadata = serde_json::from_value(serde_json::json!({"title": "x"})).unwrap();
assert!(m.other.is_empty());
assert_eq!(
serde_json::to_value(&m).unwrap(),
serde_json::json!({"title": "x"})
);
}
}