LCOV - code coverage report
Current view: top level - antares-api/src - paging.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 94.9 % 1055 1001
Test Date: 2026-11-02 13:21:27 Functions: 62.0 % 345 214

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Paging, ordering and the query-body parameters shared by every list
       3              : //! operation: the limit/offset/count triple and the next/prev links
       4              : //! (4.12, 6.3.10), entity ordering and ICU collation (4.23), the
       5              : //! `NGSILD-Warning` header (6.3.17) and the POST /entityOperations/query
       6              : //! body lifted into the same parameters its GET twin carries (5.2.23).
       7              : 
       8              : use crate::negotiate::{Accept, ApiResult};
       9              : use crate::state::AppState;
      10              : use antares_model::NgsiError;
      11              : use axum::response::Response;
      12              : use serde_json::{Map, Value};
      13              : use std::collections::HashMap;
      14              : 
      15              : /// 6.3.17: one `NGSILD-Warning` header per abnormal distributed-GET outcome —
      16              : /// scoped by the clause to /entities and /entities/{id}.
      17         5916 : pub fn attach_warnings(resp: &mut Response, warnings: &[String]) {
      18         5916 :     for w in warnings {
      19          272 :         if let Ok(v) = axum::http::HeaderValue::from_str(w) {
      20          272 :             resp.headers_mut().append("NGSILD-Warning", v);
      21          272 :         }
      22              :     }
      23         5916 : }
      24              : 
      25              : /// limit/offset/count handling (6.3.10). Returns (page, count, link headers).
      26              : /// 4.12/5.5.9.1 Pagination: L = client limit (Mc) or the default (Md); at
      27              : /// most L elements per page; remaining elements are flagged with a next
      28              : /// pointer carrying every parameter needed to fetch the page, prev on every
      29              : /// iteration but the first, and only prev on the last. Shared by every
      30              : /// paginated list operation (5.7.2, 5.7.4, 5.8.4, 5.10.2, 5.11.5).
      31         1616 : pub fn paginate(
      32         1616 :     st: &AppState,
      33         1616 :     params: &HashMap<String, String>,
      34         1616 :     matches: Vec<Value>,
      35         1616 :     path: &str,
      36         1616 : ) -> ApiResult<(Vec<Value>, Option<usize>, Vec<String>)> {
      37         1616 :     paginate_impl(st, params, matches, path, Accept::Json, None, false)
      38         1616 : }
      39              : 
      40              : /// The store already applied ORDER BY id + LIMIT/OFFSET and counted the
      41              : /// match set — `matches` IS the page; only count/links remain.
      42         1644 : pub fn paginate_pre(
      43         1644 :     st: &AppState,
      44         1644 :     params: &HashMap<String, String>,
      45         1644 :     page: Vec<Value>,
      46         1644 :     path: &str,
      47         1644 :     total: usize,
      48         1644 : ) -> ApiResult<(Vec<Value>, Option<usize>, Vec<String>)> {
      49         1644 :     paginate_impl(st, params, page, path, Accept::Json, Some(total), false)
      50         1644 : }
      51              : 
      52              : /// `paginate_pre` for a caller that negotiated a media type: the store
      53              : /// already applied ORDER BY id + LIMIT/OFFSET and counted the match set.
      54              : /// `csource`: a csource resource's Link order (see [`paginate_impl`]).
      55          214 : pub fn paginate_pre_accept(
      56          214 :     st: &AppState,
      57          214 :     params: &HashMap<String, String>,
      58          214 :     page: Vec<Value>,
      59          214 :     path: &str,
      60          214 :     accept: Accept,
      61          214 :     total: usize,
      62          214 :     csource: bool,
      63          214 : ) -> ApiResult<(Vec<Value>, Option<usize>, Vec<String>)> {
      64          214 :     paginate_impl(st, params, page, path, accept, Some(total), csource)
      65          214 : }
      66              : 
      67              : /// The query parameter carrying the keyset cursor of an Entity query's
      68              : /// rel="next" Link. The next page then seeks the primary key past
      69              : /// the last id shown instead of skipping `offset` rows; the Link still
      70              : /// carries `offset`, so the page numbers a client reads stay true.
      71              : pub(crate) const CURSOR: &str = "cursor";
      72              : /// The longest cursor read: an Entity id is at most 256 bytes as hex, plus
      73              : /// the tag.
      74              : const CURSOR_MAX: usize = 1024;
      75              : 
      76              : /// Whether an Entity query's next page may be read by keyset: a page the
      77              : /// store keeps in id order whose total nobody asked for (a counted page
      78              : /// still counts the whole set, which the keyset predicate would cut).
      79         1550 : pub(crate) fn keyset_allowed(params: &HashMap<String, String>) -> bool {
      80         1550 :     params.get("count").map(String::as_str) != Some("true") && !params.contains_key("orderBy")
      81         1550 : }
      82              : 
      83              : /// The view a cursor is bound to: the plain API.
      84         4307 : pub(crate) fn view_of(_st: &AppState) -> String {
      85         4307 :     String::new()
      86         4307 : }
      87              : 
      88              : /// The tag binding a cursor to its view, Tenant, every other parameter of
      89              : /// the request it is sent with, and the id it names. Not a secret: a
      90              : /// forged cursor can only start its own query at another id, which `q` or
      91              : /// `id` already let the caller ask; the tag makes a cursor replayed on
      92              : /// another view, Tenant or query a 400 instead of a silently shifted page.
      93          979 : fn cursor_tag(view: &str, tenant: &str, params: &HashMap<String, String>, last: &str) -> String {
      94              :     use sha2::{Digest, Sha256};
      95         3054 :     let mut kv: Vec<(&String, &String)> = params.iter().filter(|(k, _)| *k != CURSOR).collect();
      96          979 :     kv.sort();
      97          979 :     let mut h = Sha256::new();
      98         2937 :     for part in [
      99          979 :         b"antares-cursor-1".as_slice(),
     100          979 :         view.as_bytes(),
     101          979 :         tenant.as_bytes(),
     102         2937 :     ] {
     103         2937 :         h.update(part);
     104         2937 :         h.update([0]);
     105         2937 :     }
     106         3050 :     for (k, v) in kv {
     107         3050 :         h.update(k.as_bytes());
     108         3050 :         h.update([0]);
     109         3050 :         h.update(v.as_bytes());
     110         3050 :         h.update([0]);
     111         3050 :     }
     112          979 :     h.update(last.as_bytes());
     113          979 :     let digest = h.finalize();
     114          979 :     hex(digest.get(..12).unwrap_or(&digest))
     115          979 : }
     116              : 
     117         1954 : fn hex(bytes: &[u8]) -> String {
     118              :     use std::fmt::Write as _;
     119         1954 :     bytes.iter().fold(
     120         1954 :         String::with_capacity(bytes.len().saturating_mul(2)),
     121        38026 :         |mut out, b| {
     122        38026 :             let _ = write!(out, "{b:02x}");
     123        38026 :             out
     124        38026 :         },
     125              :     )
     126         1954 : }
     127              : 
     128              : /// The cursor of the page after `last` for the request `next` (the
     129              : /// parameters the next Link carries, without the cursor itself).
     130          975 : pub(crate) fn encode_cursor(
     131          975 :     view: &str,
     132          975 :     tenant: &str,
     133          975 :     next: &HashMap<String, String>,
     134          975 :     last: &str,
     135          975 : ) -> String {
     136          975 :     format!(
     137              :         "{}.{}",
     138          975 :         hex(last.as_bytes()),
     139          975 :         cursor_tag(view, tenant, next, last)
     140              :     )
     141          975 : }
     142              : 
     143              : /// The id a request's cursor seeks past, `None` without one. A cursor not
     144              : /// made for exactly this view, Tenant and query is BadRequestData.
     145         3033 : pub(crate) fn cursor_after(
     146         3033 :     view: &str,
     147         3033 :     tenant: &str,
     148         3033 :     params: &HashMap<String, String>,
     149         3033 : ) -> Result<Option<String>, NgsiError> {
     150         3033 :     let Some(c) = params.get(CURSOR) else {
     151         3029 :         return Ok(None);
     152              :     };
     153            4 :     let refused = || {
     154            0 :         NgsiError::BadRequestData(
     155            0 :             "cursor does not belong to this query; follow the next Link of its first page again"
     156            0 :                 .into(),
     157            0 :         )
     158            0 :     };
     159            4 :     if c.len() > CURSOR_MAX || !keyset_allowed(params) {
     160            0 :         return Err(refused());
     161            4 :     }
     162            4 :     let (id_hex, tag) = c.split_once('.').ok_or_else(refused)?;
     163            4 :     let bytes = (0..id_hex.len())
     164            4 :         .step_by(2)
     165           72 :         .map(|i| {
     166           72 :             id_hex
     167           72 :                 .get(i..i.saturating_add(2))
     168           72 :                 .and_then(|h| u8::from_str_radix(h, 16).ok())
     169           72 :         })
     170            4 :         .collect::<Option<Vec<u8>>>()
     171            4 :         .filter(|b| !b.is_empty() && b.len().saturating_mul(2) == id_hex.len())
     172            4 :         .ok_or_else(refused)?;
     173            4 :     let last = String::from_utf8(bytes).map_err(|_| refused())?;
     174            4 :     if cursor_tag(view, tenant, params, &last) != tag {
     175            0 :         return Err(refused());
     176            4 :     }
     177            4 :     Ok(Some(last))
     178         3033 : }
     179              : 
     180              : /// The next Link of `links` with the cursor of the page after `last`
     181              : /// added; every other Link unchanged.
     182         1274 : pub(crate) fn with_next_cursor(
     183         1274 :     links: Vec<String>,
     184         1274 :     view: &str,
     185         1274 :     tenant: &str,
     186         1274 :     params: &HashMap<String, String>,
     187         1274 :     last: Option<&str>,
     188         1274 :     next_offset: usize,
     189         1274 : ) -> Vec<String> {
     190         1274 :     let Some(last) = last else {
     191          299 :         return links;
     192              :     };
     193          975 :     let mut next = params.clone();
     194          975 :     next.remove(CURSOR);
     195          975 :     next.insert("offset".into(), next_offset.to_string());
     196          975 :     let cursor = encode_cursor(view, tenant, &next, last);
     197          975 :     links
     198          975 :         .into_iter()
     199          975 :         .map(|l| match l.split_once(">; rel=\"next\"") {
     200           52 :             Some((target, rest)) => format!("{target}&{CURSOR}={cursor}>; rel=\"next\"{rest}"),
     201           32 :             None => l,
     202           84 :         })
     203          975 :         .collect()
     204         1274 : }
     205              : 
     206              : /// 4.12 Pagination: clients specify a limit (page size), the server defines
     207              : /// a default page size, and a hard ceiling is rejected with TooManyResults
     208              : /// rather than silently clamped. The limit/offset/count triple of 6.3.10,
     209              : /// validated (ceilings included). Shared by `paginate_impl` and the
     210              : /// pushdown gate so the two paths can never disagree on what a page is.
     211         7084 : pub fn page_params(
     212         7084 :     st: &AppState,
     213         7084 :     params: &HashMap<String, String>,
     214         7084 : ) -> ApiResult<(usize, usize, bool)> {
     215         7084 :     let count = params.get("count").map(String::as_str) == Some("true");
     216         7084 :     let limit: usize = match params.get("limit") {
     217         1532 :         Some(l) => l
     218         1532 :             .parse()
     219         1532 :             .map_err(|_| NgsiError::BadRequestData(format!("invalid limit {l:?}")))?,
     220         5552 :         None => st.default_limit,
     221              :     };
     222              :     // 5.5.6: "so many results that can potentially exhaust client or server
     223              :     // resources" — the implementation threshold is max_limit; 403
     224              :     // TooManyResults, not silent clamping.
     225         7056 :     if limit > st.max_limit {
     226           16 :         return Err(NgsiError::TooManyResults(format!(
     227           16 :             "limit {limit} exceeds the server maximum {}",
     228           16 :             st.max_limit
     229           16 :         ))
     230           16 :         .into());
     231         7040 :     }
     232         7040 :     if limit == 0 && !count {
     233           12 :         return Err(
     234           12 :             NgsiError::BadRequestData("limit=0 requires count=true (6.3.10)".into()).into(),
     235           12 :         );
     236         7028 :     }
     237         7028 :     let offset: usize = match params.get("offset") {
     238          760 :         Some(o) => o
     239          760 :             .parse()
     240          760 :             .map_err(|_| NgsiError::BadRequestData(format!("invalid offset {o:?}")))?,
     241         6268 :         None => 0,
     242              :     };
     243              :     // An offset above i64::MAX wraps negative when bound as SQL `$n::bigint`
     244              :     // (Postgres then rejects a negative OFFSET → 500). Reject it as a bad
     245              :     // precondition instead.
     246         7000 :     if offset > i64::MAX as usize {
     247            4 :         return Err(NgsiError::BadRequestData(format!("offset {offset} is out of range")).into());
     248         6996 :     }
     249         6996 :     Ok((offset, limit, count))
     250         7084 : }
     251              : 
     252              : /// 6.3.10: next/prev Links carry the response media type; the suite asserts
     253              : /// `;type="application/ld+json"` on ld+json list responses (031_02). The
     254              : /// Links keep a csource resource's order (the registration list's).
     255          206 : pub fn paginate_accept(
     256          206 :     st: &AppState,
     257          206 :     params: &HashMap<String, String>,
     258          206 :     matches: Vec<Value>,
     259          206 :     path: &str,
     260          206 :     accept: Accept,
     261          206 : ) -> ApiResult<(Vec<Value>, Option<usize>, Vec<String>)> {
     262          206 :     paginate_impl(st, params, matches, path, accept, None, true)
     263          206 : }
     264              : 
     265              : /// The media type a page Link names: "exactly equal to the media type
     266              : /// resulting from the original request" (6.3.10, TS 104 176 6.4.7.3).
     267         1038 : pub(crate) fn link_media_type(accept: Accept) -> &'static str {
     268         1038 :     match accept {
     269           34 :         Accept::LdJson => "application/ld+json",
     270         1004 :         Accept::Json => "application/json",
     271            0 :         Accept::GeoJson => "application/geo+json",
     272              :     }
     273         1038 : }
     274              : 
     275         3680 : fn paginate_impl(
     276         3680 :     st: &AppState,
     277         3680 :     params: &HashMap<String, String>,
     278         3680 :     matches: Vec<Value>,
     279         3680 :     path: &str,
     280         3680 :     accept: Accept,
     281         3680 :     pre: Option<usize>,
     282         3680 :     csource_style: bool,
     283         3680 : ) -> ApiResult<(Vec<Value>, Option<usize>, Vec<String>)> {
     284         3680 :     let (offset, limit, count) = page_params(st, params)?;
     285         3672 :     let total = pre.unwrap_or(matches.len());
     286         3672 :     let page: Vec<Value> = match pre {
     287         1858 :         Some(_) => matches, // already exactly the page (store pushdown)
     288         1814 :         None => matches.into_iter().skip(offset).take(limit).collect(),
     289              :     };
     290         3672 :     let mut links = Vec::new();
     291              :     // csource resources: the suite string-compares links against
     292              :     // `?other…&limit=N&offset=M` order (037_11, 041_03); entity lists keep
     293              :     // sorted params (031_02). The type suffix follows Accept for both.
     294         3672 :     let mut mk = |off: usize, rel: &str| {
     295              :         let mut qp: Vec<String>;
     296          562 :         if csource_style {
     297           84 :             qp = params
     298           84 :                 .iter()
     299          246 :                 .filter(|(k, _)| !matches!(k.as_str(), "offset" | "limit"))
     300          100 :                 .map(|(k, v)| format!("{k}={}", query_value(v)))
     301           84 :                 .collect();
     302           84 :             qp.sort();
     303           84 :             if let Some(l) = params.get("limit") {
     304           84 :                 qp.push(format!("limit={l}"));
     305           84 :             }
     306           84 :             qp.push(format!("offset={off}"));
     307              :         } else {
     308          478 :             qp = params
     309          478 :                 .iter()
     310         2268 :                 .filter(|(k, _)| !matches!(k.as_str(), "offset" | CURSOR))
     311         1844 :                 .map(|(k, v)| format!("{k}={}", query_value(v)))
     312          478 :                 .collect();
     313          478 :             qp.push(format!("offset={off}"));
     314          478 :             qp.sort(); // deterministic order — the suite string-compares links
     315              :         }
     316              :         // 6.3.10: "At least, the type Link Target Attribute shall be included
     317              :         // ... and its value shall be exactly equal to the media type resulting
     318              :         // from the original request" — for EVERY media type, not just ld+json.
     319          562 :         let ty = link_media_type(accept);
     320          562 :         links.push(format!(
     321              :             "<{path}?{}>; rel=\"{rel}\";type=\"{ty}\"",
     322          562 :             qp.join("&")
     323              :         ));
     324          562 :     };
     325         3672 :     if offset.saturating_add(limit) < total && limit > 0 {
     326          288 :         mk(offset.saturating_add(limit), "next");
     327         3384 :     }
     328         3672 :     if offset > 0 {
     329          274 :         mk(offset.saturating_sub(limit.max(1)), "prev");
     330         3398 :     }
     331         3672 :     Ok((page, count.then_some(total), links))
     332         3680 : }
     333              : 
     334              : /// Sort by an orderBy spec: comma-separated `member[;asc|desc]`.
     335              : /// 4.23 Entity Ordering: orderBy = `AttrName[;direction] *(, …)` with asc
     336              : /// (default) / desc / dist-asc / dist-desc (4.23.3); distance keys need the
     337              : /// orderFrom reference coordinates (orderGeometry, default Point) and apply
     338              : /// to GeoProperties — non-GeoProperties fall back to value order after them
     339              : /// (4.23.2). Mixed datatypes rank Numbers < Strings < Object < Array <
     340              : /// Boolean < Time < Date < DateTime < Null < absent (4.23.2). Paths may be
     341              : /// dotted (EXAMPLE 5) or carry one trailing [member.path] bracket
     342              : /// (EXAMPLE 4). String comparison is the ICU root collation by default
     343              : /// (4.23.1: "und-x-icu"); the `collation` parameter selects another ICU
     344              : /// collation (4.23.3 EXAMPLES 6/7).
     345              : ///
     346              : /// 4.23.3 EXAMPLES 6/7: the ICU collator for an RFC 6067 collation tag
     347              : /// (e.g. und-u-ks-identic, de-u-co-phonebk). The co/kf/kn keywords travel
     348              : /// via CollatorPreferences; the -u-ks strength keyword maps onto
     349              : /// CollatorOptions. Invalid/unsupported tags are BadRequestData.
     350              : /// 5.7.2.4 / 5.7.4.4: "If a preferred collation setting is present and it
     351              : /// does not conform to a valid ICU collation (see IETF RFC 6067 \[36\]) then an
     352              : /// error of type BadRequestData shall be raised." The clause names the
     353              : /// parameter's presence, not an `orderBy` that happens to consume it, so the
     354              : /// check runs on every operation that accepts `collation`.
     355         3370 : pub fn check_collation(params: &HashMap<String, String>) -> Result<(), NgsiError> {
     356         3370 :     match params.get("collation") {
     357           18 :         Some(tag) => build_collator(tag).map(|_| ()),
     358         3352 :         None => Ok(()),
     359              :     }
     360         3370 : }
     361              : 
     362          372 : fn build_collator(tag: &str) -> Result<icu_collator::CollatorBorrowed<'static>, NgsiError> {
     363          372 :     let bad = NgsiError::BadRequestData;
     364          372 :     let locale: icu_locale_core::Locale = tag.parse().map_err(|_| {
     365           14 :         bad(format!(
     366           14 :             "collation is not an RFC 6067 tag: {tag:?} (4.23.3)"
     367           14 :         ))
     368           14 :     })?;
     369          358 :     let mut opts = icu_collator::options::CollatorOptions::default();
     370              :     use icu_collator::options::Strength;
     371              :     use icu_locale_core::extensions::unicode::key;
     372          358 :     if let Some(ks) = locale.extensions.unicode.keywords.get(&key!("ks")) {
     373            4 :         opts.strength = Some(match ks.to_string().as_str() {
     374            4 :             "level1" => Strength::Primary,
     375            0 :             "level2" => Strength::Secondary,
     376            0 :             "level3" => Strength::Tertiary,
     377            0 :             "level4" => Strength::Quaternary,
     378            0 :             "identic" => Strength::Identical,
     379            0 :             other => {
     380            0 :                 return Err(bad(format!(
     381            0 :                     "unknown collation strength {other:?} (4.23.3)"
     382            0 :                 )))
     383              :             }
     384              :         });
     385          354 :     }
     386          358 :     icu_collator::Collator::try_new((&locale).into(), opts)
     387          358 :         .map_err(|_| bad(format!("unsupported collation {tag:?} (4.23.3)")))
     388          372 : }
     389              : 
     390              : /// The expanded Attribute each key of an `orderBy` spec reads, as
     391              : /// [`order_entities`] parses it; `id`, `type`, `createdAt` and `modifiedAt`
     392              : /// are core members, no Attribute.
     393          274 : pub(crate) fn order_heads(spec: &str, ctx: &antares_jsonld::Context) -> Vec<String> {
     394          274 :     spec.split(',')
     395          350 :         .filter_map(|part| {
     396          350 :             let member = part.trim().split(';').next()?.trim();
     397          350 :             let head = member.split('[').next()?.split('.').next()?;
     398          350 :             (!matches!(head, "" | "id" | "type" | "createdAt" | "modifiedAt"))
     399          350 :                 .then(|| ctx.expand_key(head))
     400          350 :         })
     401          274 :         .collect()
     402          274 : }
     403              : 
     404          354 : pub fn order_entities(
     405          354 :     docs: &mut [Value],
     406          354 :     spec: &str,
     407          354 :     params: &HashMap<String, String>,
     408          354 :     ctx: &antares_jsonld::Context,
     409          354 : ) -> Result<(), NgsiError> {
     410              :     #[derive(PartialEq)]
     411              :     enum Dir {
     412              :         Asc,
     413              :         Desc,
     414              :         DistAsc,
     415              :         DistDesc,
     416              :     }
     417              :     struct Key {
     418              :         path: Vec<String>,
     419              :         bracket: Option<Vec<String>>,
     420              :         dir: Dir,
     421              :     }
     422          354 :     let bad = NgsiError::BadRequestData;
     423          354 :     let mut keys = Vec::new();
     424          438 :     for part in spec.split(',') {
     425          438 :         let part = part.trim();
     426          438 :         let (member, dir) = match part.split_once(';') {
     427          236 :             Some((m, d)) => (m.trim(), d.trim()),
     428          202 :             None => (part, "asc"),
     429              :         };
     430          438 :         let dir = match dir {
     431          438 :             "asc" => Dir::Asc,
     432          212 :             "desc" => Dir::Desc,
     433           56 :             "dist-asc" => Dir::DistAsc,
     434           10 :             "dist-desc" => Dir::DistDesc,
     435              :             _ => {
     436            0 :                 return Err(bad(format!(
     437            0 :                     "invalid orderBy direction in {spec:?} (4.23.3)"
     438            0 :                 )))
     439              :             }
     440              :         };
     441              :         // one trailing [member.path] bracket (EXAMPLE 4)
     442          438 :         let (head, bracket) = match member.split_once('[') {
     443           40 :             Some((h, rest)) => {
     444           40 :                 let inner = rest
     445           40 :                     .strip_suffix(']')
     446           40 :                     .ok_or_else(|| bad(format!("unclosed bracket in orderBy {spec:?}")))?;
     447           40 :                 (h, Some(inner.split('.').map(str::to_owned).collect()))
     448              :             }
     449          398 :             None => (member, None),
     450              :         };
     451          438 :         if head.is_empty() {
     452            0 :             return Err(bad(format!("invalid orderBy {spec:?} (4.23)")));
     453          438 :         }
     454          438 :         keys.push(Key {
     455          438 :             path: head.split('.').map(str::to_owned).collect(),
     456          438 :             bracket,
     457          438 :             dir,
     458          438 :         });
     459              :     }
     460              :     // 4.23.3 EXAMPLES 6/7: collation names an ICU ordering for strings;
     461              :     // without one, 4.23.1 and 5.2.43 make it the ICU root collation
     462          354 :     let collator = build_collator(params.get("collation").map_or("und", String::as_str))?;
     463              :     // dist-* keys need the orderFrom reference geometry (4.23.3 EXAMPLE 8-10)
     464          350 :     let refg = if keys
     465          350 :         .iter()
     466          434 :         .any(|k| matches!(k.dir, Dir::DistAsc | Dir::DistDesc))
     467              :     {
     468           56 :         let coords_raw = params
     469           56 :             .get("orderFrom")
     470           56 :             .ok_or_else(|| bad("dist ordering requires orderFrom (4.23.3)".into()))?;
     471           38 :         let coords: Value = serde_json::from_str(coords_raw)
     472           38 :             .map_err(|_| bad(format!("invalid orderFrom {coords_raw:?}")))?;
     473           38 :         let gtype = params
     474           38 :             .get("orderGeometry")
     475           38 :             .cloned()
     476           38 :             .unwrap_or_else(|| "Point".into());
     477           38 :         Some(antares_ql::geo::parse_ref_geometry(&gtype, &coords).map_err(bad)?)
     478              :     } else {
     479          294 :         None
     480              :     };
     481         4946 :     fn order_value(doc: &Value, k: &Key, ctx: &antares_jsonld::Context) -> Option<Value> {
     482         4946 :         let path = &k.path;
     483         4946 :         let head = path.first()?;
     484         4946 :         let base = match head.as_str() {
     485         4946 :             "id" | "createdAt" | "modifiedAt" => doc.get(head.as_str()).cloned(),
     486         4362 :             "type" => doc["type"].as_array().and_then(|a| a.first()).cloned(),
     487              :             _ => {
     488         4338 :                 let iri = ctx.expand_key(head);
     489         4338 :                 let inst = doc.get(&iri).and_then(Value::as_array)?.first()?;
     490         4322 :                 let mut cur = inst;
     491         4322 :                 for seg in path.iter().skip(1) {
     492           36 :                     match seg.as_str() {
     493           36 :                         "createdAt" | "modifiedAt" | "observedAt" | "datasetId" | "unitCode" => {
     494           12 :                             cur = cur.get(seg.as_str())?;
     495              :                         }
     496              :                         _ => {
     497           24 :                             let siri = ctx.expand_key(seg);
     498           24 :                             cur = cur
     499           24 :                                 .get(&siri)
     500           24 :                                 .and_then(Value::as_array)
     501           24 :                                 .and_then(|a| a.first())?;
     502              :                         }
     503              :                     }
     504              :                 }
     505         4322 :                 match cur.get("value").or_else(|| cur.get("object")) {
     506         4310 :                     Some(v) => Some(v.clone()),
     507           12 :                     None => Some(cur.clone()),
     508              :                 }
     509              :             }
     510            0 :         }?;
     511         4930 :         match &k.bracket {
     512         4382 :             None => Some(base),
     513          548 :             Some(b) => {
     514          548 :                 let mut cur = &base;
     515          548 :                 for seg in b {
     516          548 :                     cur = cur.get(seg)?;
     517              :                 }
     518          548 :                 Some(cur.clone())
     519              :             }
     520              :         }
     521         4946 :     }
     522              :     /// 4.23.2 datatype rank: Numbers < Strings < Object < Array < Boolean <
     523              :     /// Time < Date < DateTime < Null (absent is handled as Option::None).
     524        38456 :     fn rank(v: &Value) -> u8 {
     525        38456 :         match v {
     526        28544 :             Value::Number(_) => 0,
     527         9796 :             Value::String(s) => {
     528         9796 :                 if antares_jsonld::parse_datetime(s) {
     529           64 :                     7
     530         9732 :                 } else if is_date(s) {
     531           24 :                     6
     532         9708 :                 } else if is_time(s) {
     533           16 :                     5
     534              :                 } else {
     535         9692 :                     1
     536              :                 }
     537              :             }
     538           28 :             Value::Object(_) => 2,
     539           24 :             Value::Array(_) => 3,
     540           32 :             Value::Bool(_) => 4,
     541           32 :             Value::Null => 8,
     542              :         }
     543        38456 :     }
     544              :     /// 4.6.3 Date: YYYY-MM-DD, all components present.
     545         9732 :     fn is_date(s: &str) -> bool {
     546         9732 :         let b = s.as_bytes();
     547         9732 :         b.len() == 10
     548          600 :             && b.get(4) == Some(&b'-')
     549           24 :             && b.get(7) == Some(&b'-')
     550           24 :             && b.iter()
     551           24 :                 .enumerate()
     552          240 :                 .all(|(i, c)| matches!(i, 4 | 7) || c.is_ascii_digit())
     553         9732 :     }
     554              :     /// 4.6.3 Time: hh:mm:ss[.f*]Z.
     555         9708 :     fn is_time(s: &str) -> bool {
     556         9708 :         let b = s.as_bytes();
     557         9708 :         let digits =
     558           48 :             |r: std::ops::Range<usize>| b.get(r).is_some_and(|d| d.iter().all(u8::is_ascii_digit));
     559         9708 :         b.len() >= 9
     560         9472 :             && b.last() == Some(&b'Z')
     561           16 :             && b.get(2) == Some(&b':')
     562           16 :             && b.get(5) == Some(&b':')
     563           16 :             && digits(0..2)
     564           16 :             && digits(3..5)
     565           16 :             && digits(6..8)
     566         9708 :     }
     567        19274 :     fn cmp_vals(
     568        19274 :         a: &Option<Value>,
     569        19274 :         b: &Option<Value>,
     570        19274 :         coll: &icu_collator::CollatorBorrowed<'static>,
     571        19274 :     ) -> std::cmp::Ordering {
     572              :         use std::cmp::Ordering;
     573        19274 :         match (a, b) {
     574            4 :             (None, None) => Ordering::Equal,
     575           10 :             (None, Some(_)) => Ordering::Greater, // absent sorts last (4.23.2)
     576           32 :             (Some(_), None) => Ordering::Less,
     577        19228 :             (Some(x), Some(y)) => {
     578        19228 :                 let (rx, ry) = (rank(x), rank(y));
     579        19228 :                 if rx != ry {
     580          124 :                     return rx.cmp(&ry);
     581        19104 :                 }
     582        19104 :                 match (x, y) {
     583        14256 :                     (Value::Number(_), Value::Number(_)) => x
     584        14256 :                         .as_f64()
     585        14256 :                         .unwrap_or(f64::NAN)
     586        14256 :                         .total_cmp(&y.as_f64().unwrap_or(f64::NAN)),
     587            0 :                     (Value::Bool(bx), Value::Bool(by)) => bx.cmp(by),
     588         4848 :                     (Value::String(sx), Value::String(sy)) => {
     589         4848 :                         if rx == 7 {
     590              :                             // DateTime: canonical key so equal instants in
     591              :                             // different 4.6.3 fraction spellings tie (4.11)
     592           16 :                             antares_model::dt_key(sx).cmp(&antares_model::dt_key(sy))
     593              :                         } else {
     594              :                             // 4.23.1 root by default, 4.23.3 EXAMPLES 6/7
     595              :                             // the named ICU collation
     596         4832 :                             coll.compare(sx, sy)
     597              :                         }
     598              :                     }
     599            0 :                     _ => x.to_string().cmp(&y.to_string()),
     600              :                 }
     601              :             }
     602              :         }
     603        19274 :     }
     604              :     // Each document's ordering values are read once, not twice per comparison:
     605              :     // a comparison sort of n documents compares about n log n pairs, and each
     606              :     // read expands a term and clones a value.
     607          332 :     let distance = |v: &Option<Value>| {
     608           96 :         refg.as_ref()
     609           96 :             .zip(v.as_ref())
     610           96 :             .and_then(|(g, v)| antares_ql::geo::order_distance_m(g, v))
     611           96 :     };
     612              :     // per key: the value it reads and, under a distance key, the distance
     613              :     type Read = Vec<(Option<Value>, Option<f64>)>;
     614          332 :     let mut decorated: Vec<(Read, Value)> = docs
     615          332 :         .iter_mut()
     616         3842 :         .map(|doc| {
     617         3842 :             let read = keys
     618         3842 :                 .iter()
     619         4946 :                 .map(|k| {
     620         4946 :                     let v = order_value(doc, k, ctx);
     621         4946 :                     let d = match k.dir {
     622           96 :                         Dir::DistAsc | Dir::DistDesc => distance(&v),
     623         4850 :                         Dir::Asc | Dir::Desc => None,
     624              :                     };
     625         4946 :                     (v, d)
     626         4946 :                 })
     627         3842 :                 .collect();
     628         3842 :             (read, std::mem::take(doc))
     629         3842 :         })
     630          332 :         .collect();
     631        17992 :     decorated.sort_by(|(a, _), (b, _)| {
     632              :         use std::cmp::Ordering;
     633        19344 :         for (k, ((va, da), (vb, db))) in keys.iter().zip(a.iter().zip(b)) {
     634        19344 :             let o = match k.dir {
     635              :                 Dir::Asc | Dir::Desc => {
     636        19270 :                     let mut o = cmp_vals(va, vb, &collator);
     637        19270 :                     if k.dir == Dir::Desc {
     638        14308 :                         o = o.reverse();
     639        14308 :                     }
     640        19270 :                     o
     641              :                 }
     642           74 :                 Dir::DistAsc | Dir::DistDesc => match (da, db) {
     643           62 :                     (Some(x), Some(y)) => {
     644           62 :                         let mut o = x.total_cmp(y);
     645           62 :                         if k.dir == Dir::DistDesc {
     646           22 :                             o = o.reverse();
     647           40 :                         }
     648           62 :                         o
     649              :                     }
     650              :                     // 4.23.2 distance order: GeoProperties (by distance)
     651              :                     // rank before non-GeoProperties (by value)
     652            8 :                     (Some(_), None) => Ordering::Less,
     653            0 :                     (None, Some(_)) => Ordering::Greater,
     654            4 :                     (None, None) => cmp_vals(va, vb, &collator),
     655              :                 },
     656              :             };
     657        19344 :             if o != Ordering::Equal {
     658        14864 :                 return o;
     659         4480 :             }
     660              :         }
     661         3128 :         Ordering::Equal
     662        17992 :     });
     663         3842 :     for (slot, (_, doc)) in docs.iter_mut().zip(decorated) {
     664         3842 :         *slot = doc;
     665         3842 :     }
     666          332 :     Ok(())
     667          354 : }
     668              : 
     669              : /// 5.2.23 Query: flatten the JSON members into query-param form with the
     670              : /// Table 5.2.23-1 value spaces enforced — entities is a non-empty
     671              : /// EntitySelector[], string members must be strings, string-array members
     672              : /// are non-empty arrays of strings, joinLevel is a positive integer,
     673              : /// entityMap/splitEntities are booleans, geoQ/ordering are objects.
     674              : /// `temporal` selects the "Query Temporal Evolution of Entities" reading:
     675              : /// temporalQ/aggrParams are only allowed there, containedBy only outside it.
     676          833 : pub(crate) fn query_doc_params(
     677          833 :     q: &Map<String, Value>,
     678          833 :     temporal: bool,
     679          833 :     vp: &mut HashMap<String, String>,
     680          833 : ) -> Result<(), NgsiError> {
     681          833 :     let bad = NgsiError::BadRequestData;
     682              :     // A member lifted out of the body becomes the same parameter the GET twin
     683              :     // carries in the URI, where it is capped at MAX_URI_BYTES (6.3.4 bare
     684              :     // 414). Without the same cap here the POST form is the cheap way to hand
     685              :     // the query and projection parsers a multi-megabyte string. That includes
     686              :     // the three parameters assembled from the `entities` selectors below:
     687              :     // there is no cap on the selector array, so a body inside MAX_BODY_BYTES
     688              :     // holds hundreds of thousands of them, each costing one expanded IRI and
     689              :     // one store bind.
     690         1179 :     let capped = |k: &str, s: String| -> Result<String, NgsiError> {
     691         1179 :         if s.len() > crate::bounds::MAX_URI_BYTES {
     692           24 :             return Err(bad(format!(
     693           24 :                 "Query {k} exceeds the {} byte limit",
     694           24 :                 crate::bounds::MAX_URI_BYTES
     695           24 :             )));
     696         1155 :         }
     697         1155 :         Ok(s)
     698         1179 :     };
     699          833 :     match q.get("entities") {
     700           40 :         None => {}
     701          789 :         Some(Value::Array(es)) if !es.is_empty() => {
     702          781 :             let (mut types, mut ids, mut pats) = (Vec::new(), Vec::new(), Vec::new());
     703          781 :             let (mut with_id, mut with_pat) = (0usize, 0usize);
     704         3205 :             for e in es {
     705         3205 :                 if !e.is_object() {
     706            4 :                     return Err(bad(
     707            4 :                         "entities entries must be EntitySelector objects (5.2.33)".into(),
     708            4 :                     ));
     709         3201 :                 }
     710              :                 // Table 5.2.33-1: type is the mandatory selector member (a
     711              :                 // 4.17 type selection, "*" allowed)
     712         3201 :                 match e.get("type") {
     713         3177 :                     Some(Value::String(s)) if !s.is_empty() => types.push(s.clone()),
     714           24 :                     _ => return Err(bad("EntitySelector requires type (5.2.33)".into())),
     715              :                 }
     716              :                 // id: "String or String[]", valid URI(s)
     717         3177 :                 match e.get("id") {
     718         3071 :                     None => {}
     719           86 :                     Some(Value::String(s)) => {
     720           86 :                         antares_model::EntityId::new(s)?;
     721           76 :                         ids.push(s.clone());
     722           76 :                         with_id = with_id.saturating_add(1);
     723              :                     }
     724           20 :                     Some(Value::Array(a)) => {
     725           36 :                         for i in a {
     726           36 :                             let s = i.as_str().ok_or_else(|| {
     727            8 :                                 bad("EntitySelector id entries must be URIs (5.2.33)".into())
     728            8 :                             })?;
     729           28 :                             antares_model::EntityId::new(s)?;
     730           28 :                             ids.push(s.to_owned());
     731              :                         }
     732           12 :                         with_id = with_id.saturating_add(1);
     733              :                     }
     734              :                     Some(_) => {
     735            0 :                         return Err(bad(
     736            0 :                             "EntitySelector id must be a URI string or array (5.2.33)".into(),
     737            0 :                         ))
     738              :                     }
     739              :                 }
     740         3159 :                 match e.get("idPattern") {
     741         3125 :                     None => {}
     742           34 :                     Some(Value::String(s)) => {
     743           34 :                         pats.push(s.clone());
     744           34 :                         with_pat = with_pat.saturating_add(1);
     745           34 :                     }
     746              :                     Some(_) => {
     747            0 :                         return Err(bad(
     748            0 :                             "EntitySelector idPattern must be a string (5.2.33)".into()
     749            0 :                         ))
     750              :                     }
     751              :                 }
     752              :             }
     753          735 :             if !types.is_empty() {
     754          735 :                 vp.insert("type".into(), capped("entities type", types.join(","))?);
     755            0 :             }
     756              :             // 5.2.33: the selectors are a union, and "id takes precedence over
     757              :             // idPattern" holds PER selector. These flat params carry a single
     758              :             // id/idPattern pair applied to the whole result, so a member is
     759              :             // only emitted when every selector agrees on it — otherwise one
     760              :             // selector's id would filter away the Entities another selector
     761              :             // selects on its own. Where they disagree the type predicate alone
     762              :             // stands, which over-matches rather than losing Entities.
     763          731 :             if with_id == es.len() && !ids.is_empty() {
     764           72 :                 vp.insert("id".into(), capped("entities id", ids.join(","))?);
     765          659 :             }
     766          731 :             if with_id == 0 && with_pat == es.len() && !pats.is_empty() {
     767           14 :                 vp.insert(
     768           14 :                     "idPattern".into(),
     769           14 :                     capped("entities idPattern", pats.join("|"))?,
     770              :                 );
     771          717 :             }
     772              :         }
     773              :         Some(_) => {
     774           12 :             return Err(bad(
     775           12 :                 "entities must be a non-empty EntitySelector array (5.2.23)".into(),
     776           12 :             ))
     777              :         }
     778              :     }
     779         5209 :     for k in [
     780          771 :         "q",
     781          771 :         "scopeQ",
     782          771 :         "csf",
     783          771 :         "lang",
     784          771 :         "join",
     785          771 :         "expandValues",
     786          771 :         "jsonKeys",
     787          771 :     ] {
     788         5209 :         match q.get(k) {
     789         5057 :             None => {}
     790          140 :             Some(Value::String(s)) => {
     791          140 :                 if k == "scopeQ" {
     792           36 :                     antares_ql::scope::check_scope_q(s)?;
     793          104 :                 }
     794          120 :                 vp.insert(k.into(), capped(k, s.clone())?);
     795              :             }
     796           12 :             Some(_) => return Err(bad(format!("Query {k} must be a string (5.2.23)"))),
     797              :         }
     798              :     }
     799              :     // string-array members; "Empty array (0 length) is not allowed"
     800         3587 :     for k in ["attrs", "pick", "omit", "containedBy", "datasetId"] {
     801         3587 :         match q.get(k) {
     802         3505 :             None => {}
     803           78 :             Some(Value::Array(a)) if !a.is_empty() => {
     804           62 :                 if k == "containedBy" && temporal {
     805            4 :                     return Err(bad(
     806            4 :                         "containedBy is only applicable to Retrieve Entity and Query Entities (5.2.23)"
     807            4 :                             .into(),
     808            4 :                     ));
     809           58 :                 }
     810           58 :                 let mut parts = Vec::with_capacity(a.len());
     811         2458 :                 for m in a {
     812         2458 :                     parts.push(m.as_str().ok_or_else(|| {
     813            4 :                         bad(format!("Query {k} entries must be strings (5.2.23)"))
     814            4 :                     })?);
     815              :                 }
     816           54 :                 vp.insert(k.into(), capped(k, parts.join(","))?);
     817              :             }
     818              :             Some(_) => {
     819           20 :                 return Err(bad(format!(
     820           20 :                     "Query {k} must be a non-empty array of strings (5.2.23)"
     821           20 :                 )))
     822              :             }
     823              :         }
     824              :     }
     825          703 :     if let Some(n) = q.get("joinLevel") {
     826           14 :         let v = n
     827           14 :             .as_u64()
     828           14 :             .filter(|v| *v >= 1)
     829           14 :             .ok_or_else(|| bad("Query joinLevel must be a positive integer (5.2.23)".into()))?;
     830            2 :         vp.insert("joinLevel".into(), v.to_string());
     831          689 :     }
     832         1378 :     for k in ["entityMap", "splitEntities"] {
     833         1378 :         match q.get(k) {
     834         1358 :             None => {}
     835           12 :             Some(Value::Bool(b)) => {
     836           12 :                 vp.insert(k.into(), b.to_string());
     837           12 :             }
     838            8 :             Some(_) => return Err(bad(format!("Query {k} must be a boolean (5.2.23)"))),
     839              :         }
     840              :     }
     841          683 :     if let Some(l) = q.get("entityMapLifetime") {
     842              :         // ISO 8601 duration; EntityMap lifetimes are the broker's call
     843              :         // ("possibly overriding the requested duration") — 5.14.x surface.
     844            0 :         if !l.is_string() {
     845            0 :             return Err(bad(
     846            0 :                 "Query entityMapLifetime must be a string (5.2.23)".into()
     847            0 :             ));
     848            0 :         }
     849          683 :     }
     850          683 :     match q.get("geoQ") {
     851          651 :         None => {}
     852           28 :         Some(Value::Object(g)) => {
     853           76 :             for k in ["georel", "geometry", "geoproperty"] {
     854           76 :                 match g.get(k) {
     855           24 :                     None => {}
     856           52 :                     Some(Value::String(s)) => {
     857           52 :                         vp.insert(k.into(), capped(k, s.clone())?);
     858              :                     }
     859            0 :                     Some(_) => return Err(bad(format!("geoQ {k} must be a string (5.2.13)"))),
     860              :                 }
     861              :             }
     862              :             // `coordinates` is the one lifted member NOT capped in bytes: its
     863              :             // ceiling is MAX_GEO_VERTICES (1024), which a legal polygon can
     864              :             // spend more than MAX_URI_BYTES on. One cap per parameter, the
     865              :             // one that governs it.
     866           24 :             if let Some(c) = g.get("coordinates") {
     867           24 :                 vp.insert(
     868           24 :                     "coordinates".into(),
     869           24 :                     match c {
     870            6 :                         Value::String(s) => s.clone(),
     871           18 :                         other => other.to_string(),
     872              :                     },
     873              :                 );
     874            0 :             }
     875              :         }
     876            4 :         Some(_) => return Err(bad("geoQ must be a GeoQuery object (5.2.13)".into())),
     877              :     }
     878          675 :     match q.get("temporalQ") {
     879          457 :         None => {}
     880          218 :         Some(Value::Object(tq)) if temporal => temporal_q_params(tq, vp)?,
     881              :         Some(Value::Object(_)) => {
     882            8 :             return Err(bad(
     883            8 :                 "temporalQ is only allowed for Query Temporal Evolution of Entities (5.2.23)"
     884            8 :                     .into(),
     885            8 :             ))
     886              :         }
     887              :         Some(_) => {
     888            0 :             return Err(bad(
     889            0 :                 "temporalQ must be a TemporalQuery object (5.2.21)".into()
     890            0 :             ))
     891              :         }
     892              :     }
     893          623 :     match q.get("aggrParams") {
     894          579 :         None => {}
     895           40 :         Some(Value::Object(ap)) if temporal => {
     896              :             // 5.2.44 AggregationParams: aggrMethods + aggrPeriodDuration
     897           36 :             match ap.get("aggrMethods") {
     898            0 :                 None => {}
     899            4 :                 Some(Value::String(s)) => {
     900            4 :                     vp.insert("aggrMethods".into(), capped("aggrMethods", s.clone())?);
     901              :                 }
     902           28 :                 Some(Value::Array(a)) => {
     903           28 :                     let mut parts = Vec::with_capacity(a.len());
     904         2424 :                     for m in a {
     905         2424 :                         parts.push(m.as_str().ok_or_else(|| {
     906            0 :                             bad("aggrParams aggrMethods entries must be strings (5.2.44)".into())
     907            0 :                         })?);
     908              :                     }
     909           28 :                     vp.insert(
     910           28 :                         "aggrMethods".into(),
     911           28 :                         capped("aggrMethods", parts.join(","))?,
     912              :                     );
     913              :                 }
     914              :                 Some(_) => {
     915            4 :                     return Err(bad(
     916            4 :                         "aggrParams aggrMethods must be a comma separated list of strings (5.2.44)"
     917            4 :                             .into(),
     918            4 :                     ))
     919              :                 }
     920              :             }
     921           28 :             match ap.get("aggrPeriodDuration") {
     922           12 :                 None => {}
     923           16 :                 Some(Value::String(s)) => {
     924           16 :                     vp.insert(
     925           16 :                         "aggrPeriodDuration".into(),
     926           16 :                         capped("aggrPeriodDuration", s.clone())?,
     927              :                     );
     928              :                 }
     929              :                 Some(_) => {
     930            0 :                     return Err(bad(
     931            0 :                         "aggrParams aggrPeriodDuration must be a string (5.2.44)".into(),
     932            0 :                     ))
     933              :                 }
     934              :             }
     935              :         }
     936              :         Some(Value::Object(_)) => {
     937            4 :             return Err(bad(
     938            4 :                 "aggrParams is only allowed for Query Temporal Evolution of Entities (5.2.23)"
     939            4 :                     .into(),
     940            4 :             ))
     941              :         }
     942              :         Some(_) => {
     943            4 :             return Err(bad(
     944            4 :                 "aggrParams must be an AggregationParams object (5.2.44)".into(),
     945            4 :             ))
     946              :         }
     947              :     }
     948          607 :     match q.get("ordering") {
     949          545 :         None => {}
     950           58 :         Some(Value::Object(o)) => {
     951              :             // Table 5.2.43-1 (OrderingParams): orderBy String[] -> the 4.23
     952              :             // keys; coordinates (JSON array) + geometry (default "Point")
     953              :             // -> the dist-ordering reference (orderFrom/orderGeometry).
     954              :             // Every member lifted here takes the same MAX_URI_BYTES cap as
     955              :             // the rest of the body, for the same reason: it becomes the
     956              :             // parameter the GET twin carries in its URI. `coordinates` is
     957              :             // again the exception — its ceiling is MAX_GEO_VERTICES, the one
     958              :             // that governs a reference geometry.
     959           58 :             if let Some(ob) = o.get("orderBy") {
     960           58 :                 let a = ob.as_array().ok_or_else(|| {
     961            0 :                     bad("ordering orderBy must be an array of strings (5.2.43)".into())
     962            0 :                 })?;
     963           58 :                 let mut parts = Vec::with_capacity(a.len());
     964         2458 :                 for m in a {
     965         2458 :                     parts.push(m.as_str().ok_or_else(|| {
     966            0 :                         bad("ordering orderBy entries must be strings (5.2.43)".into())
     967            0 :                     })?);
     968              :                 }
     969           58 :                 vp.insert("orderBy".into(), capped("orderBy", parts.join(","))?);
     970            0 :             }
     971           54 :             match o.get("coordinates") {
     972           38 :                 None => {}
     973            8 :                 Some(Value::Array(c)) => {
     974            8 :                     vp.insert("orderFrom".into(), Value::Array(c.clone()).to_string());
     975            8 :                 }
     976              :                 Some(_) => {
     977            8 :                     return Err(bad(
     978            8 :                         "ordering coordinates must be a JSON array (5.2.43)".into()
     979            8 :                     ))
     980              :                 }
     981              :             }
     982           46 :             match o.get("geometry") {
     983           30 :                 None => {}
     984            8 :                 Some(Value::String(g)) => {
     985            8 :                     vp.insert("orderGeometry".into(), capped("orderGeometry", g.clone())?);
     986              :                 }
     987            8 :                 Some(_) => return Err(bad("ordering geometry must be a string (5.2.43)".into())),
     988              :             }
     989           38 :             match o.get("collation") {
     990           16 :                 None => {}
     991           18 :                 Some(Value::String(c)) => {
     992           18 :                     vp.insert("collation".into(), capped("collation", c.clone())?);
     993              :                 }
     994            4 :                 Some(_) => return Err(bad("ordering collation must be a string (5.2.43)".into())),
     995              :             }
     996              :         }
     997              :         Some(_) => {
     998            4 :             return Err(bad(
     999            4 :                 "ordering must be an OrderingParams object (5.2.43)".into()
    1000            4 :             ))
    1001              :         }
    1002              :     }
    1003          579 :     Ok(())
    1004          833 : }
    1005              : 
    1006              : /// Percent-encode one client-controlled value for use as a query-string
    1007              : /// value (RFC 3986 clause 3.4: a query is made of `pchar`, `/` and `?`).
    1008              : /// Parameters reach a handler already percent-decoded, so a value spliced
    1009              : /// back into a URI raw would change the query it belongs to (`&` and `=`
    1010              : /// start another parameter, `%` re-decodes, `+` reads back as a space) and,
    1011              : /// in a Link header, `>` would end the link-value (RFC 8288 clause 3).
    1012         3804 : pub(crate) fn query_value(s: &str) -> String {
    1013         3868 :     pct_encode(s, |b| {
    1014          710 :         matches!(
    1015         3164 :             b,
    1016              :             b'-' | b'.'
    1017              :                 | b'_'
    1018              :                 | b'~'
    1019              :                 | b'!'
    1020              :                 | b'$'
    1021              :                 | b'\''
    1022              :                 | b'('
    1023              :                 | b')'
    1024              :                 | b'*'
    1025              :                 | b','
    1026              :                 | b';'
    1027              :                 | b':'
    1028              :                 | b'@'
    1029              :                 | b'/'
    1030              :                 | b'?'
    1031              :         )
    1032         3164 :     })
    1033         3804 : }
    1034              : 
    1035              : /// RFC 3986 clause 2.1: every byte the caller does not keep becomes its
    1036              : /// percent-encoded triplet; ASCII letters and digits are always kept.
    1037        19034 : pub(crate) fn pct_encode(s: &str, keep: impl Fn(u8) -> bool) -> String {
    1038        19034 :     let mut out = String::with_capacity(s.len());
    1039       440032 :     for b in s.bytes() {
    1040       440032 :         if b.is_ascii_alphanumeric() || keep(b) {
    1041       439174 :             out.push(b as char);
    1042       439174 :         } else {
    1043          858 :             out.push_str(&format!("%{b:02X}"));
    1044          858 :         }
    1045              :     }
    1046        19034 :     out
    1047        19034 : }
    1048              : 
    1049              : /// 5.2.21 TemporalQuery (JSON form): flatten the object's members into
    1050              : /// query-param form, enforcing the Table 5.2.21-1 value spaces — the string
    1051              : /// members must be JSON strings, aggrMethods a comma separated list of
    1052              : /// string (string or string-array spelling), lastN a positive integer.
    1053              : /// Vocabulary/range rules are then enforced by the shared param validators
    1054              : /// (TemporalQ::from_params, parse_trepr).
    1055          254 : pub(crate) fn temporal_q_params(
    1056          254 :     tq: &Map<String, Value>,
    1057          254 :     out: &mut HashMap<String, String>,
    1058          254 : ) -> Result<(), NgsiError> {
    1059          254 :     let bad = NgsiError::BadRequestData;
    1060         1258 :     for k in [
    1061          254 :         "timerel",
    1062          254 :         "timeAt",
    1063          254 :         "endTimeAt",
    1064          254 :         "timeproperty",
    1065          254 :         "aggrPeriodDuration",
    1066          254 :     ] {
    1067         1258 :         match tq.get(k) {
    1068          708 :             None => {}
    1069          546 :             Some(Value::String(s)) => {
    1070          546 :                 out.insert(k.into(), s.clone());
    1071          546 :             }
    1072            4 :             Some(_) => return Err(bad(format!("temporalQ {k} must be a string (5.2.21)"))),
    1073              :         }
    1074              :     }
    1075              :     // TS 104 175 5.2.5: firstN, lastN and offsetN are TemporalParams
    1076              :     // scoped within temporalQ; the query-string parser judges the pair
    1077          698 :     for (k, min, what) in [
    1078          250 :         ("lastN", 1, "a positive"),
    1079          250 :         ("firstN", 1, "a positive"),
    1080          250 :         ("offsetN", 0, "a non-negative"),
    1081          250 :     ] {
    1082          698 :         if let Some(n) = tq.get(k) {
    1083           60 :             let v = n
    1084           60 :                 .as_u64()
    1085           60 :                 .filter(|v| *v >= min)
    1086           60 :                 .ok_or_else(|| bad(format!("temporalQ {k} must be {what} integer (5.2.21)")))?;
    1087           24 :             out.insert(k.into(), v.to_string());
    1088          638 :         }
    1089              :     }
    1090          214 :     match tq.get("aggrMethods") {
    1091          182 :         None => {}
    1092            8 :         Some(Value::String(s)) => {
    1093            8 :             out.insert("aggrMethods".into(), s.clone());
    1094            8 :         }
    1095           20 :         Some(Value::Array(a)) => {
    1096           20 :             let mut parts = Vec::with_capacity(a.len());
    1097           24 :             for m in a {
    1098           24 :                 parts.push(m.as_str().ok_or_else(|| {
    1099            0 :                     bad("temporalQ aggrMethods entries must be strings (5.2.21)".into())
    1100            0 :                 })?);
    1101              :             }
    1102           20 :             out.insert("aggrMethods".into(), parts.join(","));
    1103              :         }
    1104              :         Some(_) => {
    1105            4 :             return Err(bad(
    1106            4 :                 "temporalQ aggrMethods must be a comma separated list of string (5.2.21)".into(),
    1107            4 :             ))
    1108              :         }
    1109              :     }
    1110          210 :     Ok(())
    1111          254 : }
    1112              : 
    1113              : #[cfg(test)]
    1114              : mod tests {
    1115              :     use super::*;
    1116              :     use antares_jsonld::Loader;
    1117              :     use serde_json::json;
    1118              : 
    1119              :     const D: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
    1120              : 
    1121           16 :     fn state() -> AppState {
    1122           16 :         AppState::new("http://localhost:9090".into())
    1123           16 :     }
    1124              : 
    1125          168 :     fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
    1126          168 :         pairs
    1127          168 :             .iter()
    1128          172 :             .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
    1129          168 :             .collect()
    1130          168 :     }
    1131              : 
    1132           32 :     fn items(n: usize) -> Vec<Value> {
    1133         8472 :         (0..n).map(|i| json!({"id": format!("urn:{i}")})).collect()
    1134           32 :     }
    1135              : 
    1136          844 :     fn ent(id: &str, attr: &str, v: Value) -> Value {
    1137          844 :         json!({"id": id, "type": ["T"],
    1138          844 :             format!("{D}{attr}"): [{"type": "Property", "value": v}]})
    1139          844 :     }
    1140              : 
    1141           32 :     fn ids(docs: &[Value]) -> Vec<&str> {
    1142         1696 :         docs.iter().map(|d| d["id"].as_str().unwrap()).collect()
    1143           32 :     }
    1144              : 
    1145              :     /// 4.12: clients specify a limit (page size); a next link flags remaining
    1146              :     /// elements; prev enables backwards iteration; absent on the edges.
    1147              :     #[test]
    1148            4 :     fn next_and_prev_flag_remaining_elements() {
    1149            4 :         let st = state();
    1150            4 :         let (page, _, links) = paginate(&st, &params(&[("limit", "1")]), items(3), "/e").unwrap();
    1151            4 :         assert_eq!(page.len(), 1);
    1152            4 :         assert!(links.iter().any(|l| l.contains("rel=\"next\"")));
    1153            4 :         assert!(
    1154            4 :             !links.iter().any(|l| l.contains("rel=\"prev\"")),
    1155              :             "no prev on the first page"
    1156              :         );
    1157            4 :         let (_, _, links) = paginate(
    1158            4 :             &st,
    1159            4 :             &params(&[("limit", "1"), ("offset", "1")]),
    1160            4 :             items(3),
    1161            4 :             "/e",
    1162            4 :         )
    1163            4 :         .unwrap();
    1164            4 :         assert!(links.iter().any(|l| l.contains("rel=\"next\"")));
    1165            8 :         assert!(links.iter().any(|l| l.contains("rel=\"prev\"")));
    1166            4 :         let (_, _, links) = paginate(
    1167            4 :             &st,
    1168            4 :             &params(&[("limit", "1"), ("offset", "2")]),
    1169            4 :             items(3),
    1170            4 :             "/e",
    1171            4 :         )
    1172            4 :         .unwrap();
    1173            4 :         assert!(
    1174            4 :             !links.iter().any(|l| l.contains("rel=\"next\"")),
    1175              :             "no next on the last page"
    1176              :         );
    1177            4 :         assert!(links.iter().any(|l| l.contains("rel=\"prev\"")));
    1178            4 :     }
    1179              : 
    1180              :     /// 4.12: "define a default limit (default page size)" — applied when the
    1181              :     /// client sends none.
    1182              :     #[test]
    1183            4 :     fn default_page_size_applies() {
    1184            4 :         let st = state();
    1185            4 :         let n = st.default_limit + 5;
    1186            4 :         let (page, _, links) = paginate(&st, &params(&[]), items(n), "/e").unwrap();
    1187            4 :         assert_eq!(page.len(), st.default_limit);
    1188            4 :         assert!(links.iter().any(|l| l.contains("rel=\"next\"")));
    1189            4 :     }
    1190              : 
    1191              :     /// 4.12 should: a hard result-size ceiling, rejected with TooManyResults
    1192              :     /// (not silently clamped).
    1193              :     #[test]
    1194            4 :     fn limit_above_the_ceiling_is_too_many_results() {
    1195            4 :         let st = state();
    1196              :         // the ceiling is the one setting, 1,000 unless ANTARES_MAX_LIMIT says otherwise
    1197            4 :         assert_eq!((st.max_limit, *crate::bounds::MAX_LIMIT), (1000, 1000));
    1198            4 :         let at_max = st.max_limit.to_string();
    1199            4 :         assert!(paginate(&st, &params(&[("limit", &at_max)]), items(1), "/e").is_ok());
    1200            4 :         let over = (st.max_limit + 1).to_string();
    1201            4 :         let err = paginate(&st, &params(&[("limit", &over)]), items(1), "/e").unwrap_err();
    1202            4 :         assert!(format!("{err:?}").contains("TooManyResults"));
    1203            4 :     }
    1204              : 
    1205              :     /// Default 100, maximum 1,000: a request naming no
    1206              :     /// `limit` gets 100 Entities and a next Link; `limit=1000` gets 1,000.
    1207              :     #[test]
    1208            4 :     fn no_limit_answers_the_default_100_and_limit_1000_answers_1000() {
    1209            4 :         let st = state();
    1210            4 :         assert_eq!(
    1211            4 :             (st.default_limit, *crate::bounds::DEFAULT_LIMIT),
    1212              :             (100, 100)
    1213              :         );
    1214            4 :         let (page, _, links) = paginate(&st, &params(&[]), items(1001), "/e").unwrap();
    1215            4 :         assert_eq!(page.len(), 100);
    1216            4 :         assert!(links.iter().any(|l| l.contains("rel=\"next\"")));
    1217            4 :         let (page, _, _) = paginate(&st, &params(&[("limit", "1000")]), items(1001), "/e").unwrap();
    1218            4 :         assert_eq!(page.len(), 1000);
    1219            4 :     }
    1220              : 
    1221              :     /// 4.13: the result count is relayed "whenever this is requested by the
    1222              :     /// client" — and only then.
    1223              :     #[test]
    1224            4 :     fn count_is_returned_only_on_request() {
    1225            4 :         let st = AppState::new("http://localhost:9090".into());
    1226           12 :         let items: Vec<Value> = (0..3).map(|i| json!({"id": format!("urn:{i}")})).collect();
    1227            4 :         let (_, count, _) =
    1228            4 :             paginate(&st, &params(&[("count", "true")]), items.clone(), "/e").unwrap();
    1229            4 :         assert_eq!(count, Some(3));
    1230            4 :         let (_, count, _) = paginate(&st, &params(&[]), items, "/e").unwrap();
    1231            4 :         assert_eq!(count, None, "no count member unless requested");
    1232            4 :     }
    1233              : 
    1234              :     /// 4.13: "a client can issue a query that limits to zero the number of
    1235              :     /// desired results but asks for the count to be present" — limit=0 is
    1236              :     /// only valid together with count.
    1237              :     #[test]
    1238            4 :     fn limit_zero_with_count_yields_an_empty_page_and_the_total() {
    1239            4 :         let st = AppState::new("http://localhost:9090".into());
    1240           28 :         let items: Vec<Value> = (0..7).map(|i| json!({"id": format!("urn:{i}")})).collect();
    1241            4 :         let (page, count, links) = paginate(
    1242            4 :             &st,
    1243            4 :             &params(&[("limit", "0"), ("count", "true")]),
    1244            4 :             items.clone(),
    1245            4 :             "/e",
    1246            4 :         )
    1247            4 :         .unwrap();
    1248            4 :         assert!(page.is_empty());
    1249            4 :         assert_eq!(count, Some(7));
    1250            4 :         assert!(links.is_empty(), "limit=0 pages have no next/prev");
    1251            4 :         assert!(
    1252            4 :             paginate(&st, &params(&[("limit", "0")]), items, "/e").is_err(),
    1253              :             "limit=0 without count is rejected"
    1254              :         );
    1255            4 :     }
    1256              : 
    1257              :     /// 4.23.2: mixed datatypes order as Numbers < Strings < Object < Array <
    1258              :     /// Boolean < Time < Date < DateTime < Null < absent.
    1259              :     #[test]
    1260            4 :     fn datatype_comparison_order() {
    1261            4 :         let ctx = Loader::new().core();
    1262            4 :         let mut docs = vec![
    1263            4 :             ent("urn:null", "x", Value::Null),
    1264            4 :             ent("urn:datetime", "x", json!("2020-01-01T00:00:00Z")),
    1265            4 :             ent("urn:bool", "x", json!(true)),
    1266            4 :             json!({"id": "urn:absent", "type": ["T"]}),
    1267            4 :             ent("urn:array", "x", json!([1, 2])),
    1268            4 :             ent("urn:string", "x", json!("abc")),
    1269            4 :             ent("urn:date", "x", json!("2020-01-01")),
    1270            4 :             ent("urn:object", "x", json!({"k": 1})),
    1271            4 :             ent("urn:number", "x", json!(5)),
    1272            4 :             ent("urn:time", "x", json!("12:00:00Z")),
    1273              :         ];
    1274            4 :         order_entities(&mut docs, "x", &params(&[]), &ctx).expect("order");
    1275            4 :         assert_eq!(
    1276            4 :             ids(&docs),
    1277            4 :             vec![
    1278              :                 "urn:number",
    1279            4 :                 "urn:string",
    1280            4 :                 "urn:object",
    1281            4 :                 "urn:array",
    1282            4 :                 "urn:bool",
    1283            4 :                 "urn:time",
    1284            4 :                 "urn:date",
    1285            4 :                 "urn:datetime",
    1286            4 :                 "urn:null",
    1287            4 :                 "urn:absent"
    1288              :             ]
    1289              :         );
    1290            4 :     }
    1291              : 
    1292              :     /// each document's ordering value is read once, before the sort;
    1293              :     /// the order is the comparison's, and Entities that tie keep the order
    1294              :     /// the store gave them.
    1295              :     #[test]
    1296            4 :     fn a_tie_keeps_the_store_order_and_desc_reverses_the_values() {
    1297            4 :         let ctx = Loader::new().core();
    1298            4 :         let mut docs: Vec<Value> = (0..200)
    1299          800 :             .map(|n| ent(&format!("urn:{n:03}"), "level", json!(n % 7)))
    1300            4 :             .collect();
    1301            4 :         let mut want: Vec<String> = ids(&docs).into_iter().map(str::to_owned).collect();
    1302         8784 :         want.sort_by_key(|id| {
    1303         8784 :             let n: i64 = id.trim_start_matches("urn:").parse().unwrap_or(0);
    1304         8784 :             std::cmp::Reverse(n % 7)
    1305         8784 :         });
    1306            4 :         order_entities(&mut docs, "level;desc", &params(&[]), &ctx).expect("order");
    1307            4 :         assert_eq!(ids(&docs), want);
    1308            4 :     }
    1309              : 
    1310              :     /// 4.23.3 EXAMPLES 8/9: dist-asc / dist-desc rank by haversine distance
    1311              :     /// from the orderFrom reference; a non-GeoProperty under a dist ordering
    1312              :     /// falls back to value ordering after the geo-ranked ones (4.23.2).
    1313              :     #[test]
    1314            4 :     fn distance_ordering() {
    1315            4 :         let ctx = Loader::new().core();
    1316           16 :         let geo = |id: &str, lon: f64, lat: f64| {
    1317           16 :             json!({"id": id, "type": ["T"],
    1318           16 :                 "https://uri.etsi.org/ngsi-ld/location": [
    1319           16 :                     {"type": "GeoProperty",
    1320           16 :                      "value": {"type": "Point", "coordinates": [lon, lat]}}]})
    1321           16 :         };
    1322            4 :         let mut docs = vec![
    1323            4 :             geo("urn:far", 10.0, 45.0),
    1324            4 :             geo("urn:near", 8.01, 40.01),
    1325            4 :             geo("urn:mid", 9.0, 41.0),
    1326              :         ];
    1327            4 :         let p = params(&[("orderFrom", "[8,40]")]);
    1328            4 :         order_entities(&mut docs, "location;dist-asc", &p, &ctx).expect("order");
    1329            4 :         assert_eq!(ids(&docs), vec!["urn:near", "urn:mid", "urn:far"]);
    1330            4 :         order_entities(&mut docs, "location;dist-desc", &p, &ctx).expect("order");
    1331            4 :         assert_eq!(ids(&docs), vec!["urn:far", "urn:mid", "urn:near"]);
    1332              :         // dist without orderFrom is a violation
    1333            4 :         assert!(order_entities(&mut docs, "location;dist-asc", &params(&[]), &ctx).is_err());
    1334              :         // 4.23.2: under a distance ordering the GeoProperties rank first by
    1335              :         // distance, and the non-GeoProperties after them BY VALUE — so the
    1336              :         // ordering member has to be the same (core) one the geo entities use,
    1337              :         // and there have to be two of them for their own order to mean
    1338              :         // anything.
    1339            8 :         let plain = |id: &str, v: &str| {
    1340            8 :             json!({"id": id, "type": ["T"],
    1341            8 :                 "https://uri.etsi.org/ngsi-ld/location": [
    1342            8 :                     {"type": "Property", "value": v}]})
    1343            8 :         };
    1344            4 :         let mut mixed = vec![
    1345            4 :             plain("urn:plain-z", "zzz"),
    1346            4 :             plain("urn:plain-a", "aaa"),
    1347            4 :             geo("urn:g", 8.0, 40.0),
    1348              :         ];
    1349            4 :         order_entities(&mut mixed, "location;dist-asc", &p, &ctx).expect("order");
    1350            4 :         assert_eq!(ids(&mixed), vec!["urn:g", "urn:plain-a", "urn:plain-z"]);
    1351            4 :     }
    1352              : 
    1353              :     /// 4.23.3 EXAMPLE 4: a trailing [path] addresses a compound-value
    1354              :     /// subitem; EXAMPLE 3: per-key directions apply sequentially.
    1355              :     #[test]
    1356            4 :     fn bracket_paths_and_sequential_keys() {
    1357            4 :         let ctx = Loader::new().core();
    1358            8 :         let addr = |id: &str, city: &str| ent(id, "address", json!({"city": city}));
    1359            4 :         let mut docs = vec![addr("urn:b", "Berlin"), addr("urn:a", "Amsterdam")];
    1360            4 :         order_entities(&mut docs, "address[city]", &params(&[]), &ctx).expect("order");
    1361            4 :         assert_eq!(ids(&docs), vec!["urn:a", "urn:b"]);
    1362              :         // name asc, then age desc among equals (EXAMPLE 3)
    1363           12 :         let two = |id: &str, name: &str, age: i64| {
    1364           12 :             json!({"id": id, "type": ["T"],
    1365           12 :                 format!("{D}name"): [{"type": "Property", "value": name}],
    1366           12 :                 format!("{D}age"): [{"type": "Property", "value": age}]})
    1367           12 :         };
    1368            4 :         let mut docs = vec![
    1369            4 :             two("urn:x1", "same", 1),
    1370            4 :             two("urn:x9", "same", 9),
    1371            4 :             two("urn:a", "aaa", 5),
    1372              :         ];
    1373            4 :         order_entities(&mut docs, "name,age;desc", &params(&[]), &ctx).expect("order");
    1374            4 :         assert_eq!(ids(&docs), vec!["urn:a", "urn:x9", "urn:x1"]);
    1375            4 :     }
    1376              : 
    1377              :     /// 6.3.10 count-only page (`limit=0&count=true`): the pushed shape — no
    1378              :     /// rows from the store plus its pre-LIMIT count — must be the same answer
    1379              :     /// the full scan builds from a materialized match set, page contents,
    1380              :     /// count and Links included.
    1381              :     #[test]
    1382            4 :     fn the_count_only_page_is_the_same_answer_pushed_or_scanned() {
    1383            4 :         let st = AppState::new("antares-test".into());
    1384            4 :         let matches: Vec<Value> = (0..7)
    1385           28 :             .map(|i| serde_json::json!({"id": format!("urn:ngsi-ld:T:{i}"), "type": "T"}))
    1386            4 :             .collect();
    1387            8 :         for extra in [vec![], vec![("offset", "3")]] {
    1388            8 :             let mut pairs = vec![("type", "T"), ("limit", "0"), ("count", "true")];
    1389            8 :             pairs.extend(extra.iter().copied());
    1390            8 :             let p = params(&pairs);
    1391            8 :             let scanned = paginate(&st, &p, matches.clone(), "/ngsi-ld/v1/entities").expect("scan");
    1392            8 :             let pushed = paginate_pre(
    1393            8 :                 &st,
    1394            8 :                 &p,
    1395            8 :                 Vec::new(),
    1396            8 :                 "/ngsi-ld/v1/entities",
    1397              :                 // what the store's count(*) reports for the same query
    1398            8 :                 matches.len(),
    1399              :             )
    1400            8 :             .expect("pushed");
    1401            8 :             assert_eq!(scanned.0, pushed.0, "page contents differ: {pairs:?}");
    1402            8 :             assert_eq!(scanned.1, pushed.1, "count differs: {pairs:?}");
    1403            8 :             assert_eq!(scanned.2, pushed.2, "Links differ: {pairs:?}");
    1404            8 :             assert!(
    1405            8 :                 pushed.0.is_empty(),
    1406              :                 "a count-only page carries no Entity: {:?}",
    1407              :                 pushed.0
    1408              :             );
    1409            8 :             assert_eq!(pushed.1, Some(matches.len()), "the count is the match set");
    1410            8 :             assert!(
    1411            8 :                 !pushed.2.iter().any(|l| l.contains("rel=\"next\"")),
    1412              :                 "a page of zero has no next page: {:?}",
    1413              :                 pushed.2
    1414              :             );
    1415              :         }
    1416            4 :     }
    1417              : 
    1418              :     /// 5.5.9 pagination is driven by `limit` and `offset`, and 5.5.6 answers
    1419              :     /// a request for "so many results that can potentially exhaust client or
    1420              :     /// server resources" with TooManyResults rather than clamping. 6.3.10
    1421              :     /// takes `limit=0` only together with `count=true`. Every one of these is
    1422              :     /// a client-supplied number, so the boundaries are what a client reaches
    1423              :     /// for: the largest offset the store can bind, the first one it cannot,
    1424              :     /// a number no integer type holds, and the negative form of both.
    1425              :     #[test]
    1426            4 :     fn the_paging_numbers_are_refused_at_their_boundaries_not_wrapped() {
    1427            4 :         let st = AppState::new("http://localhost:9090".into());
    1428              : 
    1429            4 :         let (offset, _, _) = page_params(&st, &params(&[("offset", &i64::MAX.to_string())]))
    1430            4 :             .expect("the largest offset the store can bind is servable");
    1431            4 :         assert_eq!(offset, i64::MAX as usize);
    1432              : 
    1433              :         // one past it wraps negative when bound as `$n::bigint`, and a
    1434              :         // negative OFFSET is a Postgres error, so it is a bad request here
    1435            4 :         let over = (i64::MAX as u128 + 1).to_string();
    1436            4 :         assert!(
    1437            4 :             matches!(
    1438            4 :                 page_params(&st, &params(&[("offset", &over)])),
    1439            4 :                 Err(e) if matches!(e, crate::negotiate::ApiError::Ngsi(NgsiError::BadRequestData(_)))
    1440              :             ),
    1441              :             "an offset above i64::MAX must not reach the store"
    1442              :         );
    1443              : 
    1444           24 :         for bad in ["-1", "1.5", "", " 1", "0x10", "99999999999999999999999999"] {
    1445           24 :             assert!(
    1446           24 :                 matches!(
    1447           24 :                     page_params(&st, &params(&[("offset", bad)])),
    1448           24 :                     Err(e) if matches!(e, crate::negotiate::ApiError::Ngsi(NgsiError::BadRequestData(_)))
    1449              :                 ),
    1450              :                 "offset {bad:?} is not a number this API takes"
    1451              :             );
    1452           24 :             assert!(
    1453           24 :                 matches!(
    1454           24 :                     page_params(&st, &params(&[("limit", bad)])),
    1455           24 :                     Err(e) if matches!(e, crate::negotiate::ApiError::Ngsi(NgsiError::BadRequestData(_)))
    1456              :                 ),
    1457              :                 "limit {bad:?} is not a number this API takes"
    1458              :             );
    1459              :         }
    1460              : 
    1461              :         // 5.5.6: over the server maximum is 403, never a silent clamp
    1462            4 :         let over_max = (st.max_limit + 1).to_string();
    1463            4 :         assert!(
    1464            4 :             matches!(
    1465            4 :                 page_params(&st, &params(&[("limit", &over_max)])),
    1466            4 :                 Err(e) if matches!(e, crate::negotiate::ApiError::Ngsi(NgsiError::TooManyResults(_)))
    1467              :             ),
    1468              :             "a limit above the maximum is TooManyResults"
    1469              :         );
    1470            4 :         let (_, limit, _) = page_params(&st, &params(&[("limit", &st.max_limit.to_string())]))
    1471            4 :             .expect("the maximum itself is servable");
    1472            4 :         assert_eq!(limit, st.max_limit, "the boundary value is not clamped");
    1473              : 
    1474              :         // 6.3.10: limit=0 is the count-only request and nothing else
    1475            4 :         assert!(
    1476            4 :             matches!(
    1477            4 :                 page_params(&st, &params(&[("limit", "0")])),
    1478            4 :                 Err(e) if matches!(e, crate::negotiate::ApiError::Ngsi(NgsiError::BadRequestData(_)))
    1479              :             ),
    1480              :             "limit=0 without count asks for a page of nothing"
    1481              :         );
    1482            4 :         let (_, limit, count) = page_params(&st, &params(&[("limit", "0"), ("count", "true")]))
    1483            4 :             .expect("limit=0 with count=true is the count-only request");
    1484            4 :         assert_eq!((limit, count), (0, true));
    1485              : 
    1486              :         // `count` is the literal "true" and nothing else is a count request
    1487           16 :         for not_true in ["TRUE", "1", "yes", ""] {
    1488           16 :             let (_, _, count) = page_params(&st, &params(&[("count", not_true)])).expect("params");
    1489           16 :             assert!(!count, "count={not_true:?} is not count=true");
    1490              :         }
    1491            4 :     }
    1492              : 
    1493              :     /// Table 5.2.23-1 splitEntities: "If true it is assumed that single
    1494              :     /// Entities are distributed between different Context Brokers and/or
    1495              :     /// Context Sources and this has to be taken into account when applying
    1496              :     /// any kind of filters" — the body member drives the same read as the
    1497              :     /// query-parameter twin, so it has to reach the filter path.
    1498              :     #[test]
    1499            4 :     fn query_body_split_entities_reaches_the_filter_params() {
    1500            4 :         let q = json!({"type": "Query", "entityMap": true, "splitEntities": true});
    1501            4 :         let mut vp = HashMap::new();
    1502            4 :         query_doc_params(q.as_object().expect("object"), false, &mut vp).expect("valid Query");
    1503            4 :         assert_eq!(
    1504            4 :             vp.get("splitEntities").map(String::as_str),
    1505              :             Some("true"),
    1506              :             "splitEntities must not be dropped: {vp:?}"
    1507              :         );
    1508            4 :         assert_eq!(vp.get("entityMap").map(String::as_str), Some("true"));
    1509              :         // the false reading is carried through unchanged, never as "true"
    1510            4 :         let q = json!({"type": "Query", "splitEntities": false});
    1511            4 :         let mut vp = HashMap::new();
    1512            4 :         query_doc_params(q.as_object().expect("object"), false, &mut vp).expect("valid Query");
    1513            4 :         assert_eq!(vp.get("splitEntities").map(String::as_str), Some("false"));
    1514            4 :     }
    1515              : 
    1516              :     /// 5.2.33: the `entities` EntitySelectors are a union and "id takes
    1517              :     /// precedence over idPattern" PER selector. A flat filter carries one
    1518              :     /// id/idPattern pair, so a member may only be emitted when it holds for
    1519              :     /// every selector — otherwise the flat filter excludes Entities that a
    1520              :     /// selector on its own selects.
    1521              :     #[test]
    1522            4 :     fn entity_selectors_are_a_union_not_a_flat_id_filter() {
    1523            4 :         let mixed = json!({"type": "Query", "entities": [
    1524            4 :             {"type": "Vehicle", "id": "urn:ngsi-ld:Vehicle:1"},
    1525            4 :             {"type": "Building", "idPattern": "^urn:ngsi-ld:Building:"}
    1526              :         ]});
    1527            4 :         let mut vp = HashMap::new();
    1528            4 :         query_doc_params(mixed.as_object().expect("object"), false, &mut vp).expect("valid Query");
    1529            4 :         assert_eq!(vp.get("type").map(String::as_str), Some("Vehicle,Building"));
    1530            4 :         assert!(
    1531            4 :             !vp.contains_key("id"),
    1532              :             "an id from one selector must not filter the other selector out: {vp:?}"
    1533              :         );
    1534            4 :         assert!(
    1535            4 :             !vp.contains_key("idPattern"),
    1536              :             "a pattern from one selector must not filter the other out: {vp:?}"
    1537              :         );
    1538              :         // every selector carries id → the union of ids is exact
    1539            4 :         let all_ids = json!({"type": "Query", "entities": [
    1540            4 :             {"type": "Vehicle", "id": "urn:ngsi-ld:Vehicle:1"},
    1541            4 :             {"type": "Building", "id": ["urn:ngsi-ld:Building:1"]}
    1542              :         ]});
    1543            4 :         let mut vp = HashMap::new();
    1544            4 :         query_doc_params(all_ids.as_object().expect("object"), false, &mut vp)
    1545            4 :             .expect("valid Query");
    1546            4 :         assert_eq!(
    1547            4 :             vp.get("id").map(String::as_str),
    1548              :             Some("urn:ngsi-ld:Vehicle:1,urn:ngsi-ld:Building:1")
    1549              :         );
    1550            4 :         assert!(!vp.contains_key("idPattern"));
    1551              :         // no selector carries id and every one carries idPattern → union
    1552            4 :         let all_pats = json!({"type": "Query", "entities": [
    1553            4 :             {"type": "Vehicle", "idPattern": "^urn:ngsi-ld:Vehicle:"},
    1554            4 :             {"type": "Building", "idPattern": "^urn:ngsi-ld:Building:"}
    1555              :         ]});
    1556            4 :         let mut vp = HashMap::new();
    1557            4 :         query_doc_params(all_pats.as_object().expect("object"), false, &mut vp)
    1558            4 :             .expect("valid Query");
    1559            4 :         assert_eq!(
    1560            4 :             vp.get("idPattern").map(String::as_str),
    1561              :             Some("^urn:ngsi-ld:Vehicle:|^urn:ngsi-ld:Building:")
    1562              :         );
    1563            4 :         assert!(!vp.contains_key("id"));
    1564              :         // a single selector keeps the 5.2.33 id-over-idPattern precedence
    1565            4 :         let both = json!({"type": "Query", "entities": [
    1566            4 :             {"type": "Vehicle", "id": "urn:ngsi-ld:Vehicle:1", "idPattern": "^urn:"}
    1567              :         ]});
    1568            4 :         let mut vp = HashMap::new();
    1569            4 :         query_doc_params(both.as_object().expect("object"), false, &mut vp).expect("valid Query");
    1570            4 :         assert_eq!(
    1571            4 :             vp.get("id").map(String::as_str),
    1572              :             Some("urn:ngsi-ld:Vehicle:1")
    1573              :         );
    1574            4 :         assert!(!vp.contains_key("idPattern"), "id wins in one selector");
    1575            4 :     }
    1576              : 
    1577              :     /// RFC 3986 clause 3.4 + RFC 8288 clause 3: a value spliced back into a
    1578              :     /// query string must not be able to start another parameter, decode a
    1579              :     /// second time, or end the link-value it is carried in. The characters an
    1580              :     /// NGSI-LD filter legitimately uses (`urn:`, `.`, `*`, `-`) survive, or
    1581              :     /// every pagination link would run a different query than the one that
    1582              :     /// produced it.
    1583              :     #[test]
    1584            4 :     fn query_value_encodes_what_would_change_the_query() {
    1585            4 :         assert_eq!(
    1586            4 :             query_value("urn:ngsi-ld:Building:01931.*"),
    1587              :             "urn:ngsi-ld:Building:01931.*"
    1588              :         );
    1589            4 :         assert_eq!(query_value("cat>1"), "cat%3E1");
    1590            4 :         assert_eq!(query_value(r#"cat=="a&b""#), "cat%3D%3D%22a%26b%22");
    1591              :         // a value already carrying a percent must not decode twice
    1592            4 :         assert_eq!(query_value("a%26b"), "a%2526b");
    1593              :         // `+` reads back as a space in a query string
    1594            4 :         assert_eq!(query_value("a+b"), "a%2Bb");
    1595            4 :         assert_eq!(query_value("é"), "%C3%A9");
    1596            4 :     }
    1597              : }
        

Generated by: LCOV version 2.0-1