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