Skip to main content

mdmodels_core/jsonld/
schema.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use serde_json::Value as JsonValue;
4
5/// Represents the JSON-LD document header, containing context, id, and type definitions.
6/// This structure is used as the envelope for JSON-LD documents and nodes.
7///
8/// The fields correspond to the standard JSON-LD keywords:
9/// - `@context`: Describes the term definitions and mapping information; can be an IRI, an object, or an array of contexts.
10/// - `@id`: The node identifier, usually as an IRI string.
11/// - `@type`: The semantic type(s) of the node as a string or array of strings.
12#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Default)]
13pub struct JsonLdHeader {
14    #[serde(rename = "@context", skip_serializing_if = "Option::is_none")]
15    pub context: Option<JsonLdContext>,
16
17    #[serde(rename = "@id", skip_serializing_if = "Option::is_none")]
18    pub id: Option<String>,
19
20    #[serde(rename = "@type", skip_serializing_if = "Option::is_none")]
21    pub type_: Option<TypeOrVec>,
22}
23
24impl JsonLdHeader {
25    /// Add a new term definition to the context, creating the context object if not present.
26    ///
27    /// # Arguments
28    ///
29    /// * `name` - The key/term to be added to the `@context`
30    /// * `term` - The associated mapping (simple IRI, detailed mapping, or null)
31    pub fn add_term(&mut self, name: &str, term: TermDef) {
32        let ctx = self
33            .context
34            .get_or_insert_with(|| JsonLdContext::Object(SimpleContext::default()));
35        if let JsonLdContext::Object(object) = ctx {
36            object.terms.insert(name.to_string(), term);
37        }
38    }
39
40    /// Insert or replace a term definition in the context.
41    pub fn update_term(&mut self, name: &str, term: TermDef) {
42        self.add_term(name, term);
43    }
44
45    /// Remove a term from the context. Returns true if the term existed and was removed.
46    pub fn remove_term(&mut self, name: &str) -> bool {
47        if let Some(JsonLdContext::Object(object)) = &mut self.context {
48            object.terms.shift_remove(name).is_some()
49        } else {
50            false
51        }
52    }
53
54    /// Add an `@import` field as required by JSON-LD 1.1.
55    /// If `@import` already exists, merges multiple contexts into an array.
56    ///
57    /// # Arguments
58    ///
59    /// * `import_url` - The URL (IRI) to include with `@import`.
60    pub fn add_import(&mut self, import_url: impl Into<String>) {
61        let import_url = import_url.into();
62        match self.context.take() {
63            None => {
64                let obj = SimpleContext {
65                    import: Some(import_url),
66                    ..Default::default()
67                };
68                self.context = Some(JsonLdContext::Object(obj));
69            }
70            Some(JsonLdContext::Object(mut obj)) => {
71                if obj.import.is_none() {
72                    obj.import = Some(import_url);
73                    self.context = Some(JsonLdContext::Object(obj));
74                } else {
75                    let arr = vec![
76                        JsonLdContext::Object(obj),
77                        JsonLdContext::Object(SimpleContext {
78                            import: Some(import_url),
79                            ..Default::default()
80                        }),
81                    ];
82                    self.context = Some(JsonLdContext::Array(arr));
83                }
84            }
85            Some(JsonLdContext::Iri(iri)) => {
86                let arr = vec![
87                    JsonLdContext::Iri(iri),
88                    JsonLdContext::Object(SimpleContext {
89                        import: Some(import_url),
90                        ..Default::default()
91                    }),
92                ];
93                self.context = Some(JsonLdContext::Array(arr));
94            }
95            Some(JsonLdContext::Array(mut arr)) => {
96                arr.push(JsonLdContext::Object(SimpleContext {
97                    import: Some(import_url),
98                    ..Default::default()
99                }));
100                self.context = Some(JsonLdContext::Array(arr));
101            }
102        }
103    }
104}
105
106/// Represents the possible values for the `@type` field in JSON-LD nodes.
107/// Accepts a single type string or an array of type strings.
108#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
109#[serde(untagged)]
110pub enum TypeOrVec {
111    /// A single type annotation as a string
112    Single(String),
113    /// Multiple types as a vector of strings
114    Multi(Vec<String>),
115}
116
117/// Describes how a JSON-LD `@context` may be represented:
118/// as a remote IRI, an inline object, or a list of multiple contexts.
119///
120/// This flexibility enables referencing remote contexts, inlining ad-hoc mappings,
121/// or combining several contexts in a single document.
122#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
123#[serde(untagged)]
124#[allow(clippy::large_enum_variant)]
125pub enum JsonLdContext {
126    /// The context is a remote document specified by an IRI.
127    Iri(String),
128    /// The context is a locally inlined object containing mappings and settings.
129    Object(SimpleContext),
130    /// The context is a list of IRIs and/or objects, processed in order.
131    Array(Vec<JsonLdContext>),
132}
133
134/// Defines the structure of an inline JSON-LD `@context` object, supporting:
135/// - reserved context keywords (e.g., `@base`, `@vocab`, `@protected`)
136/// - term mappings for expanding compact keys to IRIs or more detailed definitions
137///
138/// Most reserved keywords are optional.
139/// Doubly-Option fields (e.g., `Option<Option<String>>`) allow distinguishing between
140/// omitted, null, or non-null values when serializing.
141#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]
142pub struct SimpleContext {
143    /// `@import`: Import external context definition (JSON-LD 1.1 feature).
144    #[serde(rename = "@import", skip_serializing_if = "Option::is_none")]
145    pub import: Option<String>,
146
147    /// `@type`: The type of the context object.
148    #[serde(rename = "@type", skip_serializing_if = "Option::is_none")]
149    pub type_: Option<TypeOrVec>,
150
151    /// `@version`: Indicates the JSON-LD version this context targets.
152    #[serde(rename = "@version", skip_serializing_if = "Option::is_none")]
153    pub version: Option<String>,
154
155    /// `@base`: The default base IRI; Some(None) serializes to null (explicitly clear).
156    #[serde(rename = "@base", skip_serializing_if = "Option::is_none")]
157    pub base: Option<Option<String>>,
158
159    /// `@vocab`: The default vocabulary IRI; Some(None) outputs null for explicit clearing.
160    #[serde(rename = "@vocab", skip_serializing_if = "Option::is_none")]
161    pub vocab: Option<Option<String>>,
162
163    /// `@language`: Default language code. Some(None) serializes as null.
164    #[serde(rename = "@language", skip_serializing_if = "Option::is_none")]
165    pub language: Option<Option<String>>,
166
167    /// `@direction`: Base direction for string values ("ltr" or "rtl").
168    #[serde(rename = "@direction", skip_serializing_if = "Option::is_none")]
169    pub direction: Option<String>,
170
171    /// `@protected`: When true, marks all term definitions in this context as protected.
172    #[serde(rename = "@protected", skip_serializing_if = "Option::is_none")]
173    pub protected: Option<bool>,
174
175    /// Key-value term definitions (mappings or detailed definitions for context terms).
176    #[serde(flatten, skip_serializing_if = "IndexMap::is_empty", default)]
177    pub terms: IndexMap<String, TermDef>,
178}
179
180/// Represents a context term mapping:
181/// - as a simple IRI string,
182/// - as a detailed object (`TermDetail`) with advanced options,
183/// - or explicitly `null` to remove or overwrite a term definition.
184///
185/// The null variant uses `serde_json::Value::Null` under the hood to allow proper null emission in serialization.
186#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
187#[serde(untagged)]
188pub enum TermDef {
189    /// Direct mapping to an IRI (string).
190    Simple(String),
191    /// Full-featured definition using advanced JSON-LD keyword fields.
192    Detailed(TermDetail),
193    /// Explicit null value for term removal or overriding.
194    Null(JsonValue), // Must be JsonValue::Null in use.
195}
196
197/// Provides a full mapping for a term in a JSON-LD context, supporting advanced features such as:
198/// type coercion, container types, nested context definition, language, protection, prefixing, reverse properties, and data nesting.
199///
200/// Each field is optional and maps to the appropriate JSON-LD 1.1 feature.
201#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]
202pub struct TermDetail {
203    /// The IRI or keyword that this term maps to (`@id`).
204    #[serde(rename = "@id", skip_serializing_if = "Option::is_none")]
205    pub id: Option<String>,
206
207    /// Type coercion target for values (`@type`). E.g., "@id", "@vocab", or custom IRI.
208    #[serde(rename = "@type", skip_serializing_if = "Option::is_none")]
209    pub type_: Option<String>,
210
211    /// Specifies container type(s) (e.g., ["@set"], "@list"). Single value or array.
212    #[serde(rename = "@container", skip_serializing_if = "Option::is_none")]
213    pub container: Option<OneOrMany<String>>,
214
215    /// Nested context (`@context`) to use for this term, if any.
216    #[serde(rename = "@context", skip_serializing_if = "Option::is_none")]
217    pub context: Option<Box<JsonLdContext>>,
218
219    /// Marks this context term as protected (`@protected`).
220    #[serde(rename = "@protected", skip_serializing_if = "Option::is_none")]
221    pub protected: Option<bool>,
222
223    /// Whether this term provides prefix expansion (`@prefix`).
224    #[serde(rename = "@prefix", skip_serializing_if = "Option::is_none")]
225    pub prefix: Option<bool>,
226
227    /// Defines a reverse property mapping (`@reverse`).
228    #[serde(rename = "@reverse", skip_serializing_if = "Option::is_none")]
229    pub reverse: Option<String>,
230
231    /// Directs compacted output to nest data at this property (`@nest`).
232    #[serde(rename = "@nest", skip_serializing_if = "Option::is_none")]
233    pub nest: Option<String>,
234
235    /// Assigns the property to a named index (`@index`).
236    #[serde(rename = "@index", skip_serializing_if = "Option::is_none")]
237    pub index: Option<String>,
238
239    /// Sets the default language for string values in this term. If Some(None), serializes as explicit null.
240    #[serde(rename = "@language", skip_serializing_if = "Option::is_none")]
241    pub language: Option<Option<String>>,
242}
243
244/// Utility type that accepts either a single element or a list, used for keywords like
245/// `@container` which can take a string or array of strings in JSON-LD.
246#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
247#[serde(untagged)]
248pub enum OneOrMany<T> {
249    /// Single value.
250    One(T),
251    /// Multiple values.
252    Many(Vec<T>),
253}
254
255impl TermDef {
256    /// Convenience constructor for a null-valued term, meaning term removal or explicit undefinition.
257    pub fn null() -> Self {
258        TermDef::Null(JsonValue::Null)
259    }
260}
261
262impl SimpleContext {
263    /// Ensures the inline context explicitly contains `@version: 1.1`.
264    /// Returns self for fluent usage.
265    pub fn ensure_v11(mut self) -> Self {
266        self.version = Some("1.1".to_string());
267        self
268    }
269}