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