newt_interaction/tag.rs
1//! The versioned type tag, as a TYPE.
2//!
3//! A `schema: String` field lets a record claim any tag, and — more to the
4//! point for the slice that publishes a cross-language contract — it makes
5//! the generated JSON Schema say `{"type": "string"}`. A foreign
6//! implementor validating a definition record against `response.schema.json`
7//! would PASS, while our decoder classifies it unknown. That is not a
8//! description of "response v1"; it is a description of "any record with
9//! some string in its schema field".
10//!
11//! Each record therefore carries a tag type that serializes and
12//! deserializes exactly one value, and whose generated schema is a
13//! `const`. The outer PROBE stays permissive on purpose — it reads the tag
14//! as a plain string and dispatches to the strict deserializer only on an
15//! exact match, which is what lets an unknown version be preserved rather
16//! than rejected as malformed.
17
18use serde::de::{Error as _, Unexpected};
19use serde::{Deserialize, Deserializer, Serialize, Serializer};
20
21/// Define a tag type that is exactly one string on the wire.
22macro_rules! schema_tag {
23 ($(#[$meta:meta])* $name:ident, $value:path) => {
24 $(#[$meta])*
25 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
26 pub struct $name;
27
28 impl $name {
29 /// The one value this tag takes on the wire.
30 #[must_use]
31 pub fn as_str(&self) -> &'static str {
32 $value
33 }
34 }
35
36 impl Serialize for $name {
37 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
38 serializer.serialize_str($value)
39 }
40 }
41
42 impl<'de> Deserialize<'de> for $name {
43 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
44 let text = String::deserialize(deserializer)?;
45 if text == $value {
46 Ok(Self)
47 } else {
48 Err(D::Error::invalid_value(
49 Unexpected::Str(&text),
50 &$value,
51 ))
52 }
53 }
54 }
55
56 impl std::fmt::Display for $name {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.write_str($value)
59 }
60 }
61
62 #[cfg(feature = "schema")]
63 impl schemars::JsonSchema for $name {
64 fn schema_name() -> String {
65 stringify!($name).to_string()
66 }
67
68 fn json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
69 let mut schema = schemars::schema::SchemaObject {
70 instance_type: Some(schemars::schema::InstanceType::String.into()),
71 ..Default::default()
72 };
73 schema.const_value = Some(serde_json::Value::String($value.to_string()));
74 schema.into()
75 }
76 }
77 };
78}
79
80schema_tag!(
81 /// The tag every v1 definition carries, and the only value it accepts.
82 DefinitionTag,
83 crate::definition::DEFINITION_SCHEMA_V1
84);
85schema_tag!(
86 /// The tag every v1 instance carries.
87 InstanceTag,
88 crate::instance::INSTANCE_SCHEMA_V1
89);
90schema_tag!(
91 /// The tag every v1 response carries.
92 ResponseTag,
93 crate::response::RESPONSE_SCHEMA_V1
94);
95
96/// Publish a constrained string schema for a validated scalar newtype.
97///
98/// A derived `JsonSchema` on these would emit a bare `{"type": "string"}`,
99/// which permits values the decoder refuses — the same false-guarantee
100/// defect as a doc comment claiming a validation the wire does not
101/// perform. A schema is a published contract; it has to state the rule.
102#[cfg(feature = "schema")]
103#[macro_export]
104macro_rules! string_scalar_schema {
105 ($name:ident, $pattern:expr) => {
106 impl schemars::JsonSchema for $name {
107 fn schema_name() -> String {
108 stringify!($name).to_string()
109 }
110
111 fn json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
112 let mut schema = schemars::schema::SchemaObject {
113 instance_type: Some(schemars::schema::InstanceType::String.into()),
114 ..Default::default()
115 };
116 schema.string().min_length = Some(1);
117 if let Some(pattern) = $pattern {
118 schema.string().pattern = Some(pattern.to_string());
119 }
120 schema.into()
121 }
122 }
123 };
124}