LCOV - code coverage report
Current view: top level - antares-api/src/batch - mod.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.5 % 188 187
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 operations /entityOperations/* (5.6.7–5.6.10, 5.6.20, 5.7.2-POST;
       3              : //! resources 6.14–6.17, 6.23, 6.31).
       4              : 
       5              : mod delete;
       6              : mod forward;
       7              : mod query;
       8              : #[cfg(test)]
       9              : mod tests;
      10              : mod write;
      11              : 
      12              : pub use delete::batch_delete;
      13              : use forward::*;
      14              : pub use query::batch_query;
      15              : pub use write::{batch_create, batch_merge, batch_update, batch_upsert};
      16              : 
      17              : use crate::entities::merge_into;
      18              : use crate::negotiate::*;
      19              : use crate::paging::{paginate, query_doc_params};
      20              : use crate::repr::{apply, parse_repr};
      21              : use crate::stamp::stamp_new;
      22              : use crate::state::{now_iso, AppState};
      23              : use antares_jsonld::{expand_entity, ExpandOpts};
      24              : use antares_model::NgsiError;
      25              : use antares_store::CurrentStateDriverExt;
      26              : use axum::body::Bytes;
      27              : use axum::extract::State;
      28              : use axum::http::{HeaderMap, StatusCode};
      29              : use axum::response::{IntoResponse, Response};
      30              : use serde_json::{json, Value};
      31              : use std::collections::HashMap;
      32              : 
      33              : use crate::negotiate::CleanParams;
      34              : 
      35              : /// Parse a batch body: JSON array of entity documents; per-document context
      36              : /// resolution (ld+json ⇒ each doc's own @context; json ⇒ Link header).
      37              : /// Batch bodies are the ingest hot path — sonic-rs (3–4× parse) behind
      38              : /// the `sonic` feature, serde_json always compiled as the fallback.
      39              : #[cfg(feature = "sonic")]
      40              : fn parse_batch_body(body: &[u8]) -> Result<Value, String> {
      41              :     sonic_rs::from_slice(body).map_err(|e| e.to_string())
      42              : }
      43              : #[cfg(not(feature = "sonic"))]
      44          578 : fn parse_batch_body(body: &[u8]) -> Result<Value, String> {
      45          578 :     serde_json::from_slice(body).map_err(|e| e.to_string())
      46          578 : }
      47              : 
      48              : /// One entry of a batch array as it arrives: the item and the result of
      49              : /// resolving the `@context` that governs it. A failed resolution is carried
      50              : /// per item, because 5.6.7 answers the array with one error per entity.
      51              : type BatchItem = (Value, ApiResult<std::sync::Arc<antares_jsonld::Context>>);
      52              : 
      53              : /// One entry of a batch as a forward sends it: the object and the `@context`
      54              : /// that expanded it.
      55              : type FwdItem = (
      56              :     serde_json::Map<String, Value>,
      57              :     std::sync::Arc<antares_jsonld::Context>,
      58              : );
      59              : 
      60              : /// One batch body into (item, its resolved @context) pairs. Each item's
      61              : /// `@context` — or the Link header standing in for it — resolves within the
      62              : /// requesting Tenant (5.5.10): a Hosted @context belongs to the Tenant that
      63              : /// stored it (5.13.1), so for any other Tenant the URL is unresolvable rather
      64              : /// than a set of term mappings the Entity would be expanded through.
      65          582 : async fn parse_batch(
      66          582 :     st: &AppState,
      67          582 :     tenant: &antares_model::TenantId,
      68          582 :     headers: &HeaderMap,
      69          582 :     body: &[u8],
      70          582 : ) -> ApiResult<Vec<BatchItem>> {
      71          582 :     let ct = content_type(headers)?;
      72          582 :     let ld = match ct.as_str() {
      73          582 :         "application/json" => false,
      74          300 :         "application/ld+json" => true,
      75            4 :         _ => return Err(ApiError::Bare(StatusCode::UNSUPPORTED_MEDIA_TYPE)),
      76              :     };
      77          578 :     let value: Value = parse_batch_body(body)
      78          578 :         .map_err(|e| NgsiError::InvalidRequest(format!("body is not valid JSON: {e}")))?;
      79          562 :     let items = value.as_array().filter(|a| !a.is_empty()).ok_or_else(|| {
      80           18 :         NgsiError::BadRequestData("batch body must be a non-empty JSON array".into())
      81           18 :     })?;
      82              :     // 5.6.7.4 (and siblings): a null value in ANY item fails the whole
      83              :     // request with BadRequestData — not a per-item 207 error.
      84          544 :     if items.iter().any(Value::is_null) {
      85           14 :         return Err(
      86           14 :             NgsiError::BadRequestData("batch array must not contain null items".into()).into(),
      87           14 :         );
      88          530 :     }
      89              :     // Batch entity count cap
      90          530 :     if items.len() > *crate::bounds::MAX_BATCH_ITEMS {
      91            4 :         return Err(NgsiError::BadRequestData(format!(
      92            4 :             "batch of {} exceeds the {}-entity limit",
      93            4 :             items.len(),
      94            4 :             *crate::bounds::MAX_BATCH_ITEMS
      95            4 :         ))
      96            4 :         .into());
      97          526 :     }
      98          526 :     let link = link_context(headers)?;
      99          526 :     if ld && link.is_some() {
     100            4 :         return Err(NgsiError::BadRequestData(
     101            4 :             "application/ld+json batch must not also carry a Link @context (6.3.5)".into(),
     102            4 :         )
     103            4 :         .into());
     104          522 :     }
     105          522 :     let mut out = Vec::new();
     106              :     // The loader caps ONE @context resolution at MAX_CONTEXT_FETCHES fetched
     107              :     // documents. A batch resolves once per item, so without a ceiling here
     108              :     // the item count multiplies that cap: a body inside the 4 MiB limit can
     109              :     // name a thousand different @contexts and buy a thousand crawls of a
     110              :     // chosen host from one request. Distinct values are what cost a crawl —
     111              :     // repeats hit the loader's merged cache, and an inline object fetches
     112              :     // nothing — so only those are counted.
     113          522 :     let mut named_contexts: std::collections::HashSet<String> = std::collections::HashSet::new();
     114          980 :     let mut count_context = |c: &Value| -> Result<(), ApiError> {
     115              :         // an array names each of its URLs: count them one by one, inline
     116              :         // objects fetch nothing
     117          980 :         let named: std::collections::HashSet<&str> = match c {
     118          416 :             Value::String(u) => std::iter::once(u.as_str()).collect(),
     119          564 :             Value::Array(a) => a.iter().filter_map(Value::as_str).collect(),
     120            0 :             _ => std::collections::HashSet::new(),
     121              :         };
     122          980 :         let fresh: Vec<&str> = named
     123          980 :             .into_iter()
     124         2976 :             .filter(|u| !named_contexts.contains(*u))
     125          980 :             .collect();
     126              :         // Past the ceiling nothing more is remembered either: the set is
     127              :         // keyed by client-supplied text, so it may not grow past the cap it
     128              :         // guards.
     129          980 :         if named_contexts.len().saturating_add(fresh.len()) > crate::bounds::MAX_CONTEXT_FETCHES {
     130          398 :             return Err(NgsiError::BadRequestData(format!(
     131          398 :                 "batch names more than {} distinct @contexts",
     132          398 :                 crate::bounds::MAX_CONTEXT_FETCHES
     133          398 :             ))
     134          398 :             .into());
     135          582 :         }
     136          582 :         named_contexts.extend(fresh.into_iter().map(str::to_owned));
     137          582 :         Ok(())
     138          980 :     };
     139         6280 :     for item in items {
     140         6280 :         let ctx = if ld {
     141          984 :             match item.get("@context") {
     142          980 :                 Some(c) => match count_context(c) {
     143          398 :                     Err(e) => Err(e),
     144          582 :                     Ok(()) => st
     145          582 :                         .loader
     146          582 :                         .resolve_for(tenant, c)
     147          582 :                         .await
     148          582 :                         .map_err(ApiError::from),
     149              :                 },
     150            4 :                 None => Err(NgsiError::BadRequestData(
     151            4 :                     "ld+json batch entity without @context".into(),
     152            4 :                 )
     153            4 :                 .into()),
     154              :             }
     155         5296 :         } else if item.get("@context").is_some() {
     156            4 :             Err(NgsiError::BadRequestData("application/json entity carries @context".into()).into())
     157              :         } else {
     158         5292 :             match &link {
     159           12 :                 Some(url) => st
     160           12 :                     .loader
     161           12 :                     .resolve_for(tenant, &Value::String(url.clone()))
     162           12 :                     .await
     163           12 :                     .map_err(ApiError::from),
     164         5280 :                 None => Ok(st.loader.core()),
     165              :             }
     166              :         };
     167         6280 :         out.push((item.clone(), ctx));
     168              :     }
     169          522 :     Ok(out)
     170          582 : }
     171              : 
     172              : struct BatchOutcome {
     173              :     success: Vec<Value>,
     174              :     errors: Vec<Value>,
     175              : }
     176              : 
     177              : impl BatchOutcome {
     178          834 :     const fn new() -> Self {
     179          834 :         Self {
     180          834 :             success: Vec::new(),
     181          834 :             errors: Vec::new(),
     182          834 :         }
     183          834 :     }
     184              : 
     185              :     /// `id` joins S once, however many members or forwards succeeded for it.
     186         6400 :     fn succeed(&mut self, id: &str) {
     187      1246932 :         if !self.success.iter().any(|v| v.as_str() == Some(id)) {
     188         6184 :             self.success.push(Value::String(id.to_owned()));
     189         6184 :         }
     190         6400 :     }
     191              : 
     192              :     /// Batch output data (5.6.7.5 / 5.6.8.5 / 5.6.9.5 / 5.6.10.5): if every
     193              :     /// entity succeeded, the operation's all-ok status (201 + id array for
     194              :     /// create, else 204 with no body); otherwise 207 with the S array
     195              :     /// ("success") and the E array of BatchEntityError 5.2.17 ("errors").
     196          810 :     fn respond(
     197          810 :         self,
     198          810 :         tenant: &antares_model::TenantId,
     199          810 :         all_ok_status: StatusCode,
     200          810 :         body_on_ok: bool,
     201          810 :     ) -> Response {
     202          810 :         if self.errors.is_empty() {
     203          586 :             if body_on_ok {
     204          200 :                 let mut resp = (
     205          200 :                     all_ok_status,
     206          200 :                     [(axum::http::header::CONTENT_TYPE, "application/json")],
     207          200 :                     axum::Json(Value::Array(self.success)),
     208          200 :                 )
     209          200 :                     .into_response();
     210          200 :                 echo_tenant(tenant, &mut resp);
     211          200 :                 resp
     212              :             } else {
     213          386 :                 let mut resp = all_ok_status.into_response();
     214          386 :                 echo_tenant(tenant, &mut resp);
     215          386 :                 resp
     216              :             }
     217              :         } else {
     218          224 :             multi_status(
     219          224 :                 json!({"success": self.success, "errors": self.errors}),
     220          224 :                 tenant,
     221              :             )
     222              :         }
     223          810 :     }
     224              : }
     225              : 
     226              : /// 5.2.17 BatchEntityError for a member the policy engine refused: the
     227              : /// AccessDenied problem a refused single-Entity write answers with (ADR-0020).
     228           12 : fn denied_entry(id: Option<&str>, why: &str) -> Value {
     229           12 :     json!({
     230           12 :         "entityId": id.unwrap_or("unknown"),
     231           12 :         "error": {
     232           12 :             "type": crate::policy::ACCESS_DENIED_TYPE,
     233           12 :             "title": crate::policy::ACCESS_DENIED_TITLE,
     234           12 :             "status": StatusCode::FORBIDDEN.as_u16(),
     235           12 :             "detail": why,
     236              :         },
     237              :     })
     238           12 : }
     239              : 
     240              : /// The reason a batch is refused whole: every member was refused, so the
     241              : /// answer is the 403 a single refusal gives, carrying the first reason.
     242           72 : fn whole_refusal(refused: &[Result<crate::policy::Filter, String>]) -> Option<String> {
     243           72 :     if refused.iter().all(Result::is_err) {
     244           10 :         refused.first().and_then(|r| r.clone().err())
     245              :     } else {
     246           62 :         None
     247              :     }
     248           72 : }
     249              : 
     250              : /// The conditions the engine set on the allowed members, by id.
     251              : /// One id named by several members holds to every condition any of them
     252              : /// carries: a round writes each id once, so the strictest reading is the
     253              : /// one that never applies a member's write past its own condition.
     254              : #[derive(Default)]
     255              : struct Conditions(HashMap<String, Vec<crate::policy::Condition>>);
     256              : 
     257              : impl Conditions {
     258          248 :     fn add(&mut self, id: Option<&str>, granted: &crate::policy::Filter) {
     259          248 :         if let (Some(id), Some(c)) = (id, crate::policy::Condition::of(granted)) {
     260           36 :             self.0.entry(id.to_owned()).or_default().push(c);
     261          212 :         }
     262          248 :     }
     263              : 
     264          246 :     fn holds(&self, id: &str, doc: &Value) -> bool {
     265          246 :         self.0
     266          246 :             .get(id)
     267          246 :             .is_none_or(|cs| cs.iter().all(|c| c.holds(doc)))
     268          246 :     }
     269              : 
     270              :     /// Inside the store closure: a member whose stored Entity a condition
     271              :     /// does not select is not found, as an absent one is.
     272          240 :     fn pre(&self, id: &str, doc: &Value) -> Result<(), NgsiError> {
     273          240 :         if self.holds(id, doc) {
     274          228 :             Ok(())
     275              :         } else {
     276           12 :             Err(Self::not_found(id))
     277              :         }
     278          240 :     }
     279              : 
     280              :     /// Whether a condition holds `id`: an upsert of it may update an Entity
     281              :     /// the condition selects and never creates one (5.6.8).
     282          244 :     fn has(&self, id: &str) -> bool {
     283          244 :         self.0.contains_key(id)
     284          244 :     }
     285              : 
     286              :     /// The one answer for a conditioned member that is absent or not
     287              :     /// selected, so the two cannot be told apart.
     288           16 :     fn not_found(id: &str) -> NgsiError {
     289           16 :         NgsiError::ResourceNotFound(format!("entity {id} not found"))
     290           16 :     }
     291              : 
     292          816 :     fn unforwarded(&self, forwards: bool) -> Result<(), crate::policy::Denied> {
     293          816 :         if forwards && !self.0.is_empty() {
     294            2 :             Err(crate::policy::Denied(
     295            2 :                 crate::policy::CONDITION_NOT_FORWARDED.to_owned(),
     296            2 :             ))
     297              :         } else {
     298          814 :             Ok(())
     299              :         }
     300          816 :     }
     301              : }
     302              : 
     303          588 : fn err_entry(id: Option<&str>, e: &NgsiError) -> Value {
     304          588 :     json!({
     305          588 :         "entityId": id.unwrap_or("unknown"),
     306          588 :         "error": problem_value(e),
     307              :     })
     308          588 : }
     309              : 
     310              : #[derive(Clone, Copy, PartialEq)]
     311              : enum BatchMode {
     312              :     Create,
     313              :     Upsert,
     314              :     Update,
     315              :     Merge,
     316              : }
     317              : 
     318              : impl BatchMode {
     319              :     /// The clause the policy seam is asked about: one function serves the
     320              :     /// four batch write operations, and they are four clauses.
     321          248 :     const fn clause(self) -> &'static str {
     322          248 :         match self {
     323          186 :             Self::Create => "5.6.7",
     324           36 :             Self::Upsert => "5.6.8",
     325           14 :             Self::Update => "5.6.9",
     326           12 :             Self::Merge => "5.6.20",
     327              :         }
     328          248 :     }
     329              : 
     330              :     /// The operation a registration names to take the whole array (4.3.6.1).
     331          130 :     const fn batch_op(self) -> &'static str {
     332          130 :         match self {
     333           34 :             Self::Create => "createBatch",
     334           42 :             Self::Upsert => "upsertBatch",
     335           36 :             Self::Update => "updateBatch",
     336           18 :             Self::Merge => "mergeBatch",
     337              :         }
     338          130 :     }
     339              : 
     340              :     /// The `/entityOperations/{path}` resource of the operation.
     341          108 :     const fn batch_path(self) -> &'static str {
     342          108 :         match self {
     343           22 :             Self::Create => "create",
     344           38 :             Self::Upsert => "upsert",
     345           32 :             Self::Update => "update",
     346           16 :             Self::Merge => "merge",
     347              :         }
     348          108 :     }
     349              : }
     350              : 
     351              : /// The write a batch asks for: the operation and the 6.3.7 `options` that
     352              : /// select its mode, 6.15.3.1 update (else replace) for upsert and 6.16.3.1
     353              : /// noOverwrite for update.
     354              : #[derive(Clone, Copy)]
     355              : struct WriteOpts {
     356              :     mode: BatchMode,
     357              :     update: bool,
     358              :     no_overwrite: bool,
     359              : }
        

Generated by: LCOV version 2.0-1