LCOV - code coverage report
Current view: top level - antares-api/src/batch - delete.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 96.2 % 290 279
Test Date: 2026-11-02 13:21:27 Functions: 54.5 % 112 61

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Batch Entity Delete (5.6.10): the per-member decision, one multi-row
       3              : //! DELETE, the 5.6.10.4 distributed ladder and the S/E outcome.
       4              : 
       5              : use super::*;
       6              : 
       7              : // ---------- POST /entityOperations/delete (5.6.10) ----------
       8              : 
       9          326 : pub async fn batch_delete(
      10          326 :     State(st): State<AppState>,
      11          326 :     CleanParams(params): CleanParams,
      12          326 :     headers: HeaderMap,
      13          326 :     body: Bytes,
      14          326 : ) -> Response {
      15          326 :     batch_delete_inner(&st, &params, &headers, &body)
      16          326 :         .await
      17          326 :         .unwrap_or_else(|e| e.into_response())
      18          326 : }
      19              : 
      20          326 : async fn batch_delete_inner(
      21          326 :     st: &AppState,
      22          326 :     params: &HashMap<String, String>,
      23          326 :     headers: &HeaderMap,
      24          326 :     body: &[u8],
      25          326 : ) -> ApiResult<Response> {
      26          326 :     let tenant = tenant_from(headers)?;
      27          326 :     check_params(params, &["local"])?;
      28              :     // A delete batch holds a connection as long as a write batch does.
      29          326 :     let _admitted = crate::bounds::admit_batch(body.len())
      30          326 :         .await
      31          326 :         .ok_or(ApiError::Overloaded(crate::negotiate::RETRY_AFTER_SECONDS))?;
      32          326 :     let ids = delete_ids(headers, body)?;
      33          312 :     let mut out = BatchOutcome::new();
      34              :     // ADR-0020 per member, as for the write batches: each id
      35              :     // is decided as the single-Entity delete decides it, with the types
      36              :     // stored under it. The body has to be read to know them, so the gate
      37              :     // sits behind the parse the way the single-Entity delete sits behind
      38              :     // its path parameter. A refused id lands in E, the rest are deleted.
      39          312 :     let mut ids: Vec<&Value> = ids.iter().collect();
      40          312 :     let mut conds = Conditions::default();
      41          312 :     decide_deletes(st, &tenant, headers, &mut ids, &mut out, &mut conds).await?;
      42          308 :     let spec = crate::registry::CsrSpec {
      43              :         ids: Some(
      44          308 :             ids.iter()
      45          560 :                 .filter_map(|v| v.as_str())
      46          308 :                 .map(str::to_owned)
      47          308 :                 .collect(),
      48              :         ),
      49          308 :         ..Default::default()
      50              :     };
      51          308 :     let regs =
      52          308 :         match crate::federation::write_plan(st, &tenant, &spec, &st.loader.core(), params, headers)
      53          308 :             .await?
      54              :         {
      55            0 :             crate::federation::WritePlan::Answered(r) => return Ok(*r),
      56          308 :             crate::federation::WritePlan::Forward(regs) => regs,
      57              :         };
      58          308 :     conds.unforwarded(!regs.is_empty())?;
      59              :     // One multi-row DELETE for the whole batch; flags in input order.
      60          308 :     let id_strs: Vec<String> = ids
      61          308 :         .iter()
      62          308 :         .copied()
      63          308 :         .filter_map(Value::as_str)
      64          308 :         .map(str::to_owned)
      65          308 :         .collect();
      66          308 :     let (flags, failed) = delete_local(st, &tenant, &id_strs, &conds).await?;
      67              :     // a store error after a committed delete: the ids from the failing one
      68              :     // on were not attempted, and go to E with that error (5.6.10.4 reports
      69              :     // each Entity) rather than a 5xx that hides the deletes that landed
      70          306 :     let attempted = flags.len();
      71          306 :     let mut flags = flags.into_iter();
      72          306 :     let mut local_ok: std::collections::HashSet<String> = Default::default();
      73          306 :     let mut local_miss: Vec<String> = Vec::new();
      74          306 :     let mut seen = 0usize;
      75          558 :     for id in ids {
      76          558 :         let Some(id) = id.as_str() else {
      77            6 :             out.errors.push(err_entry(
      78            6 :                 None,
      79            6 :                 &NgsiError::BadRequestData("entity id must be a string".into()),
      80              :             ));
      81            6 :             continue;
      82              :         };
      83          552 :         seen = seen.saturating_add(1);
      84          552 :         if let Some(e) = failed.as_ref().filter(|_| seen > attempted) {
      85            2 :             out.errors.push(err_entry(Some(id), e));
      86            2 :             continue;
      87          550 :         }
      88          550 :         if flags.next().unwrap_or(false) {
      89              :             // 5.6.10 deletes carry the same temporal-deletion semantics
      90              :             // as 5.6.6 — without this, batch-deleted entities live on in
      91              :             // the temporal store (the reset's batch delete leaked
      92              :             // every prior suite's Buildings into the orderBy queries).
      93          482 :             crate::history::mirror_delete_entity(st, &tenant, id).await;
      94          482 :             local_ok.insert(id.to_owned());
      95           68 :         } else {
      96           68 :             // proxied entities may live remotely only (4.3.6.3) — a
      97           68 :             // local miss becomes an error only if no forward covers it.
      98           68 :             local_miss.push(id.to_owned());
      99           68 :         }
     100              :     }
     101          306 :     let id_strs = if failed.is_some() {
     102            2 :         id_strs.get(..attempted).unwrap_or(&id_strs)
     103              :     } else {
     104          304 :         &id_strs
     105              :     };
     106          306 :     let remote = forward_deletes(st, &tenant, headers, &regs, id_strs).await;
     107          306 :     delete_outcome(&mut out, id_strs, &local_ok, local_miss, remote);
     108          306 :     Ok(out.respond(&tenant, StatusCode::NO_CONTENT, false))
     109          326 : }
     110              : 
     111              : /// The Batch Entity Delete body: a non-empty JSON array without null items,
     112              : /// inside the batch ceiling.
     113          326 : fn delete_ids(headers: &HeaderMap, body: &[u8]) -> ApiResult<Vec<Value>> {
     114          326 :     let ct = content_type(headers)?;
     115          326 :     if ct != "application/json" && ct != "application/ld+json" {
     116            0 :         return Err(ApiError::Bare(StatusCode::UNSUPPORTED_MEDIA_TYPE));
     117          326 :     }
     118          326 :     let value: Value = serde_json::from_slice(body)
     119          326 :         .map_err(|e| NgsiError::InvalidRequest(format!("body is not valid JSON: {e}")))?;
     120          322 :     let ids = match value {
     121          322 :         Value::Array(a) if !a.is_empty() => a,
     122              :         _ => {
     123            4 :             return Err(NgsiError::BadRequestData(
     124            4 :                 "batch delete body must be a non-empty array".into(),
     125            4 :             )
     126            4 :             .into())
     127              :         }
     128              :     };
     129              :     // 5.6.10.4: a null item fails the whole request
     130          318 :     if ids.iter().any(Value::is_null) {
     131            2 :         return Err(
     132            2 :             NgsiError::BadRequestData("batch array must not contain null items".into()).into(),
     133            2 :         );
     134          316 :     }
     135              :     // Batch entity count cap — the same ceiling the write batches carry,
     136              :     // and the one that bounds the per-id forwarded DELETE fan-out.
     137          316 :     if ids.len() > *crate::bounds::MAX_BATCH_ITEMS {
     138            4 :         return Err(NgsiError::BadRequestData(format!(
     139            4 :             "batch of {} exceeds the {}-entity limit",
     140            4 :             ids.len(),
     141            4 :             *crate::bounds::MAX_BATCH_ITEMS
     142            4 :         ))
     143            4 :         .into());
     144          312 :     }
     145          312 :     Ok(ids)
     146          326 : }
     147              : 
     148              : /// The engine's verdict per id: a refused id leaves `ids` for E, an allowed
     149              : /// one leaves its condition in `conds`; every id refused is the 403.
     150          312 : async fn decide_deletes(
     151          312 :     st: &AppState,
     152          312 :     tenant: &antares_model::TenantId,
     153          312 :     headers: &HeaderMap,
     154          312 :     ids: &mut Vec<&Value>,
     155          312 :     out: &mut BatchOutcome,
     156          312 :     conds: &mut Conditions,
     157          312 : ) -> ApiResult<()> {
     158          312 :     let Some(engine) = &st.policy else {
     159          294 :         return Ok(());
     160              :     };
     161              :     // One read for every id, failing closed as `pre_image`.
     162           26 :     let wanted: Vec<&str> = ids.iter().filter_map(|v| v.as_str()).collect();
     163           18 :     let stored = if wanted.is_empty() {
     164            4 :         HashMap::new()
     165              :     } else {
     166           14 :         st.store.get_entities(tenant, &wanted).await?
     167              :     };
     168           16 :     let pres: Vec<crate::policy::PreImage> = ids
     169           16 :         .iter()
     170           24 :         .map(|v| match v.as_str() {
     171           20 :             Some(id) => crate::policy::image(stored.get(id).cloned(), None),
     172            4 :             None => crate::policy::PreImage::default(),
     173           24 :         })
     174           16 :         .collect();
     175           16 :     let one: Vec<Vec<&str>> = ids
     176           16 :         .iter()
     177           24 :         .map(|v| v.as_str().into_iter().collect())
     178           16 :         .collect();
     179           16 :     let ops: Vec<crate::policy::Operation<'_>> = one
     180           16 :         .iter()
     181           16 :         .zip(&pres)
     182           16 :         .map(|(ids, pre)| crate::policy::Operation {
     183           24 :             ids,
     184           24 :             types: &pre.types,
     185           24 :             stored: pre.stored.as_ref(),
     186           24 :             ..crate::policy::Operation::new("5.6.10")
     187           24 :         })
     188           16 :         .collect();
     189           16 :     let asking = crate::snapshots::asking_tenant(st, tenant).await;
     190           16 :     let refused = crate::policy::gate_each(engine.as_ref(), &asking, headers, &ops).await;
     191           16 :     if let Some(why) = whole_refusal(&refused) {
     192            2 :         return Err(crate::policy::Denied(why).into());
     193           14 :     }
     194           14 :     let mut verdicts = refused.into_iter();
     195           22 :     ids.retain(|id| match verdicts.next() {
     196            2 :         Some(Err(why)) => {
     197            2 :             out.errors.push(denied_entry(id.as_str(), &why));
     198            2 :             false
     199              :         }
     200           20 :         Some(Ok(granted)) => {
     201           20 :             conds.add(id.as_str(), &granted);
     202           20 :             true
     203              :         }
     204            0 :         None => true,
     205           22 :     });
     206           14 :     Ok(())
     207          312 : }
     208              : 
     209              : /// The local deletes, a flag per id in input order. Under a policy view
     210              : /// or an engine's condition each delete reads its Entity under the lock and
     211              : /// leaves one out of the view, or one the condition does not select, which
     212              : /// then answers as absent.
     213              : ///
     214              : /// Those deletes commit one by one. A store error before any of them
     215              : /// deleted anything is the request's answer; one after is returned beside
     216              : /// the flags of the ids attempted before it, and the rest are not tried.
     217          308 : async fn delete_local(
     218          308 :     st: &AppState,
     219          308 :     tenant: &antares_model::TenantId,
     220          308 :     id_strs: &[String],
     221          308 :     conds: &Conditions,
     222          308 : ) -> Result<(Vec<bool>, Option<NgsiError>), NgsiError> {
     223          308 :     let judged = !conds.0.is_empty();
     224          308 :     if !judged {
     225          302 :         return Ok((st.store.batch_delete(tenant, id_strs).await?, None));
     226            6 :     }
     227            6 :     let mut flags = Vec::with_capacity(id_strs.len());
     228           10 :     for id in id_strs {
     229           10 :         let keep = |d: &Value| conds.holds(id, d);
     230           10 :         match st.store.delete_entity_if(tenant, id, &keep).await {
     231            6 :             Ok(deleted) => flags.push(deleted),
     232            4 :             Err(e) if !flags.contains(&true) => return Err(e),
     233            2 :             Err(e) => return Ok((flags, Some(e))),
     234              :         }
     235              :     }
     236            2 :     Ok((flags, None))
     237          308 : }
     238              : 
     239              : /// 5.6.10.4 support ladder: deleteBatch -> one batch forward; else
     240              : /// per-entity Delete Entity forwards; else proxy modes get Conflict
     241              : /// per entity. Remote outcomes merge into S/E (never opaque parts).
     242          306 : async fn forward_deletes(
     243          306 :     st: &AppState,
     244          306 :     tenant: &antares_model::TenantId,
     245          306 :     headers: &HeaderMap,
     246          306 :     regs: &[crate::federation::FedReg],
     247          306 :     id_strs: &[String],
     248          306 : ) -> Remote {
     249          306 :     let mut remote = Remote::default();
     250          306 :     if regs.is_empty() {
     251          226 :         return remote;
     252           80 :     }
     253           80 :     let ctx_url = crate::federation::ctx_link_url(headers, &st.loader.core().source);
     254           92 :     for reg in regs {
     255              :         // "Remove from IN all Entities not matched by CSR" — an
     256              :         // id-scoped registration (exact ids OR idPattern, 5.12)
     257              :         // only receives its own ids (4.3.6.1).
     258           92 :         let sent_ids: Vec<String> = id_strs
     259           92 :             .iter()
     260          176 :             .filter(|i| reg.can_match_id(i))
     261           92 :             .cloned()
     262           92 :             .collect();
     263           92 :         if sent_ids.is_empty() {
     264            0 :             continue;
     265           92 :         }
     266           92 :         if reg.supports("deleteBatch") {
     267           88 :             let sent_vals: Vec<Value> = sent_ids.iter().cloned().map(Value::String).collect();
     268           88 :             let (status, body, _) = crate::federation::forward(
     269           88 :                 st,
     270           88 :                 reqwest::Method::POST,
     271           88 :                 format!("{}/ngsi-ld/v1/entityOperations/delete", reg.endpoint),
     272           88 :                 &[],
     273           88 :                 headers,
     274           88 :                 tenant,
     275           88 :                 reg,
     276           88 :                 &ctx_url,
     277           88 :                 Some(Value::Array(sent_vals)),
     278              :             )
     279           88 :             .await;
     280           88 :             merge_remote_batch(
     281           88 :                 status,
     282           88 :                 &body,
     283           88 :                 &sent_ids,
     284              :                 false,
     285           88 :                 &mut remote.ok,
     286           88 :                 &mut remote.err,
     287              :             );
     288            4 :         } else if reg.supports("deleteEntity") {
     289            2 :             for id in &sent_ids {
     290            2 :                 let status = forward_delete(st, tenant, headers, reg, &ctx_url, id).await;
     291            2 :                 if (200..300).contains(&status) && status != 207 {
     292            2 :                     remote.ok.push((id.clone(), false));
     293            2 :                 } else {
     294            0 :                     remote.err.push(err_remote(
     295            0 :                         Some(id),
     296            0 :                         status,
     297            0 :                         &format!("forwarded delete returned {status}"),
     298            0 :                     ));
     299            0 :                 }
     300              :             }
     301            2 :         } else if reg.is_proxy() {
     302              :             // 5.6.10.4 last rung: an exclusive or redirect CSR that
     303              :             // supports neither delete operation contributes an Error
     304              :             // of type Conflict for each Entity in IN.
     305            2 :             for id in &sent_ids {
     306            2 :                 remote.err.push(err_entry(
     307            2 :                     Some(id),
     308            2 :                     &NgsiError::Conflict(
     309            2 :                         "registration does not accept the operation deleteBatch".into(),
     310            2 :                     ),
     311            2 :                 ));
     312            2 :             }
     313            0 :         }
     314              :     }
     315           80 :     remote
     316          306 : }
     317              : 
     318              : /// One Delete Entity (5.6.6) forwarded to `reg`; the status it answered.
     319            2 : async fn forward_delete(
     320            2 :     st: &AppState,
     321            2 :     tenant: &antares_model::TenantId,
     322            2 :     headers: &HeaderMap,
     323            2 :     reg: &crate::federation::FedReg,
     324            2 :     ctx_url: &str,
     325            2 :     id: &str,
     326            2 : ) -> u16 {
     327            2 :     let (status, _, _) = crate::federation::forward(
     328            2 :         st,
     329            2 :         reqwest::Method::DELETE,
     330            2 :         format!(
     331              :             "{}/ngsi-ld/v1/entities/{}",
     332              :             reg.endpoint,
     333            2 :             crate::federation::path_segment(id)
     334              :         ),
     335            2 :         &[],
     336            2 :         headers,
     337            2 :         tenant,
     338            2 :         reg,
     339            2 :         ctx_url,
     340            2 :         None,
     341              :     )
     342            2 :     .await;
     343            2 :     status
     344            2 : }
     345              : 
     346              : /// S in input order (local first occurrence), then remote-only; E gets the
     347              : /// remote errors, then each local miss no forward resolved.
     348          306 : fn delete_outcome(
     349          306 :     out: &mut BatchOutcome,
     350          306 :     id_strs: &[String],
     351          306 :     local_ok: &std::collections::HashSet<String>,
     352          306 :     local_miss: Vec<String>,
     353          306 :     remote: Remote,
     354          306 : ) {
     355          306 :     let remote_success: std::collections::HashSet<String> =
     356          306 :         remote.ok.iter().map(|(id, _)| id.clone()).collect();
     357          550 :     for id in id_strs.iter().filter(|id| local_ok.contains(*id)) {
     358          490 :         out.succeed(id);
     359          490 :     }
     360          306 :     for (id, _) in &remote.ok {
     361           66 :         out.succeed(id);
     362           66 :     }
     363          306 :     let erred: Vec<String> = remote
     364          306 :         .err
     365          306 :         .iter()
     366          306 :         .filter_map(|e| e.get("entityId").and_then(Value::as_str).map(str::to_owned))
     367          306 :         .collect();
     368          306 :     out.errors.extend(remote.err);
     369              :     // 5.6.10.4 local step (5.6.6 limited to local): a missed occurrence
     370              :     // is ResourceNotFound unless a FORWARD resolved that id — a local
     371              :     // success does not excuse it (5.5.11.4 duplicate-id semantics: the
     372              :     // second occurrence of the same id errors).
     373          306 :     for id in local_miss {
     374           68 :         if !remote_success.contains(&id) && !erred.contains(&id) {
     375           40 :             out.errors.push(err_entry(
     376           40 :                 Some(&id),
     377           40 :                 &NgsiError::ResourceNotFound(format!("entity {id} not found")),
     378           40 :             ));
     379           40 :         }
     380              :     }
     381          306 : }
        

Generated by: LCOV version 2.0-1