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 36310 : pub fn reject_first_level_nulls(doc: &Map<String, Value>) -> Result<(), NgsiError> {
148 150950 : for (k, v) in doc {
149 150950 : if v.as_str() == Some("urn:ngsi-ld:null") {
150 32 : return Err(NgsiError::BadRequestData(format!(
151 32 : "member {k}: \"urn:ngsi-ld:null\" is only allowed in partial \
152 32 : update or merge fragments (5.5.4)"
153 32 : )));
154 150918 : }
155 : }
156 36278 : Ok(())
157 36310 : }
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 203778 : fn has_object_member_null(v: &Value) -> bool {
162 203778 : match v {
163 6460 : Value::Object(m) => m
164 6460 : .values()
165 12864 : .any(|x| x.as_str() == Some("urn:ngsi-ld:null") || has_object_member_null(x)),
166 12359 : Value::Array(a) => a.iter().any(has_object_member_null),
167 184959 : _ => false,
168 : }
169 203778 : }
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 44624 : pub fn expand_entity(
174 44624 : doc: &Map<String, Value>,
175 44624 : ctx: &Context,
176 44624 : opts: ExpandOpts,
177 44624 : ) -> Result<Value, NgsiError> {
178 44624 : let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
179 44624 : 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 44624 : if !opts.allow_null {
184 29392 : reject_first_level_nulls(doc)?;
185 15232 : }
186 :
187 : // id
188 44600 : match doc.get("id").or_else(|| doc.get("@id")) {
189 30545 : Some(Value::String(id)) => {
190 30545 : antares_model::EntityId::new(id)?;
191 30469 : out.insert("id".into(), Value::String(id.clone()));
192 : }
193 2 : Some(_) => return Err(bad("entity id must be a string URI")),
194 14043 : None if opts.fragment => {}
195 10 : None => return Err(bad("entity id is required")),
196 : }
197 :
198 : // type
199 44512 : match doc.get("type").or_else(|| doc.get("@type")) {
200 30491 : Some(v) => {
201 30491 : let types = expand_types(v, ctx)?;
202 30464 : out.insert("type".into(), Value::Array(types));
203 : }
204 14015 : None if opts.fragment => {}
205 6 : None => return Err(bad("entity type is required")),
206 : }
207 : // TS 104 175 Annex B: the type-scoped core contexts of the Entity's
208 : // types name its own members. A fragment without a type has none in
209 : // force, as a JSON-LD processor sees the same body.
210 44479 : let types: Vec<&str> = out
211 44479 : .get("type")
212 44479 : .and_then(Value::as_array)
213 44479 : .map(|ts| ts.iter().filter_map(Value::as_str).collect())
214 44479 : .unwrap_or_default();
215 44479 : let members = ctx.within_types(&types);
216 :
217 : // scope
218 44479 : if let Some(v) = doc.get("scope") {
219 : // 4.18: "urn:ngsi-ld:null" shall ONLY appear for deleted scopes —
220 : // creatable solely on null-allowing (merge/patch) inputs.
221 7929 : let valid_scope = |s: &str| -> bool {
222 7929 : if s == "urn:ngsi-ld:null" {
223 20 : return opts.allow_null;
224 7909 : }
225 7909 : valid_scope_value(s)
226 7929 : };
227 6055 : let scopes: Vec<Value> = match v {
228 1153 : Value::String(s) => vec![Value::String(s.clone())],
229 4900 : Value::Array(a) => {
230 4900 : let mut items = Vec::new();
231 6778 : for s in a {
232 80 : match s {
233 6696 : Value::String(s) => items.push(Value::String(s.clone())),
234 : // 4.5.6: on temporal input the scope is the temporal
235 : // representation of a Property — instance objects
236 : // whose value is a scope string or array thereof.
237 80 : Value::Object(o) if opts.temporal => {
238 78 : let vals: Vec<&str> = match o.get("value") {
239 68 : Some(Value::String(s)) => vec![s.as_str()],
240 8 : Some(Value::Array(vs)) if vs.iter().all(Value::is_string) => {
241 6 : vs.iter().filter_map(Value::as_str).collect()
242 : }
243 4 : _ => return Err(bad("scope instance needs a string value")),
244 : };
245 80 : for sv in vals {
246 80 : if !valid_scope(sv) {
247 2 : return Err(bad(&format!(
248 2 : "invalid scope {sv:?} (4.18 grammar)"
249 2 : )));
250 78 : }
251 : }
252 72 : items.push(s.clone());
253 : }
254 4 : _ => return Err(bad("scope entries must be strings")),
255 : }
256 : }
257 4890 : items
258 : }
259 2 : _ => return Err(bad("scope must be a string or array of strings")),
260 : };
261 7857 : for s in scopes.iter().filter_map(Value::as_str) {
262 7849 : if !valid_scope(s) {
263 32 : return Err(bad(&format!("invalid scope {s:?} (4.18 grammar)")));
264 7817 : }
265 : }
266 : // The sentinel deletes the whole scope member (5.5.12), so it is the
267 : // whole value or it is not there. Mixed with real scopes it would be
268 : // stored as one of them, and 4.18 has no scope that spells it.
269 6011 : if scopes.len() > 1 && scopes.iter().any(is_ngsi_null) {
270 6 : return Err(bad(
271 6 : "\"urn:ngsi-ld:null\" is the whole scope or none of it (4.18, 5.5.12)",
272 6 : ));
273 6005 : }
274 6005 : out.insert("scope".into(), Value::Array(scopes));
275 38424 : }
276 :
277 : // expiresAt (4.22 transient storage): the one client-settable temporal
278 : // meta member. Keep it as a bare top-level DateTime string — the shape the
279 : // read-boundary filter (filter::expired_at), the GC sweep, the postgres
280 : // `expires_at` column extraction and temporal `meta_of` all expect. Missing
281 : // this made 4.22 dead code on every backend.
282 44429 : if let Some(v) = doc.get("expiresAt") {
283 : // In a merge/partial FRAGMENT an NGSI-LD Null asks for the expiry's
284 : // removal (5.5.12) — pass it through for merge_into to act on. 5.5.4
285 : // limits the marker to those fragments, so on a whole-Entity input it
286 : // stays BadRequestData: a temporal import allows nulls for its 4.5.7
287 : // tombstones, and storing the marker as a lifetime there poisons every
288 : // later read of the tenant.
289 106 : let is_null_removal = opts.allow_null
290 36 : && (opts.fragment || opts.merge)
291 28 : && v.as_str() == Some("urn:ngsi-ld:null");
292 106 : let s = v
293 106 : .as_str()
294 106 : .filter(|s| is_null_removal || parse_datetime(s))
295 106 : .ok_or_else(|| bad("expiresAt must be an ISO 8601 DateTime"))?;
296 88 : out.insert("expiresAt".into(), Value::String(s.to_owned()));
297 44323 : }
298 :
299 : // 4.8: with sys expansion the ENTITY-level system timestamps survive —
300 : // a federated import (5.7.2.4 forwards request options=sysAttrs) must
301 : // keep the remote system's createdAt/modifiedAt/deletedAt rather than
302 : // dropping them (they are re-stamped only on local writes).
303 44411 : if opts.sys {
304 2172 : for k in ["createdAt", "modifiedAt", "deletedAt"] {
305 2172 : if let Some(Value::String(ts)) = doc.get(k) {
306 836 : if parse_datetime(ts) {
307 834 : out.insert(k.to_owned(), Value::String(ts.clone()));
308 834 : }
309 1336 : }
310 : }
311 43687 : }
312 174052 : for (key, v) in doc {
313 174052 : match key.as_str() {
314 174052 : "id" | "@id" | "type" | "@type" | "@context" | "scope" | "expiresAt" | "createdAt"
315 106643 : | "modifiedAt" | "deletedAt" => continue,
316 102427 : _ => {}
317 : }
318 102427 : if key.is_empty() {
319 2 : return Err(bad("empty attribute name"));
320 102425 : }
321 102425 : let iri = expand_attr_name(key, &members)?;
322 : // 4.5.1: core non-reified terms shall not be used as Attribute names.
323 102423 : if iri
324 102423 : .strip_prefix(crate::context::NGSI_LD_BASE)
325 102423 : .is_some_and(|t| NON_REIFIED_TERMS.contains(&t))
326 : {
327 30 : return Err(bad(&format!(
328 30 : "{key} is a core non-reified term and cannot be used as an Attribute name (4.5.1)"
329 30 : )));
330 102393 : }
331 102393 : let instances = expand_attribute(key, v, &ctx.within(&iri), opts, 0)?;
332 101702 : if GEO_ENTITY_MEMBERS.contains(&iri.as_str()) {
333 5069 : for inst in &instances {
334 5069 : let t = inst.get("type").and_then(Value::as_str);
335 5069 : let is_deletion = opts.allow_null && inst.get("value").is_some_and(is_ngsi_null);
336 5069 : if t != Some("GeoProperty") && !is_deletion {
337 14 : return Err(bad(&format!("{key} must be a GeoProperty")));
338 5055 : }
339 : }
340 97015 : }
341 101688 : if !opts.temporal {
342 99376 : validate_dataset_ids(key, &instances)?;
343 2312 : }
344 : // 4.5.5.1: "There can only be one default Attribute instance for an
345 : // Attribute with a given Attribute name in any request or response" —
346 : // a term and its own expanded IRI are ONE Attribute name, so keeping
347 : // the last writer would silently discard the other member's data.
348 101670 : if out
349 101670 : .insert(iri.clone(), Value::Array(instances.into_iter().collect()))
350 101670 : .is_some()
351 : {
352 2 : return Err(bad(&format!(
353 2 : "attribute {key} expands to {iri}, which another member of \
354 2 : this Entity already defines (4.5.5.1)"
355 2 : )));
356 101668 : }
357 : }
358 43652 : Ok(Value::Object(out))
359 44624 : }
360 :
361 : /// Expand a `type` member (string or array) to absolute IRIs; a name that
362 : /// does not expand to one is `BadRequestData` (5.5.4, 4.6.2).
363 30497 : pub fn expand_types(v: &Value, ctx: &Context) -> Result<Vec<Value>, NgsiError> {
364 30497 : let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
365 : // 5.5.4/4.6.2: an Entity Type must expand to an absolute IRI; a name that
366 : // is a JSON-LD-keyword alias in the @context (e.g. "type" → "@type") is
367 : // invalid (001_02_04).
368 31983 : let one = |t: &str| -> Result<Value, NgsiError> {
369 : // 4.5.0: the NGSI-LD Null asks for a deletion, and an Entity's type
370 : // is mandatory (4.5.1): it is never a type, not even on a merge.
371 31983 : if is_ngsi_null(&Value::String(t.to_owned())) {
372 8 : return Err(bad("the NGSI-LD Null is no Entity Type (4.5.0, 4.5.1)"));
373 31975 : }
374 : // 4.6.2: Entity Type names obey the name grammar (BadRequestData).
375 31975 : if !valid_name(t) {
376 8 : return Err(bad(&format!(
377 8 : "entity type {t:?} violates the 4.6.2 name grammar"
378 8 : )));
379 31967 : }
380 31967 : let iri = ctx.expand_key(t);
381 31967 : if crate::context::is_absolute_iri(&iri) {
382 31962 : Ok(Value::String(iri))
383 : } else {
384 5 : Err(bad(&format!("entity type {t:?} does not expand to an IRI")))
385 : }
386 31983 : };
387 28503 : match v {
388 28503 : Value::String(t) if !t.is_empty() => Ok(vec![one(t)?]),
389 1992 : Value::Array(a) if !a.is_empty() => {
390 1990 : let mut out = Vec::new();
391 3486 : for t in a {
392 3484 : match t {
393 3484 : Value::String(t) if !t.is_empty() => out.push(one(t)?),
394 4 : _ => return Err(bad("entity type entries must be non-empty strings")),
395 : }
396 : }
397 1982 : Ok(out)
398 : }
399 6 : _ => Err(bad("entity type must be a non-empty string or array")),
400 : }
401 30497 : }
402 :
403 : /// An expanded document as the object it is. `expand_entity` and
404 : /// `expand_attr_fragment` both return a JSON object, so a value that is not
405 : /// one did not come from them: the caller wired the wrong value in, and the
406 : /// mistake stays inside the one request instead of taking the process down.
407 13732 : pub fn expanded_object(v: &Value) -> Result<&serde_json::Map<String, Value>, NgsiError> {
408 13732 : v.as_object()
409 13732 : .ok_or_else(|| NgsiError::InternalError("expanded document is not a JSON object".into()))
410 13732 : }
411 :
412 : /// The `id` of an expanded Entity. `expand_entity` validates it as a URI
413 : /// before it returns, so the same rule as `expanded_object` applies.
414 19270 : pub fn expanded_id(v: &Value) -> Result<&str, NgsiError> {
415 19270 : v.get("id")
416 19270 : .and_then(Value::as_str)
417 19270 : .ok_or_else(|| NgsiError::InternalError("expanded entity carries no id".into()))
418 19270 : }
419 :
420 : /// The `id` of an expanded Entity about to be created. 5.7.2 names Entities
421 : /// by a comma-separated `id` list, so an id holding a comma could never be
422 : /// queried by id and splits in two on every page rebuilt from such a list
423 : /// (EntityMap, temporal pages, federation forwards): refused at create.
424 17504 : pub fn new_entity_id(v: &Value) -> Result<&str, NgsiError> {
425 17504 : let id = expanded_id(v)?;
426 17502 : if id.contains(',') {
427 22 : return Err(NgsiError::BadRequestData(format!(
428 22 : "entity id {id:?} holds a comma: an id query (5.7.2) names Entities as a comma-separated list, so such an id cannot be created; use another character, e.g. {:?}",
429 22 : id.replace(',', "-")
430 22 : )));
431 17480 : }
432 17480 : Ok(id)
433 17504 : }
434 :
435 : /// 4.5.1/5.5.4: an Attribute or sub-Attribute name shall expand to an
436 : /// absolute IRI. A user @context is merged before the Core one (4.4), so a
437 : /// term defined as `{"@id": "id"}` stays RELATIVE and would otherwise land
438 : /// on a reserved member (`id`, `value`, `datasetId`, `observedAt`, …) and
439 : /// overwrite it — skipping that member's own validation. Same rule
440 : /// expand_types applies to Entity Type names.
441 : ///
442 : /// Public because the Attribute name also arrives in a URL path (5.6.4,
443 : /// 5.6.5, 5.6.19, 5.6.13, 5.6.14), where the clauses require the same
444 : /// "fully qualified name (URI)" from the same 5.5.7 expansion. A path name
445 : /// that skips this check lands on a member of the stored document that is
446 : /// not an Attribute.
447 105500 : pub fn expand_attr_name(name: &str, ctx: &Context) -> Result<String, NgsiError> {
448 105500 : let iri = ctx.expand_key(name);
449 105500 : if crate::context::is_absolute_iri(&iri) {
450 105478 : Ok(iri)
451 : } else {
452 22 : Err(NgsiError::BadRequestData(format!(
453 22 : "attribute name {name:?} does not expand to an absolute IRI"
454 22 : )))
455 : }
456 105500 : }
457 :
458 : /// Table 5.2.6-1 `objectType` / Table 5.2.35-1 `vocab`: "String or String[]",
459 : /// "Both short hand string(s) (type name) or URI(s) are allowed" — every entry
460 : /// is @vocab-coerced against the request @context, so the short and the
461 : /// expanded spelling of one target type cannot be stored differently.
462 146 : fn expand_terms(name: &str, member: &str, v: &Value, ctx: &Context) -> Result<Value, NgsiError> {
463 146 : let bad = || NgsiError::BadRequestData(format!("attribute {name}: invalid {member}"));
464 146 : match v {
465 115 : Value::String(s) => Ok(Value::String(ctx.expand_key(s))),
466 21 : Value::Array(a) => Ok(Value::Array(
467 21 : a.iter()
468 38 : .map(|s| {
469 38 : s.as_str()
470 38 : .map(|s| Value::String(ctx.expand_key(s)))
471 38 : .ok_or_else(bad)
472 38 : })
473 21 : .collect::<Result<_, _>>()?,
474 : )),
475 10 : _ => Err(bad()),
476 : }
477 146 : }
478 :
479 : /// 4.5.5.1: a datasetId is a URI string; "datasetId": "@none" designates the
480 : /// default Attribute instance, which never carries one — normalized to
481 : /// absent (`Ok(None)`) so storage, matching and responses treat it as such.
482 : /// 5.5.8/5.5.12: "A datasetId cannot be deleted by setting it to the value
483 : /// urn:ngsi-ld:null" — rejected on every input.
484 36465 : fn dataset_id_member(d: &Value) -> Result<Option<Value>, NgsiError> {
485 36465 : let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
486 36465 : let s = d.as_str().ok_or_else(|| bad("datasetId must be a URI"))?;
487 36457 : if s == "urn:ngsi-ld:null" {
488 12 : return Err(bad(
489 12 : "a datasetId cannot be set or deleted via \"urn:ngsi-ld:null\" (5.5.8)",
490 12 : ));
491 36445 : }
492 36445 : if s == "@none" {
493 14 : return Ok(None);
494 36431 : }
495 36431 : antares_model::EntityId::new(s).map_err(|_| bad("datasetId must be a URI"))?;
496 36427 : Ok(Some(d.clone()))
497 36465 : }
498 :
499 : /// 5.2.1: "In all other cases, implementations shall raise an error of type
500 : /// BadRequestData if an NGSI-LD Null value is encountered"; 5.5.4 bans it as
501 : /// the value of a key-value pair inside a Property's compound value except
502 : /// in merge fragments. The concise forms hand the client's JSON back as the
503 : /// Property value unchanged, so they carry the same two checks as the
504 : /// normalized path.
505 234 : fn check_value_nulls(name: &str, val: &Value, opts: ExpandOpts) -> Result<(), NgsiError> {
506 234 : let bad = NgsiError::BadRequestData;
507 234 : let nullish = match val {
508 102 : Value::String(s) => s == "urn:ngsi-ld:null",
509 16 : Value::Array(a) => a.iter().any(is_ngsi_null),
510 116 : _ => false,
511 : };
512 234 : if !opts.allow_null && nullish {
513 6 : return Err(bad(format!(
514 6 : "attribute {name}: the NGSI-LD Null is only allowed in \
515 6 : partial update or merge inputs (5.2.1)"
516 6 : )));
517 228 : }
518 228 : if !opts.merge && has_object_member_null(val) {
519 2 : return Err(bad(format!(
520 2 : "attribute {name}: \"urn:ngsi-ld:null\" inside a compound value \
521 2 : is only allowed in merge fragments (5.5.4)"
522 2 : )));
523 226 : }
524 226 : Ok(())
525 234 : }
526 :
527 : /// The NGSI-LD null sentinel — ONLY the string form (a plain JSON null is
528 : /// invalid data, 057_03_02).
529 413562 : pub fn is_ngsi_null(v: &Value) -> bool {
530 173057 : matches!(v, Value::String(s) if s == "urn:ngsi-ld:null")
531 413562 : }
532 :
533 : /// A LanguageProperty deletion carries `{"@none": "urn:ngsi-ld:null"}`.
534 1898 : pub fn is_ngsi_null_langmap(v: &Value) -> bool {
535 1898 : is_ngsi_null(v)
536 1896 : || v.as_object()
537 1896 : .is_some_and(|m| m.len() == 1 && m.get("@none").is_some_and(is_ngsi_null))
538 1898 : }
539 :
540 : /// 4.5.21.2/4.5.22.2: a List deletion is "an array consisting of a single
541 : /// NGSI-LD Null" as the valueList/objectList (bare null tolerated too).
542 86 : pub fn is_ngsi_null_list(v: &Value) -> bool {
543 86 : is_ngsi_null(v) || matches!(v.as_array().map(Vec::as_slice), Some([only]) if is_ngsi_null(only))
544 86 : }
545 :
546 : /// Whole-instance deletion marker (merge patch, 5.5.12).
547 52274 : pub fn is_deletion_instance(inst: &Value) -> bool {
548 52274 : is_ngsi_null(inst)
549 52270 : || inst.as_object().is_some_and(|o| {
550 52260 : o.get("value").is_some_and(is_ngsi_null)
551 52154 : || o.get("object").is_some_and(is_ngsi_null)
552 52098 : || o.get("languageMap").is_some_and(is_ngsi_null_langmap)
553 52046 : || o.get("json").is_some_and(is_ngsi_null)
554 52044 : || o.get("vocab").is_some_and(is_ngsi_null)
555 52042 : || o.get("valueList").is_some_and(is_ngsi_null_list)
556 52040 : || o.get("objectList").is_some_and(is_ngsi_null_list)
557 52260 : })
558 52274 : }
559 :
560 : /// Whether a fragment member removes its target member (5.5.12, 10.2.5.4): JSON null, the
561 : /// NGSI-LD Null, or a sub-Attribute every instance of which is a deletion instance.
562 784 : pub fn removes_member(v: &Value) -> bool {
563 784 : v.is_null()
564 782 : || is_ngsi_null(v)
565 776 : || v.as_array().is_some_and(|instances| {
566 74 : !instances.is_empty() && instances.iter().all(is_deletion_instance)
567 74 : })
568 784 : }
569 :
570 : /// 4.18 Scope grammar: [/] ScopeLevel *(/ScopeLevel), ScopeLevel =
571 : /// unicodeLetter *(letter/digit/_) — shared by entity scopes and the 5.2.9
572 : /// registration scope member.
573 7945 : pub fn valid_scope_value(s: &str) -> bool {
574 7945 : let body = s.strip_prefix('/').unwrap_or(s);
575 7945 : !body.is_empty()
576 14196 : && body.split('/').all(|level| {
577 14196 : let mut ch = level.chars();
578 14196 : ch.next().is_some_and(char::is_alphabetic)
579 18494 : && ch.all(|c| c.is_alphabetic() || c.is_numeric() || c == '_')
580 14196 : })
581 7945 : }
582 :
583 : /// 4.6.2 Supported names: `name = unicodeLetter *(unicodeLetter /
584 : /// unicodeNumber / "_")`. A key containing ':' is a compact or absolute IRI
585 : /// (the spec's prefix:name production) and is outside the term grammar.
586 : // Known ceiling: colon-keys are exempt wholesale — a malformed "pre fix:x" slips
587 : // through as an IRI; tighten to per-part validation if it ever matters.
588 844185 : pub fn valid_name(s: &str) -> bool {
589 844185 : if s.contains(':') {
590 26 : return true;
591 844159 : }
592 844159 : let mut ch = s.chars();
593 844159 : ch.next().is_some_and(char::is_alphabetic)
594 1167558 : && ch.all(|c| c.is_alphabetic() || c.is_numeric() || c == '_')
595 844185 : }
596 :
597 : /// Expand one attribute's value into a normalized instance list.
598 104352 : fn expand_attribute(
599 104352 : name: &str,
600 104352 : v: &Value,
601 104352 : ctx: &Context,
602 104352 : opts: ExpandOpts,
603 104352 : depth: usize,
604 104352 : ) -> Result<Vec<Value>, NgsiError> {
605 104352 : let bad = NgsiError::BadRequestData;
606 104352 : if depth > 8 {
607 2 : return Err(bad(format!("attribute {name}: nesting too deep")));
608 104350 : }
609 : // 4.6.2: Property/Relationship names with characters outside the name
610 : // grammar raise BadRequestData.
611 104350 : if !valid_name(name) {
612 16 : return Err(bad(format!(
613 16 : "attribute name {name:?} violates the 4.6.2 name grammar"
614 16 : )));
615 104334 : }
616 4369 : match v {
617 4369 : Value::Array(items) if items.iter().all(looks_like_instance) && !items.is_empty() => {
618 4355 : let mut out = Vec::new();
619 46665 : for item in items {
620 46665 : out.push(expand_instance(name, item, ctx, opts, depth)?);
621 : }
622 4351 : Ok(out)
623 : }
624 99979 : _ => Ok(vec![expand_instance(name, v, ctx, opts, depth)?]),
625 : }
626 104352 : }
627 :
628 46677 : fn looks_like_instance(v: &Value) -> bool {
629 46677 : v.as_object().is_some_and(|o| {
630 46667 : o.get("type")
631 46667 : .and_then(Value::as_str)
632 46667 : .is_some_and(|t| ATTR_TYPES.contains(&t))
633 6 : || [
634 6 : "value",
635 6 : "object",
636 6 : "languageMap",
637 6 : "vocab",
638 6 : "json",
639 6 : "valueList",
640 6 : "objectList",
641 6 : ]
642 6 : .iter()
643 18 : .any(|k| o.contains_key(*k))
644 46667 : })
645 46677 : }
646 :
647 : /// 4.5.2.2 Prohibited (mirrored by 4.5.3.2 and the 4.5.18-4.5.24
648 : /// subclasses): an instance "shall never include" the value-defining
649 : /// member of a DIFFERENT attribute type, nor the output-only members
650 : /// inline Linked Entity retrieval and showChanges notifications produce.
651 : /// `entityIdSealed`/`entityTypeSealed` are the one exception the clause
652 : /// grants, on the `ngsildproof` Property alone, and because they are
653 : /// reserved members this is also where they are copied out.
654 146368 : fn check_prohibited_members(
655 146368 : name: &str,
656 146368 : obj: &Map<String, Value>,
657 146368 : attr_type: &str,
658 146368 : ctx: &Context,
659 146368 : opts: ExpandOpts,
660 146368 : out: &mut Map<String, Value>,
661 146368 : ) -> Result<(), NgsiError> {
662 146368 : let bad = NgsiError::BadRequestData;
663 : const VALUE_OWNERS: &[(&str, &[&str])] = &[
664 : ("value", &["Property", "GeoProperty"]),
665 : ("object", &["Relationship"]),
666 : ("languageMap", &["LanguageProperty"]),
667 : ("json", &["JsonProperty"]),
668 : ("vocab", &["VocabProperty"]),
669 : ("valueList", &["ListProperty"]),
670 : ("objectList", &["ListRelationship"]),
671 : ];
672 1024242 : for (m, owners) in VALUE_OWNERS {
673 1024242 : if obj.contains_key(*m) && !owners.contains(&attr_type) {
674 74 : return Err(bad(format!(
675 74 : "attribute {name}: {m} is not allowed on a {attr_type} (4.5.2.2)"
676 74 : )));
677 1024168 : }
678 : }
679 : // 5.2.6/5.2.37: a Relationship's members name an object, never a
680 : // datatype; valueType belongs to a Property value (4.5.2.2).
681 146294 : if obj.contains_key("valueType") && matches!(attr_type, "Relationship" | "ListRelationship") {
682 4 : return Err(bad(format!(
683 4 : "attribute {name}: valueType is not allowed on a {attr_type} (5.2.6)"
684 4 : )));
685 146290 : }
686 1316504 : if let Some(m) = OUTPUT_ONLY.iter().find(|m| obj.contains_key(**m)) {
687 18 : return Err(bad(format!(
688 18 : "attribute {name}: {m} is output-only and not allowed in input (4.5.2.2)"
689 18 : )));
690 146272 : }
691 : // 4.5.2.2/4.5.2.3 grant the only exception there is: "unless the
692 : // PROPERTY name is ngsildproof", the member being defined as "a
693 : // Property ... with the non-reified subproperties". 4.5.3.2 and
694 : // 4.5.3.3 repeat the ban for a Relationship with no exception at
695 : // all, so the attribute name alone is not the test.
696 146272 : let sealed_ok = attr_type == "Property" && name == "ngsildproof";
697 146272 : if !sealed_ok && (obj.contains_key("entityIdSealed") || obj.contains_key("entityTypeSealed")) {
698 38 : return Err(bad(format!(
699 38 : "attribute {name}: entityIdSealed/entityTypeSealed are only allowed \
700 38 : on the ngsildproof Property (4.5.2.2, 4.5.3.2)"
701 38 : )));
702 146234 : }
703 : // 4.5.2.2 / C.11 / annex B: ngsildproof's NON-REIFIED sealed
704 : // subproperties — entityIdSealed is a plain string term,
705 : // entityTypeSealed is "@type": "@vocab" (it seals the entity type,
706 : // so its value expands like a type name). They are reserved
707 : // members, so without this explicit copy they silently vanish.
708 146234 : if sealed_ok {
709 38 : if let Some(v) = obj.get("entityIdSealed") {
710 22 : let s = v.as_str().ok_or_else(|| {
711 4 : bad(format!(
712 4 : "attribute {name}: entityIdSealed must be a string (4.5.2.2)"
713 4 : ))
714 4 : })?;
715 18 : out.insert("entityIdSealed".into(), Value::String(s.to_owned()));
716 16 : }
717 34 : if let Some(v) = obj.get("entityTypeSealed") {
718 22 : let s = v.as_str().ok_or_else(|| {
719 6 : bad(format!(
720 6 : "attribute {name}: entityTypeSealed must be a string (4.5.2.2)"
721 6 : ))
722 6 : })?;
723 16 : out.insert("entityTypeSealed".into(), Value::String(ctx.expand_key(s)));
724 12 : }
725 : // "The value of its \"value\" element shall be an object
726 : // containing the W3C Data integrity \"proof\" structure"
727 28 : if let Some(v) = obj.get("value") {
728 26 : if !v.is_object() && !(opts.allow_null && is_ngsi_null(v)) {
729 8 : return Err(bad(format!(
730 8 : "attribute {name}: ngsildproof value shall be an object \
731 8 : containing the W3C proof structure (4.5.2.2)"
732 8 : )));
733 18 : }
734 2 : }
735 146196 : }
736 : // 4.5.3.2: "unitCode shall never be present, as Relationships are
737 : // unitless." 4.5.18.2/3 and 4.5.20.2/3 extend the prohibition to
738 : // LanguageProperty and VocabProperty ("always strings and hence
739 : // unitless").
740 : // (4.5.24.2/3 add JsonProperty — "raw JSON objects are unitless".)
741 146216 : if obj.contains_key("unitCode")
742 24 : && matches!(
743 1017 : attr_type,
744 1017 : "Relationship"
745 1009 : | "ListRelationship"
746 1007 : | "LanguageProperty"
747 1005 : | "VocabProperty"
748 1001 : | "JsonProperty"
749 : )
750 : {
751 24 : return Err(bad(format!(
752 24 : "attribute {name}: unitCode is not allowed on a {attr_type}"
753 24 : )));
754 146192 : }
755 146192 : Ok(())
756 146368 : }
757 :
758 : /// The members Table 5.2.5-1 allows on any Attribute instance beside its
759 : /// value: the 4.5.5 `datasetId`, the 4.8 temporal members, the system
760 : /// attributes, and `unitCode`, `valueType`, `lang` and `objectType`,
761 : /// each expanded the way its own subclause defines.
762 145916 : fn expand_common_members(
763 145916 : name: &str,
764 145916 : obj: &Map<String, Value>,
765 145916 : attr_type: &str,
766 145916 : ctx: &Context,
767 145916 : opts: ExpandOpts,
768 145916 : out: &mut Map<String, Value>,
769 145916 : ) -> Result<(), NgsiError> {
770 145916 : let bad = NgsiError::BadRequestData;
771 145916 : if let Some(d) = obj.get("datasetId") {
772 36421 : if let Some(d) = dataset_id_member(d).map_err(|e| bad(format!("attribute {name}: {e}")))? {
773 36401 : out.insert("datasetId".into(), d);
774 36401 : }
775 109495 : }
776 : // 5.5.12: in a merge (or partial update) input the NGSI-LD Null removes the member
777 145908 : let nulls = opts.allow_null || opts.merge;
778 291816 : for k in ["observedAt", "expiresAt"] {
779 291816 : if let Some(v) = obj.get(k).filter(|v| nulls && is_ngsi_null(v)) {
780 10 : out.insert(k.into(), v.clone());
781 291806 : }
782 : }
783 145908 : if let Some(o) = obj
784 145908 : .get("observedAt")
785 145908 : .filter(|v| !(nulls && is_ngsi_null(v)))
786 : {
787 63823 : let s = o
788 63823 : .as_str()
789 63823 : .filter(|s| parse_datetime(s))
790 63823 : .ok_or_else(|| bad(format!("attribute {name}: invalid observedAt")))?;
791 63786 : out.insert("observedAt".into(), Value::String(point_fraction(s)));
792 82085 : }
793 145871 : if let Some(e) = obj.get("expiresAt").filter(|v| !(nulls && is_ngsi_null(v))) {
794 : // 4.22 transient attribute instances carry their own expiresAt.
795 102 : let s = e
796 102 : .as_str()
797 102 : .filter(|s| parse_datetime(s))
798 102 : .ok_or_else(|| bad(format!("attribute {name}: invalid expiresAt")))?;
799 90 : out.insert("expiresAt".into(), Value::String(point_fraction(s)));
800 145769 : }
801 145859 : if opts.sys {
802 : // 4.8/4.5.7: deletedAt marks a deletion instance in a Temporal
803 : // Evolution — dropping it here would strip remote tombstones of the
804 : // timestamp their deletedAt-window matching needs (5.7.3.4 merge).
805 2886 : for k in ["createdAt", "modifiedAt", "deletedAt"] {
806 2886 : if let Some(Value::String(s)) = obj.get(k) {
807 836 : if parse_datetime(s) {
808 834 : out.insert(k.into(), Value::String(s.clone()));
809 834 : }
810 2050 : }
811 : }
812 144897 : }
813 145859 : if let Some(u) = obj.get("unitCode") {
814 1001 : if !u.is_string() {
815 4 : return Err(bad(format!("attribute {name}: unitCode must be a string")));
816 997 : }
817 997 : out.insert("unitCode".into(), u.clone());
818 144858 : }
819 145855 : if let Some(vt) = obj.get("valueType") {
820 : // 4.5.2.2: "valueType": a string value which shall be type coerced
821 : // into a datatype URI — the non-reified alternative to a native
822 : // JSON-LD @type on the Property value.
823 24 : let s = vt
824 24 : .as_str()
825 24 : .ok_or_else(|| bad(format!("attribute {name}: valueType must be a string")))?;
826 : // Table 5.2.32-1: on a LanguageProperty valueType "shall be equal
827 : // to langString" (the rdf:langString datatype) — kept literal.
828 22 : if attr_type == "LanguageProperty" {
829 12 : if s != "langString" {
830 6 : return Err(bad(format!(
831 6 : "attribute {name}: valueType shall be \"langString\" on a LanguageProperty"
832 6 : )));
833 6 : }
834 6 : out.insert("valueType".into(), vt.clone());
835 10 : } else {
836 10 : out.insert("valueType".into(), Value::String(ctx.expand_key(s)));
837 10 : }
838 145831 : }
839 145847 : if let Some(l) = obj.get("lang") {
840 : // 4.15: the language filter augments the converted Property with "an
841 : // additional non-reified subproperty lang indicating the actual
842 : // language returned" — a langtag. The member is broker-produced and
843 : // the clause says nothing about a client supplying one, so it is
844 : // kept; a non-string would leave the instance in a shape no reader
845 : // of 4.15 can interpret.
846 12 : let s = l
847 12 : .as_str()
848 12 : .ok_or_else(|| bad(format!("attribute {name}: lang must be a language tag")))?;
849 2 : out.insert("lang".into(), Value::String(s.to_owned()));
850 145835 : }
851 145837 : if let Some(ot) = obj.get("objectType") {
852 24 : out.insert(
853 24 : "objectType".into(),
854 24 : expand_terms(name, "objectType", ot, ctx)?,
855 : );
856 145813 : }
857 145831 : Ok(())
858 145916 : }
859 :
860 : /// 4.5.3: a Relationship's `object` is a URI, or a non-empty array of
861 : /// URIs; the NGSI-LD Null passes under `allow_null`. The one check the
862 : /// full-instance and the partial-update fragment paths share.
863 11140 : fn check_object(name: &str, objv: &Value, opts: ExpandOpts) -> Result<(), NgsiError> {
864 11140 : let bad = NgsiError::BadRequestData;
865 8 : match objv {
866 11124 : Value::String(s) => {
867 11124 : if !(opts.allow_null && s == "urn:ngsi-ld:null") {
868 11078 : antares_model::EntityId::new(s)
869 11078 : .map_err(|_| bad(format!("attribute {name}: object must be a URI")))?;
870 46 : }
871 : }
872 8 : Value::Array(items) if !items.is_empty() => {
873 10 : for s in items {
874 10 : let s = s
875 10 : .as_str()
876 10 : .ok_or_else(|| bad(format!("attribute {name}: object entries must be URIs")))?;
877 8 : antares_model::EntityId::new(s)
878 8 : .map_err(|_| bad(format!("attribute {name}: object must be a URI")))?;
879 : }
880 : }
881 10 : _ => return Err(bad(format!("attribute {name}: invalid object"))),
882 : }
883 11118 : Ok(())
884 11140 : }
885 :
886 : /// 4.5.22.2/4.5.22.3: objectList is an ordered array of Relationship
887 : /// objects — `{"object": <URI>}` objects (normalized) or bare URI strings
888 : /// (concise). Internal form is bare URIs; the normalized output shape is
889 : /// restored at compaction. The `[null]` deletion form (single NGSI-LD Null)
890 : /// passes through under allow_null.
891 66 : fn object_list(name: &str, l: &Value, opts: ExpandOpts) -> Result<Value, NgsiError> {
892 66 : let bad = NgsiError::BadRequestData;
893 62 : match l {
894 66 : _ if opts.allow_null && is_ngsi_null_list(l) => Ok(l.clone()),
895 58 : Value::Array(items) => {
896 58 : let mut uris = Vec::with_capacity(items.len());
897 88 : for it in items {
898 80 : let uri = match it {
899 64 : Value::String(s) => s.as_str(),
900 20 : Value::Object(o)
901 20 : if o.len() == 1 && o.get("object").is_some_and(Value::is_string) =>
902 : {
903 16 : o["object"].as_str().unwrap_or_default()
904 : }
905 : _ => {
906 8 : return Err(bad(format!(
907 8 : "attribute {name}: objectList entries must be URIs \
908 8 : or {{\"object\": <URI>}} objects"
909 8 : )))
910 : }
911 : };
912 80 : antares_model::EntityId::new(uri)
913 80 : .map_err(|_| bad(format!("attribute {name}: objectList entry is not a URI")))?;
914 72 : uris.push(Value::String(uri.to_owned()));
915 : }
916 42 : Ok(Value::Array(uris))
917 : }
918 4 : _ => Err(bad(format!(
919 4 : "attribute {name}: objectList must be an array"
920 4 : ))),
921 : }
922 66 : }
923 :
924 : /// Expand a single instance (normalized or concise) to normalized form.
925 : ///
926 : /// This is where the 4.2.2 Meta Model's own SHALLs are enforced: "An NGSI-LD
927 : /// Property shall have a value, stated through hasValue" and "An NGSI-LD
928 : /// Relationship shall have an object stated through hasObject" — a Property
929 : /// without `value` (and each specialized property type without its own
930 : /// value member, 5.2.5/5.2.32/5.2.35–5.2.38) or a Relationship without a
931 : /// URI `object` is rejected as BadRequestData. "An NGSI-LD Value shall be
932 : /// either a rdfs:Literal or a node object" — any JSON literal, array or
933 : /// object is accepted as `value`, a bare JSON `null` is not (4.5.2, the
934 : /// null sentinel is the string form only).
935 146644 : fn expand_instance(
936 146644 : name: &str,
937 146644 : v: &Value,
938 146644 : ctx: &Context,
939 146644 : opts: ExpandOpts,
940 146644 : depth: usize,
941 146644 : ) -> Result<Value, NgsiError> {
942 146644 : let bad = NgsiError::BadRequestData;
943 :
944 : // NGSI-LD null: attribute deletion marker (merge-patch only).
945 146644 : if is_ngsi_null(v) {
946 26 : if opts.allow_null {
947 24 : return Ok(json!({"type": "Property", "value": "urn:ngsi-ld:null"}));
948 2 : }
949 2 : return Err(bad(format!("attribute {name}: null is not allowed here")));
950 146618 : }
951 :
952 146618 : let obj = match v {
953 146490 : Value::Object(o) => o,
954 : // concise: primitive / array value ⇒ Property
955 128 : prim => {
956 128 : check_value_nulls(name, prim, opts)?;
957 126 : return Ok(json!({"type": "Property", "value": prim.clone()}));
958 : }
959 : };
960 : // The core @context aliases type to @type: an Attribute written with the
961 : // keyword is the same document, one with both names collides (JSON-LD
962 : // 1.1 API expansion "colliding keywords").
963 : let aliased;
964 146490 : let obj = match obj.get("@type") {
965 6 : Some(_) if obj.contains_key("type") => {
966 4 : return Err(bad(format!("attribute {name}: both type and @type given")))
967 : }
968 2 : Some(t) => {
969 2 : let mut m = obj.clone();
970 2 : m.remove("@type");
971 2 : m.insert("type".into(), t.clone());
972 2 : aliased = m;
973 2 : &aliased
974 : }
975 146484 : None => obj,
976 : };
977 : let core;
978 146486 : let obj = match core_members(name, obj, ctx)? {
979 18 : Some(m) => {
980 18 : core = m;
981 18 : &core
982 : }
983 146464 : None => obj,
984 : };
985 :
986 146482 : let declared = match obj.get("type") {
987 124 : None => None,
988 146352 : Some(Value::String(t)) => Some(t.as_str()),
989 6 : Some(t) => return Err(bad(format!("attribute {name}: type {t} is not a string"))),
990 : };
991 146368 : let attr_type: &str = match declared {
992 146352 : Some(t) if ATTR_TYPES.contains(&t) => t,
993 78 : Some(t) if GEO_TYPES.contains(&t) && obj.contains_key("coordinates") => {
994 : // concise GeoProperty: bare GeoJSON object as the value. 4.7.3
995 : // mandates `coordinates` "as defined by the relevant GeoJSON
996 : // Geometry", so the concise form is held to the same RFC 7946
997 : // restrictions as the verbose one.
998 70 : check_value_nulls(name, v, opts)?;
999 70 : validate_geojson(name, v)?;
1000 32 : return Ok(json!({"type": "GeoProperty", "value": v.clone()}));
1001 : }
1002 8 : Some(t) => {
1003 8 : return Err(bad(format!(
1004 8 : "attribute {name}: invalid attribute type {t:?}"
1005 8 : )))
1006 : }
1007 : None => {
1008 : // concise object form — infer from members
1009 124 : if obj.contains_key("object") {
1010 20 : "Relationship"
1011 104 : } else if obj.contains_key("languageMap") {
1012 12 : "LanguageProperty"
1013 92 : } else if obj.contains_key("vocab") {
1014 4 : "VocabProperty"
1015 88 : } else if obj.contains_key("json") {
1016 12 : "JsonProperty"
1017 76 : } else if obj.contains_key("valueList") {
1018 4 : "ListProperty"
1019 72 : } else if obj.contains_key("objectList") {
1020 2 : "ListRelationship"
1021 70 : } else if obj.contains_key("value") {
1022 : // 4.5.2.3: type may be omitted — "Property can be inferred by
1023 : // the presence of the value attribute. An exception to this
1024 : // inference rule occurs for geospatial Property Values, where
1025 : // the GeoProperty sub-type shall be inferred instead, if the
1026 : // Property Value resolves to a supported GeoJSON geometry."
1027 40 : let v = &obj["value"];
1028 40 : if v.get("type")
1029 40 : .and_then(Value::as_str)
1030 40 : .is_some_and(|t| GEO_TYPES.contains(&t))
1031 16 : && v.get("coordinates").is_some()
1032 : {
1033 12 : "GeoProperty"
1034 : } else {
1035 28 : "Property"
1036 : }
1037 : } else {
1038 : // whole object is a Property value (4.5.2.3)
1039 30 : check_value_nulls(name, v, opts)?;
1040 28 : if embeds_context(v) {
1041 2 : return Err(bad(format!(
1042 2 : "attribute {name}: embedded @context is not allowed (4.5.1/5.5.7)"
1043 2 : )));
1044 26 : }
1045 26 : return Ok(json!({"type": "Property", "value": v.clone()}));
1046 : }
1047 : }
1048 : };
1049 :
1050 146368 : let mut out = Map::new();
1051 146368 : out.insert("type".into(), Value::String(attr_type.to_owned()));
1052 :
1053 146368 : check_prohibited_members(name, obj, attr_type, ctx, opts, &mut out)?;
1054 :
1055 : // required member per type
1056 146192 : match attr_type {
1057 146192 : "Property" => {
1058 127693 : let val = obj
1059 127693 : .get("value")
1060 127693 : .ok_or_else(|| bad(format!("attribute {name}: Property needs value")))?;
1061 127682 : if val.is_null() {
1062 21 : return Err(bad(format!(
1063 21 : "attribute {name}: JSON null is not a valid value (use \"urn:ngsi-ld:null\")"
1064 21 : )));
1065 127661 : }
1066 127661 : if let Some(vo) = val.as_object().filter(|o| o.contains_key("@value")) {
1067 20 : check_value_object(name, vo, ctx)?;
1068 127641 : }
1069 : // 5.5.7: no part of a user @context below the top level, a
1070 : // structured Property value included
1071 127653 : if embeds_context(val) {
1072 2 : return Err(bad(format!(
1073 2 : "attribute {name}: embedded @context is not allowed (4.5.1/5.5.7)"
1074 2 : )));
1075 127651 : }
1076 127651 : out.insert("value".into(), val.clone());
1077 : }
1078 18499 : "GeoProperty" => {
1079 5178 : let val = obj
1080 5178 : .get("value")
1081 5178 : .ok_or_else(|| bad(format!("attribute {name}: GeoProperty needs value")))?;
1082 : // 4.7.2: a whole geometry may arrive as an encoded JSON string,
1083 : // accepted "if and only if" it parses into a valid geometry —
1084 : // normalized here to the object form so storage, geo-queries and
1085 : // responses all see one representation.
1086 5168 : let val = match val {
1087 82 : Value::String(s) if !(opts.allow_null && is_ngsi_null(val)) => {
1088 56 : serde_json::from_str::<Value>(s).map_err(|_| {
1089 8 : bad(format!(
1090 8 : "attribute {name}: string-encoded geometry is not valid JSON"
1091 8 : ))
1092 8 : })?
1093 : }
1094 5120 : _ => val.clone(),
1095 : };
1096 5168 : if !(opts.allow_null && is_ngsi_null(&val)) {
1097 5142 : validate_geojson(name, &val)?;
1098 26 : }
1099 5066 : out.insert("value".into(), val);
1100 : }
1101 13321 : "Relationship" => {
1102 11139 : let objv = obj
1103 11139 : .get("object")
1104 11139 : .ok_or_else(|| bad(format!("attribute {name}: Relationship needs object")))?;
1105 11130 : check_object(name, objv, opts)?;
1106 11112 : out.insert("object".into(), objv.clone());
1107 : }
1108 2182 : "LanguageProperty" => {
1109 : // 4.5.18.2: "a JSON object consisting of a set of non-empty
1110 : // language tags (RFC 5646) or the language tag "@none"", each
1111 : // mapping to a single string or array of strings.
1112 1803 : let lm = obj
1113 1803 : .get("languageMap")
1114 1803 : .ok_or_else(|| bad(format!("attribute {name}: needs languageMap")))?;
1115 : // 4.6.5: {"@none": "urn:ngsi-ld:null"} is exclusively the
1116 : // partial/merge-patch deletion encoding — outside allow_null it
1117 : // is an NGSI-LD Null in a create and thus BadRequestData.
1118 1790 : let ok = if is_ngsi_null_langmap(lm) {
1119 50 : opts.allow_null
1120 : } else {
1121 1740 : valid_language_map(lm)
1122 : };
1123 1790 : if !ok {
1124 38 : return Err(bad(format!("attribute {name}: invalid languageMap")));
1125 1752 : }
1126 1752 : out.insert("languageMap".into(), lm.clone());
1127 : }
1128 379 : "JsonProperty" => {
1129 : // TS 104 175 5.2.6.4.10 types json by the NGSI-LD Value of 3.1:
1130 : // "JSON value (i.e. a string, a number, true or false, an object,
1131 : // an array)" — CIM 009 4.5.24.2's object-only rule is lifted.
1132 : // Never expanded or compacted; kept verbatim. JSON null
1133 : // is no NGSI-LD Value; the bare NGSI-LD Null is the deletion form
1134 : // and passes only under allow_null.
1135 145 : let j = obj
1136 145 : .get("json")
1137 145 : .ok_or_else(|| bad(format!("attribute {name}: needs json")))?;
1138 141 : let ok = if is_ngsi_null(j) {
1139 6 : opts.allow_null
1140 : } else {
1141 135 : !j.is_null()
1142 : };
1143 141 : if !ok {
1144 10 : return Err(bad(format!(
1145 10 : "attribute {name}: json must be a JSON value (string, number, boolean, object or array), not null"
1146 10 : )));
1147 131 : }
1148 131 : out.insert("json".into(), j.clone());
1149 : }
1150 234 : "VocabProperty" => {
1151 118 : let vv = obj
1152 118 : .get("vocab")
1153 118 : .ok_or_else(|| bad(format!("attribute {name}: needs vocab")))?;
1154 114 : out.insert("vocab".into(), expand_terms(name, "vocab", vv, ctx)?);
1155 : }
1156 116 : "ListProperty" => {
1157 54 : let l = obj
1158 54 : .get("valueList")
1159 54 : .ok_or_else(|| bad(format!("attribute {name}: needs valueList")))?;
1160 50 : if !l.is_array() && !(opts.allow_null && is_ngsi_null(l)) {
1161 6 : return Err(bad(format!("attribute {name}: valueList must be an array")));
1162 44 : }
1163 : // 5.5.7: no user @context below the top level, inside a list's
1164 : // values as inside a Property value
1165 44 : if embeds_context(l) {
1166 2 : return Err(bad(format!(
1167 2 : "attribute {name}: embedded @context is not allowed (4.5.1/5.5.7)"
1168 2 : )));
1169 42 : }
1170 42 : out.insert("valueList".into(), l.clone());
1171 : }
1172 62 : "ListRelationship" => {
1173 62 : let l = obj
1174 62 : .get("objectList")
1175 62 : .ok_or_else(|| bad(format!("attribute {name}: needs objectList")))?;
1176 58 : out.insert("objectList".into(), object_list(name, l, opts)?);
1177 : }
1178 : // 4.5.2 closes the set of Attribute types, and `attr_type` is
1179 : // either a member of ATTR_TYPES or one of the inferred literals
1180 : // above — so every reachable value has an arm. A member added to
1181 : // that list without an arm here would arrive as a client-supplied
1182 : // `"type"`, which is a request to answer, not a reason to panic:
1183 : // pinned by
1184 : // `every_declarable_attribute_type_is_dispatched_not_unreachable`.
1185 : _ => {
1186 0 : return Err(NgsiError::InternalError(format!(
1187 0 : "attribute type {attr_type} has no expansion arm"
1188 0 : )))
1189 : }
1190 : }
1191 :
1192 : // optional standard members
1193 145904 : expand_common_members(name, obj, attr_type, ctx, opts, &mut out)?;
1194 :
1195 : // sub-attributes
1196 395625 : for (k, sub) in obj {
1197 395625 : if k == "@context" {
1198 : // 4.5.1/5.5.7: "Attributes shall not contain any embedded
1199 : // @context" — a nested user context could override core terms,
1200 : // so it "should result in an error of type BadRequestData".
1201 8 : return Err(bad(format!(
1202 8 : "attribute {name}: embedded @context is not allowed (4.5.1/5.5.7)"
1203 8 : )));
1204 395617 : }
1205 395617 : if RESERVED_MEMBERS.contains(&k.as_str()) {
1206 393734 : continue;
1207 1883 : }
1208 1883 : if k.is_empty() {
1209 2 : return Err(bad(format!("attribute {name}: empty sub-attribute name")));
1210 1881 : }
1211 1881 : let iri = expand_attr_name(k, ctx)?;
1212 1879 : let instances = expand_attribute(k, sub, &ctx.within(&iri), opts, depth.saturating_add(1))?;
1213 : // 4.5.5.1 again, one level down: two sub-attribute names expanding to
1214 : // one IRI would drop whichever the map orders first.
1215 1847 : if out.insert(iri.clone(), Value::Array(instances)).is_some() {
1216 2 : return Err(bad(format!(
1217 2 : "attribute {name}: sub-attribute {k} expands to {iri}, which \
1218 2 : another member already defines (4.5.5.1)"
1219 2 : )));
1220 1845 : }
1221 : }
1222 :
1223 : // 5.2.1: "In all other cases, implementations shall raise an error of
1224 : // type BadRequestData if an NGSI-LD Null value is encountered" — the
1225 : // deletion marker is only meaningful on partial-update/merge inputs
1226 : // (allow_null). `json` is exempt: raw JSON is never interpreted.
1227 145777 : if !opts.allow_null {
1228 84831 : let nullish = |v: &Value| match v {
1229 22369 : Value::String(s) => s == "urn:ngsi-ld:null",
1230 43997 : Value::Array(a) => a.iter().any(|x| x.as_str() == Some("urn:ngsi-ld:null")),
1231 57802 : _ => false,
1232 84831 : };
1233 416629 : for k in ["value", "object", "vocab", "valueList", "objectList"] {
1234 416629 : if out.get(k).is_some_and(&nullish) {
1235 26 : return Err(bad(format!(
1236 26 : "attribute {name}: the NGSI-LD Null is only allowed in \
1237 26 : partial update or merge inputs (5.2.1)"
1238 26 : )));
1239 416603 : }
1240 : }
1241 83319 : if out
1242 83319 : .get("languageMap")
1243 83319 : .and_then(Value::as_object)
1244 83319 : .is_some_and(|m| m.values().any(nullish))
1245 : {
1246 2 : return Err(bad(format!(
1247 2 : "attribute {name}: the NGSI-LD Null is only allowed in \
1248 2 : partial update or merge inputs (5.2.1)"
1249 2 : )));
1250 83317 : }
1251 62432 : }
1252 :
1253 : // 5.5.4: "urn:ngsi-ld:null" as the value of a key-value pair within a
1254 : // JSON object that is the Property's value is BadRequestData — excepted
1255 : // solely for merge fragments (5.5.12). `json` stays exempt (raw JSON).
1256 145749 : if !opts.merge && out.get("value").is_some_and(has_object_member_null) {
1257 18 : return Err(bad(format!(
1258 18 : "attribute {name}: \"urn:ngsi-ld:null\" inside a compound value \
1259 18 : is only allowed in merge fragments (5.5.4)"
1260 18 : )));
1261 145731 : }
1262 :
1263 145731 : Ok(Value::Object(out))
1264 146644 : }
1265 :
1266 : /// 5.5.7: "The term to URI expansion or compaction shall be performed using a
1267 : /// @context as described by the JSON-LD specification", so a core member
1268 : /// written as its full IRI (`https://uri.etsi.org/ngsi-ld/observedAt`,
1269 : /// `.../hasValue`) or an Attribute type written as one
1270 : /// (`https://uri.etsi.org/ngsi-ld/Property`) is the same document as the one
1271 : /// using the term. Matched by the short key alone, the IRI spelling became a
1272 : /// sub-Property. `None` when nothing is spelled out.
1273 146486 : fn core_members(
1274 146486 : name: &str,
1275 146486 : obj: &Map<String, Value>,
1276 146486 : ctx: &Context,
1277 146486 : ) -> Result<Option<Map<String, Value>>, NgsiError> {
1278 397219 : let core = |k: &str| -> Option<&'static str> {
1279 397219 : if !k.starts_with(crate::context::NGSI_LD_BASE) {
1280 397179 : return None;
1281 40 : }
1282 40 : RESERVED_MEMBERS
1283 40 : .iter()
1284 40 : .copied()
1285 222 : .find(|r| *r != "type" && ctx.expand_key(r) == k)
1286 397219 : };
1287 146486 : let type_iri = obj
1288 146486 : .get("type")
1289 146486 : .and_then(Value::as_str)
1290 146486 : .and_then(|t| t.strip_prefix(crate::context::NGSI_LD_BASE))
1291 146486 : .filter(|t| ATTR_TYPES.contains(t));
1292 397161 : if type_iri.is_none() && !obj.keys().any(|k| core(k).is_some()) {
1293 146464 : return Ok(None);
1294 22 : }
1295 22 : let mut out = Map::new();
1296 58 : for (k, v) in obj {
1297 58 : let (key, v) = match (k.as_str(), core(k)) {
1298 58 : ("type", _) => (
1299 : "type",
1300 22 : type_iri.map_or_else(|| v.clone(), |t| Value::String(t.into())),
1301 : ),
1302 20 : (_, Some(term)) => (term, v.clone()),
1303 16 : (k, None) => (k, v.clone()),
1304 : };
1305 58 : if out.insert(key.to_owned(), v).is_some() {
1306 4 : return Err(NgsiError::BadRequestData(format!(
1307 4 : "attribute {name}: {key} given both as a term and as its IRI (5.5.7)"
1308 4 : )));
1309 54 : }
1310 : }
1311 18 : Ok(Some(out))
1312 146486 : }
1313 :
1314 : /// 4.5.18.2: "a JSON object consisting of a set of non-empty language tags
1315 : /// ... or the language tag "@none"", each a string or an array of strings.
1316 1782 : fn valid_language_map(lm: &Value) -> bool {
1317 1782 : lm.as_object().is_some_and(|m| {
1318 3493 : m.keys().all(|k| k == "@none" || is_language_tag(k))
1319 3465 : && m.values().all(|v| {
1320 3465 : v.is_string() || v.as_array().is_some_and(|a| a.iter().all(Value::is_string))
1321 3465 : })
1322 1780 : })
1323 1782 : }
1324 :
1325 : /// Whether a JSON value holds an `@context` member at any depth.
1326 178059 : fn embeds_context(v: &Value) -> bool {
1327 178059 : match v {
1328 1372 : Value::Object(m) => m.contains_key("@context") || m.values().any(embeds_context),
1329 6971 : Value::Array(a) => a.iter().any(embeds_context),
1330 169716 : _ => false,
1331 : }
1332 178059 : }
1333 :
1334 : /// A Property value carrying `@value` is a JSON-LD value object (JSON-LD
1335 : /// 1.1 section 9.5): "MUST NOT contain any other keys" than `@type`,
1336 : /// `@language`, `@direction`, `@index` and `@context`, and a typed one's
1337 : /// `@type` is an IRI string. Typed as `DateTime`, its `@value` is a 4.6.3
1338 : /// DateTime.
1339 22 : fn check_value_object(name: &str, vo: &Map<String, Value>, ctx: &Context) -> Result<(), NgsiError> {
1340 22 : let bad = |m: &str| Err(NgsiError::BadRequestData(format!("attribute {name}: {m}")));
1341 46 : if let Some(k) = vo.keys().find(|k| {
1342 42 : !matches!(
1343 46 : k.as_str(),
1344 46 : "@value" | "@type" | "@language" | "@direction" | "@index" | "@context"
1345 : )
1346 46 : }) {
1347 4 : return bad(&format!(
1348 4 : "value object member {k:?} is not allowed (JSON-LD 1.1 9.5)"
1349 4 : ));
1350 18 : }
1351 18 : match vo.get("@type") {
1352 2 : None => Ok(()),
1353 14 : Some(Value::String(t)) => {
1354 14 : let datetime = format!("{}DateTime", crate::context::NGSI_LD_BASE);
1355 14 : let is_datetime = ctx.expand_key(t) == datetime || *t == datetime;
1356 14 : if is_datetime
1357 8 : && !vo
1358 8 : .get("@value")
1359 8 : .and_then(Value::as_str)
1360 8 : .is_some_and(parse_datetime)
1361 : {
1362 4 : return bad("a DateTime value object needs an ISO 8601 DateTime @value (4.6.3)");
1363 10 : }
1364 10 : Ok(())
1365 : }
1366 2 : Some(_) => bad("value object @type must be a string (JSON-LD 1.1 9.5)"),
1367 : }
1368 22 : }
1369 :
1370 : /// RFC 5646 section 2.1 well-formedness, as far as the shape goes: hyphen
1371 : /// separated subtags of 1-8 letters or digits, led by a 2-8 letter language
1372 : /// or the `x`/`i` singleton of a private-use or grandfathered tag.
1373 : // No registry lookup, so a well-formed but unregistered subtag passes
1374 3483 : fn is_language_tag(tag: &str) -> bool {
1375 3483 : let mut subtags = tag.split('-');
1376 3483 : let first = subtags.next().unwrap_or_default();
1377 6920 : let lead = (2..=8).contains(&first.len()) && first.bytes().all(|b| b.is_ascii_alphabetic());
1378 3483 : let singleton = matches!(first, "x" | "X" | "i" | "I");
1379 3483 : let rest: Vec<&str> = subtags.collect();
1380 3483 : (lead || (singleton && !rest.is_empty()))
1381 3459 : && rest
1382 3459 : .iter()
1383 3459 : .all(|t| (1..=8).contains(&t.len()) && t.bytes().all(|b| b.is_ascii_alphanumeric()))
1384 3483 : }
1385 :
1386 : /// Expand a PARTIAL-UPDATE attribute fragment (5.6.4): reserved members are
1387 : /// kept, others become sub-attributes — and crucially NO attribute-type
1388 : /// inference happens (a fragment `{providedBy: …}` patches the sub-attribute,
1389 : /// it is not a concise Property value).
1390 376 : pub fn expand_attr_fragment(obj: &Map<String, Value>, ctx: &Context) -> Result<Value, NgsiError> {
1391 : /// the attribute name the member checks report: a fragment has none
1392 : const FRAGMENT: &str = "fragment";
1393 376 : let bad = NgsiError::BadRequestData;
1394 376 : let opts = ExpandOpts {
1395 376 : fragment: true,
1396 376 : allow_null: true,
1397 376 : ..Default::default()
1398 376 : };
1399 376 : let mut out = Map::new();
1400 852 : for (k, v) in obj {
1401 852 : match k.as_str() {
1402 : // 5.2.5 Table 5.2.5-2 / 4.5.2.2 System Generated: output-only
1403 : // members "shall not be provided by Context Producers. In the
1404 : // event that they are provided (in update or create operations)
1405 : // NGSI-LD implementations shall ignore them." The sealed
1406 : // subproperties are Prohibited outside a full ngsildproof
1407 : // instance, which this path cannot identify.
1408 852 : "@context" | "createdAt" | "modifiedAt" | "deletedAt" | "instanceId"
1409 690 : | "entityIdSealed" | "entityTypeSealed" => continue,
1410 686 : _ if OUTPUT_ONLY.contains(&k.as_str()) => continue,
1411 668 : "type" => {
1412 190 : let t = v
1413 190 : .as_str()
1414 190 : .filter(|t| ATTR_TYPES.contains(t))
1415 190 : .ok_or_else(|| bad("invalid attribute type in fragment".into()))?;
1416 188 : out.insert("type".into(), Value::String(t.to_owned()));
1417 : }
1418 : // 4.6.3: both members are ISO 8601 DateTimes (4.8 observedAt,
1419 : // 4.22 expiresAt) — the same check the full-instance path runs.
1420 : // 10.2.5.4: "NGSI-LD Nulls should be supported by this operation": the Null
1421 : // removes the member from the patched instance (5.5.12).
1422 478 : "observedAt" | "expiresAt" if is_ngsi_null(v) => {
1423 6 : out.insert(k.clone(), v.clone());
1424 6 : }
1425 : "observedAt" | "expiresAt" => {
1426 28 : let sdt = v
1427 28 : .as_str()
1428 28 : .filter(|s| parse_datetime(s))
1429 28 : .ok_or_else(|| bad(format!("invalid {k} in fragment")))?;
1430 18 : out.insert(k.clone(), Value::String(sdt.to_owned()));
1431 : }
1432 444 : "value" => {
1433 202 : if v.is_null() {
1434 2 : return Err(bad("JSON null is not a valid value".into()));
1435 200 : }
1436 : // 5.5.4: a null inside a compound value is legal in merge
1437 : // fragments only — a partial update (5.5.8) is not one.
1438 200 : if has_object_member_null(v) {
1439 6 : return Err(bad("\"urn:ngsi-ld:null\" inside a compound value is only \
1440 6 : allowed in merge fragments (5.5.4)"
1441 6 : .into()));
1442 194 : }
1443 : // the value rules a full Property instance obeys
1444 194 : if let Some(vo) = v.as_object().filter(|o| o.contains_key("@value")) {
1445 2 : check_value_object(FRAGMENT, vo, ctx)?;
1446 192 : }
1447 192 : if embeds_context(v) {
1448 2 : return Err(bad(
1449 2 : "embedded @context is not allowed in a fragment (4.5.1/5.5.7)".into(),
1450 2 : ));
1451 190 : }
1452 190 : out.insert("value".into(), v.clone());
1453 : }
1454 : // 4.5.5.1/5.5.8: the fragment's datasetId selects the instance to
1455 : // patch and is copied onto it — it obeys the same URI-string rule
1456 : // as a full instance, or the patched instance stops answering to
1457 : // the datasetId lookups that keep one default instance per name.
1458 242 : "datasetId" => {
1459 44 : if let Some(d) = dataset_id_member(v)? {
1460 26 : out.insert("datasetId".into(), d);
1461 26 : }
1462 : }
1463 198 : "languageMap"
1464 32 : if !(is_ngsi_null(v) || is_ngsi_null_langmap(v) || valid_language_map(v)) =>
1465 : {
1466 6 : return Err(bad("invalid languageMap in fragment (4.5.18.2)".into()));
1467 : }
1468 : // 10.2.5.4/5.5.12: a null (either form) removes the member from
1469 : // the patched instance (`removes_member`), whatever the member
1470 192 : _ if RESERVED_MEMBERS.contains(&k.as_str()) && (v.is_null() || is_ngsi_null(v)) => {
1471 42 : out.insert(k.clone(), v.clone());
1472 42 : }
1473 : // 5.5.7: the rest of the reserved members are held to the rules,
1474 : // and expanded the way, a full instance's are — or one PATCH
1475 : // stores a Relationship target that is no URI, an objectList
1476 : // outside the bare-URI internal form, or a short-term vocab that
1477 : // a create would have stored as an IRI
1478 150 : "object" => {
1479 10 : check_object(FRAGMENT, v, opts)?;
1480 6 : out.insert(k.clone(), v.clone());
1481 : }
1482 140 : "objectList" => {
1483 8 : out.insert(k.clone(), object_list(FRAGMENT, v, opts)?);
1484 : }
1485 132 : "vocab" => {
1486 8 : out.insert(k.clone(), expand_terms(FRAGMENT, k, v, ctx)?);
1487 : }
1488 124 : "valueList" if !(v.is_array() || is_ngsi_null_list(v)) => {
1489 2 : return Err(bad("valueList in fragment must be an array".into()));
1490 : }
1491 : // 5.5.7, as for a fragment's value
1492 2 : "valueList" if embeds_context(v) => {
1493 2 : return Err(bad(
1494 2 : "embedded @context is not allowed in a fragment (4.5.1/5.5.7)".into(),
1495 2 : ));
1496 : }
1497 120 : "unitCode" | "lang" | "valueType" | "objectType" => {
1498 : // the fragment may carry no type; Table 5.2.32-1's literal
1499 : // langString then still reads as the LanguageProperty's
1500 12 : let attr_type = match obj.get("type").and_then(Value::as_str) {
1501 4 : Some(t) => t,
1502 8 : None if v.as_str() == Some("langString") => "LanguageProperty",
1503 8 : None => "",
1504 : };
1505 12 : let one = Map::from_iter([(k.clone(), v.clone())]);
1506 12 : expand_common_members(FRAGMENT, &one, attr_type, ctx, opts, &mut out)?;
1507 : }
1508 108 : _ if RESERVED_MEMBERS.contains(&k.as_str()) => {
1509 28 : out.insert(k.clone(), v.clone());
1510 28 : }
1511 : _ => {
1512 80 : let iri = expand_attr_name(k, ctx)?;
1513 80 : let instances = expand_attribute(k, v, &ctx.within(&iri), opts, 1)?;
1514 : // 4.5.5.1: one Attribute name = one member of the fragment.
1515 80 : if out.insert(iri.clone(), Value::Array(instances)).is_some() {
1516 2 : return Err(bad(format!(
1517 2 : "sub-attribute {k} expands to {iri}, which another \
1518 2 : member of this fragment already defines (4.5.5.1)"
1519 2 : )));
1520 78 : }
1521 : }
1522 : }
1523 : }
1524 310 : Ok(Value::Object(out))
1525 376 : }
1526 :
1527 : /// 4.5.5.1: "There can only be one default Attribute instance for an
1528 : /// Attribute with a given Attribute name in any request or response";
1529 : /// datasetIds must be distinct per attribute (explicit "@none" is normalized
1530 : /// to absent before this check, so absent + "@none" counts as two defaults).
1531 99376 : fn validate_dataset_ids(name: &str, instances: &[Value]) -> Result<(), NgsiError> {
1532 : // A set, not a scanned list: the instance count is bounded only by the
1533 : // request body, so a linear scan per instance makes the check quadratic
1534 : // in what a client sends.
1535 99376 : let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
1536 99376 : let mut default_count = 0usize;
1537 121576 : for inst in instances {
1538 121576 : match inst.get("datasetId").and_then(Value::as_str) {
1539 24209 : Some(d) => {
1540 24209 : if !seen.insert(d) {
1541 12 : return Err(NgsiError::BadRequestData(format!(
1542 12 : "attribute {name}: duplicate datasetId {d}"
1543 12 : )));
1544 24197 : }
1545 : }
1546 97367 : None => default_count = default_count.saturating_add(1),
1547 : }
1548 : }
1549 99364 : if default_count > 1 {
1550 6 : return Err(NgsiError::BadRequestData(format!(
1551 6 : "attribute {name}: more than one instance without datasetId"
1552 6 : )));
1553 99358 : }
1554 99358 : Ok(())
1555 99376 : }
1556 :
1557 : /// RFC 7946 3.1.1: "A position is an array of numbers. There MUST be two or
1558 : /// more elements. The first two elements are longitude and latitude \[…\]
1559 : /// using decimal numbers", read in the coordinate reference system the
1560 : /// format fixes: "a geographic coordinate reference system, using the World
1561 : /// Geodetic System 1984 \[…\] datum, with longitude and latitude units of
1562 : /// decimal degrees" (RFC 7946 4). 4.7.2 adds that the coordinates are "values
1563 : /// of a JSON-LD floating point number data type".
1564 : ///
1565 : /// The range is not decoration: a latitude of 999 reaches PostGIS as a
1566 : /// `::geography` cast that errors, so a single accepted write would break
1567 : /// every later `near` query in that tenant.
1568 22787 : fn check_position(p: &Value) -> Result<(), String> {
1569 22787 : let a = p.as_array().ok_or("position is not an array")?;
1570 22761 : if a.len() < 2 {
1571 32 : return Err(format!("position has {} elements (minimum 2)", a.len()));
1572 22729 : }
1573 45466 : let mut n = a.iter().map(|c| c.as_f64().filter(|f| f.is_finite()));
1574 22729 : let (Some(Some(lon)), Some(Some(lat))) = (n.next(), n.next()) else {
1575 14 : return Err("position holds a value that is not a number".into());
1576 : };
1577 22715 : if n.any(|c| c.is_none()) {
1578 2 : return Err("position holds a value that is not a number".into());
1579 22713 : }
1580 22713 : if !(-180.0..=180.0).contains(&lon) || !(-90.0..=90.0).contains(&lat) {
1581 36 : return Err(format!(
1582 36 : "position [{lon}, {lat}] is outside the WGS84 range [-180 -90, 180 90]"
1583 36 : ));
1584 22677 : }
1585 22677 : Ok(())
1586 22787 : }
1587 :
1588 : /// RFC 7946 3.1.4: a LineString is "two or more positions".
1589 99 : fn check_line(v: &Value) -> Result<(), String> {
1590 99 : let a = v.as_array().ok_or("LineString is not an array")?;
1591 99 : if a.len() < 2 {
1592 12 : return Err(format!("LineString has {} positions (minimum 2)", a.len()));
1593 87 : }
1594 87 : a.iter().try_for_each(check_position)
1595 99 : }
1596 :
1597 : /// RFC 7946 3.1.6: a linear ring is "closed \[…\] with four or more positions",
1598 : /// "the first and last positions \[…\] equivalent".
1599 1402 : fn check_ring(v: &Value) -> Result<(), String> {
1600 1402 : let a = v.as_array().ok_or("linear ring is not an array")?;
1601 1402 : if a.len() < 4 {
1602 42 : return Err(format!("linear ring has {} positions (minimum 4)", a.len()));
1603 1360 : }
1604 1360 : if a.first() != a.last() {
1605 4 : return Err("linear ring is not closed (first != last position)".into());
1606 1356 : }
1607 1356 : a.iter().try_for_each(check_position)
1608 1402 : }
1609 :
1610 1514 : fn each(v: &Value, what: &str, f: impl FnMut(&Value) -> Result<(), String>) -> Result<(), String> {
1611 1514 : v.as_array()
1612 1514 : .ok_or_else(|| format!("{what} is not an array"))?
1613 1504 : .iter()
1614 1504 : .try_for_each(f)
1615 1514 : }
1616 :
1617 : /// The nesting RFC 7946 3.1 gives each geometry type its `coordinates`.
1618 : /// Empty multi-geometries are geometries: only the shapes the RFC names a
1619 : /// minimum for carry one.
1620 10200 : pub fn check_geometry(gtype: &str, coords: &Value) -> Result<(), String> {
1621 10200 : match gtype {
1622 10200 : "Point" => check_position(coords),
1623 1185 : "MultiPoint" => each(coords, "MultiPoint coordinates", check_position),
1624 1145 : "LineString" => check_line(coords),
1625 1050 : "MultiLineString" => each(coords, "MultiLineString coordinates", check_line),
1626 1030 : "Polygon" => each(coords, "Polygon coordinates", check_ring),
1627 428 : "MultiPolygon" => each(coords, "MultiPolygon coordinates", |p| {
1628 428 : each(p, "MultiPolygon polygon", check_ring)
1629 428 : }),
1630 4 : _ => Err(format!("{gtype} is not a supported GeoJSON geometry type")),
1631 : }
1632 10200 : }
1633 :
1634 : /// 4.6.3: supported Value geometries are "All the GeoJSON Geometries \[8\]
1635 : /// with the exception of GeometryCollection" — GEO_TYPES holds exactly that
1636 : /// set. 4.7.2 accepts a geometry "if and only if \[…\] meeting the syntax and
1637 : /// restrictions mandated by IETF RFC 7946 \[8\] when representing a valid
1638 : /// Geometry of the type specified", so the shape of `coordinates` is checked
1639 : /// against the declared type, not merely for being an array: a geometry that
1640 : /// is not one must not reach storage, the 4.5.16 GeoJSON rendering path or a
1641 : /// PostGIS cast.
1642 5294 : pub fn validate_geojson(name: &str, v: &Value) -> Result<(), NgsiError> {
1643 5294 : let bad = |m: &str| NgsiError::BadRequestData(format!("attribute {name}: {m}"));
1644 5294 : let shape = v.as_object().and_then(|o| {
1645 5284 : let t = o.get("type").and_then(Value::as_str)?;
1646 5282 : GEO_TYPES.contains(&t).then_some((t, o.get("coordinates")?))
1647 5284 : });
1648 5294 : let Some((gtype, coords)) = shape else {
1649 30 : return Err(bad("value is not a valid GeoJSON geometry"));
1650 : };
1651 5264 : check_geometry(gtype, coords).map_err(|e| bad(&e))
1652 5294 : }
1653 :
1654 : /// ISO 8601 DateTime check — 4.6.3, `YYYY-MM-DDThh:mm:ss[.ffffff]Z`.
1655 : ///
1656 : /// The clause is strict in three ways this used to get wrong:
1657 : /// - "The trailing timestamp component … shall always be equal to the
1658 : /// character `Z`. Therefore, all timestamps shall be expressed in UTC" —
1659 : /// so `+HH:MM`/`-HH:MM` offsets are INVALID, not an alternative form.
1660 : /// - "All the referred components shall appear in the string; reduced
1661 : /// representations are not permitted" — a bare 19-char form has no zone.
1662 : /// - "The Seconds component may optionally contain a decimal fraction …
1663 : /// up to a maximum of six \[digits\]. … In requests, also a comma instead of a
1664 : /// decimal point may be used as separator for compatibility reasons."
1665 : ///
1666 : /// Digit-shape alone is not enough: `2026-13-45T00:00:00Z` is all digits in
1667 : /// the right places, and letting it through let one write make every later
1668 : /// temporal query in that tenant fail on the `::timestamptz` cast.
1669 79840 : pub fn parse_datetime(s: &str) -> bool {
1670 : // the mandatory Z, then at least YYYY-MM-DDThh:mm:ss
1671 79840 : let Some(body) = s.strip_suffix('Z') else {
1672 9830 : return false;
1673 : };
1674 69984 : let [y0, y1, y2, y3, b'-', mo0, mo1, b'-', d0, d1, b'T', h0, h1, b':', mi0, mi1, b':', s0, s1, ..] =
1675 70010 : body.as_bytes()
1676 : else {
1677 26 : return false;
1678 : };
1679 69984 : if ![y0, y1, y2, y3, mo0, mo1, d0, d1, h0, h1, mi0, mi1, s0, s1]
1680 69984 : .iter()
1681 979764 : .all(|c| c.is_ascii_digit())
1682 : {
1683 2 : return false;
1684 69982 : }
1685 : // the 19 bytes matched above are ASCII, so both cuts are char boundaries
1686 69982 : let (Some(seconds), Some(frac)) = (body.get(..19), body.get(19..)) else {
1687 0 : return false;
1688 : };
1689 : // between second 19 and the trailing Z: nothing, or a fraction of 1..=6
1690 69982 : if !frac.is_empty() {
1691 2867 : let Some(rest) = frac.strip_prefix('.').or_else(|| frac.strip_prefix(',')) else {
1692 2 : return false;
1693 : };
1694 8567 : if rest.is_empty() || rest.len() > 6 || !rest.bytes().all(|c| c.is_ascii_digit()) {
1695 6 : return false;
1696 2859 : }
1697 67115 : }
1698 : // real calendar date/time, not just digits in the right slots
1699 69974 : let normalized = format!("{seconds}Z");
1700 69974 : chrono::DateTime::parse_from_rfc3339(&normalized).is_ok()
1701 79840 : }
1702 :
1703 : /// A DateTime `parse_datetime` accepted, with a comma fraction separator
1704 : /// turned into the decimal point (4.6.3: the comma only "In requests").
1705 63876 : fn point_fraction(s: &str) -> String {
1706 63876 : match s.get(19..20) {
1707 63876 : Some(",") => format!("{}.{}", &s[..19], &s[20..]),
1708 63872 : _ => s.to_owned(),
1709 : }
1710 63876 : }
1711 :
1712 : #[cfg(test)]
1713 : mod tests {
1714 : use super::*;
1715 : use serde_json::json;
1716 :
1717 : /// 4.5.5.1: explicit "datasetId": "@none" designates the default
1718 : /// instance — normalized to absent, so it never appears in responses and
1719 : /// absent + "@none" in one request is two default instances (rejected).
1720 : #[test]
1721 2 : fn dataset_id_none_is_the_default_instance() {
1722 2 : let doc = json!({"id": "urn:x", "type": "T",
1723 2 : "speed": {"type": "Property", "value": 1, "datasetId": "@none"}});
1724 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1725 2 : .expect("@none accepted");
1726 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
1727 2 : assert!(inst.get("datasetId").is_none(), "@none must be dropped");
1728 : // absent + "@none" = two defaults → BadRequestData
1729 2 : let doc = json!({"id": "urn:x", "type": "T",
1730 2 : "speed": [{"type": "Property", "value": 1},
1731 2 : {"type": "Property", "value": 2, "datasetId": "@none"}]});
1732 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1733 2 : }
1734 :
1735 : /// 4.5.5.1: "there cannot be several Attribute instances with the same
1736 : /// datasetId" — two instances naming one datasetId are BadRequestData
1737 : /// wherever the repeat sits, and the instance count of one Attribute is
1738 : /// bounded by the request body alone, so the check may not scan the
1739 : /// instances it has already seen for each new one.
1740 : #[test]
1741 2 : fn clause_4_5_5_1_a_repeated_dataset_id_is_refused_at_any_position() {
1742 8006 : let inst = |i: usize| {
1743 8006 : json!({"type": "Property", "value": i,
1744 8006 : "datasetId": format!("urn:ngsi-ld:Dataset:{i:05}")})
1745 8006 : };
1746 : const N: usize = 4000;
1747 2 : let distinct: Vec<Value> = (0..N).map(inst).collect();
1748 2 : let doc = json!({"id": "urn:ngsi-ld:X:1", "type": "T", "speed": distinct.clone()});
1749 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1750 2 : .expect("distinct datasetIds are legal however many there are");
1751 2 : assert_eq!(
1752 2 : out["https://uri.etsi.org/ngsi-ld/default-context/speed"]
1753 2 : .as_array()
1754 2 : .expect("array")
1755 2 : .len(),
1756 : N
1757 : );
1758 : // the repeat at the front, in the middle and at the end: a check that
1759 : // stops early, or one that only compares neighbours, misses two of them
1760 6 : for at in [1usize, N / 2, N - 1] {
1761 6 : let mut insts = distinct.clone();
1762 6 : insts[at] = inst(0);
1763 6 : let doc = json!({"id": "urn:ngsi-ld:X:1", "type": "T", "speed": insts});
1764 6 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
1765 6 : let Err(NgsiError::BadRequestData(msg)) = out else {
1766 0 : panic!("a repeat at {at} must be BadRequestData: {out:?}");
1767 : };
1768 6 : assert!(msg.contains("duplicate datasetId"), "{msg}");
1769 : }
1770 2 : }
1771 :
1772 : /// 4.5.2.2 / C.11: "ngsildproof": a Property with the non-reified
1773 : /// subproperties "entityIdSealed" and "entityTypeSealed" as specified
1774 : /// in [35]; annex B maps entityIdSealed as a plain term and
1775 : /// entityTypeSealed with "@type": "@vocab" (the value expands like a
1776 : /// type name). Both must survive the expand→compact round trip — they
1777 : /// were RESERVED_MEMBERS with no explicit copy and silently vanished.
1778 : #[test]
1779 2 : fn ngsildproof_sealed_members_round_trip() {
1780 2 : let doc = json!({"id": "urn:ngsi-ld:Store:002", "type": "Store",
1781 2 : "ngsildproof": {"type": "Property",
1782 2 : "entityIdSealed": "urn:ngsi-ld:Store:002",
1783 2 : "entityTypeSealed": "Store",
1784 2 : "value": {"type": "DataIntegrityProof",
1785 2 : "cryptosuite": "eddsa-rdfc-2022",
1786 2 : "created": "2025-01-27T21:02:24Z",
1787 2 : "proofPurpose": "assertionMethod",
1788 2 : "proofValue": "zQeVbY4oey5q2M3XKaxup3tmzN4DRFTLVqpLMweBrSxMY"}}});
1789 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1790 2 : .expect("C.11-shaped ngsildproof is valid");
1791 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/ngsildproof"][0];
1792 2 : assert_eq!(inst["entityIdSealed"], "urn:ngsi-ld:Store:002");
1793 2 : assert_eq!(
1794 2 : inst["entityTypeSealed"], "https://uri.etsi.org/ngsi-ld/default-context/Store",
1795 : "entityTypeSealed is @vocab-coerced (annex B)"
1796 : );
1797 :
1798 2 : let back = crate::compact::compact_entity(&out, &core());
1799 2 : let np = &back["ngsildproof"];
1800 2 : assert_eq!(np["entityIdSealed"], "urn:ngsi-ld:Store:002");
1801 2 : assert_eq!(np["entityTypeSealed"], "Store", "compacts back to the term");
1802 2 : assert_eq!(
1803 2 : np["value"]["proofValue"], "zQeVbY4oey5q2M3XKaxup3tmzN4DRFTLVqpLMweBrSxMY",
1804 : "the W3C proof structure is untouched"
1805 : );
1806 : // negative: non-reified means BARE strings — never Property objects,
1807 : // and never dropped
1808 2 : assert!(np["entityIdSealed"].is_string());
1809 2 : assert!(np["entityTypeSealed"].is_string());
1810 2 : }
1811 :
1812 : /// 4.5.2.2: the sealed members are strings ([35] seals the entity id and
1813 : /// type); "The value of its \"value\" element shall be an object
1814 : /// containing the W3C Data integrity \"proof\" structure" — a
1815 : /// non-object proof value is BadRequestData.
1816 : #[test]
1817 2 : fn ngsildproof_shapes_are_validated() {
1818 : // non-string sealed members
1819 4 : for bad_seal in [json!(42), json!({"type": "Property", "value": true})] {
1820 4 : let doc = json!({"id": "urn:x", "type": "Store",
1821 4 : "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
1822 4 : "entityIdSealed": bad_seal}});
1823 4 : assert!(
1824 4 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1825 : "entityIdSealed must be a string"
1826 : );
1827 : }
1828 2 : let doc = json!({"id": "urn:x", "type": "Store",
1829 2 : "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
1830 2 : "entityTypeSealed": ["Store"]}});
1831 2 : assert!(
1832 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1833 : "entityTypeSealed must be a string"
1834 : );
1835 : // the proof value shall be an object
1836 2 : let doc = json!({"id": "urn:x", "type": "Store",
1837 2 : "ngsildproof": {"type": "Property", "value": "not-a-proof"}});
1838 2 : assert!(
1839 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1840 : "ngsildproof value shall be an object (4.5.2.2)"
1841 : );
1842 : // sealed members on an ordinary attribute stay rejected (the
1843 : // existing 4.5.2.2 guard — pinned here as the negative pair)
1844 2 : let doc = json!({"id": "urn:x", "type": "Store",
1845 2 : "speed": {"type": "Property", "value": 1, "entityIdSealed": "urn:x"}});
1846 2 : assert!(
1847 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1848 : "sealed members only under ngsildproof"
1849 : );
1850 2 : }
1851 :
1852 : /// 4.15: the language filter augments the converted Property with "a
1853 : /// non-reified subproperty lang indicating the actual language
1854 : /// returned" — a langtag string (RFC 5646). The member is broker-
1855 : /// produced, and the clause is silent on a client supplying one, so it
1856 : /// is stored; it is not stored in a shape no consumer can read. Every
1857 : /// other non-reified member of an instance (unitCode, valueType,
1858 : /// datasetId, observedAt) is checked here, and lang was copied through
1859 : /// whatever its JSON type.
1860 : #[test]
1861 2 : fn clause_4_15_a_supplied_lang_member_is_a_string() {
1862 8 : for bad_lang in [json!({"en": "x"}), json!(["fr"]), json!(7), json!(true)] {
1863 8 : let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle",
1864 8 : "street": {"type": "Property", "value": "Grand Place", "lang": bad_lang}});
1865 8 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
1866 8 : assert!(out.is_err(), "lang must be a langtag string: {out:?}");
1867 : }
1868 2 : let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle",
1869 2 : "street": {"type": "Property", "value": "Grand Place", "lang": "fr"}});
1870 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1871 2 : .expect("a langtag string is kept");
1872 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/street"][0];
1873 2 : assert_eq!(inst["lang"], "fr");
1874 : // and it stays a member of the instance, never a reified
1875 : // sub-attribute of its own
1876 2 : assert!(
1877 2 : out.get("https://uri.etsi.org/ngsi-ld/default-context/lang")
1878 2 : .is_none(),
1879 : "lang is non-reified: {out}"
1880 : );
1881 2 : }
1882 :
1883 : /// 4.5.3.2 Prohibited: on a Relationship "entityIdSealed" and
1884 : /// "entityTypeSealed" shall never be present — flat, with no exception.
1885 : /// 4.5.2.2 and 4.5.2.3 write the only exception there is as "unless the
1886 : /// PROPERTY name is ngsildproof", and the member itself is defined as
1887 : /// "a Property ... with the non-reified subproperties". The attribute
1888 : /// name alone is therefore not the test: an attribute called
1889 : /// ngsildproof that is not a Property seals nothing.
1890 : #[test]
1891 2 : fn clause_4_5_3_2_sealed_members_are_carried_by_a_property_only() {
1892 8 : for not_a_property in [
1893 2 : json!({"type": "Relationship", "object": "urn:ngsi-ld:Store:1",
1894 2 : "entityIdSealed": "urn:ngsi-ld:Store:1"}),
1895 2 : json!({"type": "Relationship", "object": "urn:ngsi-ld:Store:1",
1896 2 : "entityTypeSealed": "Store"}),
1897 2 : json!({"type": "LanguageProperty", "languageMap": {"en": "x"},
1898 2 : "entityIdSealed": "urn:ngsi-ld:Store:1"}),
1899 2 : json!({"type": "ListRelationship", "objectList": ["urn:ngsi-ld:Store:1"],
1900 2 : "entityTypeSealed": "Store"}),
1901 2 : ] {
1902 8 : let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
1903 8 : "ngsildproof": not_a_property});
1904 8 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
1905 8 : assert!(
1906 8 : out.is_err(),
1907 : "a non-Property ngsildproof carries no sealed member: {out:?}"
1908 : );
1909 : }
1910 : // the concise form infers the attribute type, and the inference
1911 : // decides the same way: `object` makes this a Relationship
1912 2 : let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
1913 2 : "ngsildproof": {"object": "urn:ngsi-ld:Store:1",
1914 2 : "entityIdSealed": "urn:ngsi-ld:Store:1"}});
1915 2 : assert!(
1916 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1917 : "the concise Relationship form is a Relationship (4.5.3.3)"
1918 : );
1919 : // and the Property form the clause does allow still round-trips
1920 2 : let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
1921 2 : "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
1922 2 : "entityIdSealed": "urn:ngsi-ld:Store:1",
1923 2 : "entityTypeSealed": "Store"}});
1924 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1925 2 : .expect("the ngsildproof Property is the one carrier");
1926 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/ngsildproof"][0];
1927 2 : assert_eq!(inst["entityIdSealed"], "urn:ngsi-ld:Store:1");
1928 2 : }
1929 :
1930 : /// 4.5.3.3: "type: If missing, Relationship can be inferred by the
1931 : /// presence of the object attribute" — and the shared prohibitions apply
1932 : /// to the inferred instance too.
1933 : #[test]
1934 2 : fn concise_relationship_inference() {
1935 2 : let doc = json!({"id": "urn:x", "type": "T",
1936 2 : "isParked": {"object": "urn:ngsi-ld:P:1",
1937 2 : "observedAt": "2026-01-01T00:00:00Z"}});
1938 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1939 2 : .expect("concise relationship");
1940 2 : let rel = &out["https://uri.etsi.org/ngsi-ld/default-context/isParked"][0];
1941 2 : assert_eq!(rel["type"], "Relationship");
1942 2 : assert_eq!(rel["object"], "urn:ngsi-ld:P:1");
1943 : // inferred Relationship still rejects a Property value member
1944 2 : let doc = json!({"id": "urn:x", "type": "T",
1945 2 : "isParked": {"object": "urn:ngsi-ld:P:1", "value": 1}});
1946 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
1947 2 : }
1948 :
1949 : /// 4.5.3.2: a normalized Relationship "shall never include" unitCode
1950 : /// ("Relationships are unitless") or the value-defining members of the
1951 : /// Property family — while objectType stays a legal optional member.
1952 : #[test]
1953 2 : fn relationship_prohibited_members_rejected() {
1954 12 : let mk = |extra: (&str, Value)| {
1955 12 : json!({
1956 12 : "id": "urn:x", "type": "T",
1957 12 : "isParked": {"type": "Relationship", "object": "urn:ngsi-ld:P:1",
1958 12 : extra.0: extra.1}
1959 : })
1960 12 : };
1961 10 : for (m, v) in [
1962 2 : ("unitCode", json!("MTR")),
1963 2 : ("value", json!(1)),
1964 2 : ("languageMap", json!({"en": "x"})),
1965 2 : ("valueList", json!([1])),
1966 2 : ("previousObject", json!("urn:a")),
1967 2 : ] {
1968 10 : let doc = mk((m, v));
1969 10 : assert!(
1970 10 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
1971 : "{m} must be prohibited on a Relationship"
1972 : );
1973 : }
1974 2 : let ok = mk(("objectType", json!("Parking")));
1975 2 : assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
1976 2 : }
1977 :
1978 : /// 4.5.2.3: concise Property forms — a geometry-shaped value infers
1979 : /// GeoProperty (both as the whole object and as the value member); an
1980 : /// object carrying a "type" member is treated as normalized; a concise
1981 : /// object mixing value with another type's defining member rejects.
1982 : #[test]
1983 2 : fn concise_property_inference_rules() {
1984 2 : let geo = json!({"type": "Point", "coordinates": [1.0, 2.0]});
1985 : // whole object IS the geometry
1986 2 : let doc = json!({"id": "urn:x", "type": "T", "area": geo});
1987 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1988 2 : .expect("bare geometry");
1989 2 : assert_eq!(
1990 2 : out["https://uri.etsi.org/ngsi-ld/default-context/area"][0]["type"],
1991 : "GeoProperty"
1992 : );
1993 : // geometry as the value member of a type-less object
1994 2 : let doc = json!({"id": "urn:x", "type": "T",
1995 2 : "area": {"value": {"type": "Point", "coordinates": [1.0, 2.0]},
1996 2 : "observedAt": "2026-01-01T00:00:00Z"}});
1997 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
1998 2 : .expect("geometry value");
1999 2 : assert_eq!(
2000 2 : out["https://uri.etsi.org/ngsi-ld/default-context/area"][0]["type"],
2001 : "GeoProperty"
2002 : );
2003 : // an object with a "type" member is normalized — unknown type rejects
2004 2 : let doc = json!({"id": "urn:x", "type": "T", "a": {"type": "Custom", "x": 1}});
2005 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2006 : // concise mix of value and a foreign defining member rejects
2007 2 : let doc = json!({"id": "urn:x", "type": "T",
2008 2 : "a": {"value": 1, "languageMap": {"en": "x"}}});
2009 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2010 2 : }
2011 :
2012 : /// 4.5.2.2: a normalized Property "shall never include" the value-defining
2013 : /// members of other attribute types, output-only members, or the sealed
2014 : /// members outside ngsildproof — and valueType coerces to a datatype URI.
2015 : #[test]
2016 2 : fn property_prohibited_members_rejected() {
2017 22 : let mk = |extra: (&str, Value)| {
2018 22 : json!({
2019 22 : "id": "urn:x", "type": "T",
2020 22 : "speed": {"type": "Property", "value": 1, extra.0: extra.1}
2021 : })
2022 22 : };
2023 22 : for (m, v) in [
2024 2 : ("object", json!("urn:ngsi-ld:other:1")),
2025 2 : ("languageMap", json!({"en": "hi"})),
2026 2 : ("json", json!({"k": 1})),
2027 2 : ("vocab", json!("term")),
2028 2 : ("valueList", json!([1, 2])),
2029 2 : ("objectList", json!(["urn:a"])),
2030 2 : ("entity", json!({"id": "urn:a", "type": "T"})),
2031 2 : ("entityList", json!([])),
2032 2 : ("previousValue", json!(0)),
2033 2 : ("previousObject", json!("urn:a")),
2034 2 : ("entityIdSealed", json!(true)),
2035 2 : ] {
2036 22 : let doc = mk((m, v));
2037 22 : assert!(
2038 22 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
2039 : "{m} must be prohibited on a Property"
2040 : );
2041 : }
2042 : // valueType is a legal optional member and coerces to a datatype URI
2043 2 : let doc = json!({
2044 2 : "id": "urn:x", "type": "T",
2045 2 : "speed": {"type": "Property", "value": 1.5, "valueType": "xsd:double"}
2046 : });
2047 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2048 2 : .expect("valueType is optional");
2049 2 : let attr = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
2050 2 : assert!(attr["valueType"]
2051 2 : .as_str()
2052 2 : .is_some_and(|s| s.contains("double")));
2053 2 : }
2054 :
2055 : /// 4.5.1: "Terms defined in the Core Context as non-reified Properties
2056 : /// (such as datasetId, instanceId, etc.) shall not be used as Attribute
2057 : /// names."
2058 : #[test]
2059 2 : fn core_non_reified_terms_rejected_as_attribute_names() {
2060 8 : for name in ["datasetId", "instanceId", "observedAt", "unitCode"] {
2061 8 : let doc = json!({
2062 8 : "id": "urn:x", "type": "T",
2063 8 : name: {"type": "Property", "value": 1}
2064 : });
2065 8 : assert!(
2066 8 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
2067 : "{name} must be rejected as an Attribute name"
2068 : );
2069 : }
2070 2 : let ok = json!({
2071 2 : "id": "urn:x", "type": "T",
2072 2 : "speed": {"type": "Property", "value": 1}
2073 : });
2074 2 : assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
2075 2 : }
2076 :
2077 : /// 4.5.1: "Attributes shall not contain any embedded @context" — 5.5.7:
2078 : /// such content "should result in an error of type BadRequestData".
2079 : #[test]
2080 2 : fn embedded_context_in_attribute_rejected() {
2081 2 : let doc = json!({
2082 2 : "id": "urn:x", "type": "T",
2083 2 : "speed": {"type": "Property", "value": 1,
2084 2 : "@context": {"speed": "https://evil.example/speed"}}
2085 : });
2086 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2087 : // nested inside a sub-attribute as well
2088 2 : let doc = json!({
2089 2 : "id": "urn:x", "type": "T",
2090 2 : "speed": {"type": "Property", "value": 1,
2091 2 : "source": {"type": "Property", "value": "s",
2092 2 : "@context": {"x": "https://e/x"}}}
2093 : });
2094 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2095 : // inside a structured Property value, normalized and concise
2096 4 : for speed in [
2097 2 : json!({"type": "Property", "value": {"street": "Main", "@context": "https://e/c"}}),
2098 2 : json!({"street": "Main", "n": [{"@context": "https://e/c"}]}),
2099 2 : ] {
2100 4 : let doc = json!({"id": "urn:x", "type": "T", "speed": speed});
2101 4 : assert!(
2102 4 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err()
2103 : );
2104 : }
2105 : // a JsonProperty's json is opaque (@json): an @context there is data
2106 2 : let doc = json!({"id": "urn:x", "type": "T",
2107 2 : "cfg": {"type": "JsonProperty", "json": {"@context": "https://e/c"}}});
2108 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
2109 2 : }
2110 :
2111 : /// The core @context aliases type to @type (Coraine
2112 : /// duplicate_id_type_alias_every_level 01, 02, 04).
2113 : #[test]
2114 2 : fn attribute_type_keyword_alias() {
2115 2 : let doc = json!({"id": "urn:x", "type": "T", "p": {"@type": "Property", "value": 1}});
2116 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).unwrap();
2117 2 : let p = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
2118 2 : assert_eq!(p["type"], json!("Property"));
2119 2 : assert_eq!(p.get("@type"), None);
2120 4 : for p in [
2121 2 : json!({"type": "Property", "@type": "Property", "value": 1}),
2122 2 : json!({"type": "Property", "value": 1, "s": {"type": "Property", "@type": "Property", "value": 2}}),
2123 2 : ] {
2124 4 : let doc = json!({"id": "urn:x", "type": "T", "p": p});
2125 4 : assert!(
2126 4 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err()
2127 : );
2128 : }
2129 2 : }
2130 :
2131 : /// 5.2.6: a Relationship has no valueType member (Coraine value_type_subattr 03).
2132 : #[test]
2133 2 : fn value_type_on_a_relationship_rejected() {
2134 4 : for r in [
2135 2 : json!({"type": "Relationship", "object": "urn:o", "valueType": "Integer"}),
2136 2 : json!({"type": "ListRelationship", "objectList": [{"object": "urn:o"}], "valueType": "Integer"}),
2137 2 : ] {
2138 4 : let doc = json!({"id": "urn:x", "type": "T", "r": r});
2139 4 : assert!(
2140 4 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err()
2141 : );
2142 : }
2143 2 : let ok = json!({"id": "urn:x", "type": "T", "p": {"type": "Property", "value": 1, "valueType": "Integer"}});
2144 2 : assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
2145 2 : }
2146 :
2147 : /// 5.5.7: a core member or Attribute type spelled as its full IRI
2148 : /// expands to the same document as the term.
2149 : #[test]
2150 2 : fn a_core_member_iri_is_the_member() {
2151 12 : let iri = |t: &str| format!("{}{t}", crate::context::NGSI_LD_BASE);
2152 2 : let pairs = [
2153 2 : (
2154 2 : json!({"type": "Property", "value": 1, iri("observedAt"): "2026-01-01T00:00:00Z"}),
2155 2 : json!({"type": "Property", "value": 1, "observedAt": "2026-01-01T00:00:00Z"}),
2156 2 : ),
2157 2 : (
2158 2 : json!({"type": iri("Relationship"), iri("hasObject"): "urn:o"}),
2159 2 : json!({"type": "Relationship", "object": "urn:o"}),
2160 2 : ),
2161 2 : (
2162 2 : json!({"type": iri("Unknown"), "value": 1}),
2163 2 : json!({"type": iri("Unknown"), "value": 1}),
2164 2 : ),
2165 2 : ];
2166 6 : for (sent, same) in pairs {
2167 6 : let a = json!({"id": "urn:x", "type": "T", "p": sent});
2168 6 : let b = json!({"id": "urn:x", "type": "T", "p": same});
2169 6 : let got = expand_entity(a.as_object().unwrap(), &core(), ExpandOpts::default())
2170 6 : .map_err(|e| e.to_string());
2171 6 : let want = expand_entity(b.as_object().unwrap(), &core(), ExpandOpts::default())
2172 6 : .map_err(|e| e.to_string());
2173 6 : assert_eq!(got, want);
2174 : }
2175 2 : let both = json!({"id": "urn:x", "type": "T", "p": {"type": "Property", "value": 1, iri("hasValue"): 2}});
2176 2 : let err = expand_entity(both.as_object().unwrap(), &core(), ExpandOpts::default())
2177 2 : .map_err(|e| e.to_string());
2178 2 : let refused = err
2179 2 : .err()
2180 2 : .map(|e| e.contains("given both as a term and as its IRI"));
2181 2 : assert_eq!(refused, Some(true));
2182 2 : }
2183 :
2184 536 : fn core() -> std::sync::Arc<Context> {
2185 536 : std::sync::Arc::new(crate::core_context())
2186 536 : }
2187 :
2188 : #[test]
2189 2 : fn expands_simple_entity() {
2190 2 : let doc = serde_json::json!({
2191 2 : "id": "urn:ngsi-ld:Building:1",
2192 2 : "type": "Building",
2193 2 : "name": {"type": "Property", "value": "Eiffel Tower"}
2194 : });
2195 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2196 2 : .expect("expand");
2197 2 : assert_eq!(out["id"], "urn:ngsi-ld:Building:1");
2198 2 : assert_eq!(
2199 2 : out["type"][0],
2200 : "https://uri.etsi.org/ngsi-ld/default-context/Building"
2201 : );
2202 2 : let name = &out["https://uri.etsi.org/ngsi-ld/default-context/name"];
2203 2 : assert_eq!(name[0]["value"], "Eiffel Tower");
2204 2 : }
2205 :
2206 : #[test]
2207 2 : fn expires_at_kept_as_meta_not_property() {
2208 : // 4.22: a top-level expiresAt must survive as a bare DateTime string
2209 : // (the shape the read-boundary filter / GC / expires_at column read),
2210 : // never as a Property under its IRI.
2211 2 : let doc = serde_json::json!({
2212 2 : "id": "urn:ngsi-ld:T:1",
2213 2 : "type": "T",
2214 2 : "expiresAt": "2020-01-01T00:00:00Z",
2215 2 : "foo": {"type": "Property", "value": 1}
2216 : });
2217 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2218 2 : .expect("expand");
2219 2 : assert_eq!(out["expiresAt"], "2020-01-01T00:00:00Z");
2220 2 : assert!(out.get("https://uri.etsi.org/ngsi-ld/expiresAt").is_none());
2221 : // a non-DateTime expiresAt is rejected
2222 2 : let bad = serde_json::json!({"id": "urn:ngsi-ld:T:2", "type": "T", "expiresAt": "soon"});
2223 2 : assert!(expand_entity(bad.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2224 2 : }
2225 :
2226 : #[test]
2227 2 : fn concise_and_multi_instance() {
2228 2 : let doc = serde_json::json!({
2229 2 : "id": "urn:ngsi-ld:Vehicle:1",
2230 2 : "type": "Vehicle",
2231 2 : "speed": 55,
2232 2 : "brand": [
2233 2 : {"type": "Property", "value": "Volvo", "datasetId": "urn:ngsi-ld:d:1"},
2234 2 : {"type": "Property", "value": "Ford"}
2235 : ]
2236 : });
2237 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2238 2 : .expect("expand");
2239 2 : let speed = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"];
2240 2 : assert_eq!(speed[0]["type"], "Property");
2241 2 : assert_eq!(speed[0]["value"], 55);
2242 2 : let brand = &out["https://uri.etsi.org/ngsi-ld/default-context/brand"];
2243 2 : assert_eq!(brand.as_array().unwrap().len(), 2);
2244 2 : }
2245 :
2246 : /// 4.5.2.2: "type": "Equal to "Property""; a type that is no string
2247 : /// names no attribute type (Coraine attribute_type_invalid).
2248 : #[test]
2249 2 : fn rejects_an_attribute_type_that_is_no_string() {
2250 6 : for t in [
2251 2 : serde_json::json!(42),
2252 2 : serde_json::json!(["Property"]),
2253 2 : serde_json::json!({}),
2254 2 : ] {
2255 6 : let doc = serde_json::json!({"id": "urn:a", "type": "T", "p": {"type": t, "value": 1}});
2256 6 : let err = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
2257 6 : assert!(err.is_err(), "{t}");
2258 : }
2259 2 : }
2260 :
2261 : /// 4.6.3: the fraction separator is "a decimal point"; "In requests, also
2262 : /// a comma ... may be used", so what is stored, and later answered, uses
2263 : /// the point (Coraine datetime_comma_separator).
2264 : #[test]
2265 2 : fn a_comma_fraction_is_stored_with_a_decimal_point() {
2266 2 : let doc = serde_json::json!({"id": "urn:a", "type": "T", "p": {"type": "Property", "value": 1,
2267 2 : "observedAt": "2026-04-28T10:00:00,5Z", "expiresAt": "2099-01-01T00:00:00,25Z"}});
2268 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2269 2 : .expect("expand");
2270 2 : let p = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
2271 2 : assert_eq!(p["observedAt"], "2026-04-28T10:00:00.5Z");
2272 2 : assert_eq!(p["expiresAt"], "2099-01-01T00:00:00.25Z");
2273 2 : }
2274 :
2275 : /// 4.5.18.2: languageMap is "a JSON object"; a partial update fragment
2276 : /// is held to it as a create is (Coraine langprop_patch_attribute).
2277 : #[test]
2278 2 : fn a_fragment_language_map_is_an_object_of_strings() {
2279 6 : for bad in [
2280 2 : serde_json::json!(13),
2281 2 : serde_json::json!({"en": 1}),
2282 2 : serde_json::json!({"": "x"}),
2283 2 : ] {
2284 6 : let f = serde_json::json!({"languageMap": bad});
2285 6 : assert!(
2286 6 : expand_attr_fragment(f.as_object().unwrap(), &core()).is_err(),
2287 : "{bad}"
2288 : );
2289 : }
2290 6 : for ok in [
2291 2 : serde_json::json!({"en": "x", "es": ["y"]}),
2292 2 : serde_json::json!({"@none": "urn:ngsi-ld:null"}),
2293 2 : serde_json::json!("urn:ngsi-ld:null"),
2294 2 : ] {
2295 6 : let f = serde_json::json!({"languageMap": ok});
2296 6 : assert!(
2297 6 : expand_attr_fragment(f.as_object().unwrap(), &core()).is_ok(),
2298 : "{ok}"
2299 : );
2300 : }
2301 2 : }
2302 :
2303 : /// JSON-LD 1.1 9.5 value objects and 4.6.3 DateTime in a Property value
2304 : /// (Coraine value_type_atvalue_validation, entity_attr_typed_value_object).
2305 : #[test]
2306 2 : fn a_property_value_object_is_checked() {
2307 16 : let attempt = |value: serde_json::Value| {
2308 16 : let doc = serde_json::json!({"id": "urn:ngsi-ld:E:1", "type": "T",
2309 16 : "p": {"type": "Property", "value": value}});
2310 16 : expand_entity(
2311 16 : doc.as_object().expect("object"),
2312 16 : &core(),
2313 16 : ExpandOpts::default(),
2314 : )
2315 16 : };
2316 8 : for bad in [
2317 2 : serde_json::json!({"@type": 1, "@value": "2018-12-04T12:00:00Z"}),
2318 2 : serde_json::json!({"@type": "DateTime", "a": 19, "@value": "2018-12-04T12:00:00Z"}),
2319 2 : serde_json::json!({"@type": "DateTime", "@value": 12}),
2320 2 : serde_json::json!({"@type": "DateTime", "@value": "banana"}),
2321 2 : ] {
2322 8 : assert!(attempt(bad.clone()).is_err(), "{bad}");
2323 : }
2324 8 : for ok in [
2325 2 : serde_json::json!({"@type": "DateTime", "@value": "2018-12-04T12:00:00Z"}),
2326 2 : serde_json::json!({"@type": "xsd:integer", "@value": 42}),
2327 2 : serde_json::json!({"@value": "plain", "@language": "en"}),
2328 2 : serde_json::json!({"a": 1, "b": 2}),
2329 2 : ] {
2330 8 : assert!(attempt(ok.clone()).is_ok(), "{ok}");
2331 : }
2332 2 : }
2333 :
2334 : /// 4.5.18.2: languageMap keys are RFC 5646 language tags (Coraine
2335 : /// languagemap_key_rfc5646).
2336 : #[test]
2337 2 : fn language_map_keys_are_rfc_5646_tags() {
2338 12 : for ok in [
2339 2 : "en",
2340 2 : "en-US",
2341 2 : "zh-Hans-CN",
2342 2 : "x-private",
2343 2 : "i-klingon",
2344 2 : "@none",
2345 2 : ] {
2346 12 : assert!(valid_language_map(&serde_json::json!({ok: "t"})), "{ok}");
2347 : }
2348 18 : for bad in [
2349 2 : "",
2350 2 : "123",
2351 2 : "!!",
2352 2 : "en_US",
2353 2 : "en-",
2354 2 : "e",
2355 2 : "x",
2356 2 : "this is not a language",
2357 2 : "en-toolongsubtag",
2358 2 : ] {
2359 18 : assert!(!valid_language_map(&serde_json::json!({bad: "t"})), "{bad}");
2360 : }
2361 2 : }
2362 :
2363 : /// 4.5.0, 4.5.1: the NGSI-LD Null is never an Entity Type, alone or in
2364 : /// an array (Coraine entity_member_delete_and_null 12-13).
2365 : #[test]
2366 2 : fn the_ngsi_null_is_no_entity_type() {
2367 4 : for v in [
2368 2 : serde_json::json!("urn:ngsi-ld:null"),
2369 2 : serde_json::json!(["T", "urn:ngsi-ld:null"]),
2370 2 : ] {
2371 4 : assert!(expand_types(&v, &core()).is_err(), "{v}");
2372 : }
2373 2 : assert!(expand_types(&serde_json::json!("T"), &core()).is_ok());
2374 2 : }
2375 :
2376 : #[test]
2377 2 : fn rejects_missing_type() {
2378 2 : let doc = serde_json::json!({"id": "urn:ngsi-ld:Building:1"});
2379 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2380 2 : }
2381 :
2382 : /// 4.8: "Temporal Properties in NGSI-LD shall be represented based on
2383 : /// the DateTime data type as mandated by clause 4.6.3" and "a
2384 : /// TemporalProperty does not allow reification" — only a valid UTC
2385 : /// DateTime STRING is a legal observedAt.
2386 : #[test]
2387 2 : fn rejects_bad_observed_at() {
2388 6 : for bad in [
2389 2 : serde_json::json!("not-a-date"),
2390 2 : // 4.6.3: trailing component shall be Z — offsets are invalid
2391 2 : serde_json::json!("2026-08-10T12:00:00+02:00"),
2392 2 : // non-reified: a Property-shaped observedAt is not a DateTime
2393 2 : serde_json::json!({"type": "Property", "value": "2026-08-10T12:00:00Z"}),
2394 2 : ] {
2395 6 : let doc = serde_json::json!({
2396 6 : "id": "urn:ngsi-ld:Building:1",
2397 6 : "type": "Building",
2398 6 : "a": {"type": "Property", "value": 1, "observedAt": bad}
2399 : });
2400 6 : assert!(
2401 6 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
2402 : "observedAt {bad} must be rejected (4.8/4.6.3)"
2403 : );
2404 : }
2405 2 : }
2406 :
2407 : #[test]
2408 2 : fn relationship_needs_uri_object() {
2409 2 : let doc = serde_json::json!({
2410 2 : "id": "urn:ngsi-ld:A:1",
2411 2 : "type": "T",
2412 2 : "rel": {"type": "Relationship", "object": "not a uri"}
2413 : });
2414 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2415 2 : }
2416 :
2417 : /// 4.2.2 Meta Model: "An NGSI-LD Property shall have a value, stated
2418 : /// through hasValue. An NGSI-LD Relationship shall have an object stated
2419 : /// through hasObject." The member is REQUIRED — a typed attribute
2420 : /// without it is rejected, per specialized type as well (5.2.32/5.2.38).
2421 : #[test]
2422 2 : fn meta_model_required_member_per_attribute_type() {
2423 14 : for (ty, wrong_member) in [
2424 2 : ("Property", "object"),
2425 2 : ("Relationship", "value"),
2426 2 : ("LanguageProperty", "value"),
2427 2 : ("JsonProperty", "value"),
2428 2 : ("VocabProperty", "value"),
2429 2 : ("ListProperty", "value"),
2430 2 : ("ListRelationship", "valueList"),
2431 2 : ] {
2432 14 : let doc = serde_json::json!({
2433 14 : "id": "urn:ngsi-ld:A:1",
2434 14 : "type": "T",
2435 14 : "attr": {"type": ty, wrong_member: "x"}
2436 : });
2437 14 : assert!(
2438 14 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
2439 : "{ty} without its required member must be rejected (4.2.2)"
2440 : );
2441 : }
2442 2 : }
2443 :
2444 : /// 4.2.2: "An NGSI-LD Value shall be either a rdfs:Literal or a node
2445 : /// object" — every JSON literal, array and object is a legal value;
2446 : /// a bare JSON null is NOT (the null sentinel is the string form,
2447 : /// 4.5.2 / 057_03_02).
2448 : #[test]
2449 2 : fn meta_model_value_space() {
2450 12 : for v in [
2451 2 : serde_json::json!(17),
2452 2 : serde_json::json!(1.5),
2453 2 : serde_json::json!(true),
2454 2 : serde_json::json!("text"),
2455 2 : serde_json::json!([1, 2, 3]),
2456 2 : serde_json::json!({"nested": {"deep": [1]}}),
2457 2 : ] {
2458 12 : let doc = serde_json::json!({
2459 12 : "id": "urn:ngsi-ld:A:1", "type": "T",
2460 12 : "attr": {"type": "Property", "value": v}
2461 : });
2462 12 : assert!(
2463 12 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok(),
2464 : "literal/node-object value must be accepted (4.2.2): {v}"
2465 : );
2466 : }
2467 2 : let doc = serde_json::json!({
2468 2 : "id": "urn:ngsi-ld:A:1", "type": "T",
2469 2 : "attr": {"type": "Property", "value": null}
2470 : });
2471 2 : assert!(
2472 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
2473 : "bare JSON null is not an NGSI-LD Value (4.2.2/4.5.2)"
2474 : );
2475 2 : }
2476 :
2477 : #[test]
2478 2 : fn location_must_be_geo() {
2479 2 : let doc = serde_json::json!({
2480 2 : "id": "urn:ngsi-ld:A:1",
2481 2 : "type": "T",
2482 2 : "location": {"type": "Property", "value": 3}
2483 : });
2484 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
2485 2 : let ok = serde_json::json!({
2486 2 : "id": "urn:ngsi-ld:A:1",
2487 2 : "type": "T",
2488 2 : "location": {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [1.0, 2.0]}}
2489 : });
2490 2 : assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
2491 2 : }
2492 :
2493 : #[test]
2494 2 : fn datetime_validation() {
2495 : // 4.6.3, p.80-81. Accepted forms:
2496 2 : assert!(parse_datetime("2020-09-09T16:40:00Z"));
2497 2 : assert!(parse_datetime("2020-09-09T16:40:00.000Z"));
2498 2 : assert!(
2499 2 : parse_datetime("2020-09-09T16:40:00.123456Z"),
2500 : "6 fraction digits"
2501 : );
2502 : // "In requests, also a comma instead of a decimal point may be used as
2503 : // separator for compatibility reasons."
2504 2 : assert!(
2505 2 : parse_datetime("2020-09-09T16:40:00,123Z"),
2506 : "comma separator"
2507 : );
2508 2 : assert!(
2509 2 : parse_datetime("2020-02-29T00:00:00Z"),
2510 : "2020 is a leap year"
2511 : );
2512 :
2513 : // Rejected. NOTE: the offset case previously asserted the OPPOSITE —
2514 : // 4.6.3 is explicit that "the trailing timestamp component … shall
2515 : // always be equal to the character Z. Therefore, all timestamps shall
2516 : // be expressed in UTC", so an offset is invalid, not an alternative.
2517 2 : assert!(
2518 2 : !parse_datetime("2020-09-09T16:40:00+02:00"),
2519 : "offset forbidden"
2520 : );
2521 2 : assert!(
2522 2 : !parse_datetime("2020-09-09T16:40:00-05:00"),
2523 : "offset forbidden"
2524 : );
2525 : // "All the referred components shall appear in the string; reduced
2526 : // representations are not permitted."
2527 2 : assert!(!parse_datetime("2020-09-09T16:40:00"), "no zone");
2528 2 : assert!(!parse_datetime("2020-09-09"));
2529 2 : assert!(!parse_datetime("nope"));
2530 : // fraction bounds: 1..=6 digits, and a separator is required
2531 2 : assert!(!parse_datetime("2020-09-09T16:40:00.Z"), "empty fraction");
2532 2 : assert!(!parse_datetime("2020-09-09T16:40:00.1234567Z"), "7 digits");
2533 2 : assert!(!parse_datetime("2020-09-09T16:40:00123Z"), "no separator");
2534 : // calendar reality — digit-shape alone let this through, and one such
2535 : // write made every later temporal query in the tenant 500 on the:
2536 : // :timestamptz cast
2537 2 : assert!(!parse_datetime("2026-13-45T00:00:00Z"), "month 13, day 45");
2538 2 : assert!(
2539 2 : !parse_datetime("2021-02-29T00:00:00Z"),
2540 : "2021 is not a leap year"
2541 : );
2542 2 : assert!(!parse_datetime("2020-09-09T25:00:00Z"), "hour 25");
2543 2 : }
2544 :
2545 : /// 4.7.1/4.7.2/4.7.3 Geospatial Properties: location & co. must be
2546 : /// GeoProperties (4.7.1); a whole geometry MAY arrive as an encoded JSON
2547 : /// string, accepted "if and only if" it parses into a valid geometry of
2548 : /// the stated type (4.7.2, normalized to the object form); the concise
2549 : /// forms infer GeoProperty from a resolving geometry value (4.7.3).
2550 : #[test]
2551 2 : fn geo_property_rules() {
2552 14 : let ent = |attr: Value| {
2553 14 : let doc = json!({"id": "urn:x", "type": "T", "g": attr});
2554 14 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2555 14 : };
2556 : // 4.7.2: string-encoded geometry accepted and normalized to the object
2557 2 : let out = ent(json!({"type": "GeoProperty",
2558 2 : "value": "{\"type\": \"Point\", \"coordinates\": [17.1, 48.7]}"}))
2559 2 : .expect("string-encoded geometry");
2560 2 : let val = &out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["value"];
2561 2 : assert!(
2562 2 : !val.is_string(),
2563 : "value must be normalized, not stay a string"
2564 : );
2565 2 : assert_eq!(val["type"], "Point");
2566 2 : assert_eq!(val["coordinates"][0], 17.1);
2567 : // iff: unparseable, non-geometry and GeometryCollection strings → 400
2568 6 : for bad_s in [
2569 2 : "not json",
2570 2 : "{\"a\": 1}",
2571 2 : "{\"type\": \"GeometryCollection\", \"geometries\": []}",
2572 2 : ] {
2573 6 : let err = ent(json!({"type": "GeoProperty", "value": bad_s})).expect_err(bad_s);
2574 6 : assert!(matches!(err, NgsiError::BadRequestData(_)), "{bad_s}");
2575 : }
2576 : // 4.7.1: location shall be a GeoProperty
2577 2 : let doc = json!({"id": "urn:x", "type": "T",
2578 2 : "location": {"type": "Property", "value": 1}});
2579 2 : assert!(
2580 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
2581 : "location as plain Property"
2582 : );
2583 : // 4.7.3: concise inference — bare geometry and resolving value
2584 2 : let out = ent(json!({"type": "Point", "coordinates": [1.0, 2.0]}))
2585 2 : .expect("bare geometry concise form");
2586 2 : assert_eq!(
2587 2 : out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
2588 : "GeoProperty"
2589 : );
2590 2 : let out = ent(json!({"value": {"type": "Point", "coordinates": [1.0, 2.0]}}))
2591 2 : .expect("resolving value");
2592 2 : assert_eq!(
2593 2 : out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
2594 : "GeoProperty"
2595 : );
2596 : // an ordinary string value must NOT be inferred as GeoProperty
2597 2 : let out = ent(json!({"value": "Point"})).expect("plain string value");
2598 2 : assert_eq!(
2599 2 : out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
2600 : "Property"
2601 : );
2602 2 : }
2603 :
2604 : /// 4.7.2: a geometry is accepted "if and only if" it meets "the syntax
2605 : /// and restrictions mandated by IETF RFC 7946 \[8\] when representing a
2606 : /// valid Geometry of the type specified", and its coordinates are "values
2607 : /// of a JSON-LD floating point number data type". The verbose, concise
2608 : /// and string-encoded forms are one Value and all three are held to it.
2609 : #[test]
2610 2 : fn geojson_geometries_meet_the_rfc_7946_restrictions() {
2611 150 : let ent = |attr: Value| {
2612 150 : let doc = json!({"id": "urn:x", "type": "T", "location": attr});
2613 150 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2614 150 : };
2615 2 : let bad_geoms = vec![
2616 : // RFC 7946 3.1.1: "A position is an array of numbers. There MUST
2617 : // be two or more elements."
2618 2 : (
2619 2 : "point is not an array",
2620 2 : json!({"type": "Point", "coordinates": 1}),
2621 2 : ),
2622 2 : (
2623 2 : "point is an object",
2624 2 : json!({"type": "Point", "coordinates": {"lon": 1}}),
2625 2 : ),
2626 2 : (
2627 2 : "one element",
2628 2 : json!({"type": "Point", "coordinates": [1.0]}),
2629 2 : ),
2630 2 : (
2631 2 : "empty position",
2632 2 : json!({"type": "Point", "coordinates": []}),
2633 2 : ),
2634 : // 4.7.2: coordinates are floating point numbers.
2635 2 : (
2636 2 : "strings, not numbers",
2637 2 : json!({"type": "Point", "coordinates": ["1", "2"]}),
2638 2 : ),
2639 2 : (
2640 2 : "null coordinate",
2641 2 : json!({"type": "Point", "coordinates": [1.0, null]}),
2642 2 : ),
2643 2 : (
2644 2 : "nested where a position belongs",
2645 2 : json!({"type": "Point", "coordinates": [[1.0, 2.0]]}),
2646 2 : ),
2647 : // RFC 7946 4: the CRS is WGS84 "with longitude and latitude units
2648 : // of decimal degrees" — 999 is not a latitude. Left through, it
2649 : // reaches PostGIS as a `::geography` cast that errors, so one
2650 : // write breaks every later `near` query in the tenant.
2651 2 : (
2652 2 : "latitude past the pole",
2653 2 : json!({"type": "Point", "coordinates": [0.0, 999.0]}),
2654 2 : ),
2655 2 : (
2656 2 : "longitude past the antimeridian",
2657 2 : json!({"type": "Point", "coordinates": [181.0, 0.0]}),
2658 2 : ),
2659 2 : (
2660 2 : "latitude below the pole",
2661 2 : json!({"type": "Point", "coordinates": [0.0, -90.5]}),
2662 2 : ),
2663 : // RFC 7946 3.1.4: a LineString needs "two or more positions".
2664 2 : (
2665 2 : "one-position LineString",
2666 2 : json!({"type": "LineString", "coordinates": [[1.0, 2.0]]}),
2667 2 : ),
2668 2 : (
2669 2 : "LineString of numbers",
2670 2 : json!({"type": "LineString", "coordinates": [1.0, 2.0]}),
2671 2 : ),
2672 : // RFC 7946 3.1.6: rings are closed and have four or more positions.
2673 2 : (
2674 2 : "open ring",
2675 2 : json!({"type": "Polygon", "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]]}),
2676 2 : ),
2677 2 : (
2678 2 : "short ring",
2679 2 : json!({"type": "Polygon", "coordinates": [[[0.0, 0.0], [1.0, 0.0], [0.0, 0.0]]]}),
2680 2 : ),
2681 2 : (
2682 2 : "ring out of range",
2683 2 : json!({"type": "Polygon",
2684 2 : "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 91.0], [0.0, 0.0]]]}),
2685 2 : ),
2686 2 : (
2687 2 : "polygon of positions",
2688 2 : json!({"type": "Polygon", "coordinates": [[0.0, 0.0]]}),
2689 2 : ),
2690 2 : (
2691 2 : "MultiPolygon nested one level short",
2692 2 : json!({"type": "MultiPolygon",
2693 2 : "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]}),
2694 2 : ),
2695 : ];
2696 34 : for (why, geom) in &bad_geoms {
2697 : // verbose form
2698 34 : let err = ent(json!({"type": "GeoProperty", "value": geom})).expect_err(why);
2699 34 : assert!(
2700 34 : matches!(err, NgsiError::BadRequestData(_)),
2701 : "{why}: {err:?}"
2702 : );
2703 : // 4.7.3 concise form — the same Value, the same restrictions
2704 34 : let err = ent(geom.clone()).expect_err(&format!("{why} (concise)"));
2705 34 : assert!(
2706 34 : matches!(err, NgsiError::BadRequestData(_)),
2707 : "{why} (concise)"
2708 : );
2709 : // 4.7.2 string-encoded form
2710 34 : let encoded = serde_json::to_string(geom).expect("encode");
2711 34 : let err = ent(json!({"type": "GeoProperty", "value": encoded}))
2712 34 : .expect_err(&format!("{why} (encoded)"));
2713 34 : assert!(
2714 34 : matches!(err, NgsiError::BadRequestData(_)),
2715 : "{why} (encoded)"
2716 : );
2717 : }
2718 : // the shapes RFC 7946 does allow stay accepted, in all three forms
2719 2 : let good = vec![
2720 2 : json!({"type": "Point", "coordinates": [17.1, 48.7]}),
2721 2 : json!({"type": "Point", "coordinates": [-180.0, -90.0]}),
2722 2 : json!({"type": "Point", "coordinates": [180.0, 90.0]}),
2723 : // "Altitude or elevation MAY be included as an optional third element"
2724 2 : json!({"type": "Point", "coordinates": [1.0, 2.0, 300.0]}),
2725 2 : json!({"type": "MultiPoint", "coordinates": [[1.0, 2.0], [3.0, 4.0]]}),
2726 2 : json!({"type": "LineString", "coordinates": [[1.0, 2.0], [3.0, 4.0]]}),
2727 2 : json!({"type": "MultiLineString", "coordinates": [[[1.0, 2.0], [3.0, 4.0]]]}),
2728 2 : json!({"type": "Polygon",
2729 2 : "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]}),
2730 2 : json!({"type": "Polygon", "coordinates": [
2731 : [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 0.0]],
2732 : [[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 1.0]]]}),
2733 2 : json!({"type": "MultiPolygon",
2734 2 : "coordinates": [[[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]]}),
2735 : // RFC 7946 3.1: an empty multi-geometry is still a geometry
2736 2 : json!({"type": "MultiPoint", "coordinates": []}),
2737 2 : json!({"type": "Polygon", "coordinates": []}),
2738 : ];
2739 24 : for geom in &good {
2740 24 : ent(json!({"type": "GeoProperty", "value": geom}))
2741 24 : .unwrap_or_else(|e| panic!("{geom} rejected: {e:?}"));
2742 24 : ent(geom.clone()).unwrap_or_else(|e| panic!("{geom} concise rejected: {e:?}"));
2743 : }
2744 2 : }
2745 :
2746 : /// 5.5.4 General NGSI-LD validation: "urn:ngsi-ld:null" as a first-level
2747 : /// member value is BadRequestData outside partial-update/merge fragments;
2748 : /// as the value of a key inside a JSON object that is a Property's value
2749 : /// it is BadRequestData everywhere EXCEPT merge fragments (5.5.12).
2750 : #[test]
2751 2 : fn clause_5_5_4_null_placement() {
2752 2 : let e =
2753 20 : |doc: Value, opts: ExpandOpts| expand_entity(doc.as_object().unwrap(), &core(), opts);
2754 : // first-level member value — id and type are first-level members too
2755 2 : assert!(
2756 2 : e(
2757 2 : json!({"id": "urn:ngsi-ld:null", "type": "T"}),
2758 2 : ExpandOpts::default()
2759 2 : )
2760 2 : .is_err(),
2761 : "null URN as id must 400 on create"
2762 : );
2763 2 : assert!(
2764 2 : e(
2765 2 : json!({"id": "urn:x", "type": "urn:ngsi-ld:null"}),
2766 2 : ExpandOpts::default()
2767 2 : )
2768 2 : .is_err(),
2769 : "null URN as type must 400 on create"
2770 : );
2771 : // null inside a JSON object that is a Property value
2772 2 : let nested = json!({"id": "urn:x", "type": "T",
2773 2 : "p": {"type": "Property", "value": {"a": "urn:ngsi-ld:null"}}});
2774 2 : assert!(
2775 2 : e(nested.clone(), ExpandOpts::default()).is_err(),
2776 : "nested null in value object must 400 on create"
2777 : );
2778 : // partial update allows top-level nulls (allow_null) but the
2779 : // object-nested form is excepted for merge ONLY
2780 2 : assert!(
2781 2 : e(
2782 2 : nested.clone(),
2783 2 : ExpandOpts {
2784 2 : fragment: true,
2785 2 : allow_null: true,
2786 2 : ..Default::default()
2787 2 : }
2788 2 : )
2789 2 : .is_err(),
2790 : "nested null in value object must 400 on partial update"
2791 : );
2792 : // merge fragment: accepted and preserved for merge_into
2793 2 : let ok = e(
2794 2 : nested,
2795 2 : ExpandOpts {
2796 2 : fragment: true,
2797 2 : allow_null: true,
2798 2 : merge: true,
2799 2 : ..Default::default()
2800 2 : },
2801 2 : )
2802 2 : .expect("merge fragment keeps the nested null");
2803 2 : assert_eq!(
2804 2 : ok["https://uri.etsi.org/ngsi-ld/default-context/p"][0]["value"]["a"],
2805 : "urn:ngsi-ld:null"
2806 : );
2807 : // deep nesting (object in object, object in array) is caught too
2808 2 : let deep = json!({"id": "urn:x", "type": "T",
2809 2 : "p": {"type": "Property", "value": {"a": {"b": "urn:ngsi-ld:null"}}}});
2810 2 : assert!(
2811 2 : e(deep, ExpandOpts::default()).is_err(),
2812 : "deep nested null must 400"
2813 : );
2814 2 : let in_array = json!({"id": "urn:x", "type": "T",
2815 2 : "p": {"type": "Property", "value": [{"a": "urn:ngsi-ld:null"}]}});
2816 2 : assert!(
2817 2 : e(in_array, ExpandOpts::default()).is_err(),
2818 : "null in object in array must 400"
2819 : );
2820 : // negative: a benign object value passes and carries no null
2821 2 : let fine = e(
2822 2 : json!({"id": "urn:x", "type": "T",
2823 2 : "p": {"type": "Property", "value": {"a": 1}}}),
2824 2 : ExpandOpts::default(),
2825 2 : )
2826 2 : .expect("plain object value stays legal");
2827 2 : assert!(
2828 2 : !fine.to_string().contains("urn:ngsi-ld:null"),
2829 : "no null leakage"
2830 : );
2831 : // top-level attribute null stays the fragment deletion form:
2832 : // rejected on create, accepted under allow_null (5.5.8/5.5.12)
2833 2 : let top = json!({"id": "urn:x", "type": "T", "p": "urn:ngsi-ld:null"});
2834 2 : assert!(e(top.clone(), ExpandOpts::default()).is_err());
2835 2 : e(
2836 2 : top,
2837 2 : ExpandOpts {
2838 2 : fragment: true,
2839 2 : allow_null: true,
2840 2 : ..Default::default()
2841 2 : },
2842 2 : )
2843 2 : .expect("first-level null is the deletion form in fragments");
2844 2 : }
2845 :
2846 : /// 5.5.8: "A datasetId cannot be deleted by setting it to the value
2847 : /// urn:ngsi-ld:null" — such a fragment is rejected on every input,
2848 : /// including null-allowing (update/merge) ones.
2849 : #[test]
2850 2 : fn clause_5_5_8_dataset_id_null_rejected() {
2851 2 : let doc = json!({"id": "urn:x", "type": "T",
2852 2 : "speed": {"type": "Property", "value": 1,
2853 2 : "datasetId": "urn:ngsi-ld:null"}});
2854 6 : for opts in [
2855 2 : ExpandOpts::default(),
2856 2 : ExpandOpts {
2857 2 : fragment: true,
2858 2 : allow_null: true,
2859 2 : ..Default::default()
2860 2 : },
2861 2 : ExpandOpts {
2862 2 : fragment: true,
2863 2 : allow_null: true,
2864 2 : merge: true,
2865 2 : ..Default::default()
2866 2 : },
2867 2 : ] {
2868 6 : assert!(
2869 6 : expand_entity(doc.as_object().unwrap(), &core(), opts).is_err(),
2870 : "datasetId null must be rejected (opts {opts:?})"
2871 : );
2872 : }
2873 : // the attribute-level fragment path (5.6.4) rejects it too
2874 2 : let frag = json!({"type": "Property", "value": 1,
2875 2 : "datasetId": "urn:ngsi-ld:null"});
2876 2 : assert!(expand_attr_fragment(frag.as_object().unwrap(), &core()).is_err());
2877 : // a REAL datasetId still passes and is preserved
2878 2 : let ok = expand_entity(
2879 2 : json!({"id": "urn:x", "type": "T",
2880 2 : "speed": {"type": "Property", "value": 1,
2881 2 : "datasetId": "urn:ngsi-ld:Dataset:a"}})
2882 2 : .as_object()
2883 2 : .unwrap(),
2884 2 : &core(),
2885 2 : ExpandOpts::default(),
2886 : )
2887 2 : .expect("real datasetId");
2888 2 : assert_eq!(
2889 2 : ok["https://uri.etsi.org/ngsi-ld/default-context/speed"][0]["datasetId"],
2890 : "urn:ngsi-ld:Dataset:a"
2891 : );
2892 2 : }
2893 :
2894 : /// 4.6.5 Supported data types for LanguageMaps: keys are RFC 5646 tags
2895 : /// or "@none", values are strings or arrays of strings; the
2896 : /// {"@none": "urn:ngsi-ld:null"} form is ONLY the partial/merge-patch
2897 : /// deletion encoding — invalid in a create/append (no allow_null).
2898 : #[test]
2899 2 : fn language_map_data_types() {
2900 10 : let lp = |lm: Value, opts: ExpandOpts| {
2901 10 : let doc = json!({"id": "urn:x", "type": "T",
2902 10 : "brandName": {"type": "LanguageProperty", "languageMap": lm}});
2903 10 : expand_entity(doc.as_object().unwrap(), &core(), opts)
2904 10 : };
2905 2 : let out = lp(
2906 2 : json!({"sk": "škola", "en": ["school", "academy"], "@none": "default"}),
2907 2 : ExpandOpts::default(),
2908 2 : )
2909 2 : .expect("strings and arrays of strings");
2910 2 : let m = &out["https://uri.etsi.org/ngsi-ld/default-context/brandName"][0]["languageMap"];
2911 2 : assert_eq!(m["en"][1], "academy");
2912 2 : assert!(m.get("urn:ngsi-ld:null").is_none(), "no null leakage");
2913 : // non-string value rejected
2914 2 : assert!(lp(json!({"en": 5}), ExpandOpts::default()).is_err());
2915 2 : assert!(lp(json!({"en": [5]}), ExpandOpts::default()).is_err());
2916 : // the null encoding is a deletion marker: rejected on create,
2917 : // accepted under allow_null (patch/merge)
2918 2 : assert!(
2919 2 : lp(json!({"@none": "urn:ngsi-ld:null"}), ExpandOpts::default()).is_err(),
2920 : "langmap null form invalid outside patch/merge"
2921 : );
2922 2 : lp(
2923 2 : json!({"@none": "urn:ngsi-ld:null"}),
2924 2 : ExpandOpts {
2925 2 : allow_null: true,
2926 2 : ..ExpandOpts::default()
2927 2 : },
2928 2 : )
2929 2 : .expect("deletion form under allow_null");
2930 2 : }
2931 :
2932 : /// 4.6.3 Supported data types for Values: "All the GeoJSON Geometries
2933 : /// [8] with the exception of GeometryCollection" — a GeoProperty value
2934 : /// of type GeometryCollection is BadRequestData; plain JSON values and
2935 : /// a bare JSON null follow 4.5.2 (null rejected outside merge-patch).
2936 : #[test]
2937 2 : fn value_data_types_rules() {
2938 6 : let geo = |val: Value| {
2939 6 : let doc = json!({"id": "urn:x", "type": "T",
2940 6 : "location": {"type": "GeoProperty", "value": val}});
2941 6 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2942 6 : };
2943 2 : let out = geo(json!({"type": "Point", "coordinates": [17.1, 48.7]}))
2944 2 : .expect("Point is a supported geometry");
2945 2 : let loc = &out["https://uri.etsi.org/ngsi-ld/location"][0];
2946 2 : assert_eq!(loc["value"]["type"], "Point");
2947 2 : let err = geo(json!({"type": "GeometryCollection", "geometries": [
2948 2 : {"type": "Point", "coordinates": [1.0, 2.0]}]}))
2949 2 : .expect_err("GeometryCollection is excluded by 4.6.3");
2950 2 : assert!(matches!(err, NgsiError::BadRequestData(_)), "{err:?}");
2951 : // a geometry type must still carry coordinates
2952 2 : assert!(geo(json!({"type": "Point"})).is_err(), "no coordinates");
2953 : // bare JSON null is not a legal Value outside merge-patch (4.5.2)
2954 2 : let doc = json!({"id": "urn:x", "type": "T",
2955 2 : "speed": {"type": "Property", "value": null}});
2956 2 : assert!(
2957 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
2958 : "bare null value"
2959 : );
2960 2 : }
2961 :
2962 : /// 4.6.2 Supported names: name = unicodeLetter *(letter|number|_) —
2963 : /// Entity Type / Property / Relationship names with other characters are
2964 : /// BadRequestData; keys containing ':' (compact or absolute IRIs) are
2965 : /// out of the term grammar's scope.
2966 : #[test]
2967 2 : fn name_grammar_rules() {
2968 20 : let attr = |name: &str| {
2969 20 : let doc = json!({"id": "urn:x", "type": "T",
2970 20 : name: {"type": "Property", "value": 1}});
2971 20 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
2972 20 : };
2973 10 : for bad_name in ["my attr", "1temp", "temp-erature", "temp!", "_hidden"] {
2974 10 : let err = attr(bad_name).expect_err(bad_name);
2975 : // 4.6.2 names the error type: BadRequestData, nothing else
2976 10 : assert!(
2977 10 : matches!(err, NgsiError::BadRequestData(_)),
2978 : "{bad_name}: {err:?}"
2979 : );
2980 : }
2981 10 : for good in [
2982 2 : "teplota_1",
2983 2 : "Ωmega",
2984 2 : "výška",
2985 2 : "ns:temp",
2986 2 : "https://example.com/a b",
2987 10 : ] {
2988 10 : attr(good).expect(good);
2989 10 : }
2990 : // sub-attribute names obey the same grammar
2991 2 : let doc = json!({"id": "urn:x", "type": "T",
2992 2 : "speed": {"type": "Property", "value": 1,
2993 2 : "bad sub": {"type": "Property", "value": 2}}});
2994 2 : assert!(
2995 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
2996 : "sub-attribute with space must be rejected"
2997 : );
2998 : // entity type names too; multi-type checks each entry
2999 6 : let ty = |t: Value| {
3000 6 : let doc = json!({"id": "urn:x", "type": t, "speed": {"type": "Property", "value": 1}});
3001 6 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
3002 6 : };
3003 2 : assert!(ty(json!("My Type")).is_err(), "type with space");
3004 2 : assert!(
3005 2 : ty(json!(["T", "9T"])).is_err(),
3006 : "type starting with a digit"
3007 : );
3008 2 : ty(json!("Škola")).expect("unicode-letter type");
3009 2 : }
3010 :
3011 : /// 4.5.24.2/4.5.24.3, TS 104 175 5.2.6.4.10: JsonProperty — json is any
3012 : /// NGSI-LD Value (kept verbatim), unitCode and value prohibited, concise
3013 : /// inference from json.
3014 : #[test]
3015 2 : fn json_property_rules() {
3016 24 : let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "tickets": attr});
3017 : // valid: object and array-of-objects, kept verbatim (no expansion)
3018 2 : let doc = mk(json!({"type": "JsonProperty", "json": {"id": "x", "value": 1}}));
3019 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
3020 2 : .expect("valid json object");
3021 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/tickets"][0];
3022 2 : assert_eq!(
3023 2 : inst["json"],
3024 2 : json!({"id": "x", "value": 1}),
3025 : "raw JSON kept verbatim"
3026 : );
3027 2 : let doc = mk(json!({"type": "JsonProperty", "json": [{"a": 1}, {"b": 2}]}));
3028 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
3029 2 : .expect("array of objects");
3030 : // concise inference
3031 2 : let doc = mk(json!({"json": {"a": 1}}));
3032 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
3033 2 : .expect("concise");
3034 2 : assert_eq!(
3035 2 : out["https://uri.etsi.org/ngsi-ld/default-context/tickets"][0]["type"],
3036 : "JsonProperty"
3037 : );
3038 : // TS 104 175 5.2.6.4.10: every NGSI-LD Value kind, kept verbatim
3039 10 : for j in [
3040 2 : json!(5),
3041 2 : json!("x"),
3042 2 : json!(true),
3043 2 : json!(["a"]),
3044 2 : json!([1, {"a": 1}]),
3045 2 : ] {
3046 10 : let doc = mk(json!({"type": "JsonProperty", "json": j}));
3047 10 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
3048 10 : .expect("any JSON value");
3049 10 : assert_eq!(
3050 10 : out["https://uri.etsi.org/ngsi-ld/default-context/tickets"][0]["json"],
3051 : j
3052 : );
3053 : }
3054 : // JSON null is no NGSI-LD Value; the NGSI-LD Null only deletes
3055 2 : let doc = mk(json!({"type": "JsonProperty", "json": null}));
3056 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3057 2 : let doc = mk(json!({"type": "JsonProperty", "json": "urn:ngsi-ld:null"}));
3058 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3059 : // unitCode prohibited
3060 2 : let doc = mk(json!({"type": "JsonProperty", "json": {"a": 1}, "unitCode": "MTR"}));
3061 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3062 : // value prohibited
3063 2 : let doc = mk(json!({"type": "JsonProperty", "json": {"a": 1}, "value": 1}));
3064 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3065 2 : }
3066 :
3067 : /// 4.5.21/4.5.22: ListProperty and ListRelationship — objectList accepts
3068 : /// bare URIs or {"object": URI} objects (normalized to bare URIs
3069 : /// internally), non-URIs rejected, [null] deletion form, value/object
3070 : /// prohibited, concise inference.
3071 : #[test]
3072 2 : fn list_property_and_relationship_rules() {
3073 14 : let mk = |name: &str, attr: Value| json!({"id": "urn:x", "type": "T", name: attr});
3074 : // ListProperty: ordered array of Property Values, value prohibited
3075 2 : let doc = mk(
3076 2 : "steps",
3077 2 : json!({"type": "ListProperty", "valueList": [1, "two", true]}),
3078 2 : );
3079 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).expect("valid");
3080 2 : let doc = mk(
3081 2 : "steps",
3082 2 : json!({"type": "ListProperty", "valueList": [1], "value": 2}),
3083 2 : );
3084 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3085 : // concise inference
3086 2 : let doc = mk("steps", json!({"valueList": [1]}));
3087 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
3088 2 : .expect("concise");
3089 2 : assert_eq!(
3090 2 : out["https://uri.etsi.org/ngsi-ld/default-context/steps"][0]["type"],
3091 : "ListProperty"
3092 : );
3093 : // ListRelationship: both entry forms normalize to bare URIs
3094 2 : let doc = mk(
3095 2 : "route",
3096 2 : json!({"type": "ListRelationship",
3097 2 : "objectList": ["urn:ngsi-ld:R:1", {"object": "urn:ngsi-ld:R:2"}]}),
3098 2 : );
3099 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
3100 2 : .expect("both forms");
3101 2 : assert_eq!(
3102 2 : out["https://uri.etsi.org/ngsi-ld/default-context/route"][0]["objectList"],
3103 2 : json!(["urn:ngsi-ld:R:1", "urn:ngsi-ld:R:2"])
3104 : );
3105 : // non-URI entry rejected
3106 2 : let doc = mk(
3107 2 : "route",
3108 2 : json!({"type": "ListRelationship", "objectList": ["not a uri"]}),
3109 2 : );
3110 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3111 : // object prohibited on ListRelationship
3112 2 : let doc = mk(
3113 2 : "route",
3114 2 : json!({"type": "ListRelationship",
3115 2 : "objectList": ["urn:ngsi-ld:R:1"], "object": "urn:ngsi-ld:R:2"}),
3116 2 : );
3117 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3118 : // [null] deletion form accepted under allow_null
3119 2 : let doc = mk(
3120 2 : "route",
3121 2 : json!({"type": "ListRelationship",
3122 2 : "objectList": ["urn:ngsi-ld:null"]}),
3123 2 : );
3124 2 : let opts = ExpandOpts {
3125 2 : allow_null: true,
3126 2 : fragment: true,
3127 2 : ..Default::default()
3128 2 : };
3129 2 : expand_entity(doc.as_object().unwrap(), &core(), opts).expect("deletion form");
3130 2 : assert!(is_ngsi_null_list(&json!(["urn:ngsi-ld:null"])));
3131 2 : assert!(!is_ngsi_null_list(&json!(["urn:ngsi-ld:null", "urn:x"])));
3132 2 : }
3133 :
3134 : /// 4.5.20.2/4.5.20.3: VocabProperty — vocab is a string or array of
3135 : /// strings coerced to IRIs; unitCode and value prohibited; concise
3136 : /// inference from vocab.
3137 : #[test]
3138 2 : fn vocab_property_rules() {
3139 10 : let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "category": attr});
3140 : // normalized: term expands to an IRI under the context
3141 2 : let doc = mk(json!({"type": "VocabProperty", "vocab": "non-commercial"}));
3142 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
3143 2 : .expect("valid vocab");
3144 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
3145 2 : assert_eq!(
3146 2 : inst["vocab"],
3147 : "https://uri.etsi.org/ngsi-ld/default-context/non-commercial"
3148 : );
3149 : // concise inference from vocab
3150 2 : let doc = mk(json!({"vocab": ["a", "b"]}));
3151 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
3152 2 : .expect("concise");
3153 2 : assert_eq!(
3154 2 : out["https://uri.etsi.org/ngsi-ld/default-context/category"][0]["type"],
3155 : "VocabProperty"
3156 : );
3157 : // non-string vocab rejected
3158 2 : let doc = mk(json!({"type": "VocabProperty", "vocab": 5}));
3159 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3160 : // unitCode prohibited
3161 2 : let doc = mk(json!({"type": "VocabProperty", "vocab": "x", "unitCode": "MTR"}));
3162 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3163 : // value prohibited
3164 2 : let doc = mk(json!({"type": "VocabProperty", "vocab": "x", "value": 1}));
3165 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3166 2 : }
3167 :
3168 : /// 4.5.18.2/4.5.18.3: LanguageProperty — non-empty language tags, unitCode
3169 : /// prohibited, value prohibited; concise inference from languageMap.
3170 : #[test]
3171 2 : fn language_property_rules() {
3172 12 : let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "says": attr});
3173 : // valid normalized + "@none" tag
3174 2 : let doc = mk(json!({"type": "LanguageProperty",
3175 2 : "languageMap": {"en": "hi", "@none": "hey"}}));
3176 2 : expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).expect("valid");
3177 : // concise inference from languageMap
3178 2 : let doc = mk(json!({"languageMap": {"en": "hi"}}));
3179 2 : let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
3180 2 : .expect("concise");
3181 2 : assert_eq!(
3182 2 : out["https://uri.etsi.org/ngsi-ld/default-context/says"][0]["type"],
3183 : "LanguageProperty"
3184 : );
3185 : // empty language tag rejected
3186 2 : let doc = mk(json!({"type": "LanguageProperty", "languageMap": {"": "hi"}}));
3187 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3188 : // unitCode prohibited
3189 2 : let doc = mk(json!({"type": "LanguageProperty",
3190 2 : "languageMap": {"en": "hi"}, "unitCode": "MTR"}));
3191 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3192 : // value prohibited
3193 2 : let doc = mk(json!({"type": "LanguageProperty",
3194 2 : "languageMap": {"en": "hi"}, "value": 1}));
3195 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3196 : // non-string languageMap values rejected
3197 2 : let doc = mk(json!({"type": "LanguageProperty", "languageMap": {"en": 5}}));
3198 2 : assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
3199 2 : }
3200 : }
3201 :
3202 : #[cfg(test)]
3203 : mod bench {
3204 : use super::*;
3205 :
3206 : /// The go/no-go threshold for a hand-rolled JSON-LD processor was
3207 : /// ≥5k expansions/s/core. Antares hand-rolled its processor from day one
3208 : /// rather than forking a `json-ld` crate — this measures it. Run with
3209 : /// `cargo test -p ngsild-jsonld --release -- --ignored bench_expansion`.
3210 : #[test]
3211 : #[ignore = "benchmark — run explicitly in release"]
3212 0 : fn bench_expansion_rate() {
3213 0 : let ctx = crate::core_context();
3214 0 : let entity: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
3215 0 : r#"{
3216 0 : "id": "urn:ngsi-ld:Vehicle:bench-1", "type": "Vehicle",
3217 0 : "speed": {"type": "Property", "value": 55.1,
3218 0 : "observedAt": "2026-08-04T12:00:00Z", "unitCode": "KMH",
3219 0 : "source": {"type": "Property", "value": "GPS"}},
3220 0 : "heading": {"type": "Property", "value": 180},
3221 0 : "isParked": {"type": "Relationship",
3222 0 : "object": "urn:ngsi-ld:OffStreetParking:p1",
3223 0 : "providedBy": {"type": "Relationship",
3224 0 : "object": "urn:ngsi-ld:Person:bob"}},
3225 0 : "location": {"type": "GeoProperty",
3226 0 : "value": {"type": "Point", "coordinates": [13.35, 52.51]}},
3227 0 : "name": {"type": "LanguageProperty",
3228 0 : "languageMap": {"en": "car", "de": "Auto"}}
3229 0 : }"#,
3230 : )
3231 0 : .expect("entity");
3232 0 : let n = 20_000u32;
3233 0 : let start = std::time::Instant::now();
3234 0 : for _ in 0..n {
3235 0 : let out = expand_entity(&entity, &ctx, ExpandOpts::default()).expect("expand");
3236 0 : std::hint::black_box(out);
3237 0 : }
3238 0 : let secs = start.elapsed().as_secs_f64();
3239 0 : let rate = f64::from(n) / secs;
3240 0 : eprintln!("expansion rate: {rate:.0}/s/core ({n} iterations in {secs:.2}s)");
3241 0 : assert!(
3242 0 : rate >= 5_000.0,
3243 : "expansion rate {rate:.0}/s is below the 5k/s/core phase-0 gate"
3244 : );
3245 0 : }
3246 : }
3247 :
3248 : #[cfg(test)]
3249 : mod clause_4_18 {
3250 : use super::*;
3251 : use serde_json::json;
3252 :
3253 28 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3254 28 : expand_entity(
3255 28 : doc.as_object().expect("obj"),
3256 28 : &std::sync::Arc::new(crate::core_context()),
3257 28 : ExpandOpts::default(),
3258 : )
3259 28 : }
3260 :
3261 34 : fn with_scope(s: serde_json::Value) -> serde_json::Value {
3262 34 : json!({"id": "urn:x", "type": "T", "scope": s})
3263 34 : }
3264 :
3265 : /// 4.18 Scope grammar: [/] ScopeLevel *(/ScopeLevel), ScopeLevel =
3266 : /// unicodeLetter *(letter/number/_). EXAMPLES 1-4 must pass.
3267 : #[test]
3268 2 : fn scope_grammar_accepts_the_examples() {
3269 8 : for s in [
3270 2 : "/Madrid",
3271 2 : "Madrid",
3272 2 : "/Madrid/Gardens/ParqueNorte",
3273 2 : "/CompanyA/OrganizationB/UnitC",
3274 2 : ] {
3275 8 : assert!(expand(with_scope(json!(s))).is_ok(), "{s} must be valid");
3276 : }
3277 2 : let out = expand(with_scope(json!(["/A", "B/C_2"]))).expect("multi scope");
3278 2 : assert_eq!(out["scope"], json!(["/A", "B/C_2"]));
3279 2 : }
3280 :
3281 : /// 4.18: levels start with a letter, carry only letters/digits/_, no
3282 : /// empty levels; "urn:ngsi-ld:null" "shall be only used and only appear
3283 : /// in case of deleted scopes" — never creatable.
3284 : #[test]
3285 2 : fn scope_grammar_rejects_malformed_values() {
3286 14 : for s in [
3287 2 : "9bad", // level starts with a digit
3288 2 : "/a//b", // empty level
3289 2 : "a-b", // '-' not a ScopeLevelChar
3290 2 : "/", // no level at all
3291 2 : "", // empty
3292 2 : "/a/b/", // trailing empty level
3293 2 : "a b", // space
3294 2 : ] {
3295 14 : assert!(
3296 14 : expand(with_scope(json!(s))).is_err(),
3297 : "{s:?} must be rejected"
3298 : );
3299 : }
3300 2 : assert!(
3301 2 : expand(with_scope(json!("urn:ngsi-ld:null"))).is_err(),
3302 : "the NGSI-LD Null scope is only for deletions, not creation"
3303 : );
3304 2 : assert!(
3305 2 : expand(with_scope(json!(["/ok", "9bad"]))).is_err(),
3306 : "one bad entry poisons the array"
3307 : );
3308 2 : }
3309 :
3310 : /// 5.5.12 deletes a member whose value IS an NGSI-LD Null, so on the
3311 : /// inputs that admit one the sentinel is the whole scope. Beside a real
3312 : /// scope it would be stored as one, and 4.18 spells no such scope.
3313 : #[test]
3314 2 : fn a_null_scope_is_the_whole_value_or_none_of_it() {
3315 6 : let merge = |s: serde_json::Value| {
3316 6 : expand_entity(
3317 6 : with_scope(s).as_object().expect("obj"),
3318 6 : &std::sync::Arc::new(crate::core_context()),
3319 6 : ExpandOpts {
3320 6 : allow_null: true,
3321 6 : ..ExpandOpts::default()
3322 6 : },
3323 : )
3324 6 : };
3325 2 : let out = merge(json!("urn:ngsi-ld:null")).expect("the deletion form");
3326 2 : assert_eq!(out["scope"], json!(["urn:ngsi-ld:null"]));
3327 2 : assert_eq!(
3328 2 : merge(json!(["urn:ngsi-ld:null"])).expect("array form")["scope"],
3329 2 : json!(["urn:ngsi-ld:null"])
3330 : );
3331 2 : assert!(
3332 2 : merge(json!(["/Madrid", "urn:ngsi-ld:null"])).is_err(),
3333 : "a deletion mixed with a scope is neither"
3334 : );
3335 2 : }
3336 : }
3337 :
3338 : #[cfg(test)]
3339 : mod clause_5_2_1 {
3340 : use super::*;
3341 : use serde_json::json;
3342 :
3343 6 : fn expand_create(doc: serde_json::Value) -> Result<Value, NgsiError> {
3344 6 : expand_entity(
3345 6 : doc.as_object().expect("obj"),
3346 6 : &std::sync::Arc::new(crate::core_context()),
3347 6 : ExpandOpts::default(), // create: allow_null = false
3348 : )
3349 6 : }
3350 :
3351 : /// 5.2.1: outside partial-update/merge inputs, "implementations shall
3352 : /// raise an error of type BadRequestData if an NGSI-LD Null value is
3353 : /// encountered" — Property value, Relationship object, sub-attribute.
3354 : #[test]
3355 2 : fn ngsi_ld_null_is_rejected_outside_merge_inputs() {
3356 6 : for doc in [
3357 2 : json!({"id": "urn:x", "type": "T",
3358 2 : "p": {"type": "Property", "value": "urn:ngsi-ld:null"}}),
3359 2 : json!({"id": "urn:x", "type": "T",
3360 2 : "r": {"type": "Relationship", "object": "urn:ngsi-ld:null"}}),
3361 2 : json!({"id": "urn:x", "type": "T",
3362 2 : "p": {"type": "Property", "value": 1,
3363 2 : "sub": {"type": "Property", "value": "urn:ngsi-ld:null"}}}),
3364 2 : ] {
3365 6 : let e = expand_create(doc).expect_err("NGSI-LD Null on create must be rejected");
3366 6 : assert!(
3367 6 : matches!(e, NgsiError::BadRequestData(_)),
3368 : "must be BadRequestData, got {e:?}"
3369 : );
3370 : }
3371 2 : }
3372 :
3373 : /// 5.2.1: the deletion marker stays legal on null-allowing inputs
3374 : /// (merge/partial-update, 5.5.8/5.5.12).
3375 : #[test]
3376 2 : fn ngsi_ld_null_survives_on_merge_inputs() {
3377 2 : let doc = json!({"p": {"type": "Property", "value": "urn:ngsi-ld:null"}});
3378 2 : let out = expand_entity(
3379 2 : doc.as_object().expect("obj"),
3380 2 : &std::sync::Arc::new(crate::core_context()),
3381 2 : ExpandOpts {
3382 2 : fragment: true,
3383 2 : allow_null: true,
3384 2 : ..ExpandOpts::default()
3385 2 : },
3386 : )
3387 2 : .expect("merge fragment expands");
3388 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
3389 2 : assert!(
3390 2 : is_deletion_instance(inst),
3391 : "the marker must remain recognizable: {inst}"
3392 : );
3393 2 : }
3394 :
3395 : /// 5.5.4: the marker is legal only in a Fragment used in a partial update
3396 : /// or merge. A temporal import allows nulls for 4.5.7 tombstones, but its
3397 : /// document is a whole Entity — an entity-level expiresAt carrying the
3398 : /// marker there is BadRequestData, not a stored lifetime of
3399 : /// "urn:ngsi-ld:null".
3400 : #[test]
3401 2 : fn the_entity_expires_at_marker_is_only_a_fragment_form() {
3402 2 : let doc = json!({
3403 2 : "id": "urn:ngsi-ld:Vehicle:1",
3404 2 : "type": "Vehicle",
3405 2 : "expiresAt": "urn:ngsi-ld:null",
3406 2 : "speed": [{"type": "Property", "value": 1}]
3407 : });
3408 2 : let e = expand_entity(
3409 2 : doc.as_object().expect("obj"),
3410 2 : &std::sync::Arc::new(crate::core_context()),
3411 2 : ExpandOpts {
3412 2 : allow_null: true,
3413 2 : temporal: true,
3414 2 : sys: true,
3415 2 : ..ExpandOpts::default()
3416 2 : },
3417 : )
3418 2 : .expect_err("a whole entity cannot ask for the removal");
3419 2 : assert!(matches!(e, NgsiError::BadRequestData(_)), "{e:?}");
3420 :
3421 : // the fragment form still asks for the removal
3422 2 : let frag = json!({"expiresAt": "urn:ngsi-ld:null"});
3423 2 : let out = expand_entity(
3424 2 : frag.as_object().expect("obj"),
3425 2 : &std::sync::Arc::new(crate::core_context()),
3426 2 : ExpandOpts {
3427 2 : fragment: true,
3428 2 : allow_null: true,
3429 2 : ..ExpandOpts::default()
3430 2 : },
3431 : )
3432 2 : .expect("merge fragment expands");
3433 2 : assert_eq!(out["expiresAt"], "urn:ngsi-ld:null");
3434 2 : }
3435 : }
3436 :
3437 : #[cfg(test)]
3438 : mod clause_5_2_4 {
3439 : use super::*;
3440 : use serde_json::json;
3441 :
3442 24 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3443 24 : expand_entity(
3444 24 : doc.as_object().expect("obj"),
3445 24 : &std::sync::Arc::new(crate::core_context()),
3446 24 : ExpandOpts::default(),
3447 : )
3448 24 : }
3449 :
3450 : /// Table 5.2.4-1: id must be a valid URI; type accepts a short name, a
3451 : /// URI, or an array of either; expiresAt must be a 4.6.3 DateTime.
3452 : #[test]
3453 2 : fn entity_member_table_restrictions() {
3454 2 : assert!(expand(json!({"id": "not a uri", "type": "T"})).is_err());
3455 2 : assert!(expand(json!({"id": "urn:x", "type": "T"})).is_ok());
3456 2 : assert!(expand(json!({"id": "urn:x", "type": ["T", "https://ex.org/U"]})).is_ok());
3457 2 : assert!(expand(json!({"id": "urn:x", "type": 5})).is_err());
3458 2 : assert!(
3459 2 : expand(json!({"id": "urn:x", "type": "T", "expiresAt": "2020-01-01"})).is_err(),
3460 : "expiresAt must be a DateTime, not a Date"
3461 : );
3462 2 : assert!(
3463 2 : expand(json!({"id": "urn:x", "type": "T", "expiresAt": "2030-01-01T00:00:00Z"}))
3464 2 : .is_ok()
3465 : );
3466 2 : }
3467 :
3468 : /// Table 5.2.4-1: location/observationSpace/operationSpace are
3469 : /// GeoProperties (5.2.7) — a plain Property under those names is a
3470 : /// violation (4.7.1).
3471 : #[test]
3472 2 : fn default_geo_names_must_be_geoproperties() {
3473 6 : for name in ["location", "observationSpace", "operationSpace"] {
3474 6 : let doc = json!({"id": "urn:x", "type": "T",
3475 6 : name: {"type": "Property", "value": 3}});
3476 6 : assert!(expand(doc).is_err(), "{name} as a plain Property must 400");
3477 6 : let ok = json!({"id": "urn:x", "type": "T",
3478 6 : name: {"type": "GeoProperty",
3479 6 : "value": {"type": "Point", "coordinates": [8, 40]}}});
3480 6 : assert!(expand(ok).is_ok(), "{name} as a GeoProperty is fine");
3481 : }
3482 2 : }
3483 : }
3484 :
3485 : #[cfg(test)]
3486 : mod clause_5_2_5 {
3487 : use super::*;
3488 : use serde_json::json;
3489 :
3490 14 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3491 14 : expand_entity(
3492 14 : doc.as_object().expect("obj"),
3493 14 : &std::sync::Arc::new(crate::core_context()),
3494 14 : ExpandOpts::default(),
3495 : )
3496 14 : }
3497 :
3498 14 : fn with_p(p: serde_json::Value) -> serde_json::Value {
3499 14 : json!({"id": "urn:x", "type": "T", "p": p})
3500 14 : }
3501 :
3502 : /// Table 5.2.5-1: value mandatory (any JSON value), datasetId a URI,
3503 : /// observedAt/expiresAt DateTimes, unitCode a string, sub-attributes
3504 : /// nest per their own tables.
3505 : #[test]
3506 2 : fn property_member_table_restrictions() {
3507 2 : assert!(
3508 2 : expand(with_p(json!({"type": "Property"}))).is_err(),
3509 : "value mandatory"
3510 : );
3511 2 : assert!(
3512 2 : expand(with_p(json!({"type": "Property", "value": {"k": [1, "x"]},
3513 2 : "datasetId": "urn:ds:1", "observedAt": "2020-09-09T16:40:00Z",
3514 2 : "unitCode": "CEL",
3515 2 : "sub": {"type": "Relationship", "object": "urn:o:1"}})))
3516 2 : .is_ok()
3517 : );
3518 2 : assert!(expand(with_p(json!({"type": "Property", "value": 1,
3519 2 : "datasetId": "not a uri"})))
3520 2 : .is_err());
3521 2 : assert!(expand(with_p(json!({"type": "Property", "value": 1,
3522 2 : "observedAt": "2020-09-09"})))
3523 2 : .is_err());
3524 2 : assert!(expand(with_p(json!({"type": "Property", "value": 1,
3525 2 : "unitCode": 7})))
3526 2 : .is_err());
3527 2 : }
3528 :
3529 : /// 5.2.5: in the concise representation type="Property" is inferred from
3530 : /// `value` — but a GeoJSON-object value "would be interpreted as a
3531 : /// GeoProperty" and so infers GeoProperty, not Property.
3532 : #[test]
3533 2 : fn concise_inference_and_the_geojson_value_carveout() {
3534 2 : let out = expand(with_p(json!({"value": 42}))).expect("concise Property");
3535 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
3536 2 : assert_eq!(inst["type"], "Property");
3537 2 : let out = expand(with_p(json!({"value":
3538 2 : {"type": "Point", "coordinates": [8, 40]}})))
3539 2 : .expect("concise geo value");
3540 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
3541 2 : assert_eq!(
3542 2 : inst["type"], "GeoProperty",
3543 : "a GeoJSON object value infers GeoProperty (5.2.5/5.2.7)"
3544 : );
3545 2 : }
3546 : }
3547 :
3548 : #[cfg(test)]
3549 : mod clause_5_2_6 {
3550 : use super::*;
3551 : use serde_json::json;
3552 :
3553 12 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3554 12 : expand_entity(
3555 12 : doc.as_object().expect("obj"),
3556 12 : &std::sync::Arc::new(crate::core_context()),
3557 12 : ExpandOpts::default(),
3558 : )
3559 12 : }
3560 :
3561 12 : fn with_r(r: serde_json::Value) -> serde_json::Value {
3562 12 : json!({"id": "urn:x", "type": "T", "r": r})
3563 12 : }
3564 :
3565 : /// Table 5.2.6-1: object mandatory — a URI or an array of URIs; datasetId
3566 : /// a URI; objectType coerced; concise inference from `object`; unitCode
3567 : /// prohibited (4.5.3.2).
3568 : #[test]
3569 2 : fn relationship_member_table_restrictions() {
3570 2 : assert!(
3571 2 : expand(with_r(json!({"type": "Relationship"}))).is_err(),
3572 : "object mandatory"
3573 : );
3574 2 : assert!(expand(with_r(
3575 2 : json!({"type": "Relationship", "object": "not a uri"})
3576 2 : ))
3577 2 : .is_err());
3578 2 : assert!(
3579 2 : expand(with_r(json!({"type": "Relationship",
3580 2 : "object": ["urn:a", "urn:b"], "datasetId": "urn:ds:1",
3581 2 : "objectType": "Device"})))
3582 2 : .is_ok(),
3583 : "array of URIs is legal"
3584 : );
3585 2 : assert!(
3586 2 : expand(with_r(json!({"type": "Relationship",
3587 2 : "object": ["urn:a", "not a uri"]})))
3588 2 : .is_err(),
3589 : "every array entry must be a URI"
3590 : );
3591 2 : assert!(
3592 2 : expand(with_r(json!({"type": "Relationship", "object": "urn:a",
3593 2 : "unitCode": "C62"})))
3594 2 : .is_err(),
3595 : "Relationships are unitless"
3596 : );
3597 : // concise inference from the object member
3598 2 : let out = expand(with_r(json!({"object": "urn:o:1"}))).expect("concise");
3599 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/r"][0];
3600 2 : assert_eq!(inst["type"], "Relationship");
3601 2 : }
3602 : }
3603 :
3604 : #[cfg(test)]
3605 : mod clause_5_2_32 {
3606 : use super::*;
3607 : use serde_json::json;
3608 :
3609 14 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3610 14 : expand_entity(
3611 14 : doc.as_object().expect("obj"),
3612 14 : &std::sync::Arc::new(crate::core_context()),
3613 14 : ExpandOpts::default(),
3614 : )
3615 14 : }
3616 :
3617 14 : fn with_lp(lp: serde_json::Value) -> serde_json::Value {
3618 14 : json!({"id": "urn:x", "type": "T", "greeting": lp})
3619 14 : }
3620 :
3621 : /// Table 5.2.32-1: languageMap keys are non-empty language tags mapping
3622 : /// to strings or string arrays; valueType, when present, shall be equal
3623 : /// to "langString"; datasetId is a URI; observedAt a DateTime.
3624 : #[test]
3625 2 : fn language_property_member_table_restrictions() {
3626 2 : let ok = expand(with_lp(json!({"type": "LanguageProperty",
3627 2 : "languageMap": {"en": "hello", "sk": ["ahoj", "servus"]},
3628 2 : "valueType": "langString"})))
3629 2 : .expect("conformant LanguageProperty");
3630 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/greeting"][0];
3631 2 : assert_eq!(attr["valueType"], "langString");
3632 2 : assert!(attr.get("value").is_none(), "languageMap, not value");
3633 2 : assert!(
3634 2 : expand(with_lp(json!({"type": "LanguageProperty",
3635 2 : "languageMap": {"en": "x"}, "valueType": "xsd:string"})))
3636 2 : .is_err(),
3637 : "valueType shall be equal to langString"
3638 : );
3639 2 : assert!(
3640 2 : expand(with_lp(json!({"type": "LanguageProperty",
3641 2 : "languageMap": {"en": 5}})))
3642 2 : .is_err(),
3643 : "languageMap values are strings or string arrays"
3644 : );
3645 2 : assert!(
3646 2 : expand(with_lp(json!({"type": "LanguageProperty",
3647 2 : "languageMap": {"": "x"}})))
3648 2 : .is_err(),
3649 : "empty language tag"
3650 : );
3651 2 : assert!(
3652 2 : expand(with_lp(json!({"type": "LanguageProperty",
3653 2 : "languageMap": {"en": ["a", 5]}})))
3654 2 : .is_err(),
3655 : "array entries must all be strings"
3656 : );
3657 2 : assert!(
3658 2 : expand(with_lp(json!({"type": "LanguageProperty"}))).is_err(),
3659 : "languageMap is mandatory"
3660 : );
3661 2 : assert!(
3662 2 : expand(with_lp(json!({"type": "LanguageProperty",
3663 2 : "languageMap": {"en": "x"}, "observedAt": "not-a-date"})))
3664 2 : .is_err(),
3665 : "observedAt must be a DateTime"
3666 : );
3667 2 : }
3668 : }
3669 :
3670 : #[cfg(test)]
3671 : mod clause_5_2_35 {
3672 : use super::*;
3673 : use serde_json::json;
3674 :
3675 14 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3676 14 : expand_entity(
3677 14 : doc.as_object().expect("obj"),
3678 14 : &std::sync::Arc::new(crate::core_context()),
3679 14 : ExpandOpts::default(),
3680 : )
3681 14 : }
3682 :
3683 14 : fn with_vp(vp: serde_json::Value) -> serde_json::Value {
3684 14 : json!({"id": "urn:x", "type": "T", "category": vp})
3685 14 : }
3686 :
3687 : /// Table 5.2.35-1: vocab is a String or String[] type-coerced to URIs
3688 : /// under the @context; unitCode is prohibited (4.5.20.2); concise form
3689 : /// infers VocabProperty from the vocab member.
3690 : #[test]
3691 2 : fn vocab_property_member_table_restrictions() {
3692 2 : let ok = expand(with_vp(json!({"type": "VocabProperty", "vocab": "term"})))
3693 2 : .expect("conformant VocabProperty");
3694 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
3695 2 : assert_eq!(
3696 2 : attr["vocab"], "https://uri.etsi.org/ngsi-ld/default-context/term",
3697 : "vocab is term-expanded"
3698 : );
3699 2 : assert!(attr.get("value").is_none(), "vocab, not value");
3700 2 : let ok = expand(with_vp(
3701 2 : json!({"type": "VocabProperty", "vocab": ["a", "b"]}),
3702 : ))
3703 2 : .expect("string[] form");
3704 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
3705 2 : assert_eq!(attr["vocab"].as_array().map(Vec::len), Some(2));
3706 2 : assert!(
3707 2 : expand(with_vp(json!({"type": "VocabProperty", "vocab": ["a", 5]}))).is_err(),
3708 : "vocab array entries must be strings"
3709 : );
3710 2 : assert!(
3711 2 : expand(with_vp(json!({"type": "VocabProperty", "vocab": 5}))).is_err(),
3712 : "vocab must be a string or string array"
3713 : );
3714 2 : assert!(
3715 2 : expand(with_vp(json!({"type": "VocabProperty"}))).is_err(),
3716 : "vocab is mandatory"
3717 : );
3718 2 : assert!(
3719 2 : expand(with_vp(
3720 2 : json!({"type": "VocabProperty", "vocab": "t", "unitCode": "C"})
3721 2 : ))
3722 2 : .is_err(),
3723 : "unitCode prohibited (4.5.20.2)"
3724 : );
3725 : // concise: the vocab member alone infers VocabProperty
3726 2 : let ok = expand(with_vp(json!({"vocab": "term"}))).expect("concise inference");
3727 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
3728 2 : assert_eq!(attr["type"], "VocabProperty");
3729 2 : }
3730 : }
3731 :
3732 : #[cfg(test)]
3733 : mod clause_5_2_36 {
3734 : use super::*;
3735 : use serde_json::json;
3736 :
3737 10 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3738 10 : expand_entity(
3739 10 : doc.as_object().expect("obj"),
3740 10 : &std::sync::Arc::new(crate::core_context()),
3741 10 : ExpandOpts::default(),
3742 : )
3743 10 : }
3744 :
3745 10 : fn with_list(lp: serde_json::Value) -> serde_json::Value {
3746 10 : json!({"id": "urn:x", "type": "T", "readings": lp})
3747 10 : }
3748 :
3749 : /// Table 5.2.36-1: valueList is a mandatory ordered array of JSON
3750 : /// values; concise form infers ListProperty from the valueList member.
3751 : #[test]
3752 2 : fn list_property_member_table_restrictions() {
3753 2 : let ok = expand(with_list(json!({"type": "ListProperty",
3754 2 : "valueList": [1, "a", {"o": 2}]})))
3755 2 : .expect("conformant ListProperty");
3756 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/readings"][0];
3757 2 : assert_eq!(attr["valueList"], json!([1, "a", {"o": 2}]), "order kept");
3758 2 : assert!(attr.get("value").is_none(), "valueList, not value");
3759 2 : assert!(
3760 2 : expand(with_list(json!({"type": "ListProperty", "valueList": 5}))).is_err(),
3761 : "valueList must be an array"
3762 : );
3763 2 : assert!(
3764 2 : expand(with_list(json!({"type": "ListProperty"}))).is_err(),
3765 : "valueList is mandatory"
3766 : );
3767 2 : let ok = expand(with_list(json!({"valueList": [1, 2]}))).expect("concise inference");
3768 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/readings"][0];
3769 2 : assert_eq!(attr["type"], "ListProperty");
3770 2 : }
3771 :
3772 : /// 5.5.7: a user @context lives at the top level only; a ListProperty's
3773 : /// values may not carry one, in a full attribute or in a fragment, any
3774 : /// more than a Property value may.
3775 : #[test]
3776 2 : fn a_list_value_may_not_embed_a_context() {
3777 2 : let embedded = json!([{"@context": {"a": "http://evil/a"}, "a": 1}]);
3778 2 : assert!(
3779 2 : expand(with_list(
3780 2 : json!({"type": "ListProperty", "valueList": embedded.clone()})
3781 2 : ))
3782 2 : .is_err(),
3783 : "a full ListProperty"
3784 : );
3785 2 : let fragment = json!({"type": "ListProperty", "valueList": embedded});
3786 2 : assert!(
3787 2 : expand_attr_fragment(
3788 2 : fragment.as_object().expect("obj"),
3789 2 : &std::sync::Arc::new(crate::core_context()),
3790 2 : )
3791 2 : .is_err(),
3792 : "a fragment"
3793 : );
3794 2 : }
3795 : }
3796 :
3797 : #[cfg(test)]
3798 : mod clause_5_2_37 {
3799 : use super::*;
3800 : use serde_json::json;
3801 :
3802 14 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3803 14 : expand_entity(
3804 14 : doc.as_object().expect("obj"),
3805 14 : &std::sync::Arc::new(crate::core_context()),
3806 14 : ExpandOpts::default(),
3807 : )
3808 14 : }
3809 :
3810 14 : fn with_lr(lr: serde_json::Value) -> serde_json::Value {
3811 14 : json!({"id": "urn:x", "type": "T", "route" : lr})
3812 14 : }
3813 :
3814 : /// Table 5.2.37-1: objectList is a mandatory array of URIs — accepted
3815 : /// both as bare URI strings and as {"object": URI} entries (4.5.22.2);
3816 : /// invalid URIs rejected; unitCode prohibited; concise form infers
3817 : /// ListRelationship from the objectList member.
3818 : #[test]
3819 2 : fn list_relationship_member_table_restrictions() {
3820 4 : for form in [
3821 2 : json!(["urn:a", "urn:b"]),
3822 2 : json!([{"object": "urn:a"}, {"object": "urn:b"}]),
3823 2 : ] {
3824 4 : let ok = expand(with_lr(
3825 4 : json!({"type": "ListRelationship", "objectList": form}),
3826 : ))
3827 4 : .expect("conformant ListRelationship");
3828 4 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/route"][0];
3829 4 : let list = attr["objectList"].as_array().expect("objectList");
3830 4 : assert_eq!(list.len(), 2);
3831 4 : assert!(attr.get("object").is_none(), "objectList, not object");
3832 : }
3833 2 : assert!(
3834 2 : expand(with_lr(
3835 2 : json!({"type": "ListRelationship", "objectList": ["not a uri"]})
3836 2 : ))
3837 2 : .is_err(),
3838 : "objectList entries must be URIs"
3839 : );
3840 2 : assert!(
3841 2 : expand(with_lr(
3842 2 : json!({"type": "ListRelationship", "objectList": "urn:a"})
3843 2 : ))
3844 2 : .is_err(),
3845 : "objectList must be an array"
3846 : );
3847 2 : assert!(
3848 2 : expand(with_lr(json!({"type": "ListRelationship"}))).is_err(),
3849 : "objectList is mandatory"
3850 : );
3851 2 : assert!(
3852 2 : expand(with_lr(
3853 2 : json!({"type": "ListRelationship", "objectList": ["urn:a"],
3854 2 : "unitCode": "C"})
3855 2 : ))
3856 2 : .is_err(),
3857 : "unitCode prohibited on a ListRelationship"
3858 : );
3859 2 : let ok = expand(with_lr(json!({"objectList": ["urn:a"]}))).expect("concise inference");
3860 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/route"][0];
3861 2 : assert_eq!(attr["type"], "ListRelationship");
3862 2 : }
3863 : }
3864 :
3865 : #[cfg(test)]
3866 : mod clause_5_2_38 {
3867 : use super::*;
3868 : use serde_json::json;
3869 :
3870 14 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3871 14 : expand_entity(
3872 14 : doc.as_object().expect("obj"),
3873 14 : &std::sync::Arc::new(crate::core_context()),
3874 14 : ExpandOpts::default(),
3875 : )
3876 14 : }
3877 :
3878 14 : fn with_jp(jp: serde_json::Value) -> serde_json::Value {
3879 14 : json!({"id": "urn:x", "type": "T", "payload": jp})
3880 14 : }
3881 :
3882 : /// Table 5.2.38-1 / TS 104 175 Table 5.2.6.4.10-1: json is a mandatory
3883 : /// NGSI-LD Value, never expanded; unitCode prohibited (4.5.24.2); concise form
3884 : /// infers JsonProperty from the json member.
3885 : #[test]
3886 2 : fn json_property_member_table_restrictions() {
3887 2 : let ok = expand(with_jp(json!({"type": "JsonProperty",
3888 2 : "json": {"type": "kept-verbatim", "en": 1}})))
3889 2 : .expect("conformant JsonProperty");
3890 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
3891 2 : assert_eq!(
3892 2 : attr["json"],
3893 2 : json!({"type": "kept-verbatim", "en": 1}),
3894 : "raw JSON kept verbatim, no expansion"
3895 : );
3896 2 : assert!(attr.get("value").is_none(), "json, not value");
3897 2 : let ok = expand(with_jp(
3898 2 : json!({"type": "JsonProperty", "json": [{"a": 1}, {"b": 2}]}),
3899 : ))
3900 2 : .expect("array-of-objects form");
3901 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
3902 2 : assert_eq!(attr["json"].as_array().map(Vec::len), Some(2));
3903 2 : let ok = expand(with_jp(json!({"type": "JsonProperty", "json": [1, 2]})))
3904 2 : .expect("TS 104 175 5.2.6.4.10: any JSON value");
3905 2 : assert_eq!(
3906 2 : ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0]["json"],
3907 2 : json!([1, 2])
3908 : );
3909 2 : assert!(
3910 2 : expand(with_jp(json!({"type": "JsonProperty", "json": null}))).is_err(),
3911 : "JSON null is no NGSI-LD Value"
3912 : );
3913 2 : assert!(
3914 2 : expand(with_jp(json!({"type": "JsonProperty"}))).is_err(),
3915 : "json is mandatory"
3916 : );
3917 2 : assert!(
3918 2 : expand(with_jp(
3919 2 : json!({"type": "JsonProperty", "json": {"a": 1}, "unitCode": "C"})
3920 2 : ))
3921 2 : .is_err(),
3922 : "unitCode prohibited on a JsonProperty"
3923 : );
3924 2 : let ok = expand(with_jp(json!({"json": {"a": 1}}))).expect("concise inference");
3925 2 : let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
3926 2 : assert_eq!(attr["type"], "JsonProperty");
3927 2 : }
3928 : }
3929 :
3930 : #[cfg(test)]
3931 : mod clause_5_5_4 {
3932 : use super::*;
3933 : use serde_json::json;
3934 :
3935 12 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3936 12 : expand_entity(
3937 12 : doc.as_object().expect("obj"),
3938 12 : &std::sync::Arc::new(crate::core_context()),
3939 12 : ExpandOpts::default(),
3940 : )
3941 12 : }
3942 :
3943 : /// 5.5.4: outside fragments/notifications, "urn:ngsi-ld:null" is
3944 : /// BadRequestData as a first-level member value, as a Property value /
3945 : /// Relationship object, as the languageMap {"@none": null} form, AND as
3946 : /// a key value inside a JSON object that is a Property value.
3947 : #[test]
3948 2 : fn ngsi_null_rejected_everywhere_on_create() {
3949 10 : let with = |attr: serde_json::Value| json!({"id": "urn:x", "type": "T", "a": attr});
3950 2 : assert!(
3951 2 : expand(json!({"id": "urn:x", "type": "T", "scope": "urn:ngsi-ld:null"})).is_err(),
3952 : "first-level member value"
3953 : );
3954 2 : assert!(
3955 2 : expand(with(
3956 2 : json!({"type": "Property", "value": "urn:ngsi-ld:null"})
3957 2 : ))
3958 2 : .is_err(),
3959 : "Property value"
3960 : );
3961 2 : assert!(
3962 2 : expand(with(
3963 2 : json!({"type": "Relationship", "object": "urn:ngsi-ld:null"})
3964 2 : ))
3965 2 : .is_err(),
3966 : "Relationship object"
3967 : );
3968 2 : assert!(
3969 2 : expand(with(json!({"type": "LanguageProperty",
3970 2 : "languageMap": {"@none": "urn:ngsi-ld:null"}})))
3971 2 : .is_err(),
3972 : "languageMap null form"
3973 : );
3974 2 : assert!(
3975 2 : expand(with(json!({"type": "Property",
3976 2 : "value": {"nested": "urn:ngsi-ld:null"}})))
3977 2 : .is_err(),
3978 : "null inside a compound Property value"
3979 : );
3980 : // control: an ordinary compound value stays creatable
3981 2 : assert!(expand(with(json!({"type": "Property", "value": {"nested": 1}}))).is_ok());
3982 2 : }
3983 : }
3984 :
3985 : #[cfg(test)]
3986 : mod clause_5_2_7 {
3987 : use super::*;
3988 : use serde_json::json;
3989 :
3990 10 : fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
3991 10 : expand_entity(
3992 10 : doc.as_object().expect("obj"),
3993 10 : &std::sync::Arc::new(crate::core_context()),
3994 10 : ExpandOpts::default(),
3995 : )
3996 10 : }
3997 :
3998 10 : fn with_g(g: serde_json::Value) -> serde_json::Value {
3999 10 : json!({"id": "urn:x", "type": "T", "g": g})
4000 10 : }
4001 :
4002 : /// Table 5.2.7-1: value must be a 4.7 GeoJSON geometry object (a plain
4003 : /// number/string is a violation), GeometryCollection excluded (4.6.3);
4004 : /// unitCode prohibited — GeoProperties carry coordinates, not units.
4005 : #[test]
4006 2 : fn geoproperty_member_table_restrictions() {
4007 2 : assert!(expand(with_g(json!({"type": "GeoProperty", "value": 5}))).is_err());
4008 2 : assert!(expand(with_g(json!({"type": "GeoProperty",
4009 2 : "value": {"type": "Nonsense", "coordinates": [1, 2]}})))
4010 2 : .is_err());
4011 2 : assert!(expand(with_g(json!({"type": "GeoProperty",
4012 2 : "value": {"type": "GeometryCollection", "geometries": []}})))
4013 2 : .is_err());
4014 2 : assert!(expand(with_g(json!({"type": "GeoProperty",
4015 2 : "value": {"type": "Point", "coordinates": [8, 40]},
4016 2 : "datasetId": "urn:ds:1"})))
4017 2 : .is_ok());
4018 2 : assert!(expand(with_g(json!({"type": "GeoProperty",
4019 2 : "value": {"type": "LineString",
4020 2 : "coordinates": [[8, 40], [9, 41]]}})))
4021 2 : .is_ok());
4022 2 : }
4023 : }
4024 :
4025 : #[cfg(test)]
4026 : mod reserved_member_guards {
4027 : use super::*;
4028 : use serde_json::json;
4029 :
4030 : /// 4.5.1: an Attribute name is expanded against the @context, and the
4031 : /// user @context is merged BEFORE the core one — so a term whose "@id"
4032 : /// is a bare word stays a RELATIVE IRI. Expanding an attribute onto
4033 : /// "id" must not be allowed to replace the Entity id (5.5.4
4034 : /// BadRequestData), the same rule expand_types applies to type names.
4035 : #[test]
4036 2 : fn attribute_name_must_expand_to_an_absolute_iri() {
4037 2 : let ctx = crate::resolve_with(&crate::PinnedOnly, &json!({"hostile": {"@id": "id"}}))
4038 2 : .expect("inline @context");
4039 2 : assert_eq!(ctx.expand_key("hostile"), "id", "term maps to a bare word");
4040 :
4041 2 : let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "T",
4042 2 : "hostile": {"type": "Property", "value": 1}});
4043 2 : let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default());
4044 2 : assert!(
4045 2 : out.is_err(),
4046 : "an attribute name that does not expand to an absolute IRI is BadRequestData, got {out:?}"
4047 : );
4048 2 : }
4049 :
4050 : /// 4.5.1/5.5.4: the same at sub-attribute level — a term expanding onto
4051 : /// "observedAt" would replace the validated DateTime with an instance
4052 : /// array, bypassing the 4.6.3 DateTime check.
4053 : #[test]
4054 2 : fn sub_attribute_name_must_not_overwrite_observed_at() {
4055 2 : let ctx = crate::resolve_with(
4056 2 : &crate::PinnedOnly,
4057 2 : &json!({"hostile": {"@id": "observedAt"}}),
4058 : )
4059 2 : .expect("inline @context");
4060 2 : assert_eq!(ctx.expand_key("hostile"), "observedAt");
4061 :
4062 2 : let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "T",
4063 2 : "speed": {"type": "Property", "value": 1,
4064 2 : "observedAt": "2026-01-01T00:00:00Z",
4065 2 : "hostile": {"type": "Property", "value": "x"}}});
4066 2 : let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default());
4067 2 : assert!(out.is_err(), "expected BadRequestData, got {out:?}");
4068 :
4069 : // negative: with a well-behaved @context the sub-attribute lands on
4070 : // its own IRI and observedAt still holds the DateTime string.
4071 2 : let ctx = crate::resolve_with(
4072 2 : &crate::PinnedOnly,
4073 2 : &json!({"hostile": {"@id": "https://example.org/hostile"}}),
4074 : )
4075 2 : .expect("inline @context");
4076 2 : let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default())
4077 2 : .expect("absolute IRI is fine");
4078 2 : let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
4079 2 : assert_eq!(inst["observedAt"], "2026-01-01T00:00:00Z");
4080 2 : assert!(inst["https://example.org/hostile"].is_array());
4081 2 : }
4082 :
4083 : /// 4.5.5.1/5.5.8: "datasetId" is a URI string in a partial-update
4084 : /// fragment exactly as in a full instance — a non-string one is copied
4085 : /// onto the target instance and hides its default slot from every
4086 : /// datasetId-absent lookup.
4087 : #[test]
4088 2 : fn fragment_dataset_id_must_be_a_uri_string() {
4089 10 : for bad in [
4090 2 : json!(42),
4091 2 : json!(["urn:ngsi-ld:Dataset:a"]),
4092 2 : json!({"object": "urn:ngsi-ld:Dataset:a"}),
4093 2 : json!(true),
4094 2 : json!("not a uri"),
4095 2 : ] {
4096 10 : let frag = json!({"type": "Property", "value": 1, "datasetId": bad});
4097 10 : let out = expand_attr_fragment(frag.as_object().expect("obj"), &core());
4098 10 : assert!(
4099 10 : out.is_err(),
4100 : "datasetId {bad} must be rejected, got {out:?}"
4101 : );
4102 : }
4103 2 : let frag = json!({"type": "Property", "value": 1, "datasetId": "urn:ngsi-ld:Dataset:a"});
4104 2 : let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
4105 2 : .expect("a URI datasetId is valid");
4106 2 : assert_eq!(out["datasetId"], "urn:ngsi-ld:Dataset:a");
4107 2 : }
4108 :
4109 : /// 5.2.1: "In all other cases, implementations shall raise an error of
4110 : /// type BadRequestData if an NGSI-LD Null value is encountered" — the
4111 : /// concise forms (a bare value, a bare object value) must not be a way
4112 : /// around it, or a plain append deletes the instance it targets.
4113 : #[test]
4114 2 : fn concise_values_do_not_smuggle_the_ngsi_null() {
4115 8 : let create = |attr: serde_json::Value| -> Result<Value, NgsiError> {
4116 8 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T", "a": attr});
4117 8 : expand_entity(
4118 8 : doc.as_object().expect("obj"),
4119 8 : &core(),
4120 8 : ExpandOpts::default(),
4121 : )
4122 8 : };
4123 8 : for attr in [
4124 2 : json!(["urn:ngsi-ld:null"]),
4125 2 : json!({"foo": "urn:ngsi-ld:null"}),
4126 2 : json!({"type": "Property", "value": "urn:ngsi-ld:null"}),
4127 2 : json!({"value": ["urn:ngsi-ld:null"]}),
4128 2 : ] {
4129 8 : let out = create(attr.clone());
4130 8 : assert!(out.is_err(), "{attr} must be BadRequestData, got {out:?}");
4131 : }
4132 : // negative: the same documents stay legal on a merge fragment
4133 : // (5.5.12), and a create with no sentinel keeps its value intact.
4134 2 : let doc = json!({"a": {"foo": "urn:ngsi-ld:null"}});
4135 2 : assert!(expand_entity(
4136 2 : doc.as_object().expect("obj"),
4137 2 : &core(),
4138 2 : ExpandOpts {
4139 2 : fragment: true,
4140 2 : allow_null: true,
4141 2 : merge: true,
4142 2 : ..Default::default()
4143 2 : }
4144 2 : )
4145 2 : .is_ok());
4146 2 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T", "a": {"foo": "bar"}});
4147 2 : let out = expand_entity(
4148 2 : doc.as_object().expect("obj"),
4149 2 : &core(),
4150 2 : ExpandOpts::default(),
4151 : )
4152 2 : .expect("plain compound value");
4153 2 : assert_eq!(
4154 2 : out["https://uri.etsi.org/ngsi-ld/default-context/a"][0]["value"]["foo"],
4155 : "bar"
4156 : );
4157 2 : }
4158 :
4159 : /// 5.2.5 Table 5.2.5-2 preamble: the output-only members "shall not be
4160 : /// provided by Context Producers. In the event that they are provided (in
4161 : /// update or create operations) NGSI-LD implementations shall ignore
4162 : /// them." 4.5.2.2 Prohibited adds "shall never include" for entity,
4163 : /// entityList and the previous* family, and entityIdSealed/
4164 : /// entityTypeSealed "unless the Property name is ngsildproof".
4165 : #[test]
4166 2 : fn fragment_ignores_output_only_and_prohibited_members() {
4167 2 : let frag = json!({
4168 2 : "type": "Property",
4169 2 : "value": 5,
4170 2 : "previousValue": 999,
4171 2 : "previousObject": "urn:ngsi-ld:Other:1",
4172 2 : "previousLanguageMap": {"en": "x"},
4173 2 : "previousJson": {"a": 1},
4174 2 : "previousVocab": "x",
4175 2 : "previousValueList": [1],
4176 2 : "previousObjectList": ["urn:ngsi-ld:Other:1"],
4177 2 : "entity": {"id": "urn:evil", "type": "T"},
4178 2 : "entityList": [{"id": "urn:evil", "type": "T"}],
4179 2 : "entityIdSealed": "urn:evil",
4180 2 : "entityTypeSealed": "T",
4181 2 : "deletedAt": "2026-01-01T00:00:00Z",
4182 : });
4183 2 : let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
4184 2 : .expect("the ignored members must not make the fragment invalid");
4185 2 : let m = out.as_object().expect("object");
4186 24 : for k in [
4187 2 : "previousValue",
4188 2 : "previousObject",
4189 2 : "previousLanguageMap",
4190 2 : "previousJson",
4191 2 : "previousVocab",
4192 2 : "previousValueList",
4193 2 : "previousObjectList",
4194 2 : "entity",
4195 2 : "entityList",
4196 2 : "entityIdSealed",
4197 2 : "entityTypeSealed",
4198 2 : "deletedAt",
4199 2 : ] {
4200 24 : assert!(
4201 24 : !m.contains_key(k),
4202 : "{k} must be ignored on input, got {out:#}"
4203 : );
4204 : }
4205 : // negative: the members the fragment IS allowed to carry survive.
4206 2 : assert_eq!(m["value"], 5);
4207 2 : assert_eq!(m["type"], "Property");
4208 2 : }
4209 :
4210 : /// 4.6.3/4.22: expiresAt is an ISO 8601 DateTime in a partial-update
4211 : /// fragment exactly as in a full instance — an unparsable one must not
4212 : /// reach the transient-entity boundary check.
4213 : #[test]
4214 2 : fn fragment_expires_at_is_a_datetime() {
4215 2 : let frag = json!({"type": "Property", "value": 1, "expiresAt": "soon"});
4216 2 : let out = expand_attr_fragment(frag.as_object().expect("obj"), &core());
4217 2 : assert!(
4218 2 : matches!(out, Err(NgsiError::BadRequestData(_))),
4219 : "an invalid expiresAt is BadRequestData, got {out:?}"
4220 : );
4221 2 : let frag = json!({"type": "Property", "value": 1, "expiresAt": "2026-01-01T00:00:00Z"});
4222 2 : let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
4223 2 : .expect("a valid DateTime is kept");
4224 2 : assert_eq!(out["expiresAt"], "2026-01-01T00:00:00Z");
4225 2 : }
4226 :
4227 : /// 4.5.1: "Terms defined in the Core Context as non-reified Properties
4228 : /// (such as datasetId, instanceId, etc.) shall not be used as Attribute
4229 : /// names." createdAt/modifiedAt/deletedAt/expiresAt/scope map 1:1 onto
4230 : /// their core IRI, so the fully-qualified spelling would compact straight
4231 : /// back onto the Entity's own system member.
4232 : #[test]
4233 2 : fn core_system_member_iris_cannot_be_attribute_names() {
4234 10 : for term in ["createdAt", "modifiedAt", "deletedAt", "expiresAt", "scope"] {
4235 10 : let mut doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T"});
4236 10 : doc.as_object_mut().expect("obj").insert(
4237 10 : format!("https://uri.etsi.org/ngsi-ld/{term}"),
4238 10 : json!({"type": "Property", "value": "pwned"}),
4239 : );
4240 10 : let out = expand_entity(
4241 10 : doc.as_object().expect("obj"),
4242 10 : &core(),
4243 10 : ExpandOpts::default(),
4244 : );
4245 10 : assert!(
4246 10 : matches!(out, Err(NgsiError::BadRequestData(_))),
4247 : "{term} as an Attribute name is BadRequestData, got {out:?}"
4248 : );
4249 : }
4250 : // negative: the system members themselves still expand normally.
4251 2 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
4252 2 : "expiresAt": "2026-01-01T00:00:00Z", "scope": "/a"});
4253 2 : let out = expand_entity(
4254 2 : doc.as_object().expect("obj"),
4255 2 : &core(),
4256 2 : ExpandOpts::default(),
4257 : )
4258 2 : .expect("plain system members");
4259 2 : assert_eq!(out["expiresAt"], "2026-01-01T00:00:00Z");
4260 2 : }
4261 :
4262 : /// Table 5.2.6-1: objectType is "String or String[]" and "Both short hand
4263 : /// string(s) (type name) or URI(s) are allowed" — both shapes are
4264 : /// @vocab-coerced, so the two spellings of one target type cannot be
4265 : /// stored differently.
4266 : #[test]
4267 2 : fn object_type_expands_in_both_string_and_array_form() {
4268 2 : let expanded = "https://uri.etsi.org/ngsi-ld/default-context/Device";
4269 10 : let rel = |ot: serde_json::Value| -> Result<Value, NgsiError> {
4270 10 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
4271 10 : "r": {"type": "Relationship", "object": "urn:ngsi-ld:D:1", "objectType": ot}});
4272 10 : expand_entity(
4273 10 : doc.as_object().expect("obj"),
4274 10 : &core(),
4275 10 : ExpandOpts::default(),
4276 : )
4277 10 : };
4278 2 : let scalar = rel(json!("Device")).expect("scalar objectType");
4279 2 : let array = rel(json!(["Device"])).expect("array objectType");
4280 6 : let at = |v: &Value| {
4281 6 : v["https://uri.etsi.org/ngsi-ld/default-context/r"][0]["objectType"].clone()
4282 6 : };
4283 2 : assert_eq!(at(&scalar), json!(expanded));
4284 2 : assert_eq!(at(&array), json!([expanded]));
4285 : // negative: the bare term must never survive unexpanded.
4286 2 : assert_ne!(at(&array), json!(["Device"]));
4287 6 : for bad in [json!(42), json!({"a": 1}), json!([1])] {
4288 6 : let out = rel(bad.clone());
4289 6 : assert!(
4290 6 : matches!(out, Err(NgsiError::BadRequestData(_))),
4291 : "objectType {bad} must be rejected, got {out:?}"
4292 : );
4293 : }
4294 2 : }
4295 :
4296 : /// 4.6.3/Table 5.2.7-1: a GeoProperty value is a clause 4.7 GeoJSON
4297 : /// geometry, whose "coordinates" is an array — a scalar or object one is
4298 : /// not a geometry and must not reach the GeoJSON rendering path.
4299 : #[test]
4300 2 : fn geojson_coordinates_must_be_an_array() {
4301 8 : for bad in [json!("boom"), json!(42), json!({"lat": 1}), json!(null)] {
4302 8 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
4303 8 : "location": {"type": "GeoProperty",
4304 8 : "value": {"type": "Point", "coordinates": bad}}});
4305 8 : let out = expand_entity(
4306 8 : doc.as_object().expect("obj"),
4307 8 : &core(),
4308 8 : ExpandOpts::default(),
4309 : );
4310 8 : assert!(
4311 8 : matches!(out, Err(NgsiError::BadRequestData(_))),
4312 : "coordinates {bad} must be rejected, got {out:?}"
4313 : );
4314 : }
4315 : // negative: a real geometry still passes untouched.
4316 2 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
4317 2 : "location": {"type": "GeoProperty",
4318 2 : "value": {"type": "Point", "coordinates": [1.0, 2.0]}}});
4319 2 : let out = expand_entity(
4320 2 : doc.as_object().expect("obj"),
4321 2 : &core(),
4322 2 : ExpandOpts::default(),
4323 : )
4324 2 : .expect("valid Point");
4325 2 : assert_eq!(
4326 2 : out["https://uri.etsi.org/ngsi-ld/location"][0]["value"]["coordinates"],
4327 2 : json!([1.0, 2.0])
4328 : );
4329 2 : }
4330 :
4331 : /// 4.5.5.1: "There can only be one default Attribute instance for an
4332 : /// Attribute with a given Attribute name in any request or response" — a
4333 : /// term and its own expanded IRI are one Attribute name, so accepting
4334 : /// both would silently discard one client's data.
4335 : #[test]
4336 2 : fn two_names_expanding_to_one_iri_are_rejected() {
4337 2 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
4338 2 : "temperature": {"type": "Property", "value": 1},
4339 2 : "https://uri.etsi.org/ngsi-ld/default-context/temperature":
4340 2 : {"type": "Property", "value": 2}});
4341 2 : let out = expand_entity(
4342 2 : doc.as_object().expect("obj"),
4343 2 : &core(),
4344 2 : ExpandOpts::default(),
4345 : );
4346 2 : assert!(
4347 2 : matches!(out, Err(NgsiError::BadRequestData(_))),
4348 : "duplicate expanded Attribute name is BadRequestData, got {out:?}"
4349 : );
4350 : // the same collision one level down, on sub-attributes.
4351 2 : let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
4352 2 : "speed": {"type": "Property", "value": 1,
4353 2 : "accuracy": {"type": "Property", "value": 1},
4354 2 : "https://uri.etsi.org/ngsi-ld/default-context/accuracy":
4355 2 : {"type": "Property", "value": 2}}});
4356 2 : let out = expand_entity(
4357 2 : doc.as_object().expect("obj"),
4358 2 : &core(),
4359 2 : ExpandOpts::default(),
4360 : );
4361 2 : assert!(
4362 2 : matches!(out, Err(NgsiError::BadRequestData(_))),
4363 : "duplicate expanded sub-Attribute name is BadRequestData, got {out:?}"
4364 : );
4365 2 : }
4366 :
4367 82 : fn core() -> std::sync::Arc<Context> {
4368 82 : std::sync::Arc::new(crate::core_context())
4369 82 : }
4370 :
4371 : /// `expand_instance` dispatches on `attr_type` and closes the match with
4372 : /// `unreachable!()`. That arm is reachable exactly when `ATTR_TYPES` — a
4373 : /// `pub` list, and the gate a CLIENT-supplied `"type"` passes through —
4374 : /// carries a member the match does not: 4.5.2 gives Attribute types a
4375 : /// closed set, but a later edition adding one to the list without an arm
4376 : /// turns `{"type": "<new>"}` into a panic on the request path instead of
4377 : /// a Table 6.3.2-1 error.
4378 : ///
4379 : /// So every member of the list is walked here. A type may legitimately
4380 : /// refuse an instance that carries the wrong members (BadRequestData is a
4381 : /// fine answer); it may not panic, and it may not answer with an error
4382 : /// that is not an NGSI-LD one.
4383 : #[test]
4384 2 : fn every_declarable_attribute_type_is_dispatched_not_unreachable() {
4385 16 : for t in ATTR_TYPES {
4386 16 : let doc = serde_json::json!({
4387 16 : "id": "urn:ngsi-ld:Vehicle:dispatch",
4388 16 : "type": "Vehicle",
4389 : // Bare on purpose: 4.5.2.2 lets a value-defining member
4390 : // appear only on its own type, so an instance carrying them
4391 : // all is refused BEFORE the dispatch and would prove nothing.
4392 16 : "attr": {"type": t},
4393 : });
4394 : // Whatever the answer is, reaching one is the assertion: an
4395 : // unhandled type would have panicked before returning.
4396 16 : let got = expand_entity(
4397 16 : doc.as_object().expect("object"),
4398 16 : &core(),
4399 16 : ExpandOpts::default(),
4400 : );
4401 16 : if let Err(e) = got {
4402 16 : assert_eq!(
4403 16 : e.status(),
4404 : 400,
4405 : "{t}: an instance carrying the wrong members is BadRequestData, not {e}"
4406 : );
4407 0 : }
4408 : }
4409 2 : }
4410 : }
4411 :
4412 : /// The refusals and pass-throughs no clause test above reaches: each case is
4413 : /// one malformed (or one borderline legal) input and the answer it gets.
4414 : #[cfg(test)]
4415 : mod edges {
4416 : use super::*;
4417 : use serde_json::json;
4418 :
4419 : const MERGE: ExpandOpts = ExpandOpts {
4420 : fragment: true,
4421 : allow_null: true,
4422 : merge: true,
4423 : temporal: false,
4424 : sys: false,
4425 : };
4426 :
4427 102 : fn run(doc: Value, opts: ExpandOpts) -> Result<Value, NgsiError> {
4428 102 : expand_entity(
4429 102 : doc.as_object().expect("obj"),
4430 102 : &std::sync::Arc::new(crate::core_context()),
4431 102 : opts,
4432 : )
4433 102 : }
4434 :
4435 38 : fn ok(doc: Value, opts: ExpandOpts) -> Value {
4436 38 : run(doc.clone(), opts).unwrap_or_else(|e| panic!("{doc} refused: {e:?}"))
4437 38 : }
4438 :
4439 62 : fn refused(doc: Value, opts: ExpandOpts) {
4440 62 : let out = run(doc.clone(), opts);
4441 62 : assert!(
4442 62 : matches!(out, Err(NgsiError::BadRequestData(_))),
4443 : "{doc} must be BadRequestData, got {out:?}"
4444 : );
4445 62 : }
4446 :
4447 62 : fn with(attr: Value) -> Value {
4448 62 : json!({"id": "urn:ngsi-ld:T:1", "type": "T", "a": attr})
4449 62 : }
4450 :
4451 : const A: &str = "https://uri.etsi.org/ngsi-ld/default-context/a";
4452 :
4453 : #[test]
4454 2 : fn entity_members() {
4455 2 : let d = ExpandOpts::default();
4456 2 : refused(json!({"id": 5, "type": "T"}), d);
4457 2 : refused(json!({"type": "T"}), d);
4458 2 : refused(json!({"id": "urn:x", "type": ["T", 5]}), d);
4459 2 : refused(json!({"id": "urn:x", "type": ["T", ""]}), d);
4460 2 : refused(
4461 2 : json!({"id": "urn:x", "type": "T", "": {"type": "Property", "value": 1}}),
4462 2 : d,
4463 : );
4464 2 : refused(
4465 2 : with(
4466 2 : json!([{"type": "Property", "value": 1, "datasetId": "urn:d"},
4467 2 : {"type": "Property", "value": 2, "datasetId": "urn:d"}]),
4468 : ),
4469 2 : d,
4470 : );
4471 2 : let sys = ExpandOpts { sys: true, ..d };
4472 2 : let out = ok(
4473 2 : json!({"id": "urn:x", "type": "T", "createdAt": "2020-01-01T00:00:00Z",
4474 2 : "modifiedAt": "yesterday", "deletedAt": 5}),
4475 2 : sys,
4476 : );
4477 2 : assert_eq!(out["createdAt"], "2020-01-01T00:00:00Z");
4478 2 : assert!(out.get("modifiedAt").is_none());
4479 2 : assert!(out.get("deletedAt").is_none());
4480 2 : refused(json!({"id": "urn:x", "type": ""}), d);
4481 2 : refused(json!({"id": "urn:x", "type": []}), d);
4482 : // temporal input repeats datasetIds legally (4.5.6)
4483 2 : ok(
4484 2 : with(
4485 2 : json!([{"type": "Property", "value": 1, "datasetId": "urn:d"},
4486 2 : {"type": "Property", "value": 2, "datasetId": "urn:d"}]),
4487 : ),
4488 2 : ExpandOpts {
4489 2 : temporal: true,
4490 2 : ..d
4491 2 : },
4492 : );
4493 : // a merge may delete the location
4494 2 : ok(
4495 2 : json!({"id": "urn:x", "type": "T", "location": "urn:ngsi-ld:null"}),
4496 : MERGE,
4497 : );
4498 : // a merge of a whole Entity may remove its expiry too
4499 2 : let whole_merge = ExpandOpts {
4500 2 : allow_null: true,
4501 2 : merge: true,
4502 2 : ..d
4503 2 : };
4504 2 : ok(
4505 2 : json!({"id": "urn:x", "type": "T", "expiresAt": "urn:ngsi-ld:null"}),
4506 2 : whole_merge,
4507 : );
4508 2 : }
4509 :
4510 : /// 4.5.6: temporal input carries scope as Property instances.
4511 : #[test]
4512 2 : fn temporal_scope_instances() {
4513 2 : let t = ExpandOpts {
4514 2 : temporal: true,
4515 2 : ..Default::default()
4516 2 : };
4517 14 : let scope = |s: Value| json!({"id": "urn:x", "type": "T", "scope": s});
4518 2 : let out = ok(scope(json!([{"value": "/a"}, {"value": ["/b", "/c"]}])), t);
4519 2 : assert_eq!(out["scope"].as_array().map(Vec::len), Some(2));
4520 2 : refused(scope(json!([{"value": 5}])), t);
4521 2 : refused(scope(json!([{"value": ["/a", 5]}])), t);
4522 2 : refused(scope(json!([{"value": "/a b"}])), t);
4523 2 : refused(scope(json!([5])), t);
4524 2 : refused(scope(json!([{"value": "/a"}])), ExpandOpts::default());
4525 2 : refused(scope(json!(5)), ExpandOpts::default());
4526 2 : }
4527 :
4528 : #[test]
4529 2 : fn expanded_accessors_name_the_wiring_mistake() {
4530 2 : assert!(matches!(
4531 2 : expanded_object(&json!(1)),
4532 : Err(NgsiError::InternalError(_))
4533 : ));
4534 2 : assert!(expanded_object(&json!({})).is_ok());
4535 2 : assert!(matches!(
4536 2 : expanded_id(&json!({})),
4537 : Err(NgsiError::InternalError(_))
4538 : ));
4539 2 : assert_eq!(expanded_id(&json!({"id": "urn:x"})).ok(), Some("urn:x"));
4540 2 : }
4541 :
4542 : #[test]
4543 2 : fn a_new_entity_id_holding_a_comma_is_refused() {
4544 2 : assert_eq!(
4545 2 : new_entity_id(&json!({"id": "urn:x:a"})).ok(),
4546 : Some("urn:x:a")
4547 : );
4548 2 : let err = format!("{:?}", new_entity_id(&json!({"id": "urn:x:a,b"})));
4549 2 : assert!(err.starts_with("Err(BadRequestData("), "{err}");
4550 2 : assert!(err.contains("urn:x:a-b"), "{err}");
4551 2 : let none = format!("{:?}", new_entity_id(&json!({})));
4552 2 : assert!(none.starts_with("Err(InternalError("), "{none}");
4553 2 : }
4554 :
4555 : #[test]
4556 2 : fn deletion_markers() {
4557 2 : let n = "urn:ngsi-ld:null";
4558 18 : for inst in [
4559 2 : json!(n),
4560 2 : json!({"value": n}),
4561 2 : json!({"object": n}),
4562 2 : json!({"languageMap": {"@none": n}}),
4563 2 : json!({"languageMap": n}),
4564 2 : json!({"json": n}),
4565 2 : json!({"vocab": n}),
4566 2 : json!({"valueList": [n]}),
4567 2 : json!({"objectList": n}),
4568 2 : ] {
4569 18 : assert!(is_deletion_instance(&inst), "{inst}");
4570 : }
4571 8 : for inst in [
4572 2 : json!({"value": 1}),
4573 2 : json!(5),
4574 2 : json!({"languageMap": {"en": n, "@none": n}}),
4575 2 : json!({"valueList": ["x"]}),
4576 2 : ] {
4577 8 : assert!(!is_deletion_instance(&inst), "{inst}");
4578 : }
4579 2 : assert!(check_value_nulls("a", &json!(n), ExpandOpts::default()).is_err());
4580 2 : assert!(check_value_nulls("a", &json!([1, n]), ExpandOpts::default()).is_err());
4581 2 : assert!(check_value_nulls("a", &json!(n), MERGE).is_ok());
4582 2 : }
4583 :
4584 : #[test]
4585 2 : fn nesting_and_instance_arrays() {
4586 2 : let mut attr = json!({"type": "Property", "value": 1});
4587 20 : for _ in 0..10 {
4588 20 : attr = json!({"type": "Property", "value": 1, "s": attr});
4589 20 : }
4590 2 : refused(with(attr), ExpandOpts::default());
4591 : // members without "type" still mark an array element as an instance
4592 2 : let out = ok(
4593 2 : with(json!([{"value": 1, "datasetId": "urn:d1"}, {"value": 2, "datasetId": "urn:d2"}])),
4594 2 : ExpandOpts::default(),
4595 : );
4596 2 : assert_eq!(out[A].as_array().map(Vec::len), Some(2));
4597 : // an array of plain objects is one Property value
4598 2 : let out = ok(with(json!([{"x": 1}])), ExpandOpts::default());
4599 2 : assert_eq!(out[A].as_array().map(Vec::len), Some(1));
4600 2 : let out = ok(with(json!([])), ExpandOpts::default());
4601 2 : assert_eq!(out[A][0]["value"], json!([]));
4602 2 : }
4603 :
4604 : #[test]
4605 2 : fn instance_members() {
4606 2 : let d = ExpandOpts::default();
4607 12 : let p = |extra: Value| {
4608 12 : let mut o = json!({"type": "Property", "value": 1});
4609 12 : o.as_object_mut()
4610 12 : .unwrap()
4611 12 : .extend(extra.as_object().unwrap().clone());
4612 12 : with(o)
4613 12 : };
4614 2 : refused(
4615 2 : json!({"id": "urn:x", "type": "T",
4616 2 : "ngsildproof": {"type": "Property", "entityIdSealed": "urn:x"}}),
4617 2 : d,
4618 : );
4619 2 : let out = ok(p(json!({"expiresAt": "2030-01-01T00:00:00Z"})), d);
4620 2 : assert_eq!(out[A][0]["expiresAt"], "2030-01-01T00:00:00Z");
4621 2 : refused(p(json!({"expiresAt": "soon"})), d);
4622 2 : refused(p(json!({"valueType": 5})), d);
4623 2 : let sys = ExpandOpts { sys: true, ..d };
4624 2 : let out = ok(
4625 2 : p(json!({"createdAt": "2020-01-01T00:00:00Z", "modifiedAt": "no", "deletedAt": 1})),
4626 2 : sys,
4627 : );
4628 2 : assert_eq!(out[A][0]["createdAt"], "2020-01-01T00:00:00Z");
4629 2 : assert!(out[A][0].get("modifiedAt").is_none());
4630 2 : assert!(out[A][0].get("deletedAt").is_none());
4631 : // a geometry type without coordinates is no geometry: a Property
4632 2 : let out = ok(with(json!({"value": {"type": "Point"}})), d);
4633 2 : assert_eq!(out[A][0]["type"], "Property");
4634 2 : refused(p(json!({"s": "urn:ngsi-ld:null"})), d);
4635 2 : refused(p(json!({"": {"type": "Property", "value": 2}})), d);
4636 2 : refused(
4637 2 : with(json!({"type": "LanguageProperty", "languageMap": {"en": "urn:ngsi-ld:null"}})),
4638 2 : d,
4639 : );
4640 2 : }
4641 :
4642 : #[test]
4643 2 : fn typed_values() {
4644 2 : let d = ExpandOpts::default();
4645 2 : let n = "urn:ngsi-ld:null";
4646 2 : refused(
4647 2 : with(json!({"type": "GeoProperty",
4648 2 : "value": "{\"type\":\"Point\",\"coordinates\":[200,0]}"})),
4649 2 : d,
4650 : );
4651 2 : assert_eq!(
4652 2 : ok(with(json!({"type": "GeoProperty", "value": n})), MERGE)[A][0]["value"],
4653 : n
4654 : );
4655 2 : refused(
4656 2 : with(json!({"type": "Relationship", "object": "not a uri"})),
4657 2 : d,
4658 : );
4659 2 : refused(with(json!({"type": "Relationship", "object": [5]})), d);
4660 2 : refused(with(json!({"type": "Relationship", "object": 5})), d);
4661 2 : refused(with(json!({"type": "Relationship", "object": []})), d);
4662 2 : assert_eq!(
4663 2 : ok(with(json!({"type": "Relationship", "object": n})), MERGE)[A][0]["object"],
4664 : n
4665 : );
4666 2 : assert_eq!(
4667 2 : ok(with(json!({"type": "JsonProperty", "json": n})), MERGE)[A][0]["json"],
4668 : n
4669 : );
4670 : // merge inputs that carry no null take the ordinary checks
4671 2 : ok(
4672 2 : with(
4673 2 : json!({"type": "GeoProperty", "value": "{\"type\":\"Point\",\"coordinates\":[1,2]}"}),
4674 : ),
4675 : MERGE,
4676 : );
4677 2 : ok(
4678 2 : with(json!({"type": "Relationship", "object": "urn:a"})),
4679 : MERGE,
4680 : );
4681 2 : ok(with(json!({"type": "ListProperty", "valueList": n})), MERGE);
4682 2 : refused(with(json!({"type": "ListProperty", "valueList": 5})), MERGE);
4683 2 : ok(
4684 2 : with(json!({"type": "ListRelationship", "objectList": ["urn:a"]})),
4685 : MERGE,
4686 : );
4687 2 : ok(
4688 2 : json!({"id": "urn:x", "type": "T", "ngsildproof": {"type": "Property", "value": n}}),
4689 : MERGE,
4690 : );
4691 2 : refused(
4692 2 : json!({"id": "urn:x", "type": "T", "ngsildproof": {"type": "Property", "value": 5}}),
4693 : MERGE,
4694 : );
4695 2 : let point = run(with(json!({"type": "Point"})), d);
4696 2 : assert!(point.is_err(), "{point:?}");
4697 6 : for list in [
4698 2 : json!([5]),
4699 2 : json!([{"object": "urn:a", "x": 1}]),
4700 2 : json!([{"object": 5}]),
4701 6 : ] {
4702 6 : refused(
4703 6 : with(json!({"type": "ListRelationship", "objectList": list})),
4704 6 : d,
4705 6 : );
4706 6 : }
4707 2 : }
4708 :
4709 : #[test]
4710 2 : fn attribute_fragments() {
4711 2 : let ctx = std::sync::Arc::new(crate::core_context());
4712 10 : let frag = |v: Value| expand_attr_fragment(v.as_object().expect("obj"), &ctx);
4713 2 : assert!(frag(json!({"type": "Foo"})).is_err());
4714 2 : assert!(frag(json!({"value": null})).is_err());
4715 2 : assert!(frag(json!({"value": {"x": "urn:ngsi-ld:null"}})).is_err());
4716 2 : let out = frag(json!({"datasetId": "@none", "unitCode": "C",
4717 2 : "s": {"type": "Property", "value": 1}}))
4718 2 : .expect("fragment");
4719 2 : assert!(out.get("datasetId").is_none());
4720 2 : assert_eq!(out["unitCode"], "C");
4721 2 : assert_eq!(
4722 2 : out["https://uri.etsi.org/ngsi-ld/default-context/s"][0]["value"],
4723 : 1
4724 : );
4725 2 : assert!(frag(json!({"s": {"type": "Property", "value": 1},
4726 2 : "https://uri.etsi.org/ngsi-ld/default-context/s":
4727 2 : {"type": "Property", "value": 2}}))
4728 2 : .is_err());
4729 2 : }
4730 :
4731 : /// 5.5.7 + 4.5.3/4.5.22: a partial-update fragment's reserved members
4732 : /// obey the rules a full instance's do — a Relationship target is a
4733 : /// URI, an objectList holds the bare-URI internal form, vocab and
4734 : /// objectType are expanded — or one PATCH stores what a create refuses.
4735 : #[test]
4736 2 : fn a_fragment_holds_reserved_members_to_the_instance_rules() {
4737 2 : let ctx = std::sync::Arc::new(crate::core_context());
4738 52 : let frag = |v: Value| expand_attr_fragment(v.as_object().expect("obj"), &ctx);
4739 20 : for bad in [
4740 2 : json!({"object": "not a uri"}),
4741 2 : json!({"object": 5}),
4742 2 : json!({"unitCode": 7}),
4743 2 : json!({"lang": ["fr"]}),
4744 2 : json!({"objectList": [5]}),
4745 2 : json!({"objectList": "urn:a"}),
4746 2 : json!({"valueList": 5}),
4747 2 : json!({"vocab": 5}),
4748 2 : json!({"value": {"@value": 1, "x": 2}}),
4749 2 : json!({"value": {"a": {"@context": "https://e/c"}}}),
4750 2 : ] {
4751 20 : assert!(frag(bad.clone()).is_err(), "{bad}");
4752 : }
4753 2 : let out = frag(json!({"vocab": "non-commercial"})).expect("vocab");
4754 2 : assert_eq!(
4755 2 : out["vocab"],
4756 : "https://uri.etsi.org/ngsi-ld/default-context/non-commercial"
4757 : );
4758 2 : let out = frag(json!({"objectList": [{"object": "urn:a"}, "urn:b"]})).expect("list");
4759 2 : assert_eq!(out["objectList"], json!(["urn:a", "urn:b"]));
4760 2 : let out = frag(json!({"objectType": "Parking", "object": "urn:a"})).expect("rel");
4761 2 : assert_eq!(
4762 2 : out["objectType"],
4763 : "https://uri.etsi.org/ngsi-ld/default-context/Parking"
4764 : );
4765 : // the deletion forms still remove the member (10.2.5.4)
4766 4 : for null in [json!("urn:ngsi-ld:null"), Value::Null] {
4767 24 : for k in ["object", "unitCode", "lang", "vocab", "objectType", "json"] {
4768 24 : let one = Value::Object(Map::from_iter([(k.to_owned(), null.clone())]));
4769 24 : let out = frag(one).unwrap_or_else(|e| panic!("{k}: {e:?}"));
4770 24 : assert_eq!(out[k], null, "{k}");
4771 : }
4772 : }
4773 2 : let out = frag(json!({"objectList": ["urn:ngsi-ld:null"]})).expect("list deletion");
4774 2 : assert_eq!(out["objectList"], json!(["urn:ngsi-ld:null"]));
4775 2 : }
4776 :
4777 : #[test]
4778 2 : fn geometry_shapes() {
4779 2 : assert!(check_position(&json!([1, 2, "x"])).is_err());
4780 2 : assert!(check_ring(&json!([[0, 0], [1, 0], [0, 1], [1, 1]])).is_err());
4781 8 : for t in ["MultiPoint", "MultiLineString", "Polygon", "MultiPolygon"] {
4782 8 : assert!(check_geometry(t, &json!(5)).is_err(), "{t}");
4783 : }
4784 2 : assert!(check_geometry("MultiPolygon", &json!([5])).is_err());
4785 2 : assert!(check_geometry("Foo", &json!([0, 0])).is_err());
4786 2 : }
4787 :
4788 : #[test]
4789 2 : fn datetimes() {
4790 2 : assert!(!parse_datetime("2020Z"));
4791 2 : assert!(!parse_datetime("2020-01-0xT00:00:00Z"));
4792 2 : assert!(parse_datetime("2020-01-01T00:00:00Z"));
4793 2 : }
4794 : }
4795 :
4796 : #[cfg(test)]
4797 : mod null_member_tests {
4798 : use super::*;
4799 : use serde_json::json;
4800 :
4801 : const SPEED: &str = "https://uri.etsi.org/ngsi-ld/default-context/speed";
4802 :
4803 : /// 5.5.12, 10.2.5.4: what removes a member from the patched instance.
4804 : #[test]
4805 2 : fn removes_member_is_null_the_ngsi_null_or_only_deletion_instances() {
4806 2 : assert!(removes_member(&Value::Null));
4807 2 : assert!(removes_member(&json!("urn:ngsi-ld:null")));
4808 2 : assert!(removes_member(&json!([
4809 2 : {"type": "Relationship", "object": "urn:ngsi-ld:null"},
4810 2 : "urn:ngsi-ld:null"
4811 2 : ])));
4812 : // an empty list deletes nothing, one live instance keeps the member
4813 2 : assert!(!removes_member(&json!([])));
4814 2 : assert!(!removes_member(&json!([
4815 2 : {"type": "Property", "value": "urn:ngsi-ld:null"},
4816 2 : {"type": "Property", "value": 1}
4817 2 : ])));
4818 2 : assert!(!removes_member(&json!({"type": "Property", "value": 1})));
4819 2 : assert!(!removes_member(&json!("urn:ngsi-ld:Thing:1")));
4820 2 : }
4821 :
4822 : /// 10.2.5.4 / 10.2.9.4: observedAt and expiresAt set to the Null are kept as the deletion
4823 : /// marker in a partial update or merge input, and stay invalid DateTimes on create.
4824 : #[test]
4825 2 : fn observed_at_and_expires_at_null_only_in_null_allowing_inputs() {
4826 2 : let ctx = crate::core_context();
4827 2 : let doc = json!({"id": "urn:x", "type": "T",
4828 2 : "speed": {"type": "Property", "value": 1,
4829 2 : "observedAt": "urn:ngsi-ld:null", "expiresAt": "urn:ngsi-ld:null"}});
4830 2 : let doc = doc.as_object().unwrap();
4831 2 : assert!(
4832 2 : expand_entity(doc, &ctx, ExpandOpts::default()).is_err(),
4833 : "create refuses the Null"
4834 : );
4835 4 : for opts in [
4836 2 : ExpandOpts {
4837 2 : fragment: true,
4838 2 : allow_null: true,
4839 2 : ..Default::default()
4840 2 : },
4841 2 : ExpandOpts {
4842 2 : fragment: true,
4843 2 : merge: true,
4844 2 : ..Default::default()
4845 2 : },
4846 2 : ] {
4847 4 : let out = expand_entity(doc, &ctx, opts).expect("the Null is a deletion marker here");
4848 4 : assert_eq!(out[SPEED][0]["observedAt"], "urn:ngsi-ld:null", "{opts:?}");
4849 4 : assert_eq!(out[SPEED][0]["expiresAt"], "urn:ngsi-ld:null", "{opts:?}");
4850 : }
4851 : // a real DateTime passes through, anything else is still refused under allow_null
4852 2 : let opts = ExpandOpts {
4853 2 : fragment: true,
4854 2 : allow_null: true,
4855 2 : ..Default::default()
4856 2 : };
4857 2 : let ok = json!({"speed": {"type": "Property", "value": 1, "observedAt": "2026-10-15T00:00:00Z"}});
4858 2 : let out = expand_entity(ok.as_object().unwrap(), &ctx, opts).expect("DateTime accepted");
4859 2 : assert_eq!(out[SPEED][0]["observedAt"], "2026-10-15T00:00:00Z");
4860 4 : for k in ["observedAt", "expiresAt"] {
4861 4 : let bad = json!({"speed": {"type": "Property", "value": 1, k: "urn:ngsi-ld:Thing:1"}});
4862 4 : assert!(
4863 4 : expand_entity(bad.as_object().unwrap(), &ctx, opts).is_err(),
4864 : "{k}"
4865 : );
4866 : }
4867 2 : }
4868 :
4869 : /// 10.2.5.4 on the attribute path (5.6.4): the Null is kept for the patch to remove.
4870 : #[test]
4871 2 : fn attr_fragment_keeps_a_null_observed_at_and_expires_at() {
4872 2 : let ctx = crate::core_context();
4873 2 : let frag = json!({"type": "Property", "value": 1,
4874 2 : "observedAt": "urn:ngsi-ld:null", "expiresAt": "urn:ngsi-ld:null"});
4875 2 : let out = expand_attr_fragment(frag.as_object().unwrap(), &ctx).expect("Null accepted");
4876 2 : assert_eq!(out["observedAt"], "urn:ngsi-ld:null");
4877 2 : assert_eq!(out["expiresAt"], "urn:ngsi-ld:null");
4878 2 : let bad = json!({"type": "Property", "value": 1, "observedAt": "not a date"});
4879 2 : assert!(expand_attr_fragment(bad.as_object().unwrap(), &ctx).is_err());
4880 2 : }
4881 : }
|