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(>ype, &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, ¶ms(&[("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 : ¶ms(&[("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 : ¶ms(&[("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, ¶ms(&[]), 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, ¶ms(&[("limit", &at_max)]), items(1), "/e").is_ok());
1200 4 : let over = (st.max_limit + 1).to_string();
1201 4 : let err = paginate(&st, ¶ms(&[("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, ¶ms(&[]), 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, ¶ms(&[("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, ¶ms(&[("count", "true")]), items.clone(), "/e").unwrap();
1229 4 : assert_eq!(count, Some(3));
1230 4 : let (_, count, _) = paginate(&st, ¶ms(&[]), 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 : ¶ms(&[("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, ¶ms(&[("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", ¶ms(&[]), &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", ¶ms(&[]), &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", ¶ms(&[]), &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]", ¶ms(&[]), &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", ¶ms(&[]), &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, ¶ms(&[("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, ¶ms(&[("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, ¶ms(&[("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, ¶ms(&[("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, ¶ms(&[("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, ¶ms(&[("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, ¶ms(&[("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, ¶ms(&[("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, ¶ms(&[("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 : }
|