LCOV - code coverage report
Current view: top level - antares-api/src - negotiate.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.8 % 1248 1233
Test Date: 2026-09-21 10:31:06 Functions: 73.7 % 342 252

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

Generated by: LCOV version 2.0-1