Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! EntityMaps (5.14; resources 6.32, 6.34, 6.35): per-query candidate maps
3 : //! recording which Entities — and which Context Sources — are relevant to an
4 : //! ongoing consumption request (4.5.25, data type 5.2.39).
5 :
6 : use crate::entity_map::{created_response, dt, map_delete, map_get, map_set_expiry, open_map};
7 : use crate::negotiate::*;
8 : use crate::state::AppState;
9 : use antares_model::{NgsiError, TenantId};
10 : use axum::body::Bytes;
11 : use axum::extract::{Path, State};
12 : use axum::http::{HeaderMap, StatusCode};
13 : use axum::response::{IntoResponse, Response};
14 : use serde_json::Value;
15 : use std::collections::HashMap;
16 :
17 : // ---------- storage (5.14.1.1: "internal storage, or memory") ----------
18 :
19 : // ---------- 5.14.1 / 5.14.2 / 5.14.3: /entityMaps/{id} (6.32) ----------
20 :
21 : /// The map under `id`, if it is this subject's.
22 : ///
23 : /// 5.14.1.4 and 5.14.3.4 answer an id "that does not correspond to any
24 : /// existing EntityMap" with ResourceNotFound, and a map built for another
25 : /// subject is exactly that from here: its `entityMap` member IS the set of
26 : /// Entity ids a query matched, so serving one to whoever asks would hand
27 : /// over the ids a narrowing was there to withhold, and deleting one would
28 : /// end another subject's transaction (ADR-0020).
29 : ///
30 : /// 5.5.14's one allowance for other components — they "shall only be allowed
31 : /// to update the expiry timestamp" — is why 5.14.2 Update is NOT routed
32 : /// through here: extending a lifetime is what the clause lets a stranger do.
33 224 : async fn mine(st: &AppState, tenant: &TenantId, headers: &HeaderMap, id: &str) -> ApiResult<Value> {
34 224 : map_get(st, tenant, id)
35 224 : .await?
36 220 : .filter(|doc| crate::policy::belongs_to(doc, &crate::policy::subject_of(tenant, headers)))
37 220 : .ok_or_else(|| NgsiError::ResourceNotFound(format!("EntityMap {id} not found")).into())
38 224 : }
39 :
40 : /// 5.14.1.4 Retrieve EntityMap: invalid-URI id → 400 BadRequestData, unknown
41 : /// id → 404 ResourceNotFound, else the 5.2.39 JSON-LD object.
42 106 : pub async fn retrieve_entity_map(
43 106 : State(st): State<AppState>,
44 106 : Path(id): Path<String>,
45 106 : CleanParams(params): CleanParams,
46 106 : headers: HeaderMap,
47 106 : ) -> Response {
48 106 : let go = async {
49 106 : let tenant = open_map(¶ms, &headers, &id)?;
50 100 : gate!(st, &tenant, &headers, "5.14.1", ids: &[&id]).await?;
51 100 : let accept = parse_accept(&headers)?;
52 100 : let ctx = request_context(&st.loader, &headers).await?;
53 100 : let mut doc = mine(&st, &tenant, &headers, &id).await?;
54 : // 5.2.39 defines no member for whose map this is
55 62 : crate::policy::strip_internal(&mut doc);
56 62 : Ok::<_, ApiError>(respond(StatusCode::OK, doc, &ctx, accept, &tenant))
57 106 : };
58 106 : go.await.unwrap_or_else(|e| e.into_response())
59 106 : }
60 :
61 : /// 5.14.2.4 Update EntityMap: partial update of the target EntityMap;
62 : /// output-only members (entityMap, linkedMaps — 5.2.39) are ignored, and per
63 : /// 5.5.14 other components may only update the expiry timestamp.
64 48 : pub async fn update_entity_map(
65 48 : State(st): State<AppState>,
66 48 : Path(id): Path<String>,
67 48 : CleanParams(params): CleanParams,
68 48 : headers: HeaderMap,
69 48 : body: Bytes,
70 48 : ) -> Response {
71 48 : let go = async {
72 48 : let tenant = open_map(¶ms, &headers, &id)?;
73 48 : gate!(st, &tenant, &headers, "5.14.2", ids: &[&id]).await?;
74 48 : let frag: Value = serde_json::from_slice(&body)
75 48 : .map_err(|e| NgsiError::InvalidRequest(format!("body is not valid JSON: {e}")))?;
76 48 : let obj = frag.as_object().ok_or_else(|| {
77 0 : NgsiError::BadRequestData("EntityMap fragment must be a JSON object".into())
78 0 : })?;
79 48 : let gone = || NgsiError::ResourceNotFound(format!("EntityMap {id} not found"));
80 48 : map_get(&st, &tenant, &id).await?.ok_or_else(gone)?;
81 22 : if let Some(e) = obj.get("expiresAt") {
82 22 : let s = e.as_str().filter(|s| dt(s).is_some()).ok_or_else(|| {
83 6 : NgsiError::BadRequestData("expiresAt must be a DateTime (4.6.3)".into())
84 6 : })?;
85 : // only the member this update names, onto the map as stored now
86 16 : if !map_set_expiry(&st, &tenant, &id, s).await? {
87 0 : return Err(gone().into());
88 16 : }
89 0 : }
90 16 : Ok::<_, ApiError>(no_content(&tenant))
91 48 : };
92 48 : go.await.unwrap_or_else(|e| e.into_response())
93 48 : }
94 :
95 : /// 5.14.3.4 Delete EntityMap: invalid-URI id → 400, unknown id → 404, else
96 : /// the EntityMap is removed from storage/memory (204).
97 130 : pub async fn delete_entity_map(
98 130 : State(st): State<AppState>,
99 130 : Path(id): Path<String>,
100 130 : CleanParams(params): CleanParams,
101 130 : headers: HeaderMap,
102 130 : ) -> Response {
103 130 : let go = async {
104 130 : let tenant = open_map(¶ms, &headers, &id)?;
105 124 : gate!(st, &tenant, &headers, "5.14.3", ids: &[&id]).await?;
106 124 : mine(&st, &tenant, &headers, &id).await?;
107 104 : if !map_delete(&st, &tenant, &id).await? {
108 0 : return Err(NgsiError::ResourceNotFound(format!("EntityMap {id} not found")).into());
109 104 : }
110 104 : Ok::<_, ApiError>(no_content(&tenant))
111 130 : };
112 130 : go.await.unwrap_or_else(|e| e.into_response())
113 130 : }
114 :
115 : // ---------- 5.14.4: Create EntityMap for Query Entities (6.34) ----------
116 :
117 212 : fn allowed_create_params() -> Vec<&'static str> {
118 212 : let mut v = crate::negotiate::QUERY_PARAMS.to_vec();
119 212 : v.extend(["entityMapLifetime", "splitEntities"]);
120 212 : v
121 212 : }
122 :
123 : /// The 6.35.3.1/6.35.3.2 parameters: the query set above plus the temporal
124 : /// query's own (5.7.4). Checked in the handler because a split-reduced
125 : /// temporal query rebuilds its parameters from a fixed list and would drop an
126 : /// unknown one before 5.7.4 ever sees it (6.3.20).
127 50 : fn allowed_temporal_create_params() -> Vec<&'static str> {
128 50 : let mut v = allowed_create_params();
129 50 : v.extend([
130 50 : "timerel",
131 50 : "timeAt",
132 50 : "endTimeAt",
133 50 : "timeproperty",
134 50 : "aggrMethods",
135 50 : "aggrPeriodDuration",
136 50 : "lastN",
137 50 : "firstN",
138 50 : "offsetN",
139 50 : ]);
140 50 : v
141 50 : }
142 :
143 : /// 5.14.4 / 5.14.5 Create EntityMap. The four resource methods are one
144 : /// operation: 6.34.3.2 and 6.35.3.2 carry the 5.2.23 Query object where
145 : /// 6.34.3.1 and 6.35.3.1 carry query parameters, and the temporal pair adds
146 : /// the 5.7.4 temporal query to the same pipeline.
147 212 : async fn create_map(
148 212 : st: AppState,
149 212 : params: HashMap<String, String>,
150 212 : headers: HeaderMap,
151 212 : body: Option<Bytes>,
152 212 : temporal: bool,
153 212 : ) -> Response {
154 212 : let go = async {
155 212 : let tenant = tenant_from(&headers)?;
156 212 : let allowed = if temporal {
157 50 : allowed_temporal_create_params()
158 : } else {
159 162 : allowed_create_params()
160 : };
161 : // The Query object is folded into the parameters BEFORE 6.3.20 runs:
162 : // a member it carries is a parameter of this request.
163 212 : let vp = match &body {
164 24 : Some(b) => query_body_params(b, ¶ms, temporal)?,
165 188 : None => params,
166 : };
167 : // ADR-0020: the engine is shown the query the map pages
168 210 : let named = crate::entities::ReadMembers::of(&st, &headers, &vp).await;
169 210 : let filter = gate!(
170 : st, &tenant, &headers, if temporal { "5.14.5" } else { "5.14.4" },
171 : q: named.q.as_ref(),
172 : scope_q: vp.get("scopeQ").map(String::as_str),
173 : types: &named.types,
174 : attrs: &named.attrs,
175 : geo: named.geo.as_ref(),
176 : )
177 210 : .await?;
178 204 : check_params(&vp, &allowed)?;
179 196 : let accept = parse_accept(&headers)?;
180 196 : let ctx = request_context(&st.loader, &headers).await?;
181 : // 5.14.4/5.14.5: the map is the id set of the query it was built
182 : // from, so it is built from the NARROWED query — an id the subject
183 : // may not see never enters the map it will page through.
184 196 : let doc = if temporal {
185 40 : crate::temporal::build_temporal_map(&st, &tenant, &headers, &ctx, &vp, &filter).await?
186 : } else {
187 156 : crate::entities::build_query_map(&st, &tenant, &headers, &ctx, &vp, &filter).await?
188 : };
189 170 : let mut resp = created_response(doc, &ctx, accept, &tenant);
190 170 : filter.mark(&st.host_alias, resp.headers_mut());
191 170 : Ok::<_, ApiError>(resp)
192 212 : };
193 212 : go.await.unwrap_or_else(|e| e.into_response())
194 212 : }
195 :
196 : /// 5.2.23: a Query object stands in for the query parameters. Its members are
197 : /// folded into them, so one rule serves both forms of every Create EntityMap.
198 24 : fn query_body_params(
199 24 : body: &Bytes,
200 24 : params: &HashMap<String, String>,
201 24 : temporal: bool,
202 24 : ) -> ApiResult<HashMap<String, String>> {
203 24 : let q: Value = serde_json::from_slice(body)
204 24 : .map_err(|e| NgsiError::InvalidRequest(format!("body is not valid JSON: {e}")))?;
205 24 : if q.get("type").and_then(Value::as_str) != Some("Query") {
206 2 : return Err(NgsiError::BadRequestData("body type must be Query".into()).into());
207 22 : }
208 22 : let qo = q
209 22 : .as_object()
210 22 : .ok_or_else(|| NgsiError::BadRequestData("query body must be an object".into()))?;
211 22 : let mut vp: HashMap<String, String> = params.clone();
212 22 : crate::paging::query_doc_params(qo, temporal, &mut vp)?;
213 22 : Ok(vp)
214 24 : }
215 :
216 : /// GET /entityMaps — Create EntityMap for Query Entities (6.34.3.1).
217 148 : pub async fn create_entity_map(
218 148 : State(st): State<AppState>,
219 148 : CleanParams(params): CleanParams,
220 148 : headers: HeaderMap,
221 148 : ) -> Response {
222 148 : create_map(st, params, headers, None, false).await
223 148 : }
224 :
225 : /// POST /entityMaps — the 5.2.23 Query-object form (6.34.3.2).
226 14 : pub async fn create_entity_map_post(
227 14 : State(st): State<AppState>,
228 14 : CleanParams(params): CleanParams,
229 14 : headers: HeaderMap,
230 14 : body: Bytes,
231 14 : ) -> Response {
232 14 : create_map(st, params, headers, Some(body), false).await
233 14 : }
234 :
235 : // ------ 5.14.5: Create EntityMap for Query Temporal Evolution (6.35) ------
236 :
237 : /// GET /temporal/entityMaps — Create EntityMap for Query Temporal Evolution
238 : /// of Entities (6.35.3.1).
239 40 : pub async fn create_temporal_entity_map(
240 40 : State(st): State<AppState>,
241 40 : CleanParams(params): CleanParams,
242 40 : headers: HeaderMap,
243 40 : ) -> Response {
244 40 : create_map(st, params, headers, None, true).await
245 40 : }
246 :
247 : /// POST /temporal/entityMaps — the Query-object form (6.35.3.2).
248 10 : pub async fn create_temporal_entity_map_post(
249 10 : State(st): State<AppState>,
250 10 : CleanParams(params): CleanParams,
251 10 : headers: HeaderMap,
252 10 : body: Bytes,
253 10 : ) -> Response {
254 10 : create_map(st, params, headers, Some(body), true).await
255 10 : }
256 :
257 : #[cfg(test)]
258 : mod tests {
259 : use super::*;
260 : use crate::entities::build_query_map;
261 : use crate::entity_map::map_id_check;
262 : use antares_model::TenantId;
263 : use antares_store::Kind;
264 : use serde_json::json;
265 : use std::collections::HashMap;
266 :
267 16 : fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
268 16 : pairs
269 16 : .iter()
270 40 : .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
271 16 : .collect()
272 16 : }
273 :
274 : /// 5.14.4.4 + 5.5.9.3: the EntityMap holds the CANDIDATE identifiers a
275 : /// later paginated request re-checks, so building one must not depend on
276 : /// materializing the tenant's whole match set. The candidate set is
277 : /// bounded by the broker ceiling, exactly as the temporal twin is.
278 : #[tokio::test]
279 4 : async fn clause_5_14_4_query_map_candidate_set_is_bounded() {
280 4 : let mut st = AppState::new("antares-em-bound".into());
281 4 : st.max_limit = 8;
282 4 : let t = TenantId::default();
283 160 : for i in 0..40 {
284 160 : let id = format!("urn:ngsi-ld:Vehicle:{i:03}");
285 160 : st.store
286 160 : .create(
287 160 : &t,
288 160 : Kind::Entity,
289 160 : &id,
290 160 : json!({
291 160 : "id": id,
292 160 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
293 160 : }),
294 160 : )
295 160 : .await
296 160 : .expect("seed");
297 : }
298 4 : let doc = build_query_map(
299 4 : &st,
300 4 : &t,
301 4 : &HeaderMap::new(),
302 4 : &antares_jsonld::Context::default(),
303 4 : ¶ms(&[("type", "Vehicle"), ("local", "true")]),
304 4 : &crate::policy::Filter::default(),
305 4 : )
306 4 : .await
307 4 : .expect("EntityMap");
308 4 : let emap = doc["entityMap"].as_object().expect("entityMap object");
309 4 : assert!(!emap.is_empty(), "the matching entities are candidates");
310 4 : assert!(
311 4 : emap.len() <= st.max_limit,
312 : "the candidate set is unbounded: {} entries for a ceiling of {}",
313 0 : emap.len(),
314 0 : st.max_limit
315 : );
316 : // a query that matches nothing must not pick up unrelated entities
317 4 : let none = build_query_map(
318 4 : &st,
319 4 : &t,
320 4 : &HeaderMap::new(),
321 4 : &antares_jsonld::Context::default(),
322 4 : ¶ms(&[("type", "Ship"), ("local", "true")]),
323 4 : &crate::policy::Filter::default(),
324 4 : )
325 4 : .await
326 4 : .expect("EntityMap");
327 4 : assert_eq!(none["entityMap"], json!({}));
328 4 : }
329 :
330 : /// 6.3.20: an unknown query parameter is InvalidRequest. The temporal
331 : /// EntityMap resources take the 6.35.3.1/6.35.3.2 parameters and nothing
332 : /// else — including when splitEntities=true reduces the query, where the
333 : /// unknown parameter would otherwise be dropped before anything sees it.
334 : #[tokio::test]
335 4 : async fn clause_6_3_20_temporal_map_rejects_unknown_parameters() {
336 4 : let st = AppState::new("antares-em-params".into());
337 4 : let p = params(&[
338 4 : ("type", "Vehicle"),
339 4 : ("timerel", "before"),
340 4 : ("timeAt", "2020-01-01T00:00:00Z"),
341 4 : ("splitEntities", "true"),
342 4 : ("bogus", "1"),
343 4 : ]);
344 4 : let resp =
345 4 : create_temporal_entity_map(State(st.clone()), CleanParams(p), HeaderMap::new()).await;
346 4 : assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "GET");
347 4 : let body = Bytes::from_static(
348 4 : br#"{"type":"Query","timerel":"before","timeAt":"2020-01-01T00:00:00Z"}"#,
349 : );
350 4 : let resp = create_temporal_entity_map_post(
351 4 : State(st.clone()),
352 4 : CleanParams(params(&[("bogus", "1")])),
353 4 : HeaderMap::new(),
354 4 : body,
355 4 : )
356 4 : .await;
357 4 : assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "POST");
358 4 : }
359 :
360 : /// 5.14.1.4 / 5.14.3.4: "If the EntityMap id is not present or it is not
361 : /// a valid URI, then an error of type BadRequestData shall be raised."
362 : #[test]
363 4 : fn clause_5_14_1_map_id_must_be_a_uri() {
364 4 : assert!(map_id_check("urn:ngsi-ld:entitymap:1").is_ok());
365 24 : for bad in [
366 4 : "",
367 4 : "entitymap",
368 4 : "urn:ngsi-ld:entity map:1",
369 4 : "urn:ngsi-ld:entitymap:1\r\nX: y",
370 4 : ":nostem",
371 4 : "urn:",
372 4 : ] {
373 24 : match map_id_check(bad) {
374 24 : Err(NgsiError::BadRequestData(_)) => {}
375 0 : other => panic!("{bad:?} must be BadRequestData, got {other:?}"),
376 : }
377 : }
378 4 : }
379 : }
|