Skip to main content

oxirs_core/jsonld/
framing.rs

1//! Main entry point for JSON-LD 1.1 Framing.
2//!
3//! Implements the [W3C JSON-LD 1.1 Framing](https://www.w3.org/TR/json-ld11-framing/)
4//! specification, which allows reshaping a JSON-LD document into a tree structure
5//! described by a "frame".
6//!
7//! # Overview
8//!
9//! Given an expanded JSON-LD document and a frame object, framing:
10//!
11//! 1. Builds a flat subject map from the expanded input.
12//! 2. Matches subjects against the frame using type, `@id`, and property constraints.
13//! 3. Embeds matched subjects according to the configured `EmbedPolicy`, applying
14//!    cycle detection via `FramingState`.
15//! 4. Optionally prunes properties (`@explicit`) and injects defaults (`@omitDefault`).
16//! 5. Returns the framed document as `{"@graph": [...], "@context": {}}`.
17
18use serde_json::{json, Value};
19use std::collections::{HashMap, HashSet};
20use thiserror::Error;
21
22use super::framing_embed::{apply_defaults, apply_explicit, embed_subject, EmbedDecision};
23use super::framing_match::matches_frame;
24
25// ──────────────────────────────────────────────────────────────────────────────
26// Error type
27// ──────────────────────────────────────────────────────────────────────────────
28
29/// Errors that may occur during JSON-LD 1.1 Framing.
30#[derive(Debug, Error)]
31pub enum FramingError {
32    #[error("Invalid frame: {0}")]
33    InvalidFrame(String),
34    #[error("Expansion required before framing: {0}")]
35    ExpansionRequired(String),
36    #[error("Invalid @embed value: {0}")]
37    InvalidEmbedValue(String),
38    #[error("Cyclic embed detected for subject: {0}")]
39    CyclicEmbed(String),
40}
41
42// ──────────────────────────────────────────────────────────────────────────────
43// EmbedPolicy
44// ──────────────────────────────────────────────────────────────────────────────
45
46/// Controls how matched subjects are embedded in the framed output.
47///
48/// Corresponds to the JSON-LD 1.1 Framing `@embed` keyword values.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum EmbedPolicy {
51    /// Embed only the first occurrence of a subject; subsequent references become `@id` links.
52    First,
53    /// Embed the last occurrence; earlier references become `@id` links.
54    Last,
55    /// Always embed, but use `@id` links to break detected cycles.
56    Always,
57    /// Never embed; all subject references are `@id` links.
58    Never,
59    /// Embed using `@link` references to support lossless round-trips.
60    Link,
61}
62
63impl Default for EmbedPolicy {
64    /// The W3C spec default is `@once`, which is equivalent to `First`.
65    fn default() -> Self {
66        Self::First
67    }
68}
69
70impl EmbedPolicy {
71    /// Parse from a JSON-LD `@embed` string value.
72    pub fn parse_embed_value(s: &str) -> Result<Self, FramingError> {
73        match s {
74            "@first" | "@once" => Ok(Self::First),
75            "@last" => Ok(Self::Last),
76            "@always" => Ok(Self::Always),
77            "@never" => Ok(Self::Never),
78            "@link" => Ok(Self::Link),
79            other => Err(FramingError::InvalidEmbedValue(other.to_string())),
80        }
81    }
82}
83
84// ──────────────────────────────────────────────────────────────────────────────
85// FramingOptions
86// ──────────────────────────────────────────────────────────────────────────────
87
88/// Configuration for a JSON-LD framing operation.
89///
90/// These correspond to the processing options defined in the W3C Framing spec
91/// (§4.2 *Framing Flags*).
92#[derive(Debug, Clone)]
93pub struct FramingOptions {
94    /// Embedding policy (default: `First`).
95    pub embed: EmbedPolicy,
96    /// When `true`, only frame-specified properties are included in the output.
97    pub explicit: bool,
98    /// When `true`, missing properties with `@default` values are omitted.
99    pub omit_default: bool,
100    /// When `true`, a subject must match **all** frame patterns (logical AND).
101    /// When `false` (default), matching **any** frame property pattern is sufficient.
102    pub require_all: bool,
103    /// When `true`, prune `@none` values from language maps.
104    pub pruned_none: bool,
105}
106
107impl Default for FramingOptions {
108    fn default() -> Self {
109        Self {
110            embed: EmbedPolicy::First,
111            explicit: false,
112            omit_default: false,
113            require_all: false,
114            pruned_none: false,
115        }
116    }
117}
118
119// ──────────────────────────────────────────────────────────────────────────────
120// FramingState
121// ──────────────────────────────────────────────────────────────────────────────
122
123/// Mutable state shared across a single framing operation.
124///
125/// Tracks which subject IRIs have already been fully embedded to prevent
126/// infinite recursion, and maintains `@link` references for the `Link` embed
127/// policy.
128#[derive(Debug, Default)]
129pub struct FramingState {
130    /// Subject IRIs that have already been embedded in the current output tree.
131    pub embedded: HashSet<String>,
132    /// For the `Link` embed policy: maps subject IRI → embedded node object.
133    pub link: HashMap<String, Value>,
134}
135
136impl FramingState {
137    /// Create a fresh framing state.
138    pub fn new() -> Self {
139        Self {
140            embedded: HashSet::new(),
141            link: HashMap::new(),
142        }
143    }
144}
145
146// ──────────────────────────────────────────────────────────────────────────────
147// JsonLdFramer
148// ──────────────────────────────────────────────────────────────────────────────
149
150/// Main struct for performing JSON-LD 1.1 Framing.
151///
152/// # Example
153///
154/// ```rust
155/// use oxirs_core::jsonld::framing::{JsonLdFramer, FramingOptions, EmbedPolicy};
156///
157/// let options = FramingOptions::default();
158/// let framer = JsonLdFramer::new(options);
159///
160/// let input = serde_json::json!([
161///     {"@id": "http://example.org/alice", "@type": ["http://example.org/Person"],
162///      "http://example.org/name": [{"@value": "Alice"}]}
163/// ]);
164/// let frame = serde_json::json!({"@type": "http://example.org/Person"});
165///
166/// let result = framer.frame(&input, &frame).expect("framing should succeed");
167/// assert!(result["@graph"].is_array());
168/// ```
169#[derive(Debug, Clone)]
170pub struct JsonLdFramer {
171    options: FramingOptions,
172}
173
174impl JsonLdFramer {
175    /// Create a new framer with the given options.
176    pub fn new(options: FramingOptions) -> Self {
177        Self { options }
178    }
179
180    /// Perform JSON-LD 1.1 Framing.
181    ///
182    /// Both `input` and `frame` must already be in **expanded** form (i.e. an
183    /// array of subject nodes).  The returned document has the shape:
184    ///
185    /// ```json
186    /// {
187    ///   "@graph": [...],
188    ///   "@context": {}
189    /// }
190    /// ```
191    ///
192    /// The caller is responsible for expansion and compaction if needed.
193    pub fn frame(&self, input: &Value, frame: &Value) -> Result<Value, FramingError> {
194        // ── Step 1: Build subject map from the expanded input ──────────────────
195        let subjects = self.build_subject_map(input)?;
196
197        // ── Step 2: Resolve frame options overrides from the frame object ──────
198        let options = self.resolve_frame_options(frame);
199
200        // ── Step 3: Frame subjects ─────────────────────────────────────────────
201        let mut state = FramingState::new();
202        let framed = self.frame_subjects(&mut state, &subjects, frame, &options)?;
203
204        // ── Step 4: Build the output document ─────────────────────────────────
205        Ok(json!({
206            "@context": {},
207            "@graph": framed
208        }))
209    }
210
211    /// Build a flat subject map (`@id` → node object) from an expanded
212    /// JSON-LD input.  Handles both array-form `[{...}, ...]` and single-
213    /// object form `{...}`.
214    fn build_subject_map(&self, input: &Value) -> Result<HashMap<String, Value>, FramingError> {
215        let mut map: HashMap<String, Value> = HashMap::new();
216
217        let nodes = match input {
218            Value::Array(arr) => arr.as_slice(),
219            Value::Object(_) => std::slice::from_ref(input),
220            Value::Null => return Ok(map),
221            _ => {
222                return Err(FramingError::ExpansionRequired(
223                    "Input must be an expanded JSON-LD array or object".to_string(),
224                ))
225            }
226        };
227
228        for node in nodes {
229            self.collect_subjects(node, &mut map);
230        }
231
232        Ok(map)
233    }
234
235    /// Recursively extract all subject nodes from a node object, populating
236    /// `map`.  Nested graph/value objects are traversed but only nodes with
237    /// an `@id` are indexed.
238    fn collect_subjects(&self, node: &Value, map: &mut HashMap<String, Value>) {
239        let obj = match node {
240            Value::Object(o) => o,
241            Value::Array(arr) => {
242                for item in arr {
243                    self.collect_subjects(item, map);
244                }
245                return;
246            }
247            _ => return,
248        };
249
250        // Index if this node has an @id
251        if let Some(Value::String(id)) = obj.get("@id") {
252            map.entry(id.clone()).or_insert_with(|| node.clone());
253        }
254
255        // Recurse into property values and @graph
256        for (key, val) in obj {
257            if key == "@graph" {
258                self.collect_subjects(val, map);
259            } else if key.starts_with('@') {
260                // skip other keywords
261            } else {
262                // property values are arrays of nodes/values
263                if let Value::Array(vals) = val {
264                    for v in vals {
265                        if let Value::Object(vo) = v {
266                            if !vo.contains_key("@value") {
267                                self.collect_subjects(v, map);
268                            }
269                        }
270                    }
271                }
272            }
273        }
274    }
275
276    /// Resolve per-operation options from `@embed`, `@explicit`, etc. flags
277    /// embedded in the frame object, overlaying the constructor-level options.
278    fn resolve_frame_options(&self, frame: &Value) -> FramingOptions {
279        let mut opts = self.options.clone();
280
281        if let Value::Object(fobj) = frame {
282            if let Some(Value::String(embed_val)) = fobj.get("@embed") {
283                if let Ok(policy) = EmbedPolicy::parse_embed_value(embed_val.as_str()) {
284                    opts.embed = policy;
285                }
286            }
287            if let Some(Value::Bool(explicit)) = fobj.get("@explicit") {
288                opts.explicit = *explicit;
289            }
290            if let Some(Value::Bool(omit)) = fobj.get("@omitDefault") {
291                opts.omit_default = *omit;
292            }
293            if let Some(Value::Bool(req_all)) = fobj.get("@requireAll") {
294                opts.require_all = *req_all;
295            }
296        }
297
298        opts
299    }
300
301    /// Match all subjects against the frame and return the embedded output nodes.
302    ///
303    /// Implements *§4.5 Framing algorithm* from the W3C spec:
304    /// for each subject in the map, test `matches_frame`; if it matches, call
305    /// `embed_subject`.
306    pub fn frame_subjects(
307        &self,
308        state: &mut FramingState,
309        subjects: &HashMap<String, Value>,
310        frame: &Value,
311        options: &FramingOptions,
312    ) -> Result<Vec<Value>, FramingError> {
313        let mut output: Vec<Value> = Vec::new();
314
315        // Collect and sort subject IDs for deterministic ordering
316        let mut ids: Vec<&String> = subjects.keys().collect();
317        ids.sort();
318
319        for id in ids {
320            let subject = subjects.get(id).expect("key from map");
321
322            if !matches_frame(subject, frame, options) {
323                continue;
324            }
325
326            let embed_decision =
327                super::framing_embed::apply_embed_policy(state, id, options.embed.clone());
328
329            match embed_decision {
330                EmbedDecision::Skip => {
331                    // Emit a bare @id reference
332                    output.push(json!({"@id": id}));
333                }
334                EmbedDecision::Link => {
335                    // Reuse the previously computed node via @link
336                    if let Some(linked) = state.link.get(id) {
337                        output.push(linked.clone());
338                    } else {
339                        output.push(json!({"@id": id}));
340                    }
341                }
342                EmbedDecision::Full => {
343                    state.embedded.insert(id.clone());
344                    let mut embedded = embed_subject(state, subject, frame, subjects, options)?;
345
346                    // Apply @explicit pruning
347                    embedded = apply_explicit(&embedded, frame, options);
348
349                    // Inject @default values for missing properties
350                    apply_defaults(&mut embedded, frame, options);
351
352                    if options.embed == EmbedPolicy::Link {
353                        state.link.insert(id.clone(), embedded.clone());
354                    }
355
356                    output.push(embedded);
357                }
358            }
359        }
360
361        Ok(output)
362    }
363}