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.
27/// `$types` (0.4.0, runtime#10) carries a multi-typed node's FULL type set.
28const ENVELOPE_KEYS: [&str; 7] = [
29    "$type",
30    "$types",
31    "$id",
32    "$name",
33    "$contentHash",
34    "$version",
35    "$extra",
36];
37
38/// A node is a JSON object.
39pub type Node = Map<String, Json>;
40
41/// Errors raised by the codec runtime. Fails loudly — no fallbacks.
42#[derive(Debug)]
43pub enum CodecError {
44    /// A node, schema, or package context was malformed.
45    Malformed(String),
46    /// The underlying canonical library rejected a lexical/value.
47    Canon(CanonError),
48}
49
50impl std::fmt::Display for CodecError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            CodecError::Malformed(m) => write!(f, "{}", m),
54            CodecError::Canon(e) => write!(f, "{}", e.0),
55        }
56    }
57}
58
59impl std::error::Error for CodecError {}
60
61impl From<CanonError> for CodecError {
62    fn from(e: CanonError) -> Self {
63        CodecError::Canon(e)
64    }
65}
66
67fn err<T>(msg: impl Into<String>) -> Result<T, CodecError> {
68    Err(CodecError::Malformed(msg.into()))
69}
70
71/// The raw lexical token of a scalar — the input the canonical form normalizes.
72/// bool -> "true"/"false"; string -> the string; number -> its plain decimal
73/// string (serde_json's `Number::to_string()` gives "5" / "1.5", never
74/// scientific notation). The canonical crate re-normalizes from there.
75fn lexical(value: &Json) -> String {
76    match value {
77        Json::Bool(b) => {
78            if *b {
79                "true".to_string()
80            } else {
81                "false".to_string()
82            }
83        }
84        Json::String(s) => s.clone(),
85        Json::Number(n) => n.to_string(),
86        other => other.to_string(),
87    }
88}
89
90/// Validate a node-or-embedded's `$types` envelope (0.4.0, runtime#10) and
91/// return the validated set, or `None` when the node is single-typed.
92/// Invariants: sorted by UTF-8 bytes, at least two members, no duplicates, and
93/// `$type` (the dispatch key, chosen by the schema layer's primary rule) is a
94/// member. Enforced wherever the envelope is touched — serialize, deserialize,
95/// and canonicalization — so a producer fails at emit time and a reader never
96/// masks a nondeterministic emitter by silently repairing the set.
97fn validated_types(
98    map: &Map<String, Json>,
99    where_: &str,
100) -> Result<Option<Vec<String>>, CodecError> {
101    let raw = match map.get("$types") {
102        None | Some(Json::Null) => return Ok(None),
103        Some(v) => v,
104    };
105    let items = match raw.as_array() {
106        Some(items) => items,
107        None => {
108            return err(format!(
109                "{}: $types must be a list of non-empty type URIs",
110                where_
111            ))
112        }
113    };
114    let mut types: Vec<String> = Vec::with_capacity(items.len());
115    for item in items {
116        match item.as_str() {
117            Some(s) if !s.is_empty() => types.push(s.to_string()),
118            _ => {
119                return err(format!(
120                    "{}: $types must be a list of non-empty type URIs",
121                    where_
122                ))
123            }
124        }
125    }
126    if types.len() < 2 {
127        return err(format!(
128            "{}: $types with {} member(s) is forbidden — a single-typed node carries \
129             only $type (a second encoding of the same content would be hash-ambiguous)",
130            where_,
131            types.len()
132        ));
133    }
134    for pair in types.windows(2) {
135        match pair[0].as_bytes().cmp(pair[1].as_bytes()) {
136            std::cmp::Ordering::Equal => {
137                return err(format!(
138                    "{}: $types carries duplicate member {}",
139                    where_, pair[1]
140                ))
141            }
142            std::cmp::Ordering::Greater => {
143                return err(format!(
144                    "{}: $types is not sorted by UTF-8 bytes ({} sorts after {}) — \
145                     ordering is the producer's job, never the reader's",
146                    where_, pair[0], pair[1]
147                ))
148            }
149            std::cmp::Ordering::Less => {}
150        }
151    }
152    match map.get("$type").and_then(|t| t.as_str()) {
153        Some(primary) if types.iter().any(|t| t == primary) => Ok(Some(types)),
154        other => err(format!(
155            "{}: $type ({:?}) must be present and a member of $types",
156            where_, other
157        )),
158    }
159}
160
161/// Recursively validate every `$types` envelope in a wire value (the node
162/// itself and any embedded node at any depth). Shared by [`serialize`] (the
163/// producer fails at emit time) and [`deserialize`] (the strict reader rejects
164/// rather than repairs).
165fn assert_types_envelopes(value: &Json, where_: &str) -> Result<(), CodecError> {
166    match value {
167        Json::Array(items) => {
168            for (i, item) in items.iter().enumerate() {
169                assert_types_envelopes(item, &format!("{}[{}]", where_, i))?;
170            }
171            Ok(())
172        }
173        Json::Object(map) => assert_types_envelopes_map(map, where_),
174        _ => Ok(()),
175    }
176}
177
178fn assert_types_envelopes_map(map: &Map<String, Json>, where_: &str) -> Result<(), CodecError> {
179    if map.contains_key("$types") {
180        validated_types(map, where_)?;
181    }
182    for (key, value) in map.iter() {
183        if key != "$types" {
184            assert_types_envelopes(value, &format!("{}.{}", where_, key))?;
185        }
186    }
187    Ok(())
188}
189
190/// Build a single canonical `Value` for one (non-list) field datum, per its
191/// schema prop.
192fn build_value(prop: &Json, raw: &Json, schema: &Json) -> Result<Value, CodecError> {
193    let kind = prop
194        .get("kind")
195        .and_then(|k| k.as_str())
196        .ok_or_else(|| CodecError::Malformed("schema prop is missing 'kind'".into()))?;
197    if kind == "object" {
198        // A node: a reference (`{"$ref"}`) or an embedded resource.
199        if let Some(reference) = raw.get("$ref").and_then(|r| r.as_str()) {
200            return Ok(Value::Reference(reference.to_string()));
201        }
202        if let Some(map) = raw.as_object() {
203            return embedded_value(prop, map, schema);
204        }
205        return err(format!(
206            "Object property expects a reference ({{\"$ref\": ...}}) or an embedded \
207             node (a map), got {}",
208            raw
209        ));
210    }
211    let datatype = prop
212        .get("datatype")
213        .and_then(|d| d.as_str())
214        .ok_or_else(|| CodecError::Malformed("datatype prop is missing 'datatype'".into()))?;
215    match carrier_of(datatype) {
216        None => Ok(Value::Raw(lexical(raw))),
217        Some(carrier) => Ok(Value::Typed {
218            carrier,
219            lexical: lexical(raw),
220        }),
221    }
222}
223
224/// Canonicalize an embedded value (0.2.0): a map with no `$id`, an optional
225/// `$name` (the authored dict-key — hash-relevant), an optional `$type`, and
226/// schema-mapped fields. An explicit `$type` emits a type statement inside the
227/// embedded (hash-relevant even when it equals the range-derived type); without
228/// it, fields map via the containing property's `range` and NO type statement is
229/// emitted — range-derived typing is inference only.
230fn embedded_value(
231    prop: &Json,
232    map: &Map<String, Json>,
233    schema: &Json,
234) -> Result<Value, CodecError> {
235    if map.contains_key("$id") {
236        return err(
237            "An embedded value must not carry $id — to point at a named resource, \
238             pass a reference ({\"$ref\": ...}).",
239        );
240    }
241    let types = validated_types(map, "Embedded value")?;
242    let explicit_type = map.get("$type").and_then(|t| t.as_str());
243    let cls_uri = match explicit_type.or_else(|| prop.get("range").and_then(|r| r.as_str())) {
244        Some(uri) => uri,
245        None => {
246            return err(
247                "Cannot map embedded value: it carries no $type and the property \
248                 declares no range.",
249            )
250        }
251    };
252    let cls = schema
253        .get("classes")
254        .and_then(|c| c.get(cls_uri))
255        .ok_or_else(|| CodecError::Malformed(format!("no schema for embedded type {}", cls_uri)))?;
256    let props = cls
257        .get("props")
258        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", cls_uri)))?;
259
260    let mut statements = field_statements(map, props, schema)?;
261    if let Some(members) = types {
262        // A multi-typed embedded ($types implies an explicit $type): one type
263        // statement per member, in $types (UTF-8 sorted) order — all hash-relevant.
264        let type_predicate = schema
265            .get("typePredicate")
266            .and_then(|p| p.as_str())
267            .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
268        for member in members {
269            statements.push(Statement {
270                predicate: type_predicate.to_string(),
271                value: Value::Reference(member),
272            });
273        }
274    } else if let Some(type_uri) = explicit_type {
275        let type_predicate = schema
276            .get("typePredicate")
277            .and_then(|p| p.as_str())
278            .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
279        statements.push(Statement {
280            predicate: type_predicate.to_string(),
281            value: Value::Reference(type_uri.to_string()),
282        });
283    }
284    let name = map
285        .get("$name")
286        .and_then(|n| n.as_str())
287        .filter(|s| !s.is_empty())
288        .map(|s| s.to_string());
289    Ok(Value::Embedded { name, statements })
290}
291
292/// The statements for one node-or-embedded's modeled fields + its `$extra` —
293/// everything except the type triple (subjects always carry one; embeddeds only
294/// when explicitly typed).
295fn field_statements(
296    source: &Map<String, Json>,
297    props: &Json,
298    schema: &Json,
299) -> Result<Vec<Statement>, CodecError> {
300    let mut out: Vec<Statement> = Vec::new();
301
302    for (key, raw) in source.iter() {
303        if ENVELOPE_KEYS.contains(&key.as_str()) || raw.is_null() {
304            continue;
305        }
306        match props.get(key) {
307            None => out.push(Statement {
308                predicate: key.clone(),
309                value: Value::Raw(lexical(raw)),
310            }),
311            Some(prop) => {
312                let predicate =
313                    prop.get("predicate")
314                        .and_then(|p| p.as_str())
315                        .ok_or_else(|| {
316                            CodecError::Malformed(format!("prop {} is missing 'predicate'", key))
317                        })?;
318                let value = match raw.as_array() {
319                    Some(items) => {
320                        // An empty list contributes NO statement — absent and empty
321                        // are identical at the canonical layer (the wire serialize
322                        // still preserves the empty list).
323                        if items.is_empty() {
324                            continue;
325                        }
326                        let mut list = Vec::with_capacity(items.len());
327                        for item in items {
328                            list.push(build_value(prop, item, schema)?);
329                        }
330                        Value::List(list)
331                    }
332                    None => build_value(prop, raw, schema)?,
333                };
334                out.push(Statement {
335                    predicate: predicate.to_string(),
336                    value,
337                });
338            }
339        }
340    }
341
342    if let Some(extra) = source.get("$extra") {
343        let extra = extra
344            .as_object()
345            .ok_or_else(|| CodecError::Malformed("$extra must be an object".into()))?;
346        for (predicate, raw) in extra.iter() {
347            if raw.is_null() {
348                continue;
349            }
350            out.push(Statement {
351                predicate: predicate.clone(),
352                value: Value::Raw(lexical(raw)),
353            });
354        }
355    }
356    Ok(out)
357}
358
359/// The statements for one subject node: the rdf:type triple(s), then its fields.
360fn statements(node: &Node, schema: &Json) -> Result<Vec<Statement>, CodecError> {
361    let types = {
362        let id = node
363            .get("$id")
364            .and_then(|i| i.as_str())
365            .unwrap_or("(no $id)");
366        validated_types(node, &format!("Node {}", id))?
367    };
368    let type_uri = node
369        .get("$type")
370        .and_then(|t| t.as_str())
371        .filter(|s| !s.is_empty())
372        .ok_or_else(|| CodecError::Malformed("node is missing $type".into()))?;
373
374    let classes = schema
375        .get("classes")
376        .ok_or_else(|| CodecError::Malformed("schema is missing 'classes'".into()))?;
377    let cls = classes
378        .get(type_uri)
379        .ok_or_else(|| CodecError::Malformed(format!("no schema for type {}", type_uri)))?;
380    let props = cls
381        .get("props")
382        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", type_uri)))?;
383
384    let type_predicate = schema
385        .get("typePredicate")
386        .and_then(|p| p.as_str())
387        .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
388
389    // The rdf:type triple(s) every subject carries: one per $types member for a
390    // multi-typed node (in $types' UTF-8 sorted order), else the single $type.
391    let members = types.unwrap_or_else(|| vec![type_uri.to_string()]);
392    let mut out: Vec<Statement> = members
393        .into_iter()
394        .map(|member| Statement {
395            predicate: type_predicate.to_string(),
396            value: Value::Reference(member),
397        })
398        .collect();
399    out.extend(field_statements(node, props, schema)?);
400    Ok(out)
401}
402
403/// Build the canonical input model: a subject per node + the synthesized
404/// package-wrapper subject (raw label + `Package` type), exactly the subject set
405/// `kanonak hash` produces for the equivalent authored package.
406pub fn build_package(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<Package, CodecError> {
407    let mut subjects: Vec<Subject> = Vec::with_capacity(nodes.len() + 1);
408    for node in nodes {
409        let id = node
410            .get("$id")
411            .and_then(|i| i.as_str())
412            .filter(|s| !s.is_empty())
413            .ok_or_else(|| CodecError::Malformed("node is missing $id".into()))?;
414        subjects.push(Subject {
415            uri: id.to_string(),
416            statements: statements(node, schema)?,
417        });
418    }
419
420    let publisher = pkg
421        .get("publisher")
422        .and_then(|p| p.as_str())
423        .ok_or_else(|| CodecError::Malformed("pkg is missing 'publisher'".into()))?;
424    let package_name = pkg
425        .get("packageName")
426        .and_then(|p| p.as_str())
427        .ok_or_else(|| CodecError::Malformed("pkg is missing 'packageName'".into()))?;
428    let version = pkg
429        .get("version")
430        .and_then(|p| p.as_str())
431        .ok_or_else(|| CodecError::Malformed("pkg is missing 'version'".into()))?;
432
433    let pkg_uri = format!(
434        "{}/{}@{}/{}",
435        publisher, package_name, version, package_name
436    );
437
438    let type_predicate = schema
439        .get("typePredicate")
440        .and_then(|p| p.as_str())
441        .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
442    let label_predicate = schema
443        .get("labelPredicate")
444        .and_then(|p| p.as_str())
445        .ok_or_else(|| CodecError::Malformed("schema is missing 'labelPredicate'".into()))?;
446    let package_type_uri = schema
447        .get("packageTypeUri")
448        .and_then(|p| p.as_str())
449        .ok_or_else(|| CodecError::Malformed("schema is missing 'packageTypeUri'".into()))?;
450
451    let mut pkg_statements: Vec<Statement> = Vec::new();
452    if let Some(label) = pkg.get("label") {
453        if !label.is_null() {
454            let label = label
455                .as_str()
456                .ok_or_else(|| CodecError::Malformed("pkg label must be a string".into()))?;
457            pkg_statements.push(Statement {
458                predicate: label_predicate.to_string(),
459                value: Value::Raw(label.to_string()),
460            });
461        }
462    }
463    pkg_statements.push(Statement {
464        predicate: type_predicate.to_string(),
465        value: Value::Reference(package_type_uri.to_string()),
466    });
467    subjects.push(Subject {
468        uri: pkg_uri,
469        statements: pkg_statements,
470    });
471
472    Ok(Package { subjects })
473}
474
475/// The canonical form (the `{subjects:[...]}` JSON) of a package from nodes.
476pub fn canonical_form(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<String, CodecError> {
477    Ok(canonical_form_pkg(&build_package(nodes, schema, pkg)?)?)
478}
479
480/// The `sha256:` content hash of a package from nodes — matches `kanonak hash`.
481pub fn content_hash(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<String, CodecError> {
482    Ok(canonical_hash_pkg(&build_package(nodes, schema, pkg)?)?)
483}
484
485/// Serialize a typed node to its normalized-JSON wire form. `$extra` entries ride
486/// as sibling fields after the modeled ones; a modeled field wins a name
487/// collision (`[JsonExtensionData]` semantics). No `$extra` key on the wire.
488/// Fallible since 0.4.0: an invalid `$types` envelope (at any depth) is a
489/// producer bug and fails at emit time.
490pub fn serialize(node: &Node) -> Result<Node, CodecError> {
491    let where_ = node
492        .get("$id")
493        .or_else(|| node.get("$type"))
494        .and_then(|v| v.as_str())
495        .unwrap_or("(node)");
496    assert_types_envelopes_map(node, &format!("serialize {}", where_))?;
497    let mut out = Map::new();
498    for (key, value) in node.iter() {
499        if key == "$extra" || value.is_null() {
500            continue;
501        }
502        out.insert(key.clone(), value.clone());
503    }
504    if let Some(extra) = node.get("$extra").and_then(|e| e.as_object()) {
505        for (key, value) in extra.iter() {
506            if !value.is_null() && !out.contains_key(key) {
507                out.insert(key.clone(), value.clone());
508            }
509        }
510    }
511    Ok(out)
512}
513
514/// Parse normalized JSON into a typed node. `$`-envelope keys and fields modeled
515/// on the node's `$type` stay top-level; every other key is collected into
516/// `$extra` so a strongly-typed consumer round-trips it losslessly.
517pub fn deserialize(json_obj: &Node, schema: &Json) -> Result<Node, CodecError> {
518    let type_uri = json_obj
519        .get("$type")
520        .and_then(|t| t.as_str())
521        .ok_or_else(|| CodecError::Malformed("Cannot deserialize: missing string $type".into()))?;
522
523    // Reader-side $types validation, at every depth: an unsorted / singleton /
524    // duplicate / non-member set is REJECTED, never silently repaired —
525    // determinism belongs to the producer, and a lenient reader would mask a
526    // nondeterministic emitter.
527    {
528        let where_ = json_obj
529            .get("$id")
530            .and_then(|i| i.as_str())
531            .unwrap_or(type_uri);
532        assert_types_envelopes_map(json_obj, &format!("deserialize {}", where_))?;
533    }
534
535    let classes = schema
536        .get("classes")
537        .ok_or_else(|| CodecError::Malformed("schema is missing 'classes'".into()))?;
538    let cls = classes.get(type_uri).ok_or_else(|| {
539        CodecError::Malformed(format!(
540            "Cannot deserialize: no schema for type {}",
541            type_uri
542        ))
543    })?;
544    let props = cls
545        .get("props")
546        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", type_uri)))?;
547
548    let mut node = Map::new();
549    node.insert("$type".to_string(), Json::String(type_uri.to_string()));
550    let mut extra = Map::new();
551    for (key, value) in json_obj.iter() {
552        if key == "$type" {
553            continue;
554        }
555        if key.starts_with('$') || props.get(key).is_some() {
556            node.insert(key.clone(), value.clone());
557        } else {
558            extra.insert(key.clone(), value.clone());
559        }
560    }
561    if !extra.is_empty() {
562        node.insert("$extra".to_string(), Json::Object(extra));
563    }
564    Ok(node)
565}