Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! /subscriptions and /csourceSubscriptions (5.8, 5.11; resources 6.10/6.11,
3 : //! 6.12/6.13). One implementation, two store kinds — both use the
4 : //! Subscription data type (5.2.12).
5 :
6 : use crate::negotiate::*;
7 : use crate::notify::endpoint_uri;
8 : use crate::state::{now_iso, AppState};
9 : use antares_jsonld::{parse_datetime, Context};
10 : use antares_model::{NgsiError, TenantId};
11 : use antares_store::CurrentStateDriverExt;
12 : use antares_store::Kind;
13 : use axum::body::Bytes;
14 : use axum::extract::{Path, State};
15 : use axum::http::{HeaderMap, StatusCode};
16 : use axum::response::{IntoResponse, Response};
17 : use serde_json::{Map, Value};
18 : use std::collections::HashMap;
19 :
20 : use crate::negotiate::CleanParams;
21 :
22 1414 : fn resource_path(kind: Kind) -> &'static str {
23 1414 : match kind {
24 342 : Kind::CSourceSubscription => "csourceSubscriptions",
25 1072 : _ => "subscriptions",
26 : }
27 1414 : }
28 :
29 : /// Table 5.2.33-1 EntitySelector: the `entities` member of a Subscription
30 : /// (5.2.12). A selector needs a type; ids are URIs, an idPattern is a
31 : /// regular expression, and a type-selection expression (4.17) stays raw
32 : /// because it is evaluated at match time.
33 2332 : fn norm_entity_selectors(v: &Value, ctx: &Context) -> Result<Value, NgsiError> {
34 2332 : let bad = NgsiError::BadRequestData;
35 2332 : let arr = v
36 2332 : .as_array()
37 2332 : .filter(|a| !a.is_empty())
38 2332 : .ok_or_else(|| bad("entities must be a non-empty array".into()))?;
39 2324 : let mut entities = Vec::new();
40 2328 : for e in arr {
41 2328 : let eo = e
42 2328 : .as_object()
43 2328 : .ok_or_else(|| bad("entities entries must be objects".into()))?;
44 : // the core @context aliases id to @id and type to @type: both names
45 : // in one selector collide (JSON-LD 1.1 API expansion)
46 2324 : if let Some(k) = ["id", "type"]
47 2324 : .into_iter()
48 4644 : .find(|k| eo.contains_key(*k) && eo.contains_key(&format!("@{k}")))
49 : {
50 8 : return Err(bad(format!(
51 8 : "EntitySelector carries both {k} and @{k} (5.2.33)"
52 8 : )));
53 2316 : }
54 2316 : let mut ne = Map::new();
55 2404 : for (ek, ev) in eo {
56 2404 : match ek.as_str() {
57 2404 : "type" => {
58 2288 : let t = ev
59 2288 : .as_str()
60 2288 : .filter(|t| !t.is_empty())
61 2288 : .ok_or_else(|| bad("EntitySelector type is required".into()))?;
62 : // 4.17 type-selection expressions stay raw and
63 : // are evaluated at match time (046_16). Table
64 : // 5.2.33-1's "*" — "a request for all
65 : // Entities" — is not a term either: expanded,
66 : // it becomes an IRI no entity carries and the
67 : // subscription notifies nothing.
68 2284 : if t == "*" || t.contains(['|', ',', ';', '(']) {
69 20 : ne.insert("type".into(), ev.clone());
70 2264 : } else {
71 2264 : ne.insert("type".into(), Value::String(ctx.expand_key(t)));
72 2264 : }
73 : }
74 116 : "id" => {
75 : // Table 5.2.33-1: id is "String or String[]"
76 : // of valid URIs
77 100 : match ev {
78 84 : Value::String(id) => {
79 84 : antares_model::EntityId::new(id)?;
80 : }
81 16 : Value::Array(a) => {
82 32 : for i in a {
83 32 : let id = i.as_str().ok_or_else(|| {
84 8 : bad("EntitySelector id entries must be URIs (5.2.33)".into())
85 8 : })?;
86 24 : antares_model::EntityId::new(id)?;
87 : }
88 : }
89 : _ => {
90 0 : return Err(bad(
91 0 : "EntitySelector id must be a URI string or array (5.2.33)".into(),
92 0 : ))
93 : }
94 : }
95 88 : ne.insert("id".into(), ev.clone());
96 : }
97 16 : "idPattern" => {
98 16 : let p = ev
99 16 : .as_str()
100 16 : .ok_or_else(|| bad("idPattern must be a string".into()))?;
101 12 : antares_ql::regex::compile(p)
102 12 : .map_err(|_| bad(format!("invalid idPattern {p:?}")))?;
103 4 : ne.insert("idPattern".into(), ev.clone());
104 : }
105 0 : _ => {
106 0 : ne.insert(ek.clone(), ev.clone());
107 0 : }
108 : }
109 : }
110 2288 : if !ne.contains_key("type") {
111 4 : return Err(bad("EntitySelector requires type (5.2.33)".into()));
112 2284 : }
113 2284 : entities.push(Value::Object(ne));
114 : }
115 2280 : Ok(Value::Array(entities))
116 2332 : }
117 :
118 : /// Table 5.2.14.1-1 NotificationParams: the `notification` member of a
119 : /// Subscription (5.2.12). Output-only members are dropped, every other
120 : /// member is held to its value space, and the Endpoint is checked by
121 : /// `check_endpoint_params`.
122 2192 : fn norm_notification(v: &Value, ctx: &Context) -> Result<Value, NgsiError> {
123 2192 : let bad = NgsiError::BadRequestData;
124 2192 : let n = v
125 2192 : .as_object()
126 2192 : .ok_or_else(|| bad("notification must be an object (5.2.14)".into()))?;
127 2188 : let mut nn = n.clone();
128 : // 5.2.14.2: output-only members are read-only — provided
129 : // ones are ignored, never stored.
130 10940 : for k in [
131 2188 : "timesSent",
132 2188 : "timesFailed",
133 2188 : "lastNotification",
134 2188 : "lastSuccess",
135 2188 : "lastFailure",
136 10940 : ] {
137 10940 : nn.remove(k);
138 10940 : }
139 2188 : if let Some(f) = n.get("format").and_then(Value::as_str) {
140 600 : if !["normalized", "keyValues", "simplified", "concise"].contains(&f) {
141 4 : return Err(bad(format!("invalid notification format {f:?}")));
142 596 : }
143 1588 : }
144 : // Table 5.2.14.1-1 p.120: "showChanges cannot be true in case
145 : // format is keyValues" — "simplified" is the declared synonym
146 2184 : if n.get("showChanges").and_then(Value::as_bool) == Some(true)
147 16 : && matches!(
148 100 : n.get("format").and_then(Value::as_str),
149 24 : Some("keyValues") | Some("simplified")
150 : )
151 : {
152 16 : return Err(bad(
153 16 : "showChanges cannot be true when format is keyValues (5.2.14)".into(),
154 16 : ));
155 2168 : }
156 : // Table 5.2.14.1-1: join / joinLevel / sysAttrs /
157 : // showChanges value spaces.
158 2168 : if let Some(j) = n.get("join") {
159 92 : if !j
160 92 : .as_str()
161 92 : .is_some_and(|j| ["flat", "inline", "@none"].contains(&j))
162 : {
163 16 : return Err(bad(format!("invalid notification join {j:?} (5.2.14)")));
164 76 : }
165 2076 : }
166 : // Table 5.2.14.1-1: a positive integer. The depth it names is
167 : // the same Linked Entity traversal (4.5.23) a query drives, so
168 : // it carries the same ceiling — every notification of this
169 : // Subscription pays that traversal, and an unbounded level
170 : // makes one accepted Subscription an amplification lever.
171 2152 : if let Some(jl) = n.get("joinLevel") {
172 100 : let cap = crate::bounds::MAX_JOIN_LEVEL as u64;
173 100 : let ok = jl.as_u64().is_some_and(|v| (1..=cap).contains(&v));
174 100 : if !ok {
175 32 : return Err(bad(format!(
176 32 : "notification.joinLevel must be an integer in 1..={cap} (5.2.14)"
177 32 : )));
178 68 : }
179 2052 : }
180 4232 : for key in ["sysAttrs", "showChanges"] {
181 4232 : if n.get(key).is_some_and(|v| !v.is_boolean()) {
182 16 : return Err(bad(format!(
183 16 : "notification.{key} must be a boolean (5.2.14)"
184 16 : )));
185 4216 : }
186 : }
187 2104 : if let Some(attrs) = n.get("attributes").and_then(Value::as_array) {
188 : // Table 5.2.14.1-1 p.119: "Empty array (0 length) is not
189 : // allowed" — same restriction on pick and omit below
190 100 : if attrs.is_empty() {
191 8 : return Err(bad(
192 8 : "notification.attributes must not be empty (5.2.14)".into()
193 8 : ));
194 92 : }
195 92 : let mut na = Vec::new();
196 92 : for a in attrs {
197 92 : let s = a
198 92 : .as_str()
199 92 : .ok_or_else(|| bad("notification.attributes must be strings".into()))?;
200 : // "A synonym for pick, except that id, type, scope
201 : // are not allowed."
202 88 : if ["id", "type", "scope"].contains(&s) {
203 24 : return Err(bad(format!(
204 24 : "notification.attributes may not name {s:?} (5.2.14)"
205 24 : )));
206 64 : }
207 64 : na.push(Value::String(ctx.expand_key(s)));
208 : }
209 64 : nn.insert("attributes".into(), Value::Array(na));
210 2004 : }
211 4100 : for key in ["pick", "omit"] {
212 4100 : let Some(members) = n.get(key).and_then(Value::as_array) else {
213 3960 : continue;
214 : };
215 140 : if members.is_empty() {
216 16 : return Err(bad(format!(
217 16 : "notification.{key} must not be empty (5.2.14)"
218 16 : )));
219 124 : }
220 : // Table 5.2.14.1-1: each member is "a valid attribute
221 : // projection language string as per clause 4.21". The
222 : // notification path parses it again at delivery and drops
223 : // a member it cannot parse, so an unparseable `omit`
224 : // accepted here would deliver the Attribute the subscriber
225 : // asked to have removed.
226 174 : for m in members {
227 174 : let term = m
228 174 : .as_str()
229 174 : .ok_or_else(|| bad(format!("notification.{key} members are Strings (5.2.14)")))?;
230 166 : crate::repr::parse_projection(term, ctx).map_err(|_| {
231 48 : bad(format!(
232 48 : "notification.{key} member {term:?} is not an attribute \
233 48 : projection language string (4.21)"
234 48 : ))
235 48 : })?;
236 : }
237 : }
238 1996 : let ep = n
239 1996 : .get("endpoint")
240 1996 : .and_then(Value::as_object)
241 1996 : .ok_or_else(|| bad("notification.endpoint is required (5.2.14)".into()))?;
242 1980 : let uri = ep
243 1980 : .get("uri")
244 1980 : .and_then(Value::as_str)
245 1980 : .ok_or_else(|| bad("endpoint.uri is required (5.2.15)".into()))?;
246 1972 : antares_model::EntityId::new(uri)
247 1972 : .map_err(|_| bad(format!("endpoint.uri is not a valid URI: {uri:?}")))?;
248 :
249 3904 : let member_names = |key: &str| -> Vec<String> {
250 3904 : n.get(key)
251 3904 : .and_then(Value::as_array)
252 3904 : .map(|a| {
253 68 : a.iter()
254 68 : .filter_map(Value::as_str)
255 68 : .map(str::to_owned)
256 68 : .collect()
257 68 : })
258 3904 : .unwrap_or_default()
259 3904 : };
260 1952 : let pick = member_names("pick");
261 1952 : let omit = member_names("omit");
262 1952 : if !pick.is_empty() && n.contains_key("attributes") {
263 4 : return Err(bad("notification.pick and attributes are exclusive".into()));
264 1948 : }
265 1948 : if !omit.is_empty() && n.contains_key("attributes") {
266 4 : return Err(bad("notification.omit and attributes are exclusive".into()));
267 1944 : }
268 1944 : if pick.iter().any(|p| omit.contains(p)) {
269 8 : return Err(bad(
270 8 : "notification.pick and omit name the same entity member".into(),
271 8 : ));
272 1936 : }
273 1936 : check_endpoint_params(ep)?;
274 1732 : Ok(Value::Object(nn))
275 2192 : }
276 :
277 : /// Table 5.2.15-1 Endpoint: the members that decide how a notification is
278 : /// sent. `uri` is checked by the caller, which needs it first.
279 1936 : fn check_endpoint_params(ep: &Map<String, Value>) -> Result<(), NgsiError> {
280 1936 : let bad = NgsiError::BadRequestData;
281 : // Table 5.2.15-1: receiverInfo/notifierInfo are
282 : // KeyValuePair[] — per Table 5.2.22-1 both key and value
283 : // are Strings, cardinality 1.
284 3824 : for key in ["receiverInfo", "notifierInfo"] {
285 3824 : if let Some(arr) = ep.get(key) {
286 216 : let ok = arr.as_array().is_some_and(|a| {
287 222 : a.iter().all(|kv| {
288 222 : kv.get("key").is_some_and(Value::is_string)
289 202 : && kv.get("value").is_some_and(Value::is_string)
290 222 : })
291 208 : });
292 216 : if !ok {
293 72 : return Err(bad(format!(
294 72 : "endpoint.{key} entries must be {{key, value}} pairs (5.2.15/5.2.22)"
295 72 : )));
296 144 : }
297 3608 : }
298 : }
299 : // 6.3.8 and 6.3.9: each receiverInfo pair becomes one custom
300 : // header on the notification POST, and "'Key' and 'value'
301 : // members shall adhere to IETF RFC 7230 ... definitions
302 : // concerning HTTP headers". A pair that cannot be a header is
303 : // input the operation cannot meet (5.8.1.4), so it is refused
304 : // here rather than accepted into a Subscription that can only
305 : // ever dead-letter. notifierInfo is not headers — its own
306 : // binding validates it through the sink.
307 1864 : if let Some(arr) = ep.get("receiverInfo").and_then(Value::as_array) {
308 150 : for kv in arr {
309 150 : let (k, v) = (kv["key"].as_str(), kv["value"].as_str());
310 150 : if !k.is_some_and(is_field_name) || !v.is_some_and(is_field_value) {
311 76 : return Err(bad(format!(
312 76 : "endpoint.receiverInfo entry {kv} is not a valid HTTP header \
313 76 : (RFC 7230, 6.3.8)"
314 76 : )));
315 74 : }
316 : }
317 1728 : }
318 : // Table 5.2.15-1: cooldown and timeout are Numbers "Greater
319 : // than 0"
320 3560 : for key in ["cooldown", "timeout"] {
321 3560 : if let Some(v) = ep.get(key) {
322 84 : v.as_f64().filter(|n| *n > 0.0).ok_or_else(|| {
323 32 : bad(format!(
324 32 : "endpoint.{key} must be a number greater than 0 (5.2.15)"
325 32 : ))
326 32 : })?;
327 3476 : }
328 : }
329 1756 : if let Some(acc) = ep.get("accept").and_then(Value::as_str) {
330 684 : if ![
331 684 : "application/json",
332 684 : "application/ld+json",
333 684 : "application/geo+json",
334 684 : ]
335 684 : .contains(&acc)
336 : {
337 8 : return Err(bad(format!("invalid endpoint accept {acc:?}")));
338 676 : }
339 : // TS 104 243 5.2, Table 5.2-2: an MQTT message's metadata
340 : // Content-Type is "application/json" or "application/ld+json"; the
341 : // binding has no GeoJSON. Refused here, at create and at update,
342 : // rather than published under a Content-Type the table forbids.
343 676 : let scheme = ep
344 676 : .get("uri")
345 676 : .and_then(Value::as_str)
346 676 : .and_then(|uri| uri.split_once("://"))
347 676 : .map(|(scheme, _)| scheme.to_ascii_lowercase());
348 676 : if acc == "application/geo+json" && matches!(scheme.as_deref(), Some("mqtt" | "mqtts")) {
349 16 : return Err(bad(format!(
350 16 : "endpoint.accept {acc:?} is not available for an {} endpoint: the MQTT \
351 16 : binding sends \"application/json\" or \"application/ld+json\" \
352 16 : (TS 104 243 Table 5.2-2)",
353 16 : scheme.as_deref().unwrap_or_default()
354 16 : )));
355 660 : }
356 1072 : }
357 1732 : Ok(())
358 1936 : }
359 :
360 : /// Table 5.2.12-1: "Valid notification triggers are entityCreated,
361 : /// entityUpdated, entityDeleted, attributeCreated, attributeUpdated,
362 : /// attributeDeleted." A trigger outside that set is accepted and then
363 : /// matches nothing, leaving a subscription that never fires.
364 194 : fn check_notification_triggers(v: &Value) -> Result<(), NgsiError> {
365 194 : let bad = NgsiError::BadRequestData;
366 : const TRIGGERS: [&str; 6] = [
367 : "entityCreated",
368 : "entityUpdated",
369 : "entityDeleted",
370 : "attributeCreated",
371 : "attributeUpdated",
372 : "attributeDeleted",
373 : ];
374 194 : let list = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| {
375 8 : bad("notificationTrigger must be a non-empty array of strings (5.2.12)".into())
376 8 : })?;
377 222 : for t in list {
378 222 : let t = t
379 222 : .as_str()
380 222 : .ok_or_else(|| bad("notificationTrigger entries must be strings (5.2.12)".into()))?;
381 218 : if !TRIGGERS.contains(&t) {
382 12 : return Err(bad(format!(
383 12 : "{t} is not a valid notification trigger (5.2.12)"
384 12 : )));
385 206 : }
386 : }
387 170 : Ok(())
388 194 : }
389 :
390 : /// One member of Table 5.2.12-1, validated and normalized into `out`.
391 : /// A member the table does not name is kept verbatim: 5.5.9 asks a
392 : /// receiver to tolerate what it does not know.
393 9436 : fn norm_member(
394 9436 : k: &str,
395 9436 : v: &Value,
396 9436 : ctx: &Context,
397 9436 : out: &mut Map<String, Value>,
398 9436 : ) -> Result<(), NgsiError> {
399 9436 : let bad = NgsiError::BadRequestData;
400 122 : match k {
401 9436 : "@context" | "createdAt" | "modifiedAt" | "status" => return Ok(()),
402 8604 : "id" => {
403 1276 : let id = v
404 1276 : .as_str()
405 1276 : .ok_or_else(|| bad("subscription id must be a string URI".into()))?;
406 1276 : antares_model::EntityId::new(id)?;
407 1268 : out.insert("id".into(), v.clone());
408 : }
409 7328 : "type" => {
410 1598 : if v.as_str() != Some("Subscription") {
411 4 : return Err(bad("type must be \"Subscription\" (5.2.12)".into()));
412 1594 : }
413 1594 : out.insert("type".into(), v.clone());
414 : }
415 5730 : "entities" => {
416 2332 : out.insert("entities".into(), norm_entity_selectors(v, ctx)?);
417 : }
418 3398 : "watchedAttributes" => {
419 168 : let arr = v
420 168 : .as_array()
421 168 : .filter(|a| !a.is_empty())
422 168 : .ok_or_else(|| bad("watchedAttributes must be a non-empty array".into()))?;
423 156 : let mut attrs = Vec::new();
424 204 : for a in arr {
425 204 : let s = a
426 204 : .as_str()
427 204 : .filter(|s| !s.is_empty())
428 204 : .ok_or_else(|| bad("watchedAttributes entries must be strings".into()))?;
429 196 : attrs.push(Value::String(ctx.expand_key(s)));
430 : }
431 148 : out.insert("watchedAttributes".into(), Value::Array(attrs));
432 : }
433 3230 : "q" => {
434 60 : let q = v.as_str().ok_or_else(|| bad("q must be a string".into()))?;
435 : // Validate the string the MATCHER will parse: a JSON string,
436 : // read as written (5.2.12, 046_05), never percent-decoded.
437 56 : antares_ql::parse_q(q)?;
438 44 : out.insert("q".into(), v.clone());
439 : }
440 3170 : "geoQ" => {
441 72 : let g = v
442 72 : .as_object()
443 72 : .ok_or_else(|| bad("geoQ must be an object".into()))?;
444 68 : antares_ql::geo::GeoQuery::from_params(&antares_matcher::geo_params(g))?
445 48 : .ok_or_else(|| bad("geoQ requires georel (5.2.13)".into()))?;
446 48 : let mut ng = g.clone();
447 48 : if let Some(gp) = g.get("geoproperty").and_then(Value::as_str) {
448 36 : ng.insert("geoproperty".into(), Value::String(ctx.expand_key(gp)));
449 36 : }
450 48 : out.insert("geoQ".into(), Value::Object(ng));
451 : }
452 3098 : "notification" => {
453 2192 : out.insert("notification".into(), norm_notification(v, ctx)?);
454 : }
455 906 : "expiresAt" => {
456 80 : let s = v
457 80 : .as_str()
458 80 : .filter(|s| parse_datetime(s))
459 80 : .ok_or_else(|| bad("expiresAt must be an ISO 8601 DateTime".into()))?;
460 : // 4.6.3 admits several spellings of one instant, so whether a
461 : // DateTime has passed cannot be read off the raw strings.
462 60 : if antares_model::dt_key(s) < antares_model::dt_key(&now_iso()) {
463 28 : return Err(bad("expiresAt is in the past (5.8.1)".into()));
464 32 : }
465 32 : out.insert("expiresAt".into(), v.clone());
466 : }
467 826 : "throttling" => {
468 38 : v.as_f64()
469 38 : .filter(|n| *n > 0.0)
470 38 : .ok_or_else(|| bad("throttling must be a positive number".into()))?;
471 18 : out.insert("throttling".into(), v.clone());
472 : }
473 788 : "timeInterval" => {
474 122 : v.as_f64()
475 122 : .filter(|n| *n > 0.0)
476 122 : .ok_or_else(|| bad("timeInterval must be a positive number".into()))?;
477 102 : out.insert("timeInterval".into(), v.clone());
478 : }
479 : // Table 5.2.12-1: all three are Booleans. `localOnly` and
480 : // `splitEntities` decide how far the Subscription reaches — a
481 : // string read through `as_bool()` is falsy, so accepting one
482 : // would turn a subscriber's request for local scope (5.5.13)
483 : // into a distributed subscription without telling it.
484 666 : "isActive" | "localOnly" | "splitEntities" => {
485 224 : if !v.is_boolean() {
486 32 : return Err(bad(format!("{k} must be a boolean (5.2.12)")));
487 192 : }
488 192 : out.insert(k.to_owned(), v.clone());
489 : }
490 442 : "temporalQ" => {
491 : // 5.2.21 TemporalQuery: timerel and timeAt are cardinality 1
492 : // and every member must sit in its Table 5.2.21-1 value
493 : // space (used by CSR subscriptions, 5.11.7).
494 52 : let tq = v
495 52 : .as_object()
496 52 : .ok_or_else(|| bad("temporalQ must be a TemporalQuery object (5.2.21)".into()))?;
497 44 : let mut p = std::collections::HashMap::new();
498 44 : crate::paging::temporal_q_params(tq, &mut p)?;
499 44 : crate::temporalq::TemporalQ::from_params(&p, true)?;
500 20 : out.insert(k.to_owned(), v.clone());
501 : }
502 : // Table 5.2.12-1: "Valid notification triggers are entityCreated,
503 : // entityUpdated, entityDeleted, attributeCreated, attributeUpdated,
504 : // attributeDeleted." A trigger outside that set is accepted and
505 : // then matches nothing, leaving a subscription that never fires.
506 390 : "notificationTrigger" => {
507 194 : check_notification_triggers(v)?;
508 170 : out.insert(k.to_owned(), v.clone());
509 : }
510 : // Table 5.2.12-1: csf is "A valid query string as per clause 4.9".
511 : // Unparsed here it is stored and only fails at Context Source
512 : // Registration matching (5.11.2.4), where it silently matches
513 : // nothing instead of telling the subscriber the filter is broken.
514 196 : "csf" => {
515 32 : let s = v
516 32 : .as_str()
517 32 : .ok_or_else(|| bad("csf must be a query string (5.2.12)".into()))?;
518 24 : antares_ql::parse_q(s)?;
519 12 : out.insert(k.to_owned(), v.clone());
520 : }
521 : // Table 5.2.12-1: both are a String, "comma separated list of
522 : // attribute names", and the pair decides how the notification
523 : // condition compares a value (4.9). A non-string stored here reads
524 : // as an absent list at matching time, so the Subscription would
525 : // silently compare the wrong thing rather than say the member is
526 : // malformed.
527 164 : "expandValues" | "jsonKeys" => {
528 42 : if !v.is_string() {
529 32 : return Err(bad(format!(
530 32 : "{k} must be a comma separated list of attribute names (5.2.12)"
531 32 : )));
532 10 : }
533 10 : out.insert(k.to_owned(), v.clone());
534 : }
535 : // 5.8.6: the @context governing a subscription's notifications is
536 : // the @context of the creating request, held in a broker-internal
537 : // member. This function only ever sees client input — a create
538 : // body or a patch fragment — so dropping the member here stops a
539 : // subscriber both from seeding it and from replacing it later.
540 : // __via is the same class: the 6.3.18 chain comes from the Via
541 : // HTTP header of the creating request, never from the body, and so
542 : // is __subject. The prefix as a whole is the broker's, so a member
543 : // added later cannot be forgotten here.
544 122 : k if k.starts_with("__") => return Ok(()),
545 98 : "scopeQ" => {
546 22 : let q = v
547 22 : .as_str()
548 22 : .ok_or_else(|| bad("scopeQ must be a string (5.2.12)".into()))?;
549 20 : antares_ql::scope::check_scope_q(q)?;
550 8 : out.insert(k.to_owned(), v.clone());
551 : }
552 76 : "ngsildConformance" => {
553 : // Table 5.2.12-1: "a semantically versioned string in the form major.minor"
554 20 : let ok = v
555 20 : .as_str()
556 20 : .and_then(|s| s.split_once('.'))
557 20 : .is_some_and(|(a, b)| {
558 8 : [a, b]
559 8 : .iter()
560 20 : .all(|p| !p.is_empty() && p.bytes().all(|c| c.is_ascii_digit()))
561 8 : });
562 20 : if !ok {
563 16 : return Err(bad("ngsildConformance must be major.minor (5.2.12)".into()));
564 4 : }
565 4 : out.insert(k.to_owned(), v.clone());
566 : }
567 56 : "lang" | "subscriptionName" | "name" | "description" | "jsonldContext" | "datasetId" => {
568 32 : out.insert(k.to_owned(), v.clone());
569 32 : }
570 : // tolerant reader: keep unknown members
571 24 : _ => {
572 24 : out.insert(k.to_owned(), v.clone());
573 24 : }
574 : }
575 7738 : Ok(())
576 9436 : }
577 :
578 : /// The Table 5.2.12-1 rules that hold between members rather than over
579 : /// one: what a Subscription must carry, and the pairs it may not.
580 1664 : fn check_subscription_members(out: &Map<String, Value>, is_patch: bool) -> Result<(), NgsiError> {
581 1664 : let bad = NgsiError::BadRequestData;
582 1664 : if !is_patch {
583 1510 : if !out.contains_key("type") {
584 0 : return Err(bad("type must be \"Subscription\" (5.2.12)".into()));
585 1510 : }
586 : // 5.2.12: "At least one of (a) entities or (b) watchedAttributes
587 : // shall be present, unless the member localOnly is set to true"
588 : // (local scope, 5.5.13).
589 1510 : let local_only = out.get("localOnly").and_then(Value::as_bool) == Some(true);
590 1510 : if !local_only && !out.contains_key("entities") && !out.contains_key("watchedAttributes") {
591 12 : return Err(bad(
592 12 : "one of entities or watchedAttributes is required (5.2.12)".into(),
593 12 : ));
594 1498 : }
595 1498 : if !out.contains_key("notification") {
596 8 : return Err(bad("notification is required (5.2.12)".into()));
597 1490 : }
598 154 : }
599 1644 : check_exclusive_pairs(out)
600 1664 : }
601 :
602 : /// The Table 5.2.12-1 member pairs a Subscription may not carry together,
603 : /// checked on a fragment and again on the Subscription it updates.
604 1726 : fn check_exclusive_pairs(out: &Map<String, Value>) -> Result<(), NgsiError> {
605 1726 : let bad = NgsiError::BadRequestData;
606 1726 : if out.contains_key("timeInterval") && out.contains_key("watchedAttributes") {
607 10 : return Err(bad(
608 10 : "timeInterval and watchedAttributes are mutually exclusive (5.2.12)".into(),
609 10 : ));
610 1716 : }
611 1716 : if out.contains_key("timeInterval") && out.contains_key("throttling") {
612 4 : return Err(bad(
613 4 : "timeInterval and throttling are mutually exclusive (5.2.12)".into(),
614 4 : ));
615 1712 : }
616 1712 : Ok(())
617 1726 : }
618 :
619 : /// Validate + normalize a subscription document (5.8.1). Types/attribute
620 : /// names are expanded to IRIs; the rest is stored verbatim.
621 2522 : pub fn normalize_subscription(
622 2522 : doc: &Map<String, Value>,
623 2522 : ctx: &Context,
624 2522 : is_patch: bool,
625 2522 : ) -> Result<Map<String, Value>, NgsiError> {
626 2522 : let bad = NgsiError::BadRequestData;
627 : // 5.5.4: first-level member nulls are only legal in fragments (patch)
628 2522 : if !is_patch {
629 2318 : antares_jsonld::reject_first_level_nulls(doc)?;
630 204 : }
631 2518 : let mut out = Map::new();
632 9458 : for (k, v) in doc {
633 : // 5.4 Fragment member removal: null and the NGSI-LD Null both delete
634 : // the member. Read literally, the NGSI-LD Null was stored as the
635 : // string it is spelled with, so the member survived carrying it.
636 9458 : if is_patch && k != "id" && (v.is_null() || v.as_str() == Some("urn:ngsi-ld:null")) {
637 22 : if ["type", "notification"].contains(&k.as_str()) {
638 12 : return Err(bad(format!("cannot remove mandatory member {k} (5.8.3)")));
639 10 : }
640 10 : out.insert(k.clone(), Value::Null);
641 10 : continue;
642 9436 : }
643 9436 : norm_member(k, v, ctx, &mut out)?;
644 : }
645 1664 : check_subscription_members(&out, is_patch)?;
646 1632 : Ok(out)
647 2522 : }
648 :
649 : /// Output shaping: compact IRIs, add status (5.8.3).
650 452 : pub fn present_subscription(doc: &Value, ctx: &Context, sys_attrs: bool, csource: bool) -> Value {
651 452 : let Some(obj) = doc.as_object() else {
652 0 : return doc.clone();
653 : };
654 452 : let mut out = Map::new();
655 3680 : for (k, v) in obj {
656 3680 : match k.as_str() {
657 : // 5.8.3/5.8.4 serve the 5.2.12 data type, which has no member
658 : // under this prefix — see policy::SUBJECT_MEMBER
659 3680 : k if k.starts_with("__") => continue,
660 3210 : "createdAt" | "modifiedAt" if !sys_attrs => continue,
661 2386 : "entities" => {
662 452 : let entities: Vec<Value> = v
663 452 : .as_array()
664 452 : .cloned()
665 452 : .unwrap_or_default()
666 452 : .iter()
667 452 : .map(|e| {
668 452 : let mut ne = e.as_object().cloned().unwrap_or_default();
669 452 : if let Some(t) = ne.get("type").and_then(Value::as_str) {
670 452 : ne.insert("type".into(), Value::String(ctx.compact_iri(t)));
671 452 : }
672 452 : Value::Object(ne)
673 452 : })
674 452 : .collect();
675 452 : out.insert("entities".into(), Value::Array(entities));
676 : }
677 1934 : "watchedAttributes" => {
678 54 : let attrs: Vec<Value> = v
679 54 : .as_array()
680 54 : .cloned()
681 54 : .unwrap_or_default()
682 54 : .iter()
683 54 : .filter_map(Value::as_str)
684 82 : .map(|a| Value::String(ctx.compact_iri(a)))
685 54 : .collect();
686 54 : out.insert("watchedAttributes".into(), Value::Array(attrs));
687 : }
688 1880 : "notification" => {
689 452 : let mut n = v.as_object().cloned().unwrap_or_default();
690 452 : if let Some(attrs) = n.get("attributes").and_then(Value::as_array) {
691 68 : let na: Vec<Value> = attrs
692 68 : .iter()
693 68 : .filter_map(Value::as_str)
694 68 : .map(|a| Value::String(ctx.compact_iri(a)))
695 68 : .collect();
696 68 : n.insert("attributes".into(), Value::Array(na));
697 384 : }
698 452 : mask_receiver_info(&mut n);
699 452 : out.insert("notification".into(), Value::Object(n));
700 : }
701 1428 : "geoQ" => {
702 39 : let mut g = v.as_object().cloned().unwrap_or_default();
703 39 : if let Some(gp) = g.get("geoproperty").and_then(Value::as_str) {
704 39 : g.insert("geoproperty".into(), Value::String(ctx.compact_iri(gp)));
705 39 : }
706 39 : out.insert("geoQ".into(), Value::Object(g));
707 : }
708 1417 : _ => {
709 1417 : out.insert(k.clone(), v.clone());
710 1417 : }
711 : }
712 : }
713 : // default notificationTrigger surfaced on output (5.2.12; 028_06) —
714 : // entity subscriptions only, csource subs have no such default (5.11)
715 452 : if !csource && !out.contains_key("notificationTrigger") && !out.contains_key("timeInterval") {
716 284 : out.insert(
717 284 : "notificationTrigger".into(),
718 284 : serde_json::json!(["attributeCreated", "attributeUpdated"]),
719 284 : );
720 284 : }
721 : // status (Table 5.2.12-2): active | paused | expired; a failed delivery
722 : // is notification.status (5.8.6). Only a CSR subscription itself turns
723 : // "failed" (5.11.7: "Update the subscription status to "failed"").
724 452 : let expired = obj
725 452 : .get("expiresAt")
726 452 : .and_then(Value::as_str)
727 452 : .is_some_and(|e| antares_model::dt_key(e) < antares_model::dt_key(&now_iso()));
728 452 : let paused = obj.get("isActive") == Some(&Value::Bool(false));
729 452 : let status = if expired {
730 12 : "expired"
731 440 : } else if paused {
732 25 : "paused"
733 415 : } else if csource && obj.get("status").and_then(Value::as_str) == Some("failed") {
734 4 : "failed"
735 : } else {
736 411 : "active"
737 : };
738 452 : out.insert("status".into(), Value::String(status.into()));
739 452 : Value::Object(out)
740 452 : }
741 :
742 : /// What a read answers in place of each `endpoint.receiverInfo` value.
743 : pub(crate) const MASKED: &str = "***";
744 :
745 : /// `receiverInfo` carries the credentials a notification is sent with (6.3.8: each pair is a
746 : /// header, the Authorization header the example). No read answers them: the keys stay, the
747 : /// values are [`MASKED`].
748 736 : pub(crate) fn mask_receiver_info(notification: &mut Map<String, Value>) {
749 : // 7.2 userinfo in the endpoint URI is a credential too.
750 736 : if let Some(uri) = notification
751 736 : .get_mut("endpoint")
752 736 : .and_then(|e| e.get_mut("uri"))
753 : {
754 486 : if let Some(masked) = uri.as_str().and_then(masked_uri) {
755 24 : *uri = Value::String(masked);
756 462 : }
757 250 : }
758 736 : let pairs = notification
759 736 : .get_mut("endpoint")
760 736 : .and_then(|e| e.get_mut("receiverInfo"))
761 736 : .and_then(Value::as_array_mut);
762 736 : for pair in pairs.into_iter().flatten() {
763 32 : if let Some(v) = pair.get_mut("value") {
764 32 : *v = Value::String(MASKED.into());
765 32 : }
766 : }
767 736 : }
768 :
769 : /// The endpoint URI with its userinfo replaced by [`MASKED`]; `None` when it
770 : /// carries none.
771 572 : fn masked_uri(uri: &str) -> Option<String> {
772 572 : let bare = antares_notifier::redact_userinfo(uri);
773 572 : if bare == uri {
774 514 : return None;
775 58 : }
776 58 : let (scheme, host) = bare.split_once("//")?;
777 58 : Some(format!("{scheme}//{MASKED}@{host}"))
778 572 : }
779 :
780 : /// A client that reads a Subscription and writes its `notification` back sends the
781 : /// [`MASKED`] values it was answered. Such a value keeps the credential stored under the same
782 : /// key instead of overwriting it; a key the stored endpoint lacks keeps the literal value.
783 : /// The stored credential stays with the stored URI: a masked value under a stored key beside
784 : /// another URI is refused, or an update-right holder could redirect the credential.
785 136 : pub(crate) fn keep_masked_receiver_info(
786 136 : fragment: &mut Map<String, Value>,
787 136 : stored: &Map<String, Value>,
788 136 : ) -> Result<(), NgsiError> {
789 : // The masked URI it was answered keeps the stored one, credentials and all.
790 136 : let stored_uri = stored
791 136 : .get("notification")
792 136 : .and_then(|n| n.get("endpoint"))
793 136 : .and_then(|e| e.get("uri"))
794 136 : .and_then(Value::as_str);
795 74 : if let (Some(stored_uri), Some(uri)) = (
796 136 : stored_uri,
797 136 : fragment
798 136 : .get_mut("notification")
799 136 : .and_then(|n| n.get_mut("endpoint"))
800 136 : .and_then(|e| e.get_mut("uri")),
801 : ) {
802 74 : match (masked_uri(stored_uri), uri.as_str()) {
803 22 : (Some(masked), Some(sent)) if masked == sent => {
804 10 : *uri = Value::String(stored_uri.to_owned());
805 10 : }
806 : // The masked userinfo it was answered, beside another URI: the
807 : // stored credential cannot follow it there (as a masked
808 : // receiverInfo value below cannot), and stored as it came it
809 : // was sent as a user literally named `***`.
810 12 : (Some(_), Some(sent)) if masked_uri(sent).is_some_and(|m| m == sent) => {
811 8 : return Err(NgsiError::BadRequestData(
812 8 : "endpoint.uri: a masked userinfo cannot move to another uri: send the credential again".into(),
813 8 : ));
814 : }
815 56 : _ => {}
816 : }
817 62 : }
818 128 : let held = stored
819 128 : .get("notification")
820 128 : .and_then(|n| n.get("endpoint"))
821 128 : .and_then(|e| e.get("receiverInfo"))
822 128 : .and_then(Value::as_array);
823 128 : let Some(held) = held else {
824 102 : return Ok(());
825 : };
826 26 : let endpoint = fragment
827 26 : .get_mut("notification")
828 26 : .and_then(|n| n.get_mut("endpoint"));
829 26 : let moved = endpoint
830 26 : .as_ref()
831 26 : .and_then(|e| e.get("uri"))
832 26 : .is_some_and(|uri| uri.as_str() != stored_uri);
833 26 : let pairs = endpoint
834 26 : .and_then(|e| e.get_mut("receiverInfo"))
835 26 : .and_then(Value::as_array_mut);
836 26 : for pair in pairs.into_iter().flatten() {
837 26 : if pair.get("value").and_then(Value::as_str) != Some(MASKED) {
838 6 : continue;
839 20 : }
840 20 : let key = pair.get("key").cloned();
841 20 : let previous = held
842 20 : .iter()
843 20 : .find(|h| h.get("key") == key.as_ref())
844 20 : .and_then(|h| h.get("value"))
845 20 : .cloned();
846 20 : if let (Some(previous), Some(v)) = (previous, pair.get_mut("value")) {
847 16 : if moved {
848 8 : return Err(NgsiError::BadRequestData(
849 8 : "receiverInfo: a masked value cannot move to another uri: send the credential again".into(),
850 8 : ));
851 8 : }
852 8 : *v = previous;
853 4 : }
854 : }
855 18 : Ok(())
856 136 : }
857 :
858 : // ---------- handlers (parameterized by Kind) ----------
859 :
860 : /// Validate a subscription's jsonldContext member (5.2.12): must be a
861 : /// dereferenceable @context — invalid value ⇒ 400, unresolvable ⇒ 504.
862 : /// 5.8.1.4 and 5.8.2.4: the notification endpoint has to be one this
863 : /// deployment can deliver to. The sink registered for the URI's scheme
864 : /// (6.3.8, and 7.2 for the optional MQTT binding) validates the endpoint's
865 : /// own syntax and its `notifierInfo`; a scheme no sink serves is input data
866 : /// that does not meet the requirements of the operation — BadRequestData,
867 : /// never a fall-through to the HTTP binding. A fragment that carries no
868 : /// endpoint leaves the stored one in place and has nothing to check.
869 1342 : fn check_endpoint(st: &AppState, norm: &Map<String, Value>) -> Result<(), NgsiError> {
870 1342 : let Some(ep) = norm
871 1342 : .get("notification")
872 1342 : .and_then(|n| n.get("endpoint"))
873 1342 : .and_then(Value::as_object)
874 : else {
875 78 : return Ok(());
876 : };
877 1264 : let Some(uri) = ep.get("uri").and_then(Value::as_str) else {
878 0 : return Ok(());
879 : };
880 1264 : let notifier_info = ep
881 1264 : .get("notifierInfo")
882 1264 : .and_then(Value::as_array)
883 1264 : .map(|ni| {
884 4 : ni.iter()
885 4 : .filter_map(|kv| Some((kv.get("key")?.as_str()?, kv.get("value")?.as_str()?)))
886 4 : .collect::<Vec<_>>()
887 4 : })
888 1264 : .unwrap_or_default();
889 1264 : st.sinks.require(uri, ¬ifier_info)
890 1342 : }
891 :
892 1334 : async fn check_jsonld_context(
893 1334 : st: &AppState,
894 1334 : tenant: &TenantId,
895 1334 : norm: &Map<String, Value>,
896 1334 : ) -> Result<(), ApiError> {
897 1334 : let Some(v) = norm.get("jsonldContext") else {
898 1312 : return Ok(());
899 : };
900 22 : let is_url = |s: &str| s.starts_with("http://") || s.starts_with("https://");
901 22 : let ok_shape = match v {
902 22 : Value::String(s) => is_url(s),
903 0 : Value::Array(a) => a.iter().all(|e| e.as_str().is_some_and(is_url)),
904 0 : _ => false,
905 : };
906 22 : if !ok_shape {
907 4 : return Err(NgsiError::BadRequestData(format!(
908 4 : "jsonldContext is not a valid @context reference: {v}"
909 4 : ))
910 4 : .into());
911 18 : }
912 18 : st.loader.resolve_for(tenant, v).await?;
913 12 : Ok(())
914 1334 : }
915 :
916 : /// One function serves both subscription resources, and they are different
917 : /// clauses: 5.8 for Subscriptions, 5.11 for Context Source Registration
918 : /// Subscriptions. The seam is asked about the operation the caller actually
919 : /// made.
920 : /// What a Subscription names, for the policy gate (ADR-0020): the types of
921 : /// its entity selectors, its watchedAttributes, its q and its geoQ, as
922 : /// normalization expanded them.
923 78 : fn sub_members(norm: &Map<String, Value>) -> crate::entities::ReadMembers {
924 156 : let strings = |key: &str, member: Option<&str>| -> Vec<String> {
925 156 : let mut out: Vec<String> = Vec::new();
926 156 : for v in norm
927 156 : .get(key)
928 156 : .and_then(Value::as_array)
929 156 : .into_iter()
930 156 : .flatten()
931 : {
932 82 : let s = match member {
933 78 : Some(m) => v.get(m).and_then(Value::as_str),
934 4 : None => v.as_str(),
935 : };
936 82 : if let Some(s) = s.filter(|s| !out.iter().any(|o| o == s)) {
937 82 : out.push(s.to_owned());
938 82 : }
939 : }
940 156 : out
941 156 : };
942 : crate::entities::ReadMembers {
943 78 : types: strings("entities", Some("type")),
944 78 : attrs: strings("watchedAttributes", None),
945 78 : q: norm
946 78 : .get("q")
947 78 : .and_then(Value::as_str)
948 78 : .and_then(|q| antares_ql::parse_q(q).ok()),
949 78 : geo: norm
950 78 : .get("geoQ")
951 78 : .and_then(Value::as_object)
952 78 : .and_then(|g| {
953 4 : antares_ql::geo::GeoQuery::from_params(&crate::notify::geo_params(g)).ok()
954 4 : })
955 78 : .flatten(),
956 : }
957 78 : }
958 :
959 1432 : fn clause_of(kind: Kind, sub: &'static str, csub: &'static str) -> &'static str {
960 1432 : if kind == Kind::CSourceSubscription {
961 390 : csub
962 : } else {
963 1042 : sub
964 : }
965 1432 : }
966 :
967 : /// Query Entities: the clause a socket's own Subscription is decided as.
968 : const WATCH: &str = "5.7.2";
969 :
970 : /// whether `uri` is the endpoint of the
971 : /// connection making this very call. The sink is the test, as it is for
972 : /// the endpoint itself: it accepts a socket URI only inside that
973 : /// connection's call, so HTTP and any other connection answer `false`.
974 1940 : fn own_socket(st: &AppState, uri: Option<&str>) -> bool {
975 1940 : uri.is_some_and(|u| u.starts_with(SOCKET_ENDPOINT) && st.sinks.require(u, &[]).is_ok())
976 1940 : }
977 :
978 : /// The clause an operation on the stored Subscription `id` is decided as:
979 : /// [`WATCH`] when its endpoint is the calling socket, `sub` otherwise.
980 1302 : async fn clause_on(
981 1302 : st: &AppState,
982 1302 : tenant: &TenantId,
983 1302 : kind: Kind,
984 1302 : id: &str,
985 1302 : sub: &'static str,
986 1302 : csub: &'static str,
987 1302 : ) -> Result<&'static str, ApiError> {
988 1302 : if kind == Kind::Subscription {
989 952 : let doc = st.store.get(tenant, kind, id).await?;
990 952 : if own_socket(
991 952 : st,
992 952 : doc.as_ref()
993 952 : .and_then(Value::as_object)
994 952 : .and_then(endpoint_uri),
995 : ) {
996 0 : return Ok(WATCH);
997 952 : }
998 350 : }
999 1302 : Ok(clause_of(kind, sub, csub))
1000 1302 : }
1001 :
1002 1494 : pub async fn create(
1003 1494 : st: &AppState,
1004 1494 : kind: Kind,
1005 1494 : params: &HashMap<String, String>,
1006 1494 : headers: &HeaderMap,
1007 1494 : body: &[u8],
1008 1494 : ) -> ApiResult<Response> {
1009 1494 : let tenant = tenant_from(headers)?;
1010 1494 : check_params(params, &["local"])?;
1011 1490 : let parsed = parse_body(st, headers, body, BodyKind::Standard).await?;
1012 1466 : let obj = parsed.object(NgsiError::BadRequestData(
1013 1466 : "subscription must be a JSON object".into(),
1014 1466 : ))?;
1015 : // ADR-0020: the client may choose the id (5.8.1.4 / 5.11.2.4), and it is
1016 : // in hand here — an engine that owns a segment of the id space has to be
1017 : // told which one is being claimed. A body that names none leaves the
1018 : // list empty, which is right: there is no client choice to decide about.
1019 1466 : let named = obj.get("id").and_then(Value::as_str);
1020 1466 : let mut norm = normalize_subscription(obj, &parsed.ctx, false)?;
1021 1230 : let shown = match &st.policy {
1022 72 : Some(_) => sub_members(&norm),
1023 1158 : None => crate::entities::ReadMembers::default(),
1024 : };
1025 1230 : let watch = kind == Kind::Subscription && own_socket(st, endpoint_uri(&norm));
1026 1230 : gate!(
1027 : st, &tenant, headers, if watch { WATCH } else { clause_of(kind, "5.8.1", "5.11.2") },
1028 : ids: named.as_slice(),
1029 : types: &shown.types,
1030 : attrs: &shown.attrs,
1031 : q: shown.q.as_ref(),
1032 : scope_q: norm.get("scopeQ").and_then(Value::as_str),
1033 : geo: shown.geo.as_ref(),
1034 : )
1035 1230 : .await?;
1036 1226 : check_endpoint(st, &norm)?;
1037 1218 : check_jsonld_context(st, &tenant, &norm).await?;
1038 1208 : let id = match norm.get("id").and_then(Value::as_str) {
1039 1090 : Some(id) => id.to_owned(),
1040 : None => {
1041 118 : let id = format!("urn:ngsi-ld:Subscription:{}", uuid::Uuid::new_v4());
1042 118 : norm.insert("id".into(), Value::String(id.clone()));
1043 118 : id
1044 : }
1045 : };
1046 : // The Registration Subscriptions the distributed half owns (5.8.1.4)
1047 : // are stored outside the client kinds, under this id namespace: a
1048 : // client document claiming it would be looked up in that store on the
1049 : // notification path and would silently never notify.
1050 1208 : if id.starts_with(crate::registry::INTERNAL_CSR_PREFIX) {
1051 0 : return Err(NgsiError::BadRequestData(format!(
1052 0 : "subscription id {id} is reserved by the broker"
1053 0 : ))
1054 0 : .into());
1055 1208 : }
1056 1208 : let ts = now_iso();
1057 1208 : norm.insert("createdAt".into(), Value::String(ts.clone()));
1058 1208 : norm.insert("modifiedAt".into(), Value::String(ts.clone()));
1059 : // notification @context = the creating request's context (5.8.6),
1060 : // stored as its own column — internal member, stripped on output.
1061 1208 : norm.insert("__context".into(), parsed.ctx.source.clone());
1062 : // 6.3.17/6.3.18: a Subscription arriving as a forwarded copy (5.8.1.4)
1063 : // carries the Via chain of the brokers it has passed through — kept on
1064 : // the stored document so the distributed half can extend the chain
1065 : // outbound and refuse to re-forward a copy that has looped back.
1066 1208 : if let Some(via) = crate::federation::inbound_via(headers) {
1067 24 : norm.insert("__via".into(), Value::String(via));
1068 1184 : }
1069 : // ADR-0020: 5.8.6 delivery is broker-initiated, so there is no request
1070 : // to read a subject off when the notification is sent. The subject is
1071 : // the subscriber's, taken from the creating request and kept the way
1072 : // the notification @context is — internal member, never rendered, never
1073 : // forwarded, never settable by a client.
1074 52 : if let Some(subject) =
1075 1208 : crate::policy::subject_member(&crate::policy::subject_of(&tenant, headers))
1076 52 : {
1077 52 : norm.insert(crate::policy::SUBJECT_MEMBER.into(), subject);
1078 1156 : }
1079 : // 5.8.1.4: "If not present, the jsonldContext field shall be initialized
1080 : // with the @context applicable for the Subscription" (5.5.5 when it has
1081 : // none). One URL is that URL; anything inline the broker hosts at its own
1082 : // URL as an ImplicitlyCreated @context (5.13.1, 050_03). A Context Source
1083 : // Registration Subscription (5.2.13) has no such initialisation.
1084 1208 : if kind == Kind::Subscription && !norm.contains_key("jsonldContext") {
1085 954 : let one_url = match &parsed.ctx.source {
1086 0 : Value::Null => Some(antares_jsonld::CORE_CONTEXT.to_owned()),
1087 418 : Value::String(u) => Some(u.clone()),
1088 528 : Value::Array(a) => match a.as_slice() {
1089 474 : [one] => one.as_str().map(str::to_owned),
1090 54 : _ => None,
1091 : },
1092 8 : _ => None,
1093 : };
1094 954 : if let Some(url) = one_url {
1095 892 : norm.insert("jsonldContext".into(), Value::String(url));
1096 892 : } else {
1097 : // 5.13.2.4: "a locally unique identifier (URI)"
1098 62 : let local_id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
1099 62 : let url = format!("{}/{local_id}", crate::contexts::base_url(headers));
1100 62 : st.store
1101 62 : .context_put(
1102 62 : Some(&tenant),
1103 62 : &local_id,
1104 62 : serde_json::json!({
1105 62 : "url": url,
1106 62 : "localId": local_id,
1107 62 : "kind": "ImplicitlyCreated",
1108 62 : "createdAt": ts,
1109 62 : // owned by the tenant whose subscription created it
1110 62 : "owner": tenant.as_str(),
1111 62 : "body": {"@context": parsed.ctx.source.clone()},
1112 62 : }),
1113 62 : )
1114 62 : .await?;
1115 62 : st.loader
1116 62 : .put_local_for(&tenant, url.clone(), parsed.ctx.source.clone())
1117 62 : .await;
1118 62 : norm.insert("jsonldContext".into(), Value::String(url));
1119 : }
1120 254 : }
1121 1208 : let doc = Value::Object(norm);
1122 1208 : if !st.store.create(&tenant, kind, &id, doc.clone()).await? {
1123 8 : return Err(NgsiError::AlreadyExists(format!("subscription {id} already exists")).into());
1124 1200 : }
1125 1200 : st.sub_changed(&tenant, kind, &id, Some(&doc));
1126 1200 : if kind == Kind::Subscription {
1127 : // 5.8.1.4: a distributed Subscription creates its internal Context
1128 : // Source Registration Subscription (consumer half)
1129 962 : crate::distsub::on_subscription_created(st, &tenant, &doc).await;
1130 238 : }
1131 1200 : if kind == Kind::CSourceSubscription {
1132 : // initial CSourceNotification with all matching registrations (5.11.2.4)
1133 238 : let (st2, t2, id2) = (st.clone(), tenant.clone(), id.clone());
1134 238 : crate::spawn(async move {
1135 228 : crate::notify::csource_initial(&st2, &t2, &id2).await;
1136 228 : });
1137 962 : }
1138 1200 : Ok(created(
1139 1200 : format!("/ngsi-ld/v1/{}/{id}", resource_path(kind)),
1140 1200 : &tenant,
1141 1200 : ))
1142 1494 : }
1143 :
1144 : /// 5.8.3 Retrieve Subscription: invalid URI 400, unknown id 404, else the
1145 : /// 5.2.12 subscription document (status/timesSent are output members).
1146 332 : pub async fn retrieve(
1147 332 : st: &AppState,
1148 332 : kind: Kind,
1149 332 : id: &str,
1150 332 : params: &HashMap<String, String>,
1151 332 : headers: &HeaderMap,
1152 332 : ) -> ApiResult<Response> {
1153 332 : let tenant = tenant_from(headers)?;
1154 332 : antares_model::EntityId::new(id)
1155 332 : .map_err(|_| NgsiError::BadRequestData(format!("invalid subscription id {id:?}")))?;
1156 322 : check_params(params, &["options", "format", "sysAttrs", "local"])?;
1157 322 : let accept = parse_accept(headers)?;
1158 314 : let ctx = request_context(&st.loader, headers).await?;
1159 314 : let clause = clause_on(st, &tenant, kind, id, "5.8.3", "5.11.4").await?;
1160 314 : gate!(st, &tenant, headers, clause, ids: &[id]).await?;
1161 314 : let doc = st
1162 314 : .store
1163 314 : .get(&tenant, kind, id)
1164 314 : .await?
1165 314 : .ok_or_else(|| NgsiError::ResourceNotFound(format!("subscription {id} not found")))?;
1166 254 : let sys = sys_attrs_asked(params);
1167 254 : let payload = present_subscription(&doc, &ctx, sys, kind == Kind::CSourceSubscription);
1168 254 : Ok(respond(StatusCode::OK, payload, &ctx, accept, &tenant))
1169 332 : }
1170 :
1171 : /// 5.8.4 Query Subscriptions: list with 5.5.9 pagination; each element a
1172 : /// 5.2.12 subscription document.
1173 242 : pub async fn list(
1174 242 : st: &AppState,
1175 242 : kind: Kind,
1176 242 : params: &HashMap<String, String>,
1177 242 : headers: &HeaderMap,
1178 242 : ) -> ApiResult<Response> {
1179 242 : let tenant = tenant_from(headers)?;
1180 242 : check_params(
1181 242 : params,
1182 242 : &["limit", "offset", "count", "options", "format", "local"],
1183 4 : )?;
1184 238 : let accept = parse_accept(headers)?;
1185 234 : let ctx = request_context(&st.loader, headers).await?;
1186 234 : let narrowing = gate!(st, &tenant, headers, clause_of(kind, "5.8.4", "5.11.5")).await?;
1187 226 : let cond = crate::policy::Condition::of(&narrowing);
1188 : // 5.5.9.1: "only up to a maximum of L NGSI-LD Elements are RETRIEVED
1189 : // and returned". 5.8.4 takes no filter parameters — `check_params`
1190 : // above is the whole list — so the tenant IS the match set and the
1191 : // window is the store's to apply. Reading the tenant and slicing it
1192 : // here made a tenant at the document ceiling unable to list at all,
1193 : // because that read is the one carrying the ceiling for client queries.
1194 226 : let (offset, limit, _) = crate::paging::page_params(st, params)?;
1195 : // A policy narrowing selects among the tenant's Subscriptions, so the
1196 : // count and the window are over that selection.
1197 214 : let (page_docs, total) = if cond.is_some() {
1198 : // A scan of the tenant's Subscriptions; an indexed column
1199 : // when narrowings hold many.
1200 24 : let own: Vec<Value> = st
1201 24 : .store
1202 24 : .list(&tenant, kind)
1203 24 : .await?
1204 24 : .into_iter()
1205 120 : .filter(|d| cond.as_ref().is_none_or(|c| c.holds_for_record(d)))
1206 24 : .collect();
1207 24 : let total = own.len();
1208 24 : (own.into_iter().skip(offset).take(limit).collect(), total)
1209 : } else {
1210 190 : st.store.list_slice(&tenant, kind, offset, limit).await?
1211 : };
1212 214 : let (page, count_hdr, links) = crate::paging::paginate_pre_accept(
1213 214 : st,
1214 214 : params,
1215 214 : page_docs,
1216 214 : &format!("/ngsi-ld/v1/{}", resource_path(kind)),
1217 214 : accept,
1218 214 : total,
1219 214 : kind == Kind::CSourceSubscription,
1220 0 : )?;
1221 214 : let sys = sys_attrs_asked(params);
1222 214 : let payload: Vec<Value> = page
1223 214 : .iter()
1224 214 : .map(|d| present_subscription(d, &ctx, sys, kind == Kind::CSourceSubscription))
1225 214 : .collect();
1226 214 : let mut resp = crate::negotiate::respond_list(StatusCode::OK, payload, &ctx, accept, &tenant);
1227 214 : attach_paging(&mut resp, count_hdr, &links);
1228 214 : Ok(resp)
1229 242 : }
1230 :
1231 : /// 5.8.2 Update Subscription: invalid URI 400, unknown id 404, fragment
1232 : /// validated per 5.5.4 + 5.2.12 (past expiresAt 400), jsonldContext
1233 : /// unavailable -> LdContextNotAvailable / invalid -> 400, modify per 5.5.8;
1234 : /// the 5.8.2.4 status table falls out of the computed status
1235 : /// (isActive/expiresAt) on read.
1236 160 : pub async fn update(
1237 160 : st: &AppState,
1238 160 : kind: Kind,
1239 160 : id: &str,
1240 160 : params: &HashMap<String, String>,
1241 160 : headers: &HeaderMap,
1242 160 : body: &[u8],
1243 160 : ) -> ApiResult<Response> {
1244 160 : let tenant = tenant_from(headers)?;
1245 160 : antares_model::EntityId::new(id)
1246 160 : .map_err(|_| NgsiError::BadRequestData(format!("invalid subscription id {id:?}")))?;
1247 152 : check_params(params, &["local"])?;
1248 152 : let clause = clause_on(st, &tenant, kind, id, "5.8.2", "5.11.3").await?;
1249 152 : let parsed = parse_body(st, headers, body, BodyKind::MergePatch).await?;
1250 144 : let obj = parsed.object(NgsiError::BadRequestData(
1251 144 : "fragment must be a JSON object".into(),
1252 144 : ))?;
1253 144 : if let Some(bid) = obj.get("id").and_then(Value::as_str) {
1254 0 : if bid != id {
1255 0 : return Err(NgsiError::BadRequestData("fragment id mismatch".into()).into());
1256 0 : }
1257 144 : }
1258 144 : let norm = normalize_subscription(obj, &parsed.ctx, true)?;
1259 : // a fragment that moves the endpoint off the socket is no watch
1260 118 : let mut watch = clause == WATCH;
1261 118 : if watch && endpoint_uri(&norm).is_some_and(|u| !own_socket(st, Some(u))) {
1262 0 : watch = false;
1263 118 : }
1264 : // the engine decides the Subscription as it will be, as
1265 : // create does, so a fragment cannot move a permitted Subscription onto
1266 : // types, attributes or a q the engine refuses; one that moved the
1267 : // endpoint off the socket is a plain 5.8.2.
1268 : // Decided on the stored document read here; an update racing
1269 : // between this read and the write below is decided on its own merge.
1270 118 : let decided = if clause == WATCH && !watch {
1271 0 : "5.8.2"
1272 : } else {
1273 118 : clause
1274 : };
1275 118 : let mut stored = match &st.policy {
1276 18 : Some(_) => st.store.get(&tenant, kind, id).await?,
1277 100 : None => None,
1278 : };
1279 118 : let merged = match stored.as_mut() {
1280 6 : Some(doc) => {
1281 6 : let merged = antares_store::stored_object(doc)?;
1282 6 : crate::apply_doc_fragment(merged, &norm, &now_iso());
1283 6 : Some(merged)
1284 : }
1285 112 : None => None,
1286 : };
1287 118 : let shown = merged.as_deref().map(sub_members).unwrap_or_default();
1288 118 : gate!(
1289 : st, &tenant, headers, decided,
1290 : ids: &[id],
1291 : types: &shown.types,
1292 : attrs: &shown.attrs,
1293 : q: shown.q.as_ref(),
1294 6 : scope_q: merged.as_deref().and_then(|m| m.get("scopeQ")).and_then(Value::as_str),
1295 : geo: shown.geo.as_ref(),
1296 : )
1297 118 : .await?;
1298 116 : check_endpoint(st, &norm)?;
1299 116 : check_jsonld_context(st, &tenant, &norm).await?;
1300 116 : let ts = now_iso();
1301 116 : let res = st
1302 116 : .store
1303 116 : .mutate(&tenant, kind, id, |doc| {
1304 : // a watch decided on the stored socket endpoint holds only
1305 : // while it is still that endpoint
1306 86 : if watch && !own_socket(st, doc.as_object().and_then(endpoint_uri)) {
1307 0 : return Err(NgsiError::ResourceNotFound(format!(
1308 0 : "subscription {id} not found"
1309 0 : )));
1310 86 : }
1311 86 : let target = antares_store::stored_object(doc)?;
1312 86 : let mut norm = norm.clone();
1313 86 : keep_masked_receiver_info(&mut norm, target)?;
1314 84 : crate::apply_doc_fragment(target, &norm, &ts);
1315 : // 5.2.12 holds for the updated Subscription too: deleting both
1316 : // selectors is refused and nothing is written
1317 84 : let local_only = target.get("localOnly").and_then(Value::as_bool) == Some(true);
1318 84 : if !local_only
1319 84 : && !target.contains_key("entities")
1320 2 : && !target.contains_key("watchedAttributes")
1321 : {
1322 2 : return Err(NgsiError::BadRequestData(
1323 2 : "one of entities or watchedAttributes is required (5.2.12)".into(),
1324 2 : ));
1325 82 : }
1326 82 : check_exclusive_pairs(target)
1327 86 : })
1328 116 : .await?;
1329 86 : match res {
1330 30 : None => Err(NgsiError::ResourceNotFound(format!("subscription {id} not found")).into()),
1331 6 : Some(Err(e)) => Err(e.into()),
1332 : Some(Ok(())) => {
1333 80 : if kind == Kind::CSourceSubscription {
1334 : // 5.11.3.4: after update, notify with all currently matching
1335 20 : let (st2, t2, id2) = (st.clone(), tenant.clone(), id.to_owned());
1336 20 : crate::spawn(async move {
1337 20 : crate::notify::csource_initial(&st2, &t2, &id2).await;
1338 20 : });
1339 60 : }
1340 80 : if st.sub_sync.is_some() {
1341 76 : let doc = st.store.get(&tenant, kind, id).await?;
1342 76 : st.sub_changed(&tenant, kind, id, doc.as_ref());
1343 4 : }
1344 80 : if kind == Kind::Subscription {
1345 : // 5.8.2.4: the CSR subscription and the mapped remote
1346 : // subscriptions follow the update (5.11.3)
1347 60 : crate::distsub::on_subscription_updated(st, &tenant, id).await;
1348 20 : }
1349 80 : Ok(no_content(&tenant))
1350 : }
1351 : }
1352 160 : }
1353 :
1354 : /// 5.8.5 Delete Subscription: invalid URI 400, unknown id 404, 204 on
1355 : /// success and no further notifications (sub_changed drops the mirror).
1356 844 : pub async fn delete(
1357 844 : st: &AppState,
1358 844 : kind: Kind,
1359 844 : id: &str,
1360 844 : params: &HashMap<String, String>,
1361 844 : headers: &HeaderMap,
1362 844 : ) -> ApiResult<Response> {
1363 844 : let tenant = tenant_from(headers)?;
1364 844 : antares_model::EntityId::new(id)
1365 844 : .map_err(|_| NgsiError::BadRequestData(format!("invalid subscription id {id:?}")))?;
1366 836 : check_params(params, &["local"])?;
1367 836 : let clause = clause_on(st, &tenant, kind, id, "5.8.5", "5.11.6").await?;
1368 836 : gate!(st, &tenant, headers, clause, ids: &[id]).await?;
1369 836 : if st.store.delete(&tenant, kind, id).await? {
1370 786 : st.sub_changed(&tenant, kind, id, None);
1371 786 : if kind == Kind::Subscription {
1372 : // 5.8.5.4: forward the delete to every mapped Context Source
1373 : // and drop the internal CSR subscription (5.11.6)
1374 574 : crate::distsub::on_subscription_deleted(st, &tenant, id).await;
1375 212 : }
1376 786 : Ok(no_content(&tenant))
1377 : } else {
1378 50 : Err(NgsiError::ResourceNotFound(format!("subscription {id} not found")).into())
1379 : }
1380 844 : }
1381 :
1382 : // axum route fns
1383 :
1384 : macro_rules! route4 {
1385 : ($create:ident, $retrieve:ident, $list:ident, $update:ident, $delete:ident, $kind:expr, $c:literal) => {
1386 : #[doc = concat!("HTTP handler for ", $c, " Create: the axum seam over [`create`].")]
1387 1494 : pub async fn $create(
1388 1494 : State(st): State<AppState>,
1389 1494 : CleanParams(params): CleanParams,
1390 1494 : headers: HeaderMap,
1391 1494 : body: Bytes,
1392 1494 : ) -> Response {
1393 1494 : create(&st, $kind, ¶ms, &headers, &body)
1394 1494 : .await
1395 1494 : .unwrap_or_else(|e| e.into_response())
1396 1494 : }
1397 : #[doc = concat!("HTTP handler for ", $c, " Retrieve: the axum seam over [`retrieve`].")]
1398 332 : pub async fn $retrieve(
1399 332 : State(st): State<AppState>,
1400 332 : Path(id): Path<String>,
1401 332 : CleanParams(params): CleanParams,
1402 332 : headers: HeaderMap,
1403 332 : ) -> Response {
1404 332 : retrieve(&st, $kind, &id, ¶ms, &headers)
1405 332 : .await
1406 332 : .unwrap_or_else(|e| e.into_response())
1407 332 : }
1408 : #[doc = concat!("HTTP handler for ", $c, " Query: the axum seam over [`list`].")]
1409 242 : pub async fn $list(
1410 242 : State(st): State<AppState>,
1411 242 : CleanParams(params): CleanParams,
1412 242 : headers: HeaderMap,
1413 242 : ) -> Response {
1414 242 : list(&st, $kind, ¶ms, &headers)
1415 242 : .await
1416 242 : .unwrap_or_else(|e| e.into_response())
1417 242 : }
1418 : #[doc = concat!("HTTP handler for ", $c, " Update: the axum seam over [`update`].")]
1419 160 : pub async fn $update(
1420 160 : State(st): State<AppState>,
1421 160 : Path(id): Path<String>,
1422 160 : CleanParams(params): CleanParams,
1423 160 : headers: HeaderMap,
1424 160 : body: Bytes,
1425 160 : ) -> Response {
1426 160 : update(&st, $kind, &id, ¶ms, &headers, &body)
1427 160 : .await
1428 160 : .unwrap_or_else(|e| e.into_response())
1429 160 : }
1430 : #[doc = concat!("HTTP handler for ", $c, " Delete: the axum seam over [`delete`].")]
1431 844 : pub async fn $delete(
1432 844 : State(st): State<AppState>,
1433 844 : Path(id): Path<String>,
1434 844 : CleanParams(params): CleanParams,
1435 844 : headers: HeaderMap,
1436 844 : ) -> Response {
1437 844 : delete(&st, $kind, &id, ¶ms, &headers)
1438 844 : .await
1439 844 : .unwrap_or_else(|e| e.into_response())
1440 844 : }
1441 : };
1442 : }
1443 :
1444 : route4!(
1445 : create_subscription,
1446 : retrieve_subscription,
1447 : query_subscriptions,
1448 : update_subscription,
1449 : delete_subscription,
1450 : Kind::Subscription,
1451 : "5.8 Subscription"
1452 : );
1453 : route4!(
1454 : create_csource_subscription,
1455 : retrieve_csource_subscription,
1456 : query_csource_subscriptions,
1457 : update_csource_subscription,
1458 : delete_csource_subscription,
1459 : Kind::CSourceSubscription,
1460 : "5.11 Context Source Registration Subscription"
1461 : );
1462 :
1463 : /// The endpoint of a Subscription made over a socket.
1464 : const SOCKET_ENDPOINT: &str = "urn:antares:ws:";
1465 :
1466 : /// a Subscription made over a socket carries a lease
1467 : /// its socket renews; one whose lease passed belongs to a connection no
1468 : /// process holds any more (it died without its grace) and is deleted as the
1469 : /// grace would have. A client's own expired Subscription stays, readable as
1470 : /// `expired` (5.8.1.4).
1471 145058 : pub(crate) async fn sweep_expired_socket_subscriptions(st: &AppState, tenant: &TenantId) -> usize {
1472 145058 : let mut dead: Vec<String> = Vec::new();
1473 145058 : let walked = crate::csource::walk_docs(st, tenant, Kind::Subscription, |doc| {
1474 52054 : let socket = doc
1475 52054 : .pointer("/notification/endpoint/uri")
1476 52054 : .and_then(Value::as_str)
1477 52054 : .is_some_and(|uri| uri.starts_with(SOCKET_ENDPOINT));
1478 52054 : if socket && crate::registry::reg_expired(&doc) {
1479 4 : if let Some(id) = doc.get("id").and_then(Value::as_str) {
1480 4 : dead.push(id.to_owned());
1481 4 : }
1482 52050 : }
1483 52054 : Ok(())
1484 52054 : })
1485 145058 : .await;
1486 145058 : if walked.is_err() {
1487 13 : return 0;
1488 145045 : }
1489 145045 : let mut n: usize = 0;
1490 145045 : for id in dead {
1491 4 : if st
1492 4 : .store
1493 4 : .delete(tenant, Kind::Subscription, &id)
1494 4 : .await
1495 4 : .unwrap_or(false)
1496 : {
1497 4 : st.sub_changed(tenant, Kind::Subscription, &id, None);
1498 4 : crate::distsub::on_subscription_deleted(st, tenant, &id).await;
1499 4 : n = n.saturating_add(1);
1500 0 : }
1501 : }
1502 145045 : n
1503 145058 : }
1504 :
1505 : #[cfg(test)]
1506 : mod tests {
1507 : use super::*;
1508 : use antares_jsonld::Loader;
1509 : use serde_json::json;
1510 :
1511 24 : fn receiver_info(pairs: Value) -> Map<String, Value> {
1512 24 : let doc =
1513 24 : json!({"notification": {"endpoint": {"uri": "http://h/n", "receiverInfo": pairs}}});
1514 24 : doc.as_object().cloned().unwrap_or_default()
1515 24 : }
1516 :
1517 : /// a masked value keeps the stored one only under the same key;
1518 : /// with nothing stored, or a key the stored endpoint lacks, the literal
1519 : /// value is what the client wrote.
1520 : #[test]
1521 4 : fn a_masked_value_keeps_only_a_credential_stored_under_its_key() {
1522 4 : let written = json!([{"key": "X-New", "value": MASKED}, {"key": "A", "value": MASKED}]);
1523 4 : let mut fragment = receiver_info(written.clone());
1524 4 : assert!(keep_masked_receiver_info(&mut fragment, &Map::new()).is_ok());
1525 4 : assert_eq!(fragment, receiver_info(written));
1526 :
1527 4 : let stored = receiver_info(json!([{"key": "A", "value": "Bearer s"}]));
1528 4 : let mut fragment = receiver_info(
1529 4 : json!([{"key": "X-New", "value": MASKED}, {"key": "A", "value": MASKED}]),
1530 : );
1531 4 : assert!(keep_masked_receiver_info(&mut fragment, &stored).is_ok());
1532 4 : let expected =
1533 4 : json!([{"key": "X-New", "value": MASKED}, {"key": "A", "value": "Bearer s"}]);
1534 4 : assert_eq!(fragment, receiver_info(expected));
1535 4 : }
1536 :
1537 : /// a masked value under a stored key stays with the stored uri;
1538 : /// beside another uri the update is refused naming receiverInfo, and a
1539 : /// literal value may go anywhere.
1540 : #[test]
1541 4 : fn a_masked_value_does_not_follow_a_new_uri() {
1542 4 : let stored = receiver_info(json!([{"key": "A", "value": "Bearer s"}]));
1543 4 : let mut fragment = json!({"notification": {"endpoint": {
1544 4 : "uri": "https://elsewhere/n",
1545 4 : "receiverInfo": [{"key": "A", "value": MASKED}]
1546 : }}})
1547 4 : .as_object()
1548 4 : .cloned()
1549 4 : .unwrap_or_default();
1550 4 : let refused = keep_masked_receiver_info(&mut fragment, &stored);
1551 4 : let detail = refused.err().map(|e| e.to_string()).unwrap_or_default();
1552 4 : assert!(detail.starts_with("receiverInfo:"), "{detail}");
1553 4 : assert!(!detail.contains("Bearer s"), "{detail}");
1554 :
1555 4 : let mut literal = json!({"notification": {"endpoint": {
1556 4 : "uri": "https://elsewhere/n",
1557 4 : "receiverInfo": [{"key": "A", "value": "Bearer new"}]
1558 : }}})
1559 4 : .as_object()
1560 4 : .cloned()
1561 4 : .unwrap_or_default();
1562 4 : let before = literal.clone();
1563 4 : assert!(keep_masked_receiver_info(&mut literal, &stored).is_ok());
1564 4 : assert_eq!(literal, before);
1565 4 : }
1566 :
1567 : /// a read masks the endpoint URI's userinfo, and the masked
1568 : /// URI written back keeps the stored credentials.
1569 : #[test]
1570 4 : fn the_endpoint_uri_credentials_are_masked_and_kept() {
1571 4 : let mut n = json!({"endpoint": {"uri": "mqtt://u:secret@h:1883/t"}})
1572 4 : .as_object()
1573 4 : .cloned()
1574 4 : .unwrap_or_default();
1575 4 : mask_receiver_info(&mut n);
1576 4 : assert_eq!(n["endpoint"]["uri"], "mqtt://***@h:1883/t");
1577 4 : let stored = json!({"notification": {"endpoint": {"uri": "mqtt://u:secret@h:1883/t"}}})
1578 4 : .as_object()
1579 4 : .cloned()
1580 4 : .unwrap_or_default();
1581 4 : let mut back = json!({"notification": n})
1582 4 : .as_object()
1583 4 : .cloned()
1584 4 : .unwrap_or_default();
1585 4 : assert!(keep_masked_receiver_info(&mut back, &stored).is_ok());
1586 4 : assert_eq!(
1587 4 : back["notification"]["endpoint"]["uri"],
1588 : "mqtt://u:secret@h:1883/t"
1589 : );
1590 4 : let mut plain = json!({"endpoint": {"uri": "http://h/n"}})
1591 4 : .as_object()
1592 4 : .cloned()
1593 4 : .unwrap_or_default();
1594 4 : mask_receiver_info(&mut plain);
1595 4 : assert_eq!(plain["endpoint"]["uri"], "http://h/n");
1596 4 : }
1597 :
1598 : /// The masked userinfo of a read, written back beside another host or
1599 : /// path, cannot carry the stored credential there: refused, as a masked
1600 : /// receiverInfo value beside another uri is. It used to be stored as it
1601 : /// came, and notifications went out as a user named `***`. A URI the
1602 : /// client spells with its own userinfo, and a stored URI without any,
1603 : /// are left alone.
1604 : #[test]
1605 4 : fn a_masked_uri_userinfo_does_not_follow_a_new_uri() {
1606 32 : let at = |uri: &str| {
1607 32 : json!({"notification": {"endpoint": {"uri": uri}}})
1608 32 : .as_object()
1609 32 : .cloned()
1610 32 : .unwrap_or_default()
1611 32 : };
1612 4 : let stored = at("http://u:secret@h1/n");
1613 8 : for moved in ["http://***@h2/n", "http://***@h1/other"] {
1614 8 : let refused = keep_masked_receiver_info(&mut at(moved), &stored);
1615 8 : let detail = refused.err().map(|e| e.to_string()).unwrap_or_default();
1616 8 : assert!(detail.starts_with("endpoint.uri:"), "{moved}: {detail}");
1617 8 : assert!(!detail.contains("secret"), "{detail}");
1618 : }
1619 4 : let mut own = at("http://v:new@h2/n");
1620 4 : assert!(keep_masked_receiver_info(&mut own, &stored).is_ok());
1621 4 : assert_eq!(own, at("http://v:new@h2/n"));
1622 4 : let mut kept = at("http://***@h1/n");
1623 4 : assert!(keep_masked_receiver_info(&mut kept, &stored).is_ok());
1624 4 : assert_eq!(kept, stored);
1625 4 : assert!(keep_masked_receiver_info(&mut at("http://***@h2/n"), &at("http://h1/n")).is_ok());
1626 4 : }
1627 :
1628 : /// Table 5.2.33-1 `type`: "To indicate a request for all Entities (with
1629 : /// implied local scope), \"*\" is also allowed as a value." It is neither
1630 : /// a term nor a 4.17 expression: expanding it yields an IRI no entity
1631 : /// carries, so the subscription is created and then silently notifies
1632 : /// nothing. The stored form has to survive normalization for the matcher
1633 : /// to read it.
1634 : #[test]
1635 4 : fn clause_5_2_33_a_star_selector_survives_normalization_and_matches() {
1636 4 : let ctx = Loader::new().core();
1637 4 : let doc = json!({
1638 4 : "id": "urn:ngsi-ld:Subscription:star",
1639 4 : "type": "Subscription",
1640 4 : "entities": [{"type": "*"}],
1641 4 : "notification": {"endpoint": {"uri": "http://localhost:1/n"}},
1642 : });
1643 4 : let norm = normalize_subscription(doc.as_object().expect("object"), &ctx, false)
1644 4 : .expect("Table 5.2.33-1 allows \"*\"");
1645 4 : assert_eq!(
1646 4 : norm["entities"][0]["type"],
1647 4 : json!("*"),
1648 : "\"*\" must stay raw: an expanded term matches no entity"
1649 : );
1650 4 : let stored = Value::Object(norm);
1651 4 : assert!(
1652 4 : antares_matcher::selector_match(
1653 4 : &stored,
1654 4 : &json!({"id": "urn:ngsi-ld:Vehicle:1",
1655 4 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"]}),
1656 4 : &ctx,
1657 : ),
1658 : "a stored \"*\" subscription must match every Entity"
1659 : );
1660 4 : }
1661 :
1662 : /// Table 5.2.12-1 ngsildConformance: "A semantically versioned string in
1663 : /// the form major.minor" (Coraine subscription_ngsild_conformance).
1664 : #[test]
1665 4 : fn ngsild_conformance_is_major_dot_minor() {
1666 4 : let ctx = Loader::new().core();
1667 20 : let sub = |v: Value| {
1668 20 : json!({"type": "Subscription", "entities": [{"type": "Building"}], "ngsildConformance": v,
1669 20 : "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}}})
1670 20 : };
1671 16 : for bad in [json!("oneDotFive"), json!("1"), json!("1.8.1"), json!(1.8)] {
1672 16 : assert!(
1673 16 : normalize_subscription(sub(bad.clone()).as_object().unwrap(), &ctx, false).is_err(),
1674 : "{bad}"
1675 : );
1676 : }
1677 4 : assert!(
1678 4 : normalize_subscription(sub(json!("1.8")).as_object().unwrap(), &ctx, false).is_ok()
1679 : );
1680 4 : }
1681 :
1682 : /// 5.5.4: "urn:ngsi-ld:null" as a first-level member value is
1683 : /// BadRequestData on create; in a patch fragment it is the removal form.
1684 : #[test]
1685 4 : fn clause_5_5_4_first_level_null_in_subscription() {
1686 4 : let ctx = Loader::new().core();
1687 4 : let doc = json!({
1688 4 : "type": "Subscription",
1689 4 : "entities": [{"type": "Building"}],
1690 4 : "description": "urn:ngsi-ld:null",
1691 4 : "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}}
1692 : });
1693 4 : assert!(
1694 4 : normalize_subscription(doc.as_object().unwrap(), &ctx, false).is_err(),
1695 : "create with a first-level null URN must be rejected"
1696 : );
1697 : // patch fragment: 5.4 removal semantics — the member is marked for
1698 : // deletion, never stored carrying the string it is spelled with
1699 4 : let patch = json!({"description": "urn:ngsi-ld:null"});
1700 4 : let n = normalize_subscription(patch.as_object().unwrap(), &ctx, true).expect("fragment");
1701 4 : assert_eq!(
1702 4 : n["description"],
1703 : Value::Null,
1704 : "the NGSI-LD Null removes the member, like a JSON null"
1705 : );
1706 : // and a mandatory member cannot be removed at all (5.8.3)
1707 8 : for k in ["type", "notification"] {
1708 8 : let patch = json!({ k: "urn:ngsi-ld:null" });
1709 8 : assert!(
1710 8 : normalize_subscription(patch.as_object().unwrap(), &ctx, true).is_err(),
1711 : "removing the mandatory member {k} must be refused"
1712 : );
1713 : }
1714 4 : }
1715 :
1716 : #[test]
1717 4 : fn validates_subscription() {
1718 4 : let ctx = Loader::new().core();
1719 4 : let doc = json!({
1720 4 : "id": "urn:ngsi-ld:Subscription:1",
1721 4 : "type": "Subscription",
1722 4 : "entities": [{"type": "Building"}],
1723 4 : "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}}
1724 : });
1725 4 : let n = normalize_subscription(doc.as_object().unwrap(), &ctx, false).expect("valid");
1726 4 : assert_eq!(
1727 4 : n["entities"][0]["type"],
1728 : "https://uri.etsi.org/ngsi-ld/default-context/Building"
1729 : );
1730 :
1731 4 : let missing_notification = json!({
1732 4 : "type": "Subscription",
1733 4 : "entities": [{"type": "Building"}]
1734 : });
1735 4 : assert!(
1736 4 : normalize_subscription(missing_notification.as_object().unwrap(), &ctx, false).is_err()
1737 : );
1738 :
1739 4 : let past_expiry = json!({
1740 4 : "type": "Subscription",
1741 4 : "entities": [{"type": "Building"}],
1742 4 : "expiresAt": "2020-01-01T00:00:00Z",
1743 4 : "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}}
1744 : });
1745 4 : assert!(normalize_subscription(past_expiry.as_object().unwrap(), &ctx, false).is_err());
1746 4 : }
1747 :
1748 : /// Table 5.2.14.1-1 p.120: "showChanges cannot be true in case format is
1749 : /// keyValues". "simplified" is the table's declared synonym.
1750 : #[test]
1751 4 : fn show_changes_with_key_values_is_rejected() {
1752 4 : let ctx = Loader::new().core();
1753 20 : let mk = |format: &str, show: bool| {
1754 20 : json!({
1755 20 : "type": "Subscription",
1756 20 : "entities": [{"type": "Building"}],
1757 20 : "notification": {
1758 20 : "format": format,
1759 20 : "showChanges": show,
1760 20 : "endpoint": {"uri": "http://localhost:1111/notify"}
1761 : }
1762 : })
1763 20 : };
1764 8 : for f in ["keyValues", "simplified"] {
1765 8 : assert!(
1766 8 : normalize_subscription(mk(f, true).as_object().unwrap(), &ctx, false).is_err(),
1767 : "showChanges+{f} must be rejected"
1768 : );
1769 8 : assert!(
1770 8 : normalize_subscription(mk(f, false).as_object().unwrap(), &ctx, false).is_ok(),
1771 : "{f} without showChanges is fine"
1772 : );
1773 : }
1774 4 : assert!(
1775 4 : normalize_subscription(mk("normalized", true).as_object().unwrap(), &ctx, false)
1776 4 : .is_ok(),
1777 : "showChanges+normalized is fine"
1778 : );
1779 4 : }
1780 :
1781 : /// Table 5.2.14.1-1 p.119: "Empty array (0 length) is not allowed" on
1782 : /// notification.attributes / pick / omit.
1783 : #[test]
1784 4 : fn empty_projection_arrays_are_rejected() {
1785 4 : let ctx = Loader::new().core();
1786 12 : for key in ["attributes", "pick", "omit"] {
1787 12 : let doc = json!({
1788 12 : "type": "Subscription",
1789 12 : "entities": [{"type": "Building"}],
1790 12 : "notification": {
1791 12 : key: [],
1792 12 : "endpoint": {"uri": "http://localhost:1111/notify"}
1793 : }
1794 : });
1795 12 : assert!(
1796 12 : normalize_subscription(doc.as_object().unwrap(), &ctx, false).is_err(),
1797 : "empty notification.{key} must be rejected"
1798 : );
1799 : }
1800 4 : }
1801 :
1802 : // ---------- shared fixtures ----------
1803 :
1804 : /// The minimal valid 5.2.12 Subscription, with `extra` merged over it.
1805 800 : fn sub(extra: Value) -> Value {
1806 800 : let mut base = json!({
1807 800 : "type": "Subscription",
1808 800 : "entities": [{"type": "Building"}],
1809 800 : "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}}
1810 : });
1811 800 : let obj = base.as_object_mut().expect("object");
1812 824 : for (k, v) in extra.as_object().expect("object") {
1813 824 : obj.insert(k.clone(), v.clone());
1814 824 : }
1815 800 : base
1816 800 : }
1817 :
1818 776 : fn norm(doc: &Value) -> Result<Map<String, Value>, NgsiError> {
1819 776 : normalize_subscription(
1820 776 : doc.as_object().expect("object"),
1821 776 : &Loader::new().core(),
1822 : false,
1823 : )
1824 776 : }
1825 :
1826 36 : fn frag(doc: &Value) -> Result<Map<String, Value>, NgsiError> {
1827 36 : normalize_subscription(
1828 36 : doc.as_object().expect("object"),
1829 36 : &Loader::new().core(),
1830 : true,
1831 : )
1832 36 : }
1833 :
1834 : /// 6.3.8 / 6.3.9: "each of the KeyValuePairs shall be included as a
1835 : /// custom HTTP header … 'Key' and 'value' members shall adhere to IETF
1836 : /// RFC 7230 clause 3.2 definitions concerning HTTP headers". A pair that
1837 : /// cannot be written as a header is refused at create, not at delivery:
1838 : /// a subscriber that could smuggle CR or LF into a receiverInfo value
1839 : /// would write its own headers — or its own request line — into every
1840 : /// notification the broker sends to that endpoint.
1841 : #[test]
1842 4 : fn clause_6_3_8_receiver_info_that_cannot_be_a_header_is_refused() {
1843 52 : let with = |k: &str, v: &str| {
1844 52 : sub(json!({"notification": {"endpoint": {
1845 52 : "uri": "http://localhost:1111/notify",
1846 52 : "receiverInfo": [{"key": k, "value": v}],
1847 : }}}))
1848 52 : };
1849 44 : for (k, v, why) in [
1850 4 : ("X-Ok", "a\r\nInjected: 1", "a CRLF splits the header block"),
1851 4 : (
1852 4 : "X-Ok",
1853 4 : "a\nInjected: 1",
1854 4 : "a bare LF is enough for most parsers",
1855 4 : ),
1856 4 : ("X-Ok", "a\rInjected: 1", "so is a bare CR"),
1857 4 : ("X-Ok", "a\0b", "NUL is not field-content"),
1858 4 : ("X-Ok", " leading", "OWS may not be part of the value"),
1859 4 : ("X-Ok", "trailing\t", "nor may it trail it"),
1860 4 : ("Bad Key", "v", "SP is not a tchar"),
1861 4 : ("X:Ok", "v", "neither is the field separator"),
1862 4 : ("", "v", "a field-name is one or more tchar"),
1863 4 : ("X-Ok\r\nInjected", "v", "the key is a header name too"),
1864 4 : ("X-Ünicode", "v", "field-name is ASCII"),
1865 4 : ] {
1866 44 : assert!(
1867 44 : norm(&with(k, v)).is_err(),
1868 : "receiverInfo {k:?}: {v:?} must be refused — {why}"
1869 : );
1870 : }
1871 : // and the negative: an empty value and the full tchar repertoire are
1872 : // legal headers, so they stay legal Subscriptions
1873 8 : for (k, v) in [
1874 4 : ("X-Empty", ""),
1875 4 : ("!#$%&x*+-.^_`|~0Aa", "value with spaces inside"),
1876 4 : ] {
1877 8 : assert!(norm(&with(k, v)).is_ok(), "receiverInfo {k:?}: {v:?}");
1878 : }
1879 4 : }
1880 :
1881 : /// Table 5.2.12-1: `localOnly` and `splitEntities` are Booleans. Both
1882 : /// decide how far a Subscription reaches — `localOnly=true` confines it
1883 : /// to "the Entities stored locally" (5.5.13), and `splitEntities` decides
1884 : /// whether filters may be applied locally at all. A string `"true"` read
1885 : /// through `as_bool()` is falsy, so accepting one turns a subscriber's
1886 : /// request for local scope into a distributed subscription that forwards
1887 : /// to every matching Context Source, and the subscriber is never told.
1888 : #[test]
1889 4 : fn clause_5_2_12_local_only_and_split_entities_are_booleans() {
1890 8 : for member in ["localOnly", "splitEntities"] {
1891 32 : for v in [json!("true"), json!(1), json!("false"), json!([true])] {
1892 32 : assert!(
1893 32 : norm(&sub(json!({member: v.clone()}))).is_err(),
1894 : "{member}: {v} is not a Boolean (5.2.12)"
1895 : );
1896 : }
1897 16 : for v in [json!(true), json!(false)] {
1898 16 : let out = norm(&sub(json!({member: v.clone()}))).expect("a Boolean is accepted");
1899 16 : assert_eq!(out[member], v, "{member} is stored as given");
1900 : }
1901 : }
1902 4 : }
1903 :
1904 : /// Table 5.2.12-1: `expandValues` and `jsonKeys` are each a String,
1905 : /// "comma separated list of attribute names". They decide whether the
1906 : /// notification condition compares a term's value as written or after
1907 : /// JSON-LD type coercion (4.9), and a non-string reads as an absent
1908 : /// list at matching time — the Subscription would then quietly match on
1909 : /// the other reading instead of refusing the member.
1910 : #[test]
1911 4 : fn clause_5_2_12_expand_values_and_json_keys_are_strings() {
1912 8 : for member in ["expandValues", "jsonKeys"] {
1913 32 : for v in [json!(1), json!(["category"]), json!(true), json!({})] {
1914 32 : assert!(
1915 32 : norm(&sub(json!({member: v.clone()}))).is_err(),
1916 : "{member}: {v} is not a comma separated list (5.2.12)"
1917 : );
1918 : }
1919 8 : let v = json!("category,brandName");
1920 8 : let out = norm(&sub(json!({member: v.clone()}))).expect("a String is accepted");
1921 8 : assert_eq!(out[member], v, "{member} is stored as given");
1922 : }
1923 4 : }
1924 :
1925 : // ---------- 5.2.12 read-only and internal members ----------
1926 :
1927 : /// 5.8.6: the @context governing a Subscription's notifications is the
1928 : /// @context of the creating request, kept in a broker-internal member.
1929 : /// A Context Subscriber can neither set it (create) nor replace it
1930 : /// (update), and it appears in no served representation — 5.8.3 and
1931 : /// 5.8.4 serve the 5.2.12 data type, which has no such member.
1932 : #[test]
1933 4 : fn clause_5_8_6_internal_context_member_is_client_proof() {
1934 4 : let hostile = json!({"__context": "http://attacker.invalid/ctx.jsonld",
1935 4 : "__via": "1.1 forged-alias"});
1936 4 : let created = norm(&sub(hostile.clone())).expect("valid subscription");
1937 4 : assert!(
1938 4 : !created.contains_key("__context"),
1939 : "a client-supplied __context must not reach storage: {created:?}"
1940 : );
1941 4 : assert!(
1942 4 : !created.contains_key("__via"),
1943 : "a body member must not forge the Via chain — the chain comes \
1944 : from the HTTP header only: {created:?}"
1945 : );
1946 4 : let patched = frag(&hostile).expect("valid fragment");
1947 4 : assert!(
1948 4 : !patched.contains_key("__context"),
1949 : "a patch fragment must not replace the notification @context: {patched:?}"
1950 : );
1951 4 : assert!(
1952 4 : !patched.contains_key("__via"),
1953 : "a patch fragment must not rewrite the stored Via chain: {patched:?}"
1954 : );
1955 4 : let ctx = Loader::new().core();
1956 4 : let stored = json!({
1957 4 : "id": "urn:ngsi-ld:Subscription:ctx",
1958 4 : "type": "Subscription",
1959 4 : "entities": [{"type": "Building"}],
1960 4 : "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}},
1961 4 : "__context": "https://example.org/private-ctx.jsonld",
1962 4 : "__via": "1.1 upstream-broker",
1963 : });
1964 8 : for csource in [false, true] {
1965 16 : for sys in [false, true] {
1966 16 : let out = present_subscription(&stored, &ctx, sys, csource);
1967 16 : assert!(
1968 16 : !out.to_string().contains("__context"),
1969 : "served representation leaked the internal @context member: {out}"
1970 : );
1971 16 : assert!(
1972 16 : !out.to_string().contains("private-ctx"),
1973 : "served representation leaked the internal @context value: {out}"
1974 : );
1975 16 : assert!(
1976 16 : !out.to_string().contains("__via")
1977 16 : && !out.to_string().contains("upstream-broker"),
1978 : "5.8.3/5.8.4 serve the 5.2.12 data type, which has no Via member: {out}"
1979 : );
1980 : }
1981 : }
1982 4 : }
1983 :
1984 : /// Table 5.2.12-2 and 5.2.14.2: read-only members "shall not be provided
1985 : /// by Context Subscribers. In the event that they are provided (in
1986 : /// update or create operations) NGSI-LD implementations shall ignore
1987 : /// them."
1988 : #[test]
1989 4 : fn clause_5_2_12_read_only_members_are_ignored() {
1990 4 : let doc = sub(json!({
1991 4 : "status": "active",
1992 4 : "createdAt": "2020-01-01T00:00:00Z",
1993 4 : "modifiedAt": "2020-01-01T00:00:00Z",
1994 4 : "notification": {
1995 4 : "endpoint": {"uri": "http://localhost:1111/notify"},
1996 4 : "timesSent": 42,
1997 4 : "lastNotification": "2020-01-01T00:00:00Z",
1998 4 : "lastSuccess": "2020-01-01T00:00:00Z",
1999 4 : "lastFailure": "2020-01-01T00:00:00Z",
2000 : }
2001 : }));
2002 8 : for is_patch in [false, true] {
2003 8 : let n = normalize_subscription(
2004 8 : doc.as_object().expect("object"),
2005 8 : &Loader::new().core(),
2006 8 : is_patch,
2007 : )
2008 8 : .expect("valid");
2009 24 : for k in ["status", "createdAt", "modifiedAt"] {
2010 24 : assert!(
2011 24 : !n.contains_key(k),
2012 : "{k} must not persist (patch={is_patch})"
2013 : );
2014 : }
2015 8 : let notif = n["notification"].as_object().expect("notification");
2016 32 : for k in [
2017 8 : "timesSent",
2018 8 : "lastNotification",
2019 8 : "lastSuccess",
2020 8 : "lastFailure",
2021 8 : ] {
2022 32 : assert!(
2023 32 : !notif.contains_key(k),
2024 : "notification.{k} must not persist (patch={is_patch})"
2025 : );
2026 : }
2027 : }
2028 4 : }
2029 :
2030 : // ---------- 5.2.12 value spaces ----------
2031 :
2032 : /// Table 5.2.12-1: "Valid notification triggers are entityCreated,
2033 : /// entityUpdated, entityDeleted, attributeCreated, attributeUpdated,
2034 : /// attributeDeleted". A trigger outside that set would leave a
2035 : /// subscription that silently never fires.
2036 : #[test]
2037 4 : fn clause_5_2_12_notification_trigger_value_space() {
2038 12 : for good in [
2039 4 : json!(["entityCreated"]),
2040 4 : json!(["entityUpdated", "entityDeleted"]),
2041 4 : json!(["attributeCreated", "attributeUpdated", "attributeDeleted"]),
2042 4 : ] {
2043 12 : assert!(
2044 12 : norm(&sub(json!({ "notificationTrigger": good }))).is_ok(),
2045 : "{good} must be accepted"
2046 : );
2047 : }
2048 20 : for bad in [
2049 4 : json!(["entityChanged"]),
2050 4 : json!(["attributeCreated", "nope"]),
2051 4 : json!("entityCreated"),
2052 4 : json!([1]),
2053 4 : json!({}),
2054 4 : ] {
2055 20 : assert!(
2056 20 : norm(&sub(json!({ "notificationTrigger": bad }))).is_err(),
2057 : "{bad} must be rejected"
2058 : );
2059 : }
2060 4 : }
2061 :
2062 : /// Table 5.2.12-1: csf is "A valid query string as per clause 4.9". An
2063 : /// unparseable filter is otherwise stored and only fails at Context
2064 : /// Source Registration matching time (5.11.2.4), where it silently
2065 : /// matches nothing.
2066 : #[test]
2067 4 : fn clause_5_2_12_csf_must_be_a_valid_query() {
2068 4 : assert!(norm(&sub(json!({"csf": "endpoint==\"http://a/x\""}))).is_ok());
2069 16 : for bad in [json!("(("), json!("a==(("), json!(5), json!(["a==1"])] {
2070 16 : assert!(
2071 16 : norm(&sub(json!({ "csf": bad }))).is_err(),
2072 : "{bad} must be rejected"
2073 : );
2074 : }
2075 4 : }
2076 :
2077 : /// Table 5.2.12-1: throttling and timeInterval are Numbers "Greater
2078 : /// than 0"; throttling allows fractional values, and neither accepts a
2079 : /// stringified number. The table sets no lower bound on `timeInterval`
2080 : /// beyond that, so a sub-second one is a legal Subscription and is
2081 : /// rejected by nothing.
2082 : #[test]
2083 4 : fn clause_5_2_12_throttling_and_time_interval_value_space() {
2084 4 : assert!(norm(&sub(json!({"throttling": 0.5}))).is_ok());
2085 4 : assert!(norm(&sub(json!({"timeInterval": 5}))).is_ok());
2086 4 : assert!(norm(&sub(json!({"timeInterval": 0.5}))).is_ok());
2087 20 : for bad in [json!(0), json!(-1), json!("5"), json!(true), json!(null)] {
2088 20 : assert!(
2089 20 : norm(&sub(json!({ "throttling": bad }))).is_err(),
2090 : "throttling {bad} must be rejected"
2091 : );
2092 20 : assert!(
2093 20 : norm(&sub(json!({ "timeInterval": bad }))).is_err(),
2094 : "timeInterval {bad} must be rejected"
2095 : );
2096 : }
2097 4 : }
2098 :
2099 : /// Table 5.2.12-1: expiresAt is a 4.6.3 DateTime, and 5.8.1.4/5.8.2.4
2100 : /// reject one "referring to a DateTime in the past". 4.6.3 admits both
2101 : /// fraction separators, so the past/future decision must be taken on the
2102 : /// instant, not on the raw string.
2103 : #[test]
2104 4 : fn clause_5_2_12_expires_at_boundary() {
2105 4 : assert!(norm(&sub(json!({"expiresAt": "2099-01-01T00:00:00Z"}))).is_ok());
2106 : // comma is the other legal fraction separator (4.6.3); comparing the
2107 : // raw strings would place ',' before now_iso()'s '.' and reject it
2108 4 : assert!(
2109 4 : norm(&sub(json!({"expiresAt": "2099-01-01T00:00:00,500Z"}))).is_ok(),
2110 : "a comma fraction separator is legal (4.6.3)"
2111 : );
2112 4 : let soon = (chrono::Utc::now() + chrono::Duration::seconds(30))
2113 4 : .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2114 4 : assert!(
2115 4 : norm(&sub(json!({ "expiresAt": soon }))).is_ok(),
2116 : "a whole-second DateTime 30s ahead is in the future: {soon}"
2117 : );
2118 20 : for bad in [
2119 4 : json!("2020-01-01T00:00:00Z"),
2120 4 : json!("tomorrow"),
2121 4 : json!("2099-01-01"),
2122 4 : json!("2099-01-01T00:00:00+05:00"),
2123 4 : json!(1234),
2124 4 : ] {
2125 20 : assert!(
2126 20 : norm(&sub(json!({ "expiresAt": bad }))).is_err(),
2127 : "expiresAt {bad} must be rejected"
2128 : );
2129 : }
2130 4 : }
2131 :
2132 : /// Table 5.2.12-1: watchedAttributes is a String[] of Attribute names,
2133 : /// "Empty array (0 length) is not allowed"; names expand per 5.5.7.
2134 : #[test]
2135 4 : fn clause_5_2_12_watched_attributes_contract() {
2136 4 : let n = norm(&sub(json!({"watchedAttributes": ["temperature"]}))).expect("valid");
2137 4 : assert_eq!(
2138 4 : n["watchedAttributes"][0],
2139 : "https://uri.etsi.org/ngsi-ld/default-context/temperature"
2140 : );
2141 16 : for bad in [json!([]), json!("temperature"), json!([1]), json!([""])] {
2142 16 : assert!(
2143 16 : norm(&sub(json!({ "watchedAttributes": bad }))).is_err(),
2144 : "watchedAttributes {bad} must be rejected"
2145 : );
2146 : }
2147 4 : }
2148 :
2149 : /// Table 5.2.12-1: q is "A valid query string as per clause 4.9", the
2150 : /// same string the matcher parses — read as written, so an encoded form
2151 : /// is not a query the matcher could ever run.
2152 : #[test]
2153 4 : fn clause_5_2_12_q_is_validated_as_written() {
2154 4 : assert!(norm(&sub(json!({"q": "temperature>20"}))).is_ok());
2155 4 : assert!(norm(&sub(json!({"q": "(("}))).is_err());
2156 4 : assert!(
2157 4 : norm(&sub(json!({"q": "%28%28%28"}))).is_err(),
2158 : "percent signs are not part of the 4.9 grammar"
2159 : );
2160 4 : assert!(norm(&sub(json!({"q": 5}))).is_err());
2161 4 : }
2162 :
2163 : /// Table 5.2.12-1: "At least one of (a) entities or (b)
2164 : /// watchedAttributes shall be present, unless the member localOnly is
2165 : /// set to true"; timeInterval excludes watchedAttributes and throttling.
2166 : #[test]
2167 4 : fn clause_5_2_12_mutual_exclusions_and_local_only() {
2168 4 : let bare = json!({
2169 4 : "type": "Subscription",
2170 4 : "notification": {"endpoint": {"uri": "http://localhost:1111/notify"}}
2171 : });
2172 4 : assert!(
2173 4 : norm(&bare).is_err(),
2174 : "neither entities nor watchedAttributes"
2175 : );
2176 4 : let mut local = bare.clone();
2177 4 : local["localOnly"] = json!(true);
2178 4 : assert!(
2179 4 : norm(&local).is_ok(),
2180 : "localOnly=true waives the rule (5.5.13)"
2181 : );
2182 4 : assert!(norm(&sub(json!({
2183 4 : "timeInterval": 5,
2184 4 : "watchedAttributes": ["temperature"]
2185 4 : })))
2186 4 : .is_err());
2187 4 : assert!(norm(&sub(json!({"timeInterval": 5, "throttling": 5}))).is_err());
2188 : // a fragment carries no mandatory members of its own (5.8.2.4)
2189 4 : assert!(frag(&json!({"isActive": false})).is_ok());
2190 4 : }
2191 :
2192 : /// Table 5.2.33-1 EntitySelector: type is required, id is "String or
2193 : /// String[]" of valid URIs, idPattern is a regular expression; 4.17
2194 : /// type-selection expressions stay unexpanded.
2195 : #[test]
2196 4 : fn clause_5_2_33_entity_selector_contract() {
2197 4 : let n = norm(&sub(json!({"entities": [{"type": "Building|Room"}]}))).expect("valid");
2198 4 : assert_eq!(
2199 4 : n["entities"][0]["type"], "Building|Room",
2200 : "a 4.17 type-selection expression is evaluated at match time"
2201 : );
2202 4 : let n = norm(&sub(json!({
2203 4 : "entities": [{"type": "Building", "id": ["urn:ngsi-ld:B:1", "urn:ngsi-ld:B:2"]}]
2204 4 : })))
2205 4 : .expect("valid");
2206 4 : assert_eq!(n["entities"][0]["id"][1], "urn:ngsi-ld:B:2");
2207 48 : for bad in [
2208 4 : json!([]),
2209 4 : json!("Building"),
2210 4 : json!([{"id": "urn:ngsi-ld:B:1"}]),
2211 4 : json!([{"type": ""}]),
2212 4 : json!([{"type": "Building", "id": "not a uri"}]),
2213 4 : json!([{"type": "Building", "id": ["urn:ngsi-ld:B:1", 7]}]),
2214 4 : json!([{"type": "Building", "idPattern": "["}]),
2215 4 : // 21 bytes, a 16 MiB automaton: above the compile ceiling the
2216 4 : // pattern is refused here rather than compiled per event
2217 4 : json!([{"type": "Building", "idPattern": r"(?:\p{Any}{100}){100}"}]),
2218 4 : json!([{"type": "Building", "idPattern": 7}]),
2219 4 : json!(["Building"]),
2220 4 : // id and its keyword alias collide (Coraine duplicate_id_type_alias_every_level 03)
2221 4 : json!([{"type": "Building", "id": "urn:ngsi-ld:B:1", "@id": "urn:ngsi-ld:B:2"}]),
2222 4 : json!([{"type": "Building", "@type": "Room"}]),
2223 4 : ] {
2224 48 : assert!(
2225 48 : norm(&sub(json!({ "entities": bad }))).is_err(),
2226 : "entities {bad} must be rejected"
2227 : );
2228 : }
2229 4 : }
2230 :
2231 : // ---------- 5.2.14 / 5.2.15 notification parameters ----------
2232 :
2233 : /// TS 104 243 5.2, Table 5.2-2: an MQTT notification's
2234 : /// `metadata.Content-Type` is "application/json" or
2235 : /// "application/ld+json", taken from `endpoint.accept`. A Subscription
2236 : /// asking an mqtt[s] endpoint for GeoJSON is refused at create and at
2237 : /// update (a fragment replaces `notification` whole); an HTTP endpoint
2238 : /// keeps it (TS 104 175 5.2.15).
2239 : #[test]
2240 4 : fn ts104243_5_2_mqtt_endpoint_refuses_geojson_accept() {
2241 56 : let doc = |uri: &str, accept: &str| {
2242 56 : sub(json!({"notification": {"endpoint": {"uri": uri, "accept": accept}}}))
2243 56 : };
2244 8 : for uri in ["mqtt://broker:1883/topic", "MQTTS://broker/t/sub"] {
2245 16 : for normalize in [norm, frag] {
2246 16 : let err = normalize(&doc(uri, "application/geo+json"))
2247 16 : .expect_err("an mqtt endpoint cannot carry GeoJSON");
2248 16 : assert!(
2249 16 : matches!(&err, NgsiError::BadRequestData(why)
2250 16 : if why.contains("application/json") && why.contains("application/ld+json")),
2251 : "{err:?}"
2252 : );
2253 32 : for accept in ["application/json", "application/ld+json"] {
2254 32 : assert!(normalize(&doc(uri, accept)).is_ok(), "{uri} {accept}");
2255 : }
2256 : }
2257 : }
2258 8 : for normalize in [norm, frag] {
2259 8 : assert!(normalize(&doc("http://a/x", "application/geo+json")).is_ok());
2260 : }
2261 4 : }
2262 :
2263 : /// Table 5.2.15-1 Endpoint: uri is mandatory and a valid URI; cooldown
2264 : /// and timeout are Numbers "Greater than 0"; accept is one of the three
2265 : /// media types. Which schemes are deliverable is the sink registry's
2266 : /// question, answered by `check_endpoint` where the state is in hand.
2267 : #[test]
2268 4 : fn clause_5_2_15_endpoint_contract() {
2269 132 : let mk = |ep: Value| sub(json!({"notification": {"endpoint": ep}}));
2270 4 : assert!(
2271 4 : norm(&sub(json!({"notification": {}}))).is_err(),
2272 : "endpoint required"
2273 : );
2274 4 : assert!(norm(&mk(json!({}))).is_err(), "endpoint.uri required");
2275 4 : assert!(norm(&mk(json!({"uri": "no-scheme"}))).is_err());
2276 4 : assert!(norm(&mk(json!({"uri": "http://a/x\r\nX: y"}))).is_err());
2277 8 : for key in ["cooldown", "timeout"] {
2278 24 : for bad in [json!(0), json!(-1), json!("5")] {
2279 24 : assert!(
2280 24 : norm(&mk(json!({"uri": "http://a/x", key: bad}))).is_err(),
2281 : "endpoint.{key} {bad} must be rejected"
2282 : );
2283 : }
2284 8 : assert!(norm(&mk(json!({"uri": "http://a/x", key: 1.5}))).is_ok());
2285 : }
2286 8 : for key in ["receiverInfo", "notifierInfo"] {
2287 24 : for bad in [
2288 8 : json!({}),
2289 8 : json!([{"key": "k"}]),
2290 8 : json!([{"key": 1, "value": "v"}]),
2291 8 : ] {
2292 24 : assert!(
2293 24 : norm(&mk(json!({"uri": "http://a/x", key: bad}))).is_err(),
2294 : "endpoint.{key} {bad} must be rejected"
2295 : );
2296 : }
2297 8 : assert!(norm(&mk(
2298 8 : json!({"uri": "http://a/x", key: [{"key": "k", "value": "v"}]})
2299 8 : ))
2300 8 : .is_ok());
2301 : }
2302 : // 6.3.8/6.3.9: each receiverInfo pair becomes one custom HTTP header,
2303 : // and "'Key' and 'value' members shall adhere to IETF RFC 7230 ...
2304 : // definitions concerning HTTP headers" — so a pair that cannot be a
2305 : // header is input the operation cannot meet, not a delivery that fails
2306 : // later.
2307 32 : for bad in [
2308 4 : json!([{"key": "", "value": "v"}]),
2309 4 : json!([{"key": "Bad Key", "value": "v"}]),
2310 4 : json!([{"key": "X:Y", "value": "v"}]),
2311 4 : json!([{"key": "X\r\nInjected", "value": "v"}]),
2312 4 : json!([{"key": "X", "value": "a\r\nInjected: 1"}]),
2313 4 : json!([{"key": "X", "value": "tab\u{7f}del"}]),
2314 4 : json!([{"key": "X", "value": " leading"}]),
2315 4 : json!([{"key": "X", "value": "trailing "}]),
2316 4 : ] {
2317 32 : assert!(
2318 32 : norm(&mk(json!({"uri": "http://a/x", "receiverInfo": bad}))).is_err(),
2319 : "receiverInfo {bad} must be rejected"
2320 : );
2321 : }
2322 16 : for ok in [
2323 4 : json!([{"key": "Authorization", "value": "Bearer t"}]),
2324 4 : json!([{"key": "X-Custom_1!", "value": ""}]),
2325 4 : json!([{"key": "Prefer", "value": "body=json"}]),
2326 4 : json!([{"key": "X", "value": "a\tb"}]),
2327 4 : ] {
2328 16 : assert!(
2329 16 : norm(&mk(json!({"uri": "http://a/x", "receiverInfo": ok}))).is_ok(),
2330 : "receiverInfo {ok} must be accepted"
2331 : );
2332 : }
2333 4 : assert!(norm(&mk(json!({"uri": "http://a/x", "accept": "text/html"}))).is_err());
2334 4 : assert!(norm(&mk(
2335 4 : json!({"uri": "http://a/x", "accept": "application/geo+json"})
2336 4 : ))
2337 4 : .is_ok());
2338 4 : }
2339 :
2340 : /// Table 5.2.14.1-1 NotificationParams: format value space, join /
2341 : /// joinLevel, boolean sysAttrs/showChanges, and the pick/omit/attributes
2342 : /// exclusivity — "A synonym for pick, except that id, type, scope are
2343 : /// not allowed."
2344 : #[test]
2345 4 : fn clause_5_2_14_notification_params_contract() {
2346 128 : let mk = |extra: Value| {
2347 128 : let mut n = json!({"endpoint": {"uri": "http://localhost:1111/notify"}});
2348 128 : let o = n.as_object_mut().expect("object");
2349 144 : for (k, v) in extra.as_object().expect("object") {
2350 144 : o.insert(k.clone(), v.clone());
2351 144 : }
2352 128 : sub(json!({ "notification": n }))
2353 128 : };
2354 4 : assert!(
2355 4 : norm(&sub(json!({"notification": []}))).is_err(),
2356 : "not an object"
2357 : );
2358 4 : assert!(norm(&mk(json!({"format": "verbose"}))).is_err());
2359 16 : for f in ["normalized", "keyValues", "simplified", "concise"] {
2360 16 : assert!(norm(&mk(json!({ "format": f }))).is_ok(), "{f}");
2361 : }
2362 12 : for bad in [json!("nested"), json!(1), json!("")] {
2363 12 : assert!(norm(&mk(json!({ "join": bad }))).is_err(), "join {bad}");
2364 : }
2365 12 : for good in ["flat", "inline", "@none"] {
2366 12 : assert!(norm(&mk(json!({ "join": good }))).is_ok(), "{good}");
2367 : }
2368 : // Table 5.2.14.1-1 restricts joinLevel to a positive integer, and the
2369 : // depth it names is the same Linked Entity traversal the query
2370 : // parameter drives — so the ceiling this deployment publishes as
2371 : // maxJoinLevel bounds it on both surfaces, not on the query alone.
2372 4 : let cap = crate::bounds::MAX_JOIN_LEVEL;
2373 24 : for bad in [
2374 4 : json!(0),
2375 4 : json!(-1),
2376 4 : json!("2"),
2377 4 : json!(1.5),
2378 4 : json!(cap + 1),
2379 4 : json!(u64::MAX),
2380 4 : ] {
2381 24 : assert!(
2382 24 : norm(&mk(json!({ "joinLevel": bad }))).is_err(),
2383 : "joinLevel {bad}"
2384 : );
2385 : }
2386 4 : assert!(norm(&mk(json!({"joinLevel": 1}))).is_ok());
2387 4 : assert!(norm(&mk(json!({ "joinLevel": cap }))).is_ok(), "at the cap");
2388 8 : for key in ["sysAttrs", "showChanges"] {
2389 8 : assert!(
2390 8 : norm(&mk(json!({ key: "true" }))).is_err(),
2391 : "{key} must be a boolean"
2392 : );
2393 8 : assert!(norm(&mk(json!({ key: true }))).is_ok());
2394 : }
2395 12 : for name in ["id", "type", "scope"] {
2396 12 : assert!(
2397 12 : norm(&mk(json!({"attributes": [name]}))).is_err(),
2398 : "notification.attributes may not name {name}"
2399 : );
2400 : }
2401 4 : assert!(norm(&mk(json!({"attributes": [1]}))).is_err());
2402 4 : assert!(norm(&mk(json!({"attributes": ["a"], "pick": ["b"]}))).is_err());
2403 4 : assert!(norm(&mk(json!({"attributes": ["a"], "omit": ["b"]}))).is_err());
2404 4 : assert!(norm(&mk(json!({"pick": ["a"], "omit": ["a"]}))).is_err());
2405 4 : assert!(norm(&mk(json!({"pick": ["a"], "omit": ["b"]}))).is_ok());
2406 : // attribute names expand per 5.5.7
2407 4 : let n = norm(&mk(json!({"attributes": ["temperature"]}))).expect("valid");
2408 4 : assert_eq!(
2409 4 : n["notification"]["attributes"][0],
2410 : "https://uri.etsi.org/ngsi-ld/default-context/temperature"
2411 : );
2412 4 : }
2413 :
2414 : /// Table 5.2.14.1-1: a `pick` or `omit` member is "a valid attribute
2415 : /// projection language string as per clause 4.21". The notification path
2416 : /// parses the member again at delivery time and drops one it cannot
2417 : /// parse, so a Subscription accepted with an unparseable `omit` delivers
2418 : /// the Attribute the subscriber asked to have removed — the failure is
2419 : /// silent and it is in the leaking direction. The value space belongs at
2420 : /// the door, where 5.8.1.4 raises BadRequestData for a Subscription that
2421 : /// does not validate.
2422 : #[test]
2423 4 : fn clause_5_2_14_pick_and_omit_members_are_projection_language() {
2424 56 : let mk = |extra: Value| {
2425 56 : let mut n = json!({"endpoint": {"uri": "http://localhost:1111/notify"}});
2426 56 : let o = n.as_object_mut().expect("object");
2427 56 : for (k, v) in extra.as_object().expect("object") {
2428 56 : o.insert(k.clone(), v.clone());
2429 56 : }
2430 56 : sub(json!({ "notification": n }))
2431 56 : };
2432 8 : for key in ["pick", "omit"] {
2433 48 : for term in [
2434 8 : json!("street address"), // a space is outside the 4.21 character set
2435 8 : json!("{model"), // unbalanced brace
2436 8 : json!("a,,b"), // empty projection member
2437 8 : json!(""), // empty term
2438 8 : json!("{model}"), // a LinkedEntityTerm with no AttrName
2439 8 : json!(42), // not a String
2440 8 : ] {
2441 48 : assert!(
2442 48 : norm(&mk(json!({ key: [term] }))).is_err(),
2443 : "notification.{key} member {term} is not 4.21 projection language"
2444 : );
2445 : }
2446 8 : assert!(
2447 8 : norm(&mk(json!({ key: ["refDevice{model}", "temperature"] }))).is_ok(),
2448 : "a nested term and a bare term are both valid 4.21 (notification.{key})"
2449 : );
2450 : // 5.8.2.4 replaces the member from a fragment, so the same value
2451 : // space has to hold on that way in: the delivery path cannot tell
2452 : // which operation wrote the Subscription it reads. The fragment
2453 : // carries a complete endpoint deliberately — `notification`
2454 : // without one is refused for the missing endpoint (5.2.14,
2455 : // cardinality 1), which would make this assertion pass while
2456 : // proving nothing about the projection member.
2457 8 : let ctx = antares_jsonld::Loader::new().core();
2458 8 : let fragment = json!({"notification": {
2459 8 : "endpoint": {"uri": "http://localhost:1111/notify"}, key: ["a b"]}});
2460 8 : assert!(
2461 8 : normalize_subscription(fragment.as_object().expect("object"), &ctx, true).is_err(),
2462 : "an update fragment wrote a notification.{key} the 4.21 grammar refuses"
2463 : );
2464 : }
2465 4 : }
2466 :
2467 : /// Table 5.2.13-1 GeoQuery: georel is mandatory and the geoproperty
2468 : /// name expands per 5.5.7.
2469 : #[test]
2470 4 : fn clause_5_2_13_geo_q_contract() {
2471 4 : let ok = json!({
2472 4 : "georel": "near;maxDistance==2000",
2473 4 : "geometry": "Point",
2474 4 : "coordinates": [-8.5, 41.2],
2475 4 : "geoproperty": "location"
2476 : });
2477 4 : let n = norm(&sub(json!({ "geoQ": ok }))).expect("valid");
2478 4 : assert_eq!(
2479 4 : n["geoQ"]["geoproperty"],
2480 : "https://uri.etsi.org/ngsi-ld/location"
2481 : );
2482 12 : for bad in [
2483 4 : json!("near"),
2484 4 : json!({"geometry": "Point", "coordinates": [1, 2]}),
2485 4 : json!({"georel": "sideways", "geometry": "Point", "coordinates": [1, 2]}),
2486 4 : ] {
2487 12 : assert!(
2488 12 : norm(&sub(json!({ "geoQ": bad }))).is_err(),
2489 : "geoQ {bad} must be rejected"
2490 : );
2491 : }
2492 4 : }
2493 :
2494 : /// 5.2.21 TemporalQuery, used by Context Source Registration
2495 : /// Subscriptions (5.11): timerel and timeAt are cardinality 1.
2496 : #[test]
2497 4 : fn clause_5_2_21_temporal_q_contract() {
2498 4 : assert!(norm(&sub(json!({
2499 4 : "temporalQ": {"timerel": "after", "timeAt": "2020-01-01T00:00:00Z"}
2500 4 : })))
2501 4 : .is_ok());
2502 16 : for bad in [
2503 4 : json!("after"),
2504 4 : json!({"timerel": "after"}),
2505 4 : json!({"timerel": "sideways", "timeAt": "2020-01-01T00:00:00Z"}),
2506 4 : json!({"timerel": "after", "timeAt": "yesterday"}),
2507 4 : ] {
2508 16 : assert!(
2509 16 : norm(&sub(json!({ "temporalQ": bad }))).is_err(),
2510 : "temporalQ {bad} must be rejected"
2511 : );
2512 : }
2513 4 : }
2514 :
2515 : // ---------- 5.8.3 / 5.8.4 presentation ----------
2516 :
2517 : /// 5.2.12 Table 5.2.12-2: status is "Provided by the system"; 5.8.2.4
2518 : /// fixes its value space to active | paused | expired. The default
2519 : /// notificationTrigger is surfaced for entity Subscriptions only, and
2520 : /// createdAt/modifiedAt stay behind the sysAttrs gate.
2521 : #[test]
2522 4 : fn clause_5_8_3_presented_subscription_shape() {
2523 4 : let ctx = Loader::new().core();
2524 4 : let base = json!({
2525 4 : "id": "urn:ngsi-ld:Subscription:p1",
2526 4 : "type": "Subscription",
2527 4 : "entities": [{"type": "https://uri.etsi.org/ngsi-ld/default-context/Building"}],
2528 4 : "watchedAttributes": ["https://uri.etsi.org/ngsi-ld/default-context/temperature"],
2529 4 : "notification": {
2530 4 : "endpoint": {"uri": "http://localhost:1111/notify"},
2531 4 : "attributes": ["https://uri.etsi.org/ngsi-ld/default-context/temperature"]
2532 : },
2533 4 : "geoQ": {"georel": "near;maxDistance==1", "geoproperty": "https://uri.etsi.org/ngsi-ld/location"},
2534 4 : "createdAt": "2020-01-01T00:00:00Z",
2535 4 : "modifiedAt": "2020-01-01T00:00:00Z",
2536 : });
2537 4 : let out = present_subscription(&base, &ctx, false, false);
2538 4 : assert_eq!(out["status"], "active");
2539 4 : assert_eq!(out["entities"][0]["type"], "Building");
2540 4 : assert_eq!(out["watchedAttributes"][0], "temperature");
2541 4 : assert_eq!(out["notification"]["attributes"][0], "temperature");
2542 4 : assert_eq!(out["geoQ"]["geoproperty"], "location");
2543 4 : assert!(
2544 4 : out.get("createdAt").is_none() && out.get("modifiedAt").is_none(),
2545 : "sysAttrs are gated (6.3.11): {out}"
2546 : );
2547 4 : assert_eq!(
2548 4 : out["notificationTrigger"],
2549 4 : json!(["attributeCreated", "attributeUpdated"])
2550 : );
2551 4 : let sys = present_subscription(&base, &ctx, true, false);
2552 4 : assert_eq!(sys["createdAt"], "2020-01-01T00:00:00Z");
2553 : // a Context Source Registration Subscription has no such default
2554 4 : let cs = present_subscription(&base, &ctx, false, true);
2555 4 : assert!(cs.get("notificationTrigger").is_none(), "{cs}");
2556 :
2557 4 : let mut paused = base.clone();
2558 4 : paused["isActive"] = json!(false);
2559 4 : assert_eq!(
2560 4 : present_subscription(&paused, &ctx, false, false)["status"],
2561 : "paused"
2562 : );
2563 4 : let mut expired = base.clone();
2564 4 : expired["expiresAt"] = json!("2020-01-01T00:00:00Z");
2565 4 : expired["isActive"] = json!(false);
2566 4 : assert_eq!(
2567 4 : present_subscription(&expired, &ctx, false, false)["status"],
2568 : "expired",
2569 : "expiry wins over paused (5.8.2.4)"
2570 : );
2571 4 : let mut periodic = base.clone();
2572 4 : periodic["timeInterval"] = json!(30);
2573 4 : assert!(
2574 4 : present_subscription(&periodic, &ctx, false, false)
2575 4 : .get("notificationTrigger")
2576 4 : .is_none(),
2577 : "a periodic subscription has no attribute triggers"
2578 : );
2579 4 : }
2580 : }
|