LCOV - code coverage report
Current view: top level - antares-api/src - negotiate.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.8 % 1279 1264
Test Date: 2026-11-02 13:21:27 Functions: 76.0 % 350 266

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! HTTP negotiation (CIM 009 6.3.4/6.3.5/6.3.6): content types, Accept,
       3              : //! Link-header @context resolution, response building.
       4              : 
       5              : use antares_jsonld::{Context, Loader, CORE_CONTEXT};
       6              : use antares_model::{NgsiError, TenantId};
       7              : use axum::http::{header, HeaderMap, StatusCode};
       8              : use axum::response::{IntoResponse, Response};
       9              : use serde_json::{Map, Value};
      10              : use std::collections::HashMap;
      11              : use std::sync::Arc;
      12              : 
      13              : pub const JSONLD_CONTEXT_REL: &str = "http://www.w3.org/ns/json-ld#context";
      14              : 
      15              : /// Query-string extractor that drops empty-valued parameters — the Robot
      16              : /// suite's keywords frequently send `datasetId=`/`options=` as empty strings
      17              : /// meaning "absent". A parameter with no value carries nothing that could be
      18              : /// "incompatible with the operation" (6.3.20), so an unknown one spelled
      19              : /// that way is absent too, not an InvalidRequest.
      20              : ///
      21              : /// `+` decodes to a space, the x-www-form-urlencoded convention every browser
      22              : /// query builder (`URLSearchParams`) writes. RFC 3986 clause 3.4 also allows
      23              : /// a literal `+` in a query, so a client that means one percent-encodes it —
      24              : /// the two readings cannot both be served, and no clause picks either. The
      25              : /// DateTime parameters are unaffected whichever way it goes: 4.6.3 fixes them
      26              : /// to the UTC "Z" form, so an offset spelling is refused before its `+`
      27              : /// matters.
      28              : pub struct CleanParams(pub std::collections::HashMap<String, String>);
      29              : 
      30              : impl<S: Send + Sync> axum::extract::FromRequestParts<S> for CleanParams {
      31              :     type Rejection = ApiError;
      32              : 
      33        54590 :     async fn from_request_parts(
      34        54590 :         parts: &mut axum::http::request::Parts,
      35        54590 :         _state: &S,
      36        54590 :     ) -> Result<Self, Self::Rejection> {
      37        54590 :         let raw = parts.uri.query().unwrap_or("");
      38        54590 :         let mut map = std::collections::HashMap::new();
      39        54590 :         let mut seen = std::collections::HashSet::new();
      40        64036 :         for pair in raw.split('&') {
      41        64036 :             if pair.is_empty() {
      42        47670 :                 continue;
      43        16366 :             }
      44        16366 :             let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
      45        32732 :             let dec = |s: &str| percent_decode(s.replace('+', " ").as_bytes());
      46        16366 :             let (k, v) = (dec(k), dec(v));
      47              :             // No clause names which occurrence of a repeated parameter wins,
      48              :             // and implementations disagree (first, last, or the values
      49              :             // joined). CIM 009 gives the broker no authorization model, so a
      50              :             // policy layer sits in front of it; resolving the ambiguity here
      51              :             // would let that layer read one value while the operation acts on
      52              :             // another. 6.3.14 already refuses a repeated NGSILD-Tenant on the
      53              :             // same reasoning, and the value-emptiness filter below must not
      54              :             // hide the repeat, so this counts occurrences of the KEY.
      55        16366 :             if !seen.insert(k.clone()) {
      56           14 :                 return Err(
      57           14 :                     NgsiError::InvalidRequest(format!("repeated query parameter {k:?}")).into(),
      58           14 :                 );
      59        16352 :             }
      60              :             // 4.17: `type` is an Entity Type Selection on every route that takes it
      61        16352 :             if k == "type" && !v.is_empty() && v != "*" {
      62         3756 :                 antares_ql::check_type_selection(&v)?;
      63        12596 :             }
      64        16346 :             if !v.is_empty() {
      65        16332 :                 map.insert(k, v);
      66        16332 :             }
      67              :         }
      68        54570 :         Ok(Self(map))
      69        54590 :     }
      70              : }
      71              : 
      72              : pub(crate) use antares_ql::percent_decode;
      73              : 
      74              : /// Handler-level error: an NGSI-LD ProblemDetails or a bare status (6.3.4).
      75              : #[derive(Debug)]
      76              : pub enum ApiError {
      77              :     Ngsi(NgsiError),
      78              :     Bare(StatusCode),
      79              :     /// 6.3.4: 406 whose body lists the available representations.
      80              :     NotAcceptable(&'static [&'static str]),
      81              :     /// The store has no connection to give inside its acquire timeout.
      82              :     /// 503 with `Retry-After`, carrying the seconds to wait.
      83              :     Overloaded(u64),
      84              :     /// The policy engine refused the operation (ADR-0020), carrying its
      85              :     /// reason. 403 with a ProblemDetails in this broker's own namespace.
      86              :     Denied(String),
      87              : }
      88              : 
      89              : impl From<crate::policy::Denied> for ApiError {
      90          144 :     fn from(d: crate::policy::Denied) -> Self {
      91          144 :         Self::Denied(d.0)
      92          144 :     }
      93              : }
      94              : 
      95              : /// How long a client is told to wait after a 503. The store waited its whole
      96              : /// acquire timeout before answering, so a retry sooner than that walks into
      97              : /// the same wall; one second past it is the first moment the queue can have
      98              : /// moved.
      99              : pub(crate) const RETRY_AFTER_SECONDS: u64 = 6;
     100              : 
     101              : impl From<NgsiError> for ApiError {
     102         9230 :     fn from(e: NgsiError) -> Self {
     103              :         // A pool that timed out is overload, not a fault: the operation was
     104              :         // never attempted and the same request will succeed once the queue
     105              :         // drains. The driver marks it with the detail both ends name.
     106         9230 :         if let NgsiError::InternalError(d) = &e {
     107           44 :             if d == antares_model::error::DB_OVERLOADED {
     108            6 :                 return Self::Overloaded(RETRY_AFTER_SECONDS);
     109           38 :             }
     110         9186 :         }
     111         9224 :         Self::Ngsi(e)
     112         9230 :     }
     113              : }
     114              : 
     115              : /// 6.3.3 Reporting errors: Content-Type application/json, HTTP status per
     116              : /// Table 6.3.2-1, payload = the RFC 7807 object with the 5.5.3 terms.
     117              : impl IntoResponse for ApiError {
     118         8862 :     fn into_response(self) -> Response {
     119         8862 :         match self {
     120         8608 :             Self::Ngsi(e) => {
     121         8608 :                 let pd = e.to_problem_details();
     122         8608 :                 (
     123         8608 :                     StatusCode::from_u16(pd.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
     124         8608 :                     [(header::CONTENT_TYPE, "application/json")],
     125         8608 :                     axum::Json(serde_json::json!({
     126         8608 :                         "type": pd.r#type,
     127         8608 :                         "title": pd.title,
     128         8608 :                         "status": pd.status,
     129         8608 :                         "detail": pd.detail,
     130         8608 :                     })),
     131         8608 :                 )
     132         8608 :                     .into_response()
     133              :             }
     134           40 :             Self::Bare(code) => code.into_response(),
     135              :             // 6.3.2 requires the HTTP binding's own status codes beside
     136              :             // Table 6.3.2-1 ("such as the following", an open list), and
     137              :             // 6.3.4 answers the binding's own conditions with the bare
     138              :             // status. Overload is one of those: 503 with Retry-After
     139              :             // (RFC 7231 clause 6.6.4, clause 7.1.3), no ProblemDetails body,
     140              :             // and no ETSI error type claimed for a condition the spec does
     141              :             // not name.
     142            6 :             Self::Overloaded(secs) => (
     143            6 :                 StatusCode::SERVICE_UNAVAILABLE,
     144            6 :                 [(header::RETRY_AFTER, secs.to_string())],
     145            6 :             )
     146            6 :                 .into_response(),
     147              :             // Table 6.3.2-1 names no access-denied error, and 6.3.2's open
     148              :             // list of binding errors is about the HTTP binding, not about
     149              :             // who may see what. So the refusal is answered in a namespace
     150              :             // that is visibly not ETSI's, with the engine's own reason as
     151              :             // the detail — narrowing stays silent, only a refusal speaks.
     152          152 :             Self::Denied(why) => (
     153          152 :                 StatusCode::FORBIDDEN,
     154          152 :                 [(header::CONTENT_TYPE, "application/json")],
     155          152 :                 axum::Json(serde_json::json!({
     156          152 :                     "type": crate::policy::ACCESS_DENIED_TYPE,
     157          152 :                     "title": crate::policy::ACCESS_DENIED_TITLE,
     158          152 :                     "status": StatusCode::FORBIDDEN.as_u16(),
     159          152 :                     "detail": why,
     160          152 :                 })),
     161          152 :             )
     162          152 :                 .into_response(),
     163              :             // 6.3.4: "the body of the message shall contain the list of the
     164              :             // available representations of the resources"
     165           56 :             Self::NotAcceptable(available) => (
     166           56 :                 StatusCode::NOT_ACCEPTABLE,
     167           56 :                 [(header::CONTENT_TYPE, "application/json")],
     168           56 :                 axum::Json(serde_json::json!({
     169           56 :                     "availableRepresentations": available,
     170           56 :                 })),
     171           56 :             )
     172           56 :                 .into_response(),
     173              :         }
     174         8862 :     }
     175              : }
     176              : 
     177              : pub type ApiResult<T> = Result<T, ApiError>;
     178              : 
     179              : /// The one value of a request header CIM 009 gives cardinality 0..1
     180              : /// (`NGSILD-Tenant` 6.3.14, `NGSILD-Snapshot` 6.3.22, `NGSILD-EntityMap`
     181              : /// Table 6.4.3.2-2). Such a field is not list-type, so repeated field lines
     182              : /// cannot be joined into one value (RFC 9110 clause 5.3) and the request
     183              : /// names nothing; `HeaderMap::get` would answer with the first of them, and
     184              : /// a value that is not ASCII would read as no header at all. Both are
     185              : /// `BadRequestData` — each of these headers selects the data the operation
     186              : /// runs against, and a request must never be answered against a dataset
     187              : /// the client did not name.
     188       301416 : pub(crate) fn single_header(headers: &HeaderMap, name: &str) -> ApiResult<Option<String>> {
     189       301416 :     let mut vals = headers.get_all(name).iter();
     190       301416 :     match (vals.next(), vals.next()) {
     191       289290 :         (None, _) => Ok(None),
     192           20 :         (Some(_), Some(_)) => Err(NgsiError::BadRequestData(format!("repeated {name}")).into()),
     193        12106 :         (Some(v), None) => Ok(Some(
     194        12106 :             v.to_str()
     195        12106 :                 .map_err(|_| NgsiError::BadRequestData(format!("non-ASCII {name}")))?
     196        12098 :                 .to_owned(),
     197              :         )),
     198              :     }
     199       301416 : }
     200              : 
     201              : /// Tenant from the NGSILD-Tenant header (6.3.14).
     202       129416 : pub fn tenant_from(headers: &HeaderMap) -> ApiResult<TenantId> {
     203       129416 :     match single_header(headers, "NGSILD-Tenant")? {
     204       122560 :         None => Ok(TenantId::default()),
     205              :         // Grammar only: a CLIENT naming one of the broker's own tenants is
     206              :         // refused by the wall (`tenant_exists_layer`), which reads the header
     207              :         // the caller sent. Below it the 6.3.22 snapshot scoping has replaced
     208              :         // that header with the snapshot's synthetic tenant, and this parse
     209              :         // has to accept the value the broker itself put there.
     210         6848 :         Some(raw) => Ok(TenantId::new_internal(&raw)?),
     211              :     }
     212       129416 : }
     213              : 
     214              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     215              : pub enum Accept {
     216              :     Json,
     217              :     LdJson,
     218              :     GeoJson,
     219              : }
     220              : 
     221              : /// One pass of RFC 9110 clause 5.3.2 over the representations 6.3.4 offers
     222              : /// for this operation. A media type takes its weight from the MOST SPECIFIC
     223              : /// range that matches it, so `application/json;q=0, */*` refuses json and
     224              : /// still offers the rest; `q=0` removes a representation from the offered set
     225              : /// rather than merely ranking it last.
     226         9664 : fn negotiate(
     227         9664 :     headers: &HeaderMap,
     228         9664 :     offers: &[(&str, Accept)],
     229         9664 :     available: &'static [&'static str],
     230         9664 : ) -> ApiResult<Accept> {
     231         9664 :     if !headers.contains_key(header::ACCEPT) {
     232         4512 :         return Ok(Accept::Json);
     233         5152 :     }
     234              :     // Accept is a list-type field, so its members may arrive split over any
     235              :     // number of field lines (RFC 9110 clause 5.3) — reading only the first
     236              :     // one turned a legal request into a 406.
     237         5152 :     let ranges: Vec<(String, f32)> = headers
     238         5152 :         .get_all(header::ACCEPT)
     239         5152 :         .iter()
     240         5156 :         .filter_map(|v| v.to_str().ok())
     241         5152 :         .flat_map(|v| v.split(','))
     242        45232 :         .filter_map(|part| {
     243        45232 :             let mut segs = part.split(';');
     244        45232 :             let mt = segs.next()?.trim().to_ascii_lowercase();
     245        45232 :             if mt.is_empty() {
     246            4 :                 return None;
     247        45228 :             }
     248        45228 :             let mut q = 1.0f32;
     249        45228 :             for p in segs {
     250              :                 // RFC 9110 clause 5.6.6: parameter names are
     251              :                 // case-insensitive, so `Q=0` refuses this range as `q=0`
     252              :                 // does.
     253        40128 :                 let Some((name, v)) = p.split_once('=') else {
     254            0 :                     continue;
     255              :                 };
     256        40128 :                 if !name.trim().eq_ignore_ascii_case("q") {
     257            4 :                     continue;
     258        40124 :                 }
     259              :                 // A weight outside the RFC 9110 clause 12.4.2 qvalue range
     260              :                 // (0 to 1) — or one that is not a number at all — is not one
     261              :                 // of the HTTP Accept processing rules, so it must not decide
     262              :                 // the outcome: the range keeps the default weight. Without
     263              :                 // the range check `q=-1` removed a representation and `q=5`
     264              :                 // outranked every legal weight.
     265        40124 :                 q = v
     266        40124 :                     .trim()
     267        40124 :                     .parse()
     268        40124 :                     .ok()
     269        40124 :                     .filter(|f: &f32| (0.0..=1.0).contains(f))
     270        40124 :                     .unwrap_or(1.0);
     271              :             }
     272        45228 :             Some((mt, q))
     273        45232 :         })
     274         5152 :         .collect();
     275              :     // 6.3.4: "the order of the list above is significant … the first one of
     276              :     // the list shall be selected, unless amended by the HTTP Accept header
     277              :     // processing rules, e.g. the presence of a q parameter". The weight
     278              :     // decides first; the offer order is the tie-break, never the order the
     279              :     // client happened to write its tokens in.
     280         5152 :     let mut best: Option<(f32, Accept)> = None;
     281        13114 :     for (mt, kind) in offers {
     282        13114 :         let mut matched: Option<(u8, f32)> = None;
     283        93302 :         for (range, q) in &ranges {
     284        93302 :             let spec = match range.as_str() {
     285        93302 :                 r if r == *mt => 2u8,
     286        91714 :                 "application/*" => 1,
     287        91706 :                 "*/*" => 0,
     288        82818 :                 _ => continue,
     289              :             };
     290        10484 :             let better = match matched {
     291        10468 :                 None => true,
     292           16 :                 Some((s, mq)) => spec > s || (spec == s && *q > mq),
     293              :             };
     294        10484 :             if better {
     295        10476 :                 matched = Some((spec, *q));
     296        10476 :             }
     297              :         }
     298        13114 :         let Some((_, q)) = matched.filter(|(_, q)| *q > 0.0) else {
     299         2678 :             continue;
     300              :         };
     301        10436 :         if match best {
     302         5068 :             None => true,
     303         5368 :             Some((bq, _)) => q > bq,
     304         5084 :         } {
     305         5084 :             best = Some((q, *kind));
     306         5352 :         }
     307              :     }
     308         5152 :     match best {
     309         5068 :         Some((_, kind)) => Ok(kind),
     310           84 :         None => Err(ApiError::NotAcceptable(available)),
     311              :     }
     312         9664 : }
     313              : 
     314              : /// Accept negotiation (6.3.4): json, ld+json, geo+json, */*; 406 otherwise.
     315              : /// Absent Accept ⇒ application/json. geo+json is only valid on
     316              : /// Retrieve/Query Entities (6.3.15) — everywhere else it is a 406.
     317         4918 : pub(crate) fn parse_accept_geo(headers: &HeaderMap) -> ApiResult<Accept> {
     318         4918 :     negotiate(
     319         4918 :         headers,
     320         4918 :         &[
     321         4918 :             ("application/json", Accept::Json),
     322         4918 :             ("application/ld+json", Accept::LdJson),
     323         4918 :             ("application/geo+json", Accept::GeoJson),
     324         4918 :         ],
     325         4918 :         &[
     326         4918 :             "application/json",
     327         4918 :             "application/ld+json",
     328         4918 :             "application/geo+json",
     329         4918 :         ],
     330              :     )
     331         4918 : }
     332              : 
     333              : /// Accept negotiation for every operation that is NOT Retrieve/Query
     334              : /// Entities: geo+json is not among the representations offered (6.3.15). It
     335              : /// is left out of the offered set rather than negotiated and then refused, so
     336              : /// a client that weights geo+json highest but also accepts ld+json is served
     337              : /// ld+json instead of a 406.
     338         4746 : pub(crate) fn parse_accept(headers: &HeaderMap) -> ApiResult<Accept> {
     339         4746 :     negotiate(
     340         4746 :         headers,
     341         4746 :         &[
     342         4746 :             ("application/json", Accept::Json),
     343         4746 :             ("application/ld+json", Accept::LdJson),
     344         4746 :         ],
     345         4746 :         &["application/json", "application/ld+json"],
     346              :     )
     347         4746 : }
     348              : 
     349              : /// 6.3.6: "Prefer: body=json" on a GeoJSON response — the @context is
     350              : /// conveyed only by the Link header and omitted from the payload body.
     351          102 : pub(crate) fn prefer_body_json(headers: &HeaderMap) -> bool {
     352              :     // RFC 9110 clause 5.3: repeated field lines carry the same meaning as one
     353              :     // comma-separated list, so every Prefer line is searched.
     354          102 :     headers
     355          102 :         .get_all("Prefer")
     356          102 :         .iter()
     357          102 :         .filter_map(|v| v.to_str().ok())
     358          102 :         .flat_map(|p| p.split(','))
     359          102 :         .any(|t| t.trim().eq_ignore_ascii_case("body=json"))
     360          102 : }
     361              : 
     362              : /// 6.3.6: build a payload-carrying response honouring Prefer on GeoJSON —
     363              : /// body=json keeps the @context out of the body (Link header only);
     364              : /// omitted / body=ld+json embeds it (the respond() default).
     365         2058 : pub(crate) fn respond_prefer(
     366         2058 :     status: StatusCode,
     367         2058 :     payload: Value,
     368         2058 :     ctx: &Context,
     369         2058 :     accept: Accept,
     370         2058 :     tenant: &TenantId,
     371         2058 :     headers: &HeaderMap,
     372         2058 : ) -> Response {
     373         2058 :     if accept == Accept::GeoJson && prefer_body_json(headers) {
     374            8 :         let mut resp = (
     375            8 :             status,
     376            8 :             [
     377            8 :                 (header::CONTENT_TYPE, "application/geo+json".to_owned()),
     378            8 :                 (header::LINK, link_header_value(ctx)),
     379            8 :             ],
     380            8 :             ordered_vec(&payload),
     381            8 :         )
     382            8 :             .into_response();
     383            8 :         echo_tenant(tenant, &mut resp);
     384            8 :         return resp;
     385         2050 :     }
     386         2050 :     respond(status, payload, ctx, accept, tenant)
     387         2058 : }
     388              : 
     389              : /// Content-Type of the request (media type only, parameters dropped).
     390              : ///
     391              : /// Two field lines naming DIFFERENT media types are `BadRequestData`. The
     392              : /// field is not list-type (RFC 9110 clause 8.3), and this one decides where
     393              : /// the @context comes from under 6.3.5 — `application/json` takes it from
     394              : /// the Link header and refuses a body member, `application/ld+json` does the
     395              : /// opposite. Reading the first of two leaves anything in front of the broker
     396              : /// free to read the second and inspect the request as a different media type
     397              : /// than the one it is stored under. Repeated lines naming the SAME media
     398              : /// type are not ambiguous: the parameters are dropped before the comparison,
     399              : /// so `application/json` and `Application/JSON; charset=utf-8` are one
     400              : /// answer, and an unreadable value still reports as the empty string, which
     401              : /// the callers separate from an absent header by presence.
     402        36806 : pub(crate) fn content_type(headers: &HeaderMap) -> ApiResult<String> {
     403        36806 :     let bare = |v: &axum::http::HeaderValue| {
     404        36804 :         v.to_str()
     405        36804 :             .unwrap_or("")
     406        36804 :             .split(';')
     407        36804 :             .next()
     408        36804 :             .unwrap_or("")
     409        36804 :             .trim()
     410        36804 :             .to_ascii_lowercase()
     411        36804 :     };
     412        36806 :     let mut found: Option<String> = None;
     413        36806 :     for v in headers.get_all(header::CONTENT_TYPE) {
     414        36804 :         let ct = bare(v);
     415           12 :         match &found {
     416           12 :             Some(first) if *first != ct => {
     417            8 :                 return Err(NgsiError::BadRequestData(
     418            8 :                     "repeated Content-Type names two media types".into(),
     419            8 :                 )
     420            8 :                 .into())
     421              :             }
     422            4 :             Some(_) => {}
     423        36792 :             None => found = Some(ct),
     424              :         }
     425              :     }
     426        36798 :     Ok(found.unwrap_or_default())
     427        36806 : }
     428              : 
     429              : /// Extract the JSON-LD context URL from Link headers (6.3.5, which takes the
     430              : /// header "as mandated by JSON-LD, section 6.2" and through it RFC 8288
     431              : /// clause 3). A field value is a comma-separated list of link-values, each a
     432              : /// URI-Reference in angle brackets followed by `;`-separated parameters. The
     433              : /// brackets are there so that `,` and `;` may appear in the URI, so neither
     434              : /// separates inside them or inside a quoted parameter value. What marks the
     435              : /// JSON-LD @context is the `rel` PARAMETER — case-insensitive name, a
     436              : /// space-separated list of relation types as its value — never the target's
     437              : /// own text: a link whose URL merely spells the relation is a different link,
     438              : /// and resolving it would fetch a document the client never designated.
     439              : ///
     440              : /// Two links naming DIFFERENT @context documents are `BadRequestData`. The
     441              : /// @context decides what every term in the request means, so picking one of
     442              : /// them silently stores the request under an expansion the client did not
     443              : /// designate — and the policy layer in front of the broker (CIM 009 defines
     444              : /// no authorization model, so there is one) can read the other. JSON-LD 1.1
     445              : /// clause 6.2 raises a multiple context link headers error for the same
     446              : /// reason, and Annex C.8 tells a client with several @context documents to
     447              : /// host a wrapper rather than send several links. The same target twice is
     448              : /// not ambiguous — an intermediary may duplicate a field line verbatim — and
     449              : /// is accepted.
     450        56363 : pub(crate) fn link_context(headers: &HeaderMap) -> ApiResult<Option<String>> {
     451        56363 :     let mut found: Option<&str> = None;
     452        56363 :     for link in headers.get_all(header::LINK) {
     453         6308 :         let Ok(s) = link.to_str() else { continue };
     454         6316 :         for value in split_unquoted(s, ',') {
     455         6316 :             let mut parts = split_unquoted(value, ';').into_iter();
     456         6316 :             let Some(target) = parts
     457         6316 :                 .next()
     458         6316 :                 .map(str::trim)
     459         6316 :                 .and_then(|t| t.strip_prefix('<'))
     460         6316 :                 .and_then(|t| t.strip_suffix('>'))
     461              :             else {
     462            4 :                 continue;
     463              :             };
     464         6312 :             let is_context = parts.any(|p| {
     465         6312 :                 let Some((k, v)) = p.split_once('=') else {
     466            0 :                     return false;
     467              :                 };
     468         6312 :                 k.trim().eq_ignore_ascii_case("rel")
     469         6308 :                     && unquote(v.trim())
     470         6308 :                         .split_ascii_whitespace()
     471         6312 :                         .any(|rel| rel == JSONLD_CONTEXT_REL)
     472         6312 :             });
     473         6312 :             if !is_context {
     474           24 :                 continue;
     475         6288 :             }
     476           12 :             match found {
     477           12 :                 Some(first) if first != target => {
     478            8 :                     return Err(NgsiError::BadRequestData(
     479            8 :                         "two Link headers name different @context documents (6.3.5)".into(),
     480            8 :                     )
     481            8 :                     .into())
     482              :                 }
     483            4 :                 Some(_) => {}
     484         6276 :                 None => found = Some(target),
     485              :             }
     486              :         }
     487              :     }
     488        56355 :     Ok(found.map(str::to_owned))
     489        56363 : }
     490              : 
     491              : /// Split on `sep` only where it separates: not inside a bracketed
     492              : /// URI-Reference and not inside a quoted-string, where `\` escapes the next
     493              : /// character (RFC 9110 clause 5.6.4).
     494        12624 : fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
     495        12624 :     let mut out = Vec::new();
     496        12624 :     let (mut start, mut angle, mut quoted, mut escaped) = (0, false, false, false);
     497      1635976 :     for (i, c) in s.char_indices() {
     498      1560488 :         match c {
     499           16 :             _ if escaped => escaped = false,
     500           16 :             '\\' if quoted => escaped = true,
     501        50208 :             '"' => quoted = !quoted,
     502        12624 :             '<' if !quoted => angle = true,
     503        12624 :             '>' if !quoted => angle = false,
     504      1560488 :             c if c == sep && !quoted && !angle => {
     505        12564 :                 out.push(&s[start..i]);
     506        12564 :                 start = i.saturating_add(c.len_utf8());
     507        12564 :             }
     508      1547924 :             _ => {}
     509              :         }
     510              :     }
     511        12624 :     out.push(&s[start..]);
     512        12624 :     out
     513        12624 : }
     514              : 
     515              : /// The value of a header parameter with its quoting removed.
     516         6308 : fn unquote(v: &str) -> &str {
     517         6308 :     v.strip_prefix('"')
     518         6308 :         .and_then(|x| x.strip_suffix('"'))
     519         6308 :         .unwrap_or(v)
     520         6308 : }
     521              : 
     522              : /// The media types a request body may carry per endpoint class.
     523              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     524              : pub enum BodyKind {
     525              :     /// POST/PUT and non-merge PATCH: json | ld+json
     526              :     Standard,
     527              :     /// PATCH accepting RFC 7396: json | ld+json | merge-patch+json
     528              :     MergePatch,
     529              : }
     530              : 
     531              : pub struct ParsedBody {
     532              :     pub value: Value,
     533              :     pub ctx: Arc<Context>,
     534              : }
     535              : 
     536              : impl ParsedBody {
     537              :     /// Every operation whose body carries one document requires that
     538              :     /// document to be a JSON object; anything else never reaches expansion.
     539              :     /// The caller supplies the error because Table 6.3.2-1 does not answer
     540              :     /// the same way everywhere: 5.6.1 raises InvalidRequest for an Entity,
     541              :     /// the fragment operations raise BadRequestData.
     542        31048 :     pub(crate) fn object(&self, err: NgsiError) -> ApiResult<&Map<String, Value>> {
     543        31048 :         self.value.as_object().ok_or_else(|| err.into())
     544        31048 :     }
     545              : }
     546              : 
     547              : /// Parse a request body per the 6.3.5 @context rules, under the state's
     548              : /// behaviour profile.
     549        35576 : pub(crate) async fn parse_body(
     550        35576 :     st: &crate::AppState,
     551        35576 :     headers: &HeaderMap,
     552        35576 :     bytes: &[u8],
     553        35576 :     kind: BodyKind,
     554        35576 : ) -> ApiResult<ParsedBody> {
     555        35576 :     parse_body_in(&st.loader, st.profile, headers, bytes, kind).await
     556        35576 : }
     557              : 
     558              : /// [`parse_body`] over a loader and a profile.
     559        35632 : pub(crate) async fn parse_body_in(
     560        35632 :     loader: &Loader,
     561        35632 :     profile: crate::Profile,
     562        35632 :     headers: &HeaderMap,
     563        35632 :     bytes: &[u8],
     564        35632 :     kind: BodyKind,
     565        35632 : ) -> ApiResult<ParsedBody> {
     566        35632 :     let ct = content_type(headers)?;
     567        35632 :     let ld = match ct.as_str() {
     568        35632 :         "application/json" => false,
     569         7446 :         "application/ld+json" => true,
     570           64 :         "application/merge-patch+json" if kind == BodyKind::MergePatch => false,
     571              :         // absent Content-Type: parse as JSON — a malformed body then reports
     572              :         // InvalidRequest 400 rather than a bare 415 (039_05)
     573           40 :         "" if !headers.contains_key(header::CONTENT_TYPE) => false,
     574           40 :         _ => return Err(ApiError::Bare(StatusCode::UNSUPPORTED_MEDIA_TYPE)),
     575              :     };
     576        35592 :     if bytes.is_empty() {
     577           12 :         return Err(NgsiError::InvalidRequest("empty request body".into()).into());
     578        35580 :     }
     579              :     // 4.6.1 Supported text encodings: JSON content is UTF-8; serde_json
     580              :     // rejects any non-UTF-8 byte sequence here, so a non-UTF-8 body fails
     581              :     // as InvalidRequest 400 (and all broker output is serde-emitted UTF-8).
     582              :     // 4.6.4 Supported Content: values pass through this parse and every
     583              :     // later stage verbatim — no sanitization or escaping of < > " ' = ; ( ),
     584              :     // "implementations shall preserve the representation of the content".
     585        35580 :     let value: Value = serde_json::from_slice(bytes)
     586        35580 :         .map_err(|e| NgsiError::InvalidRequest(format!("request body is not valid JSON: {e}")))?;
     587              :     // Every parse_body consumer takes a single JSON object (entities, fragments,
     588              :     // subscriptions, …; batch arrays go through parse_batch). A non-object is
     589              :     // valid JSON that is no valid datatype: InvalidRequest as ETSI 001_02_02
     590              :     // asserts, or BadRequestData (8.2.3) under ANTARES_NON_OBJECT_BODY.
     591        35528 :     if !value.is_object() {
     592           20 :         let detail = "request body must be a JSON object".to_owned();
     593           20 :         return Err(if profile.non_object_bad_request_data {
     594            4 :             NgsiError::BadRequestData(detail)
     595              :         } else {
     596           16 :             NgsiError::InvalidRequest(detail)
     597              :         }
     598           20 :         .into());
     599        35508 :     }
     600              : 
     601        35508 :     let link = link_context(headers)?;
     602        35508 :     let ctx = if ld {
     603         7342 :         if link.is_some() {
     604           12 :             return Err(NgsiError::BadRequestData(
     605           12 :                 "application/ld+json request must not also carry a Link @context (6.3.5)".into(),
     606           12 :             )
     607           12 :             .into());
     608         7330 :         }
     609         7330 :         let user_ctx = body_context_member(&value).ok_or_else(|| {
     610           36 :             NgsiError::BadRequestData(
     611           36 :                 "application/ld+json request must carry an @context member (6.3.5)".into(),
     612           36 :             )
     613           36 :         })?;
     614         7294 :         loader
     615         7294 :             .resolve_for(&tenant_from(headers)?, &user_ctx)
     616         7294 :             .await?
     617              :     } else {
     618        28166 :         if body_context_member(&value).is_some() {
     619           20 :             return Err(NgsiError::BadRequestData(
     620           20 :                 "application/json request must not carry an @context member (6.3.5)".into(),
     621           20 :             )
     622           20 :             .into());
     623        28146 :         }
     624        28146 :         match link {
     625          508 :             Some(url) => {
     626          508 :                 loader
     627          508 :                     .resolve_for(&tenant_from(headers)?, &Value::String(url))
     628          508 :                     .await?
     629              :             }
     630              :             // 5.5.5 Default @context assignment: input with no @context gets
     631              :             // at minimum the Core @context (no default user @context is
     632              :             // configured; core terms always take precedence).
     633        27638 :             None => loader.core(),
     634              :         }
     635              :     };
     636        35406 :     Ok(ParsedBody { value, ctx })
     637        35632 : }
     638              : 
     639              : /// The @context member for a single-document body.
     640        35496 : fn body_context_member(v: &Value) -> Option<Value> {
     641        35496 :     v.as_object().and_then(|o| o.get("@context")).cloned()
     642        35496 : }
     643              : 
     644              : /// Context for GET/DELETE requests: Link header or core (6.3.5; the
     645              : /// no-@context fallback to the Core @context is 5.5.5).
     646        12808 : pub(crate) async fn request_context(
     647        12808 :     loader: &Loader,
     648        12808 :     headers: &HeaderMap,
     649        12808 : ) -> ApiResult<Arc<Context>> {
     650        12808 :     match link_context(headers)? {
     651              :         // 5.5.10: the Tenant bounds what the operation may see, and a locally
     652              :         // stored @context (5.13.1) is information related to the Tenant that
     653              :         // stored it — so the URL resolves only for that Tenant.
     654         3290 :         Some(url) => Ok(loader
     655         3290 :             .resolve_for(&tenant_from(headers)?, &Value::String(url))
     656         3290 :             .await?),
     657         9518 :         None => Ok(loader.core()),
     658              :     }
     659        12808 : }
     660              : 
     661              : /// Reject unknown query parameters with 400 InvalidRequest (6.3.20).
     662        56246 : pub(crate) fn check_params(
     663        56246 :     params: &std::collections::HashMap<String, String>,
     664        56246 :     allowed: &[&str],
     665        56246 : ) -> ApiResult<()> {
     666        56246 :     for k in params.keys() {
     667        17490 :         if !allowed.contains(&k.as_str()) {
     668           86 :             return Err(NgsiError::InvalidRequest(format!("unknown query parameter {k:?}")).into());
     669        17404 :         }
     670              :     }
     671              :     // 4.19: a malformed scopeQ is refused before any read, at every
     672              :     // operation that takes one
     673        56160 :     if let Some(s) = params.get("scopeQ") {
     674          240 :         antares_ql::scope::check_scope_q(s)?;
     675        55920 :     }
     676        56098 :     Ok(())
     677        56246 : }
     678              : 
     679              : /// The context URL to advertise in a response Link header.
     680        12080 : pub(crate) fn context_link_url(ctx: &Context) -> String {
     681        12080 :     match &ctx.source {
     682        11922 :         Value::String(url) => url.clone(),
     683          136 :         Value::Array(items) => match items.as_slice() {
     684          132 :             [Value::String(url)] => url.clone(),
     685            4 :             _ => CORE_CONTEXT.to_owned(),
     686              :         },
     687           22 :         _ => CORE_CONTEXT.to_owned(),
     688              :     }
     689        12080 : }
     690              : 
     691        12060 : pub(crate) fn link_header_value(ctx: &Context) -> String {
     692        12060 :     format!(
     693              :         "<{}>; rel=\"{JSONLD_CONTEXT_REL}\"; type=\"application/ld+json\"",
     694        12060 :         context_link_url(ctx)
     695              :     )
     696        12060 : }
     697              : 
     698              : /// Build a payload-carrying NGSI-LD response (6.3.6). Egress key order is
     699              : /// `antares_model::ordered_vec`, shared with the notification bindings.
     700              : pub(crate) use antares_model::{ordered_vec, SpecOrder};
     701              : 
     702         4202 : pub(crate) fn respond(
     703         4202 :     status: StatusCode,
     704         4202 :     payload: Value,
     705         4202 :     ctx: &Context,
     706         4202 :     accept: Accept,
     707         4202 :     tenant: &TenantId,
     708         4202 : ) -> Response {
     709              :     // A JSON array is a page, whichever operation produced it, so it takes
     710              :     // the streaming path: the served @context is the request's own, an
     711              :     // inline object as large as the body cap allows, and under ld+json it is
     712              :     // copied onto EVERY element — buffering the whole array first turns one
     713              :     // request into page-size times that. GeoJSON is one object with one
     714              :     // top-level @context and stays here.
     715         4202 :     if accept != Accept::GeoJson {
     716         4132 :         if let Value::Array(docs) = payload {
     717          342 :             return respond_list(status, docs, ctx, accept, tenant);
     718         3790 :         }
     719           70 :     }
     720         3860 :     let mut resp = match accept {
     721              :         Accept::Json => {
     722         3172 :             let mut r = (
     723         3172 :                 status,
     724         3172 :                 [
     725         3172 :                     (header::CONTENT_TYPE, "application/json".to_owned()),
     726         3172 :                     (header::LINK, link_header_value(ctx)),
     727         3172 :                 ],
     728         3172 :                 ordered_vec(&payload),
     729         3172 :             )
     730         3172 :                 .into_response();
     731         3172 :             r.headers_mut().remove(header::CONTENT_LENGTH);
     732         3172 :             r
     733              :         }
     734              :         Accept::LdJson => {
     735          618 :             let with_ctx = inject_context(payload, ctx);
     736          618 :             (
     737          618 :                 status,
     738          618 :                 [(header::CONTENT_TYPE, "application/ld+json".to_owned())],
     739          618 :                 ordered_vec(&with_ctx),
     740          618 :             )
     741          618 :                 .into_response()
     742              :         }
     743              :         Accept::GeoJson => {
     744              :             // 6.3.15: GeoJSON bodies carry the @context at top level
     745           70 :             let with_ctx = match payload {
     746           70 :                 Value::Object(mut o) => {
     747           70 :                     o.insert("@context".into(), served_context(ctx));
     748           70 :                     Value::Object(o)
     749              :                 }
     750            0 :                 other => other,
     751              :             };
     752           70 :             (
     753           70 :                 status,
     754           70 :                 [
     755           70 :                     (header::CONTENT_TYPE, "application/geo+json".to_owned()),
     756           70 :                     (header::LINK, link_header_value(ctx)),
     757           70 :                 ],
     758           70 :                 ordered_vec(&with_ctx),
     759           70 :             )
     760           70 :                 .into_response()
     761              :         }
     762              :     };
     763         3860 :     echo_tenant(tenant, &mut resp);
     764         3860 :     resp
     765         4202 : }
     766              : 
     767              : /// 6.3.13 `NGSILD-Results-Count` + the 6.3.9 pagination `Link` headers, on
     768              : /// every paged list the API serves. A header that will not parse is dropped
     769              : /// rather than failing the response: the page itself is still the answer.
     770         5500 : pub(crate) fn attach_paging(resp: &mut Response, count_hdr: Option<usize>, links: &[String]) {
     771         5500 :     if let Some(total) = count_hdr {
     772          516 :         if let Ok(v) = total.to_string().parse() {
     773          516 :             resp.headers_mut().insert("NGSILD-Results-Count", v);
     774          516 :         }
     775         4984 :     }
     776         5500 :     for l in links {
     777         1002 :         if let Ok(v) = l.parse() {
     778         1002 :             resp.headers_mut().append(axum::http::header::LINK, v);
     779         1002 :         }
     780              :     }
     781         5500 : }
     782              : 
     783              : /// 6.3.11 `options=sysAttrs`: does this request ask for the system-generated
     784              : /// Temporal Properties (4.8) to be shown? Read the same way wherever the
     785              : /// answer is not already carried by a `Repr`.
     786          806 : pub(crate) fn sys_attrs_asked(params: &std::collections::HashMap<String, String>) -> bool {
     787          806 :     params
     788          806 :         .get("options")
     789          806 :         .is_some_and(|o| o.split(',').any(|s| s.trim() == "sysAttrs"))
     790          806 : }
     791              : 
     792              : /// Build a list response that STREAMS entity-by-entity: the serialized
     793              : /// page must never exist as one contiguous buffer, so a large page costs
     794              : /// one entity of memory rather than the whole body.
     795              : /// Json and LdJson only — GeoJSON wraps a FeatureCollection object and takes
     796              : /// the buffered `respond` path.
     797         3736 : pub(crate) fn respond_list(
     798         3736 :     status: StatusCode,
     799         3736 :     docs: Vec<Value>,
     800         3736 :     ctx: &Context,
     801         3736 :     accept: Accept,
     802         3736 :     tenant: &TenantId,
     803         3736 : ) -> Response {
     804         3736 :     if accept == Accept::GeoJson {
     805            0 :         return respond(status, Value::Array(docs), ctx, accept, tenant);
     806         3736 :     }
     807         3736 :     let ld_ctx = (accept == Accept::LdJson).then(|| served_context(ctx));
     808         3736 :     let content_type = match accept {
     809          258 :         Accept::LdJson => "application/ld+json",
     810         3478 :         _ => "application/json",
     811              :     };
     812         3736 :     let chunks = std::iter::once(axum::body::Bytes::from_static(b"["))
     813        10660 :         .chain(docs.into_iter().enumerate().map(move |(i, doc)| {
     814        10660 :             let doc = match (&ld_ctx, doc) {
     815          472 :                 (Some(ctx_val), Value::Object(mut o)) => {
     816          472 :                     o.insert("@context".into(), ctx_val.clone());
     817          472 :                     Value::Object(o)
     818              :                 }
     819        10188 :                 (_, other) => other,
     820              :             };
     821        10660 :             let mut buf = if i == 0 { Vec::new() } else { vec![b','] };
     822              :             // serializing a Value into a Vec cannot fail
     823        10660 :             let _ = serde_json::to_writer(&mut buf, &SpecOrder(&doc));
     824        10660 :             axum::body::Bytes::from(buf)
     825        10660 :         }))
     826         3736 :         .chain(std::iter::once(axum::body::Bytes::from_static(b"]")));
     827         3736 :     let body = axum::body::Body::from_stream(futures_util::stream::iter(
     828         3736 :         chunks.map(Ok::<_, std::convert::Infallible>),
     829              :     ));
     830         3736 :     let mut resp = (
     831         3736 :         status,
     832         3736 :         [
     833         3736 :             (header::CONTENT_TYPE, content_type.to_owned()),
     834         3736 :             (header::LINK, link_header_value(ctx)),
     835         3736 :         ],
     836         3736 :         body,
     837         3736 :     )
     838         3736 :         .into_response();
     839         3736 :     if accept == Accept::LdJson {
     840          258 :         resp.headers_mut().remove(header::LINK);
     841         3478 :     }
     842         3736 :     echo_tenant(tenant, &mut resp);
     843         3736 :     resp
     844         3736 : }
     845              : 
     846              : /// 5.2.3: the @context member served on pure JSON-LD bodies. The sentence
     847              : /// "containing a user @context where present, and the core @context shall be
     848              : /// included" reads as [user, core] — but the ENTIRE ETSI validation
     849              : /// ecosystem (68 official expectation files, strict-compared; the suite is
     850              : /// validated against Scorpio/Stellio) pins the user context ALONE, treating
     851              : /// the core as implicit per 4.4. Antares follows the ecosystem reading;
     852              : /// the clause wording itself is ambiguous.
     853          974 : pub(crate) fn served_context(ctx: &Context) -> Value {
     854          974 :     if ctx.source.is_null() {
     855            4 :         Value::String(CORE_CONTEXT.to_owned())
     856              :     } else {
     857          970 :         ctx.source.clone()
     858              :     }
     859          974 : }
     860              : 
     861          644 : pub(crate) fn inject_context(payload: Value, ctx: &Context) -> Value {
     862          644 :     let ctx_val = served_context(ctx);
     863          644 :     match payload {
     864          644 :         Value::Object(mut o) => {
     865          644 :             o.insert("@context".into(), ctx_val);
     866          644 :             Value::Object(o)
     867              :         }
     868            0 :         Value::Array(items) => Value::Array(
     869            0 :             items
     870            0 :                 .into_iter()
     871            0 :                 .map(|i| match i {
     872            0 :                     Value::Object(mut o) => {
     873            0 :                         o.insert("@context".into(), ctx_val.clone());
     874            0 :                         Value::Object(o)
     875              :                     }
     876            0 :                     other => other,
     877            0 :                 })
     878            0 :                 .collect(),
     879              :         ),
     880            0 :         other => other,
     881              :     }
     882          644 : }
     883              : 
     884              : /// 6.3.14: echo NGSILD-Tenant on responses when non-default.
     885        48788 : pub(crate) fn echo_tenant(tenant: &TenantId, resp: &mut Response) {
     886        48788 :     if tenant.as_str() != TenantId::DEFAULT {
     887         2610 :         if let Ok(v) = tenant.as_str().parse() {
     888         2610 :             resp.headers_mut().insert("NGSILD-Tenant", v);
     889         2610 :         }
     890        46178 :     }
     891        48788 : }
     892              : 
     893              : /// 201 Created with Location header.
     894        17924 : pub(crate) fn created(location: String, tenant: &TenantId) -> Response {
     895        17924 :     let mut resp = (StatusCode::CREATED, [(header::LOCATION, location)]).into_response();
     896        17924 :     echo_tenant(tenant, &mut resp);
     897        17924 :     resp
     898        17924 : }
     899              : 
     900        20922 : pub(crate) fn no_content(tenant: &TenantId) -> Response {
     901        20922 :     let mut resp = StatusCode::NO_CONTENT.into_response();
     902        20922 :     echo_tenant(tenant, &mut resp);
     903        20922 :     resp
     904        20922 : }
     905              : 
     906              : /// Multi-status (batch ops) — always application/json.
     907          286 : pub(crate) fn multi_status(payload: Value, tenant: &TenantId) -> Response {
     908          286 :     let mut resp = (
     909          286 :         StatusCode::MULTI_STATUS,
     910          286 :         [(header::CONTENT_TYPE, "application/json")],
     911          286 :         axum::Json(payload),
     912          286 :     )
     913          286 :         .into_response();
     914          286 :     echo_tenant(tenant, &mut resp);
     915          286 :     resp
     916          286 : }
     917              : 
     918              : /// ProblemDetails value for batch error entries.
     919          602 : pub(crate) fn problem_value(e: &NgsiError) -> Value {
     920          602 :     let pd = e.to_problem_details();
     921          602 :     serde_json::json!({
     922          602 :         "type": pd.r#type,
     923          602 :         "title": pd.title,
     924          602 :         "status": pd.status,
     925          602 :         "detail": pd.detail,
     926              :     })
     927          602 : }
     928              : 
     929              : /// 5.2.12 `jsonldContext`: the @context a Notification of this Subscription
     930              : /// is compacted against, so the member is dereferenced here rather than at
     931              : /// first delivery — a shape that is not a URL or an array of URLs is 400,
     932              : /// one that does not resolve is 504.
     933              : ///
     934              : /// Resolution is Tenant-scoped (5.5.10): a Hosted @context belongs to the
     935              : /// Tenant that stored it (5.13.1), and resolving the URL outside that Tenant
     936              : /// would compact every Notification of this Subscription against another
     937              : /// Tenant's term mappings. For any other Tenant the URL is as absent as one
     938              : /// that never existed.
     939              : /// RFC 7230 `field-name`: a `token`, one or more `tchar`.
     940          412 : pub(crate) fn is_field_name(s: &str) -> bool {
     941          412 :     !s.is_empty()
     942          392 :         && s.bytes()
     943         3362 :             .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b))
     944          412 : }
     945              : 
     946              : /// RFC 7230 `field-value`: visible ASCII, space and horizontal tab, with no
     947              : /// leading or trailing whitespace. Empty is legal; `obs-text` and the
     948              : /// deprecated `obs-fold` are not generated, so a byte outside that set — a
     949              : /// bare CR or LF above all — makes the pair unsendable as a header.
     950          314 : pub(crate) fn is_field_value(s: &str) -> bool {
     951          314 :     !s.starts_with([' ', '\t'])
     952          306 :         && !s.ends_with([' ', '\t'])
     953         3792 :         && s.bytes().all(|b| b == b'\t' || (0x20..=0x7e).contains(&b))
     954          314 : }
     955              : 
     956              : /// 5.6.2.4 (and sibling attribute operations): with a `?type` selector the
     957              : /// target Entity must ALSO match the 4.17 Entity Type Selection — otherwise
     958              : /// the entity is "not known" for this operation (ResourceNotFound).
     959        18972 : pub(crate) fn matches_type_param(
     960        18972 :     doc: &Value,
     961        18972 :     params: &HashMap<String, String>,
     962        18972 :     ctx: &antares_jsonld::Context,
     963        18972 : ) -> bool {
     964        18972 :     let Some(sel) = params.get("type").filter(|s| *s != "*") else {
     965        18840 :         return true;
     966              :     };
     967          132 :     let types: Vec<&str> = doc
     968          132 :         .get("type")
     969          132 :         .and_then(Value::as_array)
     970          132 :         .map(|a| a.iter().filter_map(Value::as_str).collect())
     971          132 :         .unwrap_or_default();
     972          132 :     antares_ql::type_selection_matches(sel, &types, ctx)
     973        18972 : }
     974              : 
     975              : pub const QUERY_PARAMS: &[&str] = &[
     976              :     "id",
     977              :     "idPattern",
     978              :     "type",
     979              :     "attrs",
     980              :     "q",
     981              :     "georel",
     982              :     "geometry",
     983              :     "coordinates",
     984              :     "geoproperty",
     985              :     "scopeQ",
     986              :     "csf",
     987              :     "limit",
     988              :     "offset",
     989              :     "count",
     990              :     "options",
     991              :     "format",
     992              :     "pick",
     993              :     "omit",
     994              :     "lang",
     995              :     "local",
     996              :     "entityMap",
     997              :     "geometryProperty",
     998              :     "expandValues",
     999              :     "jsonKeys",
    1000              :     "datasetId",
    1001              :     "join",
    1002              :     "joinLevel",
    1003              :     "containedBy",
    1004              :     "orderBy",
    1005              :     "orderFrom",
    1006              :     "orderGeometry",
    1007              :     "collation",
    1008              :     "entityMapLifetime",
    1009              :     "splitEntities",
    1010              :     // the broker's keyset cursor behind the next Link (`paging::CURSOR`)
    1011              :     "cursor",
    1012              : ];
    1013              : 
    1014              : #[cfg(test)]
    1015              : mod clause_5_5_3 {
    1016              :     use super::*;
    1017              : 
    1018              :     /// 5.5.5 Default @context assignment: "If the input provided by an API
    1019              :     /// client does not include any @context, then the implementation shall
    1020              :     /// at minimum assign the Core @context" — core terms map, non-core
    1021              :     /// terms fall to the default vocab, and no user context is invented.
    1022              :     #[tokio::test]
    1023            4 :     async fn clause_5_5_5_no_context_input_gets_the_core_context() {
    1024            4 :         let loader = antares_jsonld::Loader::new();
    1025            4 :         let mut h = HeaderMap::new();
    1026            4 :         h.insert(
    1027            4 :             header::CONTENT_TYPE,
    1028            4 :             axum::http::HeaderValue::from_static("application/json"),
    1029              :         );
    1030            4 :         let parsed = parse_body_in(
    1031            4 :             &loader,
    1032            4 :             crate::Profile::default(),
    1033            4 :             &h,
    1034            4 :             br#"{"id":"urn:x","type":"T"}"#,
    1035            4 :             BodyKind::Standard,
    1036            4 :         )
    1037            4 :         .await
    1038            4 :         .expect("no-context body parses under the core context");
    1039            4 :         assert_eq!(
    1040            4 :             parsed.ctx.expand_key("location"),
    1041              :             "https://uri.etsi.org/ngsi-ld/location",
    1042              :             "core term mapped by the assigned Core @context"
    1043              :         );
    1044            4 :         assert_eq!(
    1045            4 :             parsed.ctx.expand_key("speed"),
    1046              :             "https://uri.etsi.org/ngsi-ld/default-context/speed",
    1047              :             "non-core term falls to the default vocabulary"
    1048              :         );
    1049            4 :         assert_eq!(
    1050            4 :             parsed.ctx.source,
    1051            4 :             Value::String(antares_jsonld::CORE_CONTEXT.to_owned()),
    1052              :             "the assigned context is exactly the Core @context — no user \
    1053              :              context is invented"
    1054              :         );
    1055              :         // GET/DELETE requests take the same fallback
    1056            4 :         let ctx = request_context(&loader, &HeaderMap::new())
    1057            4 :             .await
    1058            4 :             .expect("no Link header");
    1059            4 :         assert_eq!(
    1060            4 :             ctx.expand_key("observedAt"),
    1061            4 :             "https://uri.etsi.org/ngsi-ld/observedAt"
    1062            4 :         );
    1063            4 :     }
    1064              : 
    1065              :     /// 6.3.6: geo+json + "Prefer: body=json" → Link header only, @context
    1066              :     /// omitted from the body; without the preference the body embeds it.
    1067              :     #[tokio::test]
    1068            4 :     async fn clause_6_3_6_prefer_body_json_omits_geojson_context() {
    1069            4 :         let loader = antares_jsonld::Loader::new();
    1070            4 :         let ctx = loader.core();
    1071            4 :         let tenant = TenantId::default();
    1072            4 :         let payload = serde_json::json!({"type": "FeatureCollection", "features": []});
    1073              : 
    1074            4 :         let mut h = HeaderMap::new();
    1075            4 :         h.insert("Prefer", axum::http::HeaderValue::from_static("body=json"));
    1076            4 :         let resp = respond_prefer(
    1077              :             StatusCode::OK,
    1078            4 :             payload.clone(),
    1079            4 :             &ctx,
    1080            4 :             Accept::GeoJson,
    1081            4 :             &tenant,
    1082            4 :             &h,
    1083              :         );
    1084            4 :         assert!(resp.headers().get(header::LINK).is_some());
    1085            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1086            4 :             .await
    1087            4 :             .expect("body");
    1088            4 :         let doc: Value = serde_json::from_slice(&bytes).expect("json");
    1089            4 :         assert!(doc.get("@context").is_none(), "{doc}");
    1090            4 :         assert_eq!(doc["type"], "FeatureCollection");
    1091              : 
    1092              :         // no preference → the body embeds the @context (6.3.15)
    1093            4 :         let resp = respond_prefer(
    1094              :             StatusCode::OK,
    1095            4 :             payload,
    1096            4 :             &ctx,
    1097            4 :             Accept::GeoJson,
    1098            4 :             &tenant,
    1099            4 :             &HeaderMap::new(),
    1100              :         );
    1101            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1102            4 :             .await
    1103            4 :             .expect("body");
    1104            4 :         let doc: Value = serde_json::from_slice(&bytes).expect("json");
    1105            4 :         assert!(doc.get("@context").is_some(), "{doc}");
    1106            4 :     }
    1107              : 
    1108              :     /// 6.3.5: "No mixes are allowed" — application/json takes its @context
    1109              :     /// from the Link header only (a body @context is 400), application/ld+json
    1110              :     /// from the body only (a missing body @context is 400, a Link header is
    1111              :     /// 400).
    1112              :     #[tokio::test]
    1113            4 :     async fn clause_6_3_5_context_source_mixing_rules() {
    1114            4 :         let loader = antares_jsonld::Loader::new();
    1115            4 :         let core_link = format!(
    1116              :             "<{}>; rel=\"{JSONLD_CONTEXT_REL}\"; type=\"application/ld+json\"",
    1117              :             antares_jsonld::CORE_CONTEXT
    1118              :         );
    1119           16 :         let ct = |v: &'static str| axum::http::HeaderValue::from_static(v);
    1120              : 
    1121              :         // json + body @context → BadRequestData
    1122            4 :         let mut h = HeaderMap::new();
    1123            4 :         h.insert(header::CONTENT_TYPE, ct("application/json"));
    1124            4 :         let err = parse_body_in(
    1125            4 :             &loader,
    1126            4 :             crate::Profile::default(),
    1127            4 :             &h,
    1128            4 :             br#"{"id":"urn:x","type":"T","@context":{}}"#,
    1129            4 :             BodyKind::Standard,
    1130            4 :         )
    1131            4 :         .await
    1132            4 :         .map(|_| ())
    1133            4 :         .expect_err("json body with @context");
    1134            4 :         assert!(matches!(err, ApiError::Ngsi(NgsiError::BadRequestData(_))));
    1135              : 
    1136              :         // ld+json without a body @context → BadRequestData
    1137            4 :         let mut h = HeaderMap::new();
    1138            4 :         h.insert(header::CONTENT_TYPE, ct("application/ld+json"));
    1139            4 :         let err = parse_body_in(
    1140            4 :             &loader,
    1141            4 :             crate::Profile::default(),
    1142            4 :             &h,
    1143            4 :             br#"{"id":"urn:x","type":"T"}"#,
    1144            4 :             BodyKind::Standard,
    1145            4 :         )
    1146            4 :         .await
    1147            4 :         .map(|_| ())
    1148            4 :         .expect_err("ld+json without @context");
    1149            4 :         assert!(matches!(err, ApiError::Ngsi(NgsiError::BadRequestData(_))));
    1150              : 
    1151              :         // ld+json + Link header → BadRequestData
    1152            4 :         let mut h = HeaderMap::new();
    1153            4 :         h.insert(header::CONTENT_TYPE, ct("application/ld+json"));
    1154            4 :         h.insert(header::LINK, core_link.parse().expect("link"));
    1155            4 :         let err = parse_body_in(
    1156            4 :             &loader,
    1157            4 :             crate::Profile::default(),
    1158            4 :             &h,
    1159            4 :             br#"{"id":"urn:x","type":"T","@context":{}}"#,
    1160            4 :             BodyKind::Standard,
    1161            4 :         )
    1162            4 :         .await
    1163            4 :         .map(|_| ())
    1164            4 :         .expect_err("ld+json with Link header");
    1165            4 :         assert!(matches!(err, ApiError::Ngsi(NgsiError::BadRequestData(_))));
    1166              : 
    1167              :         // the legal combinations still parse: json + Link, ld+json + body
    1168            4 :         let mut h = HeaderMap::new();
    1169            4 :         h.insert(header::CONTENT_TYPE, ct("application/json"));
    1170            4 :         h.insert(header::LINK, core_link.parse().expect("link"));
    1171            4 :         let ok = parse_body_in(
    1172            4 :             &loader,
    1173            4 :             crate::Profile::default(),
    1174            4 :             &h,
    1175            4 :             br#"{"id":"urn:x","type":"T"}"#,
    1176            4 :             BodyKind::Standard,
    1177            4 :         )
    1178            4 :         .await
    1179            4 :         .expect("json + Link is the sanctioned pair");
    1180            4 :         assert!(ok.value.get("@context").is_none());
    1181            4 :     }
    1182              : 
    1183              :     /// RFC 9110 clause 5.3: a list-type field may be split over any number of
    1184              :     /// field lines, and a single-value field may not be repeated at all.
    1185              :     #[test]
    1186            4 :     fn list_headers_are_read_across_field_lines_and_tenant_is_not() {
    1187            4 :         let mut h = HeaderMap::new();
    1188            4 :         h.append(
    1189            4 :             header::ACCEPT,
    1190            4 :             axum::http::HeaderValue::from_static("application/json;q=0.1"),
    1191              :         );
    1192            4 :         h.append(
    1193            4 :             header::ACCEPT,
    1194            4 :             axum::http::HeaderValue::from_static("application/ld+json;q=0.9"),
    1195              :         );
    1196            4 :         assert_eq!(
    1197            4 :             parse_accept(&h).expect("both field lines are one list"),
    1198              :             Accept::LdJson,
    1199              :             "a weight on a later field line must still decide"
    1200              :         );
    1201              : 
    1202            4 :         let mut h = HeaderMap::new();
    1203            4 :         h.append(
    1204              :             "NGSILD-Tenant",
    1205            4 :             axum::http::HeaderValue::from_static("alpha"),
    1206              :         );
    1207            4 :         h.append(
    1208              :             "NGSILD-Tenant",
    1209            4 :             axum::http::HeaderValue::from_static("beta"),
    1210              :         );
    1211            4 :         let err = tenant_from(&h).expect_err("a repeated tenant names two tenants");
    1212            4 :         assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
    1213              :         // the single-valued case is untouched
    1214            4 :         let mut h = HeaderMap::new();
    1215            4 :         h.insert(
    1216              :             "NGSILD-Tenant",
    1217            4 :             axum::http::HeaderValue::from_static("alpha"),
    1218              :         );
    1219            4 :         assert_eq!(tenant_from(&h).expect("one tenant").as_str(), "alpha");
    1220            4 :     }
    1221              : 
    1222              :     /// 6.3.4: "Not Acceptable Media Type … shall result in a 406 HTTP status
    1223              :     /// code and the body of the message shall contain the list of the
    1224              :     /// available representations of the resources."
    1225              :     #[tokio::test]
    1226            4 :     async fn clause_6_3_4_not_acceptable_body_lists_representations() {
    1227            4 :         let mut h = HeaderMap::new();
    1228            4 :         h.insert(
    1229            4 :             header::ACCEPT,
    1230            4 :             axum::http::HeaderValue::from_static("text/html"),
    1231              :         );
    1232            4 :         let err = parse_accept(&h).expect_err("text/html is not acceptable");
    1233            4 :         let resp = err.into_response();
    1234            4 :         assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
    1235            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1236            4 :             .await
    1237            4 :             .expect("body");
    1238            4 :         let body = String::from_utf8_lossy(&bytes);
    1239            4 :         assert!(body.contains("application/json"), "{body}");
    1240            4 :         assert!(body.contains("application/ld+json"), "{body}");
    1241              : 
    1242              :         // geo+json on a non-consumption operation: 406 with the two
    1243              :         // non-geo representations listed
    1244            4 :         let mut h = HeaderMap::new();
    1245            4 :         h.insert(
    1246            4 :             header::ACCEPT,
    1247            4 :             axum::http::HeaderValue::from_static("application/geo+json"),
    1248              :         );
    1249            4 :         let err = parse_accept(&h).expect_err("geo is not acceptable here");
    1250            4 :         let resp = err.into_response();
    1251            4 :         assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
    1252            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1253            4 :             .await
    1254            4 :             .expect("body");
    1255            4 :         let body = String::from_utf8_lossy(&bytes);
    1256            4 :         assert!(body.contains("application/ld+json"), "{body}");
    1257            4 :         assert!(!body.contains("application/geo+json"), "{body}");
    1258            4 :     }
    1259              : 
    1260              :     /// 5.5.3: error bodies are RFC 7807 objects with at least type (5.5.2
    1261              :     /// URI), title (short summary) and detail — served as application/json,
    1262              :     /// NOT application/problem+json.
    1263              :     #[tokio::test]
    1264            4 :     async fn error_body_shape_and_mime() {
    1265            4 :         let resp =
    1266            4 :             ApiError::from(NgsiError::ResourceNotFound("urn:x not found".into())).into_response();
    1267            4 :         assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
    1268            4 :         assert_eq!(
    1269            4 :             resp.headers()
    1270            4 :                 .get(header::CONTENT_TYPE)
    1271            4 :                 .and_then(|v| v.to_str().ok()),
    1272              :             Some("application/json"),
    1273              :             "5.5.3: standard JSON MIME, not problem+json"
    1274              :         );
    1275            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1276            4 :             .await
    1277            4 :             .expect("body");
    1278            4 :         let doc: Value = serde_json::from_slice(&bytes).expect("json");
    1279            4 :         assert_eq!(
    1280            4 :             doc["type"],
    1281              :             "https://uri.etsi.org/ngsi-ld/errors/ResourceNotFound"
    1282              :         );
    1283            4 :         assert_eq!(doc["title"], "ResourceNotFound");
    1284            4 :         assert!(doc["detail"].as_str().is_some_and(|d| d.contains("urn:x")));
    1285            4 :         assert_eq!(doc["status"], 404);
    1286            4 :     }
    1287              : }
    1288              : 
    1289              : /// 6.3.4/6.3.5/6.3.14 negotiation surface: header parsing on hostile input,
    1290              : /// the shape of what goes back on the wire, and what must NOT be in it.
    1291              : #[cfg(test)]
    1292              : mod negotiation {
    1293              :     use super::*;
    1294              :     use axum::extract::FromRequestParts;
    1295              :     use axum::http::HeaderValue;
    1296              :     use serde_json::json;
    1297              : 
    1298          248 :     fn hdr(name: &'static str, value: &str) -> HeaderMap {
    1299          248 :         let mut h = HeaderMap::new();
    1300          248 :         h.insert(name, HeaderValue::from_str(value).expect("header value"));
    1301          248 :         h
    1302          248 :     }
    1303              : 
    1304          116 :     fn accept(value: &str) -> HeaderMap {
    1305          116 :         hdr("accept", value)
    1306          116 :     }
    1307              : 
    1308              :     /// The selected representation on an operation that does not offer
    1309              :     /// geo+json (6.3.15), and on one that does.
    1310           80 :     fn acc(value: &str) -> Accept {
    1311           80 :         parse_accept(&accept(value)).expect("acceptable")
    1312           80 :     }
    1313            8 :     fn acc_geo(value: &str) -> Accept {
    1314            8 :         parse_accept_geo(&accept(value)).expect("acceptable")
    1315            8 :     }
    1316              : 
    1317              :     /// 6.3.14: an NGSILD-Tenant that is not `[A-Za-z0-9_-]{1,64}` is a
    1318              :     /// BadRequestData 400 — never a panic, a 500, or a silent fallback to the
    1319              :     /// default tenant.
    1320              :     #[test]
    1321            4 :     fn tenant_header_is_validated_or_400() {
    1322            4 :         assert_eq!(
    1323            4 :             tenant_from(&HeaderMap::new())
    1324            4 :                 .expect("absent header")
    1325            4 :                 .as_str(),
    1326              :             TenantId::DEFAULT
    1327              :         );
    1328            4 :         assert_eq!(
    1329            4 :             tenant_from(&hdr("NGSILD-Tenant", "city-01_A"))
    1330            4 :                 .expect("valid tenant")
    1331            4 :                 .as_str(),
    1332              :             "city-01_A"
    1333              :         );
    1334            4 :         let long = "x".repeat(65);
    1335           28 :         for bad in ["", "a b", "a/b", "../etc", "a.b", "tenant;drop", &long] {
    1336           28 :             let err = tenant_from(&hdr("NGSILD-Tenant", bad))
    1337           28 :                 .map(|t| t.as_str().to_owned())
    1338           28 :                 .expect_err("hostile tenant must be rejected");
    1339           28 :             assert!(
    1340           28 :                 matches!(err, ApiError::Ngsi(NgsiError::BadRequestData(_))),
    1341              :                 "{bad:?} → {err:?}"
    1342              :             );
    1343              :         }
    1344              :         // bytes that are valid in a header field but not in a Rust str
    1345            4 :         let mut h = HeaderMap::new();
    1346            4 :         h.insert(
    1347              :             "NGSILD-Tenant",
    1348            4 :             HeaderValue::from_bytes(&[0xff, 0xfe]).expect("opaque header bytes"),
    1349              :         );
    1350            4 :         let err = tenant_from(&h)
    1351            4 :             .map(|t| t.as_str().to_owned())
    1352            4 :             .expect_err("non-ASCII tenant");
    1353            4 :         assert!(matches!(err, ApiError::Ngsi(NgsiError::BadRequestData(_))));
    1354            4 :     }
    1355              : 
    1356              :     /// The 400 for a hostile tenant carries the ProblemDetails shape and no
    1357              :     /// broker internals.
    1358              :     #[tokio::test]
    1359            4 :     async fn rejected_tenant_body_leaks_nothing() {
    1360            4 :         let err = tenant_from(&hdr("NGSILD-Tenant", "a b"))
    1361            4 :             .map(|_| ())
    1362            4 :             .expect_err("invalid tenant");
    1363            4 :         let resp = err.into_response();
    1364            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    1365            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1366            4 :             .await
    1367            4 :             .expect("body");
    1368            4 :         let doc: Value = serde_json::from_slice(&bytes).expect("json");
    1369            4 :         assert_eq!(doc["title"], "BadRequestData");
    1370            4 :         let body = String::from_utf8_lossy(&bytes);
    1371           16 :         for internal in ["/workspace", ".rs", "panicked", "TenantId("] {
    1372           16 :             assert!(!body.contains(internal), "{internal} leaked: {body}");
    1373            4 :         }
    1374            4 :     }
    1375              : 
    1376              :     /// 6.3.4: absent Accept ⇒ application/json; the wildcards expand to the
    1377              :     /// first option of the list; an Accept naming nothing of the list — or
    1378              :     /// weighting everything to zero — is a 406.
    1379              :     #[test]
    1380            4 :     fn accept_wildcards_and_unacceptable_headers() {
    1381            4 :         assert_eq!(
    1382            4 :             parse_accept(&HeaderMap::new()).expect("absent Accept"),
    1383              :             Accept::Json
    1384              :         );
    1385            4 :         assert_eq!(acc("*/*"), Accept::Json);
    1386            4 :         assert_eq!(acc("application/*"), Accept::Json);
    1387            4 :         assert_eq!(acc_geo("*/*"), Accept::Json);
    1388            4 :         assert_eq!(acc("application/json;charset=utf-8"), Accept::Json);
    1389            4 :         assert_eq!(
    1390            4 :             acc("APPLICATION/LD+JSON"),
    1391              :             Accept::LdJson,
    1392              :             "media types are case-insensitive"
    1393              :         );
    1394           20 :         for bad in [
    1395            4 :             "",
    1396            4 :             "text/html",
    1397            4 :             "text/*",
    1398            4 :             "application/xml, text/turtle",
    1399            4 :             "*/*;q=0",
    1400            4 :         ] {
    1401           20 :             assert!(
    1402           20 :                 matches!(parse_accept(&accept(bad)), Err(ApiError::NotAcceptable(_))),
    1403              :                 "{bad:?} must be 406"
    1404              :             );
    1405              :         }
    1406              :         // header bytes that are not a Rust str: nothing acceptable was named
    1407            4 :         let mut h = HeaderMap::new();
    1408            4 :         h.insert(
    1409            4 :             header::ACCEPT,
    1410            4 :             HeaderValue::from_bytes(&[0xff]).expect("opaque header bytes"),
    1411              :         );
    1412            4 :         assert!(matches!(parse_accept(&h), Err(ApiError::NotAcceptable(_))));
    1413            4 :     }
    1414              : 
    1415              :     /// 6.3.4: "the first one of the list shall be selected, unless amended by
    1416              :     /// the HTTP Accept header processing rules". A malformed or non-finite
    1417              :     /// weight is not one of those rules and must not decide the outcome.
    1418              :     #[test]
    1419            4 :     fn accept_quality_values() {
    1420            4 :         assert_eq!(
    1421            4 :             acc("application/ld+json, application/json"),
    1422              :             Accept::Json,
    1423              :             "list order, not header order"
    1424              :         );
    1425            4 :         assert_eq!(
    1426            4 :             acc("application/json;q=0.1, application/ld+json;q=0.9"),
    1427              :             Accept::LdJson
    1428              :         );
    1429            4 :         assert_eq!(
    1430            4 :             acc("application/json;q=0, application/ld+json"),
    1431              :             Accept::LdJson,
    1432              :             "q=0 removes json from the offered set"
    1433              :         );
    1434            4 :         assert_eq!(
    1435            4 :             acc("*/*;q=0.9, application/ld+json;q=0.8"),
    1436              :             Accept::Json,
    1437              :             "RFC 9110 5.3.2: json takes the wildcard's 0.9, which outranks 0.8"
    1438              :         );
    1439            4 :         assert_eq!(
    1440            4 :             acc("application/json;q=0, */*"),
    1441              :             Accept::LdJson,
    1442              :             "the exact range is the more specific match, so json stays refused"
    1443              :         );
    1444           28 :         for weird in ["q=NaN", "q=inf", "q=", "q=abc", "q=1.0.0", "q=-1", "q=5"] {
    1445           28 :             assert_eq!(
    1446           28 :                 acc(&format!("application/ld+json;{weird}")),
    1447              :                 Accept::LdJson,
    1448              :                 "{weird} is not a usable weight — the type stays acceptable"
    1449              :             );
    1450              :         }
    1451              :         // RFC 9110 clause 5.6.6: parameter NAMES are case-insensitive, so a
    1452              :         // client refusing json with Q=0 is refusing it.
    1453            4 :         assert_eq!(
    1454            4 :             acc("application/json;Q=0, application/ld+json"),
    1455              :             Accept::LdJson,
    1456              :             "the q parameter is named case-insensitively"
    1457              :         );
    1458            4 :     }
    1459              : 
    1460              :     /// 6.3.15 restricts application/geo+json to Retrieve/Query Entity. On
    1461              :     /// every other operation it is simply not on offer, so a client that also
    1462              :     /// named an available representation gets that one; a client that named
    1463              :     /// only geo+json gets a 406 whose body must NOT advertise geo+json.
    1464              :     #[tokio::test]
    1465            4 :     async fn geojson_is_unavailable_outside_entity_consumption() {
    1466            4 :         assert_eq!(acc("application/geo+json, application/json"), Accept::Json);
    1467            4 :         assert_eq!(
    1468            4 :             acc("application/geo+json;q=0.9, application/ld+json;q=0.1"),
    1469              :             Accept::LdJson,
    1470              :             "the only available representation wins even weighted below geo"
    1471              :         );
    1472            4 :         assert_eq!(
    1473            4 :             acc_geo("application/geo+json"),
    1474              :             Accept::GeoJson,
    1475              :             "on Retrieve/Query Entity it IS available"
    1476              :         );
    1477            4 :         let err = parse_accept(&accept("application/geo+json"))
    1478            4 :             .map(|_| ())
    1479            4 :             .expect_err("406 on other operations");
    1480            4 :         let resp = err.into_response();
    1481            4 :         assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
    1482            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1483            4 :             .await
    1484            4 :             .expect("body");
    1485            4 :         let body = String::from_utf8_lossy(&bytes);
    1486            4 :         assert!(
    1487            4 :             !body.contains("geo+json"),
    1488              :             "must not offer geo+json: {body}"
    1489              :         );
    1490            4 :         assert!(!body.contains("detail"), "no internals in a 406: {body}");
    1491            4 :     }
    1492              : 
    1493              :     /// A pathological Accept header is bounded work and still answers.
    1494              :     #[test]
    1495            4 :     fn huge_accept_header_is_survivable() {
    1496            4 :         let raw = ["text/html;q=0.5"; 5000].join(",");
    1497            4 :         assert!(matches!(
    1498            4 :             parse_accept(&accept(&raw)),
    1499              :             Err(ApiError::NotAcceptable(_))
    1500              :         ));
    1501            4 :         let raw = format!("{raw},application/ld+json");
    1502            4 :         assert_eq!(acc(&raw), Accept::LdJson);
    1503            4 :     }
    1504              : 
    1505              :     /// Percent-decoding accepts only `%` + two hex digits; anything else is
    1506              :     /// literal text, and bytes that are not UTF-8 are replaced rather than
    1507              :     /// panicked on.
    1508              :     #[test]
    1509            4 :     fn percent_decoding_is_strict_and_total() {
    1510            4 :         assert_eq!(percent_decode(b"plain"), "plain");
    1511            4 :         assert_eq!(percent_decode(b"%41%42"), "AB");
    1512            4 :         assert_eq!(percent_decode(b"%7B%22a%22%7D"), r#"{"a"}"#);
    1513            4 :         assert_eq!(percent_decode(b"100%"), "100%");
    1514            4 :         assert_eq!(percent_decode(b"%4"), "%4");
    1515            4 :         assert_eq!(percent_decode(b"%zz"), "%zz");
    1516            4 :         assert_eq!(percent_decode(b"%+1"), "%+1", "a sign is not a hex digit");
    1517            4 :         assert_eq!(percent_decode(b"% 1"), "% 1");
    1518            4 :         assert_eq!(percent_decode(b"%ff"), "\u{fffd}", "lone continuation byte");
    1519            4 :         assert_eq!(percent_decode(b"%25%34%31"), "%41", "decoded exactly once");
    1520            4 :     }
    1521              : 
    1522              :     /// Query parsing (5.7.2 parameter conventions): `+` is a space, values
    1523              :     /// are percent-decoded, empty-valued parameters are dropped, and a
    1524              :     /// hostile query string never panics.
    1525              :     #[tokio::test]
    1526            4 :     async fn clean_params_drops_empties_and_decodes() {
    1527            4 :         let (mut parts, ()) = axum::http::Request::builder()
    1528            4 :             .uri("/x?q=a%3D%3D1&name=a+b&datasetId=&flag&&raw=%ff&half=%4")
    1529            4 :             .body(())
    1530            4 :             .expect("request")
    1531            4 :             .into_parts();
    1532            4 :         let CleanParams(m) = CleanParams::from_request_parts(&mut parts, &())
    1533            4 :             .await
    1534            4 :             .expect("infallible");
    1535            4 :         assert_eq!(m.get("q").map(String::as_str), Some("a==1"));
    1536            4 :         assert_eq!(m.get("name").map(String::as_str), Some("a b"));
    1537            4 :         assert!(!m.contains_key("datasetId"), "empty value means absent");
    1538            4 :         assert!(!m.contains_key("flag"), "valueless key means absent");
    1539            4 :         assert_eq!(m.get("raw").map(String::as_str), Some("\u{fffd}"));
    1540            4 :         assert_eq!(m.get("half").map(String::as_str), Some("%4"));
    1541            4 :     }
    1542              : 
    1543              :     /// 6.3.20: a query parameter the operation does not define is an
    1544              :     /// InvalidRequest 400.
    1545              :     #[test]
    1546            4 :     fn unknown_query_parameters_are_rejected() {
    1547            4 :         let mut p = std::collections::HashMap::new();
    1548            4 :         p.insert("type".to_owned(), "T".to_owned());
    1549            4 :         assert!(check_params(&p, &["type", "q"]).is_ok());
    1550            4 :         p.insert("bogus".to_owned(), "1".to_owned());
    1551            4 :         let err = check_params(&p, &["type", "q"]).expect_err("unknown parameter");
    1552            4 :         assert!(matches!(err, ApiError::Ngsi(NgsiError::InvalidRequest(_))));
    1553            4 :     }
    1554              : 
    1555              :     /// `link_context` for the cases that cannot be ambiguous; the ambiguous
    1556              :     /// ones have their own test below.
    1557           44 :     fn link_context_ok(h: &HeaderMap) -> Option<String> {
    1558           44 :         link_context(h).expect("this header names at most one @context")
    1559           44 :     }
    1560              : 
    1561              :     /// 6.3.5: the @context Link header is the one with the JSON-LD context
    1562              :     /// relation, and only a properly bracketed URI-reference counts.
    1563              :     #[test]
    1564            4 :     fn link_header_context_extraction() {
    1565           12 :         let link = |v: &str| hdr("link", v);
    1566            4 :         assert_eq!(
    1567            4 :             link_context_ok(&link(&format!(
    1568            4 :                 "<https://example.org/c.jsonld>; rel=\"{JSONLD_CONTEXT_REL}\"; type=\"application/ld+json\""
    1569            4 :             ))),
    1570            4 :             Some("https://example.org/c.jsonld".to_owned())
    1571              :         );
    1572            4 :         assert_eq!(link_context_ok(&HeaderMap::new()), None);
    1573            4 :         assert_eq!(
    1574            4 :             link_context_ok(&link("<https://example.org/c.jsonld>; rel=\"alternate\"")),
    1575              :             None,
    1576              :             "another relation is not the @context"
    1577              :         );
    1578            4 :         assert_eq!(
    1579            4 :             link_context_ok(&link(&format!(
    1580            4 :                 "https://example.org/c.jsonld; rel=\"{JSONLD_CONTEXT_REL}\""
    1581            4 :             ))),
    1582              :             None,
    1583              :             "an unbracketed target is not a Link value"
    1584              :         );
    1585              :         // several Link field lines: the JSON-LD one is picked out
    1586            4 :         let mut h = HeaderMap::new();
    1587            4 :         h.append(
    1588            4 :             header::LINK,
    1589            4 :             HeaderValue::from_static("<https://a/x>; rel=\"self\""),
    1590              :         );
    1591            4 :         h.append(
    1592            4 :             header::LINK,
    1593            4 :             HeaderValue::from_str(&format!(
    1594            4 :                 "<https://example.org/c.jsonld>; rel=\"{JSONLD_CONTEXT_REL}\""
    1595            4 :             ))
    1596            4 :             .expect("link"),
    1597              :         );
    1598            4 :         assert_eq!(
    1599            4 :             link_context_ok(&h),
    1600            4 :             Some("https://example.org/c.jsonld".to_owned())
    1601              :         );
    1602            4 :     }
    1603              : 
    1604              :     /// 6.3.5 takes the Link header "as mandated by JSON-LD [2], section 6.2",
    1605              :     /// and that clause raises a multiple context link headers error rather
    1606              :     /// than choosing between them: the @context decides what every term in
    1607              :     /// the request means, so serving a request against one of two is serving
    1608              :     /// it against an expansion nobody designated. The same target twice is
    1609              :     /// not ambiguous — an intermediary may repeat a field line — and Annex
    1610              :     /// C.8 tells a client that needs several documents to host a wrapper.
    1611              :     #[test]
    1612            4 :     fn two_link_headers_naming_different_contexts_are_refused() {
    1613           24 :         let ctx = |u: &str| format!("<{u}>; rel=\"{JSONLD_CONTEXT_REL}\"");
    1614           12 :         let two = |a: &str, b: &str| {
    1615           12 :             let mut h = HeaderMap::new();
    1616           24 :             for v in [a, b] {
    1617           24 :                 h.append(header::LINK, HeaderValue::from_str(v).expect("link"));
    1618           24 :             }
    1619           12 :             h
    1620           12 :         };
    1621              : 
    1622            4 :         let h = two(
    1623            4 :             &ctx("https://example.org/a.jsonld"),
    1624            4 :             &ctx("https://evil.example/b.jsonld"),
    1625            4 :         );
    1626            4 :         assert!(
    1627            4 :             link_context(&h).is_err(),
    1628              :             "two @context targets name no single expansion"
    1629              :         );
    1630              : 
    1631              :         // one field line carrying both is the same ambiguity, spelled the
    1632              :         // other way RFC 8288 allows
    1633            4 :         let one = format!(
    1634              :             "{}, {}",
    1635            4 :             ctx("https://example.org/a.jsonld"),
    1636            4 :             ctx("https://evil.example/b.jsonld")
    1637              :         );
    1638            4 :         let mut h = HeaderMap::new();
    1639            4 :         h.append(header::LINK, HeaderValue::from_str(&one).expect("link"));
    1640            4 :         assert!(link_context(&h).is_err(), "one field line, two targets");
    1641              : 
    1642              :         // the same target twice is one @context
    1643            4 :         let same = ctx("https://example.org/a.jsonld");
    1644            4 :         assert_eq!(
    1645            4 :             link_context(&two(&same, &same)).expect("not ambiguous"),
    1646            4 :             Some("https://example.org/a.jsonld".to_owned())
    1647              :         );
    1648              : 
    1649              :         // a second link that is not a @context changes nothing
    1650            4 :         let h = two(
    1651            4 :             &ctx("https://example.org/a.jsonld"),
    1652            4 :             "<https://a/x>; rel=\"self\"",
    1653            4 :         );
    1654            4 :         assert_eq!(
    1655            4 :             link_context(&h).expect("not ambiguous"),
    1656            4 :             Some("https://example.org/a.jsonld".to_owned())
    1657              :         );
    1658            4 :     }
    1659              : 
    1660              :     /// RFC 8288 clause 3, which JSON-LD 1.1 clause 6.2 (and through it 6.3.5)
    1661              :     /// defers to: the target lives in angle brackets so that `,` and `;` may
    1662              :     /// appear inside it, the relation is the `rel` PARAMETER and never the
    1663              :     /// target's text, parameter names are case-insensitive, and one `rel`
    1664              :     /// may list several relation types.
    1665              :     #[test]
    1666            4 :     fn link_header_is_parsed_as_rfc_8288_link_values() {
    1667           24 :         let link = |v: &str| hdr("link", v);
    1668            4 :         assert_eq!(
    1669            4 :             link_context_ok(&link(&format!(
    1670            4 :                 "<https://example.org/c.jsonld?v=1,2;a=b>; rel=\"{JSONLD_CONTEXT_REL}\""
    1671            4 :             ))),
    1672            4 :             Some("https://example.org/c.jsonld?v=1,2;a=b".to_owned()),
    1673              :             "a separator inside the bracketed target is part of the URI"
    1674              :         );
    1675            4 :         assert_eq!(
    1676            4 :             link_context_ok(&link(&format!(
    1677            4 :                 "<https://example.org/x#{JSONLD_CONTEXT_REL}>; rel=\"describedby\""
    1678            4 :             ))),
    1679              :             None,
    1680              :             "the relation is the rel parameter, not the target's text"
    1681              :         );
    1682            4 :         assert_eq!(
    1683            4 :             link_context_ok(&link(&format!(
    1684            4 :                 "<https://a/s.css>; rel=\"stylesheet {JSONLD_CONTEXT_REL}\""
    1685            4 :             ))),
    1686            4 :             Some("https://a/s.css".to_owned()),
    1687              :             "rel is a space-separated list of relation types"
    1688              :         );
    1689            4 :         assert_eq!(
    1690            4 :             link_context_ok(&link(&format!(
    1691            4 :                 "<https://example.org/c.jsonld>; REL={JSONLD_CONTEXT_REL}"
    1692            4 :             ))),
    1693            4 :             Some("https://example.org/c.jsonld".to_owned()),
    1694              :             "parameter names are case-insensitive and the value may be bare"
    1695              :         );
    1696            4 :         assert_eq!(
    1697            4 :             link_context_ok(&link(&format!(
    1698            4 :                 "<https://a/x>; rel=\"self\", <https://example.org/c.jsonld>; rel=\"{JSONLD_CONTEXT_REL}\""
    1699            4 :             ))),
    1700            4 :             Some("https://example.org/c.jsonld".to_owned()),
    1701              :             "one field line may carry several link-values"
    1702              :         );
    1703            4 :         assert_eq!(
    1704            4 :             link_context_ok(&link(&format!(
    1705            4 :                 "<https://a/x>; title=\"a, b; rel=\\\"{JSONLD_CONTEXT_REL}\\\"\""
    1706            4 :             ))),
    1707              :             None,
    1708              :             "a quoted parameter value is not a link-value boundary"
    1709              :         );
    1710            4 :     }
    1711              : 
    1712              :     /// Request Content-Type is compared as a bare media type.
    1713              :     #[test]
    1714            4 :     fn content_type_strips_parameters_and_case() {
    1715           12 :         let content_type = |h: &HeaderMap| content_type(h).expect("one media type");
    1716            4 :         assert_eq!(content_type(&HeaderMap::new()), "");
    1717            4 :         assert_eq!(
    1718            4 :             content_type(&hdr("content-type", "Application/LD+JSON; charset=UTF-8")),
    1719              :             "application/ld+json"
    1720              :         );
    1721            4 :         assert_eq!(
    1722            4 :             content_type(&hdr("content-type", " application/json ")),
    1723              :             "application/json"
    1724              :         );
    1725            4 :     }
    1726              : 
    1727              :     /// RFC 9110 clause 8.3 gives Content-Type one value, and 6.3.5 branches
    1728              :     /// on it: `application/json` takes the @context from the Link header and
    1729              :     /// refuses a body member, `application/ld+json` does the reverse. Two
    1730              :     /// field lines naming different media types therefore name two different
    1731              :     /// readings of the same bytes, and the broker refuses rather than taking
    1732              :     /// the first. Repeats that agree once the parameters are dropped are not
    1733              :     /// ambiguous.
    1734              :     #[test]
    1735            4 :     fn two_content_types_naming_different_media_types_are_refused() {
    1736           12 :         let two = |a: &str, b: &str| {
    1737           12 :             let mut h = HeaderMap::new();
    1738           24 :             for v in [a, b] {
    1739           24 :                 h.append(header::CONTENT_TYPE, HeaderValue::from_str(v).expect("ct"));
    1740           24 :             }
    1741           12 :             h
    1742           12 :         };
    1743            4 :         assert!(
    1744            4 :             content_type(&two("application/json", "application/ld+json")).is_err(),
    1745              :             "json and ld+json read the same body two ways"
    1746              :         );
    1747            4 :         assert!(
    1748            4 :             content_type(&two("application/ld+json", "application/json")).is_err(),
    1749              :             "order does not make one of them the answer"
    1750              :         );
    1751            4 :         assert_eq!(
    1752            4 :             content_type(&two("application/json", "Application/JSON; charset=utf-8"))
    1753            4 :                 .expect("one media type"),
    1754              :             "application/json",
    1755              :             "case and parameters do not make a repeat ambiguous"
    1756              :         );
    1757            4 :     }
    1758              : 
    1759              :     /// 6.3.5 request bodies: an unsupported media type is a bare 415, an
    1760              :     /// empty or non-object body is an InvalidRequest 400, and the 400 detail
    1761              :     /// carries no broker internals.
    1762              :     #[tokio::test]
    1763            4 :     async fn body_parsing_error_paths() {
    1764            4 :         let loader = antares_jsonld::Loader::new();
    1765           36 :         let ct = |v: &'static str| hdr("content-type", v);
    1766              : 
    1767           12 :         for (mime, kind) in [
    1768            4 :             ("text/plain", BodyKind::Standard),
    1769            4 :             ("application/xml", BodyKind::Standard),
    1770            4 :             ("application/merge-patch+json", BodyKind::Standard),
    1771            4 :         ] {
    1772           12 :             let err = parse_body_in(&loader, crate::Profile::default(), &ct(mime), b"{}", kind)
    1773           12 :                 .await
    1774           12 :                 .map(|_| ())
    1775           12 :                 .expect_err("unsupported media type");
    1776           12 :             assert!(
    1777           12 :                 matches!(err, ApiError::Bare(StatusCode::UNSUPPORTED_MEDIA_TYPE)),
    1778              :                 "{mime} → {err:?}"
    1779              :             );
    1780              :         }
    1781            4 :         assert!(parse_body_in(
    1782            4 :             &loader,
    1783            4 :             crate::Profile::default(),
    1784            4 :             &ct("application/merge-patch+json"),
    1785            4 :             br#"{"a":1}"#,
    1786            4 :             BodyKind::MergePatch
    1787              :         )
    1788            4 :         .await
    1789            4 :         .is_ok());
    1790              : 
    1791           20 :         for (bytes, what) in [
    1792            4 :             (&b""[..], "empty"),
    1793            4 :             (&b"[{\"id\":\"urn:x\"}]"[..], "array"),
    1794            4 :             (&b"\"scalar\""[..], "scalar"),
    1795            4 :             (&b"{oops"[..], "malformed"),
    1796            4 :             (&[0x7b, 0xff, 0x7d][..], "non-UTF-8"),
    1797            4 :         ] {
    1798           20 :             let err = parse_body_in(
    1799           20 :                 &loader,
    1800           20 :                 crate::Profile::default(),
    1801           20 :                 &ct("application/json"),
    1802           20 :                 bytes,
    1803           20 :                 BodyKind::Standard,
    1804           20 :             )
    1805           20 :             .await
    1806           20 :             .map(|_| ())
    1807           20 :             .expect_err(what);
    1808           20 :             assert!(
    1809           20 :                 matches!(err, ApiError::Ngsi(NgsiError::InvalidRequest(_))),
    1810            4 :                 "{what} body → {err:?}"
    1811            4 :             );
    1812            4 :             // the 400 detail may quote the parser, never the broker's insides
    1813           20 :             let detail = format!("{err:?}");
    1814           60 :             for internal in ["/workspace", ".rs:", "antares_"] {
    1815           60 :                 assert!(!detail.contains(internal), "{internal} leaked: {detail}");
    1816            4 :             }
    1817            4 :         }
    1818            4 :     }
    1819              : 
    1820              :     /// 6.3.6 response building: application/json carries the @context in the
    1821              :     /// Link header and NOT in the body; application/ld+json the other way
    1822              :     /// round.
    1823              :     #[tokio::test]
    1824            4 :     async fn respond_places_the_context_per_media_type() {
    1825            4 :         let loader = antares_jsonld::Loader::new();
    1826            4 :         let ctx = loader.core();
    1827            4 :         let t = TenantId::default();
    1828            4 :         let payload = json!({"id": "urn:a", "type": "T"});
    1829              : 
    1830            4 :         let resp = respond(StatusCode::OK, payload.clone(), &ctx, Accept::Json, &t);
    1831            4 :         assert!(resp.headers().get(header::LINK).is_some());
    1832            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1833            4 :             .await
    1834            4 :             .expect("body");
    1835            4 :         let doc: Value = serde_json::from_slice(&bytes).expect("json");
    1836            4 :         assert!(doc.get("@context").is_none(), "json body: Link only");
    1837              : 
    1838            4 :         let resp = respond(StatusCode::OK, payload, &ctx, Accept::LdJson, &t);
    1839            4 :         assert!(
    1840            4 :             resp.headers().get(header::LINK).is_none(),
    1841              :             "ld+json body carries the @context itself — no Link header"
    1842              :         );
    1843            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1844            4 :             .await
    1845            4 :             .expect("body");
    1846            4 :         let doc: Value = serde_json::from_slice(&bytes).expect("json");
    1847            4 :         assert_eq!(doc["@context"], json!(CORE_CONTEXT));
    1848            4 :     }
    1849              : 
    1850              :     /// A page is a page however it was asked for: an array payload streams,
    1851              :     /// so the body never exists as one contiguous buffer and the echoed
    1852              :     /// @context — client-sized, up to the whole body cap, and copied onto
    1853              :     /// every element under ld+json — is one element's worth of memory at a
    1854              :     /// time rather than one page's.
    1855              :     #[tokio::test]
    1856            4 :     async fn an_array_payload_streams_whatever_asked_for_it() {
    1857            4 :         let mut ctx = Context::default();
    1858            4 :         ctx.source = json!({"a": "http://example.org/a"});
    1859            4 :         let t = TenantId::default();
    1860            4 :         let docs = vec![
    1861            4 :             json!({"id": "urn:a", "type": "T"}),
    1862            4 :             json!({"id": "urn:b", "type": "T"}),
    1863              :         ];
    1864              : 
    1865            8 :         for accept in [Accept::Json, Accept::LdJson] {
    1866            8 :             let resp = respond(StatusCode::OK, Value::Array(docs.clone()), &ctx, accept, &t);
    1867            4 :             use axum::body::HttpBody as _;
    1868            8 :             assert!(
    1869            8 :                 resp.body().size_hint().exact().is_none(),
    1870            4 :                 "a page is streamed, not buffered whole ({accept:?})"
    1871            4 :             );
    1872            8 :             let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1873            8 :                 .await
    1874            8 :                 .expect("body");
    1875            8 :             let arr: Value = serde_json::from_slice(&bytes).expect("json");
    1876            8 :             assert_eq!(arr[0]["id"], json!("urn:a"));
    1877            8 :             assert_eq!(arr[1]["id"], json!("urn:b"));
    1878            8 :             if accept == Accept::LdJson {
    1879            4 :                 assert_eq!(arr[0]["@context"], json!({"a": "http://example.org/a"}));
    1880            4 :                 assert_eq!(arr[1]["@context"], json!({"a": "http://example.org/a"}));
    1881            4 :             } else {
    1882            4 :                 assert!(arr[0].get("@context").is_none());
    1883            4 :             }
    1884            4 :         }
    1885            4 :     }
    1886              : 
    1887              :     /// The streamed list response is a JSON array in both media types, with
    1888              :     /// the same @context placement rule as `respond`.
    1889              :     #[tokio::test]
    1890            4 :     async fn respond_list_shapes() {
    1891            4 :         let loader = antares_jsonld::Loader::new();
    1892            4 :         let ctx = loader.core();
    1893            4 :         let t = TenantId::default();
    1894            4 :         let docs = vec![
    1895            4 :             json!({"id": "urn:a", "type": "T"}),
    1896            4 :             json!({"id": "urn:b", "type": "T"}),
    1897              :         ];
    1898              : 
    1899            4 :         let resp = respond_list(StatusCode::OK, docs.clone(), &ctx, Accept::Json, &t);
    1900            4 :         assert!(resp.headers().get(header::LINK).is_some());
    1901            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1902            4 :             .await
    1903            4 :             .expect("body");
    1904            4 :         assert_eq!(
    1905            4 :             String::from_utf8_lossy(&bytes),
    1906              :             r#"[{"id":"urn:a","type":"T"},{"id":"urn:b","type":"T"}]"#
    1907              :         );
    1908              : 
    1909            4 :         let resp = respond_list(StatusCode::OK, docs, &ctx, Accept::LdJson, &t);
    1910            4 :         assert!(resp.headers().get(header::LINK).is_none());
    1911            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1912            4 :             .await
    1913            4 :             .expect("body");
    1914            4 :         let arr: Value = serde_json::from_slice(&bytes).expect("json");
    1915            4 :         assert_eq!(arr[0]["@context"], json!(CORE_CONTEXT));
    1916            4 :         assert_eq!(arr[1]["@context"], json!(CORE_CONTEXT));
    1917              : 
    1918            4 :         let resp = respond_list(StatusCode::OK, vec![], &ctx, Accept::Json, &t);
    1919            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    1920            4 :             .await
    1921            4 :             .expect("body");
    1922            4 :         assert_eq!(String::from_utf8_lossy(&bytes), "[]");
    1923            4 :     }
    1924              : 
    1925              :     /// 6.3.14: the tenant is echoed only when it is not the default one.
    1926              :     #[test]
    1927            4 :     fn tenant_echo_is_conditional() {
    1928            4 :         let mut resp = StatusCode::OK.into_response();
    1929            4 :         echo_tenant(&TenantId::default(), &mut resp);
    1930            4 :         assert!(
    1931            4 :             resp.headers().get("NGSILD-Tenant").is_none(),
    1932              :             "the default tenant is not echoed"
    1933              :         );
    1934            4 :         let mut resp = StatusCode::OK.into_response();
    1935            4 :         echo_tenant(&TenantId::new("city-01").expect("valid"), &mut resp);
    1936            4 :         assert_eq!(
    1937            4 :             resp.headers()
    1938            4 :                 .get("NGSILD-Tenant")
    1939            4 :                 .and_then(|v| v.to_str().ok()),
    1940              :             Some("city-01")
    1941              :         );
    1942            4 :     }
    1943              : 
    1944              :     /// 6.3.6 `Prefer: body=json` is one preference among the comma-separated
    1945              :     /// list, on whichever field line it arrives.
    1946              :     #[test]
    1947            4 :     fn prefer_body_json_detection() {
    1948            4 :         assert!(!prefer_body_json(&HeaderMap::new()));
    1949            4 :         assert!(prefer_body_json(&hdr("prefer", "body=json")));
    1950            4 :         assert!(prefer_body_json(&hdr("prefer", "ngsi-ld=1.5, Body=JSON")));
    1951            4 :         assert!(!prefer_body_json(&hdr("prefer", "body=ld+json")));
    1952            4 :         assert!(!prefer_body_json(&hdr("prefer", "ngsi-ld=1.5")));
    1953            4 :         let mut h = HeaderMap::new();
    1954            4 :         h.append("prefer", HeaderValue::from_static("ngsi-ld=1.5"));
    1955            4 :         h.append("prefer", HeaderValue::from_static("body=json"));
    1956            4 :         assert!(prefer_body_json(&h), "a second Prefer line counts too");
    1957            4 :     }
    1958              : 
    1959              :     /// The advertised context URL is a single URL or the core context.
    1960              :     #[test]
    1961            4 :     fn context_link_url_selection() {
    1962           20 :         let ctx_with = |source: Value| {
    1963           20 :             let mut c = Context::default();
    1964           20 :             c.source = source;
    1965           20 :             c
    1966           20 :         };
    1967            4 :         assert_eq!(
    1968            4 :             context_link_url(&ctx_with(json!("https://example.org/c.jsonld"))),
    1969              :             "https://example.org/c.jsonld"
    1970              :         );
    1971            4 :         assert_eq!(
    1972            4 :             context_link_url(&ctx_with(json!(["https://example.org/c.jsonld"]))),
    1973              :             "https://example.org/c.jsonld"
    1974              :         );
    1975            4 :         assert_eq!(
    1976            4 :             context_link_url(&ctx_with(json!(["https://a/x", "https://b/y"]))),
    1977              :             CORE_CONTEXT,
    1978              :             "an inline list cannot be advertised by reference"
    1979              :         );
    1980            4 :         assert_eq!(context_link_url(&ctx_with(json!({"a": "b"}))), CORE_CONTEXT);
    1981            4 :         assert_eq!(context_link_url(&ctx_with(Value::Null)), CORE_CONTEXT);
    1982            4 :     }
    1983              : 
    1984              :     /// The small response builders: 201 carries Location and no body, 204
    1985              :     /// carries neither, 207 is application/json.
    1986              :     #[tokio::test]
    1987            4 :     async fn status_only_responses() {
    1988            4 :         let t = TenantId::new("city-01").expect("valid");
    1989            4 :         let resp = created("/ngsi-ld/v1/entities/urn:a".to_owned(), &t);
    1990            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    1991            4 :         assert_eq!(
    1992            4 :             resp.headers()
    1993            4 :                 .get(header::LOCATION)
    1994            4 :                 .and_then(|v| v.to_str().ok()),
    1995              :             Some("/ngsi-ld/v1/entities/urn:a")
    1996              :         );
    1997            4 :         assert_eq!(
    1998            4 :             resp.headers()
    1999            4 :                 .get("NGSILD-Tenant")
    2000            4 :                 .and_then(|v| v.to_str().ok()),
    2001              :             Some("city-01")
    2002              :         );
    2003            4 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
    2004            4 :             .await
    2005            4 :             .expect("body");
    2006            4 :         assert!(bytes.is_empty(), "201 carries no payload");
    2007              : 
    2008            4 :         assert_eq!(no_content(&t).status(), StatusCode::NO_CONTENT);
    2009            4 :         let resp = multi_status(json!({"success": [], "errors": []}), &t);
    2010            4 :         assert_eq!(resp.status(), StatusCode::MULTI_STATUS);
    2011            4 :         assert_eq!(
    2012            4 :             resp.headers()
    2013            4 :                 .get(header::CONTENT_TYPE)
    2014            4 :                 .and_then(|v| v.to_str().ok()),
    2015            4 :             Some("application/json")
    2016            4 :         );
    2017            4 :     }
    2018              : }
    2019              : 
    2020              : #[cfg(test)]
    2021              : mod clause_5_2_3 {
    2022              :     use super::*;
    2023              :     use serde_json::json;
    2024              : 
    2025           12 :     fn ctx_with(source: Value) -> Context {
    2026           12 :         let mut c = Context::default();
    2027           12 :         c.source = source;
    2028           12 :         c
    2029           12 :     }
    2030              : 
    2031              :     /// 5.2.3 as read by the ETSI validation ecosystem: the served @context
    2032              :     /// echoes the user context where present (core implicit per 4.4) and
    2033              :     /// falls back to the core context alone otherwise. The literal-wording
    2034              :     /// alternative ([user, core]) fails 68 strict-compared official
    2035              :     /// expectations.
    2036              :     #[test]
    2037            4 :     fn served_context_echoes_user_or_core() {
    2038            4 :         let out = inject_context(json!({"id": "urn:x"}), &ctx_with(Value::Null));
    2039            4 :         assert_eq!(out["@context"], json!(CORE_CONTEXT));
    2040            4 :         let out = inject_context(
    2041            4 :             json!({"id": "urn:x"}),
    2042            4 :             &ctx_with(json!("https://example.org/user.jsonld")),
    2043              :         );
    2044            4 :         assert_eq!(out["@context"], json!("https://example.org/user.jsonld"));
    2045            4 :         let out = inject_context(
    2046            4 :             json!({"id": "urn:x"}),
    2047            4 :             &ctx_with(json!(["https://example.org/a.jsonld", CORE_CONTEXT])),
    2048              :         );
    2049            4 :         assert_eq!(
    2050            4 :             out["@context"],
    2051            4 :             json!(["https://example.org/a.jsonld", CORE_CONTEXT]),
    2052              :             "a user context already listing the core is echoed verbatim"
    2053              :         );
    2054            4 :     }
    2055              : }
    2056              : 
    2057              : #[cfg(test)]
    2058              : mod parsed_body_object {
    2059              :     use super::*;
    2060              :     use serde_json::json;
    2061              : 
    2062           20 :     fn parsed(v: Value) -> ParsedBody {
    2063           20 :         ParsedBody {
    2064           20 :             value: v,
    2065           20 :             ctx: Loader::new().core(),
    2066           20 :         }
    2067           20 :     }
    2068              : 
    2069              :     /// A document body that is not a JSON object never reaches expansion,
    2070              :     /// and the error is the CALLER's: Table 6.3.2-1 does not answer the same
    2071              :     /// way for every operation — 5.6.1 raises InvalidRequest for an Entity
    2072              :     /// while the fragment operations raise BadRequestData — so the shared
    2073              :     /// check must pass the operation's own error through untouched.
    2074              :     #[test]
    2075            4 :     fn a_non_object_body_raises_the_operations_own_error() {
    2076           16 :         for body in [json!([]), json!("x"), json!(1), Value::Null] {
    2077           16 :             let p = parsed(body.clone());
    2078           16 :             let e = p
    2079           16 :                 .object(NgsiError::InvalidRequest("entity".into()))
    2080           16 :                 .expect_err("rejected");
    2081           16 :             assert!(
    2082           16 :                 matches!(e, ApiError::Ngsi(NgsiError::InvalidRequest(_))),
    2083              :                 "{body} -> {e:?}"
    2084              :             );
    2085           16 :             let e = p
    2086           16 :                 .object(NgsiError::BadRequestData("fragment".into()))
    2087           16 :                 .expect_err("rejected");
    2088           16 :             assert!(
    2089           16 :                 matches!(e, ApiError::Ngsi(NgsiError::BadRequestData(_))),
    2090              :                 "{body} -> {e:?}"
    2091              :             );
    2092              :         }
    2093            4 :         let p = parsed(json!({"id": "urn:e"}));
    2094            4 :         assert!(p.object(NgsiError::InvalidRequest("entity".into())).is_ok());
    2095            4 :     }
    2096              : }
        

Generated by: LCOV version 2.0-1