Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! NGSI-LD expansion + structural validation (the NGSIObject-equivalent
3 : //! pass): compacted/concise input → internal expanded form.
4 : //!
5 : //! Internal (expanded) form:
6 : //! - `id` — string URI
7 : //! - `type` — ALWAYS an array of absolute IRIs
8 : //! - `scope` — array of scope strings (when present)
9 : //! - every attribute key is an absolute IRI, its value ALWAYS an array of
10 : //! normalized instance objects; instance members keep their short NGSI-LD
11 : //! names (`type`, `value`, `object`, `datasetId`, `observedAt`, …) and
12 : //! sub-attributes are IRI-keyed arrays recursively.
13 :
14 : use crate::context::Context;
15 : use antares_model::NgsiError;
16 : use serde_json::{json, Map, Value};
17 :
18 : /// The NGSI-LD Attribute type names (Table 5.2.4-1 and 4.5.x).
19 : pub const ATTR_TYPES: &[&str] = &[
20 : "Property",
21 : "Relationship",
22 : "GeoProperty",
23 : "LanguageProperty",
24 : "JsonProperty",
25 : "VocabProperty",
26 : "ListProperty",
27 : "ListRelationship",
28 : ];
29 :
30 : /// 4.5.1: "Terms defined in the Core Context as non-reified Properties (such
31 : /// as datasetId, instanceId, etc.) shall not be used as Attribute names."
32 : /// These are the core terms whose IRI local name equals the term (the reified
33 : /// value containers like value→hasValue alias away and cannot collide).
34 : const NON_REIFIED_TERMS: &[&str] = &[
35 : "datasetId",
36 : "instanceId",
37 : "observedAt",
38 : "unitCode",
39 : "lang",
40 : "objectType",
41 : "previousValue",
42 : "previousObject",
43 : "previousLanguageMap",
44 : // 4.5.1/4.5.2.2 System Generated + 4.22: the core context maps these 1:1
45 : // onto their own IRI, so an attribute carrying the fully-qualified
46 : // spelling compacts back onto the Entity's system member.
47 : "createdAt",
48 : "modifiedAt",
49 : "deletedAt",
50 : "expiresAt",
51 : "scope",
52 : ];
53 :
54 : /// 5.2.5 Table 5.2.5-2 output-only members plus the 4.5.2.2 Prohibited ones:
55 : /// "shall never include" `entity`/`entityList` (inline Linked Entity
56 : /// retrieval) and the `previous*` family (showChanges notifications).
57 : const OUTPUT_ONLY: &[&str] = &[
58 : "entity",
59 : "entityList",
60 : "previousValue",
61 : "previousObject",
62 : "previousLanguageMap",
63 : "previousJson",
64 : "previousVocab",
65 : "previousValueList",
66 : "previousObjectList",
67 : ];
68 :
69 : /// Instance members that are NOT sub-attributes: everything 4.5 gives an
70 : /// Attribute instance beside its sub-Attributes. The one list — a walk over
71 : /// an instance decides what is a sub-Attribute by asking it, so a second
72 : /// copy is a copy free to drift as the clause grows.
73 : pub const RESERVED_MEMBERS: &[&str] = &[
74 : "type",
75 : "value",
76 : "object",
77 : "objectType",
78 : "datasetId",
79 : "observedAt",
80 : "unitCode",
81 : "lang",
82 : "languageMap",
83 : "vocab",
84 : "json",
85 : "valueList",
86 : "objectList",
87 : "entity",
88 : "entityList",
89 : "entityIdSealed",
90 : "entityTypeSealed",
91 : "valueType",
92 : "createdAt",
93 : "modifiedAt",
94 : "deletedAt",
95 : "expiresAt",
96 : "instanceId",
97 : "previousValue",
98 : "previousObject",
99 : "previousLanguageMap",
100 : "previousJson",
101 : "previousVocab",
102 : "previousValueList",
103 : "previousObjectList",
104 : ];
105 :
106 : const GEO_TYPES: &[&str] = &[
107 : "Point",
108 : "MultiPoint",
109 : "LineString",
110 : "MultiLineString",
111 : "Polygon",
112 : "MultiPolygon",
113 : ];
114 :
115 : /// Entity members that must be GeoProperties (core IRIs).
116 : const GEO_ENTITY_MEMBERS: &[&str] = &[
117 : "https://uri.etsi.org/ngsi-ld/location",
118 : "https://uri.etsi.org/ngsi-ld/observationSpace",
119 : "https://uri.etsi.org/ngsi-ld/operationSpace",
120 : ];
121 :
122 : /// Switches for [`expand_entity`] that depend on which operation the input
123 : /// payload belongs to.
124 : #[derive(Debug, Clone, Copy, Default)]
125 : pub struct ExpandOpts {
126 : /// Fragment mode: id/type not required (append/update/partial inputs).
127 : pub fragment: bool,
128 : /// Allow NGSI-LD null (`"urn:ngsi-ld:null"`) as a deletion marker
129 : /// (merge-patch inputs, 5.5.12).
130 : pub allow_null: bool,
131 : /// Merge fragment (5.5.12): the one input where 5.5.4 permits
132 : /// "urn:ngsi-ld:null" as the value of a key inside a JSON object that is
133 : /// a Property's value. Implies `allow_null`.
134 : pub merge: bool,
135 : /// Temporal representation: repeated instances of the same datasetId are
136 : /// legal (4.5.6), so the multi-instance uniqueness check is skipped.
137 : pub temporal: bool,
138 : /// Keep instance-level createdAt/modifiedAt: federation import needs them
139 : /// for 4.5.5.3 recency resolution. Provisioning paths re-stamp, so the
140 : /// flag stays off everywhere else.
141 : pub sys: bool,
142 : }
143 :
144 : /// 5.5.4 General NGSI-LD validation: "urn:ngsi-ld:null" as a first-level
145 : /// member value is BadRequestData — legal only in NGSI-LD Fragments used in
146 : /// partial update and merge operations (5.5.8, 5.5.12).
147 18922 : pub fn reject_first_level_nulls(doc: &Map<String, Value>) -> Result<(), NgsiError> {
148 70946 : for (k, v) in doc {
149 70946 : if v.as_str() == Some("urn:ngsi-ld:null") {
150 22 : return Err(NgsiError::BadRequestData(format!(
151 22 : "member {k}: \"urn:ngsi-ld:null\" is only allowed in partial \
152 22 : update or merge fragments (5.5.4)"
153 22 : )));
154 70924 : }
155 : }
156 18900 : Ok(())
157 18922 : }
158 :
159 : /// Does any key-value pair anywhere inside `v` carry the NGSI-LD Null as its
160 : /// value? (5.5.4: banned inside a JSON object that is a Property's value.)
161 45910 : fn has_object_member_null(v: &Value) -> bool {
162 45910 : match v {
163 328 : Value::Object(m) => m
164 328 : .values()
165 612 : .any(|x| x.as_str() == Some("urn:ngsi-ld:null") || has_object_member_null(x)),
166 426 : Value::Array(a) => a.iter().any(has_object_member_null),
167 45156 : _ => false,
168 : }
169 45910 : }
170 :
171 : /// Expand an Entity (or fragment) against `ctx` and validate its structure
172 : /// per 4.5.x/5.2.4; violations are `BadRequestData`.
173 14804 : pub fn expand_entity(
174 14804 : doc: &Map<String, Value>,
175 14804 : ctx: &Context,
176 14804 : opts: ExpandOpts,
177 14804 : ) -> Result<Value, NgsiError> {
178 14804 : let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
179 14804 : let mut out = Map::new();
180 :
181 : // 5.5.4: first-level member nulls are only legal in null-allowing
182 : // (partial update / merge) fragments.
183 14804 : if !opts.allow_null {
184 14266 : reject_first_level_nulls(doc)?;
185 538 : }
186 :
187 : // id
188 14790 : match doc.get("id").or_else(|| doc.get("@id")) {
189 14450 : Some(Value::String(id)) => {
190 14450 : antares_model::EntityId::new(id)?;
191 14440 : out.insert("id".into(), Value::String(id.clone()));
192 : }
193 0 : Some(_) => return Err(bad("entity id must be a string URI")),
194 338 : None if opts.fragment => {}
195 2 : None => return Err(bad("entity id is required")),
196 : }
197 :
198 : // type
199 14778 : match doc.get("type").or_else(|| doc.get("@type")) {
200 14442 : Some(v) => {
201 14442 : let types = expand_types(v, ctx)?;
202 14432 : out.insert("type".into(), Value::Array(types));
203 : }
204 334 : None if opts.fragment => {}
205 2 : None => return Err(bad("entity type is required")),
206 : }
207 :
208 : // scope
209 14766 : if let Some(v) = doc.get("scope") {
210 : // 4.18: "urn:ngsi-ld:null" shall ONLY appear for deleted scopes —
211 : // creatable solely on null-allowing (merge/patch) inputs.
212 1044 : let valid_scope = |s: &str| -> bool {
213 1044 : if s == "urn:ngsi-ld:null" {
214 10 : return opts.allow_null;
215 1034 : }
216 1034 : valid_scope_value(s)
217 1044 : };
218 1016 : let scopes: Vec<Value> = match v {
219 954 : Value::String(s) => vec![Value::String(s.clone())],
220 62 : Value::Array(a) => {
221 62 : let mut items = Vec::new();
222 86 : for s in a {
223 56 : match s {
224 30 : Value::String(s) => items.push(Value::String(s.clone())),
225 : // 4.5.6: on temporal input the scope is the temporal
226 : // representation of a Property — instance objects
227 : // whose value is a scope string or array thereof.
228 56 : Value::Object(o) if opts.temporal => {
229 56 : let vals: Vec<&str> = match o.get("value") {
230 52 : Some(Value::String(s)) => vec![s.as_str()],
231 4 : Some(Value::Array(vs)) if vs.iter().all(Value::is_string) => {
232 4 : vs.iter().filter_map(Value::as_str).collect()
233 : }
234 0 : _ => return Err(bad("scope instance needs a string value")),
235 : };
236 60 : for sv in vals {
237 60 : if !valid_scope(sv) {
238 0 : return Err(bad(&format!(
239 0 : "invalid scope {sv:?} (4.18 grammar)"
240 0 : )));
241 60 : }
242 : }
243 56 : items.push(s.clone());
244 : }
245 0 : _ => return Err(bad("scope entries must be strings")),
246 : }
247 : }
248 62 : items
249 : }
250 0 : _ => return Err(bad("scope must be a string or array of strings")),
251 : };
252 1016 : for s in scopes.iter().filter_map(Value::as_str) {
253 984 : if !valid_scope(s) {
254 32 : return Err(bad(&format!("invalid scope {s:?} (4.18 grammar)")));
255 952 : }
256 : }
257 : // The sentinel deletes the whole scope member (5.5.12), so it is the
258 : // whole value or it is not there. Mixed with real scopes it would be
259 : // stored as one of them, and 4.18 has no scope that spells it.
260 984 : if scopes.len() > 1 && scopes.iter().any(is_ngsi_null) {
261 2 : return Err(bad(
262 2 : "\"urn:ngsi-ld:null\" is the whole scope or none of it (4.18, 5.5.12)",
263 2 : ));
264 982 : }
265 982 : out.insert("scope".into(), Value::Array(scopes));
266 13750 : }
267 :
268 : // expiresAt (4.22 transient storage): the one client-settable temporal
269 : // meta member. Keep it as a bare top-level DateTime string — the shape the
270 : // read-boundary filter (filter::expired_at), the GC sweep, the postgres
271 : // `expires_at` column extraction and temporal `meta_of` all expect. Missing
272 : // this made 4.22 dead code on every backend.
273 14732 : if let Some(v) = doc.get("expiresAt") {
274 : // In a merge/partial FRAGMENT an NGSI-LD Null asks for the expiry's
275 : // removal (5.5.12) — pass it through for merge_into to act on. 5.5.4
276 : // limits the marker to those fragments, so on a whole-Entity input it
277 : // stays BadRequestData: a temporal import allows nulls for its 4.5.7
278 : // tombstones, and storing the marker as a lifetime there poisons every
279 : // later read of the tenant.
280 60 : let is_null_removal = opts.allow_null
281 14 : && (opts.fragment || opts.merge)
282 6 : && v.as_str() == Some("urn:ngsi-ld:null");
283 60 : let s = v
284 60 : .as_str()
285 60 : .filter(|s| is_null_removal || parse_datetime(s))
286 60 : .ok_or_else(|| bad("expiresAt must be an ISO 8601 DateTime"))?;
287 42 : out.insert("expiresAt".into(), Value::String(s.to_owned()));
288 14672 : }
289 :
290 : // 4.8: with sys expansion the ENTITY-level system timestamps survive —
291 : // a federated import (5.7.2.4 forwards request options=sysAttrs) must
292 : // keep the remote system's createdAt/modifiedAt/deletedAt rather than
293 : // dropping them (they are re-stamped only on local writes).
294 14714 : if opts.sys {
295 1512 : for k in ["createdAt", "modifiedAt", "deletedAt"] {
296 1512 : if let Some(Value::String(ts)) = doc.get(k) {
297 832 : if parse_datetime(ts) {
298 832 : out.insert(k.to_owned(), Value::String(ts.clone()));
299 832 : }
300 680 : }
301 : }
302 14210 : }
303 50230 : for (key, v) in doc {
304 50230 : match key.as_str() {
305 50230 : "id" | "@id" | "type" | "@type" | "@context" | "scope" | "expiresAt" | "createdAt"
306 30508 : | "modifiedAt" | "deletedAt" => continue,
307 19722 : _ => {}
308 : }
309 19722 : if key.is_empty() {
310 0 : return Err(bad("empty attribute name"));
311 19722 : }
312 19722 : let iri = expand_attr_name(key, ctx)?;
313 : // 4.5.1: core non-reified terms shall not be used as Attribute names.
314 19720 : if iri
315 19720 : .strip_prefix(crate::context::NGSI_LD_BASE)
316 19720 : .is_some_and(|t| NON_REIFIED_TERMS.contains(&t))
317 : {
318 18 : return Err(bad(&format!(
319 18 : "{key} is a core non-reified term and cannot be used as an Attribute name (4.5.1)"
320 18 : )));
321 19702 : }
322 19702 : let instances = expand_attribute(key, v, ctx, opts, 0)?;
323 19246 : if GEO_ENTITY_MEMBERS.contains(&iri.as_str()) {
324 202 : for inst in &instances {
325 202 : let t = inst.get("type").and_then(Value::as_str);
326 202 : let is_deletion = opts.allow_null && inst.get("value").is_some_and(is_ngsi_null);
327 202 : if t != Some("GeoProperty") && !is_deletion {
328 10 : return Err(bad(&format!("{key} must be a GeoProperty")));
329 192 : }
330 : }
331 19048 : }
332 19236 : if !opts.temporal {
333 18784 : validate_dataset_ids(key, &instances)?;
334 452 : }
335 : // 4.5.5.1: "There can only be one default Attribute instance for an
336 : // Attribute with a given Attribute name in any request or response" —
337 : // a term and its own expanded IRI are ONE Attribute name, so keeping
338 : // the last writer would silently discard the other member's data.
339 19228 : if out
340 19228 : .insert(iri.clone(), Value::Array(instances.into_iter().collect()))
341 19228 : .is_some()
342 : {
343 2 : return Err(bad(&format!(
344 2 : "attribute {key} expands to {iri}, which another member of \
345 2 : this Entity already defines (4.5.5.1)"
346 2 : )));
347 19226 : }
348 : }
349 14218 : Ok(Value::Object(out))
350 14804 : }
351 :
352 : /// Expand a `type` member (string or array) to absolute IRIs; a name that
353 : /// does not expand to one is `BadRequestData` (5.5.4, 4.6.2).
354 14442 : pub fn expand_types(v: &Value, ctx: &Context) -> Result<Vec<Value>, NgsiError> {
355 14442 : let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
356 : // 5.5.4/4.6.2: an Entity Type must expand to an absolute IRI; a name that
357 : // is a JSON-LD-keyword alias in the @context (e.g. "type" → "@type") is
358 : // invalid (001_02_04).
359 14454 : let one = |t: &str| -> Result<Value, NgsiError> {
360 : // 4.6.2: Entity Type names obey the name grammar (BadRequestData).
361 14454 : if !valid_name(t) {
362 4 : return Err(bad(&format!(
363 4 : "entity type {t:?} violates the 4.6.2 name grammar"
364 4 : )));
365 14450 : }
366 14450 : let iri = ctx.expand_key(t);
367 14450 : if crate::context::is_absolute_iri(&iri) {
368 14446 : Ok(Value::String(iri))
369 : } else {
370 4 : Err(bad(&format!("entity type {t:?} does not expand to an IRI")))
371 : }
372 14454 : };
373 14426 : match v {
374 14426 : Value::String(t) if !t.is_empty() => Ok(vec![one(t)?]),
375 14 : Value::Array(a) if !a.is_empty() => {
376 14 : let mut out = Vec::new();
377 28 : for t in a {
378 28 : match t {
379 28 : Value::String(t) if !t.is_empty() => out.push(one(t)?),
380 0 : _ => return Err(bad("entity type entries must be non-empty strings")),
381 : }
382 : }
383 12 : Ok(out)
384 : }
385 2 : _ => Err(bad("entity type must be a non-empty string or array")),
386 : }
387 14442 : }
388 :
389 : /// An expanded document as the object it is. `expand_entity` and
390 : /// `expand_attr_fragment` both return a JSON object, so a value that is not
391 : /// one did not come from them: the caller wired the wrong value in, and the
392 : /// mistake stays inside the one request instead of taking the process down.
393 218 : pub fn expanded_object(v: &Value) -> Result<&serde_json::Map<String, Value>, NgsiError> {
394 218 : v.as_object()
395 218 : .ok_or_else(|| NgsiError::InternalError("expanded document is not a JSON object".into()))
396 218 : }
397 :
398 : /// The `id` of an expanded Entity. `expand_entity` validates it as a URI
399 : /// before it returns, so the same rule as `expanded_object` applies.
400 13008 : pub fn expanded_id(v: &Value) -> Result<&str, NgsiError> {
401 13008 : v.get("id")
402 13008 : .and_then(Value::as_str)
403 13008 : .ok_or_else(|| NgsiError::InternalError("expanded entity carries no id".into()))
404 13008 : }
405 :
406 : /// 4.5.1/5.5.4: an Attribute or sub-Attribute name shall expand to an
407 : /// absolute IRI. A user @context is merged before the Core one (4.4), so a
408 : /// term defined as `{"@id": "id"}` stays RELATIVE and would otherwise land
409 : /// on a reserved member (`id`, `value`, `datasetId`, `observedAt`, …) and
410 : /// overwrite it — skipping that member's own validation. Same rule
411 : /// expand_types applies to Entity Type names.
412 : ///
413 : /// Public because the Attribute name also arrives in a URL path (5.6.4,
414 : /// 5.6.5, 5.6.19, 5.6.13, 5.6.14), where the clauses require the same
415 : /// "fully qualified name (URI)" from the same 5.5.7 expansion. A path name
416 : /// that skips this check lands on a member of the stored document that is
417 : /// not an Attribute.
418 20178 : pub fn expand_attr_name(name: &str, ctx: &Context) -> Result<String, NgsiError> {
419 20178 : let iri = ctx.expand_key(name);
420 20178 : if crate::context::is_absolute_iri(&iri) {
421 20138 : Ok(iri)
422 : } else {
423 40 : Err(NgsiError::BadRequestData(format!(
424 40 : "attribute name {name:?} does not expand to an absolute IRI"
425 40 : )))
426 : }
427 20178 : }
428 :
429 : /// Table 5.2.6-1 `objectType` / Table 5.2.35-1 `vocab`: "String or String[]",
430 : /// "Both short hand string(s) (type name) or URI(s) are allowed" — every entry
431 : /// is @vocab-coerced against the request @context, so the short and the
432 : /// expanded spelling of one target type cannot be stored differently.
433 52 : fn expand_terms(name: &str, member: &str, v: &Value, ctx: &Context) -> Result<Value, NgsiError> {
434 52 : let bad = || NgsiError::BadRequestData(format!("attribute {name}: invalid {member}"));
435 52 : match v {
436 34 : Value::String(s) => Ok(Value::String(ctx.expand_key(s))),
437 10 : Value::Array(a) => Ok(Value::Array(
438 10 : a.iter()
439 16 : .map(|s| {
440 16 : s.as_str()
441 16 : .map(|s| Value::String(ctx.expand_key(s)))
442 16 : .ok_or_else(bad)
443 16 : })
444 10 : .collect::<Result<_, _>>()?,
445 : )),
446 8 : _ => Err(bad()),
447 : }
448 52 : }
449 :
450 : /// 4.5.5.1: a datasetId is a URI string; "datasetId": "@none" designates the
451 : /// default Attribute instance, which never carries one — normalized to
452 : /// absent (`Ok(None)`) so storage, matching and responses treat it as such.
453 : /// 5.5.8/5.5.12: "A datasetId cannot be deleted by setting it to the value
454 : /// urn:ngsi-ld:null" — rejected on every input.
455 32100 : fn dataset_id_member(d: &Value) -> Result<Option<Value>, NgsiError> {
456 32100 : let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
457 32100 : let s = d.as_str().ok_or_else(|| bad("datasetId must be a URI"))?;
458 32092 : if s == "urn:ngsi-ld:null" {
459 12 : return Err(bad(
460 12 : "a datasetId cannot be set or deleted via \"urn:ngsi-ld:null\" (5.5.8)",
461 12 : ));
462 32080 : }
463 32080 : if s == "@none" {
464 4 : return Ok(None);
465 32076 : }
466 32076 : antares_model::EntityId::new(s).map_err(|_| bad("datasetId must be a URI"))?;
467 32072 : Ok(Some(d.clone()))
468 32100 : }
469 :
470 : /// 5.2.1: "In all other cases, implementations shall raise an error of type
471 : /// BadRequestData if an NGSI-LD Null value is encountered"; 5.5.4 bans it as
472 : /// the value of a key-value pair inside a Property's compound value except
473 : /// in merge fragments. The concise forms hand the client's JSON back as the
474 : /// Property value unchanged, so they carry the same two checks as the
475 : /// normalized path.
476 158 : fn check_value_nulls(name: &str, val: &Value, opts: ExpandOpts) -> Result<(), NgsiError> {
477 158 : let bad = NgsiError::BadRequestData;
478 158 : let nullish = match val {
479 70 : Value::String(s) => s == "urn:ngsi-ld:null",
480 2 : Value::Array(a) => a.iter().any(is_ngsi_null),
481 86 : _ => false,
482 : };
483 158 : if !opts.allow_null && nullish {
484 2 : return Err(bad(format!(
485 2 : "attribute {name}: the NGSI-LD Null is only allowed in \
486 2 : partial update or merge inputs (5.2.1)"
487 2 : )));
488 156 : }
489 156 : if !opts.merge && has_object_member_null(val) {
490 2 : return Err(bad(format!(
491 2 : "attribute {name}: \"urn:ngsi-ld:null\" inside a compound value \
492 2 : is only allowed in merge fragments (5.5.4)"
493 2 : )));
494 154 : }
495 154 : Ok(())
496 158 : }
497 :
498 : /// The NGSI-LD null sentinel — ONLY the string form (a plain JSON null is
499 : /// invalid data, 057_03_02).
500 52838 : pub fn is_ngsi_null(v: &Value) -> bool {
501 292 : matches!(v, Value::String(s) if s == "urn:ngsi-ld:null")
502 52838 : }
503 :
504 : /// A LanguageProperty deletion carries `{"@none": "urn:ngsi-ld:null"}`.
505 116 : pub fn is_ngsi_null_langmap(v: &Value) -> bool {
506 116 : is_ngsi_null(v)
507 116 : || v.as_object()
508 116 : .is_some_and(|m| m.len() == 1 && m.get("@none").is_some_and(is_ngsi_null))
509 116 : }
510 :
511 : /// 4.5.21.2/4.5.22.2: a List deletion is "an array consisting of a single
512 : /// NGSI-LD Null" as the valueList/objectList (bare null tolerated too).
513 20 : pub fn is_ngsi_null_list(v: &Value) -> bool {
514 20 : is_ngsi_null(v)
515 20 : || v.as_array()
516 20 : .is_some_and(|a| a.len() == 1 && is_ngsi_null(&a[0]))
517 20 : }
518 :
519 : /// Whole-instance deletion marker (merge patch, 5.5.12).
520 208 : pub fn is_deletion_instance(inst: &Value) -> bool {
521 208 : is_ngsi_null(inst)
522 208 : || inst.as_object().is_some_and(|o| {
523 208 : o.get("value").is_some_and(is_ngsi_null)
524 196 : || o.get("object").is_some_and(is_ngsi_null)
525 196 : || o.get("languageMap").is_some_and(is_ngsi_null_langmap)
526 196 : || o.get("json").is_some_and(is_ngsi_null)
527 196 : || o.get("vocab").is_some_and(is_ngsi_null)
528 196 : || o.get("valueList").is_some_and(is_ngsi_null_list)
529 196 : || o.get("objectList").is_some_and(is_ngsi_null_list)
530 208 : })
531 208 : }
532 :
533 : /// 4.18 Scope grammar: [/] ScopeLevel *(/ScopeLevel), ScopeLevel =
534 : /// unicodeLetter *(letter/digit/_) — shared by entity scopes and the 5.2.9
535 : /// registration scope member.
536 1062 : pub fn valid_scope_value(s: &str) -> bool {
537 1062 : let body = s.strip_prefix('/').unwrap_or(s);
538 1062 : !body.is_empty()
539 2004 : && body.split('/').all(|level| {
540 2004 : let mut ch = level.chars();
541 2004 : ch.next().is_some_and(char::is_alphabetic)
542 6700 : && ch.all(|c| c.is_alphabetic() || c.is_numeric() || c == '_')
543 2004 : })
544 1062 : }
545 :
546 : /// 4.6.2 Supported names: `name = unicodeLetter *(unicodeLetter /
547 : /// unicodeNumber / "_")`. A key containing ':' is a compact or absolute IRI
548 : /// (the spec's prefix:name production) and is outside the term grammar.
549 : // Known ceiling: colon-keys are exempt wholesale — a malformed "pre fix:x" slips
550 : // through as an IRI; tighten to per-part validation if it ever matters.
551 34258 : pub(crate) fn valid_name(s: &str) -> bool {
552 34258 : if s.contains(':') {
553 14 : return true;
554 34244 : }
555 34244 : let mut ch = s.chars();
556 34244 : ch.next().is_some_and(char::is_alphabetic)
557 134112 : && ch.all(|c| c.is_alphabetic() || c.is_numeric() || c == '_')
558 34258 : }
559 :
560 : /// Expand one attribute's value into a normalized instance list.
561 19804 : fn expand_attribute(
562 19804 : name: &str,
563 19804 : v: &Value,
564 19804 : ctx: &Context,
565 19804 : opts: ExpandOpts,
566 19804 : depth: usize,
567 19804 : ) -> Result<Vec<Value>, NgsiError> {
568 19804 : let bad = NgsiError::BadRequestData;
569 19804 : if depth > 8 {
570 0 : return Err(bad(format!("attribute {name}: nesting too deep")));
571 19804 : }
572 : // 4.6.2: Property/Relationship names with characters outside the name
573 : // grammar raise BadRequestData.
574 19804 : if !valid_name(name) {
575 12 : return Err(bad(format!(
576 12 : "attribute name {name:?} violates the 4.6.2 name grammar"
577 12 : )));
578 19792 : }
579 440 : match v {
580 440 : Value::Array(items) if items.iter().all(looks_like_instance) && !items.is_empty() => {
581 438 : let mut out = Vec::new();
582 32632 : for item in items {
583 32632 : out.push(expand_instance(name, item, ctx, opts, depth)?);
584 : }
585 434 : Ok(out)
586 : }
587 19354 : _ => Ok(vec![expand_instance(name, v, ctx, opts, depth)?]),
588 : }
589 19804 : }
590 :
591 32634 : fn looks_like_instance(v: &Value) -> bool {
592 32634 : v.as_object().is_some_and(|o| {
593 32632 : o.get("type")
594 32632 : .and_then(Value::as_str)
595 32632 : .is_some_and(|t| ATTR_TYPES.contains(&t))
596 0 : || [
597 0 : "value",
598 0 : "object",
599 0 : "languageMap",
600 0 : "vocab",
601 0 : "json",
602 0 : "valueList",
603 0 : "objectList",
604 0 : ]
605 0 : .iter()
606 0 : .any(|k| o.contains_key(*k))
607 32632 : })
608 32634 : }
609 :
610 : /// 4.5.2.2 Prohibited (mirrored by 4.5.3.2 and the 4.5.18-4.5.24
611 : /// subclasses): an instance "shall never include" the value-defining
612 : /// member of a DIFFERENT attribute type, nor the output-only members
613 : /// inline Linked Entity retrieval and showChanges notifications produce.
614 : /// `entityIdSealed`/`entityTypeSealed` are the one exception the clause
615 : /// grants, on the `ngsildproof` Property alone, and because they are
616 : /// reserved members this is also where they are copied out.
617 51812 : fn check_prohibited_members(
618 51812 : name: &str,
619 51812 : obj: &Map<String, Value>,
620 51812 : attr_type: &str,
621 51812 : ctx: &Context,
622 51812 : opts: ExpandOpts,
623 51812 : out: &mut Map<String, Value>,
624 51812 : ) -> Result<(), NgsiError> {
625 51812 : let bad = NgsiError::BadRequestData;
626 : const VALUE_OWNERS: &[(&str, &[&str])] = &[
627 : ("value", &["Property", "GeoProperty"]),
628 : ("object", &["Relationship"]),
629 : ("languageMap", &["LanguageProperty"]),
630 : ("json", &["JsonProperty"]),
631 : ("vocab", &["VocabProperty"]),
632 : ("valueList", &["ListProperty"]),
633 : ("objectList", &["ListRelationship"]),
634 : ];
635 362478 : for (m, owners) in VALUE_OWNERS {
636 362478 : if obj.contains_key(*m) && !owners.contains(&attr_type) {
637 46 : return Err(bad(format!(
638 46 : "attribute {name}: {m} is not allowed on a {attr_type} (4.5.2.2)"
639 46 : )));
640 362432 : }
641 : }
642 465832 : if let Some(m) = OUTPUT_ONLY.iter().find(|m| obj.contains_key(**m)) {
643 10 : return Err(bad(format!(
644 10 : "attribute {name}: {m} is output-only and not allowed in input (4.5.2.2)"
645 10 : )));
646 51756 : }
647 : // 4.5.2.2/4.5.2.3 grant the only exception there is: "unless the
648 : // PROPERTY name is ngsildproof", the member being defined as "a
649 : // Property ... with the non-reified subproperties". 4.5.3.2 and
650 : // 4.5.3.3 repeat the ban for a Relationship with no exception at
651 : // all, so the attribute name alone is not the test.
652 51756 : let sealed_ok = attr_type == "Property" && name == "ngsildproof";
653 51756 : if !sealed_ok && (obj.contains_key("entityIdSealed") || obj.contains_key("entityTypeSealed")) {
654 14 : return Err(bad(format!(
655 14 : "attribute {name}: entityIdSealed/entityTypeSealed are only allowed \
656 14 : on the ngsildproof Property (4.5.2.2, 4.5.3.2)"
657 14 : )));
658 51742 : }
659 : // 4.5.2.2 / C.11 / annex B: ngsildproof's NON-REIFIED sealed
660 : // subproperties — entityIdSealed is a plain string term,
661 : // entityTypeSealed is "@type": "@vocab" (it seals the entity type,
662 : // so its value expands like a type name). They are reserved
663 : // members, so without this explicit copy they silently vanish.
664 51742 : if sealed_ok {
665 12 : if let Some(v) = obj.get("entityIdSealed") {
666 8 : let s = v.as_str().ok_or_else(|| {
667 4 : bad(format!(
668 4 : "attribute {name}: entityIdSealed must be a string (4.5.2.2)"
669 4 : ))
670 4 : })?;
671 4 : out.insert("entityIdSealed".into(), Value::String(s.to_owned()));
672 4 : }
673 8 : if let Some(v) = obj.get("entityTypeSealed") {
674 6 : let s = v.as_str().ok_or_else(|| {
675 2 : bad(format!(
676 2 : "attribute {name}: entityTypeSealed must be a string (4.5.2.2)"
677 2 : ))
678 2 : })?;
679 4 : out.insert("entityTypeSealed".into(), Value::String(ctx.expand_key(s)));
680 2 : }
681 : // "The value of its \"value\" element shall be an object
682 : // containing the W3C Data integrity \"proof\" structure"
683 6 : if let Some(v) = obj.get("value") {
684 6 : if !v.is_object() && !(opts.allow_null && is_ngsi_null(v)) {
685 2 : return Err(bad(format!(
686 2 : "attribute {name}: ngsildproof value shall be an object \
687 2 : containing the W3C proof structure (4.5.2.2)"
688 2 : )));
689 4 : }
690 0 : }
691 51730 : }
692 : // 4.5.3.2: "unitCode shall never be present, as Relationships are
693 : // unitless." 4.5.18.2/3 and 4.5.20.2/3 extend the prohibition to
694 : // LanguageProperty and VocabProperty ("always strings and hence
695 : // unitless").
696 : // (4.5.24.2/3 add JsonProperty — "raw JSON objects are unitless".)
697 51734 : if obj.contains_key("unitCode")
698 16 : && matches!(
699 54 : attr_type,
700 54 : "Relationship"
701 50 : | "ListRelationship"
702 48 : | "LanguageProperty"
703 46 : | "VocabProperty"
704 42 : | "JsonProperty"
705 : )
706 : {
707 16 : return Err(bad(format!(
708 16 : "attribute {name}: unitCode is not allowed on a {attr_type}"
709 16 : )));
710 51718 : }
711 51718 : Ok(())
712 51812 : }
713 :
714 : /// The members Table 5.2.5-1 allows on any Attribute instance beside its
715 : /// value: the 4.5.5 `datasetId`, the 4.8 temporal members, the system
716 : /// attributes, and `unitCode`, `valueType`, `lang` and `objectType`,
717 : /// each expanded the way its own subclause defines.
718 51516 : fn expand_common_members(
719 51516 : name: &str,
720 51516 : obj: &Map<String, Value>,
721 51516 : attr_type: &str,
722 51516 : ctx: &Context,
723 51516 : opts: ExpandOpts,
724 51516 : out: &mut Map<String, Value>,
725 51516 : ) -> Result<(), NgsiError> {
726 51516 : let bad = NgsiError::BadRequestData;
727 51516 : if let Some(d) = obj.get("datasetId") {
728 32082 : if let Some(d) = dataset_id_member(d).map_err(|e| bad(format!("attribute {name}: {e}")))? {
729 32070 : out.insert("datasetId".into(), d);
730 32070 : }
731 19434 : }
732 51508 : if let Some(o) = obj.get("observedAt") {
733 1038 : let s = o
734 1038 : .as_str()
735 1038 : .filter(|s| parse_datetime(s))
736 1038 : .ok_or_else(|| bad(format!("attribute {name}: invalid observedAt")))?;
737 1012 : out.insert("observedAt".into(), Value::String(s.to_owned()));
738 50470 : }
739 51482 : if let Some(e) = obj.get("expiresAt") {
740 : // 4.22 transient attribute instances carry their own expiresAt.
741 62 : let s = e
742 62 : .as_str()
743 62 : .filter(|s| parse_datetime(s))
744 62 : .ok_or_else(|| bad(format!("attribute {name}: invalid expiresAt")))?;
745 54 : out.insert("expiresAt".into(), Value::String(s.to_owned()));
746 51420 : }
747 51474 : if opts.sys {
748 : // 4.8/4.5.7: deletedAt marks a deletion instance in a Temporal
749 : // Evolution — dropping it here would strip remote tombstones of the
750 : // timestamp their deletedAt-window matching needs (5.7.3.4 merge).
751 1578 : for k in ["createdAt", "modifiedAt", "deletedAt"] {
752 1578 : if let Some(Value::String(s)) = obj.get(k) {
753 832 : if parse_datetime(s) {
754 832 : out.insert(k.into(), Value::String(s.clone()));
755 832 : }
756 746 : }
757 : }
758 50948 : }
759 51474 : if let Some(u) = obj.get("unitCode") {
760 38 : if !u.is_string() {
761 2 : return Err(bad(format!("attribute {name}: unitCode must be a string")));
762 36 : }
763 36 : out.insert("unitCode".into(), u.clone());
764 51436 : }
765 51472 : if let Some(vt) = obj.get("valueType") {
766 : // 4.5.2.2: "valueType": a string value which shall be type coerced
767 : // into a datatype URI — the non-reified alternative to a native
768 : // JSON-LD @type on the Property value.
769 14 : let s = vt
770 14 : .as_str()
771 14 : .ok_or_else(|| bad(format!("attribute {name}: valueType must be a string")))?;
772 : // Table 5.2.32-1: on a LanguageProperty valueType "shall be equal
773 : // to langString" (the rdf:langString datatype) — kept literal.
774 14 : if attr_type == "LanguageProperty" {
775 12 : if s != "langString" {
776 6 : return Err(bad(format!(
777 6 : "attribute {name}: valueType shall be \"langString\" on a LanguageProperty"
778 6 : )));
779 6 : }
780 6 : out.insert("valueType".into(), vt.clone());
781 2 : } else {
782 2 : out.insert("valueType".into(), Value::String(ctx.expand_key(s)));
783 2 : }
784 51458 : }
785 51466 : if let Some(l) = obj.get("lang") {
786 : // 4.15: the language filter augments the converted Property with "an
787 : // additional non-reified subproperty lang indicating the actual
788 : // language returned" — a langtag. The member is broker-produced and
789 : // the clause says nothing about a client supplying one, so it is
790 : // kept; a non-string would leave the instance in a shape no reader
791 : // of 4.15 can interpret.
792 10 : let s = l
793 10 : .as_str()
794 10 : .ok_or_else(|| bad(format!("attribute {name}: lang must be a language tag")))?;
795 2 : out.insert("lang".into(), Value::String(s.to_owned()));
796 51456 : }
797 51458 : if let Some(ot) = obj.get("objectType") {
798 14 : out.insert(
799 14 : "objectType".into(),
800 14 : expand_terms(name, "objectType", ot, ctx)?,
801 : );
802 51444 : }
803 51452 : Ok(())
804 51516 : }
805 :
806 : /// Expand a single instance (normalized or concise) to normalized form.
807 : ///
808 : /// This is where the 4.2.2 Meta Model's own SHALLs are enforced: "An NGSI-LD
809 : /// Property shall have a value, stated through hasValue" and "An NGSI-LD
810 : /// Relationship shall have an object stated through hasObject" — a Property
811 : /// without `value` (and each specialized property type without its own
812 : /// value member, 5.2.5/5.2.32/5.2.35–5.2.38) or a Relationship without a
813 : /// URI `object` is rejected as BadRequestData. "An NGSI-LD Value shall be
814 : /// either a rdfs:Literal or a node object" — any JSON literal, array or
815 : /// object is accepted as `value`, a bare JSON `null` is not (4.5.2, the
816 : /// null sentinel is the string form only).
817 51986 : fn expand_instance(
818 51986 : name: &str,
819 51986 : v: &Value,
820 51986 : ctx: &Context,
821 51986 : opts: ExpandOpts,
822 51986 : depth: usize,
823 51986 : ) -> Result<Value, NgsiError> {
824 51986 : let bad = NgsiError::BadRequestData;
825 :
826 : // NGSI-LD null: attribute deletion marker (merge-patch only).
827 51986 : if is_ngsi_null(v) {
828 14 : if opts.allow_null {
829 14 : return Ok(json!({"type": "Property", "value": "urn:ngsi-ld:null"}));
830 0 : }
831 0 : return Err(bad(format!("attribute {name}: null is not allowed here")));
832 51972 : }
833 :
834 51972 : let obj = match v {
835 51898 : Value::Object(o) => o,
836 : // concise: primitive / array value ⇒ Property
837 74 : prim => {
838 74 : check_value_nulls(name, prim, opts)?;
839 72 : return Ok(json!({"type": "Property", "value": prim.clone()}));
840 : }
841 : };
842 :
843 51898 : let declared = obj.get("type").and_then(Value::as_str);
844 51812 : let attr_type: &str = match declared {
845 51816 : Some(t) if ATTR_TYPES.contains(&t) => t,
846 64 : Some(t) if GEO_TYPES.contains(&t) && obj.contains_key("coordinates") => {
847 : // concise GeoProperty: bare GeoJSON object as the value. 4.7.3
848 : // mandates `coordinates` "as defined by the relevant GeoJSON
849 : // Geometry", so the concise form is held to the same RFC 7946
850 : // restrictions as the verbose one.
851 62 : check_value_nulls(name, v, opts)?;
852 62 : validate_geojson(name, v)?;
853 28 : return Ok(json!({"type": "GeoProperty", "value": v.clone()}));
854 : }
855 2 : Some(t) => {
856 2 : return Err(bad(format!(
857 2 : "attribute {name}: invalid attribute type {t:?}"
858 2 : )))
859 : }
860 : None => {
861 : // concise object form — infer from members
862 82 : if obj.contains_key("object") {
863 14 : "Relationship"
864 68 : } else if obj.contains_key("languageMap") {
865 10 : "LanguageProperty"
866 58 : } else if obj.contains_key("vocab") {
867 4 : "VocabProperty"
868 54 : } else if obj.contains_key("json") {
869 4 : "JsonProperty"
870 50 : } else if obj.contains_key("valueList") {
871 4 : "ListProperty"
872 46 : } else if obj.contains_key("objectList") {
873 2 : "ListRelationship"
874 44 : } else if obj.contains_key("value") {
875 : // 4.5.2.3: type may be omitted — "Property can be inferred by
876 : // the presence of the value attribute. An exception to this
877 : // inference rule occurs for geospatial Property Values, where
878 : // the GeoProperty sub-type shall be inferred instead, if the
879 : // Property Value resolves to a supported GeoJSON geometry."
880 22 : let v = &obj["value"];
881 22 : if v.get("type")
882 22 : .and_then(Value::as_str)
883 22 : .is_some_and(|t| GEO_TYPES.contains(&t))
884 6 : && v.get("coordinates").is_some()
885 : {
886 6 : "GeoProperty"
887 : } else {
888 16 : "Property"
889 : }
890 : } else {
891 : // whole object is a Property value (4.5.2.3)
892 22 : check_value_nulls(name, v, opts)?;
893 20 : return Ok(json!({"type": "Property", "value": v.clone()}));
894 : }
895 : }
896 : };
897 :
898 51812 : let mut out = Map::new();
899 51812 : out.insert("type".into(), Value::String(attr_type.to_owned()));
900 :
901 51812 : check_prohibited_members(name, obj, attr_type, ctx, opts, &mut out)?;
902 :
903 : // required member per type
904 51718 : match attr_type {
905 51718 : "Property" => {
906 44202 : let val = obj
907 44202 : .get("value")
908 44202 : .ok_or_else(|| bad(format!("attribute {name}: Property needs value")))?;
909 44194 : if val.is_null() {
910 8 : return Err(bad(format!(
911 8 : "attribute {name}: JSON null is not a valid value (use \"urn:ngsi-ld:null\")"
912 8 : )));
913 44186 : }
914 44186 : out.insert("value".into(), val.clone());
915 : }
916 7516 : "GeoProperty" => {
917 282 : let val = obj
918 282 : .get("value")
919 282 : .ok_or_else(|| bad(format!("attribute {name}: GeoProperty needs value")))?;
920 : // 4.7.2: a whole geometry may arrive as an encoded JSON string,
921 : // accepted "if and only if" it parses into a valid geometry —
922 : // normalized here to the object form so storage, geo-queries and
923 : // responses all see one representation.
924 278 : let val = match val {
925 42 : Value::String(s) if !(opts.allow_null && is_ngsi_null(val)) => {
926 42 : serde_json::from_str::<Value>(s).map_err(|_| {
927 2 : bad(format!(
928 2 : "attribute {name}: string-encoded geometry is not valid JSON"
929 2 : ))
930 2 : })?
931 : }
932 238 : _ => val.clone(),
933 : };
934 278 : if !(opts.allow_null && is_ngsi_null(&val)) {
935 278 : validate_geojson(name, &val)?;
936 0 : }
937 188 : out.insert("value".into(), val);
938 : }
939 7234 : "Relationship" => {
940 6978 : let objv = obj
941 6978 : .get("object")
942 6978 : .ok_or_else(|| bad(format!("attribute {name}: Relationship needs object")))?;
943 4 : match objv {
944 6966 : Value::String(s) => {
945 6966 : if !(opts.allow_null && s == "urn:ngsi-ld:null") {
946 6960 : antares_model::EntityId::new(s)
947 6960 : .map_err(|_| bad(format!("attribute {name}: object must be a URI")))?;
948 6 : }
949 : }
950 4 : Value::Array(items) if !items.is_empty() => {
951 8 : for s in items {
952 8 : let s = s.as_str().ok_or_else(|| {
953 0 : bad(format!("attribute {name}: object entries must be URIs"))
954 0 : })?;
955 8 : antares_model::EntityId::new(s)
956 8 : .map_err(|_| bad(format!("attribute {name}: object must be a URI")))?;
957 : }
958 : }
959 0 : _ => return Err(bad(format!("attribute {name}: invalid object"))),
960 : }
961 6964 : out.insert("object".into(), objv.clone());
962 : }
963 256 : "LanguageProperty" => {
964 : // 4.5.18.2: "a JSON object consisting of a set of non-empty
965 : // language tags (RFC 5646) or the language tag "@none"", each
966 : // mapping to a single string or array of strings.
967 124 : let lm = obj
968 124 : .get("languageMap")
969 124 : .ok_or_else(|| bad(format!("attribute {name}: needs languageMap")))?;
970 : // 4.6.5: {"@none": "urn:ngsi-ld:null"} is exclusively the
971 : // partial/merge-patch deletion encoding — outside allow_null it
972 : // is an NGSI-LD Null in a create and thus BadRequestData.
973 112 : let ok = if is_ngsi_null_langmap(lm) {
974 12 : opts.allow_null
975 : } else {
976 100 : lm.as_object().is_some_and(|m| {
977 162 : m.keys().all(|k| !k.is_empty())
978 154 : && m.values().all(|v| {
979 154 : v.is_string()
980 30 : || v.as_array().is_some_and(|a| a.iter().all(Value::is_string))
981 154 : })
982 100 : })
983 : };
984 112 : if !ok {
985 30 : return Err(bad(format!("attribute {name}: invalid languageMap")));
986 82 : }
987 82 : out.insert("languageMap".into(), lm.clone());
988 : }
989 132 : "JsonProperty" => {
990 : // 4.5.24.2: json is "a raw JSON object (or array of objects)" —
991 : // never expanded or compacted; kept verbatim. The bare NGSI-LD
992 : // Null deletion form passes through under allow_null.
993 34 : let j = obj
994 34 : .get("json")
995 34 : .ok_or_else(|| bad(format!("attribute {name}: needs json")))?;
996 30 : let ok = j.is_object()
997 10 : || j.as_array().is_some_and(|a| a.iter().all(Value::is_object))
998 6 : || (opts.allow_null && is_ngsi_null(j));
999 30 : if !ok {
1000 6 : return Err(bad(format!(
1001 6 : "attribute {name}: json must be an object or array of objects"
1002 6 : )));
1003 24 : }
1004 24 : out.insert("json".into(), j.clone());
1005 : }
1006 98 : "VocabProperty" => {
1007 42 : let vv = obj
1008 42 : .get("vocab")
1009 42 : .ok_or_else(|| bad(format!("attribute {name}: needs vocab")))?;
1010 38 : out.insert("vocab".into(), expand_terms(name, "vocab", vv, ctx)?);
1011 : }
1012 56 : "ListProperty" => {
1013 26 : let l = obj
1014 26 : .get("valueList")
1015 26 : .ok_or_else(|| bad(format!("attribute {name}: needs valueList")))?;
1016 22 : if !l.is_array() && !(opts.allow_null && is_ngsi_null(l)) {
1017 2 : return Err(bad(format!("attribute {name}: valueList must be an array")));
1018 20 : }
1019 20 : out.insert("valueList".into(), l.clone());
1020 : }
1021 30 : "ListRelationship" => {
1022 : // 4.5.22.2/4.5.22.3: objectList is an ordered array of
1023 : // Relationship objects — {"object": <URI>} objects (normalized)
1024 : // or bare URI strings (concise). Internal form is bare URIs; the
1025 : // normalized output shape is restored at compaction. The [null]
1026 : // deletion form (single NGSI-LD Null) passes through under
1027 : // allow_null.
1028 30 : let l = obj
1029 30 : .get("objectList")
1030 30 : .ok_or_else(|| bad(format!("attribute {name}: needs objectList")))?;
1031 24 : let normalized = match l {
1032 26 : _ if opts.allow_null && is_ngsi_null_list(l) => l.clone(),
1033 22 : Value::Array(items) => {
1034 22 : let mut uris = Vec::with_capacity(items.len());
1035 38 : for it in items {
1036 38 : let uri = match it {
1037 30 : Value::String(s) => s.as_str(),
1038 8 : Value::Object(o)
1039 8 : if o.len() == 1
1040 8 : && o.get("object").is_some_and(Value::is_string) =>
1041 : {
1042 8 : o["object"].as_str().unwrap_or_default()
1043 : }
1044 : _ => {
1045 0 : return Err(bad(format!(
1046 0 : "attribute {name}: objectList entries must be URIs \
1047 0 : or {{\"object\": <URI>}} objects"
1048 0 : )))
1049 : }
1050 : };
1051 38 : antares_model::EntityId::new(uri).map_err(|_| {
1052 4 : bad(format!("attribute {name}: objectList entry is not a URI"))
1053 4 : })?;
1054 34 : uris.push(Value::String(uri.to_owned()));
1055 : }
1056 18 : Value::Array(uris)
1057 : }
1058 : _ => {
1059 2 : return Err(bad(format!(
1060 2 : "attribute {name}: objectList must be an array"
1061 2 : )))
1062 : }
1063 : };
1064 20 : out.insert("objectList".into(), normalized);
1065 : }
1066 : // 4.5.2 closes the set of Attribute types, and `attr_type` is
1067 : // either a member of ATTR_TYPES or one of the inferred literals
1068 : // above — so every reachable value has an arm. A member added to
1069 : // that list without an arm here would arrive as a client-supplied
1070 : // `"type"`, which is a request to answer, not a reason to panic:
1071 : // pinned by
1072 : // `every_declarable_attribute_type_is_dispatched_not_unreachable`.
1073 : _ => {
1074 0 : return Err(NgsiError::InternalError(format!(
1075 0 : "attribute type {attr_type} has no expansion arm"
1076 0 : )))
1077 : }
1078 : }
1079 :
1080 : // optional standard members
1081 51516 : expand_common_members(name, obj, attr_type, ctx, opts, &mut out)?;
1082 :
1083 : // sub-attributes
1084 137010 : for (k, sub) in obj {
1085 137010 : if k == "@context" {
1086 : // 4.5.1/5.5.7: "Attributes shall not contain any embedded
1087 : // @context" — a nested user context could override core terms,
1088 : // so it "should result in an error of type BadRequestData".
1089 4 : return Err(bad(format!(
1090 4 : "attribute {name}: embedded @context is not allowed (4.5.1/5.5.7)"
1091 4 : )));
1092 137006 : }
1093 137006 : if RESERVED_MEMBERS.contains(&k.as_str()) {
1094 136922 : continue;
1095 84 : }
1096 84 : if k.is_empty() {
1097 0 : return Err(bad(format!("attribute {name}: empty sub-attribute name")));
1098 84 : }
1099 84 : let iri = expand_attr_name(k, ctx)?;
1100 82 : let instances = expand_attribute(k, sub, ctx, opts, depth + 1)?;
1101 : // 4.5.5.1 again, one level down: two sub-attribute names expanding to
1102 : // one IRI would drop whichever the map orders first.
1103 72 : if out.insert(iri.clone(), Value::Array(instances)).is_some() {
1104 2 : return Err(bad(format!(
1105 2 : "attribute {name}: sub-attribute {k} expands to {iri}, which \
1106 2 : another member already defines (4.5.5.1)"
1107 2 : )));
1108 70 : }
1109 : }
1110 :
1111 : // 5.2.1: "In all other cases, implementations shall raise an error of
1112 : // type BadRequestData if an NGSI-LD Null value is encountered" — the
1113 : // deletion marker is only meaningful on partial-update/merge inputs
1114 : // (allow_null). `json` is exempt: raw JSON is never interpreted.
1115 51434 : if !opts.allow_null {
1116 50636 : let nullish = |v: &Value| match v {
1117 9490 : Value::String(s) => s == "urn:ngsi-ld:null",
1118 92 : Value::Array(a) => a.iter().any(|x| x.as_str() == Some("urn:ngsi-ld:null")),
1119 41096 : _ => false,
1120 50634 : };
1121 253084 : for k in ["value", "object", "vocab", "valueList", "objectList"] {
1122 253084 : if out.get(k).is_some_and(&nullish) {
1123 26 : return Err(bad(format!(
1124 26 : "attribute {name}: the NGSI-LD Null is only allowed in \
1125 26 : partial update or merge inputs (5.2.1)"
1126 26 : )));
1127 253058 : }
1128 : }
1129 50610 : if out
1130 50610 : .get("languageMap")
1131 50610 : .and_then(Value::as_object)
1132 50610 : .is_some_and(|m| m.values().any(nullish))
1133 : {
1134 0 : return Err(bad(format!(
1135 0 : "attribute {name}: the NGSI-LD Null is only allowed in \
1136 0 : partial update or merge inputs (5.2.1)"
1137 0 : )));
1138 50610 : }
1139 798 : }
1140 :
1141 : // 5.5.4: "urn:ngsi-ld:null" as the value of a key-value pair within a
1142 : // JSON object that is the Property's value is BadRequestData — excepted
1143 : // solely for merge fragments (5.5.12). `json` stays exempt (raw JSON).
1144 51408 : if !opts.merge && out.get("value").is_some_and(has_object_member_null) {
1145 10 : return Err(bad(format!(
1146 10 : "attribute {name}: \"urn:ngsi-ld:null\" inside a compound value \
1147 10 : is only allowed in merge fragments (5.5.4)"
1148 10 : )));
1149 51398 : }
1150 :
1151 51398 : Ok(Value::Object(out))
1152 51986 : }
1153 :
1154 : /// Expand a PARTIAL-UPDATE attribute fragment (5.6.4): reserved members are
1155 : /// kept, others become sub-attributes — and crucially NO attribute-type
1156 : /// inference happens (a fragment `{providedBy: …}` patches the sub-attribute,
1157 : /// it is not a concise Property value).
1158 108 : pub fn expand_attr_fragment(obj: &Map<String, Value>, ctx: &Context) -> Result<Value, NgsiError> {
1159 108 : let bad = NgsiError::BadRequestData;
1160 108 : let mut out = Map::new();
1161 236 : for (k, v) in obj {
1162 236 : match k.as_str() {
1163 : // 5.2.5 Table 5.2.5-2 / 4.5.2.2 System Generated: output-only
1164 : // members "shall not be provided by Context Producers. In the
1165 : // event that they are provided (in update or create operations)
1166 : // NGSI-LD implementations shall ignore them." The sealed
1167 : // subproperties are Prohibited outside a full ngsildproof
1168 : // instance, which this path cannot identify.
1169 236 : "@context" | "createdAt" | "modifiedAt" | "deletedAt" | "instanceId"
1170 218 : | "entityIdSealed" | "entityTypeSealed" => continue,
1171 214 : _ if OUTPUT_ONLY.contains(&k.as_str()) => continue,
1172 196 : "type" => {
1173 58 : let t = v
1174 58 : .as_str()
1175 58 : .filter(|t| ATTR_TYPES.contains(t))
1176 58 : .ok_or_else(|| bad("invalid attribute type in fragment".into()))?;
1177 58 : out.insert("type".into(), Value::String(t.to_owned()));
1178 : }
1179 : // 4.6.3: both members are ISO 8601 DateTimes (4.8 observedAt,
1180 : // 4.22 expiresAt) — the same check the full-instance path runs.
1181 138 : "observedAt" | "expiresAt" => {
1182 14 : let sdt = v
1183 14 : .as_str()
1184 14 : .filter(|s| parse_datetime(s))
1185 14 : .ok_or_else(|| bad(format!("invalid {k} in fragment")))?;
1186 12 : out.insert(k.clone(), Value::String(sdt.to_owned()));
1187 : }
1188 124 : "value" => {
1189 82 : if v.is_null() {
1190 0 : return Err(bad("JSON null is not a valid value".into()));
1191 82 : }
1192 : // 5.5.4: a null inside a compound value is legal in merge
1193 : // fragments only — a partial update (5.5.8) is not one.
1194 82 : if has_object_member_null(v) {
1195 0 : return Err(bad("\"urn:ngsi-ld:null\" inside a compound value is only \
1196 0 : allowed in merge fragments (5.5.4)"
1197 0 : .into()));
1198 82 : }
1199 82 : out.insert("value".into(), v.clone());
1200 : }
1201 : // 4.5.5.1/5.5.8: the fragment's datasetId selects the instance to
1202 : // patch and is copied onto it — it obeys the same URI-string rule
1203 : // as a full instance, or the patched instance stops answering to
1204 : // the datasetId lookups that keep one default instance per name.
1205 42 : "datasetId" => {
1206 18 : if let Some(d) = dataset_id_member(v)? {
1207 2 : out.insert("datasetId".into(), d);
1208 2 : }
1209 : }
1210 24 : _ if RESERVED_MEMBERS.contains(&k.as_str()) => {
1211 4 : out.insert(k.clone(), v.clone());
1212 4 : }
1213 : _ => {
1214 20 : let iri = expand_attr_name(k, ctx)?;
1215 20 : let instances = expand_attribute(
1216 20 : k,
1217 20 : v,
1218 20 : ctx,
1219 20 : ExpandOpts {
1220 20 : fragment: true,
1221 20 : allow_null: true,
1222 20 : ..Default::default()
1223 20 : },
1224 : 1,
1225 0 : )?;
1226 : // 4.5.5.1: one Attribute name = one member of the fragment.
1227 20 : if out.insert(iri.clone(), Value::Array(instances)).is_some() {
1228 0 : return Err(bad(format!(
1229 0 : "sub-attribute {k} expands to {iri}, which another \
1230 0 : member of this fragment already defines (4.5.5.1)"
1231 0 : )));
1232 20 : }
1233 : }
1234 : }
1235 : }
1236 90 : Ok(Value::Object(out))
1237 108 : }
1238 :
1239 : /// 4.5.5.1: "There can only be one default Attribute instance for an
1240 : /// Attribute with a given Attribute name in any request or response";
1241 : /// datasetIds must be distinct per attribute (explicit "@none" is normalized
1242 : /// to absent before this check, so absent + "@none" counts as two defaults).
1243 18784 : fn validate_dataset_ids(name: &str, instances: &[Value]) -> Result<(), NgsiError> {
1244 : // A set, not a scanned list: the instance count is bounded only by the
1245 : // request body, so a linear scan per instance makes the check quadratic
1246 : // in what a client sends.
1247 18784 : let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
1248 18784 : let mut default_count = 0usize;
1249 38804 : for inst in instances {
1250 38804 : match inst.get("datasetId").and_then(Value::as_str) {
1251 20046 : Some(d) => {
1252 20046 : if !seen.insert(d) {
1253 6 : return Err(NgsiError::BadRequestData(format!(
1254 6 : "attribute {name}: duplicate datasetId {d}"
1255 6 : )));
1256 20040 : }
1257 : }
1258 18758 : None => default_count += 1,
1259 : }
1260 : }
1261 18778 : if default_count > 1 {
1262 2 : return Err(NgsiError::BadRequestData(format!(
1263 2 : "attribute {name}: more than one instance without datasetId"
1264 2 : )));
1265 18776 : }
1266 18776 : Ok(())
1267 18784 : }
1268 :
1269 : /// RFC 7946 3.1.1: "A position is an array of numbers. There MUST be two or
1270 : /// more elements. The first two elements are longitude and latitude \[…\]
1271 : /// using decimal numbers", read in the coordinate reference system the
1272 : /// format fixes: "a geographic coordinate reference system, using the World
1273 : /// Geodetic System 1984 \[…\] datum, with longitude and latitude units of
1274 : /// decimal degrees" (RFC 7946 4). 4.7.2 adds that the coordinates are "values
1275 : /// of a JSON-LD floating point number data type".
1276 : ///
1277 : /// The range is not decoration: a latitude of 999 reaches PostGIS as a
1278 : /// `::geography` cast that errors, so a single accepted write would break
1279 : /// every later `near` query in that tenant.
1280 10278 : fn check_position(p: &Value) -> Result<(), String> {
1281 10278 : let a = p.as_array().ok_or("position is not an array")?;
1282 10252 : if a.len() < 2 {
1283 28 : return Err(format!("position has {} elements (minimum 2)", a.len()));
1284 10224 : }
1285 20454 : let mut n = a.iter().map(|c| c.as_f64().filter(|f| f.is_finite()));
1286 10224 : let (Some(Some(lon)), Some(Some(lat))) = (n.next(), n.next()) else {
1287 14 : return Err("position holds a value that is not a number".into());
1288 : };
1289 10210 : if n.any(|c| c.is_none()) {
1290 0 : return Err("position holds a value that is not a number".into());
1291 10210 : }
1292 10210 : if !(-180.0..=180.0).contains(&lon) || !(-90.0..=90.0).contains(&lat) {
1293 34 : return Err(format!(
1294 34 : "position [{lon}, {lat}] is outside the WGS84 range [-180 -90, 180 90]"
1295 34 : ));
1296 10176 : }
1297 10176 : Ok(())
1298 10278 : }
1299 :
1300 : /// RFC 7946 3.1.4: a LineString is "two or more positions".
1301 60 : fn check_line(v: &Value) -> Result<(), String> {
1302 60 : let a = v.as_array().ok_or("LineString is not an array")?;
1303 60 : if a.len() < 2 {
1304 12 : return Err(format!("LineString has {} positions (minimum 2)", a.len()));
1305 48 : }
1306 48 : a.iter().try_for_each(check_position)
1307 60 : }
1308 :
1309 : /// RFC 7946 3.1.6: a linear ring is "closed \[…\] with four or more positions",
1310 : /// "the first and last positions \[…\] equivalent".
1311 576 : fn check_ring(v: &Value) -> Result<(), String> {
1312 576 : let a = v.as_array().ok_or("linear ring is not an array")?;
1313 576 : if a.len() < 4 {
1314 38 : return Err(format!("linear ring has {} positions (minimum 4)", a.len()));
1315 538 : }
1316 538 : if a.first() != a.last() {
1317 2 : return Err("linear ring is not closed (first != last position)".into());
1318 536 : }
1319 536 : a.iter().try_for_each(check_position)
1320 576 : }
1321 :
1322 686 : fn each(v: &Value, what: &str, f: impl FnMut(&Value) -> Result<(), String>) -> Result<(), String> {
1323 686 : v.as_array()
1324 686 : .ok_or_else(|| format!("{what} is not an array"))?
1325 686 : .iter()
1326 686 : .try_for_each(f)
1327 686 : }
1328 :
1329 : /// The nesting RFC 7946 3.1 gives each geometry type its `coordinates`.
1330 : /// Empty multi-geometries are geometries: only the shapes the RFC names a
1331 : /// minimum for carry one.
1332 1044 : pub fn check_geometry(gtype: &str, coords: &Value) -> Result<(), String> {
1333 1044 : match gtype {
1334 1044 : "Point" => check_position(coords),
1335 318 : "MultiPoint" => each(coords, "MultiPoint coordinates", check_position),
1336 280 : "LineString" => check_line(coords),
1337 224 : "MultiLineString" => each(coords, "MultiLineString coordinates", check_line),
1338 206 : "Polygon" => each(coords, "Polygon coordinates", check_ring),
1339 426 : "MultiPolygon" => each(coords, "MultiPolygon coordinates", |p| {
1340 426 : each(p, "MultiPolygon polygon", check_ring)
1341 426 : }),
1342 2 : _ => Err(format!("{gtype} is not a supported GeoJSON geometry type")),
1343 : }
1344 1044 : }
1345 :
1346 : /// 4.6.3: supported Value geometries are "All the GeoJSON Geometries \[8\]
1347 : /// with the exception of GeometryCollection" — GEO_TYPES holds exactly that
1348 : /// set. 4.7.2 accepts a geometry "if and only if \[…\] meeting the syntax and
1349 : /// restrictions mandated by IETF RFC 7946 \[8\] when representing a valid
1350 : /// Geometry of the type specified", so the shape of `coordinates` is checked
1351 : /// against the declared type, not merely for being an array: a geometry that
1352 : /// is not one must not reach storage, the 4.5.16 GeoJSON rendering path or a
1353 : /// PostGIS cast.
1354 370 : pub fn validate_geojson(name: &str, v: &Value) -> Result<(), NgsiError> {
1355 370 : let bad = |m: &str| NgsiError::BadRequestData(format!("attribute {name}: {m}"));
1356 370 : let shape = v.as_object().and_then(|o| {
1357 364 : let t = o.get("type").and_then(Value::as_str)?;
1358 362 : GEO_TYPES.contains(&t).then_some((t, o.get("coordinates")?))
1359 364 : });
1360 370 : let Some((gtype, coords)) = shape else {
1361 18 : return Err(bad("value is not a valid GeoJSON geometry"));
1362 : };
1363 352 : check_geometry(gtype, coords).map_err(|e| bad(&e))
1364 370 : }
1365 :
1366 : /// ISO 8601 DateTime check — 4.6.3, `YYYY-MM-DDThh:mm:ss[.ffffff]Z`.
1367 : ///
1368 : /// The clause is strict in three ways this used to get wrong:
1369 : /// - "The trailing timestamp component … shall always be equal to the
1370 : /// character `Z`. Therefore, all timestamps shall be expressed in UTC" —
1371 : /// so `+HH:MM`/`-HH:MM` offsets are INVALID, not an alternative form.
1372 : /// - "All the referred components shall appear in the string; reduced
1373 : /// representations are not permitted" — a bare 19-char form has no zone.
1374 : /// - "The Seconds component may optionally contain a decimal fraction …
1375 : /// up to a maximum of six \[digits\]. … In requests, also a comma instead of a
1376 : /// decimal point may be used as separator for compatibility reasons."
1377 : ///
1378 : /// Digit-shape alone is not enough: `2026-13-45T00:00:00Z` is all digits in
1379 : /// the right places, and letting it through let one write make every later
1380 : /// temporal query in that tenant fail on the `::timestamptz` cast.
1381 5264 : pub fn parse_datetime(s: &str) -> bool {
1382 5264 : let b = s.as_bytes();
1383 : // shortest legal form is 19 chars + the mandatory Z
1384 5264 : if b.len() < 20 || b.last() != Some(&b'Z') {
1385 274 : return false;
1386 4990 : }
1387 29924 : let digits = |r: std::ops::Range<usize>| b[r].iter().all(u8::is_ascii_digit);
1388 4990 : let shape = digits(0..4)
1389 4988 : && b[4] == b'-'
1390 4988 : && digits(5..7)
1391 4988 : && b[7] == b'-'
1392 4988 : && digits(8..10)
1393 4988 : && b[10] == b'T'
1394 4986 : && digits(11..13)
1395 4986 : && b[13] == b':'
1396 4986 : && digits(14..16)
1397 4986 : && b[16] == b':'
1398 4986 : && digits(17..19);
1399 4990 : if !shape {
1400 4 : return false;
1401 4986 : }
1402 : // between second 19 and the trailing Z: nothing, or a fraction of 1..=6
1403 4986 : let frac = &s[19..s.len() - 1];
1404 4986 : if !frac.is_empty() {
1405 1774 : let Some(rest) = frac.strip_prefix('.').or_else(|| frac.strip_prefix(',')) else {
1406 2 : return false;
1407 : };
1408 5306 : if rest.is_empty() || rest.len() > 6 || !rest.bytes().all(|c| c.is_ascii_digit()) {
1409 6 : return false;
1410 1766 : }
1411 3212 : }
1412 : // real calendar date/time, not just digits in the right slots
1413 4978 : let normalized = format!("{}Z", &s[..19]);
1414 4978 : chrono::DateTime::parse_from_rfc3339(&normalized).is_ok()
1415 5264 : }
1416 :
1417 : #[cfg(test)]
1418 : mod tests {
1419 : use super::*;
1420 : use crate::loader::Loader;
1421 : use serde_json::json;
1422 :
1423 : /// 4.5.5.1: explicit "datasetId": "@none" designates the default
1424 : /// instance — normalized to absent, so it never appears in responses and
1425 : /// absent + "@none" in one request is two default instances (rejected).
1426 : #[test]
1427 2 : fn dataset_id_none_is_the_default_instance() {
1428 2 : let doc = json!({"id": "urn:x", "type": "T",
1429 2 : "speed": {"type": "Property", "value": 1, "datasetId": "@none"}});
1430 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1431 2 : .expect("@none accepted");
1432 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
1433 2 : assert!(inst.get("datasetId").is_none(), "@none must be dropped");
1434 : // absent + "@none" = two defaults → BadRequestData
1435 2 : let doc = json!({"id": "urn:x", "type": "T",
1436 2 : "speed": [{"type": "Property", "value": 1},
1437 2 : {"type": "Property", "value": 2, "datasetId": "@none"}]});
1438 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1439 2 : }
1440 :
1441 : /// 4.5.5.1: "there cannot be several Attribute instances with the same
1442 : /// datasetId" — two instances naming one datasetId are BadRequestData
1443 : /// wherever the repeat sits, and the instance count of one Attribute is
1444 : /// bounded by the request body alone, so the check may not scan the
1445 : /// instances it has already seen for each new one.
1446 : #[test]
1447 2 : fn clause_4_5_5_1_a_repeated_dataset_id_is_refused_at_any_position() {
1448 8006 : let inst = |i: usize| {
1449 8006 : json!({"type": "Property", "value": i,
1450 8006 : "datasetId": format!("urn:ngsi-ld:Dataset:{i:05}")})
1451 8006 : };
1452 : const N: usize = 4000;
1453 2 : let distinct: Vec<Value> = (0..N).map(inst).collect();
1454 2 : let doc = json!({"id": "urn:ngsi-ld:X:1", "type": "T", "speed": distinct.clone()});
1455 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1456 2 : .expect("distinct datasetIds are legal however many there are");
1457 2 : assert_eq!(
1458 2 : out["https://uri.etsi.org/ngsi-ld/default-context/speed"]
1459 2 : .as_array()
1460 2 : .expect("array")
1461 2 : .len(),
1462 : N
1463 : );
1464 : // the repeat at the front, in the middle and at the end: a check that
1465 : // stops early, or one that only compares neighbours, misses two of them
1466 6 : for at in [1usize, N / 2, N - 1] {
1467 6 : let mut insts = distinct.clone();
1468 6 : insts[at] = inst(0);
1469 6 : let doc = json!({"id": "urn:ngsi-ld:X:1", "type": "T", "speed": insts});
1470 6 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
1471 6 : let Err(NgsiError::BadRequestData(msg)) = out else {
1472 0 : panic!("a repeat at {at} must be BadRequestData: {out:?}");
1473 : };
1474 6 : assert!(msg.contains("duplicate datasetId"), "{msg}");
1475 : }
1476 2 : }
1477 :
1478 : /// 4.5.2.2 / C.11: "ngsildproof": a Property with the non-reified
1479 : /// subproperties "entityIdSealed" and "entityTypeSealed" as specified
1480 : /// in [35]; annex B maps entityIdSealed as a plain term and
1481 : /// entityTypeSealed with "@type": "@vocab" (the value expands like a
1482 : /// type name). Both must survive the expand→compact round trip — they
1483 : /// were RESERVED_MEMBERS with no explicit copy and silently vanished.
1484 : #[test]
1485 2 : fn ngsildproof_sealed_members_round_trip() {
1486 2 : let doc = json!({"id": "urn:ngsi-ld:Store:002", "type": "Store",
1487 2 : "ngsildproof": {"type": "Property",
1488 2 : "entityIdSealed": "urn:ngsi-ld:Store:002",
1489 2 : "entityTypeSealed": "Store",
1490 2 : "value": {"type": "DataIntegrityProof",
1491 2 : "cryptosuite": "eddsa-rdfc-2022",
1492 2 : "created": "2025-01-27T21:02:24Z",
1493 2 : "proofPurpose": "assertionMethod",
1494 2 : "proofValue": "zQeVbY4oey5q2M3XKaxup3tmzN4DRFTLVqpLMweBrSxMY"}}});
1495 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1496 2 : .expect("C.11-shaped ngsildproof is valid");
1497 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/ngsildproof"][0];
1498 2 : assert_eq!(inst["entityIdSealed"], "urn:ngsi-ld:Store:002");
1499 2 : assert_eq!(
1500 2 : inst["entityTypeSealed"], "https://uri.etsi.org/ngsi-ld/default-context/Store",
1501 : "entityTypeSealed is @vocab-coerced (annex B)"
1502 : );
1503 :
1504 2 : let back = crate::compact::compact_entity(&out, &core());
1505 2 : let np = &back["ngsildproof"];
1506 2 : assert_eq!(np["entityIdSealed"], "urn:ngsi-ld:Store:002");
1507 2 : assert_eq!(np["entityTypeSealed"], "Store", "compacts back to the term");
1508 2 : assert_eq!(
1509 2 : np["value"]["proofValue"], "zQeVbY4oey5q2M3XKaxup3tmzN4DRFTLVqpLMweBrSxMY",
1510 : "the W3C proof structure is untouched"
1511 : );
1512 : // negative: non-reified means BARE strings — never Property objects,
1513 : // and never dropped
1514 2 : assert!(np["entityIdSealed"].is_string());
1515 2 : assert!(np["entityTypeSealed"].is_string());
1516 2 : }
1517 :
1518 : /// 4.5.2.2: the sealed members are strings ([35] seals the entity id and
1519 : /// type); "The value of its \"value\" element shall be an object
1520 : /// containing the W3C Data integrity \"proof\" structure" — a
1521 : /// non-object proof value is BadRequestData.
1522 : #[test]
1523 2 : fn ngsildproof_shapes_are_validated() {
1524 : // non-string sealed members
1525 4 : for bad_seal in [json!(42), json!({"type": "Property", "value": true})] {
1526 4 : let doc = json!({"id": "urn:x", "type": "Store",
1527 4 : "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
1528 4 : "entityIdSealed": bad_seal}});
1529 4 : assert!(
1530 4 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1531 : "entityIdSealed must be a string"
1532 : );
1533 : }
1534 2 : let doc = json!({"id": "urn:x", "type": "Store",
1535 2 : "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
1536 2 : "entityTypeSealed": ["Store"]}});
1537 2 : assert!(
1538 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1539 : "entityTypeSealed must be a string"
1540 : );
1541 : // the proof value shall be an object
1542 2 : let doc = json!({"id": "urn:x", "type": "Store",
1543 2 : "ngsildproof": {"type": "Property", "value": "not-a-proof"}});
1544 2 : assert!(
1545 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1546 : "ngsildproof value shall be an object (4.5.2.2)"
1547 : );
1548 : // sealed members on an ordinary attribute stay rejected (the
1549 : // existing 4.5.2.2 guard — pinned here as the negative pair)
1550 2 : let doc = json!({"id": "urn:x", "type": "Store",
1551 2 : "speed": {"type": "Property", "value": 1, "entityIdSealed": "urn:x"}});
1552 2 : assert!(
1553 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1554 : "sealed members only under ngsildproof"
1555 : );
1556 2 : }
1557 :
1558 : /// 4.15: the language filter augments the converted Property with "a
1559 : /// non-reified subproperty lang indicating the actual language
1560 : /// returned" — a langtag string (RFC 5646). The member is broker-
1561 : /// produced, and the clause is silent on a client supplying one, so it
1562 : /// is stored; it is not stored in a shape no consumer can read. Every
1563 : /// other non-reified member of an instance (unitCode, valueType,
1564 : /// datasetId, observedAt) is checked here, and lang was copied through
1565 : /// whatever its JSON type.
1566 : #[test]
1567 2 : fn clause_4_15_a_supplied_lang_member_is_a_string() {
1568 8 : for bad_lang in [json!({"en": "x"}), json!(["fr"]), json!(7), json!(true)] {
1569 8 : let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle",
1570 8 : "street": {"type": "Property", "value": "Grand Place", "lang": bad_lang}});
1571 8 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
1572 8 : assert!(out.is_err(), "lang must be a langtag string: {out:?}");
1573 : }
1574 2 : let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle",
1575 2 : "street": {"type": "Property", "value": "Grand Place", "lang": "fr"}});
1576 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1577 2 : .expect("a langtag string is kept");
1578 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/street"][0];
1579 2 : assert_eq!(inst["lang"], "fr");
1580 : // and it stays a member of the instance, never a reified
1581 : // sub-attribute of its own
1582 2 : assert!(
1583 2 : out.get("https://uri.etsi.org/ngsi-ld/default-context/lang")
1584 2 : .is_none(),
1585 : "lang is non-reified: {out}"
1586 : );
1587 2 : }
1588 :
1589 : /// 4.5.3.2 Prohibited: on a Relationship "entityIdSealed" and
1590 : /// "entityTypeSealed" shall never be present — flat, with no exception.
1591 : /// 4.5.2.2 and 4.5.2.3 write the only exception there is as "unless the
1592 : /// PROPERTY name is ngsildproof", and the member itself is defined as
1593 : /// "a Property ... with the non-reified subproperties". The attribute
1594 : /// name alone is therefore not the test: an attribute called
1595 : /// ngsildproof that is not a Property seals nothing.
1596 : #[test]
1597 2 : fn clause_4_5_3_2_sealed_members_are_carried_by_a_property_only() {
1598 8 : for not_a_property in [
1599 2 : json!({"type": "Relationship", "object": "urn:ngsi-ld:Store:1",
1600 2 : "entityIdSealed": "urn:ngsi-ld:Store:1"}),
1601 2 : json!({"type": "Relationship", "object": "urn:ngsi-ld:Store:1",
1602 2 : "entityTypeSealed": "Store"}),
1603 2 : json!({"type": "LanguageProperty", "languageMap": {"en": "x"},
1604 2 : "entityIdSealed": "urn:ngsi-ld:Store:1"}),
1605 2 : json!({"type": "ListRelationship", "objectList": ["urn:ngsi-ld:Store:1"],
1606 2 : "entityTypeSealed": "Store"}),
1607 2 : ] {
1608 8 : let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
1609 8 : "ngsildproof": not_a_property});
1610 8 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
1611 8 : assert!(
1612 8 : out.is_err(),
1613 : "a non-Property ngsildproof carries no sealed member: {out:?}"
1614 : );
1615 : }
1616 : // the concise form infers the attribute type, and the inference
1617 : // decides the same way: `object` makes this a Relationship
1618 2 : let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
1619 2 : "ngsildproof": {"object": "urn:ngsi-ld:Store:1",
1620 2 : "entityIdSealed": "urn:ngsi-ld:Store:1"}});
1621 2 : assert!(
1622 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1623 : "the concise Relationship form is a Relationship (4.5.3.3)"
1624 : );
1625 : // and the Property form the clause does allow still round-trips
1626 2 : let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
1627 2 : "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
1628 2 : "entityIdSealed": "urn:ngsi-ld:Store:1",
1629 2 : "entityTypeSealed": "Store"}});
1630 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1631 2 : .expect("the ngsildproof Property is the one carrier");
1632 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/ngsildproof"][0];
1633 2 : assert_eq!(inst["entityIdSealed"], "urn:ngsi-ld:Store:1");
1634 2 : }
1635 :
1636 : /// 4.5.3.3: "type: If missing, Relationship can be inferred by the
1637 : /// presence of the object attribute" — and the shared prohibitions apply
1638 : /// to the inferred instance too.
1639 : #[test]
1640 2 : fn concise_relationship_inference() {
1641 2 : let doc = json!({"id": "urn:x", "type": "T",
1642 2 : "isParked": {"object": "urn:ngsi-ld:P:1",
1643 2 : "observedAt": "2026-01-01T00:00:00Z"}});
1644 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1645 2 : .expect("concise relationship");
1646 2 : let rel = &out["https://uri.etsi.org/ngsi-ld/default-context/isParked"][0];
1647 2 : assert_eq!(rel["type"], "Relationship");
1648 2 : assert_eq!(rel["object"], "urn:ngsi-ld:P:1");
1649 : // inferred Relationship still rejects a Property value member
1650 2 : let doc = json!({"id": "urn:x", "type": "T",
1651 2 : "isParked": {"object": "urn:ngsi-ld:P:1", "value": 1}});
1652 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1653 2 : }
1654 :
1655 : /// 4.5.3.2: a normalized Relationship "shall never include" unitCode
1656 : /// ("Relationships are unitless") or the value-defining members of the
1657 : /// Property family — while objectType stays a legal optional member.
1658 : #[test]
1659 2 : fn relationship_prohibited_members_rejected() {
1660 12 : let mk = |extra: (&str, Value)| {
1661 12 : json!({
1662 12 : "id": "urn:x", "type": "T",
1663 12 : "isParked": {"type": "Relationship", "object": "urn:ngsi-ld:P:1",
1664 12 : extra.0: extra.1}
1665 : })
1666 12 : };
1667 10 : for (m, v) in [
1668 2 : ("unitCode", json!("MTR")),
1669 2 : ("value", json!(1)),
1670 2 : ("languageMap", json!({"en": "x"})),
1671 2 : ("valueList", json!([1])),
1672 2 : ("previousObject", json!("urn:a")),
1673 2 : ] {
1674 10 : let doc = mk((m, v));
1675 10 : assert!(
1676 10 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1677 : "{m} must be prohibited on a Relationship"
1678 : );
1679 : }
1680 2 : let ok = mk(("objectType", json!("Parking")));
1681 2 : assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
1682 2 : }
1683 :
1684 : /// 4.5.2.3: concise Property forms — a geometry-shaped value infers
1685 : /// GeoProperty (both as the whole object and as the value member); an
1686 : /// object carrying a "type" member is treated as normalized; a concise
1687 : /// object mixing value with another type's defining member rejects.
1688 : #[test]
1689 2 : fn concise_property_inference_rules() {
1690 2 : let geo = json!({"type": "Point", "coordinates": [1.0, 2.0]});
1691 : // whole object IS the geometry
1692 2 : let doc = json!({"id": "urn:x", "type": "T", "area": geo});
1693 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1694 2 : .expect("bare geometry");
1695 2 : assert_eq!(
1696 2 : out["https://uri.etsi.org/ngsi-ld/default-context/area"][0]["type"],
1697 : "GeoProperty"
1698 : );
1699 : // geometry as the value member of a type-less object
1700 2 : let doc = json!({"id": "urn:x", "type": "T",
1701 2 : "area": {"value": {"type": "Point", "coordinates": [1.0, 2.0]},
1702 2 : "observedAt": "2026-01-01T00:00:00Z"}});
1703 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1704 2 : .expect("geometry value");
1705 2 : assert_eq!(
1706 2 : out["https://uri.etsi.org/ngsi-ld/default-context/area"][0]["type"],
1707 : "GeoProperty"
1708 : );
1709 : // an object with a "type" member is normalized — unknown type rejects
1710 2 : let doc = json!({"id": "urn:x", "type": "T", "a": {"type": "Custom", "x": 1}});
1711 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1712 : // concise mix of value and a foreign defining member rejects
1713 2 : let doc = json!({"id": "urn:x", "type": "T",
1714 2 : "a": {"value": 1, "languageMap": {"en": "x"}}});
1715 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1716 2 : }
1717 :
1718 : /// 4.5.2.2: a normalized Property "shall never include" the value-defining
1719 : /// members of other attribute types, output-only members, or the sealed
1720 : /// members outside ngsildproof — and valueType coerces to a datatype URI.
1721 : #[test]
1722 2 : fn property_prohibited_members_rejected() {
1723 22 : let mk = |extra: (&str, Value)| {
1724 22 : json!({
1725 22 : "id": "urn:x", "type": "T",
1726 22 : "speed": {"type": "Property", "value": 1, extra.0: extra.1}
1727 : })
1728 22 : };
1729 22 : for (m, v) in [
1730 2 : ("object", json!("urn:ngsi-ld:other:1")),
1731 2 : ("languageMap", json!({"en": "hi"})),
1732 2 : ("json", json!({"k": 1})),
1733 2 : ("vocab", json!("term")),
1734 2 : ("valueList", json!([1, 2])),
1735 2 : ("objectList", json!(["urn:a"])),
1736 2 : ("entity", json!({"id": "urn:a", "type": "T"})),
1737 2 : ("entityList", json!([])),
1738 2 : ("previousValue", json!(0)),
1739 2 : ("previousObject", json!("urn:a")),
1740 2 : ("entityIdSealed", json!(true)),
1741 2 : ] {
1742 22 : let doc = mk((m, v));
1743 22 : assert!(
1744 22 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1745 : "{m} must be prohibited on a Property"
1746 : );
1747 : }
1748 : // valueType is a legal optional member and coerces to a datatype URI
1749 2 : let doc = json!({
1750 2 : "id": "urn:x", "type": "T",
1751 2 : "speed": {"type": "Property", "value": 1.5, "valueType": "xsd:double"}
1752 : });
1753 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1754 2 : .expect("valueType is optional");
1755 2 : let attr = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
1756 2 : assert!(attr["valueType"]
1757 2 : .as_str()
1758 2 : .is_some_and(|s| s.contains("double")));
1759 2 : }
1760 :
1761 : /// 4.5.1: "Terms defined in the Core Context as non-reified Properties
1762 : /// (such as datasetId, instanceId, etc.) shall not be used as Attribute
1763 : /// names."
1764 : #[test]
1765 2 : fn core_non_reified_terms_rejected_as_attribute_names() {
1766 8 : for name in ["datasetId", "instanceId", "observedAt", "unitCode"] {
1767 8 : let doc = json!({
1768 8 : "id": "urn:x", "type": "T",
1769 8 : name: {"type": "Property", "value": 1}
1770 : });
1771 8 : assert!(
1772 8 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1773 : "{name} must be rejected as an Attribute name"
1774 : );
1775 : }
1776 2 : let ok = json!({
1777 2 : "id": "urn:x", "type": "T",
1778 2 : "speed": {"type": "Property", "value": 1}
1779 : });
1780 2 : assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
1781 2 : }
1782 :
1783 : /// 4.5.1: "Attributes shall not contain any embedded @context" — 5.5.7:
1784 : /// such content "should result in an error of type BadRequestData".
1785 : #[test]
1786 2 : fn embedded_context_in_attribute_rejected() {
1787 2 : let doc = json!({
1788 2 : "id": "urn:x", "type": "T",
1789 2 : "speed": {"type": "Property", "value": 1,
1790 2 : "@context": {"speed": "https://evil.example/speed"}}
1791 : });
1792 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1793 : // nested inside a sub-attribute as well
1794 2 : let doc = json!({
1795 2 : "id": "urn:x", "type": "T",
1796 2 : "speed": {"type": "Property", "value": 1,
1797 2 : "source": {"type": "Property", "value": "s",
1798 2 : "@context": {"x": "https://e/x"}}}
1799 : });
1800 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1801 2 : }
1802 :
1803 450 : fn core() -> std::sync::Arc<Context> {
1804 450 : Loader::new().core()
1805 450 : }
1806 :
1807 : #[test]
1808 2 : fn expands_simple_entity() {
1809 2 : let doc = serde_json::json!({
1810 2 : "id": "urn:ngsi-ld:Building:1",
1811 2 : "type": "Building",
1812 2 : "name": {"type": "Property", "value": "Eiffel Tower"}
1813 : });
1814 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1815 2 : .expect("expand");
1816 2 : assert_eq!(out["id"], "urn:ngsi-ld:Building:1");
1817 2 : assert_eq!(
1818 2 : out["type"][0],
1819 : "https://uri.etsi.org/ngsi-ld/default-context/Building"
1820 : );
1821 2 : let name = &out["https://uri.etsi.org/ngsi-ld/default-context/name"];
1822 2 : assert_eq!(name[0]["value"], "Eiffel Tower");
1823 2 : }
1824 :
1825 : #[test]
1826 2 : fn expires_at_kept_as_meta_not_property() {
1827 : // 4.22: a top-level expiresAt must survive as a bare DateTime string
1828 : // (the shape the read-boundary filter / GC / expires_at column read),
1829 : // never as a Property under its IRI.
1830 2 : let doc = serde_json::json!({
1831 2 : "id": "urn:ngsi-ld:T:1",
1832 2 : "type": "T",
1833 2 : "expiresAt": "2020-01-01T00:00:00Z",
1834 2 : "foo": {"type": "Property", "value": 1}
1835 : });
1836 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1837 2 : .expect("expand");
1838 2 : assert_eq!(out["expiresAt"], "2020-01-01T00:00:00Z");
1839 2 : assert!(out.get("https://uri.etsi.org/ngsi-ld/expiresAt").is_none());
1840 : // a non-DateTime expiresAt is rejected
1841 2 : let bad = serde_json::json!({"id": "urn:ngsi-ld:T:2", "type": "T", "expiresAt": "soon"});
1842 2 : assert!(expand_entity(bad.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1843 2 : }
1844 :
1845 : #[test]
1846 2 : fn concise_and_multi_instance() {
1847 2 : let doc = serde_json::json!({
1848 2 : "id": "urn:ngsi-ld:Vehicle:1",
1849 2 : "type": "Vehicle",
1850 2 : "speed": 55,
1851 2 : "brand": [
1852 2 : {"type": "Property", "value": "Volvo", "datasetId": "urn:ngsi-ld:d:1"},
1853 2 : {"type": "Property", "value": "Ford"}
1854 : ]
1855 : });
1856 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1857 2 : .expect("expand");
1858 2 : let speed = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"];
1859 2 : assert_eq!(speed[0]["type"], "Property");
1860 2 : assert_eq!(speed[0]["value"], 55);
1861 2 : let brand = &out["https://uri.etsi.org/ngsi-ld/default-context/brand"];
1862 2 : assert_eq!(brand.as_array().unwrap().len(), 2);
1863 2 : }
1864 :
1865 : #[test]
1866 2 : fn rejects_missing_type() {
1867 2 : let doc = serde_json::json!({"id": "urn:ngsi-ld:Building:1"});
1868 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1869 2 : }
1870 :
1871 : /// 4.8: "Temporal Properties in NGSI-LD shall be represented based on
1872 : /// the DateTime data type as mandated by clause 4.6.3" and "a
1873 : /// TemporalProperty does not allow reification" — only a valid UTC
1874 : /// DateTime STRING is a legal observedAt.
1875 : #[test]
1876 2 : fn rejects_bad_observed_at() {
1877 6 : for bad in [
1878 2 : serde_json::json!("not-a-date"),
1879 2 : // 4.6.3: trailing component shall be Z — offsets are invalid
1880 2 : serde_json::json!("2026-08-10T12:00:00+02:00"),
1881 2 : // non-reified: a Property-shaped observedAt is not a DateTime
1882 2 : serde_json::json!({"type": "Property", "value": "2026-08-10T12:00:00Z"}),
1883 2 : ] {
1884 6 : let doc = serde_json::json!({
1885 6 : "id": "urn:ngsi-ld:Building:1",
1886 6 : "type": "Building",
1887 6 : "a": {"type": "Property", "value": 1, "observedAt": bad}
1888 : });
1889 6 : assert!(
1890 6 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1891 : "observedAt {bad} must be rejected (4.8/4.6.3)"
1892 : );
1893 : }
1894 2 : }
1895 :
1896 : #[test]
1897 2 : fn relationship_needs_uri_object() {
1898 2 : let doc = serde_json::json!({
1899 2 : "id": "urn:ngsi-ld:A:1",
1900 2 : "type": "T",
1901 2 : "rel": {"type": "Relationship", "object": "not a uri"}
1902 : });
1903 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1904 2 : }
1905 :
1906 : /// 4.2.2 Meta Model: "An NGSI-LD Property shall have a value, stated
1907 : /// through hasValue. An NGSI-LD Relationship shall have an object stated
1908 : /// through hasObject." The member is REQUIRED — a typed attribute
1909 : /// without it is rejected, per specialized type as well (5.2.32/5.2.38).
1910 : #[test]
1911 2 : fn meta_model_required_member_per_attribute_type() {
1912 14 : for (ty, wrong_member) in [
1913 2 : ("Property", "object"),
1914 2 : ("Relationship", "value"),
1915 2 : ("LanguageProperty", "value"),
1916 2 : ("JsonProperty", "value"),
1917 2 : ("VocabProperty", "value"),
1918 2 : ("ListProperty", "value"),
1919 2 : ("ListRelationship", "valueList"),
1920 2 : ] {
1921 14 : let doc = serde_json::json!({
1922 14 : "id": "urn:ngsi-ld:A:1",
1923 14 : "type": "T",
1924 14 : "attr": {"type": ty, wrong_member: "x"}
1925 : });
1926 14 : assert!(
1927 14 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1928 : "{ty} without its required member must be rejected (4.2.2)"
1929 : );
1930 : }
1931 2 : }
1932 :
1933 : /// 4.2.2: "An NGSI-LD Value shall be either a rdfs:Literal or a node
1934 : /// object" — every JSON literal, array and object is a legal value;
1935 : /// a bare JSON null is NOT (the null sentinel is the string form,
1936 : /// 4.5.2 / 057_03_02).
1937 : #[test]
1938 2 : fn meta_model_value_space() {
1939 12 : for v in [
1940 2 : serde_json::json!(17),
1941 2 : serde_json::json!(1.5),
1942 2 : serde_json::json!(true),
1943 2 : serde_json::json!("text"),
1944 2 : serde_json::json!([1, 2, 3]),
1945 2 : serde_json::json!({"nested": {"deep": [1]}}),
1946 2 : ] {
1947 12 : let doc = serde_json::json!({
1948 12 : "id": "urn:ngsi-ld:A:1", "type": "T",
1949 12 : "attr": {"type": "Property", "value": v}
1950 : });
1951 12 : assert!(
1952 12 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok(),
1953 : "literal/node-object value must be accepted (4.2.2): {v}"
1954 : );
1955 : }
1956 2 : let doc = serde_json::json!({
1957 2 : "id": "urn:ngsi-ld:A:1", "type": "T",
1958 2 : "attr": {"type": "Property", "value": null}
1959 : });
1960 2 : assert!(
1961 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1962 : "bare JSON null is not an NGSI-LD Value (4.2.2/4.5.2)"
1963 : );
1964 2 : }
1965 :
1966 : #[test]
1967 2 : fn location_must_be_geo() {
1968 2 : let doc = serde_json::json!({
1969 2 : "id": "urn:ngsi-ld:A:1",
1970 2 : "type": "T",
1971 2 : "location": {"type": "Property", "value": 3}
1972 : });
1973 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1974 2 : let ok = serde_json::json!({
1975 2 : "id": "urn:ngsi-ld:A:1",
1976 2 : "type": "T",
1977 2 : "location": {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [1.0, 2.0]}}
1978 : });
1979 2 : assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
1980 2 : }
1981 :
1982 : #[test]
1983 2 : fn datetime_validation() {
1984 : // 4.6.3, p.80-81. Accepted forms:
1985 2 : assert!(parse_datetime("2020-09-09T16:40:00Z"));
1986 2 : assert!(parse_datetime("2020-09-09T16:40:00.000Z"));
1987 2 : assert!(
1988 2 : parse_datetime("2020-09-09T16:40:00.123456Z"),
1989 : "6 fraction digits"
1990 : );
1991 : // "In requests, also a comma instead of a decimal point may be used as
1992 : // separator for compatibility reasons."
1993 2 : assert!(
1994 2 : parse_datetime("2020-09-09T16:40:00,123Z"),
1995 : "comma separator"
1996 : );
1997 2 : assert!(
1998 2 : parse_datetime("2020-02-29T00:00:00Z"),
1999 : "2020 is a leap year"
2000 : );
2001 :
2002 : // Rejected. NOTE: the offset case previously asserted the OPPOSITE —
2003 : // 4.6.3 is explicit that "the trailing timestamp component … shall
2004 : // always be equal to the character Z. Therefore, all timestamps shall
2005 : // be expressed in UTC", so an offset is invalid, not an alternative.
2006 2 : assert!(
2007 2 : !parse_datetime("2020-09-09T16:40:00+02:00"),
2008 : "offset forbidden"
2009 : );
2010 2 : assert!(
2011 2 : !parse_datetime("2020-09-09T16:40:00-05:00"),
2012 : "offset forbidden"
2013 : );
2014 : // "All the referred components shall appear in the string; reduced
2015 : // representations are not permitted."
2016 2 : assert!(!parse_datetime("2020-09-09T16:40:00"), "no zone");
2017 2 : assert!(!parse_datetime("2020-09-09"));
2018 2 : assert!(!parse_datetime("nope"));
2019 : // fraction bounds: 1..=6 digits, and a separator is required
2020 2 : assert!(!parse_datetime("2020-09-09T16:40:00.Z"), "empty fraction");
2021 2 : assert!(!parse_datetime("2020-09-09T16:40:00.1234567Z"), "7 digits");
2022 2 : assert!(!parse_datetime("2020-09-09T16:40:00123Z"), "no separator");
2023 : // calendar reality — digit-shape alone let this through, and one such
2024 : // write made every later temporal query in the tenant 500 on the
2025 : // ::timestamptz cast
2026 2 : assert!(!parse_datetime("2026-13-45T00:00:00Z"), "month 13, day 45");
2027 2 : assert!(
2028 2 : !parse_datetime("2021-02-29T00:00:00Z"),
2029 : "2021 is not a leap year"
2030 : );
2031 2 : assert!(!parse_datetime("2020-09-09T25:00:00Z"), "hour 25");
2032 2 : }
2033 :
2034 : /// 4.7.1/4.7.2/4.7.3 Geospatial Properties: location & co. must be
2035 : /// GeoProperties (4.7.1); a whole geometry MAY arrive as an encoded JSON
2036 : /// string, accepted "if and only if" it parses into a valid geometry of
2037 : /// the stated type (4.7.2, normalized to the object form); the concise
2038 : /// forms infer GeoProperty from a resolving geometry value (4.7.3).
2039 : #[test]
2040 2 : fn geo_property_rules() {
2041 14 : let ent = |attr: Value| {
2042 14 : let doc = json!({"id": "urn:x", "type": "T", "g": attr});
2043 14 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2044 14 : };
2045 : // 4.7.2: string-encoded geometry accepted and normalized to the object
2046 2 : let out = ent(json!({"type": "GeoProperty",
2047 2 : "value": "{\"type\": \"Point\", \"coordinates\": [17.1, 48.7]}"}))
2048 2 : .expect("string-encoded geometry");
2049 2 : let val = &out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["value"];
2050 2 : assert!(
2051 2 : !val.is_string(),
2052 : "value must be normalized, not stay a string"
2053 : );
2054 2 : assert_eq!(val["type"], "Point");
2055 2 : assert_eq!(val["coordinates"][0], 17.1);
2056 : // iff: unparseable, non-geometry and GeometryCollection strings → 400
2057 6 : for bad_s in [
2058 2 : "not json",
2059 2 : "{\"a\": 1}",
2060 2 : "{\"type\": \"GeometryCollection\", \"geometries\": []}",
2061 2 : ] {
2062 6 : let err = ent(json!({"type": "GeoProperty", "value": bad_s})).expect_err(bad_s);
2063 6 : assert!(matches!(err, NgsiError::BadRequestData(_)), "{bad_s}");
2064 : }
2065 : // 4.7.1: location shall be a GeoProperty
2066 2 : let doc = json!({"id": "urn:x", "type": "T",
2067 2 : "location": {"type": "Property", "value": 1}});
2068 2 : assert!(
2069 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
2070 : "location as plain Property"
2071 : );
2072 : // 4.7.3: concise inference — bare geometry and resolving value
2073 2 : let out = ent(json!({"type": "Point", "coordinates": [1.0, 2.0]}))
2074 2 : .expect("bare geometry concise form");
2075 2 : assert_eq!(
2076 2 : out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
2077 : "GeoProperty"
2078 : );
2079 2 : let out = ent(json!({"value": {"type": "Point", "coordinates": [1.0, 2.0]}}))
2080 2 : .expect("resolving value");
2081 2 : assert_eq!(
2082 2 : out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
2083 : "GeoProperty"
2084 : );
2085 : // an ordinary string value must NOT be inferred as GeoProperty
2086 2 : let out = ent(json!({"value": "Point"})).expect("plain string value");
2087 2 : assert_eq!(
2088 2 : out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
2089 : "Property"
2090 : );
2091 2 : }
2092 :
2093 : /// 4.7.2: a geometry is accepted "if and only if" it meets "the syntax
2094 : /// and restrictions mandated by IETF RFC 7946 \[8\] when representing a
2095 : /// valid Geometry of the type specified", and its coordinates are "values
2096 : /// of a JSON-LD floating point number data type". The verbose, concise
2097 : /// and string-encoded forms are one Value and all three are held to it.
2098 : #[test]
2099 2 : fn geojson_geometries_meet_the_rfc_7946_restrictions() {
2100 150 : let ent = |attr: Value| {
2101 150 : let doc = json!({"id": "urn:x", "type": "T", "location": attr});
2102 150 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2103 150 : };
2104 2 : let bad_geoms = vec![
2105 : // RFC 7946 3.1.1: "A position is an array of numbers. There MUST
2106 : // be two or more elements."
2107 2 : (
2108 2 : "point is not an array",
2109 2 : json!({"type": "Point", "coordinates": 1}),
2110 2 : ),
2111 2 : (
2112 2 : "point is an object",
2113 2 : json!({"type": "Point", "coordinates": {"lon": 1}}),
2114 2 : ),
2115 2 : (
2116 2 : "one element",
2117 2 : json!({"type": "Point", "coordinates": [1.0]}),
2118 2 : ),
2119 2 : (
2120 2 : "empty position",
2121 2 : json!({"type": "Point", "coordinates": []}),
2122 2 : ),
2123 : // 4.7.2: coordinates are floating point numbers.
2124 2 : (
2125 2 : "strings, not numbers",
2126 2 : json!({"type": "Point", "coordinates": ["1", "2"]}),
2127 2 : ),
2128 2 : (
2129 2 : "null coordinate",
2130 2 : json!({"type": "Point", "coordinates": [1.0, null]}),
2131 2 : ),
2132 2 : (
2133 2 : "nested where a position belongs",
2134 2 : json!({"type": "Point", "coordinates": [[1.0, 2.0]]}),
2135 2 : ),
2136 : // RFC 7946 4: the CRS is WGS84 "with longitude and latitude units
2137 : // of decimal degrees" — 999 is not a latitude. Left through, it
2138 : // reaches PostGIS as a `::geography` cast that errors, so one
2139 : // write breaks every later `near` query in the tenant.
2140 2 : (
2141 2 : "latitude past the pole",
2142 2 : json!({"type": "Point", "coordinates": [0.0, 999.0]}),
2143 2 : ),
2144 2 : (
2145 2 : "longitude past the antimeridian",
2146 2 : json!({"type": "Point", "coordinates": [181.0, 0.0]}),
2147 2 : ),
2148 2 : (
2149 2 : "latitude below the pole",
2150 2 : json!({"type": "Point", "coordinates": [0.0, -90.5]}),
2151 2 : ),
2152 : // RFC 7946 3.1.4: a LineString needs "two or more positions".
2153 2 : (
2154 2 : "one-position LineString",
2155 2 : json!({"type": "LineString", "coordinates": [[1.0, 2.0]]}),
2156 2 : ),
2157 2 : (
2158 2 : "LineString of numbers",
2159 2 : json!({"type": "LineString", "coordinates": [1.0, 2.0]}),
2160 2 : ),
2161 : // RFC 7946 3.1.6: rings are closed and have four or more positions.
2162 2 : (
2163 2 : "open ring",
2164 2 : json!({"type": "Polygon", "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]]}),
2165 2 : ),
2166 2 : (
2167 2 : "short ring",
2168 2 : json!({"type": "Polygon", "coordinates": [[[0.0, 0.0], [1.0, 0.0], [0.0, 0.0]]]}),
2169 2 : ),
2170 2 : (
2171 2 : "ring out of range",
2172 2 : json!({"type": "Polygon",
2173 2 : "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 91.0], [0.0, 0.0]]]}),
2174 2 : ),
2175 2 : (
2176 2 : "polygon of positions",
2177 2 : json!({"type": "Polygon", "coordinates": [[0.0, 0.0]]}),
2178 2 : ),
2179 2 : (
2180 2 : "MultiPolygon nested one level short",
2181 2 : json!({"type": "MultiPolygon",
2182 2 : "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]}),
2183 2 : ),
2184 : ];
2185 34 : for (why, geom) in &bad_geoms {
2186 : // verbose form
2187 34 : let err = ent(json!({"type": "GeoProperty", "value": geom})).expect_err(why);
2188 34 : assert!(
2189 34 : matches!(err, NgsiError::BadRequestData(_)),
2190 : "{why}: {err:?}"
2191 : );
2192 : // 4.7.3 concise form — the same Value, the same restrictions
2193 34 : let err = ent(geom.clone()).expect_err(&format!("{why} (concise)"));
2194 34 : assert!(
2195 34 : matches!(err, NgsiError::BadRequestData(_)),
2196 : "{why} (concise)"
2197 : );
2198 : // 4.7.2 string-encoded form
2199 34 : let encoded = serde_json::to_string(geom).expect("encode");
2200 34 : let err = ent(json!({"type": "GeoProperty", "value": encoded}))
2201 34 : .expect_err(&format!("{why} (encoded)"));
2202 34 : assert!(
2203 34 : matches!(err, NgsiError::BadRequestData(_)),
2204 : "{why} (encoded)"
2205 : );
2206 : }
2207 : // the shapes RFC 7946 does allow stay accepted, in all three forms
2208 2 : let good = vec![
2209 2 : json!({"type": "Point", "coordinates": [17.1, 48.7]}),
2210 2 : json!({"type": "Point", "coordinates": [-180.0, -90.0]}),
2211 2 : json!({"type": "Point", "coordinates": [180.0, 90.0]}),
2212 : // "Altitude or elevation MAY be included as an optional third element"
2213 2 : json!({"type": "Point", "coordinates": [1.0, 2.0, 300.0]}),
2214 2 : json!({"type": "MultiPoint", "coordinates": [[1.0, 2.0], [3.0, 4.0]]}),
2215 2 : json!({"type": "LineString", "coordinates": [[1.0, 2.0], [3.0, 4.0]]}),
2216 2 : json!({"type": "MultiLineString", "coordinates": [[[1.0, 2.0], [3.0, 4.0]]]}),
2217 2 : json!({"type": "Polygon",
2218 2 : "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]}),
2219 2 : json!({"type": "Polygon", "coordinates": [
2220 : [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 0.0]],
2221 : [[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 1.0]]]}),
2222 2 : json!({"type": "MultiPolygon",
2223 2 : "coordinates": [[[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]]}),
2224 : // RFC 7946 3.1: an empty multi-geometry is still a geometry
2225 2 : json!({"type": "MultiPoint", "coordinates": []}),
2226 2 : json!({"type": "Polygon", "coordinates": []}),
2227 : ];
2228 24 : for geom in &good {
2229 24 : ent(json!({"type": "GeoProperty", "value": geom}))
2230 24 : .unwrap_or_else(|e| panic!("{geom} rejected: {e:?}"));
2231 24 : ent(geom.clone()).unwrap_or_else(|e| panic!("{geom} concise rejected: {e:?}"));
2232 : }
2233 2 : }
2234 :
2235 : /// 5.5.4 General NGSI-LD validation: "urn:ngsi-ld:null" as a first-level
2236 : /// member value is BadRequestData outside partial-update/merge fragments;
2237 : /// as the value of a key inside a JSON object that is a Property's value
2238 : /// it is BadRequestData everywhere EXCEPT merge fragments (5.5.12).
2239 : #[test]
2240 2 : fn clause_5_5_4_null_placement() {
2241 2 : let e =
2242 20 : |doc: Value, opts: ExpandOpts| expand_entity(doc.as_object().unwrap(), &core(), opts);
2243 : // first-level member value — id and type are first-level members too
2244 2 : assert!(
2245 2 : e(
2246 2 : json!({"id": "urn:ngsi-ld:null", "type": "T"}),
2247 2 : ExpandOpts::default()
2248 2 : )
2249 2 : .is_err(),
2250 : "null URN as id must 400 on create"
2251 : );
2252 2 : assert!(
2253 2 : e(
2254 2 : json!({"id": "urn:x", "type": "urn:ngsi-ld:null"}),
2255 2 : ExpandOpts::default()
2256 2 : )
2257 2 : .is_err(),
2258 : "null URN as type must 400 on create"
2259 : );
2260 : // null inside a JSON object that is a Property value
2261 2 : let nested = json!({"id": "urn:x", "type": "T",
2262 2 : "p": {"type": "Property", "value": {"a": "urn:ngsi-ld:null"}}});
2263 2 : assert!(
2264 2 : e(nested.clone(), ExpandOpts::default()).is_err(),
2265 : "nested null in value object must 400 on create"
2266 : );
2267 : // partial update allows top-level nulls (allow_null) but the
2268 : // object-nested form is excepted for merge ONLY
2269 2 : assert!(
2270 2 : e(
2271 2 : nested.clone(),
2272 2 : ExpandOpts {
2273 2 : fragment: true,
2274 2 : allow_null: true,
2275 2 : ..Default::default()
2276 2 : }
2277 2 : )
2278 2 : .is_err(),
2279 : "nested null in value object must 400 on partial update"
2280 : );
2281 : // merge fragment: accepted and preserved for merge_into
2282 2 : let ok = e(
2283 2 : nested,
2284 2 : ExpandOpts {
2285 2 : fragment: true,
2286 2 : allow_null: true,
2287 2 : merge: true,
2288 2 : ..Default::default()
2289 2 : },
2290 2 : )
2291 2 : .expect("merge fragment keeps the nested null");
2292 2 : assert_eq!(
2293 2 : ok["https://uri.etsi.org/ngsi-ld/default-context/p"][0]["value"]["a"],
2294 : "urn:ngsi-ld:null"
2295 : );
2296 : // deep nesting (object in object, object in array) is caught too
2297 2 : let deep = json!({"id": "urn:x", "type": "T",
2298 2 : "p": {"type": "Property", "value": {"a": {"b": "urn:ngsi-ld:null"}}}});
2299 2 : assert!(
2300 2 : e(deep, ExpandOpts::default()).is_err(),
2301 : "deep nested null must 400"
2302 : );
2303 2 : let in_array = json!({"id": "urn:x", "type": "T",
2304 2 : "p": {"type": "Property", "value": [{"a": "urn:ngsi-ld:null"}]}});
2305 2 : assert!(
2306 2 : e(in_array, ExpandOpts::default()).is_err(),
2307 : "null in object in array must 400"
2308 : );
2309 : // negative: a benign object value passes and carries no null
2310 2 : let fine = e(
2311 2 : json!({"id": "urn:x", "type": "T",
2312 2 : "p": {"type": "Property", "value": {"a": 1}}}),
2313 2 : ExpandOpts::default(),
2314 2 : )
2315 2 : .expect("plain object value stays legal");
2316 2 : assert!(
2317 2 : !fine.to_string().contains("urn:ngsi-ld:null"),
2318 : "no null leakage"
2319 : );
2320 : // top-level attribute null stays the fragment deletion form:
2321 : // rejected on create, accepted under allow_null (5.5.8/5.5.12)
2322 2 : let top = json!({"id": "urn:x", "type": "T", "p": "urn:ngsi-ld:null"});
2323 2 : assert!(e(top.clone(), ExpandOpts::default()).is_err());
2324 2 : e(
2325 2 : top,
2326 2 : ExpandOpts {
2327 2 : fragment: true,
2328 2 : allow_null: true,
2329 2 : ..Default::default()
2330 2 : },
2331 2 : )
2332 2 : .expect("first-level null is the deletion form in fragments");
2333 2 : }
2334 :
2335 : /// 5.5.8: "A datasetId cannot be deleted by setting it to the value
2336 : /// urn:ngsi-ld:null" — such a fragment is rejected on every input,
2337 : /// including null-allowing (update/merge) ones.
2338 : #[test]
2339 2 : fn clause_5_5_8_dataset_id_null_rejected() {
2340 2 : let doc = json!({"id": "urn:x", "type": "T",
2341 2 : "speed": {"type": "Property", "value": 1,
2342 2 : "datasetId": "urn:ngsi-ld:null"}});
2343 6 : for opts in [
2344 2 : ExpandOpts::default(),
2345 2 : ExpandOpts {
2346 2 : fragment: true,
2347 2 : allow_null: true,
2348 2 : ..Default::default()
2349 2 : },
2350 2 : ExpandOpts {
2351 2 : fragment: true,
2352 2 : allow_null: true,
2353 2 : merge: true,
2354 2 : ..Default::default()
2355 2 : },
2356 2 : ] {
2357 6 : assert!(
2358 6 : expand_entity(doc.as_object().unwrap(), &core(), opts).is_err(),
2359 : "datasetId null must be rejected (opts {opts:?})"
2360 : );
2361 : }
2362 : // the attribute-level fragment path (5.6.4) rejects it too
2363 2 : let frag = json!({"type": "Property", "value": 1,
2364 2 : "datasetId": "urn:ngsi-ld:null"});
2365 2 : assert!(expand_attr_fragment(frag.as_object().unwrap(), &core()).is_err());
2366 : // a REAL datasetId still passes and is preserved
2367 2 : let ok = expand_entity(
2368 2 : json!({"id": "urn:x", "type": "T",
2369 2 : "speed": {"type": "Property", "value": 1,
2370 2 : "datasetId": "urn:ngsi-ld:Dataset:a"}})
2371 2 : .as_object()
2372 2 : .unwrap(),
2373 2 : &core(),
2374 2 : ExpandOpts::default(),
2375 : )
2376 2 : .expect("real datasetId");
2377 2 : assert_eq!(
2378 2 : ok["https://uri.etsi.org/ngsi-ld/default-context/speed"][0]["datasetId"],
2379 : "urn:ngsi-ld:Dataset:a"
2380 : );
2381 2 : }
2382 :
2383 : /// 4.6.5 Supported data types for LanguageMaps: keys are RFC 5646 tags
2384 : /// or "@none", values are strings or arrays of strings; the
2385 : /// {"@none": "urn:ngsi-ld:null"} form is ONLY the partial/merge-patch
2386 : /// deletion encoding — invalid in a create/append (no allow_null).
2387 : #[test]
2388 2 : fn language_map_data_types() {
2389 10 : let lp = |lm: Value, opts: ExpandOpts| {
2390 10 : let doc = json!({"id": "urn:x", "type": "T",
2391 10 : "brandName": {"type": "LanguageProperty", "languageMap": lm}});
2392 10 : expand_entity(doc.as_object().unwrap(), &core(), opts)
2393 10 : };
2394 2 : let out = lp(
2395 2 : json!({"sk": "škola", "en": ["school", "academy"], "@none": "default"}),
2396 2 : ExpandOpts::default(),
2397 2 : )
2398 2 : .expect("strings and arrays of strings");
2399 2 : let m = &out["https://uri.etsi.org/ngsi-ld/default-context/brandName"][0]["languageMap"];
2400 2 : assert_eq!(m["en"][1], "academy");
2401 2 : assert!(m.get("urn:ngsi-ld:null").is_none(), "no null leakage");
2402 : // non-string value rejected
2403 2 : assert!(lp(json!({"en": 5}), ExpandOpts::default()).is_err());
2404 2 : assert!(lp(json!({"en": [5]}), ExpandOpts::default()).is_err());
2405 : // the null encoding is a deletion marker: rejected on create,
2406 : // accepted under allow_null (patch/merge)
2407 2 : assert!(
2408 2 : lp(json!({"@none": "urn:ngsi-ld:null"}), ExpandOpts::default()).is_err(),
2409 : "langmap null form invalid outside patch/merge"
2410 : );
2411 2 : lp(
2412 2 : json!({"@none": "urn:ngsi-ld:null"}),
2413 2 : ExpandOpts {
2414 2 : allow_null: true,
2415 2 : ..ExpandOpts::default()
2416 2 : },
2417 2 : )
2418 2 : .expect("deletion form under allow_null");
2419 2 : }
2420 :
2421 : /// 4.6.3 Supported data types for Values: "All the GeoJSON Geometries
2422 : /// [8] with the exception of GeometryCollection" — a GeoProperty value
2423 : /// of type GeometryCollection is BadRequestData; plain JSON values and
2424 : /// a bare JSON null follow 4.5.2 (null rejected outside merge-patch).
2425 : #[test]
2426 2 : fn value_data_types_rules() {
2427 6 : let geo = |val: Value| {
2428 6 : let doc = json!({"id": "urn:x", "type": "T",
2429 6 : "location": {"type": "GeoProperty", "value": val}});
2430 6 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2431 6 : };
2432 2 : let out = geo(json!({"type": "Point", "coordinates": [17.1, 48.7]}))
2433 2 : .expect("Point is a supported geometry");
2434 2 : let loc = &out["https://uri.etsi.org/ngsi-ld/location"][0];
2435 2 : assert_eq!(loc["value"]["type"], "Point");
2436 2 : let err = geo(json!({"type": "GeometryCollection", "geometries": [
2437 2 : {"type": "Point", "coordinates": [1.0, 2.0]}]}))
2438 2 : .expect_err("GeometryCollection is excluded by 4.6.3");
2439 2 : assert!(matches!(err, NgsiError::BadRequestData(_)), "{err:?}");
2440 : // a geometry type must still carry coordinates
2441 2 : assert!(geo(json!({"type": "Point"})).is_err(), "no coordinates");
2442 : // bare JSON null is not a legal Value outside merge-patch (4.5.2)
2443 2 : let doc = json!({"id": "urn:x", "type": "T",
2444 2 : "speed": {"type": "Property", "value": null}});
2445 2 : assert!(
2446 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
2447 : "bare null value"
2448 : );
2449 2 : }
2450 :
2451 : /// 4.6.2 Supported names: name = unicodeLetter *(letter|number|_) —
2452 : /// Entity Type / Property / Relationship names with other characters are
2453 : /// BadRequestData; keys containing ':' (compact or absolute IRIs) are
2454 : /// out of the term grammar's scope.
2455 : #[test]
2456 2 : fn name_grammar_rules() {
2457 20 : let attr = |name: &str| {
2458 20 : let doc = json!({"id": "urn:x", "type": "T",
2459 20 : name: {"type": "Property", "value": 1}});
2460 20 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2461 20 : };
2462 10 : for bad_name in ["my attr", "1temp", "temp-erature", "temp!", "_hidden"] {
2463 10 : let err = attr(bad_name).expect_err(bad_name);
2464 : // 4.6.2 names the error type: BadRequestData, nothing else
2465 10 : assert!(
2466 10 : matches!(err, NgsiError::BadRequestData(_)),
2467 : "{bad_name}: {err:?}"
2468 : );
2469 : }
2470 10 : for good in [
2471 2 : "teplota_1",
2472 2 : "Ωmega",
2473 2 : "výška",
2474 2 : "ns:temp",
2475 2 : "https://example.com/a b",
2476 10 : ] {
2477 10 : attr(good).expect(good);
2478 10 : }
2479 : // sub-attribute names obey the same grammar
2480 2 : let doc = json!({"id": "urn:x", "type": "T",
2481 2 : "speed": {"type": "Property", "value": 1,
2482 2 : "bad sub": {"type": "Property", "value": 2}}});
2483 2 : assert!(
2484 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
2485 : "sub-attribute with space must be rejected"
2486 : );
2487 : // entity type names too; multi-type checks each entry
2488 6 : let ty = |t: Value| {
2489 6 : let doc = json!({"id": "urn:x", "type": t, "speed": {"type": "Property", "value": 1}});
2490 6 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2491 6 : };
2492 2 : assert!(ty(json!("My Type")).is_err(), "type with space");
2493 2 : assert!(
2494 2 : ty(json!(["T", "9T"])).is_err(),
2495 : "type starting with a digit"
2496 : );
2497 2 : ty(json!("Škola")).expect("unicode-letter type");
2498 2 : }
2499 :
2500 : /// 4.5.24.2/4.5.24.3: JsonProperty — json is a raw object or array of
2501 : /// objects (kept verbatim), unitCode and value prohibited, concise
2502 : /// inference from json.
2503 : #[test]
2504 2 : fn json_property_rules() {
2505 12 : let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "tickets": attr});
2506 : // valid: object and array-of-objects, kept verbatim (no expansion)
2507 2 : let doc = mk(json!({"type": "JsonProperty", "json": {"id": "x", "value": 1}}));
2508 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2509 2 : .expect("valid json object");
2510 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/tickets"][0];
2511 2 : assert_eq!(
2512 2 : inst["json"],
2513 2 : json!({"id": "x", "value": 1}),
2514 : "raw JSON kept verbatim"
2515 : );
2516 2 : let doc = mk(json!({"type": "JsonProperty", "json": [{"a": 1}, {"b": 2}]}));
2517 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2518 2 : .expect("array of objects");
2519 : // concise inference
2520 2 : let doc = mk(json!({"json": {"a": 1}}));
2521 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2522 2 : .expect("concise");
2523 2 : assert_eq!(
2524 2 : out["https://uri.etsi.org/ngsi-ld/default-context/tickets"][0]["type"],
2525 : "JsonProperty"
2526 : );
2527 : // scalar json rejected
2528 2 : let doc = mk(json!({"type": "JsonProperty", "json": 5}));
2529 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2530 : // unitCode prohibited
2531 2 : let doc = mk(json!({"type": "JsonProperty", "json": {"a": 1}, "unitCode": "MTR"}));
2532 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2533 : // value prohibited
2534 2 : let doc = mk(json!({"type": "JsonProperty", "json": {"a": 1}, "value": 1}));
2535 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2536 2 : }
2537 :
2538 : /// 4.5.21/4.5.22: ListProperty and ListRelationship — objectList accepts
2539 : /// bare URIs or {"object": URI} objects (normalized to bare URIs
2540 : /// internally), non-URIs rejected, [null] deletion form, value/object
2541 : /// prohibited, concise inference.
2542 : #[test]
2543 2 : fn list_property_and_relationship_rules() {
2544 14 : let mk = |name: &str, attr: Value| json!({"id": "urn:x", "type": "T", name: attr});
2545 : // ListProperty: ordered array of Property Values, value prohibited
2546 2 : let doc = mk(
2547 2 : "steps",
2548 2 : json!({"type": "ListProperty", "valueList": [1, "two", true]}),
2549 2 : );
2550 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).expect("valid");
2551 2 : let doc = mk(
2552 2 : "steps",
2553 2 : json!({"type": "ListProperty", "valueList": [1], "value": 2}),
2554 2 : );
2555 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2556 : // concise inference
2557 2 : let doc = mk("steps", json!({"valueList": [1]}));
2558 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2559 2 : .expect("concise");
2560 2 : assert_eq!(
2561 2 : out["https://uri.etsi.org/ngsi-ld/default-context/steps"][0]["type"],
2562 : "ListProperty"
2563 : );
2564 : // ListRelationship: both entry forms normalize to bare URIs
2565 2 : let doc = mk(
2566 2 : "route",
2567 2 : json!({"type": "ListRelationship",
2568 2 : "objectList": ["urn:ngsi-ld:R:1", {"object": "urn:ngsi-ld:R:2"}]}),
2569 2 : );
2570 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2571 2 : .expect("both forms");
2572 2 : assert_eq!(
2573 2 : out["https://uri.etsi.org/ngsi-ld/default-context/route"][0]["objectList"],
2574 2 : json!(["urn:ngsi-ld:R:1", "urn:ngsi-ld:R:2"])
2575 : );
2576 : // non-URI entry rejected
2577 2 : let doc = mk(
2578 2 : "route",
2579 2 : json!({"type": "ListRelationship", "objectList": ["not a uri"]}),
2580 2 : );
2581 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2582 : // object prohibited on ListRelationship
2583 2 : let doc = mk(
2584 2 : "route",
2585 2 : json!({"type": "ListRelationship",
2586 2 : "objectList": ["urn:ngsi-ld:R:1"], "object": "urn:ngsi-ld:R:2"}),
2587 2 : );
2588 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2589 : // [null] deletion form accepted under allow_null
2590 2 : let doc = mk(
2591 2 : "route",
2592 2 : json!({"type": "ListRelationship",
2593 2 : "objectList": ["urn:ngsi-ld:null"]}),
2594 2 : );
2595 2 : let opts = ExpandOpts {
2596 2 : allow_null: true,
2597 2 : fragment: true,
2598 2 : ..Default::default()
2599 2 : };
2600 2 : expand_entity(doc.as_object().unwrap(), &core(), opts).expect("deletion form");
2601 2 : assert!(is_ngsi_null_list(&json!(["urn:ngsi-ld:null"])));
2602 2 : assert!(!is_ngsi_null_list(&json!(["urn:ngsi-ld:null", "urn:x"])));
2603 2 : }
2604 :
2605 : /// 4.5.20.2/4.5.20.3: VocabProperty — vocab is a string or array of
2606 : /// strings coerced to IRIs; unitCode and value prohibited; concise
2607 : /// inference from vocab.
2608 : #[test]
2609 2 : fn vocab_property_rules() {
2610 10 : let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "category": attr});
2611 : // normalized: term expands to an IRI under the context
2612 2 : let doc = mk(json!({"type": "VocabProperty", "vocab": "non-commercial"}));
2613 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2614 2 : .expect("valid vocab");
2615 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
2616 2 : assert_eq!(
2617 2 : inst["vocab"],
2618 : "https://uri.etsi.org/ngsi-ld/default-context/non-commercial"
2619 : );
2620 : // concise inference from vocab
2621 2 : let doc = mk(json!({"vocab": ["a", "b"]}));
2622 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2623 2 : .expect("concise");
2624 2 : assert_eq!(
2625 2 : out["https://uri.etsi.org/ngsi-ld/default-context/category"][0]["type"],
2626 : "VocabProperty"
2627 : );
2628 : // non-string vocab rejected
2629 2 : let doc = mk(json!({"type": "VocabProperty", "vocab": 5}));
2630 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2631 : // unitCode prohibited
2632 2 : let doc = mk(json!({"type": "VocabProperty", "vocab": "x", "unitCode": "MTR"}));
2633 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2634 : // value prohibited
2635 2 : let doc = mk(json!({"type": "VocabProperty", "vocab": "x", "value": 1}));
2636 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2637 2 : }
2638 :
2639 : /// 4.5.18.2/4.5.18.3: LanguageProperty — non-empty language tags, unitCode
2640 : /// prohibited, value prohibited; concise inference from languageMap.
2641 : #[test]
2642 2 : fn language_property_rules() {
2643 12 : let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "says": attr});
2644 : // valid normalized + "@none" tag
2645 2 : let doc = mk(json!({"type": "LanguageProperty",
2646 2 : "languageMap": {"en": "hi", "@none": "hey"}}));
2647 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).expect("valid");
2648 : // concise inference from languageMap
2649 2 : let doc = mk(json!({"languageMap": {"en": "hi"}}));
2650 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2651 2 : .expect("concise");
2652 2 : assert_eq!(
2653 2 : out["https://uri.etsi.org/ngsi-ld/default-context/says"][0]["type"],
2654 : "LanguageProperty"
2655 : );
2656 : // empty language tag rejected
2657 2 : let doc = mk(json!({"type": "LanguageProperty", "languageMap": {"": "hi"}}));
2658 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2659 : // unitCode prohibited
2660 2 : let doc = mk(json!({"type": "LanguageProperty",
2661 2 : "languageMap": {"en": "hi"}, "unitCode": "MTR"}));
2662 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2663 : // value prohibited
2664 2 : let doc = mk(json!({"type": "LanguageProperty",
2665 2 : "languageMap": {"en": "hi"}, "value": 1}));
2666 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2667 : // non-string languageMap values rejected
2668 2 : let doc = mk(json!({"type": "LanguageProperty", "languageMap": {"en": 5}}));
2669 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2670 2 : }
2671 : }
2672 :
2673 : #[cfg(test)]
2674 : mod bench {
2675 : use super::*;
2676 :
2677 : /// The go/no-go threshold for a hand-rolled JSON-LD processor was
2678 : /// ≥5k expansions/s/core. Antares hand-rolled its processor from day one
2679 : /// rather than forking a `json-ld` crate — this measures it. Run with
2680 : /// `cargo test -p antares-jsonld --release -- --ignored bench_expansion`.
2681 : #[test]
2682 : #[ignore = "benchmark — run explicitly in release"]
2683 0 : fn bench_expansion_rate() {
2684 0 : let loader = crate::Loader::new();
2685 0 : let ctx = loader.core();
2686 0 : let entity: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
2687 0 : r#"{
2688 0 : "id": "urn:ngsi-ld:Vehicle:bench-1", "type": "Vehicle",
2689 0 : "speed": {"type": "Property", "value": 55.1,
2690 0 : "observedAt": "2026-08-04T12:00:00Z", "unitCode": "KMH",
2691 0 : "source": {"type": "Property", "value": "GPS"}},
2692 0 : "heading": {"type": "Property", "value": 180},
2693 0 : "isParked": {"type": "Relationship",
2694 0 : "object": "urn:ngsi-ld:OffStreetParking:p1",
2695 0 : "providedBy": {"type": "Relationship",
2696 0 : "object": "urn:ngsi-ld:Person:bob"}},
2697 0 : "location": {"type": "GeoProperty",
2698 0 : "value": {"type": "Point", "coordinates": [13.35, 52.51]}},
2699 0 : "name": {"type": "LanguageProperty",
2700 0 : "languageMap": {"en": "car", "de": "Auto"}}
2701 0 : }"#,
2702 : )
2703 0 : .expect("entity");
2704 0 : let n = 20_000u32;
2705 0 : let start = std::time::Instant::now();
2706 0 : for _ in 0..n {
2707 0 : let out = expand_entity(&entity, &ctx, ExpandOpts::default()).expect("expand");
2708 0 : std::hint::black_box(out);
2709 0 : }
2710 0 : let secs = start.elapsed().as_secs_f64();
2711 0 : let rate = f64::from(n) / secs;
2712 0 : eprintln!("expansion rate: {rate:.0}/s/core ({n} iterations in {secs:.2}s)");
2713 0 : assert!(
2714 0 : rate >= 5_000.0,
2715 : "expansion rate {rate:.0}/s is below the 5k/s/core phase-0 gate"
2716 : );
2717 0 : }
2718 : }
2719 :
2720 : #[cfg(test)]
2721 : mod clause_4_18 {
2722 : use super::*;
2723 : use crate::loader::Loader;
2724 : use serde_json::json;
2725 :
2726 28 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
2727 28 : expand_entity(
2728 28 : doc.as_object().expect("obj"),
2729 28 : &Loader::new().core(),
2730 28 : ExpandOpts::default(),
2731 : )
2732 28 : }
2733 :
2734 34 : fn with_scope(s: serde_json::Value) -> serde_json::Value {
2735 34 : json!({"id": "urn:x", "type": "T", "scope": s})
2736 34 : }
2737 :
2738 : /// 4.18 Scope grammar: [/] ScopeLevel *(/ScopeLevel), ScopeLevel =
2739 : /// unicodeLetter *(letter/number/_). EXAMPLES 1-4 must pass.
2740 : #[test]
2741 2 : fn scope_grammar_accepts_the_examples() {
2742 8 : for s in [
2743 2 : "/Madrid",
2744 2 : "Madrid",
2745 2 : "/Madrid/Gardens/ParqueNorte",
2746 2 : "/CompanyA/OrganizationB/UnitC",
2747 2 : ] {
2748 8 : assert!(expand(with_scope(json!(s))).is_ok(), "{s} must be valid");
2749 : }
2750 2 : let out = expand(with_scope(json!(["/A", "B/C_2"]))).expect("multi scope");
2751 2 : assert_eq!(out["scope"], json!(["/A", "B/C_2"]));
2752 2 : }
2753 :
2754 : /// 4.18: levels start with a letter, carry only letters/digits/_, no
2755 : /// empty levels; "urn:ngsi-ld:null" "shall be only used and only appear
2756 : /// in case of deleted scopes" — never creatable.
2757 : #[test]
2758 2 : fn scope_grammar_rejects_malformed_values() {
2759 14 : for s in [
2760 2 : "9bad", // level starts with a digit
2761 2 : "/a//b", // empty level
2762 2 : "a-b", // '-' not a ScopeLevelChar
2763 2 : "/", // no level at all
2764 2 : "", // empty
2765 2 : "/a/b/", // trailing empty level
2766 2 : "a b", // space
2767 2 : ] {
2768 14 : assert!(
2769 14 : expand(with_scope(json!(s))).is_err(),
2770 : "{s:?} must be rejected"
2771 : );
2772 : }
2773 2 : assert!(
2774 2 : expand(with_scope(json!("urn:ngsi-ld:null"))).is_err(),
2775 : "the NGSI-LD Null scope is only for deletions, not creation"
2776 : );
2777 2 : assert!(
2778 2 : expand(with_scope(json!(["/ok", "9bad"]))).is_err(),
2779 : "one bad entry poisons the array"
2780 : );
2781 2 : }
2782 :
2783 : /// 5.5.12 deletes a member whose value IS an NGSI-LD Null, so on the
2784 : /// inputs that admit one the sentinel is the whole scope. Beside a real
2785 : /// scope it would be stored as one, and 4.18 spells no such scope.
2786 : #[test]
2787 2 : fn a_null_scope_is_the_whole_value_or_none_of_it() {
2788 6 : let merge = |s: serde_json::Value| {
2789 6 : expand_entity(
2790 6 : with_scope(s).as_object().expect("obj"),
2791 6 : &Loader::new().core(),
2792 6 : ExpandOpts {
2793 6 : allow_null: true,
2794 6 : ..ExpandOpts::default()
2795 6 : },
2796 : )
2797 6 : };
2798 2 : let out = merge(json!("urn:ngsi-ld:null")).expect("the deletion form");
2799 2 : assert_eq!(out["scope"], json!(["urn:ngsi-ld:null"]));
2800 2 : assert_eq!(
2801 2 : merge(json!(["urn:ngsi-ld:null"])).expect("array form")["scope"],
2802 2 : json!(["urn:ngsi-ld:null"])
2803 : );
2804 2 : assert!(
2805 2 : merge(json!(["/Madrid", "urn:ngsi-ld:null"])).is_err(),
2806 : "a deletion mixed with a scope is neither"
2807 : );
2808 2 : }
2809 : }
2810 :
2811 : #[cfg(test)]
2812 : mod clause_5_2_1 {
2813 : use super::*;
2814 : use crate::loader::Loader;
2815 : use serde_json::json;
2816 :
2817 6 : fn expand_create(doc: serde_json::Value) -> Result<Value, NgsiError> {
2818 6 : expand_entity(
2819 6 : doc.as_object().expect("obj"),
2820 6 : &Loader::new().core(),
2821 6 : ExpandOpts::default(), // create: allow_null = false
2822 : )
2823 6 : }
2824 :
2825 : /// 5.2.1: outside partial-update/merge inputs, "implementations shall
2826 : /// raise an error of type BadRequestData if an NGSI-LD Null value is
2827 : /// encountered" — Property value, Relationship object, sub-attribute.
2828 : #[test]
2829 2 : fn ngsi_ld_null_is_rejected_outside_merge_inputs() {
2830 6 : for doc in [
2831 2 : json!({"id": "urn:x", "type": "T",
2832 2 : "p": {"type": "Property", "value": "urn:ngsi-ld:null"}}),
2833 2 : json!({"id": "urn:x", "type": "T",
2834 2 : "r": {"type": "Relationship", "object": "urn:ngsi-ld:null"}}),
2835 2 : json!({"id": "urn:x", "type": "T",
2836 2 : "p": {"type": "Property", "value": 1,
2837 2 : "sub": {"type": "Property", "value": "urn:ngsi-ld:null"}}}),
2838 2 : ] {
2839 6 : let e = expand_create(doc).expect_err("NGSI-LD Null on create must be rejected");
2840 6 : assert!(
2841 6 : matches!(e, NgsiError::BadRequestData(_)),
2842 : "must be BadRequestData, got {e:?}"
2843 : );
2844 : }
2845 2 : }
2846 :
2847 : /// 5.2.1: the deletion marker stays legal on null-allowing inputs
2848 : /// (merge/partial-update, 5.5.8/5.5.12).
2849 : #[test]
2850 2 : fn ngsi_ld_null_survives_on_merge_inputs() {
2851 2 : let doc = json!({"p": {"type": "Property", "value": "urn:ngsi-ld:null"}});
2852 2 : let out = expand_entity(
2853 2 : doc.as_object().expect("obj"),
2854 2 : &Loader::new().core(),
2855 2 : ExpandOpts {
2856 2 : fragment: true,
2857 2 : allow_null: true,
2858 2 : ..ExpandOpts::default()
2859 2 : },
2860 : )
2861 2 : .expect("merge fragment expands");
2862 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
2863 2 : assert!(
2864 2 : is_deletion_instance(inst),
2865 : "the marker must remain recognizable: {inst}"
2866 : );
2867 2 : }
2868 :
2869 : /// 5.5.4: the marker is legal only in a Fragment used in a partial update
2870 : /// or merge. A temporal import allows nulls for 4.5.7 tombstones, but its
2871 : /// document is a whole Entity — an entity-level expiresAt carrying the
2872 : /// marker there is BadRequestData, not a stored lifetime of
2873 : /// "urn:ngsi-ld:null".
2874 : #[test]
2875 2 : fn the_entity_expires_at_marker_is_only_a_fragment_form() {
2876 2 : let doc = json!({
2877 2 : "id": "urn:ngsi-ld:Vehicle:1",
2878 2 : "type": "Vehicle",
2879 2 : "expiresAt": "urn:ngsi-ld:null",
2880 2 : "speed": [{"type": "Property", "value": 1}]
2881 : });
2882 2 : let e = expand_entity(
2883 2 : doc.as_object().expect("obj"),
2884 2 : &Loader::new().core(),
2885 2 : ExpandOpts {
2886 2 : allow_null: true,
2887 2 : temporal: true,
2888 2 : sys: true,
2889 2 : ..ExpandOpts::default()
2890 2 : },
2891 : )
2892 2 : .expect_err("a whole entity cannot ask for the removal");
2893 2 : assert!(matches!(e, NgsiError::BadRequestData(_)), "{e:?}");
2894 :
2895 : // the fragment form still asks for the removal
2896 2 : let frag = json!({"expiresAt": "urn:ngsi-ld:null"});
2897 2 : let out = expand_entity(
2898 2 : frag.as_object().expect("obj"),
2899 2 : &Loader::new().core(),
2900 2 : ExpandOpts {
2901 2 : fragment: true,
2902 2 : allow_null: true,
2903 2 : ..ExpandOpts::default()
2904 2 : },
2905 : )
2906 2 : .expect("merge fragment expands");
2907 2 : assert_eq!(out["expiresAt"], "urn:ngsi-ld:null");
2908 2 : }
2909 : }
2910 :
2911 : #[cfg(test)]
2912 : mod clause_5_2_4 {
2913 : use super::*;
2914 : use crate::loader::Loader;
2915 : use serde_json::json;
2916 :
2917 24 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
2918 24 : expand_entity(
2919 24 : doc.as_object().expect("obj"),
2920 24 : &Loader::new().core(),
2921 24 : ExpandOpts::default(),
2922 : )
2923 24 : }
2924 :
2925 : /// Table 5.2.4-1: id must be a valid URI; type accepts a short name, a
2926 : /// URI, or an array of either; expiresAt must be a 4.6.3 DateTime.
2927 : #[test]
2928 2 : fn entity_member_table_restrictions() {
2929 2 : assert!(expand(json!({"id": "not a uri", "type": "T"})).is_err());
2930 2 : assert!(expand(json!({"id": "urn:x", "type": "T"})).is_ok());
2931 2 : assert!(expand(json!({"id": "urn:x", "type": ["T", "https://ex.org/U"]})).is_ok());
2932 2 : assert!(expand(json!({"id": "urn:x", "type": 5})).is_err());
2933 2 : assert!(
2934 2 : expand(json!({"id": "urn:x", "type": "T", "expiresAt": "2020-01-01"})).is_err(),
2935 : "expiresAt must be a DateTime, not a Date"
2936 : );
2937 2 : assert!(
2938 2 : expand(json!({"id": "urn:x", "type": "T", "expiresAt": "2030-01-01T00:00:00Z"}))
2939 2 : .is_ok()
2940 : );
2941 2 : }
2942 :
2943 : /// Table 5.2.4-1: location/observationSpace/operationSpace are
2944 : /// GeoProperties (5.2.7) — a plain Property under those names is a
2945 : /// violation (4.7.1).
2946 : #[test]
2947 2 : fn default_geo_names_must_be_geoproperties() {
2948 6 : for name in ["location", "observationSpace", "operationSpace"] {
2949 6 : let doc = json!({"id": "urn:x", "type": "T",
2950 6 : name: {"type": "Property", "value": 3}});
2951 6 : assert!(expand(doc).is_err(), "{name} as a plain Property must 400");
2952 6 : let ok = json!({"id": "urn:x", "type": "T",
2953 6 : name: {"type": "GeoProperty",
2954 6 : "value": {"type": "Point", "coordinates": [8, 40]}}});
2955 6 : assert!(expand(ok).is_ok(), "{name} as a GeoProperty is fine");
2956 : }
2957 2 : }
2958 : }
2959 :
2960 : #[cfg(test)]
2961 : mod clause_5_2_5 {
2962 : use super::*;
2963 : use crate::loader::Loader;
2964 : use serde_json::json;
2965 :
2966 14 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
2967 14 : expand_entity(
2968 14 : doc.as_object().expect("obj"),
2969 14 : &Loader::new().core(),
2970 14 : ExpandOpts::default(),
2971 : )
2972 14 : }
2973 :
2974 14 : fn with_p(p: serde_json::Value) -> serde_json::Value {
2975 14 : json!({"id": "urn:x", "type": "T", "p": p})
2976 14 : }
2977 :
2978 : /// Table 5.2.5-1: value mandatory (any JSON value), datasetId a URI,
2979 : /// observedAt/expiresAt DateTimes, unitCode a string, sub-attributes
2980 : /// nest per their own tables.
2981 : #[test]
2982 2 : fn property_member_table_restrictions() {
2983 2 : assert!(
2984 2 : expand(with_p(json!({"type": "Property"}))).is_err(),
2985 : "value mandatory"
2986 : );
2987 2 : assert!(
2988 2 : expand(with_p(json!({"type": "Property", "value": {"k": [1, "x"]},
2989 2 : "datasetId": "urn:ds:1", "observedAt": "2020-09-09T16:40:00Z",
2990 2 : "unitCode": "CEL",
2991 2 : "sub": {"type": "Relationship", "object": "urn:o:1"}})))
2992 2 : .is_ok()
2993 : );
2994 2 : assert!(expand(with_p(json!({"type": "Property", "value": 1,
2995 2 : "datasetId": "not a uri"})))
2996 2 : .is_err());
2997 2 : assert!(expand(with_p(json!({"type": "Property", "value": 1,
2998 2 : "observedAt": "2020-09-09"})))
2999 2 : .is_err());
3000 2 : assert!(expand(with_p(json!({"type": "Property", "value": 1,
3001 2 : "unitCode": 7})))
3002 2 : .is_err());
3003 2 : }
3004 :
3005 : /// 5.2.5: in the concise representation type="Property" is inferred from
3006 : /// `value` — but a GeoJSON-object value "would be interpreted as a
3007 : /// GeoProperty" and so infers GeoProperty, not Property.
3008 : #[test]
3009 2 : fn concise_inference_and_the_geojson_value_carveout() {
3010 2 : let out = expand(with_p(json!({"value": 42}))).expect("concise Property");
3011 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
3012 2 : assert_eq!(inst["type"], "Property");
3013 2 : let out = expand(with_p(json!({"value":
3014 2 : {"type": "Point", "coordinates": [8, 40]}})))
3015 2 : .expect("concise geo value");
3016 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
3017 2 : assert_eq!(
3018 2 : inst["type"], "GeoProperty",
3019 : "a GeoJSON object value infers GeoProperty (5.2.5/5.2.7)"
3020 : );
3021 2 : }
3022 : }
3023 :
3024 : #[cfg(test)]
3025 : mod clause_5_2_6 {
3026 : use super::*;
3027 : use crate::loader::Loader;
3028 : use serde_json::json;
3029 :
3030 12 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3031 12 : expand_entity(
3032 12 : doc.as_object().expect("obj"),
3033 12 : &Loader::new().core(),
3034 12 : ExpandOpts::default(),
3035 : )
3036 12 : }
3037 :
3038 12 : fn with_r(r: serde_json::Value) -> serde_json::Value {
3039 12 : json!({"id": "urn:x", "type": "T", "r": r})
3040 12 : }
3041 :
3042 : /// Table 5.2.6-1: object mandatory — a URI or an array of URIs; datasetId
3043 : /// a URI; objectType coerced; concise inference from `object`; unitCode
3044 : /// prohibited (4.5.3.2).
3045 : #[test]
3046 2 : fn relationship_member_table_restrictions() {
3047 2 : assert!(
3048 2 : expand(with_r(json!({"type": "Relationship"}))).is_err(),
3049 : "object mandatory"
3050 : );
3051 2 : assert!(expand(with_r(
3052 2 : json!({"type": "Relationship", "object": "not a uri"})
3053 2 : ))
3054 2 : .is_err());
3055 2 : assert!(
3056 2 : expand(with_r(json!({"type": "Relationship",
3057 2 : "object": ["urn:a", "urn:b"], "datasetId": "urn:ds:1",
3058 2 : "objectType": "Device"})))
3059 2 : .is_ok(),
3060 : "array of URIs is legal"
3061 : );
3062 2 : assert!(
3063 2 : expand(with_r(json!({"type": "Relationship",
3064 2 : "object": ["urn:a", "not a uri"]})))
3065 2 : .is_err(),
3066 : "every array entry must be a URI"
3067 : );
3068 2 : assert!(
3069 2 : expand(with_r(json!({"type": "Relationship", "object": "urn:a",
3070 2 : "unitCode": "C62"})))
3071 2 : .is_err(),
3072 : "Relationships are unitless"
3073 : );
3074 : // concise inference from the object member
3075 2 : let out = expand(with_r(json!({"object": "urn:o:1"}))).expect("concise");
3076 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/r"][0];
3077 2 : assert_eq!(inst["type"], "Relationship");
3078 2 : }
3079 : }
3080 :
3081 : #[cfg(test)]
3082 : mod clause_5_2_32 {
3083 : use super::*;
3084 : use crate::loader::Loader;
3085 : use serde_json::json;
3086 :
3087 14 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3088 14 : expand_entity(
3089 14 : doc.as_object().expect("obj"),
3090 14 : &Loader::new().core(),
3091 14 : ExpandOpts::default(),
3092 : )
3093 14 : }
3094 :
3095 14 : fn with_lp(lp: serde_json::Value) -> serde_json::Value {
3096 14 : json!({"id": "urn:x", "type": "T", "greeting": lp})
3097 14 : }
3098 :
3099 : /// Table 5.2.32-1: languageMap keys are non-empty language tags mapping
3100 : /// to strings or string arrays; valueType, when present, shall be equal
3101 : /// to "langString"; datasetId is a URI; observedAt a DateTime.
3102 : #[test]
3103 2 : fn language_property_member_table_restrictions() {
3104 2 : let ok = expand(with_lp(json!({"type": "LanguageProperty",
3105 2 : "languageMap": {"en": "hello", "sk": ["ahoj", "servus"]},
3106 2 : "valueType": "langString"})))
3107 2 : .expect("conformant LanguageProperty");
3108 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/greeting"][0];
3109 2 : assert_eq!(attr["valueType"], "langString");
3110 2 : assert!(attr.get("value").is_none(), "languageMap, not value");
3111 2 : assert!(
3112 2 : expand(with_lp(json!({"type": "LanguageProperty",
3113 2 : "languageMap": {"en": "x"}, "valueType": "xsd:string"})))
3114 2 : .is_err(),
3115 : "valueType shall be equal to langString"
3116 : );
3117 2 : assert!(
3118 2 : expand(with_lp(json!({"type": "LanguageProperty",
3119 2 : "languageMap": {"en": 5}})))
3120 2 : .is_err(),
3121 : "languageMap values are strings or string arrays"
3122 : );
3123 2 : assert!(
3124 2 : expand(with_lp(json!({"type": "LanguageProperty",
3125 2 : "languageMap": {"": "x"}})))
3126 2 : .is_err(),
3127 : "empty language tag"
3128 : );
3129 2 : assert!(
3130 2 : expand(with_lp(json!({"type": "LanguageProperty",
3131 2 : "languageMap": {"en": ["a", 5]}})))
3132 2 : .is_err(),
3133 : "array entries must all be strings"
3134 : );
3135 2 : assert!(
3136 2 : expand(with_lp(json!({"type": "LanguageProperty"}))).is_err(),
3137 : "languageMap is mandatory"
3138 : );
3139 2 : assert!(
3140 2 : expand(with_lp(json!({"type": "LanguageProperty",
3141 2 : "languageMap": {"en": "x"}, "observedAt": "not-a-date"})))
3142 2 : .is_err(),
3143 : "observedAt must be a DateTime"
3144 : );
3145 2 : }
3146 : }
3147 :
3148 : #[cfg(test)]
3149 : mod clause_5_2_35 {
3150 : use super::*;
3151 : use crate::loader::Loader;
3152 : use serde_json::json;
3153 :
3154 14 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3155 14 : expand_entity(
3156 14 : doc.as_object().expect("obj"),
3157 14 : &Loader::new().core(),
3158 14 : ExpandOpts::default(),
3159 : )
3160 14 : }
3161 :
3162 14 : fn with_vp(vp: serde_json::Value) -> serde_json::Value {
3163 14 : json!({"id": "urn:x", "type": "T", "category": vp})
3164 14 : }
3165 :
3166 : /// Table 5.2.35-1: vocab is a String or String[] type-coerced to URIs
3167 : /// under the @context; unitCode is prohibited (4.5.20.2); concise form
3168 : /// infers VocabProperty from the vocab member.
3169 : #[test]
3170 2 : fn vocab_property_member_table_restrictions() {
3171 2 : let ok = expand(with_vp(json!({"type": "VocabProperty", "vocab": "term"})))
3172 2 : .expect("conformant VocabProperty");
3173 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
3174 2 : assert_eq!(
3175 2 : attr["vocab"], "https://uri.etsi.org/ngsi-ld/default-context/term",
3176 : "vocab is term-expanded"
3177 : );
3178 2 : assert!(attr.get("value").is_none(), "vocab, not value");
3179 2 : let ok = expand(with_vp(
3180 2 : json!({"type": "VocabProperty", "vocab": ["a", "b"]}),
3181 : ))
3182 2 : .expect("string[] form");
3183 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
3184 2 : assert_eq!(attr["vocab"].as_array().map(Vec::len), Some(2));
3185 2 : assert!(
3186 2 : expand(with_vp(json!({"type": "VocabProperty", "vocab": ["a", 5]}))).is_err(),
3187 : "vocab array entries must be strings"
3188 : );
3189 2 : assert!(
3190 2 : expand(with_vp(json!({"type": "VocabProperty", "vocab": 5}))).is_err(),
3191 : "vocab must be a string or string array"
3192 : );
3193 2 : assert!(
3194 2 : expand(with_vp(json!({"type": "VocabProperty"}))).is_err(),
3195 : "vocab is mandatory"
3196 : );
3197 2 : assert!(
3198 2 : expand(with_vp(
3199 2 : json!({"type": "VocabProperty", "vocab": "t", "unitCode": "C"})
3200 2 : ))
3201 2 : .is_err(),
3202 : "unitCode prohibited (4.5.20.2)"
3203 : );
3204 : // concise: the vocab member alone infers VocabProperty
3205 2 : let ok = expand(with_vp(json!({"vocab": "term"}))).expect("concise inference");
3206 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
3207 2 : assert_eq!(attr["type"], "VocabProperty");
3208 2 : }
3209 : }
3210 :
3211 : #[cfg(test)]
3212 : mod clause_5_2_36 {
3213 : use super::*;
3214 : use crate::loader::Loader;
3215 : use serde_json::json;
3216 :
3217 8 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3218 8 : expand_entity(
3219 8 : doc.as_object().expect("obj"),
3220 8 : &Loader::new().core(),
3221 8 : ExpandOpts::default(),
3222 : )
3223 8 : }
3224 :
3225 8 : fn with_list(lp: serde_json::Value) -> serde_json::Value {
3226 8 : json!({"id": "urn:x", "type": "T", "readings": lp})
3227 8 : }
3228 :
3229 : /// Table 5.2.36-1: valueList is a mandatory ordered array of JSON
3230 : /// values; concise form infers ListProperty from the valueList member.
3231 : #[test]
3232 2 : fn list_property_member_table_restrictions() {
3233 2 : let ok = expand(with_list(json!({"type": "ListProperty",
3234 2 : "valueList": [1, "a", {"o": 2}]})))
3235 2 : .expect("conformant ListProperty");
3236 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/readings"][0];
3237 2 : assert_eq!(attr["valueList"], json!([1, "a", {"o": 2}]), "order kept");
3238 2 : assert!(attr.get("value").is_none(), "valueList, not value");
3239 2 : assert!(
3240 2 : expand(with_list(json!({"type": "ListProperty", "valueList": 5}))).is_err(),
3241 : "valueList must be an array"
3242 : );
3243 2 : assert!(
3244 2 : expand(with_list(json!({"type": "ListProperty"}))).is_err(),
3245 : "valueList is mandatory"
3246 : );
3247 2 : let ok = expand(with_list(json!({"valueList": [1, 2]}))).expect("concise inference");
3248 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/readings"][0];
3249 2 : assert_eq!(attr["type"], "ListProperty");
3250 2 : }
3251 : }
3252 :
3253 : #[cfg(test)]
3254 : mod clause_5_2_37 {
3255 : use super::*;
3256 : use crate::loader::Loader;
3257 : use serde_json::json;
3258 :
3259 14 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3260 14 : expand_entity(
3261 14 : doc.as_object().expect("obj"),
3262 14 : &Loader::new().core(),
3263 14 : ExpandOpts::default(),
3264 : )
3265 14 : }
3266 :
3267 14 : fn with_lr(lr: serde_json::Value) -> serde_json::Value {
3268 14 : json!({"id": "urn:x", "type": "T", "route" : lr})
3269 14 : }
3270 :
3271 : /// Table 5.2.37-1: objectList is a mandatory array of URIs — accepted
3272 : /// both as bare URI strings and as {"object": URI} entries (4.5.22.2);
3273 : /// invalid URIs rejected; unitCode prohibited; concise form infers
3274 : /// ListRelationship from the objectList member.
3275 : #[test]
3276 2 : fn list_relationship_member_table_restrictions() {
3277 4 : for form in [
3278 2 : json!(["urn:a", "urn:b"]),
3279 2 : json!([{"object": "urn:a"}, {"object": "urn:b"}]),
3280 2 : ] {
3281 4 : let ok = expand(with_lr(
3282 4 : json!({"type": "ListRelationship", "objectList": form}),
3283 : ))
3284 4 : .expect("conformant ListRelationship");
3285 4 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/route"][0];
3286 4 : let list = attr["objectList"].as_array().expect("objectList");
3287 4 : assert_eq!(list.len(), 2);
3288 4 : assert!(attr.get("object").is_none(), "objectList, not object");
3289 : }
3290 2 : assert!(
3291 2 : expand(with_lr(
3292 2 : json!({"type": "ListRelationship", "objectList": ["not a uri"]})
3293 2 : ))
3294 2 : .is_err(),
3295 : "objectList entries must be URIs"
3296 : );
3297 2 : assert!(
3298 2 : expand(with_lr(
3299 2 : json!({"type": "ListRelationship", "objectList": "urn:a"})
3300 2 : ))
3301 2 : .is_err(),
3302 : "objectList must be an array"
3303 : );
3304 2 : assert!(
3305 2 : expand(with_lr(json!({"type": "ListRelationship"}))).is_err(),
3306 : "objectList is mandatory"
3307 : );
3308 2 : assert!(
3309 2 : expand(with_lr(
3310 2 : json!({"type": "ListRelationship", "objectList": ["urn:a"],
3311 2 : "unitCode": "C"})
3312 2 : ))
3313 2 : .is_err(),
3314 : "unitCode prohibited on a ListRelationship"
3315 : );
3316 2 : let ok = expand(with_lr(json!({"objectList": ["urn:a"]}))).expect("concise inference");
3317 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/route"][0];
3318 2 : assert_eq!(attr["type"], "ListRelationship");
3319 2 : }
3320 : }
3321 :
3322 : #[cfg(test)]
3323 : mod clause_5_2_38 {
3324 : use super::*;
3325 : use crate::loader::Loader;
3326 : use serde_json::json;
3327 :
3328 14 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3329 14 : expand_entity(
3330 14 : doc.as_object().expect("obj"),
3331 14 : &Loader::new().core(),
3332 14 : ExpandOpts::default(),
3333 : )
3334 14 : }
3335 :
3336 14 : fn with_jp(jp: serde_json::Value) -> serde_json::Value {
3337 14 : json!({"id": "urn:x", "type": "T", "payload": jp})
3338 14 : }
3339 :
3340 : /// Table 5.2.38-1: json is a mandatory raw JSON object or array of
3341 : /// objects, never expanded; unitCode prohibited (4.5.24.2); concise form
3342 : /// infers JsonProperty from the json member.
3343 : #[test]
3344 2 : fn json_property_member_table_restrictions() {
3345 2 : let ok = expand(with_jp(json!({"type": "JsonProperty",
3346 2 : "json": {"type": "kept-verbatim", "en": 1}})))
3347 2 : .expect("conformant JsonProperty");
3348 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
3349 2 : assert_eq!(
3350 2 : attr["json"],
3351 2 : json!({"type": "kept-verbatim", "en": 1}),
3352 : "raw JSON kept verbatim, no expansion"
3353 : );
3354 2 : assert!(attr.get("value").is_none(), "json, not value");
3355 2 : let ok = expand(with_jp(
3356 2 : json!({"type": "JsonProperty", "json": [{"a": 1}, {"b": 2}]}),
3357 : ))
3358 2 : .expect("array-of-objects form");
3359 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
3360 2 : assert_eq!(attr["json"].as_array().map(Vec::len), Some(2));
3361 2 : assert!(
3362 2 : expand(with_jp(json!({"type": "JsonProperty", "json": 5}))).is_err(),
3363 : "json must be an object or array of objects"
3364 : );
3365 2 : assert!(
3366 2 : expand(with_jp(json!({"type": "JsonProperty", "json": [1, 2]}))).is_err(),
3367 : "array entries must be objects"
3368 : );
3369 2 : assert!(
3370 2 : expand(with_jp(json!({"type": "JsonProperty"}))).is_err(),
3371 : "json is mandatory"
3372 : );
3373 2 : assert!(
3374 2 : expand(with_jp(
3375 2 : json!({"type": "JsonProperty", "json": {"a": 1}, "unitCode": "C"})
3376 2 : ))
3377 2 : .is_err(),
3378 : "unitCode prohibited on a JsonProperty"
3379 : );
3380 2 : let ok = expand(with_jp(json!({"json": {"a": 1}}))).expect("concise inference");
3381 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
3382 2 : assert_eq!(attr["type"], "JsonProperty");
3383 2 : }
3384 : }
3385 :
3386 : #[cfg(test)]
3387 : mod clause_5_5_4 {
3388 : use super::*;
3389 : use crate::loader::Loader;
3390 : use serde_json::json;
3391 :
3392 12 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3393 12 : expand_entity(
3394 12 : doc.as_object().expect("obj"),
3395 12 : &Loader::new().core(),
3396 12 : ExpandOpts::default(),
3397 : )
3398 12 : }
3399 :
3400 : /// 5.5.4: outside fragments/notifications, "urn:ngsi-ld:null" is
3401 : /// BadRequestData as a first-level member value, as a Property value /
3402 : /// Relationship object, as the languageMap {"@none": null} form, AND as
3403 : /// a key value inside a JSON object that is a Property value.
3404 : #[test]
3405 2 : fn ngsi_null_rejected_everywhere_on_create() {
3406 10 : let with = |attr: serde_json::Value| json!({"id": "urn:x", "type": "T", "a": attr});
3407 2 : assert!(
3408 2 : expand(json!({"id": "urn:x", "type": "T", "scope": "urn:ngsi-ld:null"})).is_err(),
3409 : "first-level member value"
3410 : );
3411 2 : assert!(
3412 2 : expand(with(
3413 2 : json!({"type": "Property", "value": "urn:ngsi-ld:null"})
3414 2 : ))
3415 2 : .is_err(),
3416 : "Property value"
3417 : );
3418 2 : assert!(
3419 2 : expand(with(
3420 2 : json!({"type": "Relationship", "object": "urn:ngsi-ld:null"})
3421 2 : ))
3422 2 : .is_err(),
3423 : "Relationship object"
3424 : );
3425 2 : assert!(
3426 2 : expand(with(json!({"type": "LanguageProperty",
3427 2 : "languageMap": {"@none": "urn:ngsi-ld:null"}})))
3428 2 : .is_err(),
3429 : "languageMap null form"
3430 : );
3431 2 : assert!(
3432 2 : expand(with(json!({"type": "Property",
3433 2 : "value": {"nested": "urn:ngsi-ld:null"}})))
3434 2 : .is_err(),
3435 : "null inside a compound Property value"
3436 : );
3437 : // control: an ordinary compound value stays creatable
3438 2 : assert!(expand(with(json!({"type": "Property", "value": {"nested": 1}}))).is_ok());
3439 2 : }
3440 : }
3441 :
3442 : #[cfg(test)]
3443 : mod clause_5_2_7 {
3444 : use super::*;
3445 : use crate::loader::Loader;
3446 : use serde_json::json;
3447 :
3448 10 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3449 10 : expand_entity(
3450 10 : doc.as_object().expect("obj"),
3451 10 : &Loader::new().core(),
3452 10 : ExpandOpts::default(),
3453 : )
3454 10 : }
3455 :
3456 10 : fn with_g(g: serde_json::Value) -> serde_json::Value {
3457 10 : json!({"id": "urn:x", "type": "T", "g": g})
3458 10 : }
3459 :
3460 : /// Table 5.2.7-1: value must be a 4.7 GeoJSON geometry object (a plain
3461 : /// number/string is a violation), GeometryCollection excluded (4.6.3);
3462 : /// unitCode prohibited — GeoProperties carry coordinates, not units.
3463 : #[test]
3464 2 : fn geoproperty_member_table_restrictions() {
3465 2 : assert!(expand(with_g(json!({"type": "GeoProperty", "value": 5}))).is_err());
3466 2 : assert!(expand(with_g(json!({"type": "GeoProperty",
3467 2 : "value": {"type": "Nonsense", "coordinates": [1, 2]}})))
3468 2 : .is_err());
3469 2 : assert!(expand(with_g(json!({"type": "GeoProperty",
3470 2 : "value": {"type": "GeometryCollection", "geometries": []}})))
3471 2 : .is_err());
3472 2 : assert!(expand(with_g(json!({"type": "GeoProperty",
3473 2 : "value": {"type": "Point", "coordinates": [8, 40]},
3474 2 : "datasetId": "urn:ds:1"})))
3475 2 : .is_ok());
3476 2 : assert!(expand(with_g(json!({"type": "GeoProperty",
3477 2 : "value": {"type": "LineString",
3478 2 : "coordinates": [[8, 40], [9, 41]]}})))
3479 2 : .is_ok());
3480 2 : }
3481 : }
3482 :
3483 : #[cfg(test)]
3484 : mod reserved_member_guards {
3485 : use super::*;
3486 : use crate::loader::Loader;
3487 : use serde_json::json;
3488 :
3489 : /// 4.5.1: an Attribute name is expanded against the @context, and the
3490 : /// user @context is merged BEFORE the core one — so a term whose "@id"
3491 : /// is a bare word stays a RELATIVE IRI. Expanding an attribute onto
3492 : /// "id" must not be allowed to replace the Entity id (5.5.4
3493 : /// BadRequestData), the same rule expand_types applies to type names.
3494 : #[tokio::test]
3495 2 : async fn attribute_name_must_expand_to_an_absolute_iri() {
3496 2 : let ctx = Loader::new()
3497 2 : .resolve_quiet(&json!({"hostile": {"@id": "id"}}))
3498 2 : .await
3499 2 : .expect("inline @context");
3500 2 : assert_eq!(ctx.expand_key("hostile"), "id", "term maps to a bare word");
3501 :
3502 2 : let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "T",
3503 2 : "hostile": {"type": "Property", "value": 1}});
3504 2 : let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default());
3505 2 : assert!(
3506 2 : out.is_err(),
3507 : "an attribute name that does not expand to an absolute IRI is BadRequestData, got {out:?}"
3508 : );
3509 : // negative: the Entity id must still be its own URI string — never
3510 : // the attribute's instance array.
3511 2 : if let Ok(v) = &out {
3512 2 : assert_eq!(v["id"], "urn:ngsi-ld:Vehicle:1");
3513 2 : }
3514 2 : }
3515 :
3516 : /// 4.5.1/5.5.4: the same at sub-attribute level — a term expanding onto
3517 : /// "observedAt" would replace the validated DateTime with an instance
3518 : /// array, bypassing the 4.6.3 DateTime check.
3519 : #[tokio::test]
3520 2 : async fn sub_attribute_name_must_not_overwrite_observed_at() {
3521 2 : let ctx = Loader::new()
3522 2 : .resolve_quiet(&json!({"hostile": {"@id": "observedAt"}}))
3523 2 : .await
3524 2 : .expect("inline @context");
3525 2 : assert_eq!(ctx.expand_key("hostile"), "observedAt");
3526 :
3527 2 : let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "T",
3528 2 : "speed": {"type": "Property", "value": 1,
3529 2 : "observedAt": "2026-01-01T00:00:00Z",
3530 2 : "hostile": {"type": "Property", "value": "x"}}});
3531 2 : let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default());
3532 2 : assert!(out.is_err(), "expected BadRequestData, got {out:?}");
3533 :
3534 : // negative: with a well-behaved @context the sub-attribute lands on
3535 : // its own IRI and observedAt still holds the DateTime string.
3536 2 : let ctx = Loader::new()
3537 2 : .resolve_quiet(&json!({"hostile": {"@id": "https://example.org/hostile"}}))
3538 2 : .await
3539 2 : .expect("inline @context");
3540 2 : let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default())
3541 2 : .expect("absolute IRI is fine");
3542 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
3543 2 : assert_eq!(inst["observedAt"], "2026-01-01T00:00:00Z");
3544 2 : assert!(inst["https://example.org/hostile"].is_array());
3545 2 : }
3546 :
3547 : /// 4.5.5.1/5.5.8: "datasetId" is a URI string in a partial-update
3548 : /// fragment exactly as in a full instance — a non-string one is copied
3549 : /// onto the target instance and hides its default slot from every
3550 : /// datasetId-absent lookup.
3551 : #[test]
3552 2 : fn fragment_dataset_id_must_be_a_uri_string() {
3553 10 : for bad in [
3554 2 : json!(42),
3555 2 : json!(["urn:ngsi-ld:Dataset:a"]),
3556 2 : json!({"object": "urn:ngsi-ld:Dataset:a"}),
3557 2 : json!(true),
3558 2 : json!("not a uri"),
3559 2 : ] {
3560 10 : let frag = json!({"type": "Property", "value": 1, "datasetId": bad});
3561 10 : let out = expand_attr_fragment(frag.as_object().expect("obj"), &core());
3562 10 : assert!(
3563 10 : out.is_err(),
3564 : "datasetId {bad} must be rejected, got {out:?}"
3565 : );
3566 : // negative: no non-string datasetId ever reaches the output.
3567 0 : if let Ok(Value::Object(m)) = &out {
3568 0 : assert!(m.get("datasetId").is_none_or(Value::is_string));
3569 10 : }
3570 : }
3571 2 : let frag = json!({"type": "Property", "value": 1, "datasetId": "urn:ngsi-ld:Dataset:a"});
3572 2 : let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
3573 2 : .expect("a URI datasetId is valid");
3574 2 : assert_eq!(out["datasetId"], "urn:ngsi-ld:Dataset:a");
3575 2 : }
3576 :
3577 : /// 5.2.1: "In all other cases, implementations shall raise an error of
3578 : /// type BadRequestData if an NGSI-LD Null value is encountered" — the
3579 : /// concise forms (a bare value, a bare object value) must not be a way
3580 : /// around it, or a plain append deletes the instance it targets.
3581 : #[test]
3582 2 : fn concise_values_do_not_smuggle_the_ngsi_null() {
3583 8 : let create = |attr: serde_json::Value| -> Result<Value, NgsiError> {
3584 8 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T", "a": attr});
3585 8 : expand_entity(
3586 8 : doc.as_object().expect("obj"),
3587 8 : &core(),
3588 8 : ExpandOpts::default(),
3589 : )
3590 8 : };
3591 8 : for attr in [
3592 2 : json!(["urn:ngsi-ld:null"]),
3593 2 : json!({"foo": "urn:ngsi-ld:null"}),
3594 2 : json!({"type": "Property", "value": "urn:ngsi-ld:null"}),
3595 2 : json!({"value": ["urn:ngsi-ld:null"]}),
3596 2 : ] {
3597 8 : let out = create(attr.clone());
3598 8 : assert!(out.is_err(), "{attr} must be BadRequestData, got {out:?}");
3599 : }
3600 : // negative: the same documents stay legal on a merge fragment
3601 : // (5.5.12), and a create with no sentinel keeps its value intact.
3602 2 : let doc = json!({"a": {"foo": "urn:ngsi-ld:null"}});
3603 2 : assert!(expand_entity(
3604 2 : doc.as_object().expect("obj"),
3605 2 : &core(),
3606 2 : ExpandOpts {
3607 2 : fragment: true,
3608 2 : allow_null: true,
3609 2 : merge: true,
3610 2 : ..Default::default()
3611 2 : }
3612 2 : )
3613 2 : .is_ok());
3614 2 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T", "a": {"foo": "bar"}});
3615 2 : let out = expand_entity(
3616 2 : doc.as_object().expect("obj"),
3617 2 : &core(),
3618 2 : ExpandOpts::default(),
3619 : )
3620 2 : .expect("plain compound value");
3621 2 : assert_eq!(
3622 2 : out["https://uri.etsi.org/ngsi-ld/default-context/a"][0]["value"]["foo"],
3623 : "bar"
3624 : );
3625 2 : }
3626 :
3627 : /// 5.2.5 Table 5.2.5-2 preamble: the output-only members "shall not be
3628 : /// provided by Context Producers. In the event that they are provided (in
3629 : /// update or create operations) NGSI-LD implementations shall ignore
3630 : /// them." 4.5.2.2 Prohibited adds "shall never include" for entity,
3631 : /// entityList and the previous* family, and entityIdSealed/
3632 : /// entityTypeSealed "unless the Property name is ngsildproof".
3633 : #[test]
3634 2 : fn fragment_ignores_output_only_and_prohibited_members() {
3635 2 : let frag = json!({
3636 2 : "type": "Property",
3637 2 : "value": 5,
3638 2 : "previousValue": 999,
3639 2 : "previousObject": "urn:ngsi-ld:Other:1",
3640 2 : "previousLanguageMap": {"en": "x"},
3641 2 : "previousJson": {"a": 1},
3642 2 : "previousVocab": "x",
3643 2 : "previousValueList": [1],
3644 2 : "previousObjectList": ["urn:ngsi-ld:Other:1"],
3645 2 : "entity": {"id": "urn:evil", "type": "T"},
3646 2 : "entityList": [{"id": "urn:evil", "type": "T"}],
3647 2 : "entityIdSealed": "urn:evil",
3648 2 : "entityTypeSealed": "T",
3649 2 : "deletedAt": "2026-01-01T00:00:00Z",
3650 : });
3651 2 : let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
3652 2 : .expect("the ignored members must not make the fragment invalid");
3653 2 : let m = out.as_object().expect("object");
3654 24 : for k in [
3655 2 : "previousValue",
3656 2 : "previousObject",
3657 2 : "previousLanguageMap",
3658 2 : "previousJson",
3659 2 : "previousVocab",
3660 2 : "previousValueList",
3661 2 : "previousObjectList",
3662 2 : "entity",
3663 2 : "entityList",
3664 2 : "entityIdSealed",
3665 2 : "entityTypeSealed",
3666 2 : "deletedAt",
3667 2 : ] {
3668 24 : assert!(
3669 24 : !m.contains_key(k),
3670 : "{k} must be ignored on input, got {out:#}"
3671 : );
3672 : }
3673 : // negative: the members the fragment IS allowed to carry survive.
3674 2 : assert_eq!(m["value"], 5);
3675 2 : assert_eq!(m["type"], "Property");
3676 2 : }
3677 :
3678 : /// 4.6.3/4.22: expiresAt is an ISO 8601 DateTime in a partial-update
3679 : /// fragment exactly as in a full instance — an unparsable one must not
3680 : /// reach the transient-entity boundary check.
3681 : #[test]
3682 2 : fn fragment_expires_at_is_a_datetime() {
3683 2 : let frag = json!({"type": "Property", "value": 1, "expiresAt": "soon"});
3684 2 : let out = expand_attr_fragment(frag.as_object().expect("obj"), &core());
3685 2 : assert!(
3686 2 : matches!(out, Err(NgsiError::BadRequestData(_))),
3687 : "an invalid expiresAt is BadRequestData, got {out:?}"
3688 : );
3689 : // negative: no unvalidated expiresAt ever reaches the output.
3690 0 : if let Ok(Value::Object(m)) = &out {
3691 0 : assert!(m.get("expiresAt").is_none());
3692 2 : }
3693 2 : let frag = json!({"type": "Property", "value": 1, "expiresAt": "2026-01-01T00:00:00Z"});
3694 2 : let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
3695 2 : .expect("a valid DateTime is kept");
3696 2 : assert_eq!(out["expiresAt"], "2026-01-01T00:00:00Z");
3697 2 : }
3698 :
3699 : /// 4.5.1: "Terms defined in the Core Context as non-reified Properties
3700 : /// (such as datasetId, instanceId, etc.) shall not be used as Attribute
3701 : /// names." createdAt/modifiedAt/deletedAt/expiresAt/scope map 1:1 onto
3702 : /// their core IRI, so the fully-qualified spelling would compact straight
3703 : /// back onto the Entity's own system member.
3704 : #[test]
3705 2 : fn core_system_member_iris_cannot_be_attribute_names() {
3706 10 : for term in ["createdAt", "modifiedAt", "deletedAt", "expiresAt", "scope"] {
3707 10 : let mut doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T"});
3708 10 : doc.as_object_mut().expect("obj").insert(
3709 10 : format!("https://uri.etsi.org/ngsi-ld/{term}"),
3710 10 : json!({"type": "Property", "value": "pwned"}),
3711 : );
3712 10 : let out = expand_entity(
3713 10 : doc.as_object().expect("obj"),
3714 10 : &core(),
3715 10 : ExpandOpts::default(),
3716 : );
3717 10 : assert!(
3718 10 : matches!(out, Err(NgsiError::BadRequestData(_))),
3719 : "{term} as an Attribute name is BadRequestData, got {out:?}"
3720 : );
3721 : // negative: the poisoned value never reaches the expanded entity.
3722 10 : if let Ok(v) = &out {
3723 0 : assert_ne!(v[term], "pwned");
3724 10 : }
3725 : }
3726 : // negative: the system members themselves still expand normally.
3727 2 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
3728 2 : "expiresAt": "2026-01-01T00:00:00Z", "scope": "/a"});
3729 2 : let out = expand_entity(
3730 2 : doc.as_object().expect("obj"),
3731 2 : &core(),
3732 2 : ExpandOpts::default(),
3733 : )
3734 2 : .expect("plain system members");
3735 2 : assert_eq!(out["expiresAt"], "2026-01-01T00:00:00Z");
3736 2 : }
3737 :
3738 : /// Table 5.2.6-1: objectType is "String or String[]" and "Both short hand
3739 : /// string(s) (type name) or URI(s) are allowed" — both shapes are
3740 : /// @vocab-coerced, so the two spellings of one target type cannot be
3741 : /// stored differently.
3742 : #[test]
3743 2 : fn object_type_expands_in_both_string_and_array_form() {
3744 2 : let expanded = "https://uri.etsi.org/ngsi-ld/default-context/Device";
3745 10 : let rel = |ot: serde_json::Value| -> Result<Value, NgsiError> {
3746 10 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
3747 10 : "r": {"type": "Relationship", "object": "urn:ngsi-ld:D:1", "objectType": ot}});
3748 10 : expand_entity(
3749 10 : doc.as_object().expect("obj"),
3750 10 : &core(),
3751 10 : ExpandOpts::default(),
3752 : )
3753 10 : };
3754 2 : let scalar = rel(json!("Device")).expect("scalar objectType");
3755 2 : let array = rel(json!(["Device"])).expect("array objectType");
3756 6 : let at = |v: &Value| {
3757 6 : v["https://uri.etsi.org/ngsi-ld/default-context/r"][0]["objectType"].clone()
3758 6 : };
3759 2 : assert_eq!(at(&scalar), json!(expanded));
3760 2 : assert_eq!(at(&array), json!([expanded]));
3761 : // negative: the bare term must never survive unexpanded.
3762 2 : assert_ne!(at(&array), json!(["Device"]));
3763 6 : for bad in [json!(42), json!({"a": 1}), json!([1])] {
3764 6 : let out = rel(bad.clone());
3765 6 : assert!(
3766 6 : matches!(out, Err(NgsiError::BadRequestData(_))),
3767 : "objectType {bad} must be rejected, got {out:?}"
3768 : );
3769 : }
3770 2 : }
3771 :
3772 : /// 4.6.3/Table 5.2.7-1: a GeoProperty value is a clause 4.7 GeoJSON
3773 : /// geometry, whose "coordinates" is an array — a scalar or object one is
3774 : /// not a geometry and must not reach the GeoJSON rendering path.
3775 : #[test]
3776 2 : fn geojson_coordinates_must_be_an_array() {
3777 8 : for bad in [json!("boom"), json!(42), json!({"lat": 1}), json!(null)] {
3778 8 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
3779 8 : "location": {"type": "GeoProperty",
3780 8 : "value": {"type": "Point", "coordinates": bad}}});
3781 8 : let out = expand_entity(
3782 8 : doc.as_object().expect("obj"),
3783 8 : &core(),
3784 8 : ExpandOpts::default(),
3785 : );
3786 8 : assert!(
3787 8 : matches!(out, Err(NgsiError::BadRequestData(_))),
3788 : "coordinates {bad} must be rejected, got {out:?}"
3789 : );
3790 : }
3791 : // negative: a real geometry still passes untouched.
3792 2 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
3793 2 : "location": {"type": "GeoProperty",
3794 2 : "value": {"type": "Point", "coordinates": [1.0, 2.0]}}});
3795 2 : let out = expand_entity(
3796 2 : doc.as_object().expect("obj"),
3797 2 : &core(),
3798 2 : ExpandOpts::default(),
3799 : )
3800 2 : .expect("valid Point");
3801 2 : assert_eq!(
3802 2 : out["https://uri.etsi.org/ngsi-ld/location"][0]["value"]["coordinates"],
3803 2 : json!([1.0, 2.0])
3804 : );
3805 2 : }
3806 :
3807 : /// 4.5.5.1: "There can only be one default Attribute instance for an
3808 : /// Attribute with a given Attribute name in any request or response" — a
3809 : /// term and its own expanded IRI are one Attribute name, so accepting
3810 : /// both would silently discard one client's data.
3811 : #[test]
3812 2 : fn two_names_expanding_to_one_iri_are_rejected() {
3813 2 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
3814 2 : "temperature": {"type": "Property", "value": 1},
3815 2 : "https://uri.etsi.org/ngsi-ld/default-context/temperature":
3816 2 : {"type": "Property", "value": 2}});
3817 2 : let out = expand_entity(
3818 2 : doc.as_object().expect("obj"),
3819 2 : &core(),
3820 2 : ExpandOpts::default(),
3821 : );
3822 2 : assert!(
3823 2 : matches!(out, Err(NgsiError::BadRequestData(_))),
3824 : "duplicate expanded Attribute name is BadRequestData, got {out:?}"
3825 : );
3826 : // negative: neither value may survive alone as the single instance.
3827 2 : if let Ok(v) = &out {
3828 0 : assert!(
3829 0 : v["https://uri.etsi.org/ngsi-ld/default-context/temperature"]
3830 0 : .as_array()
3831 0 : .is_none_or(|a| a.len() != 1)
3832 : );
3833 2 : }
3834 : // the same collision one level down, on sub-attributes.
3835 2 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
3836 2 : "speed": {"type": "Property", "value": 1,
3837 2 : "accuracy": {"type": "Property", "value": 1},
3838 2 : "https://uri.etsi.org/ngsi-ld/default-context/accuracy":
3839 2 : {"type": "Property", "value": 2}}});
3840 2 : let out = expand_entity(
3841 2 : doc.as_object().expect("obj"),
3842 2 : &core(),
3843 2 : ExpandOpts::default(),
3844 : );
3845 2 : assert!(
3846 2 : matches!(out, Err(NgsiError::BadRequestData(_))),
3847 : "duplicate expanded sub-Attribute name is BadRequestData, got {out:?}"
3848 : );
3849 2 : }
3850 :
3851 82 : fn core() -> std::sync::Arc<Context> {
3852 82 : Loader::new().core()
3853 82 : }
3854 :
3855 : /// `expand_instance` dispatches on `attr_type` and closes the match with
3856 : /// `unreachable!()`. That arm is reachable exactly when `ATTR_TYPES` — a
3857 : /// `pub` list, and the gate a CLIENT-supplied `"type"` passes through —
3858 : /// carries a member the match does not: 4.5.2 gives Attribute types a
3859 : /// closed set, but a later edition adding one to the list without an arm
3860 : /// turns `{"type": "<new>"}` into a panic on the request path instead of
3861 : /// a Table 6.3.2-1 error.
3862 : ///
3863 : /// So every member of the list is walked here. A type may legitimately
3864 : /// refuse an instance that carries the wrong members (BadRequestData is a
3865 : /// fine answer); it may not panic, and it may not answer with an error
3866 : /// that is not an NGSI-LD one.
3867 : #[test]
3868 2 : fn every_declarable_attribute_type_is_dispatched_not_unreachable() {
3869 16 : for t in ATTR_TYPES {
3870 16 : let doc = serde_json::json!({
3871 16 : "id": "urn:ngsi-ld:Vehicle:dispatch",
3872 16 : "type": "Vehicle",
3873 : // Bare on purpose: 4.5.2.2 lets a value-defining member
3874 : // appear only on its own type, so an instance carrying them
3875 : // all is refused BEFORE the dispatch and would prove nothing.
3876 16 : "attr": {"type": t},
3877 : });
3878 : // Whatever the answer is, reaching one is the assertion: an
3879 : // unhandled type would have panicked before returning.
3880 16 : let got = expand_entity(
3881 16 : doc.as_object().expect("object"),
3882 16 : &core(),
3883 16 : ExpandOpts::default(),
3884 : );
3885 16 : if let Err(e) = got {
3886 16 : assert_eq!(
3887 16 : e.status(),
3888 : 400,
3889 : "{t}: an instance carrying the wrong members is BadRequestData, not {e}"
3890 : );
3891 0 : }
3892 : }
3893 2 : }
3894 : }
|