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}