Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Subscription matching (CIM 009 5.8.6) against one in-memory entity: the
3 : //! predicates the broker's notification path applies, usable without a
4 : //! broker — a gateway or an edge component can answer "would this change
5 : //! notify subscription X" with the broker's own semantics.
6 : //!
7 : //! Inputs are the stored (internal, expanded) forms: the subscription
8 : //! document as created (5.2.12, selector entity types expanded) and the
9 : //! entity document as the broker stores it (expanded attribute IRIs,
10 : //! `type` as an array). Matching is
11 : //! index-shaped in the broker (candidate lookup by (tenant, type) /
12 : //! (tenant, watched attribute)); every predicate here evaluates one
13 : //! candidate self-contained.
14 : #![cfg_attr(not(test), warn(clippy::expect_used))]
15 : #![deny(missing_docs)]
16 :
17 : use antares_jsonld::Context;
18 : use antares_model::dt_key;
19 : use antares_ql::eval::EntityLookup;
20 : use antares_ql::geo::GeoQuery;
21 : use antares_ql::type_selection_matches;
22 : use serde_json::{Map, Value};
23 :
24 1656 : fn sub_str<'a>(sub: &'a Value, key: &str) -> Option<&'a str> {
25 1656 : sub.get(key).and_then(Value::as_str)
26 1656 : }
27 :
28 32 : fn now_iso() -> String {
29 32 : chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
30 32 : }
31 :
32 : /// Every predicate at once: active, entities selector (5.2.33), `q` /
33 : /// `scopeQ` / `geoQ` conditions, throttling window. `lookup` resolves 4.9
34 : /// linked-entity terms (`attr{…}`); `&|_| None` when no store is at hand.
35 16 : pub fn would_notify(sub: &Value, doc: &Value, ctx: &Context, lookup: EntityLookup) -> bool {
36 16 : is_active(sub)
37 12 : && selector_match(sub, doc, ctx)
38 8 : && conditions_match(sub, doc, ctx, lookup)
39 4 : && !throttled(sub)
40 16 : }
41 :
42 : /// 5.8.1.4 / 5.2.12: `isActive` false or an `expiresAt` in the past means
43 : /// the subscription notifies nothing.
44 1521 : pub fn is_active(sub: &Value) -> bool {
45 1521 : if sub.get("isActive") == Some(&Value::Bool(false)) {
46 2 : return false;
47 1519 : }
48 : // 5.8.1.4 auto-expiry; dt_key so fraction spellings cannot misorder
49 : // around the boundary second (4.11)
50 1519 : !sub.get("expiresAt")
51 1519 : .and_then(Value::as_str)
52 1519 : .is_some_and(|e| dt_key(e) < dt_key(&now_iso()))
53 1521 : }
54 :
55 : /// entities selector (5.2.33) against an internal entity doc.
56 970 : pub fn selector_match(sub: &Value, doc: &Value, ctx: &Context) -> bool {
57 970 : let Some(sel) = sub.get("entities").and_then(Value::as_array) else {
58 18 : return true; // watchedAttributes-only subscription
59 : };
60 952 : let types: Vec<&str> = doc
61 952 : .get("type")
62 952 : .and_then(Value::as_array)
63 952 : .map(|a| a.iter().filter_map(Value::as_str).collect())
64 952 : .unwrap_or_default();
65 952 : let id = doc.get("id").and_then(Value::as_str).unwrap_or("");
66 960 : sel.iter().any(|e| {
67 960 : let t_ok = e.get("type").and_then(Value::as_str).is_none_or(|t| {
68 : // Table 5.2.33-1: "*" indicates "a request for all Entities" —
69 : // neither a term nor a 4.17 expression, but the absence of a type
70 : // predicate, exactly as `type=*` is on the query side.
71 902 : if t == "*" {
72 14 : true
73 888 : } else if t.contains(['|', ',', ';', '(']) {
74 16 : type_selection_matches(t, &types, ctx)
75 : } else {
76 872 : types.contains(&t)
77 : }
78 902 : });
79 : // Table 5.2.33-1: id is String or String[]; "id takes precedence
80 : // over idPattern" — a selector carrying id ignores its idPattern.
81 960 : let id_ok = match e.get("id") {
82 856 : None => true,
83 82 : Some(Value::String(i)) => i == id,
84 30 : Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).any(|i| i == id),
85 0 : Some(_) => false,
86 : };
87 960 : let pat_ok = e.get("id").is_some()
88 856 : || e.get("idPattern").and_then(Value::as_str).is_none_or(|p| {
89 42 : antares_ql::regex::compile(p).is_ok_and(|re| re.find(id).is_some())
90 42 : });
91 960 : t_ok && id_ok && pat_ok
92 960 : })
93 970 : }
94 :
95 : /// A subscription's `geoQ` (Table 5.2.13-1) in the parameter shape the 4.10
96 : /// GeoQuery parser takes. The one reading of that table: every validator,
97 : /// matcher and forwarder in the broker turns a `geoQ` object into query
98 : /// parameters here, so `coordinates` is spelled the same way on all of them.
99 34 : pub fn geo_params(g: &Map<String, Value>) -> std::collections::HashMap<String, String> {
100 34 : let mut params: std::collections::HashMap<String, String> = Default::default();
101 102 : for k in ["georel", "geometry", "geoproperty"] {
102 102 : if let Some(s) = g.get(k).and_then(Value::as_str) {
103 68 : params.insert(k.into(), s.to_owned());
104 68 : }
105 : }
106 34 : if let Some(c) = g.get("coordinates") {
107 34 : params.insert(
108 34 : "coordinates".into(),
109 34 : match c {
110 6 : Value::String(s) => s.clone(),
111 28 : other => other.to_string(),
112 : },
113 : );
114 0 : }
115 34 : params
116 34 : }
117 :
118 : /// 5.8.6 notification matching: the subscription's q (4.9), scopeQ (4.19)
119 : /// and geoQ (4.10) conditions against an internal entity doc; all present
120 : /// conditions must hold.
121 778 : pub fn conditions_match(sub: &Value, doc: &Value, ctx: &Context, lookup: EntityLookup) -> bool {
122 778 : if let Some(q) = sub_str(sub, "q") {
123 : // q values in subscription bodies may be percent-encoded (4.9, 046_05)
124 70 : let q = antares_ql::percent_decode(q.as_bytes());
125 : // parsed once per distinct q text, not once per event per candidate
126 70 : match antares_ql::regex::q_node(&q) {
127 70 : Some(node) => {
128 : // Table 5.2.12-1 gives the Subscription the `expandValues`
129 : // and `jsonKeys` pair 4.9 gives the query, and this
130 : // condition IS that query: a term the first list names is
131 : // compared after JSON-LD type coercion against the @context
132 : // the Subscription was supplied with, less the names the
133 : // second declares uninterpretable.
134 : // ponytail: the coercion rebuilds the tree, so it costs one
135 : // clone per event for a Subscription that names the list and
136 : // nothing at all for one that does not; cache the coerced
137 : // tree beside the parsed one if a deployment measures it.
138 : let coerced;
139 70 : let node: &antares_ql::QNode = match antares_ql::eval::expansion_list(
140 70 : sub_str(sub, "expandValues"),
141 70 : sub_str(sub, "jsonKeys"),
142 70 : ) {
143 64 : None => &node,
144 6 : Some(list) => {
145 6 : coerced = antares_ql::eval::apply_expand_values(
146 6 : (*node).clone(),
147 6 : Some(&list),
148 6 : ctx,
149 : );
150 6 : &coerced
151 : }
152 : };
153 70 : if !antares_ql::eval::eval_q(node, doc, ctx, lookup) {
154 40 : return false;
155 30 : }
156 : }
157 0 : None => return false,
158 : }
159 708 : }
160 738 : if let Some(sq) = sub_str(sub, "scopeQ") {
161 4 : if !antares_ql::scope::scope_matches(sq, doc) {
162 2 : return false;
163 2 : }
164 734 : }
165 736 : if let Some(g) = sub.get("geoQ").and_then(Value::as_object) {
166 : // the geometry parse is shared per distinct geoQ member; the
167 : // serialization of the stored member is the key
168 10 : let key = serde_json::to_string(g).unwrap_or_default();
169 10 : let gq = antares_ql::regex::geo_query(&key, || {
170 2 : GeoQuery::from_params(&geo_params(g)).ok().flatten()
171 2 : });
172 10 : match gq {
173 10 : Some(gq) => {
174 10 : if !gq.matches(doc, ctx) {
175 2 : return false;
176 8 : }
177 : }
178 0 : None => return false,
179 : }
180 726 : }
181 734 : true
182 778 : }
183 :
184 : /// 5.2.12 `throttling`: true while the last notification is younger than the
185 : /// throttling window, so no further notification is due yet.
186 408 : pub fn throttled(sub: &Value) -> bool {
187 408 : let Some(secs) = sub.get("throttling").and_then(Value::as_f64) else {
188 394 : return false;
189 : };
190 14 : let Some(last) = sub
191 14 : .get("notification")
192 14 : .and_then(|n| n.get("lastNotification"))
193 14 : .and_then(Value::as_str)
194 : else {
195 2 : return false;
196 : };
197 12 : chrono::DateTime::parse_from_rfc3339(last).is_ok_and(|t| {
198 12 : (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_milliseconds()
199 12 : < (secs * 1000.0) as i64
200 12 : })
201 408 : }
202 :
203 : #[cfg(test)]
204 : mod tests {
205 : use super::*;
206 : use serde_json::json;
207 : use std::sync::Arc;
208 :
209 : const DC: &str = "https://uri.etsi.org/ngsi-ld/default-context";
210 :
211 : /// The core context once per process, with no loader behind it: the
212 : /// interpreter-run of these tests (Miri) must not pay for a cache and an
213 : /// HTTP client it never uses.
214 48 : fn ctx() -> Arc<Context> {
215 : static CORE: std::sync::OnceLock<Arc<Context>> = std::sync::OnceLock::new();
216 48 : CORE.get_or_init(|| Arc::new(antares_jsonld::core_context()))
217 48 : .clone()
218 48 : }
219 :
220 : /// A stored entity in its internal form: the broker's own expansion of
221 : /// the API payload (expanded IRIs, `type` as an array, instance arrays).
222 26 : fn expand(doc: Value) -> Value {
223 26 : antares_jsonld::expand_entity(
224 26 : doc.as_object().expect("object"),
225 26 : &ctx(),
226 26 : antares_jsonld::ExpandOpts::default(),
227 : )
228 26 : .expect("a valid entity")
229 26 : }
230 :
231 22 : fn vehicle(speed: f64, lon: f64) -> Value {
232 22 : expand(json!({
233 22 : "id": "urn:ngsi-ld:Vehicle:A1",
234 22 : "type": "Vehicle",
235 22 : "speed": {"type": "Property", "value": speed},
236 22 : "driver": {"type": "Relationship", "object": "urn:ngsi-ld:Person:P1"},
237 22 : "location": {"type": "GeoProperty",
238 22 : "value": {"type": "Point", "coordinates": [lon, 40.4]}}
239 : }))
240 22 : }
241 :
242 : /// A subscription as the broker stores it: entity types in the selector
243 : /// are already expanded (5.2.33), `q`/`geoQ` as sent.
244 22 : fn sub() -> Value {
245 22 : json!({
246 22 : "id": "urn:ngsi-ld:Subscription:1",
247 22 : "type": "Subscription",
248 22 : "entities": [{"type": format!("{DC}/Vehicle"), "idPattern": "^urn:ngsi-ld:Vehicle:.*"}],
249 22 : "q": "speed>25",
250 22 : "geoQ": {"georel": "near;maxDistance==2000", "geometry": "Point",
251 22 : "coordinates": "[-3.7,40.4]"},
252 22 : "notification": {"endpoint": {"uri": "http://x/n"}}
253 : })
254 22 : }
255 :
256 : /// Table 5.2.12-1 gives a Subscription the same `expandValues` and
257 : /// `jsonKeys` pair 4.9 gives a query, and the notification condition IS
258 : /// that query: a term whose Attribute `expandValues` names is compared
259 : /// after JSON-LD type coercion, and a name `jsonKeys` carries as well is
260 : /// left uninterpreted (6.4.3.2, the broker's precedence).
261 : #[test]
262 2 : fn clause_5_2_12_expand_values_coerces_the_condition_value() {
263 2 : let c = ctx();
264 2 : let none = |_: &str| None;
265 2 : let doc = expand(json!({
266 2 : "id": "urn:ngsi-ld:Vehicle:A1",
267 2 : "type": "Vehicle",
268 2 : "category": {"type": "Property", "value": format!("{DC}/Camping")}
269 : }));
270 2 : let mut s = sub();
271 2 : s.as_object_mut().expect("object").remove("geoQ");
272 2 : s["q"] = json!("category==\"Camping\"");
273 2 : assert!(
274 2 : !conditions_match(&s, &doc, &c, &none),
275 : "the literal cannot match the expanded value on its own"
276 : );
277 2 : s["expandValues"] = json!("category");
278 2 : assert!(
279 2 : conditions_match(&s, &doc, &c, &none),
280 : "expandValues coerces the term value"
281 : );
282 2 : s["jsonKeys"] = json!("category");
283 2 : assert!(
284 2 : !conditions_match(&s, &doc, &c, &none),
285 : "a name in both lists is left uninterpreted"
286 : );
287 2 : }
288 :
289 : /// 5.8.6: every predicate holds → the change would notify.
290 : #[test]
291 2 : fn matching_change_notifies() {
292 2 : assert!(would_notify(&sub(), &vehicle(30.0, -3.7), &ctx(), &|_| {
293 0 : None
294 0 : }));
295 2 : }
296 :
297 : /// Each predicate alone refuses: q (4.9), geoQ (4.10), selector type,
298 : /// idPattern (5.2.33), `isActive`, `expiresAt` (5.8.1.4), throttling.
299 : #[test]
300 2 : fn each_failing_predicate_refuses() {
301 2 : let c = ctx();
302 2 : let none = |_: &str| None;
303 2 : assert!(!would_notify(&sub(), &vehicle(20.0, -3.7), &c, &none), "q");
304 2 : assert!(
305 2 : !would_notify(&sub(), &vehicle(30.0, -4.7), &c, &none),
306 : "geoQ"
307 : );
308 2 : let mut s = sub();
309 2 : s["entities"][0]["type"] = json!(format!("{DC}/Bicycle"));
310 2 : assert!(!would_notify(&s, &vehicle(30.0, -3.7), &c, &none), "type");
311 2 : let mut s = sub();
312 2 : s["entities"][0]["idPattern"] = json!("^urn:ngsi-ld:Bicycle:.*");
313 2 : assert!(
314 2 : !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
315 : "idPattern"
316 : );
317 2 : let mut s = sub();
318 2 : s["isActive"] = json!(false);
319 2 : assert!(
320 2 : !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
321 : "isActive"
322 : );
323 2 : let mut s = sub();
324 2 : s["expiresAt"] = json!("2000-01-01T00:00:00Z");
325 2 : assert!(
326 2 : !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
327 : "expiresAt"
328 : );
329 2 : let mut s = sub();
330 2 : s["throttling"] = json!(3600);
331 2 : s["notification"]["lastNotification"] = json!(now_iso());
332 2 : assert!(
333 2 : !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
334 : "throttling"
335 : );
336 2 : assert!(throttled(&s));
337 2 : s["notification"]["lastNotification"] = json!("2000-01-01T00:00:00Z");
338 2 : assert!(
339 2 : !throttled(&s),
340 : "an old lastNotification is outside the window"
341 : );
342 2 : }
343 :
344 : /// 5.2.33 Table 5.2.33-1: `id` takes precedence over `idPattern`, and a
345 : /// selector without `type` matches every type.
346 : #[test]
347 2 : fn selector_id_precedence_and_typeless_selector() {
348 2 : let c = ctx();
349 2 : let doc = vehicle(30.0, -3.7);
350 2 : let s = json!({"entities": [{"id": "urn:ngsi-ld:Vehicle:A1", "idPattern": "^nothing$"}]});
351 2 : assert!(selector_match(&s, &doc, &c));
352 2 : let s = json!({"entities": [{"id": ["urn:ngsi-ld:Vehicle:B2"], "type": format!("{DC}/Vehicle")}]});
353 2 : assert!(!selector_match(&s, &doc, &c));
354 2 : let s = json!({"watchedAttributes": ["speed"]});
355 2 : assert!(
356 2 : selector_match(&s, &doc, &c),
357 : "watchedAttributes-only subscription"
358 : );
359 2 : }
360 :
361 : /// 4.9 EXAMPLE 13/14: a linked-entity term (`driver{name}`) resolves the
362 : /// Relationship object through `lookup`; without a store it cannot match.
363 : #[test]
364 2 : fn linked_entity_term_uses_the_lookup() {
365 2 : let c = ctx();
366 2 : let mut s = sub();
367 2 : s["q"] = json!("driver{name}==\"Ann\"");
368 2 : let doc = vehicle(30.0, -3.7);
369 2 : let store = |uri: &str| {
370 2 : (uri == "urn:ngsi-ld:Person:P1").then(|| {
371 2 : expand(json!({"id": uri, "type": "Person",
372 2 : "name": {"type": "Property", "value": "Ann"}}))
373 2 : })
374 2 : };
375 2 : assert!(conditions_match(&s, &doc, &c, &store));
376 2 : assert!(
377 2 : !conditions_match(&s, &doc, &c, &|_| None),
378 : "no store, no match"
379 : );
380 2 : }
381 :
382 : /// 4.19: `scopeQ` is a condition like `q`; a percent-encoded `q` (as a
383 : /// subscription body may carry it) decodes before evaluation.
384 : #[test]
385 2 : fn scope_q_and_percent_encoded_q() {
386 2 : let c = ctx();
387 2 : let mut doc = vehicle(30.0, -3.7);
388 2 : doc["scope"] = json!(["/Madrid/Centre"]);
389 2 : let mut s = sub();
390 2 : s["scopeQ"] = json!("/Madrid/#");
391 2 : s["q"] = json!("speed%3E25");
392 2 : assert!(conditions_match(&s, &doc, &c, &|_| None));
393 2 : s["scopeQ"] = json!("/Paris/#");
394 2 : assert!(!conditions_match(&s, &doc, &c, &|_| None));
395 2 : }
396 :
397 : /// Table 5.2.33-1 `type`: "A valid type selection string as per clause
398 : /// 4.17. To indicate a request for all Entities (with implied local
399 : /// scope), \"*\" is also allowed as a value." 5.2.33 scopes EntitySelector
400 : /// to what is "queried or subscribed to", so a Subscription carrying it
401 : /// selects every Entity Type — it is not a 4.17 expression and not a term
402 : /// to expand, but the absence of a type predicate.
403 : #[test]
404 2 : fn a_star_selector_type_selects_every_entity_type() {
405 2 : let sub = json!({"entities": [{"type": "*"}]});
406 6 : for doc in [
407 2 : json!({"id": "urn:ngsi-ld:Vehicle:1", "type": [format!("{DC}/Vehicle")]}),
408 2 : json!({"id": "urn:ngsi-ld:Parking:1", "type": [format!("{DC}/Parking")]}),
409 2 : json!({"id": "urn:ngsi-ld:Odd:1", "type": ["urn:example:Odd"]}),
410 2 : ] {
411 6 : assert!(
412 6 : selector_match(&sub, &doc, &ctx()),
413 : "a \"*\" selector must match every type: {doc}"
414 : );
415 : }
416 : // "*" is the type predicate only — id and idPattern still narrow.
417 2 : let pinned = json!({"entities": [{"type": "*", "id": "urn:ngsi-ld:Vehicle:1"}]});
418 2 : assert!(selector_match(
419 2 : &pinned,
420 2 : &json!({"id": "urn:ngsi-ld:Vehicle:1", "type": [format!("{DC}/Vehicle")]}),
421 2 : &ctx()
422 : ));
423 2 : assert!(
424 2 : !selector_match(
425 2 : &pinned,
426 2 : &json!({"id": "urn:ngsi-ld:Vehicle:2", "type": [format!("{DC}/Vehicle")]}),
427 2 : &ctx()
428 2 : ),
429 : "a \"*\" type does not widen an id that was given"
430 : );
431 2 : }
432 : }
|