Skip to main content

cjseq/
metadata.rs

1use crate::error::CjseqError;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::HashMap;
5
6const DEFAULT_CRS_BASE_URL: &str = "https://www.opengis.net/def/crs";
7
8#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
9pub struct Transform {
10    pub scale: Vec<f64>,
11    pub translate: Vec<f64>,
12}
13impl Transform {
14    pub(crate) fn new() -> Self {
15        Transform {
16            scale: vec![1.0, 1.0, 1.0],
17            translate: vec![0., 0., 0.],
18        }
19    }
20}
21
22pub type GeographicalExtent = [f64; 6];
23
24/// The `address` member of a [`PointOfContact`] (§ 5.3 pointOfContact).
25///
26/// Deliberately free-form, and that is what the normative schema asks for:
27/// `metadata.schema.json` types `address` as a bare `{"type": "object"}` --
28/// no `properties`, no `required`, no `additionalProperties`. The prose says
29/// why in as many words: "any properties can be used, to accommodate the
30/// different ways addresses are structured in different countries" (§ 5.3).
31///
32/// This type used to declare five *required* members (`thoroughfareNumber`
33/// typed `i64`, `thoroughfareName`, `locality`, `postalCode`, `country`) and
34/// so rejected nearly every legal address -- including both of the spec's own
35/// examples, which spell the number as a *string* (`"134"`, `"24"`) and the
36/// postcode as `postcode`, not `postalCode`. Structure the model does not
37/// actually know is worse than no structure: it turns valid input into a
38/// parse error.
39#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
40pub struct Address {
41    #[serde(flatten)]
42    pub members: HashMap<String, Value>,
43}
44
45/// The `pointOfContact` member of [`Metadata`] (§ 5.3).
46///
47/// `contactName` and `emailAddress` are the schema's only two `required`
48/// members; everything else is optional. As with [`Metadata`], the schema's
49/// `contactDetails` definition declares no `additionalProperties: false`, so
50/// `other` keeps anything further rather than dropping it.
51#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
52pub struct PointOfContact {
53    #[serde(rename = "contactName")]
54    pub contact_name: String,
55    #[serde(rename = "contactType")]
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub contact_type: Option<String>,
58    #[serde(rename = "role")]
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub role: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub phone: Option<String>,
63    #[serde(rename = "emailAddress")]
64    pub email_address: String,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub website: Option<String>,
67    /// The name of the organisation, "to be used if the `contactName` is the
68    /// name of a person" (§ 5.3). Named by both the prose and the schema's
69    /// `contactDetails.properties`; it had no field here at all, so it was
70    /// silently dropped.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub organization: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub address: Option<Address>,
75    /// Every contact member the schema does not name, kept verbatim.
76    #[serde(flatten)]
77    pub other: HashMap<String, Value>,
78}
79
80/// A reference system following the OGC Name Type Specification.
81///
82/// The format follows: `http://www.opengis.net/def/crs/{authority}/{version}/{code}`
83/// where:
84/// - `{authority}` designates the authority responsible for the definition of this CRS
85///   (usually "EPSG" or "OGC")
86/// - `{version}` designates the specific version of the CRS
87///   (use "0" if there is no version)
88/// - `{code}` is the identifier for the specific coordinate reference system
89///
90/// That three-element form is what OGC names and what every real file uses,
91/// but it is **not** what makes a `referenceSystem` legal. The normative
92/// constraint in `metadata.schema.json` is a prefix pattern and nothing else:
93///
94/// ```text
95/// "referenceSystem": {
96///   "type": "string",
97///   "pattern": "^(http|https)://www.opengis.net/def/crs/"
98/// }
99/// ```
100///
101/// So `.../crs/EPSG/0/4326/extra` and `.../crs/EPSG/0/7415/` are both legal
102/// CityJSON, and an earlier version of this type rejected them outright by
103/// demanding exactly three `/`-separated elements. That is the same
104/// rejects-legal defect as the old [`Address`], and the fix is the same:
105/// stop inventing structure the format does not mandate.
106///
107/// Hence `segments` rather than three named fields. It holds the path
108/// elements after `.../def/crs/` verbatim, so [`ReferenceSystem::to_url`] can
109/// rebuild *any* legal URL byte for byte -- including a trailing slash, which
110/// is simply a final empty element. The OGC reading is still available, as
111/// [`ReferenceSystem::authority`], [`ReferenceSystem::version`] and
112/// [`ReferenceSystem::code`], which return `None` when the URL does not have
113/// that shape. Keeping the three as plain fields *alongside* the verbatim
114/// path was the obvious alternative and is a trap: the two representations
115/// can disagree, and then `to_url` silently contradicts the fields.
116///
117/// In JSON this is a single string, not an object, so the derive needs
118/// telling how to get between the two. `#[serde(try_from/into)]` does exactly
119/// that and nothing else: `Serialize` and `Deserialize` stay **derived**, and
120/// the conversion is ordinary `TryFrom`/`From` code that serde calls on
121/// either side of a plain `String`. This replaces a hand-written `Serialize`
122/// and `Deserialize` pair, which violated the crate's derived-only rule --
123/// hand-written impls are the one place a field can be silently dropped or
124/// reshaped without any type-level trace, which is the whole reason for the
125/// rule.
126#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
127#[serde(try_from = "String", into = "String")]
128pub struct ReferenceSystem {
129    /// Everything up to and including `.../def/crs`, with no trailing slash.
130    pub base_url: String,
131    /// The `/`-separated path elements after `.../def/crs/`, verbatim. OGC
132    /// names the first three `{authority}/{version}/{code}`; the schema
133    /// requires no particular number of them.
134    pub segments: Vec<String>,
135}
136
137impl ReferenceSystem {
138    /// The ordinary three-element OGC form.
139    pub fn new(base_url: Option<String>, authority: String, version: String, code: String) -> Self {
140        ReferenceSystem {
141            base_url: base_url.unwrap_or_else(|| DEFAULT_CRS_BASE_URL.to_string()),
142            segments: vec![authority, version, code],
143        }
144    }
145
146    /// The `{authority}` element, or `None` if this URL has no such element.
147    pub fn authority(&self) -> Option<&str> {
148        self.segments.first().map(String::as_str)
149    }
150
151    /// The `{version}` element (`"0"` when the CRS has no version).
152    pub fn version(&self) -> Option<&str> {
153        self.segments.get(1).map(String::as_str)
154    }
155
156    /// The `{code}` element identifying the CRS.
157    pub fn code(&self) -> Option<&str> {
158        self.segments.get(2).map(String::as_str)
159    }
160
161    /// Rebuild the URL. Exact for every input [`ReferenceSystem::from_url`]
162    /// accepts, whatever its element count -- `segments` is stored verbatim,
163    /// so there is nothing to reconstruct incorrectly.
164    pub fn to_url(&self) -> String {
165        format!("{}/{}", self.base_url, self.segments.join("/"))
166    }
167
168    /// Parse a `referenceSystem` string.
169    ///
170    /// The only thing checked is the prefix, because the prefix is the only
171    /// thing `metadata.schema.json` constrains. The OGC three-element form is
172    /// a convention this honours (see [`ReferenceSystem::authority`] and
173    /// friends) but does not enforce, so a longer or shorter path is carried
174    /// through rather than rejected.
175    pub fn from_url(url: &str) -> crate::error::Result<Self> {
176        //-- the schema's pattern is `^(http|https)://www.opengis.net/def/crs/`,
177        //-- and the `^` is load-bearing: it anchors the scheme. Searching for
178        //-- the host anywhere in the string would accept `ftp://www.opengis
179        //-- .net/def/crs/x`, or any URL that merely embeds the host in a query
180        //-- string, neither of which the pattern matches. So: `starts_with`,
181        //-- once per permitted scheme.
182        const PREFIXES: [&str; 2] = [
183            "http://www.opengis.net/def/crs/",
184            "https://www.opengis.net/def/crs/",
185        ];
186        let Some(prefix) = PREFIXES.iter().find(|p| url.starts_with(**p)) else {
187            return Err(CjseqError::Validation(format!(
188                "not a CRS URL: {url:?} does not match the required prefix \
189                 (http|https)://www.opengis.net/def/crs/"
190            )));
191        };
192        let split = prefix.len();
193        Ok(ReferenceSystem {
194            //-- everything before the `/` that separates prefix from path
195            base_url: url[..split - 1].to_string(),
196            segments: url[split..].split('/').map(str::to_string).collect(),
197        })
198    }
199}
200
201/// The two conversions `#[serde(try_from = "String", into = "String")]` calls.
202/// They carry no serde logic of their own -- they are the same `to_url` and
203/// `from_url` the rest of the crate uses.
204impl From<ReferenceSystem> for String {
205    fn from(rs: ReferenceSystem) -> String {
206        rs.to_url()
207    }
208}
209
210impl TryFrom<String> for ReferenceSystem {
211    type Error = CjseqError;
212    fn try_from(url: String) -> crate::error::Result<Self> {
213        ReferenceSystem::from_url(&url)
214    }
215}
216
217/// The `metadata` member of a [`crate::CityJSON`] object (§ 5 Metadata).
218///
219/// The six members below are the ones the core spec names. They are *not* the
220/// only ones a valid document may carry: `metadata.schema.json`'s `metadata`
221/// object declares those six under `properties` and then stops -- there is no
222/// `additionalProperties: false`, unlike (say) `transform` or
223/// `geometry-templates`, which both do declare it. So any further member is
224/// legal CityJSON, and the spec itself points at the MetadataExtended
225/// Extension as the place to put more.
226///
227/// Hence `other`: without it every such member is silently dropped on the way
228/// through. Real files rely on this -- every 3DBAG dataset carries
229/// `fullMetadataUrl` and `version` in its metadata (see
230/// `tests/data/small.city.jsonl`), and before this field existed both simply
231/// vanished.
232#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
233pub struct Metadata {
234    #[serde(rename = "geographicalExtent")]
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub geographical_extent: Option<GeographicalExtent>,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub identifier: Option<String>,
239    #[serde(rename = "pointOfContact")]
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub point_of_contact: Option<PointOfContact>,
242    #[serde(rename = "referenceDate")]
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub reference_date: Option<String>,
245    #[serde(rename = "referenceSystem")]
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub reference_system: Option<ReferenceSystem>,
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub title: Option<String>,
250    /// Every metadata member the core spec does not name, kept verbatim.
251    #[serde(flatten)]
252    pub other: HashMap<String, Value>,
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    /// Every public type in this crate derives `PartialEq` so values can be
260    /// compared directly, not just formatted and eyeballed via `Debug`.
261    /// `Transform`, `Address`, `PointOfContact`, `ReferenceSystem`, and
262    /// `Metadata` were missing it; this pins that they now support `==`.
263    #[test]
264    fn transform_with_equal_fields_is_equal() {
265        let a = Transform::new();
266        let b = Transform::new();
267        assert_eq!(a, b);
268        let mut c = Transform::new();
269        c.translate = vec![1.0, 0.0, 0.0];
270        assert_ne!(a, c);
271    }
272
273    #[test]
274    fn address_with_equal_fields_is_equal() {
275        let a: Address = serde_json::from_value(serde_json::json!({
276            "thoroughfareNumber": 1,
277            "thoroughfareName": "rue de la Patate",
278            "locality": "Chibougamau",
279            "postalCode": "H0H 0H0",
280            "country": "Canada"
281        }))
282        .unwrap();
283        let b = a.clone();
284        assert_eq!(a, b);
285        assert_ne!(a, Address::default());
286    }
287
288    #[test]
289    fn point_of_contact_with_equal_fields_is_equal() {
290        let a = PointOfContact {
291            contact_name: "Jane Doe".to_string(),
292            contact_type: None,
293            role: None,
294            phone: None,
295            email_address: "jane@example.com".to_string(),
296            website: None,
297            organization: None,
298            address: None,
299            other: HashMap::new(),
300        };
301        let b = a.clone();
302        assert_eq!(a, b);
303    }
304
305    /// The `pointOfContact` example in § 5.3 of the spec, verbatim. cjseq
306    /// rejected it outright -- `"thoroughfareNumber": "24"` is a string and
307    /// `Address.thoroughfare_number` was an `i64` -- which is the plainest
308    /// possible demonstration that the old `Address` invented structure the
309    /// spec does not have. `metadata.schema.json` types `address` as bare
310    /// `{"type": "object"}`.
311    #[test]
312    fn the_specs_own_point_of_contact_example_parses_and_round_trips() {
313        let input = serde_json::json!({
314            "contactName": "Justin Trudeau",
315            "emailAddress": "justin.trudeau@parl.gc.ca",
316            "phone": "+1-613-992-4211",
317            "address": {
318                "thoroughfareNumber": "24",
319                "thoroughfareName": "Sussez Drive",
320                "postcode": "H0H 0H0",
321                "locality": "Ottawa",
322                "country": "Canada"
323            },
324            "contactType": "individual",
325            "role": "pointOfContact"
326        });
327        let poc: PointOfContact =
328            serde_json::from_value(input.clone()).expect("the spec's own example must parse");
329        assert_eq!(
330            poc.address.as_ref().unwrap().members["thoroughfareNumber"],
331            serde_json::json!("24")
332        );
333        assert_eq!(serde_json::to_value(&poc).unwrap(), input);
334    }
335
336    /// The § 5 metadata example spells the number as a string too, and uses
337    /// `postcode`. Both spellings must survive; neither is normalized.
338    #[test]
339    fn an_address_keeps_whatever_members_it_was_given() {
340        for input in [
341            serde_json::json!({"thoroughfareNumber": "134", "postcode": "2628BL"}),
342            serde_json::json!({"thoroughfareNumber": 134, "postalCode": "2628BL"}),
343            //-- a shape from neither example: the schema forbids nothing
344            serde_json::json!({"freeform": {"nested": [1, 2]}}),
345            serde_json::json!({}),
346        ] {
347            let a: Address = serde_json::from_value(input.clone()).unwrap();
348            assert_eq!(serde_json::to_value(&a).unwrap(), input);
349        }
350    }
351
352    /// `organization` is named by both § 5.3 and the schema's
353    /// `contactDetails.properties`, and had no field here, so it vanished.
354    #[test]
355    fn point_of_contact_keeps_organization_and_unnamed_members() {
356        let input = serde_json::json!({
357            "contactName": "Jane Doe",
358            "emailAddress": "jane@example.com",
359            "organization": "3D geoinformation group, TU Delft",
360            "somethingElse": 42
361        });
362        let poc: PointOfContact = serde_json::from_value(input.clone()).unwrap();
363        assert_eq!(
364            poc.organization.as_deref(),
365            Some("3D geoinformation group, TU Delft")
366        );
367        assert_eq!(serde_json::to_value(&poc).unwrap(), input);
368    }
369
370    /// The two members the schema marks `required` are still required: this
371    /// change relaxes `address`, not the contact itself.
372    #[test]
373    fn point_of_contact_still_requires_the_two_members_the_schema_requires() {
374        assert!(serde_json::from_value::<PointOfContact>(
375            serde_json::json!({"emailAddress": "a@b.c"})
376        )
377        .is_err());
378        assert!(
379            serde_json::from_value::<PointOfContact>(serde_json::json!({"contactName": "A"}))
380                .is_err()
381        );
382    }
383
384    #[test]
385    fn reference_system_with_equal_fields_is_equal() {
386        let a = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/7415").unwrap();
387        let b = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/7415").unwrap();
388        assert_eq!(a, b);
389        let c = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/4326").unwrap();
390        assert_ne!(a, c);
391    }
392
393    /// `ReferenceSystem` is a struct in Rust and a bare string in JSON.
394    /// Swapping its hand-written `Serialize`/`Deserialize` for
395    /// `#[serde(try_from/into)]` must not change one byte of that: a derived
396    /// `Serialize` on a four-field struct would otherwise emit an *object*,
397    /// which is what this pins.
398    #[test]
399    fn reference_system_is_a_string_in_json_not_an_object() {
400        let url = "https://www.opengis.net/def/crs/EPSG/0/7415";
401        let rs: ReferenceSystem = serde_json::from_value(serde_json::json!(url)).unwrap();
402        assert_eq!(rs.authority(), Some("EPSG"));
403        assert_eq!(rs.version(), Some("0"));
404        assert_eq!(rs.code(), Some("7415"));
405        assert_eq!(serde_json::to_value(&rs).unwrap(), serde_json::json!(url));
406    }
407
408    /// `metadata.schema.json` constrains `referenceSystem` by a *prefix
409    /// pattern* and nothing else, so a path with more or fewer than three
410    /// elements is legal CityJSON. cjseq demanded exactly three and rejected
411    /// the rest outright -- the same rejects-legal defect as the old
412    /// `Address`, and caught by the same review (codex finding A2, whose
413    /// `.../EPSG/0/4326/extra` example is the first case below).
414    ///
415    /// Each must survive byte for byte: `to_url` rebuilds from the verbatim
416    /// path, so a trailing slash (a final empty element) comes back too.
417    #[test]
418    fn any_url_matching_the_schemas_prefix_pattern_is_accepted_and_round_trips() {
419        for url in [
420            //-- codex's own example
421            "https://www.opengis.net/def/crs/EPSG/0/4326/extra",
422            //-- a trailing slash: a fourth, empty element
423            "https://www.opengis.net/def/crs/EPSG/0/7415/",
424            //-- fewer elements than OGC names
425            "https://www.opengis.net/def/crs/EPSG/0",
426            "https://www.opengis.net/def/crs/EPSG",
427            //-- the degenerate case the pattern still matches
428            "https://www.opengis.net/def/crs/",
429            //-- the pattern permits plain http too
430            "http://www.opengis.net/def/crs/EPSG/0/7415",
431            //-- and the ordinary form must be unaffected
432            "https://www.opengis.net/def/crs/EPSG/0/7415",
433        ] {
434            let rs = ReferenceSystem::from_url(url)
435                .unwrap_or_else(|e| panic!("{url} matches the schema pattern: {e}"));
436            assert_eq!(rs.to_url(), url, "{url} must round-trip byte for byte");
437            //-- and through serde, which is how it is actually used
438            let via_serde: ReferenceSystem =
439                serde_json::from_value(serde_json::json!(url)).unwrap();
440            assert_eq!(
441                serde_json::to_value(&via_serde).unwrap(),
442                serde_json::json!(url)
443            );
444        }
445    }
446
447    /// The OGC accessors report absence rather than inventing an element.
448    #[test]
449    fn the_ogc_accessors_are_optional() {
450        let short = ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG").unwrap();
451        assert_eq!(short.authority(), Some("EPSG"));
452        assert_eq!(short.version(), None);
453        assert_eq!(short.code(), None);
454
455        let long =
456            ReferenceSystem::from_url("https://www.opengis.net/def/crs/EPSG/0/4326/extra").unwrap();
457        assert_eq!(long.code(), Some("4326"));
458        assert_eq!(long.segments.len(), 4);
459    }
460
461    /// Relaxing the path must not relax the prefix: the pattern is the one
462    /// thing the schema does constrain.
463    #[test]
464    fn a_url_failing_the_prefix_pattern_is_still_rejected() {
465        for bad in [
466            "https://example.com/def/crs/EPSG/0/7415",
467            "https://www.opengis.net/def/EPSG/0/7415",
468            //-- the prefix pattern ends in a slash; `.../crs` alone lacks it
469            "https://www.opengis.net/def/crs",
470            "",
471            //-- the pattern is anchored, and names exactly two schemes. A
472            //-- substring search for the host would have let all three of
473            //-- these through with a nonsense `base_url`.
474            "ftp://www.opengis.net/def/crs/EPSG/0/7415",
475            "www.opengis.net/def/crs/EPSG/0/7415",
476            "https://example.com/?redirect=https://www.opengis.net/def/crs/EPSG/0/7415",
477        ] {
478            assert!(
479                ReferenceSystem::from_url(bad).is_err(),
480                "{bad:?} does not match the schema pattern and must be rejected"
481            );
482        }
483    }
484
485    /// And inside the metadata object it composes, which is the only place it
486    /// is ever actually serialized.
487    #[test]
488    fn reference_system_round_trips_inside_metadata() {
489        let input = serde_json::json!({
490            "referenceSystem": "https://www.opengis.net/def/crs/EPSG/0/7415"
491        });
492        let m: Metadata = serde_json::from_value(input.clone()).unwrap();
493        assert_eq!(serde_json::to_value(&m).unwrap(), input);
494    }
495
496    /// A string that is not a CRS URL is still an error rather than a silent
497    /// default -- the conversion is `TryFrom`, and serde surfaces its error.
498    #[test]
499    fn a_non_crs_reference_system_is_rejected() {
500        assert!(serde_json::from_value::<ReferenceSystem>(serde_json::json!("not-a-url")).is_err());
501    }
502
503    #[test]
504    fn metadata_with_equal_fields_is_equal() {
505        let a = Metadata {
506            geographical_extent: Some([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]),
507            identifier: None,
508            point_of_contact: None,
509            reference_date: None,
510            reference_system: None,
511            title: Some("dataset".to_string()),
512            other: HashMap::new(),
513        };
514        let b = a.clone();
515        assert_eq!(a, b);
516    }
517
518    /// `metadata.schema.json`'s `metadata` object lists six properties and
519    /// declares no `additionalProperties: false`, so any further member is
520    /// legal CityJSON. Real 3DBAG files carry `fullMetadataUrl` and
521    /// `version`; before `Metadata.other` existed, both were silently dropped
522    /// on the way through, which `tests/roundtrip.rs` caught on
523    /// `tests/data/small.city.jsonl`.
524    #[test]
525    fn metadata_keeps_members_the_core_spec_does_not_name() {
526        let input = serde_json::json!({
527            "title": "3DBAG",
528            "fullMetadataUrl": "https://data.3dbag.nl/metadata/v20240420/metadata.json"
529        });
530        let m: Metadata = serde_json::from_value(input.clone()).unwrap();
531        assert_eq!(m.title.as_deref(), Some("3DBAG"));
532        assert_eq!(
533            m.other.get("fullMetadataUrl").and_then(|v| v.as_str()),
534            Some("https://data.3dbag.nl/metadata/v20240420/metadata.json")
535        );
536        assert_eq!(serde_json::to_value(&m).unwrap(), input);
537    }
538
539    /// A nested extra member (the MetadataExtended Extension puts objects
540    /// here) must survive whole, not be flattened or truncated.
541    #[test]
542    fn a_nested_extra_metadata_member_survives() {
543        let input = serde_json::json!({
544            "+metadata-extended": {"lineage": [{"thematicModels": ["Building"]}]}
545        });
546        let m: Metadata = serde_json::from_value(input.clone()).unwrap();
547        assert_eq!(serde_json::to_value(&m).unwrap(), input);
548    }
549
550    /// An absent extra member must not reappear as an empty object or a
551    /// `null`: `#[serde(flatten)]` over an empty map writes nothing.
552    #[test]
553    fn an_empty_other_adds_nothing_on_the_way_out() {
554        let m: Metadata = serde_json::from_value(serde_json::json!({"title": "x"})).unwrap();
555        assert!(m.other.is_empty());
556        assert_eq!(
557            serde_json::to_value(&m).unwrap(),
558            serde_json::json!({"title": "x"})
559        );
560    }
561}