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