Skip to main content

kanonak_codec/
typed.rs

1//! The typed SDK-facing surface (0.3.0): the `$`-envelope as data, the explicit
2//! reference-or-embedded union, and the serde bridge from a typed model to the
3//! node contract. A generated struct composes [`KanonakNode`] via
4//! `#[serde(flatten)]`, types its object properties as [`Ref<T>`], and binds
5//! through [`to_node`] — native serde, no reflection, one contract with the
6//! dictionary path.
7
8use crate::{deserialize, CodecError, Node};
9use serde::de::DeserializeOwned;
10use serde::ser::SerializeMap;
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value as Json};
13
14/// The `$`-envelope as data — composed into a generated struct via
15/// `#[serde(flatten)]`, so an instance carries its own identity and serializes
16/// straight to the normalized-JSON wire form. `extra` holds open-world
17/// assertions outside the type-model, keyed by predicate URI; being the
18/// innermost flatten map, it also collects unknown wire fields on deserialize.
19#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
20pub struct KanonakNode {
21    /// The resource's canonical URI. Required to form a subject.
22    #[serde(rename = "$id", skip_serializing_if = "Option::is_none", default)]
23    pub id: Option<String>,
24
25    /// The durable class URI — the value of the synthesized type triple.
26    #[serde(rename = "$type", skip_serializing_if = "Option::is_none", default)]
27    pub type_uri: Option<String>,
28
29    /// A multi-typed node's FULL type set (0.4.0, runtime#10) — present only
30    /// when the node carries more than one type statement. Sorted by UTF-8
31    /// bytes, at least two members, no duplicates, `$type` a member; each
32    /// member emits one type statement in canonical form. Exposed ONLY as the
33    /// `$types` envelope — deliberately no unprefixed `types` accessor, because
34    /// an ontology can model a property literally named `types`; the `$` prefix
35    /// exists to avoid exactly that collision.
36    #[serde(rename = "$types", skip_serializing_if = "Option::is_none", default)]
37    pub types: Option<Vec<String>>,
38
39    /// An embedded value's authored dict-key — HASH-RELEVANT (serialized into
40    /// the canonical form). Only meaningful when the instance is used as an
41    /// embedded value; `None` for subjects.
42    #[serde(rename = "$name", skip_serializing_if = "Option::is_none", default)]
43    pub name: Option<String>,
44
45    /// Package provenance on read; ignored if echoed back on write.
46    #[serde(
47        rename = "$contentHash",
48        skip_serializing_if = "Option::is_none",
49        default
50    )]
51    pub content_hash: Option<String>,
52
53    /// Package provenance on read; ignored if echoed back on write.
54    #[serde(rename = "$version", skip_serializing_if = "Option::is_none", default)]
55    pub version: Option<String>,
56
57    /// Open-world assertions outside the type-model, keyed by predicate URI.
58    /// Rides the wire as sibling fields (flatten semantics).
59    #[serde(flatten)]
60    pub extra: Map<String, Json>,
61}
62
63impl KanonakNode {
64    /// Whether this instance is of the class `class_uri` — [`crate::type_matches`]
65    /// over the envelope's `$types`, else its `$type` (runtime#28). What a
66    /// generated type guard calls through [`KanonakResource::kanonak_node`].
67    pub fn type_matches(&self, class_uri: &str, schema: &Json) -> Result<bool, CodecError> {
68        let members: Vec<&str> = match &self.types {
69            Some(types) => types.iter().map(String::as_str).collect(),
70            None => self
71                .type_uri
72                .as_deref()
73                .filter(|t| !t.is_empty())
74                .into_iter()
75                .collect(),
76        };
77        crate::type_matches_members(&members, class_uri, schema)
78    }
79}
80
81/// Implemented by generated typed structs (over their flattened
82/// [`KanonakNode`]) so the runtime can read/write an instance's envelope —
83/// what lets [`Ref::to_resource`] resolve identity and
84/// [`Ref::embed_named`] carry the authored dict-key.
85pub trait KanonakResource {
86    fn kanonak_node(&self) -> &KanonakNode;
87    fn kanonak_node_mut(&mut self) -> &mut KanonakNode;
88}
89
90/// An object property's value: EXACTLY ONE of a reference to a named resource
91/// (its canonical URI) or an embedded node (the value itself, carried inline —
92/// derived identity, no `$id`). The typed twin of the wire form's
93/// `{"$ref": uri}` vs embedded-node distinction; the choice between the arms
94/// is authorial and hash-relevant, so it is explicit here, never inferred.
95///
96/// The embedded payload is BOXED (0.6.0, runtime#27). A single-valued
97/// property ranging over its own class — a parent/child hierarchy, the most
98/// ordinary shape an ontology declares — makes `struct Objective { supports:
99/// Option<Ref<Objective>> }`, which has infinite size if the payload is
100/// inline (E0072) and so cannot be generated. The wire form never had that
101/// limit; `Box` is the indirection Rust needs to match it. Construct with
102/// [`Ref::embed`] / [`Ref::embed_named`] and read with [`Ref::value`] /
103/// [`Ref::uri`] and the box is invisible; only a direct `Ref::Embedded(x)`
104/// pattern sees it (`Ref::Embedded(b) => &**b`).
105#[derive(Debug, Clone, PartialEq)]
106pub enum Ref<T> {
107    /// A reference to a named resource by its canonical URI.
108    Reference(String),
109    /// An embedded value, carried inline (boxed — see the type's docs).
110    Embedded(Box<T>),
111}
112
113impl<T> Ref<T> {
114    /// A reference to a named resource by its canonical URI.
115    pub fn to(uri: impl Into<String>) -> Self {
116        Ref::Reference(uri.into())
117    }
118
119    /// A reference to a named resource by the instance itself — resolved
120    /// through the target's envelope `id`. The target must already carry its
121    /// identity; an embedded (id-less) value cannot be referenced.
122    pub fn to_resource(target: &impl KanonakResource) -> Result<Self, CodecError> {
123        match target.kanonak_node().id.as_deref() {
124            Some(id) if !id.is_empty() => Ok(Ref::Reference(id.to_string())),
125            _ => Err(CodecError::Malformed(
126                "Ref::to_resource requires a resource with a non-empty envelope id — \
127                 to carry the value inline instead, use Ref::embed."
128                    .into(),
129            )),
130        }
131    }
132
133    /// An embedded value, carried inline (derived identity, no `$id`).
134    pub fn embed(value: T) -> Self {
135        Ref::Embedded(Box::new(value))
136    }
137
138    /// An embedded value with its authored dict-key name (hash-relevant).
139    pub fn embed_named(mut value: T, name: impl Into<String>) -> Self
140    where
141        T: KanonakResource,
142    {
143        value.kanonak_node_mut().name = Some(name.into());
144        Ref::Embedded(Box::new(value))
145    }
146
147    /// True when this is the reference arm.
148    pub fn is_reference(&self) -> bool {
149        matches!(self, Ref::Reference(_))
150    }
151
152    /// The referenced resource's canonical URI — the reference arm (else `None`).
153    pub fn uri(&self) -> Option<&str> {
154        match self {
155            Ref::Reference(uri) => Some(uri),
156            Ref::Embedded(_) => None,
157        }
158    }
159
160    /// The embedded value — the embedded arm (else `None`).
161    pub fn value(&self) -> Option<&T> {
162        match self {
163            Ref::Embedded(value) => Some(value),
164            Ref::Reference(_) => None,
165        }
166    }
167
168    /// The embedded value, mutably — the embedded arm (else `None`).
169    pub fn value_mut(&mut self) -> Option<&mut T> {
170        match self {
171            Ref::Embedded(value) => Some(value),
172            Ref::Reference(_) => None,
173        }
174    }
175
176    /// Consume the embedded arm, returning its value (else `None`).
177    pub fn into_value(self) -> Option<T> {
178        match self {
179            Ref::Embedded(value) => Some(*value),
180            Ref::Reference(_) => None,
181        }
182    }
183}
184
185impl<T: Serialize> Serialize for Ref<T> {
186    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
187        match self {
188            Ref::Reference(uri) => {
189                let mut map = serializer.serialize_map(Some(1))?;
190                map.serialize_entry("$ref", uri)?;
191                map.end()
192            }
193            Ref::Embedded(value) => value.serialize(serializer),
194        }
195    }
196}
197
198impl<'de, T: DeserializeOwned> Deserialize<'de> for Ref<T> {
199    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
200        let value = Json::deserialize(deserializer)?;
201        if let Some(uri) = value.get("$ref").and_then(|r| r.as_str()) {
202            return Ok(Ref::Reference(uri.to_string()));
203        }
204        serde_json::from_value(value)
205            .map(|v| Ref::Embedded(Box::new(v)))
206            .map_err(serde::de::Error::custom)
207    }
208}
209
210/// A typed instance's codec node (the dictionary contract). The bridge is
211/// native serde: the instance serializes to its normalized-JSON wire form
212/// (envelope-as-data + [`Ref<T>`] values), and the wire form maps onto the
213/// node contract through the SAME split [`deserialize`] defines — one
214/// contract, not two.
215pub fn to_node<T: Serialize>(typed: &T, schema: &Json) -> Result<Node, CodecError> {
216    let wire = serde_json::to_value(typed)
217        .map_err(|e| CodecError::Malformed(format!("typed value failed to serialize: {}", e)))?;
218    match wire.as_object() {
219        Some(map) => deserialize(map, schema),
220        None => Err(CodecError::Malformed(
221            "a typed instance must serialize to a JSON object (the wire node form)".into(),
222        )),
223    }
224}