Skip to main content

kanonak_codec/
lib.rs

1//! kanonak-codec — the generic, ontology-independent codec runtime (Rust port).
2//!
3//! Given a `CodecSchema` (the per-package metadata a generated SDK embeds) and a
4//! set of typed nodes, it builds the canonical input model and content-addresses
5//! it via `kanonak-canonical` (the same content-form the Python/TypeScript
6//! references and the `kanonak hash` CLI produce). It also (de)serializes the
7//! normalized-JSON wire form. Self-contained: carriers come from the schema's
8//! datatype URIs, and the resolved foundation URIs are embedded by the generator,
9//! so hashing needs no runtime ontology resolution.
10//!
11//! A node is a plain JSON object (`serde_json::Map<String, serde_json::Value>`) —
12//! the `$`-envelope plus alias-collapsed local-name fields. A generated typed
13//! model serializes to one. Note: `serde_json::Value` (the node field model) is
14//! distinct from `kanonak_canonical::Value` (the canonical-input value enum).
15
16use kanonak_canonical::{
17    canonical_form as canonical_form_pkg, canonical_hash as canonical_hash_pkg, carrier_of,
18    CanonError, Package, Statement, Subject, Value,
19};
20use serde_json::{Map, Value as Json};
21
22mod typed;
23pub use typed::{to_node, KanonakNode, KanonakResource, Ref};
24
25/// The reserved `$`-envelope keys, which never become statements/predicates.
26/// `$name` (0.2.0) carries an embedded value's authored dict-key — hash-relevant.
27const ENVELOPE_KEYS: [&str; 6] = [
28    "$type",
29    "$id",
30    "$name",
31    "$contentHash",
32    "$version",
33    "$extra",
34];
35
36/// A node is a JSON object.
37pub type Node = Map<String, Json>;
38
39/// Errors raised by the codec runtime. Fails loudly — no fallbacks.
40#[derive(Debug)]
41pub enum CodecError {
42    /// A node, schema, or package context was malformed.
43    Malformed(String),
44    /// The underlying canonical library rejected a lexical/value.
45    Canon(CanonError),
46}
47
48impl std::fmt::Display for CodecError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            CodecError::Malformed(m) => write!(f, "{}", m),
52            CodecError::Canon(e) => write!(f, "{}", e.0),
53        }
54    }
55}
56
57impl std::error::Error for CodecError {}
58
59impl From<CanonError> for CodecError {
60    fn from(e: CanonError) -> Self {
61        CodecError::Canon(e)
62    }
63}
64
65fn err<T>(msg: impl Into<String>) -> Result<T, CodecError> {
66    Err(CodecError::Malformed(msg.into()))
67}
68
69/// The raw lexical token of a scalar — the input the canonical form normalizes.
70/// bool -> "true"/"false"; string -> the string; number -> its plain decimal
71/// string (serde_json's `Number::to_string()` gives "5" / "1.5", never
72/// scientific notation). The canonical crate re-normalizes from there.
73fn lexical(value: &Json) -> String {
74    match value {
75        Json::Bool(b) => {
76            if *b {
77                "true".to_string()
78            } else {
79                "false".to_string()
80            }
81        }
82        Json::String(s) => s.clone(),
83        Json::Number(n) => n.to_string(),
84        other => other.to_string(),
85    }
86}
87
88/// Build a single canonical `Value` for one (non-list) field datum, per its
89/// schema prop.
90fn build_value(prop: &Json, raw: &Json, schema: &Json) -> Result<Value, CodecError> {
91    let kind = prop
92        .get("kind")
93        .and_then(|k| k.as_str())
94        .ok_or_else(|| CodecError::Malformed("schema prop is missing 'kind'".into()))?;
95    if kind == "object" {
96        // A node: a reference (`{"$ref"}`) or an embedded resource.
97        if let Some(reference) = raw.get("$ref").and_then(|r| r.as_str()) {
98            return Ok(Value::Reference(reference.to_string()));
99        }
100        if let Some(map) = raw.as_object() {
101            return embedded_value(prop, map, schema);
102        }
103        return err(format!(
104            "Object property expects a reference ({{\"$ref\": ...}}) or an embedded \
105             node (a map), got {}",
106            raw
107        ));
108    }
109    let datatype = prop
110        .get("datatype")
111        .and_then(|d| d.as_str())
112        .ok_or_else(|| CodecError::Malformed("datatype prop is missing 'datatype'".into()))?;
113    match carrier_of(datatype) {
114        None => Ok(Value::Raw(lexical(raw))),
115        Some(carrier) => Ok(Value::Typed {
116            carrier,
117            lexical: lexical(raw),
118        }),
119    }
120}
121
122/// Canonicalize an embedded value (0.2.0): a map with no `$id`, an optional
123/// `$name` (the authored dict-key — hash-relevant), an optional `$type`, and
124/// schema-mapped fields. An explicit `$type` emits a type statement inside the
125/// embedded (hash-relevant even when it equals the range-derived type); without
126/// it, fields map via the containing property's `range` and NO type statement is
127/// emitted — range-derived typing is inference only.
128fn embedded_value(
129    prop: &Json,
130    map: &Map<String, Json>,
131    schema: &Json,
132) -> Result<Value, CodecError> {
133    if map.contains_key("$id") {
134        return err(
135            "An embedded value must not carry $id — to point at a named resource, \
136             pass a reference ({\"$ref\": ...}).",
137        );
138    }
139    let explicit_type = map.get("$type").and_then(|t| t.as_str());
140    let cls_uri = match explicit_type.or_else(|| prop.get("range").and_then(|r| r.as_str())) {
141        Some(uri) => uri,
142        None => {
143            return err(
144                "Cannot map embedded value: it carries no $type and the property \
145                 declares no range.",
146            )
147        }
148    };
149    let cls = schema
150        .get("classes")
151        .and_then(|c| c.get(cls_uri))
152        .ok_or_else(|| CodecError::Malformed(format!("no schema for embedded type {}", cls_uri)))?;
153    let props = cls
154        .get("props")
155        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", cls_uri)))?;
156
157    let mut statements = field_statements(map, props, schema)?;
158    if let Some(type_uri) = explicit_type {
159        let type_predicate = schema
160            .get("typePredicate")
161            .and_then(|p| p.as_str())
162            .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
163        statements.push(Statement {
164            predicate: type_predicate.to_string(),
165            value: Value::Reference(type_uri.to_string()),
166        });
167    }
168    let name = map
169        .get("$name")
170        .and_then(|n| n.as_str())
171        .filter(|s| !s.is_empty())
172        .map(|s| s.to_string());
173    Ok(Value::Embedded { name, statements })
174}
175
176/// The statements for one node-or-embedded's modeled fields + its `$extra` —
177/// everything except the type triple (subjects always carry one; embeddeds only
178/// when explicitly typed).
179fn field_statements(
180    source: &Map<String, Json>,
181    props: &Json,
182    schema: &Json,
183) -> Result<Vec<Statement>, CodecError> {
184    let mut out: Vec<Statement> = Vec::new();
185
186    for (key, raw) in source.iter() {
187        if ENVELOPE_KEYS.contains(&key.as_str()) || raw.is_null() {
188            continue;
189        }
190        match props.get(key) {
191            None => out.push(Statement {
192                predicate: key.clone(),
193                value: Value::Raw(lexical(raw)),
194            }),
195            Some(prop) => {
196                let predicate =
197                    prop.get("predicate")
198                        .and_then(|p| p.as_str())
199                        .ok_or_else(|| {
200                            CodecError::Malformed(format!("prop {} is missing 'predicate'", key))
201                        })?;
202                let value = match raw.as_array() {
203                    Some(items) => {
204                        // An empty list contributes NO statement — absent and empty
205                        // are identical at the canonical layer (the wire serialize
206                        // still preserves the empty list).
207                        if items.is_empty() {
208                            continue;
209                        }
210                        let mut list = Vec::with_capacity(items.len());
211                        for item in items {
212                            list.push(build_value(prop, item, schema)?);
213                        }
214                        Value::List(list)
215                    }
216                    None => build_value(prop, raw, schema)?,
217                };
218                out.push(Statement {
219                    predicate: predicate.to_string(),
220                    value,
221                });
222            }
223        }
224    }
225
226    if let Some(extra) = source.get("$extra") {
227        let extra = extra
228            .as_object()
229            .ok_or_else(|| CodecError::Malformed("$extra must be an object".into()))?;
230        for (predicate, raw) in extra.iter() {
231            if raw.is_null() {
232                continue;
233            }
234            out.push(Statement {
235                predicate: predicate.clone(),
236                value: Value::Raw(lexical(raw)),
237            });
238        }
239    }
240    Ok(out)
241}
242
243/// The statements for one subject node: the rdf:type triple, then its fields.
244fn statements(node: &Node, schema: &Json) -> Result<Vec<Statement>, CodecError> {
245    let type_uri = node
246        .get("$type")
247        .and_then(|t| t.as_str())
248        .filter(|s| !s.is_empty())
249        .ok_or_else(|| CodecError::Malformed("node is missing $type".into()))?;
250
251    let classes = schema
252        .get("classes")
253        .ok_or_else(|| CodecError::Malformed("schema is missing 'classes'".into()))?;
254    let cls = classes
255        .get(type_uri)
256        .ok_or_else(|| CodecError::Malformed(format!("no schema for type {}", type_uri)))?;
257    let props = cls
258        .get("props")
259        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", type_uri)))?;
260
261    let type_predicate = schema
262        .get("typePredicate")
263        .and_then(|p| p.as_str())
264        .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
265
266    let mut out: Vec<Statement> = vec![Statement {
267        predicate: type_predicate.to_string(),
268        value: Value::Reference(type_uri.to_string()),
269    }];
270    out.extend(field_statements(node, props, schema)?);
271    Ok(out)
272}
273
274/// Build the canonical input model: a subject per node + the synthesized
275/// package-wrapper subject (raw label + `Package` type), exactly the subject set
276/// `kanonak hash` produces for the equivalent authored package.
277pub fn build_package(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<Package, CodecError> {
278    let mut subjects: Vec<Subject> = Vec::with_capacity(nodes.len() + 1);
279    for node in nodes {
280        let id = node
281            .get("$id")
282            .and_then(|i| i.as_str())
283            .filter(|s| !s.is_empty())
284            .ok_or_else(|| CodecError::Malformed("node is missing $id".into()))?;
285        subjects.push(Subject {
286            uri: id.to_string(),
287            statements: statements(node, schema)?,
288        });
289    }
290
291    let publisher = pkg
292        .get("publisher")
293        .and_then(|p| p.as_str())
294        .ok_or_else(|| CodecError::Malformed("pkg is missing 'publisher'".into()))?;
295    let package_name = pkg
296        .get("packageName")
297        .and_then(|p| p.as_str())
298        .ok_or_else(|| CodecError::Malformed("pkg is missing 'packageName'".into()))?;
299    let version = pkg
300        .get("version")
301        .and_then(|p| p.as_str())
302        .ok_or_else(|| CodecError::Malformed("pkg is missing 'version'".into()))?;
303
304    let pkg_uri = format!(
305        "{}/{}@{}/{}",
306        publisher, package_name, version, package_name
307    );
308
309    let type_predicate = schema
310        .get("typePredicate")
311        .and_then(|p| p.as_str())
312        .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
313    let label_predicate = schema
314        .get("labelPredicate")
315        .and_then(|p| p.as_str())
316        .ok_or_else(|| CodecError::Malformed("schema is missing 'labelPredicate'".into()))?;
317    let package_type_uri = schema
318        .get("packageTypeUri")
319        .and_then(|p| p.as_str())
320        .ok_or_else(|| CodecError::Malformed("schema is missing 'packageTypeUri'".into()))?;
321
322    let mut pkg_statements: Vec<Statement> = Vec::new();
323    if let Some(label) = pkg.get("label") {
324        if !label.is_null() {
325            let label = label
326                .as_str()
327                .ok_or_else(|| CodecError::Malformed("pkg label must be a string".into()))?;
328            pkg_statements.push(Statement {
329                predicate: label_predicate.to_string(),
330                value: Value::Raw(label.to_string()),
331            });
332        }
333    }
334    pkg_statements.push(Statement {
335        predicate: type_predicate.to_string(),
336        value: Value::Reference(package_type_uri.to_string()),
337    });
338    subjects.push(Subject {
339        uri: pkg_uri,
340        statements: pkg_statements,
341    });
342
343    Ok(Package { subjects })
344}
345
346/// The canonical form (the `{subjects:[...]}` JSON) of a package from nodes.
347pub fn canonical_form(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<String, CodecError> {
348    Ok(canonical_form_pkg(&build_package(nodes, schema, pkg)?)?)
349}
350
351/// The `sha256:` content hash of a package from nodes — matches `kanonak hash`.
352pub fn content_hash(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<String, CodecError> {
353    Ok(canonical_hash_pkg(&build_package(nodes, schema, pkg)?)?)
354}
355
356/// Serialize a typed node to its normalized-JSON wire form. `$extra` entries ride
357/// as sibling fields after the modeled ones; a modeled field wins a name
358/// collision (`[JsonExtensionData]` semantics). No `$extra` key on the wire.
359pub fn serialize(node: &Node) -> Node {
360    let mut out = Map::new();
361    for (key, value) in node.iter() {
362        if key == "$extra" || value.is_null() {
363            continue;
364        }
365        out.insert(key.clone(), value.clone());
366    }
367    if let Some(extra) = node.get("$extra").and_then(|e| e.as_object()) {
368        for (key, value) in extra.iter() {
369            if !value.is_null() && !out.contains_key(key) {
370                out.insert(key.clone(), value.clone());
371            }
372        }
373    }
374    out
375}
376
377/// Parse normalized JSON into a typed node. `$`-envelope keys and fields modeled
378/// on the node's `$type` stay top-level; every other key is collected into
379/// `$extra` so a strongly-typed consumer round-trips it losslessly.
380pub fn deserialize(json_obj: &Node, schema: &Json) -> Result<Node, CodecError> {
381    let type_uri = json_obj
382        .get("$type")
383        .and_then(|t| t.as_str())
384        .ok_or_else(|| CodecError::Malformed("Cannot deserialize: missing string $type".into()))?;
385
386    let classes = schema
387        .get("classes")
388        .ok_or_else(|| CodecError::Malformed("schema is missing 'classes'".into()))?;
389    let cls = classes.get(type_uri).ok_or_else(|| {
390        CodecError::Malformed(format!(
391            "Cannot deserialize: no schema for type {}",
392            type_uri
393        ))
394    })?;
395    let props = cls
396        .get("props")
397        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", type_uri)))?;
398
399    let mut node = Map::new();
400    node.insert("$type".to_string(), Json::String(type_uri.to_string()));
401    let mut extra = Map::new();
402    for (key, value) in json_obj.iter() {
403        if key == "$type" {
404            continue;
405        }
406        if key.starts_with('$') || props.get(key).is_some() {
407            node.insert(key.clone(), value.clone());
408        } else {
409            extra.insert(key.clone(), value.clone());
410        }
411    }
412    if !extra.is_empty() {
413        node.insert("$extra".to_string(), Json::Object(extra));
414    }
415    Ok(node)
416}