Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! /jsonldContexts management (5.13; resources 6.29/6.30).
3 : //!
4 : //! Three kinds (5.13.1): Hosted (client-added, served on demand),
5 : //! Cached (externally-fetched, metadata only), ImplicitlyCreated (broker-made
6 : //! wrappers for array @contexts on subscriptions, served on demand).
7 :
8 : use crate::negotiate::*;
9 : use crate::state::{now_iso, AppState};
10 : use antares_jsonld::Context;
11 : use antares_jsonld::Loader;
12 : use antares_model::{NgsiError, TenantId};
13 : use axum::body::Bytes;
14 : use axum::extract::{Path, State};
15 : use axum::http::{header, HeaderMap, StatusCode};
16 : use axum::response::{IntoResponse, Response};
17 : use serde_json::{json, Value};
18 : use sha2::{Digest, Sha256};
19 : use std::collections::HashMap;
20 : use std::sync::Arc;
21 :
22 : use crate::negotiate::CleanParams;
23 :
24 : /// Base URL under which this broker publishes its own @context entries
25 : /// (5.13.2.4 locally unique URI, 5.13.3.5 `URL`). The address is the
26 : /// broker's, so it comes from ITS configuration — ANTARES_PUBLIC_URL, the
27 : /// same value peers are handed as the 5.8.1.4 notification endpoint. The
28 : /// request's `Host` header is client input and only the fallback for a
29 : /// deployment that configures nothing.
30 280 : pub(crate) fn base_url(headers: &HeaderMap) -> String {
31 280 : context_base(std::env::var("ANTARES_PUBLIC_URL").ok().as_deref(), headers)
32 280 : }
33 :
34 : /// The URL this broker is reached at, with no trailing slash, for a
35 : /// document that names the broker's own resources by absolute URL (an
36 : /// context URL): `ANTARES_PUBLIC_URL`, else `http://{Host}` of the
37 : /// request, the same fallback as the JSON-LD context URL.
38 : ///
39 : /// ```
40 : /// let headers = axum::http::HeaderMap::new();
41 : /// assert!(antares_api::public_base(&headers).starts_with("http"));
42 : /// ```
43 : #[must_use]
44 0 : pub fn public_base(headers: &HeaderMap) -> String {
45 0 : broker_base(std::env::var("ANTARES_PUBLIC_URL").ok().as_deref(), headers)
46 0 : }
47 :
48 296 : fn broker_base(configured: Option<&str>, headers: &HeaderMap) -> String {
49 296 : match configured.map(str::trim).filter(|u| !u.is_empty()) {
50 224 : Some(url) => url.trim_end_matches('/').to_owned(),
51 : None => {
52 72 : let host = headers
53 72 : .get(header::HOST)
54 72 : .and_then(|h| h.to_str().ok())
55 72 : .unwrap_or("localhost:9090");
56 72 : format!("http://{host}")
57 : }
58 : }
59 296 : }
60 :
61 296 : fn context_base(configured: Option<&str>, headers: &HeaderMap) -> String {
62 296 : format!(
63 : "{}/ngsi-ld/v1/jsonldContexts",
64 296 : broker_base(configured, headers)
65 : )
66 296 : }
67 :
68 : /// Validate the `details` query param: absent | true | false (053_05 sends
69 : /// `True`); anything else is 400 (052_04_02).
70 522 : fn details_param(params: &HashMap<String, String>) -> Result<bool, NgsiError> {
71 522 : match params.get("details").map(|s| s.to_ascii_lowercase()) {
72 228 : None => Ok(false),
73 294 : Some(v) if v == "true" => Ok(true),
74 70 : Some(v) if v == "false" => Ok(false),
75 14 : Some(v) => Err(NgsiError::BadRequestData(format!(
76 14 : "invalid details value {v:?}"
77 14 : ))),
78 : }
79 522 : }
80 :
81 492 : fn reload_param(params: &HashMap<String, String>) -> Result<bool, NgsiError> {
82 492 : match params.get("reload").map(|s| s.to_ascii_lowercase()) {
83 442 : None => Ok(false),
84 50 : Some(v) if v == "true" => Ok(true),
85 14 : Some(v) if v == "false" => Ok(false),
86 6 : Some(v) => Err(NgsiError::BadRequestData(format!(
87 6 : "invalid reload value {v:?}"
88 6 : ))),
89 : }
90 492 : }
91 :
92 : /// Detailed metadata object (5.13.3.5).
93 520 : fn details_obj(
94 520 : url: &str,
95 520 : local_id: &str,
96 520 : kind: &str,
97 520 : created_at: &str,
98 520 : usage: Option<&antares_jsonld::CtxUsage>,
99 520 : ) -> Value {
100 520 : let mut o = json!({
101 520 : "URL": url,
102 520 : "localId": local_id,
103 520 : "kind": kind,
104 520 : "createdAt": created_at,
105 : });
106 : // numberOfHits/lastUsage only for kinds where the suite's arithmetic
107 : // expects them (Cached + ImplicitlyCreated; 053_04 vs 053_06)
108 520 : if let (false, Some(m)) = (kind == "Hosted", o.as_object_mut()) {
109 430 : let hits = usage.map(|u| u.hits).unwrap_or(0);
110 430 : m.insert("numberOfHits".into(), json!(hits));
111 430 : if let Some(u) = usage {
112 426 : m.insert("lastUsage".into(), json!(u.last_usage));
113 426 : }
114 90 : }
115 520 : o
116 520 : }
117 :
118 : /// A resolved @context entry, whichever backing it has.
119 : enum CtxEntry {
120 : /// Hosted or ImplicitlyCreated store entry.
121 : Stored(Value),
122 : /// External URL known through the loader (kind Cached).
123 : Cached(antares_jsonld::CtxUsage),
124 : /// Built-in core context (undeletable).
125 : Core(String),
126 : }
127 :
128 : /// Usage view of a store row — the SHARED truth (per-instance loader
129 : /// stats split-brain behind a load balancer; the usage_bump hook keeps the
130 : /// row's counters current from every instance).
131 950 : fn row_usage(doc: &Value) -> antares_jsonld::CtxUsage {
132 950 : let created_at = doc["createdAt"].as_str().unwrap_or_default().to_owned();
133 : antares_jsonld::CtxUsage {
134 950 : url: doc["url"].as_str().unwrap_or_default().to_owned(),
135 950 : local_id: doc["localId"].as_str().unwrap_or_default().to_owned(),
136 950 : last_usage: doc["lastUsage"]
137 950 : .as_str()
138 950 : .map(str::to_owned)
139 950 : .unwrap_or_else(|| created_at.clone()),
140 950 : hits: doc["numberOfHits"].as_u64().unwrap_or(0),
141 950 : created_at,
142 : }
143 950 : }
144 :
145 : /// Build the Cached-entry view of a store row.
146 228 : fn cached_from_row(doc: &Value) -> CtxEntry {
147 228 : CtxEntry::Cached(row_usage(doc))
148 228 : }
149 :
150 : /// Ownership of a stored row (5.13.1, ADR-0021). Hosted and ImplicitlyCreated
151 : /// rows hold term mappings authored through one tenant's requests and are
152 : /// visible, servable and deletable only through that tenant; Cached rows are
153 : /// copies of public documents the broker fetched and belong to no tenant.
154 : /// Rows written before the owner member existed belong to the default tenant.
155 : ///
156 : /// The store answers the same question over its own `tenant_id`, so a row
157 : /// another Tenant owns does not reach this check under Postgres. Both stay:
158 : /// the day they disagree, the database is the one that refuses.
159 1046 : fn row_visible(doc: &Value, tenant: &TenantId) -> bool {
160 1046 : antares_store::context_row_visible(doc, Some(tenant))
161 1046 : }
162 :
163 : /// 5.13.1: a Hosted or ImplicitlyCreated @context holds term mappings
164 : /// authored through one Tenant's requests, so it is one of that Tenant's
165 : /// documents and a purge of the Tenant takes it too. A Cached row is a copy
166 : /// of a public document and belongs to no Tenant (see `row_visible`), so it
167 : /// stays.
168 : ///
169 : /// The rows themselves also go with the store's own `purge_tenant`
170 : /// (ADR-0021). What only this pass can do is release the loader's warm copy
171 : /// and usage entry for each URL, which live in this process and would
172 : /// otherwise keep serving a document whose row is gone.
173 36 : pub async fn purge_tenant(st: &AppState, tenant: &TenantId) -> Result<(), NgsiError> {
174 40 : for row in st.store.context_list_meta(Some(tenant)).await? {
175 26 : if row.get("kind").and_then(Value::as_str) == Some("Cached") || !row_visible(&row, tenant) {
176 8 : continue;
177 18 : }
178 18 : let Some(local_id) = row.get("localId").and_then(Value::as_str) else {
179 0 : continue;
180 : };
181 18 : st.store.context_delete(Some(tenant), local_id).await?;
182 18 : if let Some(url) = row.get("url").and_then(Value::as_str) {
183 18 : st.loader.usage_remove(url).await;
184 0 : }
185 : }
186 36 : Ok(())
187 36 : }
188 :
189 : /// Most ImplicitlyCreated @contexts one Tenant may hold. A read with an inline
190 : /// @context writes one row per DISTINCT @context, so a caller who may only
191 : /// read could otherwise grow the store without bound by varying it.
192 : const MAX_IMPLICIT_CONTEXTS: usize = 1_000;
193 :
194 : /// 6.3.6: an "application/json" response "shall include a Link to the
195 : /// associated JSON-LD @context", and a Link carries a URL. An inline @context
196 : /// (an object, or an array of more than one entry) has none, so the broker
197 : /// hosts it as an ImplicitlyCreated @context (TS 104 175 13.1) and answers
198 : /// with a copy of `ctx` whose source is that URL. The local id is
199 : /// content-addressed per Tenant: the same inline @context, request after
200 : /// request, is one row behind one URL. Past [`MAX_IMPLICIT_CONTEXTS`] the
201 : /// response keeps the Core @context Link it had before.
202 204 : pub(crate) async fn linkable(
203 204 : st: &AppState,
204 204 : tenant: &TenantId,
205 204 : headers: &HeaderMap,
206 204 : ctx: &Arc<Context>,
207 204 : accept: Accept,
208 204 : ) -> ApiResult<Arc<Context>> {
209 204 : let inline = match &ctx.source {
210 12 : Value::Object(_) => true,
211 0 : Value::Array(a) => a.len() > 1 || a.first().is_some_and(|v| !v.is_string()),
212 192 : _ => false,
213 : };
214 204 : if accept != Accept::Json || !inline {
215 194 : return Ok(Arc::clone(ctx));
216 10 : }
217 10 : let body = serde_json::to_vec(&ctx.source)
218 10 : .map_err(|e| NgsiError::InternalError(format!("@context not serializable: {e}")))?;
219 10 : let mut h = Sha256::new();
220 10 : h.update(tenant.as_str().as_bytes());
221 10 : h.update([0]);
222 10 : h.update(&body);
223 320 : let hex: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
224 10 : let local_id = format!("urn:ngsi-ld:Context:v{hex}");
225 10 : let url = match st.store.context_get(Some(tenant), &local_id).await? {
226 2 : Some(row) if row_visible(&row, tenant) => {
227 2 : row.get("url").and_then(Value::as_str).map(str::to_owned)
228 : }
229 0 : Some(_) => None,
230 : None => {
231 8 : let held = st
232 8 : .store
233 8 : .context_list_meta(Some(tenant))
234 8 : .await?
235 8 : .iter()
236 8 : .filter(|r| {
237 0 : r.get("kind").and_then(Value::as_str) == Some("ImplicitlyCreated")
238 0 : && row_visible(r, tenant)
239 0 : })
240 8 : .count();
241 8 : if held >= MAX_IMPLICIT_CONTEXTS {
242 0 : tracing::warn!(
243 0 : tenant = tenant.as_str(),
244 : "ImplicitlyCreated @context cap reached; Link names the Core @context"
245 : );
246 0 : None
247 : } else {
248 8 : let url = format!("{}/{local_id}", base_url(headers));
249 8 : st.store
250 8 : .context_put(
251 8 : Some(tenant),
252 8 : &local_id,
253 8 : json!({
254 8 : "url": url,
255 8 : "localId": local_id,
256 8 : "kind": "ImplicitlyCreated",
257 8 : "createdAt": now_iso(),
258 8 : "owner": tenant.as_str(),
259 8 : "body": {"@context": ctx.source.clone()},
260 8 : }),
261 8 : )
262 8 : .await?;
263 8 : st.loader
264 8 : .put_local_for(tenant, url.clone(), ctx.source.clone())
265 8 : .await;
266 8 : Some(url)
267 : }
268 : }
269 : };
270 10 : Ok(match url {
271 10 : Some(url) => {
272 10 : let mut linked = Context::clone(ctx);
273 10 : linked.source = Value::String(url);
274 10 : Arc::new(linked)
275 : }
276 0 : None => Arc::clone(ctx),
277 : })
278 204 : }
279 :
280 : /// Resolve an id to the @context it names (5.13.4.4). Every probe is a keyed
281 : /// lookup: a store failure is an error, never "not found" — answering 404 for
282 : /// a hiccup would tell the client to add the @context a second time.
283 774 : async fn find_entry(st: &AppState, tenant: &TenantId, id: &str) -> ApiResult<Option<CtxEntry>> {
284 774 : if let Some(doc) = st.store.context_get(Some(tenant), id).await? {
285 : // Cached rows are addressable by their deterministic localId too.
286 374 : if doc.get("kind").and_then(Value::as_str) == Some("Cached") {
287 12 : return Ok(Some(cached_from_row(&doc)));
288 362 : }
289 : // another tenant's row is as absent as one that never existed
290 362 : return Ok(row_visible(&doc, tenant).then_some(CtxEntry::Stored(doc)));
291 400 : }
292 : // Stored entries are also addressable by their full URL (5.13.2.4): the
293 : // row key IS the URL's trailing segment, so this is one keyed lookup and
294 : // the row's own url still has to match the id.
295 400 : if let Some((_, local_id)) = id.rsplit_once("/ngsi-ld/v1/jsonldContexts/") {
296 20 : if let Some(doc) = st.store.context_get(Some(tenant), local_id).await? {
297 8 : if doc.get("url").and_then(Value::as_str) == Some(id) && row_visible(&doc, tenant) {
298 4 : return Ok(Some(CtxEntry::Stored(doc)));
299 4 : }
300 12 : }
301 380 : }
302 396 : if Loader::is_pinned_core(id) {
303 60 : return Ok(Some(CtxEntry::Core(id.to_owned())));
304 336 : }
305 : // Cached entries: the persisted row is the ONE existence truth (the
306 : // per-instance usage map split-brains behind a load balancer). The row id
307 : // is uuid5(url), so a URL-shaped id resolves in O(1).
308 336 : if id.starts_with("http://") || id.starts_with("https://") {
309 240 : let rid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, id.as_bytes()).to_string();
310 240 : if let Some(doc) = st.store.context_get(Some(tenant), &rid).await? {
311 216 : if doc.get("kind").and_then(Value::as_str) == Some("Cached") {
312 216 : return Ok(Some(cached_from_row(&doc)));
313 0 : }
314 24 : }
315 96 : }
316 120 : Ok(None)
317 774 : }
318 :
319 : /// Whether the stored @contexts are shown: not through a view that hides a
320 : /// name (a view showing every name hides no term, rule 12), and not to
321 : /// a caller the engine narrows (`policy::NAME_READS`), since one may define a
322 : /// term for an Attribute that caller may not see.
323 364 : fn shows_stored(st: &AppState, narrowing: &crate::policy::Filter) -> bool {
324 364 : let _ = st;
325 364 : narrowing.is_empty()
326 364 : }
327 :
328 : // ---------- POST /jsonldContexts (5.13.2) ----------
329 :
330 : /// 5.13.2.4 + 5.5.4: a JSON-LD local context is a string (IRI), an object of
331 : /// term definitions, null, or an array of those — anything else is invalid
332 : /// JSON-LD and rejected as BadRequestData.
333 224 : fn valid_context_shape(v: &Value) -> bool {
334 224 : match v {
335 194 : Value::String(_) | Value::Object(_) | Value::Null => true,
336 18 : Value::Array(a) => a
337 18 : .iter()
338 32 : .all(|e| matches!(e, Value::String(_) | Value::Object(_) | Value::Null)),
339 12 : _ => false,
340 : }
341 224 : }
342 :
343 : /// 5.13.2.4 Add @context: store the client-supplied @context under a new
344 : /// locally unique URI, flagged "Hosted"; the URI is returned (Location).
345 242 : pub async fn add_context(
346 242 : State(st): State<AppState>,
347 242 : CleanParams(params): CleanParams,
348 242 : headers: HeaderMap,
349 242 : body: Bytes,
350 242 : ) -> Response {
351 242 : let go = async {
352 242 : let tenant = tenant_from(&headers)?;
353 242 : check_params(¶ms, &["local"])?;
354 : // 6.3.5: an unsupported media type is a bare 415. An ABSENT
355 : // Content-Type is tolerated — the body is parsed as JSON, as on the
356 : // entity routes — but a header that is present and unreadable is not
357 : // absent, and `content_type` reports both as the empty string. The
358 : // presence of the header is what separates them.
359 242 : gate!(st, &tenant, &headers, "5.13.2").await?;
360 242 : let ct = content_type(&headers)?;
361 242 : if headers.contains_key(header::CONTENT_TYPE)
362 236 : && ct != "application/ld+json"
363 230 : && ct != "application/json"
364 : {
365 4 : return Err(ApiError::Bare(StatusCode::UNSUPPORTED_MEDIA_TYPE));
366 238 : }
367 238 : let value: Value = serde_json::from_slice(&body)
368 238 : .map_err(|e| NgsiError::InvalidRequest(format!("body is not valid JSON: {e}")))?;
369 234 : let ctx_val = value.get("@context").cloned().ok_or_else(|| {
370 : // 050_02: a JSON object without @context is InvalidRequest
371 10 : NgsiError::InvalidRequest("body must carry an @context member".into())
372 10 : })?;
373 224 : if !valid_context_shape(&ctx_val) {
374 18 : return Err(NgsiError::BadRequestData("invalid JSON-LD @context value".into()).into());
375 206 : }
376 : // 5.13.2.4: "a locally unique identifier (URI)"
377 206 : let local_id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
378 206 : let url = format!("{}/{local_id}", base_url(&headers));
379 206 : let doc = json!({
380 206 : "url": url,
381 206 : "localId": local_id,
382 206 : "kind": "Hosted",
383 206 : "createdAt": now_iso(),
384 : // the adding tenant owns the entry (5.13.1 Hosted)
385 206 : "owner": tenant.as_str(),
386 206 : "body": {"@context": ctx_val.clone()},
387 : });
388 206 : st.store.context_put(Some(&tenant), &local_id, doc).await?;
389 : // stored FOR the adding Tenant (5.13.1 Hosted): 5.5.10 confines the
390 : // mappings to that Tenant's operations, so resolution is scoped the
391 : // same way the serve/list/delete paths above are
392 206 : st.loader.put_local_for(&tenant, url.clone(), ctx_val).await;
393 206 : let mut resp = (
394 206 : StatusCode::CREATED,
395 206 : [(
396 206 : header::LOCATION,
397 206 : format!("/ngsi-ld/v1/jsonldContexts/{local_id}"),
398 206 : )],
399 206 : )
400 206 : .into_response();
401 206 : echo_tenant(&tenant, &mut resp);
402 206 : Ok::<_, ApiError>(resp)
403 242 : };
404 242 : go.await.unwrap_or_else(|e| e.into_response())
405 242 : }
406 :
407 : // ---------- GET /jsonldContexts (5.13.3) ----------
408 :
409 : /// 5.13.3.4 List @contexts: one URL (or metadata object, 5.13.3.5) per
410 : /// stored @context matching the kind filter; no filter → all kinds.
411 264 : pub async fn list_contexts(
412 264 : State(st): State<AppState>,
413 264 : CleanParams(params): CleanParams,
414 264 : headers: HeaderMap,
415 264 : ) -> Response {
416 264 : let go = async {
417 264 : let tenant = tenant_from(&headers)?;
418 : // Table 6.29.3.2-1: details and kind are the only parameters of this
419 : // resource — it serves the whole list, so a pagination parameter would
420 : // be accepted and silently ignored.
421 264 : check_params(¶ms, &["kind", "details", "local"])?;
422 248 : let narrowing = gate!(st, &tenant, &headers, "5.13.3").await?;
423 :
424 248 : let details = details_param(¶ms)?;
425 234 : let kind_filter = params.get("kind");
426 234 : if let Some(k) = kind_filter {
427 124 : if !["Hosted", "Cached", "ImplicitlyCreated"].contains(&k.as_str()) {
428 10 : return Err(NgsiError::BadRequestData(format!("invalid kind {k:?}")).into());
429 114 : }
430 110 : }
431 1258 : let keep = |k: &str| kind_filter.is_none_or(|f| f == k);
432 : // (url, localId, kind, createdAt, usage)
433 224 : let mut entries: Vec<(
434 224 : String,
435 224 : String,
436 224 : String,
437 224 : String,
438 224 : Option<antares_jsonld::CtxUsage>,
439 224 : )> = Vec::new();
440 : // a stored @context is the tenant's and may define
441 : // a term for an Attribute a view hides, so through a view that hides
442 : // a name the list is the core @context alone (the pinned entries
443 : // below). The same for a caller the engine narrows.
444 224 : let stored = if !shows_stored(&st, &narrowing) {
445 4 : Vec::new()
446 : } else {
447 220 : st.store.context_list_meta(Some(&tenant)).await?
448 : };
449 1034 : for c in stored {
450 1034 : let kind = c
451 1034 : .get("kind")
452 1034 : .and_then(Value::as_str)
453 1034 : .unwrap_or("Hosted")
454 1034 : .to_owned();
455 1034 : if !keep(&kind) || !row_visible(&c, &tenant) {
456 374 : continue;
457 660 : }
458 660 : let url = c
459 660 : .get("url")
460 660 : .and_then(Value::as_str)
461 660 : .unwrap_or_default()
462 660 : .to_owned();
463 : // counters from the ROW (shared truth), never this instance's map
464 660 : let usage = Some(row_usage(&c));
465 660 : entries.push((
466 660 : url,
467 660 : c.get("localId")
468 660 : .and_then(Value::as_str)
469 660 : .unwrap_or_default()
470 660 : .to_owned(),
471 660 : kind,
472 660 : c.get("createdAt")
473 660 : .and_then(Value::as_str)
474 660 : .unwrap_or_default()
475 660 : .to_owned(),
476 660 : usage,
477 660 : ));
478 : }
479 : // Cached entries come from the store rows walked above — the shared
480 : // truth every instance sees. Loader-only usage entries are NOT
481 : // listed (an entry another instance deleted must not resurface),
482 : // with one exception: pinned core contexts never get a row and stay
483 : // listable from local usage.
484 224 : if keep("Cached") {
485 860 : for u in st.loader.usage_list().await {
486 860 : if !Loader::is_pinned_core(&u.url) {
487 584 : continue;
488 276 : }
489 276 : entries.push((
490 276 : u.url.clone(),
491 276 : u.local_id.clone(),
492 276 : "Cached".into(),
493 276 : u.created_at.clone(),
494 276 : Some(u),
495 276 : ));
496 : }
497 92 : }
498 224 : let payload = if details {
499 : Value::Array(
500 74 : entries
501 74 : .iter()
502 394 : .map(|(url, lid, kind, created, usage)| {
503 394 : details_obj(url, lid, kind, created, usage.as_ref())
504 394 : })
505 74 : .collect(),
506 : )
507 : } else {
508 542 : Value::Array(entries.iter().map(|(url, ..)| json!(url)).collect())
509 : };
510 224 : let mut resp = (
511 224 : StatusCode::OK,
512 224 : [(header::CONTENT_TYPE, "application/json")],
513 224 : axum::Json(payload),
514 224 : )
515 224 : .into_response();
516 224 : echo_tenant(&tenant, &mut resp);
517 224 : Ok::<_, ApiError>(resp)
518 264 : };
519 264 : go.await.unwrap_or_else(|e| e.into_response())
520 264 : }
521 :
522 : // ---------- GET /jsonldContexts/{ctxId} (5.13.4) ----------
523 :
524 : /// 5.13.4.4 Serve @context: full content for Hosted/ImplicitlyCreated,
525 : /// OperationNotSupported for Cached, ResourceNotFound for unknown ids;
526 : /// details=true serves metadata for all kinds.
527 274 : pub async fn serve_context(
528 274 : State(st): State<AppState>,
529 274 : Path(id): Path<String>,
530 274 : CleanParams(params): CleanParams,
531 274 : headers: HeaderMap,
532 274 : ) -> Response {
533 274 : let go = async {
534 274 : let tenant = tenant_from(&headers)?;
535 274 : check_params(¶ms, &["details", "local", "core"])?;
536 274 : let narrowing = gate!(st, &tenant, &headers, "5.13.4", ids: &[&id]).await?;
537 274 : let details = details_param(¶ms)?;
538 : // TS 104 175 13.4.4: `core` with `details` is a BadRequest, and so is
539 : // a `core` that names no NGSI-LD version this build has a core for
540 274 : let core = match params.get("core") {
541 2 : Some(_) if details => {
542 2 : return Err(NgsiError::BadRequestData("core and details together".into()).into())
543 : }
544 16 : Some(v) => Some(antares_jsonld::core_context_url(v).ok_or_else(|| {
545 8 : NgsiError::BadRequestData(format!("core {v:?} is not a known major.minor version"))
546 8 : })?),
547 256 : None => None,
548 : };
549 264 : let entry = match find_entry(&st, &tenant, &id).await? {
550 : // a stored @context the list hides from this caller is as absent
551 : // here, under a view and under a narrowing engine
552 140 : Some(CtxEntry::Stored(_)) if !shows_stored(&st, &narrowing) => None,
553 260 : entry => entry,
554 : }
555 264 : .ok_or_else(|| NgsiError::ResourceNotFound(format!("@context {id} not found")))?;
556 : // the user @context's location and the core one, for every kind: the
557 : // pair is two URLs, not the content a Cached entry never serves
558 204 : if let Some(core) = core {
559 6 : let user = match &entry {
560 4 : CtxEntry::Stored(doc) => doc["url"].as_str().unwrap_or_default().to_owned(),
561 0 : CtxEntry::Cached(u) => u.url.clone(),
562 2 : CtxEntry::Core(url) => url.clone(),
563 : };
564 6 : let mut resp = (
565 6 : StatusCode::OK,
566 6 : [(header::CONTENT_TYPE, "application/json")],
567 6 : axum::Json(json!([user, core])),
568 6 : )
569 6 : .into_response();
570 6 : echo_tenant(&tenant, &mut resp);
571 6 : return Ok(resp);
572 198 : }
573 198 : let payload = match &entry {
574 132 : CtxEntry::Stored(doc) => {
575 132 : let kind = doc["kind"].as_str().unwrap_or("Hosted");
576 132 : let url = doc["url"].as_str().unwrap_or_default();
577 132 : if details {
578 62 : let usage = row_usage(doc);
579 62 : details_obj(
580 62 : url,
581 62 : doc["localId"].as_str().unwrap_or_default(),
582 62 : kind,
583 62 : doc["createdAt"].as_str().unwrap_or_default(),
584 62 : Some(&usage),
585 : )
586 : } else {
587 70 : doc["body"].clone()
588 : }
589 : }
590 52 : CtxEntry::Cached(u) => {
591 52 : if !details {
592 : // Cached entries are never served on demand (5.13.4.4)
593 0 : return Err(NgsiError::OperationNotSupported(
594 0 : "Cached @contexts are not served on demand (5.13.4)".into(),
595 0 : )
596 0 : .into());
597 52 : }
598 52 : details_obj(&u.url, &u.local_id, "Cached", &u.created_at, Some(u))
599 : }
600 14 : CtxEntry::Core(url) => {
601 14 : if details {
602 12 : let usage = st.loader.usage_get(url).await;
603 12 : details_obj(url, url, "Cached", &st.started_at, usage.as_ref())
604 : } else {
605 2 : return Err(NgsiError::OperationNotSupported(
606 2 : "Cached @contexts are not served on demand (5.13.4)".into(),
607 2 : )
608 2 : .into());
609 : }
610 : }
611 : };
612 : // a serve counts as a hit for Cached/ImplicitlyCreated entries, after
613 : // the value shown in this response (053_06/053_08 arithmetic) —
614 : // EXCEPT broker-internal fetches (a fleet peer resolving this
615 : // @context through the LB): the resolving instance bumps the shared
616 : // row itself, a serve-side bump would double-count (053_08 fleet).
617 196 : let internal_fetch = headers.contains_key(antares_jsonld::INTERNAL_FETCH_HEADER);
618 132 : match &entry {
619 132 : CtxEntry::Stored(doc) if !internal_fetch && doc["kind"] == "ImplicitlyCreated" => {
620 56 : if let Some(u) = doc["url"].as_str() {
621 56 : let _ = st.loader.bump_url(Some(&tenant), u).await;
622 0 : }
623 : }
624 52 : CtxEntry::Cached(u) if !internal_fetch => {
625 52 : let _ = st.loader.bump_url(Some(&tenant), &u.url).await;
626 : }
627 88 : _ => {}
628 : }
629 196 : let mut resp = (
630 196 : StatusCode::OK,
631 196 : [(header::CONTENT_TYPE, "application/json")],
632 196 : axum::Json(payload),
633 196 : )
634 196 : .into_response();
635 196 : echo_tenant(&tenant, &mut resp);
636 196 : Ok::<_, ApiError>(resp)
637 274 : };
638 274 : go.await.unwrap_or_else(|e| e.into_response())
639 274 : }
640 :
641 : // ---------- DELETE /jsonldContexts/{ctxId} (5.13.5) ----------
642 :
643 : /// 5.13.5.4 Delete and Reload @context: unknown id → ResourceNotFound;
644 : /// reload=true re-downloads a Cached @context in place (failure →
645 : /// LdContextNotAvailable, entry kept) and is BadRequestData for other
646 : /// kinds; without reload the entry is removed.
647 492 : pub async fn delete_context(
648 492 : State(st): State<AppState>,
649 492 : Path(id): Path<String>,
650 492 : CleanParams(params): CleanParams,
651 492 : headers: HeaderMap,
652 492 : ) -> Response {
653 492 : let go = async {
654 492 : let tenant = tenant_from(&headers)?;
655 492 : check_params(¶ms, &["reload", "local"])?;
656 492 : gate!(st, &tenant, &headers, "5.13.5", ids: &[&id]).await?;
657 :
658 492 : let reload = reload_param(¶ms)?;
659 : // an unknown id is 404 before any reload bullet (5.13.5.4, 051_04_01)
660 486 : let Some(entry) = find_entry(&st, &tenant, &id).await? else {
661 48 : return Err(NgsiError::ResourceNotFound(format!("@context {id} not found")).into());
662 : };
663 : // A Cached copy (the core one included) belongs to no Tenant and
664 : // serves every one: only the default Tenant, the operator's, may
665 : // evict or refetch it: one Tenant could otherwise evict or
666 : // refetch the cache every other Tenant resolves from.
667 438 : if matches!(entry, CtxEntry::Cached(_) | CtxEntry::Core(_)) && tenant != TenantId::default()
668 : {
669 8 : return Err(ApiError::Denied(
670 8 : "a Cached @context is shared by every Tenant: only the default Tenant deletes or reloads it"
671 8 : .into(),
672 8 : ));
673 430 : }
674 430 : if reload {
675 : // reload is only meaningful for Cached @contexts (5.13.5.4);
676 : // other kinds are 400 (051_04_05)
677 26 : return match entry {
678 4 : CtxEntry::Core(url) => {
679 4 : st.loader.refetch(&url).await.map_err(ApiError::from)?;
680 4 : Ok(no_content(&tenant))
681 : }
682 12 : CtxEntry::Cached(u) => {
683 12 : st.loader.refetch(&u.url).await.map_err(ApiError::from)?;
684 0 : Ok(no_content(&tenant))
685 : }
686 10 : CtxEntry::Stored(_) => Err(NgsiError::BadRequestData(
687 10 : "reload is only valid for Cached @contexts (5.13.5)".into(),
688 10 : )
689 10 : .into()),
690 : };
691 404 : }
692 404 : match entry {
693 : CtxEntry::Core(_) => {
694 40 : Err(NgsiError::BadRequestData("the core @context cannot be deleted".into()).into())
695 : }
696 156 : CtxEntry::Cached(u) => {
697 156 : st.loader.usage_remove(&u.url).await;
698 : // The write-through row shares the deterministic local id —
699 : // deleting the API entry must delete the persisted copy too,
700 : // or a restart resurrects a deleted @context (5.13.5).
701 156 : st.store.context_delete(Some(&tenant), &u.local_id).await?;
702 156 : Ok(no_content(&tenant))
703 : }
704 208 : CtxEntry::Stored(doc) => {
705 208 : let lid = doc.get("localId").and_then(Value::as_str).unwrap_or(&id);
706 208 : st.store.context_delete(Some(&tenant), lid).await?;
707 208 : if let Some(url) = doc.get("url").and_then(Value::as_str) {
708 208 : st.loader.usage_remove(url).await;
709 0 : }
710 208 : Ok::<_, ApiError>(no_content(&tenant))
711 : }
712 : }
713 492 : };
714 492 : go.await.unwrap_or_else(|e| e.into_response())
715 492 : }
716 :
717 : #[cfg(test)]
718 : mod tests {
719 : use super::*;
720 :
721 16 : fn forged_host() -> HeaderMap {
722 16 : let mut h = HeaderMap::new();
723 16 : h.insert(header::HOST, "attacker.example".parse().expect("host"));
724 16 : h
725 16 : }
726 :
727 24 : fn hosted_row(url: &str, local_id: &str, owner: &TenantId) -> Value {
728 24 : json!({
729 24 : "url": url,
730 24 : "localId": local_id,
731 24 : "kind": "Hosted",
732 24 : "createdAt": now_iso(),
733 24 : "owner": owner.as_str(),
734 24 : "body": {"@context": {}},
735 : })
736 24 : }
737 :
738 : /// 5.13.1 + 5.5.10: purging a Tenant takes the @contexts authored through
739 : /// ITS requests and nothing else — one Tenant's deletion must not reach
740 : /// another Tenant's term mappings, and a Cached row is a copy of a public
741 : /// document belonging to no Tenant, so it survives every purge.
742 : #[tokio::test]
743 4 : async fn clause_5_13_1_a_tenant_purge_takes_only_its_own_contexts() {
744 4 : let st = AppState::new("antares-ctx-purge".into());
745 4 : let alpha = TenantId::new("alpha").expect("tenant");
746 4 : let beta = TenantId::new("beta").expect("tenant");
747 4 : let base = "http://broker.example/ngsi-ld/v1/jsonldContexts";
748 8 : for (lid, owner) in [("hosted-alpha", &alpha), ("hosted-beta", &beta)] {
749 8 : st.store
750 8 : .context_put(
751 8 : Some(owner),
752 8 : lid,
753 8 : hosted_row(&format!("{base}/{lid}"), lid, owner),
754 8 : )
755 8 : .await
756 8 : .expect("store the Hosted @context");
757 : }
758 : // an ImplicitlyCreated wrapper is owned the same way a Hosted one is
759 4 : let mut implicit = hosted_row(&format!("{base}/implicit-alpha"), "implicit-alpha", &alpha);
760 4 : implicit["kind"] = json!("ImplicitlyCreated");
761 4 : st.store
762 4 : .context_put(Some(&alpha), "implicit-alpha", implicit)
763 4 : .await
764 4 : .expect("store the ImplicitlyCreated @context");
765 : // and a Cached copy, which carries no owner at all
766 4 : st.store
767 4 : .context_put(
768 4 : None,
769 4 : "cached-shared",
770 4 : json!({"url": "https://example.org/ctx.jsonld", "localId": "cached-shared",
771 4 : "kind": "Cached", "createdAt": now_iso()}),
772 4 : )
773 4 : .await
774 4 : .expect("store the Cached @context");
775 : // a legacy row with no owner member belongs to the default Tenant
776 4 : let mut legacy = hosted_row(&format!("{base}/legacy"), "legacy", &alpha);
777 4 : legacy.as_object_mut().expect("row object").remove("owner");
778 4 : st.store
779 4 : .context_put(Some(&TenantId::default()), "legacy", legacy)
780 4 : .await
781 4 : .expect("store legacy");
782 :
783 4 : purge_tenant(&st, &alpha).await.expect("purge alpha");
784 :
785 : // read as the row's own Tenant: a purge that took the row and a
786 : // store that hides it from its owner are different failures, and the
787 : // assertions below are about the first.
788 28 : async fn present(st: &AppState, owner: Option<&TenantId>, lid: &str) -> bool {
789 28 : st.store
790 28 : .context_get(owner, lid)
791 28 : .await
792 28 : .expect("store read")
793 28 : .is_some()
794 28 : }
795 4 : assert!(
796 4 : !present(&st, Some(&alpha), "hosted-alpha").await,
797 : "the purged Tenant's Hosted row goes"
798 : );
799 4 : assert!(
800 4 : !present(&st, Some(&alpha), "implicit-alpha").await,
801 : "and so does its ImplicitlyCreated wrapper"
802 : );
803 4 : assert!(
804 4 : present(&st, Some(&beta), "hosted-beta").await,
805 : "another Tenant's Hosted @context must survive a purge it has no part in"
806 : );
807 4 : assert!(
808 4 : present(&st, None, "cached-shared").await,
809 : "a Cached copy belongs to no Tenant and is not purged with one"
810 : );
811 4 : assert!(
812 4 : present(&st, Some(&TenantId::default()), "legacy").await,
813 : "an owner-less row belongs to the default Tenant, not to alpha"
814 : );
815 : // the same purge run against the default Tenant reaches the legacy row
816 4 : purge_tenant(&st, &TenantId::default())
817 4 : .await
818 4 : .expect("purge default");
819 4 : assert!(!present(&st, Some(&TenantId::default()), "legacy").await);
820 4 : assert!(
821 4 : present(&st, Some(&beta), "hosted-beta").await,
822 4 : "still beta's"
823 4 : );
824 4 : }
825 :
826 : /// 5.13.4.4: a stored @context resolves by its locally unique URI —
827 : /// the localId and the full published URL name the SAME entry (5.13.2.4),
828 : /// while a URL that only ends in a known localId names no entry at all,
829 : /// and another tenant's Hosted row stays invisible (5.13.1).
830 : #[tokio::test]
831 4 : async fn clause_5_13_4_entry_resolves_by_local_id_and_by_url() {
832 4 : let st = AppState::new("antares-ctx-find".into());
833 4 : let owner = TenantId::default();
834 4 : let other = TenantId::new("beta").expect("tenant");
835 4 : let local_id = "b2a1c0de-0000-4000-8000-000000000001";
836 4 : let url = format!("http://broker.example/ngsi-ld/v1/jsonldContexts/{local_id}");
837 4 : st.store
838 4 : .context_put(Some(&owner), local_id, hosted_row(&url, local_id, &owner))
839 4 : .await
840 4 : .expect("store the @context");
841 4 : assert!(
842 4 : find_entry(&st, &owner, local_id)
843 4 : .await
844 4 : .expect("store")
845 4 : .is_some(),
846 : "the localId names the entry"
847 : );
848 4 : assert!(
849 4 : find_entry(&st, &owner, &url)
850 4 : .await
851 4 : .expect("store")
852 4 : .is_some(),
853 : "the published URL names the same entry"
854 : );
855 4 : assert!(
856 4 : find_entry(&st, &other, &url)
857 4 : .await
858 4 : .expect("store")
859 4 : .is_none(),
860 : "another tenant's Hosted @context is as absent as one that never existed"
861 : );
862 4 : let forged = format!("http://attacker.example/ngsi-ld/v1/jsonldContexts/{local_id}");
863 4 : assert!(
864 4 : find_entry(&st, &owner, &forged)
865 4 : .await
866 4 : .expect("store")
867 4 : .is_none(),
868 : "a foreign URL ending in a known localId is not that entry"
869 : );
870 4 : assert!(find_entry(&st, &owner, "no-such-context")
871 4 : .await
872 4 : .expect("store")
873 4 : .is_none());
874 4 : }
875 :
876 : /// 5.13.2.4 stores the @context "supplied by the client" under a locally
877 : /// unique URI; 5.13.1 makes that Hosted entry the adding Tenant's own
878 : /// resource and 5.5.10 makes the Tenant the boundary an operation applies
879 : /// within. So the mappings expand the adding Tenant's payloads only —
880 : /// another Tenant handing the broker the same URL resolves nothing.
881 : #[tokio::test]
882 4 : async fn clause_5_13_1_added_context_expands_only_the_adding_tenant() {
883 4 : let st = AppState::new("antares-ctx-tenant".into());
884 4 : let alpha = TenantId::new("alpha").expect("tenant");
885 4 : let beta = TenantId::new("beta").expect("tenant");
886 4 : let mut headers = HeaderMap::new();
887 : // a dead address: the published URL cannot be fetched back over the
888 : // network, so a resolution that succeeds came from the stored entry
889 4 : headers.insert(header::HOST, "127.0.0.1:9".parse().expect("host"));
890 4 : headers.insert("NGSILD-Tenant", "alpha".parse().expect("tenant header"));
891 4 : let resp = add_context(
892 4 : State(st.clone()),
893 4 : CleanParams(HashMap::new()),
894 4 : headers.clone(),
895 4 : Bytes::from(r#"{"@context":{"secret":"https://alpha.example/secret"}}"#),
896 4 : )
897 4 : .await;
898 4 : assert_eq!(resp.status(), StatusCode::CREATED);
899 4 : let local_id = resp
900 4 : .headers()
901 4 : .get(header::LOCATION)
902 4 : .and_then(|v| v.to_str().ok())
903 4 : .and_then(|l| l.rsplit('/').next())
904 4 : .expect("Location names the new @context")
905 4 : .to_owned();
906 : // 5.13.2.4: "a locally unique identifier (URI) is generated"
907 4 : assert!(
908 4 : antares_model::EntityId::new(&local_id).is_ok(),
909 : "{local_id} is a URI"
910 : );
911 4 : let url = Value::String(format!("{}/{local_id}", base_url(&headers)));
912 :
913 4 : let ctx = st
914 4 : .loader
915 4 : .resolve_for(&alpha, &url)
916 4 : .await
917 4 : .expect("the adding Tenant resolves its own @context");
918 4 : assert_eq!(ctx.expand_key("secret"), "https://alpha.example/secret");
919 :
920 4 : let err = st
921 4 : .loader
922 4 : .resolve_for(&beta, &url)
923 4 : .await
924 4 : .expect_err("another Tenant must not resolve this @context");
925 4 : assert!(
926 4 : matches!(err, NgsiError::LdContextNotAvailable(_)),
927 : "a foreign Hosted @context is not available, got {err:?}"
928 : );
929 : // and the entry is invisible to the other Tenant through the API too
930 4 : assert!(
931 4 : find_entry(&st, &beta, &local_id)
932 4 : .await
933 4 : .expect("store")
934 4 : .is_none(),
935 4 : "another Tenant's Hosted @context is as absent as one that never existed"
936 4 : );
937 4 : }
938 :
939 : /// 5.13.1 + 5.5.10 on the three handlers a client reaches, not only on the
940 : /// lookup they share: another Tenant's Hosted @context must not appear in
941 : /// the listing (with or without `details`, where the URL and the localId
942 : /// would both be readable), must not be served, and must not be deletable.
943 : /// A delete that answered 204 would be worse than a leak: the owning
944 : /// Tenant's term mappings would be gone with no error anywhere.
945 : #[tokio::test]
946 4 : async fn clause_5_13_1_the_handlers_hide_another_tenants_context() {
947 4 : let st = AppState::new("antares-ctx-crosstenant".into());
948 4 : let alpha = TenantId::new("alpha").expect("tenant");
949 4 : let lid = "b2a1c0de-0000-4000-8000-000000000002";
950 4 : let url = format!("http://broker.example/ngsi-ld/v1/jsonldContexts/{lid}");
951 4 : st.store
952 4 : .context_put(Some(&alpha), lid, hosted_row(&url, lid, &alpha))
953 4 : .await
954 4 : .expect("store the @context");
955 4 : let mut foreign = HeaderMap::new();
956 4 : foreign.insert("NGSILD-Tenant", "beta".parse().expect("tenant header"));
957 :
958 8 : for details in ["false", "true"] {
959 8 : let p: HashMap<String, String> = [("details".to_owned(), details.to_owned())]
960 8 : .into_iter()
961 8 : .collect();
962 8 : let resp = list_contexts(State(st.clone()), CleanParams(p), foreign.clone()).await;
963 8 : assert_eq!(resp.status(), StatusCode::OK);
964 8 : let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
965 8 : .await
966 8 : .expect("list body");
967 8 : let shown = String::from_utf8_lossy(&body);
968 8 : assert!(
969 8 : !shown.contains(lid),
970 : "details={details} listed another Tenant's @context: {shown}"
971 : );
972 : }
973 :
974 8 : for id in [lid.to_owned(), url.clone()] {
975 8 : let resp = serve_context(
976 8 : State(st.clone()),
977 8 : Path(id.clone()),
978 8 : CleanParams(HashMap::new()),
979 8 : foreign.clone(),
980 8 : )
981 8 : .await;
982 8 : assert_eq!(
983 8 : resp.status(),
984 : StatusCode::NOT_FOUND,
985 : "serving {id} to another Tenant"
986 : );
987 8 : let resp = delete_context(
988 8 : State(st.clone()),
989 8 : Path(id.clone()),
990 8 : CleanParams(HashMap::new()),
991 8 : foreign.clone(),
992 8 : )
993 8 : .await;
994 8 : assert_eq!(
995 8 : resp.status(),
996 : StatusCode::NOT_FOUND,
997 : "deleting {id} as another Tenant"
998 : );
999 : }
1000 4 : assert!(
1001 4 : st.store
1002 4 : .context_get(Some(&alpha), lid)
1003 4 : .await
1004 4 : .expect("store")
1005 4 : .is_some(),
1006 : "the owning Tenant's @context survived a foreign delete"
1007 : );
1008 : // and the owner still reaches it
1009 4 : let mut own = HeaderMap::new();
1010 4 : own.insert("NGSILD-Tenant", "alpha".parse().expect("tenant header"));
1011 4 : let resp = serve_context(
1012 4 : State(st.clone()),
1013 4 : Path(lid.to_owned()),
1014 4 : CleanParams(HashMap::new()),
1015 4 : own,
1016 4 : )
1017 4 : .await;
1018 4 : assert_eq!(resp.status(), StatusCode::OK, "the owner is served its own");
1019 4 : }
1020 :
1021 : /// Table 6.29.3.2-1: List @contexts defines `details` and `kind` only —
1022 : /// a pagination parameter this resource does not implement must be
1023 : /// refused (6.3.20 InvalidRequest), never accepted and ignored.
1024 : #[tokio::test]
1025 4 : async fn clause_6_29_3_2_list_takes_only_the_table_parameters() {
1026 4 : let st = AppState::new("antares-ctx-params".into());
1027 16 : for bad in ["limit", "offset", "count", "bogus"] {
1028 16 : let p: HashMap<String, String> =
1029 16 : [(bad.to_owned(), "1".to_owned())].into_iter().collect();
1030 16 : let resp = list_contexts(State(st.clone()), CleanParams(p), HeaderMap::new()).await;
1031 16 : assert_eq!(
1032 16 : resp.status(),
1033 4 : StatusCode::BAD_REQUEST,
1034 4 : "{bad:?} is not a List @contexts parameter"
1035 4 : );
1036 4 : }
1037 8 : for good in [("details", "true"), ("kind", "Hosted")] {
1038 8 : let p: HashMap<String, String> = [(good.0.to_owned(), good.1.to_owned())]
1039 8 : .into_iter()
1040 8 : .collect();
1041 8 : let resp = list_contexts(State(st.clone()), CleanParams(p), HeaderMap::new()).await;
1042 8 : assert_eq!(resp.status(), StatusCode::OK, "{good:?}");
1043 4 : }
1044 4 : }
1045 :
1046 : /// 5.13.2.4/5.13.3.5: the URI published for a broker-served @context
1047 : /// names the BROKER. It is taken from configuration, so a forged `Host`
1048 : /// header cannot make the broker advertise someone else's address, and a
1049 : /// TLS deployment is not stuck advertising `http`.
1050 : #[test]
1051 4 : fn published_context_url_comes_from_configuration_not_the_host_header() {
1052 4 : assert_eq!(
1053 4 : context_base(Some("https://broker.example"), &forged_host()),
1054 : "https://broker.example/ngsi-ld/v1/jsonldContexts"
1055 : );
1056 : // a configured value with a trailing slash must not double it
1057 4 : assert_eq!(
1058 4 : context_base(Some("https://broker.example/"), &forged_host()),
1059 : "https://broker.example/ngsi-ld/v1/jsonldContexts"
1060 : );
1061 : // nothing configured: today's Host-derived URL stays the fallback
1062 4 : assert_eq!(
1063 4 : context_base(None, &forged_host()),
1064 : "http://attacker.example/ngsi-ld/v1/jsonldContexts"
1065 : );
1066 4 : assert_eq!(
1067 4 : context_base(Some(""), &forged_host()),
1068 : "http://attacker.example/ngsi-ld/v1/jsonldContexts",
1069 : "an empty setting is no setting"
1070 : );
1071 4 : }
1072 : }
|