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 /// An embedded value's authored dict-key — HASH-RELEVANT (serialized into
30 /// the canonical form). Only meaningful when the instance is used as an
31 /// embedded value; `None` for subjects.
32 #[serde(rename = "$name", skip_serializing_if = "Option::is_none", default)]
33 pub name: Option<String>,
34
35 /// Package provenance on read; ignored if echoed back on write.
36 #[serde(
37 rename = "$contentHash",
38 skip_serializing_if = "Option::is_none",
39 default
40 )]
41 pub content_hash: Option<String>,
42
43 /// Package provenance on read; ignored if echoed back on write.
44 #[serde(rename = "$version", skip_serializing_if = "Option::is_none", default)]
45 pub version: Option<String>,
46
47 /// Open-world assertions outside the type-model, keyed by predicate URI.
48 /// Rides the wire as sibling fields (flatten semantics).
49 #[serde(flatten)]
50 pub extra: Map<String, Json>,
51}
52
53/// Implemented by generated typed structs (over their flattened
54/// [`KanonakNode`]) so the runtime can read/write an instance's envelope —
55/// what lets [`Ref::to_resource`] resolve identity and
56/// [`Ref::embed_named`] carry the authored dict-key.
57pub trait KanonakResource {
58 fn kanonak_node(&self) -> &KanonakNode;
59 fn kanonak_node_mut(&mut self) -> &mut KanonakNode;
60}
61
62/// An object property's value: EXACTLY ONE of a reference to a named resource
63/// (its canonical URI) or an embedded node (the value itself, carried inline —
64/// derived identity, no `$id`). The typed twin of the wire form's
65/// `{"$ref": uri}` vs embedded-node distinction; the choice between the arms
66/// is authorial and hash-relevant, so it is explicit here, never inferred.
67#[derive(Debug, Clone, PartialEq)]
68pub enum Ref<T> {
69 /// A reference to a named resource by its canonical URI.
70 Reference(String),
71 /// An embedded value, carried inline.
72 Embedded(T),
73}
74
75impl<T> Ref<T> {
76 /// A reference to a named resource by its canonical URI.
77 pub fn to(uri: impl Into<String>) -> Self {
78 Ref::Reference(uri.into())
79 }
80
81 /// A reference to a named resource by the instance itself — resolved
82 /// through the target's envelope `id`. The target must already carry its
83 /// identity; an embedded (id-less) value cannot be referenced.
84 pub fn to_resource(target: &impl KanonakResource) -> Result<Self, CodecError> {
85 match target.kanonak_node().id.as_deref() {
86 Some(id) if !id.is_empty() => Ok(Ref::Reference(id.to_string())),
87 _ => Err(CodecError::Malformed(
88 "Ref::to_resource requires a resource with a non-empty envelope id — \
89 to carry the value inline instead, use Ref::embed."
90 .into(),
91 )),
92 }
93 }
94
95 /// An embedded value, carried inline (derived identity, no `$id`).
96 pub fn embed(value: T) -> Self {
97 Ref::Embedded(value)
98 }
99
100 /// An embedded value with its authored dict-key name (hash-relevant).
101 pub fn embed_named(mut value: T, name: impl Into<String>) -> Self
102 where
103 T: KanonakResource,
104 {
105 value.kanonak_node_mut().name = Some(name.into());
106 Ref::Embedded(value)
107 }
108
109 /// True when this is the reference arm.
110 pub fn is_reference(&self) -> bool {
111 matches!(self, Ref::Reference(_))
112 }
113}
114
115impl<T: Serialize> Serialize for Ref<T> {
116 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
117 match self {
118 Ref::Reference(uri) => {
119 let mut map = serializer.serialize_map(Some(1))?;
120 map.serialize_entry("$ref", uri)?;
121 map.end()
122 }
123 Ref::Embedded(value) => value.serialize(serializer),
124 }
125 }
126}
127
128impl<'de, T: DeserializeOwned> Deserialize<'de> for Ref<T> {
129 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
130 let value = Json::deserialize(deserializer)?;
131 if let Some(uri) = value.get("$ref").and_then(|r| r.as_str()) {
132 return Ok(Ref::Reference(uri.to_string()));
133 }
134 serde_json::from_value(value)
135 .map(Ref::Embedded)
136 .map_err(serde::de::Error::custom)
137 }
138}
139
140/// A typed instance's codec node (the dictionary contract). The bridge is
141/// native serde: the instance serializes to its normalized-JSON wire form
142/// (envelope-as-data + [`Ref<T>`] values), and the wire form maps onto the
143/// node contract through the SAME split [`deserialize`] defines — one
144/// contract, not two.
145pub fn to_node<T: Serialize>(typed: &T, schema: &Json) -> Result<Node, CodecError> {
146 let wire = serde_json::to_value(typed)
147 .map_err(|e| CodecError::Malformed(format!("typed value failed to serialize: {}", e)))?;
148 match wire.as_object() {
149 Some(map) => deserialize(map, schema),
150 None => Err(CodecError::Malformed(
151 "a typed instance must serialize to a JSON object (the wire node form)".into(),
152 )),
153 }
154}