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}