LCOV - code coverage report
Current view: top level - antares-api/src - entity_maps.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 96.9 % 255 247
Test Date: 2026-11-02 13:21:27 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_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(&params, &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(&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 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(&params, &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, &params, 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 :             &params(&[("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 :             &params(&[("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              : }
        

Generated by: LCOV version 2.0-1