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