1use kanonak_canonical::{
17 canonical_form as canonical_form_pkg, canonical_hash as canonical_hash_pkg, carrier_of,
18 is_readable_by, parse_coordinate, versionless_key, CanonError, CoordinateVersion, Package,
19 Statement, Subject, Value,
20};
21use serde_json::{Map, Value as Json};
22
23mod typed;
24pub use typed::{to_node, KanonakNode, KanonakResource, Ref};
25
26const ENVELOPE_KEYS: [&str; 7] = [
30 "$type",
31 "$types",
32 "$id",
33 "$name",
34 "$contentHash",
35 "$version",
36 "$extra",
37];
38
39pub type Node = Map<String, Json>;
41
42#[derive(Debug)]
48#[non_exhaustive]
49pub enum CodecError {
50 Malformed(String),
52 Canon(CanonError),
54}
55
56impl std::fmt::Display for CodecError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 CodecError::Malformed(m) => write!(f, "{}", m),
60 CodecError::Canon(e) => write!(f, "{}", e.0),
61 }
62 }
63}
64
65impl std::error::Error for CodecError {}
66
67impl From<CanonError> for CodecError {
68 fn from(e: CanonError) -> Self {
69 CodecError::Canon(e)
70 }
71}
72
73fn err<T>(msg: impl Into<String>) -> Result<T, CodecError> {
74 Err(CodecError::Malformed(msg.into()))
75}
76
77fn lexical(value: &Json) -> String {
82 match value {
83 Json::Bool(b) => {
84 if *b {
85 "true".to_string()
86 } else {
87 "false".to_string()
88 }
89 }
90 Json::String(s) => s.clone(),
91 Json::Number(n) => n.to_string(),
92 other => other.to_string(),
93 }
94}
95
96fn classes_of(schema: &Json) -> Result<&Map<String, Json>, CodecError> {
103 schema
104 .get("classes")
105 .and_then(|c| c.as_object())
106 .ok_or_else(|| CodecError::Malformed("schema is missing 'classes'".into()))
107}
108
109fn versioned_coordinate(uri: &str) -> Option<(String, CoordinateVersion)> {
112 let c = parse_coordinate(uri).ok()?;
113 let version = c.version?;
114 Some((format!("{}/{}/{}", c.publisher, c.package, c.name), version))
115}
116
117fn identity_of(uri: &str) -> Option<String> {
120 versionless_key(uri).ok()
121}
122
123fn version_order(v: CoordinateVersion) -> (u64, u64, u64) {
124 (v.major, v.minor, v.patch)
125}
126
127fn format_version(v: CoordinateVersion) -> String {
128 format!("{}.{}.{}", v.major, v.minor, v.patch)
129}
130
131fn class_for<'a>(
143 classes: &'a Map<String, Json>,
144 type_uri: &str,
145) -> Result<(&'a str, &'a Json), String> {
146 if let Some((key, cls)) = classes.get_key_value(type_uri) {
147 return Ok((key.as_str(), cls));
148 }
149 let unknown = || format!("no schema for type {} [unknown-type]", type_uri);
150 let (key, written) = match versioned_coordinate(type_uri) {
151 Some(found) => found,
152 None => return Err(unknown()),
153 };
154
155 let mut readable: Option<(&'a str, &'a Json, CoordinateVersion)> = None;
156 let mut nearest: Option<CoordinateVersion> = None;
157 for (uri, cls) in classes.iter() {
158 let version = match versioned_coordinate(uri) {
159 Some((k, v)) if k == key => v,
160 _ => continue,
161 };
162 if is_readable_by(written, version) {
163 if readable.map_or(true, |(_, _, best)| {
164 version_order(version) > version_order(best)
165 }) {
166 readable = Some((uri.as_str(), cls, version));
167 }
168 } else if nearest.map_or(true, |n| version_order(version) > version_order(n)) {
169 nearest = Some(version);
170 }
171 }
172 if let Some((uri, cls, _)) = readable {
173 return Ok((uri, cls));
174 }
175 match nearest {
176 Some(r) => Err(incompatible_version(type_uri, written, r)),
177 None => Err(unknown()),
178 }
179}
180
181fn incompatible_version(type_uri: &str, w: CoordinateVersion, r: CoordinateVersion) -> String {
182 let (ws, rs) = (format_version(w), format_version(r));
183 if w.major != r.major {
184 return format!(
185 "{} is written at {}, a different major version than this codec's schema ({}) \
186 [other-major]",
187 type_uri, ws, rs
188 );
189 }
190 if w.major == 0 && w.minor != r.minor {
191 return format!(
192 "{} is written at {}; below 1.0.0 a different minor is a different version line \
193 than this codec's schema ({}) [other-minor-line]",
194 type_uri, ws, rs
195 );
196 }
197 format!(
198 "{} is written at {}, newer than this codec's schema ({}); upgrade the codec to read it \
199 [newer-version]",
200 type_uri, ws, rs
201 )
202}
203
204fn hash_class_for<'a>(
212 schema: &'a Json,
213 type_uri: &str,
214 what: &str,
215) -> Result<&'a Json, CodecError> {
216 let classes = classes_of(schema)?;
217 if let Some(cls) = classes.get(type_uri) {
218 return Ok(cls);
219 }
220 match class_for(classes, type_uri) {
221 Ok((found, _)) => err(format!(
222 "Cannot hash {} {}: this codec's schema has it at {}, and a content hash is \
223 computed with the producer's schema version — hashing needs the exact class \
224 [hash-needs-exact-version]",
225 what, type_uri, found
226 )),
227 Err(_) => err(format!(
228 "no schema for {} {} [unknown-type]",
229 what, type_uri
230 )),
231 }
232}
233
234pub fn type_matches(node: &Node, class_uri: &str, schema: &Json) -> Result<bool, CodecError> {
245 let members: Vec<&str> = match node.get("$types") {
246 None | Some(Json::Null) => node
247 .get("$type")
248 .and_then(|t| t.as_str())
249 .filter(|t| !t.is_empty())
250 .into_iter()
251 .collect(),
252 Some(Json::Array(items)) => items
253 .iter()
254 .map(|t| t.as_str())
255 .collect::<Option<Vec<&str>>>()
256 .ok_or_else(|| {
257 CodecError::Malformed("type_matches: $types must be a list of type URIs".into())
258 })?,
259 Some(_) => return err("type_matches: $types must be a list of type URIs"),
260 };
261 type_matches_members(&members, class_uri, schema)
262}
263
264pub(crate) fn type_matches_members(
267 members: &[&str],
268 class_uri: &str,
269 schema: &Json,
270) -> Result<bool, CodecError> {
271 let target = identity_of(class_uri).ok_or_else(|| {
272 CodecError::Malformed(format!(
273 "type_matches: '{}' is not a class coordinate",
274 class_uri
275 ))
276 })?;
277 let classes = classes_of(schema)?;
278
279 for &member in members {
280 let (key, cls) = match class_for(classes, member) {
281 Ok(found) => found,
282 Err(_) => continue,
283 };
284 let type_uri = cls
285 .get("typeUri")
286 .and_then(|t| t.as_str())
287 .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'typeUri'", key)))?;
288 if identity_of(type_uri).as_deref() == Some(target.as_str()) {
289 return Ok(true);
290 }
291 match cls.get("ancestors") {
292 None | Some(Json::Null) => {}
293 Some(Json::Array(ancestors)) => {
294 for ancestor in ancestors {
295 let ancestor = ancestor.as_str().ok_or_else(|| {
296 CodecError::Malformed(format!(
297 "class {}: 'ancestors' must be a list of class URIs",
298 key
299 ))
300 })?;
301 if identity_of(ancestor).as_deref() == Some(target.as_str()) {
302 return Ok(true);
303 }
304 }
305 }
306 Some(_) => {
307 return err(format!(
308 "class {}: 'ancestors' must be a list of class URIs",
309 key
310 ))
311 }
312 }
313 }
314 Ok(false)
315}
316
317#[derive(Debug, Clone, PartialEq)]
320#[non_exhaustive]
321pub struct EnumMemberMatch {
322 pub enum_type: String,
324 pub uri: String,
326 pub member: Json,
328}
329
330pub fn enum_member(schema: &Json, reference: &str) -> Option<EnumMemberMatch> {
337 let enums = schema.get("enums").and_then(|e| e.as_object())?;
338 for (enum_type, e) in enums.iter() {
339 if let Some(member) = e.get("members").and_then(|m| m.get(reference)) {
340 return Some(EnumMemberMatch {
341 enum_type: enum_type.clone(),
342 uri: reference.to_string(),
343 member: member.clone(),
344 });
345 }
346 }
347 let (key, written) = versioned_coordinate(reference)?;
348 let mut best: Option<(&str, &str, &Json, CoordinateVersion)> = None;
349 for (enum_type, e) in enums.iter() {
350 let members = match e.get("members").and_then(|m| m.as_object()) {
351 Some(members) => members,
352 None => continue,
353 };
354 for (uri, member) in members.iter() {
355 let version = match versioned_coordinate(uri) {
356 Some((k, v)) if k == key => v,
357 _ => continue,
358 };
359 if !is_readable_by(written, version) {
360 continue;
361 }
362 if best.map_or(true, |(_, _, _, b)| {
363 version_order(version) > version_order(b)
364 }) {
365 best = Some((enum_type.as_str(), uri.as_str(), member, version));
366 }
367 }
368 }
369 best.map(|(enum_type, uri, member, _)| EnumMemberMatch {
370 enum_type: enum_type.to_string(),
371 uri: uri.to_string(),
372 member: member.clone(),
373 })
374}
375
376fn validated_types(
384 map: &Map<String, Json>,
385 where_: &str,
386) -> Result<Option<Vec<String>>, CodecError> {
387 let raw = match map.get("$types") {
388 None | Some(Json::Null) => return Ok(None),
389 Some(v) => v,
390 };
391 let items = match raw.as_array() {
392 Some(items) => items,
393 None => {
394 return err(format!(
395 "{}: $types must be a list of non-empty type URIs",
396 where_
397 ))
398 }
399 };
400 let mut types: Vec<String> = Vec::with_capacity(items.len());
401 for item in items {
402 match item.as_str() {
403 Some(s) if !s.is_empty() => types.push(s.to_string()),
404 _ => {
405 return err(format!(
406 "{}: $types must be a list of non-empty type URIs",
407 where_
408 ))
409 }
410 }
411 }
412 if types.len() < 2 {
413 return err(format!(
414 "{}: $types with {} member(s) is forbidden — a single-typed node carries \
415 only $type (a second encoding of the same content would be hash-ambiguous)",
416 where_,
417 types.len()
418 ));
419 }
420 for pair in types.windows(2) {
421 match pair[0].as_bytes().cmp(pair[1].as_bytes()) {
422 std::cmp::Ordering::Equal => {
423 return err(format!(
424 "{}: $types carries duplicate member {}",
425 where_, pair[1]
426 ))
427 }
428 std::cmp::Ordering::Greater => {
429 return err(format!(
430 "{}: $types is not sorted by UTF-8 bytes ({} sorts after {}) — \
431 ordering is the producer's job, never the reader's",
432 where_, pair[0], pair[1]
433 ))
434 }
435 std::cmp::Ordering::Less => {}
436 }
437 }
438 match map.get("$type").and_then(|t| t.as_str()) {
439 Some(primary) if types.iter().any(|t| t == primary) => Ok(Some(types)),
440 other => err(format!(
441 "{}: $type ({:?}) must be present and a member of $types",
442 where_, other
443 )),
444 }
445}
446
447fn assert_types_envelopes(value: &Json, where_: &str) -> Result<(), CodecError> {
452 match value {
453 Json::Array(items) => {
454 for (i, item) in items.iter().enumerate() {
455 assert_types_envelopes(item, &format!("{}[{}]", where_, i))?;
456 }
457 Ok(())
458 }
459 Json::Object(map) => assert_types_envelopes_map(map, where_),
460 _ => Ok(()),
461 }
462}
463
464fn assert_types_envelopes_map(map: &Map<String, Json>, where_: &str) -> Result<(), CodecError> {
465 if map.contains_key("$types") {
466 validated_types(map, where_)?;
467 }
468 for (key, value) in map.iter() {
469 if key != "$types" {
470 assert_types_envelopes(value, &format!("{}.{}", where_, key))?;
471 }
472 }
473 Ok(())
474}
475
476fn assert_reference_values(value: &Json, where_: &str) -> Result<(), CodecError> {
483 match value {
484 Json::Array(items) => {
485 for (i, item) in items.iter().enumerate() {
486 assert_reference_values(item, &format!("{}[{}]", where_, i))?;
487 }
488 Ok(())
489 }
490 Json::Object(map) => assert_reference_values_map(map, where_),
491 _ => Ok(()),
492 }
493}
494
495fn assert_reference_values_map(map: &Map<String, Json>, where_: &str) -> Result<(), CodecError> {
496 if let Some(Json::String(uri)) = map.get("$ref") {
497 assert_named_reference(uri, where_)?;
498 }
499 for (key, value) in map.iter() {
500 assert_reference_values(value, &format!("{}.{}", where_, key))?;
501 }
502 Ok(())
503}
504
505fn assert_named_reference(uri: &str, where_: &str) -> Result<(), CodecError> {
506 if uri.contains('#') {
507 return err(format!(
508 "{}: reference value '{}' contains a URI fragment. A reference addresses a named \
509 resource (publisher/package[@version]/name); fragments are a navigation convention, \
510 not part of the reference graph [fragment-reference]",
511 where_, uri
512 ));
513 }
514 Ok(())
515}
516
517fn build_value(prop: &Json, raw: &Json, schema: &Json) -> Result<Value, CodecError> {
520 let kind = prop
521 .get("kind")
522 .and_then(|k| k.as_str())
523 .ok_or_else(|| CodecError::Malformed("schema prop is missing 'kind'".into()))?;
524 if kind == "object" {
525 if let Some(reference) = raw.get("$ref").and_then(|r| r.as_str()) {
527 return Ok(Value::Reference(reference.to_string()));
528 }
529 if let Some(map) = raw.as_object() {
530 return embedded_value(prop, map, schema);
531 }
532 return err(format!(
533 "Object property expects a reference ({{\"$ref\": ...}}) or an embedded \
534 node (a map), got {}",
535 raw
536 ));
537 }
538 let datatype = prop
539 .get("datatype")
540 .and_then(|d| d.as_str())
541 .ok_or_else(|| CodecError::Malformed("datatype prop is missing 'datatype'".into()))?;
542 match carrier_of(datatype) {
543 None => Ok(Value::Raw(lexical(raw))),
544 Some(carrier) => Ok(Value::Typed {
545 carrier,
546 lexical: lexical(raw),
547 }),
548 }
549}
550
551fn embedded_value(
558 prop: &Json,
559 map: &Map<String, Json>,
560 schema: &Json,
561) -> Result<Value, CodecError> {
562 if map.contains_key("$id") {
563 return err(
564 "An embedded value must not carry $id — to point at a named resource, \
565 pass a reference ({\"$ref\": ...}).",
566 );
567 }
568 let types = validated_types(map, "Embedded value")?;
569 let explicit_type = map.get("$type").and_then(|t| t.as_str());
570 let cls_uri = match explicit_type.or_else(|| prop.get("range").and_then(|r| r.as_str())) {
571 Some(uri) => uri,
572 None => {
573 return err(
574 "Cannot map embedded value: it carries no $type and the property \
575 declares no range.",
576 )
577 }
578 };
579 let cls = hash_class_for(schema, cls_uri, "embedded type")?;
580 let props = cls
581 .get("props")
582 .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", cls_uri)))?;
583
584 let mut statements = field_statements(map, cls_uri, props, schema)?;
585 if let Some(members) = types {
586 let type_predicate = schema
589 .get("typePredicate")
590 .and_then(|p| p.as_str())
591 .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
592 for member in members {
593 statements.push(Statement {
594 predicate: type_predicate.to_string(),
595 value: Value::Reference(member),
596 });
597 }
598 } else if let Some(type_uri) = explicit_type {
599 let type_predicate = schema
600 .get("typePredicate")
601 .and_then(|p| p.as_str())
602 .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
603 statements.push(Statement {
604 predicate: type_predicate.to_string(),
605 value: Value::Reference(type_uri.to_string()),
606 });
607 }
608 let name = map
609 .get("$name")
610 .and_then(|n| n.as_str())
611 .filter(|s| !s.is_empty())
612 .map(|s| s.to_string());
613 Ok(Value::Embedded { name, statements })
614}
615
616fn field_statements(
620 source: &Map<String, Json>,
621 class_uri: &str,
622 props: &Json,
623 schema: &Json,
624) -> Result<Vec<Statement>, CodecError> {
625 let mut out: Vec<Statement> = Vec::new();
626
627 for (key, raw) in source.iter() {
628 if ENVELOPE_KEYS.contains(&key.as_str()) || raw.is_null() {
629 continue;
630 }
631 match props.get(key) {
632 None => return undeclared_statement(class_uri, &format!("\"{}\"", key)),
633 Some(prop) => {
634 let predicate =
635 prop.get("predicate")
636 .and_then(|p| p.as_str())
637 .ok_or_else(|| {
638 CodecError::Malformed(format!("prop {} is missing 'predicate'", key))
639 })?;
640 let value = match raw.as_array() {
641 Some(items) => {
642 if items.is_empty() {
646 continue;
647 }
648 let mut list = Vec::with_capacity(items.len());
649 for item in items {
650 list.push(build_value(prop, item, schema)?);
651 }
652 Value::List(list)
653 }
654 None => build_value(prop, raw, schema)?,
655 };
656 out.push(Statement {
657 predicate: predicate.to_string(),
658 value,
659 });
660 }
661 }
662 }
663
664 if let Some(extra) = source.get("$extra") {
667 let extra = extra
668 .as_object()
669 .ok_or_else(|| CodecError::Malformed("$extra must be an object".into()))?;
670 for (predicate, raw) in extra.iter() {
671 if raw.is_null() {
672 continue;
673 }
674 return undeclared_statement(class_uri, &format!("$extra statement {}", predicate));
675 }
676 }
677 Ok(out)
678}
679
680fn undeclared_statement<T>(class_uri: &str, what: &str) -> Result<T, CodecError> {
686 err(format!(
687 "Cannot hash {}'s {}: this codec's schema doesn't declare it, so it can't type its value. \
688 Hash it with the SDK, over the graph [hash-undeclared-statement]",
689 class_uri, what
690 ))
691}
692
693fn statements(node: &Node, schema: &Json) -> Result<Vec<Statement>, CodecError> {
695 let types = {
696 let id = node
697 .get("$id")
698 .and_then(|i| i.as_str())
699 .unwrap_or("(no $id)");
700 let where_ = format!("Node {}", id);
701 let types = validated_types(node, &where_)?;
702 assert_reference_values_map(node, &where_)?;
703 types
704 };
705 let type_uri = node
706 .get("$type")
707 .and_then(|t| t.as_str())
708 .filter(|s| !s.is_empty())
709 .ok_or_else(|| CodecError::Malformed("node is missing $type".into()))?;
710
711 let cls = hash_class_for(schema, type_uri, "type")?;
712 let props = cls
713 .get("props")
714 .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", type_uri)))?;
715
716 let type_predicate = schema
717 .get("typePredicate")
718 .and_then(|p| p.as_str())
719 .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
720
721 let members = types.unwrap_or_else(|| vec![type_uri.to_string()]);
724 let mut out: Vec<Statement> = members
725 .into_iter()
726 .map(|member| Statement {
727 predicate: type_predicate.to_string(),
728 value: Value::Reference(member),
729 })
730 .collect();
731 out.extend(field_statements(node, type_uri, props, schema)?);
732 Ok(out)
733}
734
735pub fn build_package(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<Package, CodecError> {
739 let mut subjects: Vec<Subject> = Vec::with_capacity(nodes.len() + 1);
740 for node in nodes {
741 let id = node
742 .get("$id")
743 .and_then(|i| i.as_str())
744 .filter(|s| !s.is_empty())
745 .ok_or_else(|| CodecError::Malformed("node is missing $id".into()))?;
746 subjects.push(Subject {
747 uri: id.to_string(),
748 statements: statements(node, schema)?,
749 });
750 }
751
752 let publisher = pkg
753 .get("publisher")
754 .and_then(|p| p.as_str())
755 .ok_or_else(|| CodecError::Malformed("pkg is missing 'publisher'".into()))?;
756 let package_name = pkg
757 .get("packageName")
758 .and_then(|p| p.as_str())
759 .ok_or_else(|| CodecError::Malformed("pkg is missing 'packageName'".into()))?;
760 let version = pkg
761 .get("version")
762 .and_then(|p| p.as_str())
763 .ok_or_else(|| CodecError::Malformed("pkg is missing 'version'".into()))?;
764
765 let pkg_uri = format!(
766 "{}/{}@{}/{}",
767 publisher, package_name, version, package_name
768 );
769
770 let type_predicate = schema
771 .get("typePredicate")
772 .and_then(|p| p.as_str())
773 .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
774 let label_predicate = schema
775 .get("labelPredicate")
776 .and_then(|p| p.as_str())
777 .ok_or_else(|| CodecError::Malformed("schema is missing 'labelPredicate'".into()))?;
778 let package_type_uri = schema
779 .get("packageTypeUri")
780 .and_then(|p| p.as_str())
781 .ok_or_else(|| CodecError::Malformed("schema is missing 'packageTypeUri'".into()))?;
782
783 let mut pkg_statements: Vec<Statement> = Vec::new();
784 if let Some(label) = pkg.get("label") {
785 if !label.is_null() {
786 let label = label
787 .as_str()
788 .ok_or_else(|| CodecError::Malformed("pkg label must be a string".into()))?;
789 pkg_statements.push(Statement {
790 predicate: label_predicate.to_string(),
791 value: Value::Raw(label.to_string()),
792 });
793 }
794 }
795 pkg_statements.push(Statement {
796 predicate: type_predicate.to_string(),
797 value: Value::Reference(package_type_uri.to_string()),
798 });
799 subjects.push(Subject {
800 uri: pkg_uri,
801 statements: pkg_statements,
802 });
803
804 Ok(Package { subjects })
805}
806
807pub fn canonical_form(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<String, CodecError> {
809 Ok(canonical_form_pkg(&build_package(nodes, schema, pkg)?)?)
810}
811
812pub fn content_hash(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<String, CodecError> {
814 Ok(canonical_hash_pkg(&build_package(nodes, schema, pkg)?)?)
815}
816
817pub fn serialize(node: &Node) -> Result<Node, CodecError> {
823 let where_ = node
824 .get("$id")
825 .or_else(|| node.get("$type"))
826 .and_then(|v| v.as_str())
827 .unwrap_or("(node)");
828 assert_types_envelopes_map(node, &format!("serialize {}", where_))?;
829 assert_reference_values_map(node, &format!("serialize {}", where_))?;
830 let mut out = Map::new();
831 for (key, value) in node.iter() {
832 if key == "$extra" || value.is_null() {
833 continue;
834 }
835 out.insert(key.clone(), value.clone());
836 }
837 if let Some(extra) = node.get("$extra").and_then(|e| e.as_object()) {
838 for (key, value) in extra.iter() {
839 if !value.is_null() && !out.contains_key(key) {
840 out.insert(key.clone(), value.clone());
841 }
842 }
843 }
844 Ok(out)
845}
846
847pub fn deserialize(json_obj: &Node, schema: &Json) -> Result<Node, CodecError> {
856 let type_uri = json_obj
857 .get("$type")
858 .and_then(|t| t.as_str())
859 .ok_or_else(|| CodecError::Malformed("Cannot deserialize: missing string $type".into()))?;
860
861 {
866 let where_ = json_obj
867 .get("$id")
868 .and_then(|i| i.as_str())
869 .unwrap_or(type_uri);
870 assert_types_envelopes_map(json_obj, &format!("deserialize {}", where_))?;
871 assert_reference_values_map(json_obj, &format!("deserialize {}", where_))?;
872 }
873
874 let (cls_uri, cls) = class_for(classes_of(schema)?, type_uri)
875 .map_err(|e| CodecError::Malformed(format!("Cannot deserialize: {}", e)))?;
876 let props = cls
877 .get("props")
878 .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", cls_uri)))?;
879
880 let mut node = Map::new();
881 node.insert("$type".to_string(), Json::String(type_uri.to_string()));
882 let mut extra = Map::new();
883 for (key, value) in json_obj.iter() {
884 if key == "$type" {
885 continue;
886 }
887 if key.starts_with('$') || props.get(key).is_some() {
888 node.insert(key.clone(), value.clone());
889 } else {
890 extra.insert(key.clone(), value.clone());
891 }
892 }
893 if !extra.is_empty() {
894 node.insert("$extra".to_string(), Json::Object(extra));
895 }
896 Ok(node)
897}