LCOV - code coverage report
Current view: top level - antares-api/src - entity_maps.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 97.6 % 251 245
Test Date: 2026-09-21 10:31:06 Functions: 50.9 % 159 81

            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(&params, &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(&params, &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(&params, &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, &params, 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 :             &params(&[("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 :             &params(&[("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              : }
        

Generated by: LCOV version 2.0-1