LCOV - code coverage report
Current view: top level - antares-api/src - paging.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 93.2 % 895 834
Test Date: 2026-09-21 10:31:06 Functions: 58.7 % 252 148

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

Generated by: LCOV version 2.0-1