Skip to main content

dpp_vc/jsonld/
context.rs

1//! JSON-LD context envelope: build / frame / strip a DPP passport payload.
2
3use std::sync::OnceLock;
4
5use serde_json::{Value, json};
6
7/// Remote contexts this passport context references.
8///
9/// **A string entry in an `@context` array is fetched by the consumer at
10/// expansion time.** One that does not resolve is not cosmetic: a conforming
11/// processor fails the whole document with a remote-context load error, and a
12/// lenient one drops every term it cannot define. Since our payload uses bare
13/// keys, that means the `ld+json` door would convey no linked data at all —
14/// worse than serving plain JSON, because the `@context` is itself a claim that
15/// the document is semantically resolvable.
16///
17/// So this list is deliberately short and deliberately explicit: adding to it
18/// means editing this constant *and* the test that pins it, which is the point
19/// at which someone checks the URL. Two entries were removed on 2026-07-30 for
20/// returning 404 — `https://ref.gs1.org/standards/digital-link/context/`, which
21/// this crate referenced, and `https://odal-node.io/schemas/dpp/v1`, which the
22/// resolver hand-rolled.
23///
24/// Term-to-IRI mappings are a different matter and are inlined below: a prefix
25/// IRI names a vocabulary and is never dereferenced during expansion, so it
26/// carries no such obligation.
27pub const REMOTE_CONTEXTS: &[&str] = &["https://www.w3.org/ns/did/v1"];
28
29/// Build the JSON-LD context for an Odal Node passport.
30///
31/// The vocabulary is **inlined** rather than hosted. Hosting a context document
32/// is a commitment to keep a URL resolving for as long as any passport
33/// referencing it exists — years, under ESPR retention — and that is an
34/// operational obligation, not a library decision. An inline term map cannot
35/// 404, and it can be adopted later without invalidating passports issued now.
36///
37/// The literal is built once and cloned per call — callers extend the
38/// returned value (e.g. [`frame_passport`] merges passport fields into it),
39/// so it must stay an owned, independently-mutable `Value` per call site.
40pub fn passport_context() -> Value {
41    static CONTEXT: OnceLock<Value> = OnceLock::new();
42    CONTEXT
43        .get_or_init(|| {
44            json!({
45                "@context": [
46                    REMOTE_CONTEXTS[0],
47                    {
48                        "dpp": "https://schema.odal-node.io/dpp#",
49                        "gs1": "https://ref.gs1.org/voc/",
50                        "schema": "https://schema.org/",
51                        "gtin": "gs1:gtin",
52                        "sector": "dpp:sector",
53                        "passportId": "dpp:passportId",
54                        "status": "dpp:status",
55                        "sectorData": "dpp:sectorData",
56                        "complianceResult": "dpp:complianceResult",
57                        "createdAt": "schema:dateCreated",
58                        "updatedAt": "schema:dateModified",
59                        "jws": "dpp:jws"
60                    }
61                ]
62            })
63        })
64        .clone()
65}
66
67/// The `@context` value alone, for a caller that already has a passport object
68/// and needs to stamp the context onto it.
69///
70/// Exists so the resolver stops constructing its own: two definitions of one
71/// context is how the served one came to reference a URL that 404s while this
72/// one referenced a different URL that also 404s.
73pub fn context_value() -> Value {
74    passport_context()["@context"].clone()
75}
76
77/// Wrap a passport JSON value in a JSON-LD envelope.
78///
79/// A non-object payload cannot be merged into the `@context` object; it is
80/// returned **unchanged** rather than silently discarded into a bare, empty
81/// envelope.
82pub fn frame_passport(passport: Value) -> Value {
83    match passport {
84        Value::Object(passport_map) => {
85            let mut framed = passport_context();
86            if let Value::Object(ref mut ctx_map) = framed {
87                ctx_map.extend(passport_map);
88            }
89            framed
90        }
91        other => other,
92    }
93}
94
95/// Extract the plain data from a JSON-LD framed passport (strip `@context`).
96pub fn strip_context(framed: Value) -> Value {
97    match framed {
98        Value::Object(mut map) => {
99            map.remove("@context");
100            Value::Object(map)
101        }
102        other => other,
103    }
104}