1use std::collections::BTreeMap;
15
16use serde::de::{self, Deserializer};
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value, json};
19
20pub use runx_contracts_derive::RunxSchema;
21
22pub fn deserialize_true_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
30where
31 D: Deserializer<'de>,
32{
33 let value = bool::deserialize(deserializer)?;
34 if value {
35 Ok(true)
36 } else {
37 Err(de::Error::custom("value must be true"))
38 }
39}
40
41pub fn deserialize_false_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
48where
49 D: Deserializer<'de>,
50{
51 let value = bool::deserialize(deserializer)?;
52 if value {
53 Err(de::Error::custom("value must be false"))
54 } else {
55 Ok(false)
56 }
57}
58
59pub trait RunxSchema {
61 fn json_schema() -> Value;
63}
64
65pub struct Property {
67 pub name: &'static str,
68 pub schema: Value,
69 pub required: bool,
70}
71
72impl Property {
73 pub fn new(name: &'static str, schema: Value, required: bool) -> Self {
74 Self {
75 name,
76 schema,
77 required,
78 }
79 }
80}
81
82pub enum Identity<'a> {
90 Runx {
95 logical: &'a str,
96 url: Option<&'a str>,
97 },
98 BareId { url: &'a str },
101}
102
103pub fn object_schema(
106 properties: Vec<Property>,
107 deny_unknown: bool,
108 identity: Option<Identity<'_>>,
109) -> Value {
110 let mut required: Vec<Value> = Vec::new();
111 let mut props = Map::new();
112 for property in properties {
113 if property.required {
114 required.push(Value::String(property.name.to_owned()));
115 }
116 props.insert(property.name.to_owned(), property.schema);
117 }
118
119 let mut schema = Map::new();
120 if let Some(identity) = identity {
121 schema.insert(
122 "$schema".to_owned(),
123 json!("https://json-schema.org/draft/2020-12/schema"),
124 );
125 match identity {
126 Identity::Runx { logical, url } => {
127 let id = url
128 .map(str::to_owned)
129 .unwrap_or_else(|| schema_id_url(logical));
130 schema.insert("$id".to_owned(), json!(id));
131 schema.insert("x-runx-schema".to_owned(), json!(logical));
132 props
136 .entry("schema".to_owned())
137 .or_insert_with(|| const_string(logical));
138 }
139 Identity::BareId { url } => {
140 schema.insert("$id".to_owned(), json!(url));
141 }
142 }
143 }
144 schema.insert("additionalProperties".to_owned(), json!(!deny_unknown));
145 schema.insert("type".to_owned(), json!("object"));
146 if !required.is_empty() {
147 schema.insert("required".to_owned(), Value::Array(required));
148 }
149 schema.insert("properties".to_owned(), Value::Object(props));
150 Value::Object(schema)
151}
152
153pub fn object_schema_with_flatten(
164 properties: Vec<Property>,
165 flattened: Vec<Value>,
166 deny_unknown: bool,
167 identity: Option<Identity<'_>>,
168) -> Value {
169 let mut required: Vec<Value> = Vec::new();
170 let mut props = Map::new();
171 for property in properties {
172 if property.required {
173 required.push(Value::String(property.name.to_owned()));
174 }
175 props.insert(property.name.to_owned(), property.schema);
176 }
177
178 let mut deny_unknown = deny_unknown;
181 for flat in flattened {
182 let is_object = flat.get("type").and_then(Value::as_str) == Some("object");
183 match flat.get("properties").and_then(Value::as_object) {
184 Some(inner_props) if is_object => {
185 let inner_required: Vec<&str> = flat
186 .get("required")
187 .and_then(Value::as_array)
188 .map(|items| items.iter().filter_map(Value::as_str).collect())
189 .unwrap_or_default();
190 for (name, schema) in inner_props {
191 if inner_required.contains(&name.as_str()) {
192 required.push(Value::String(name.clone()));
193 }
194 props.insert(name.clone(), schema.clone());
195 }
196 }
197 _ => {
198 deny_unknown = false;
201 }
202 }
203 }
204
205 let mut schema = Map::new();
206 if let Some(identity) = identity {
207 schema.insert(
208 "$schema".to_owned(),
209 json!("https://json-schema.org/draft/2020-12/schema"),
210 );
211 match identity {
212 Identity::Runx { logical, url } => {
213 let id = url
214 .map(str::to_owned)
215 .unwrap_or_else(|| schema_id_url(logical));
216 schema.insert("$id".to_owned(), json!(id));
217 schema.insert("x-runx-schema".to_owned(), json!(logical));
218 props
219 .entry("schema".to_owned())
220 .or_insert_with(|| const_string(logical));
221 }
222 Identity::BareId { url } => {
223 schema.insert("$id".to_owned(), json!(url));
224 }
225 }
226 }
227 schema.insert("additionalProperties".to_owned(), json!(!deny_unknown));
228 schema.insert("type".to_owned(), json!("object"));
229 if !required.is_empty() {
230 schema.insert("required".to_owned(), Value::Array(required));
231 }
232 schema.insert("properties".to_owned(), Value::Object(props));
233 Value::Object(schema)
234}
235
236pub fn object_map_schema(value_schema: Value, identity: Option<Identity<'_>>) -> Value {
244 let mut schema = Map::new();
245 if let Some(identity) = identity {
246 schema.insert(
247 "$schema".to_owned(),
248 json!("https://json-schema.org/draft/2020-12/schema"),
249 );
250 match identity {
251 Identity::Runx { logical, url } => {
252 let id = url
253 .map(str::to_owned)
254 .unwrap_or_else(|| schema_id_url(logical));
255 schema.insert("$id".to_owned(), json!(id));
256 schema.insert("x-runx-schema".to_owned(), json!(logical));
257 }
258 Identity::BareId { url } => {
259 schema.insert("$id".to_owned(), json!(url));
260 }
261 }
262 }
263 schema.insert("type".to_owned(), json!("object"));
264 schema.insert(
265 "patternProperties".to_owned(),
266 json!({ "^(.*)$": value_schema }),
267 );
268 Value::Object(schema)
269}
270
271pub fn string_enum(variants: &[&str]) -> Value {
274 let any_of: Vec<Value> = variants
275 .iter()
276 .map(|variant| const_string(variant))
277 .collect();
278 json!({ "anyOf": any_of })
279}
280
281pub fn any_of(variants: Vec<Value>) -> Value {
285 json!({ "anyOf": variants })
286}
287
288pub fn any_of_with_identity(variants: Vec<Value>, identity: Option<Identity<'_>>) -> Value {
296 let mut schema = Map::new();
297 if let Some(identity) = identity {
298 schema.insert(
299 "$schema".to_owned(),
300 json!("https://json-schema.org/draft/2020-12/schema"),
301 );
302 match identity {
303 Identity::Runx { logical, url } => {
304 let id = url
305 .map(str::to_owned)
306 .unwrap_or_else(|| schema_id_url(logical));
307 schema.insert("$id".to_owned(), json!(id));
308 schema.insert("x-runx-schema".to_owned(), json!(logical));
309 }
310 Identity::BareId { url } => {
311 schema.insert("$id".to_owned(), json!(url));
312 }
313 }
314 }
315 schema.insert("anyOf".to_owned(), Value::Array(variants));
316 Value::Object(schema)
317}
318
319pub fn nullable(inner: Value) -> Value {
324 json!({ "anyOf": [inner, { "type": "null" }] })
325}
326
327pub fn externally_tagged_variant(tag: &'static str, inner: Value) -> Value {
331 object_schema(vec![Property::new(tag, inner, true)], true, None)
332}
333
334pub fn const_string(value: &str) -> Value {
336 json!({ "const": value, "type": "string" })
337}
338
339pub fn schema_id_url(logical: &str) -> String {
345 let path = logical
346 .split('.')
347 .map(|segment| segment.replace('_', "-"))
348 .collect::<Vec<_>>()
349 .join("/");
350 format!("https://schemas.runx.ai/{path}.json")
351}
352
353impl RunxSchema for String {
354 fn json_schema() -> Value {
355 json!({ "type": "string" })
356 }
357}
358
359impl RunxSchema for bool {
360 fn json_schema() -> Value {
361 json!({ "type": "boolean" })
362 }
363}
364
365impl RunxSchema for f64 {
366 fn json_schema() -> Value {
367 json!({ "type": "number" })
368 }
369}
370
371macro_rules! integer_schema {
372 ($($ty:ty),+) => {
373 $(impl RunxSchema for $ty {
374 fn json_schema() -> Value {
375 json!({ "type": "integer" })
376 }
377 })+
378 };
379}
380integer_schema!(i8, i16, i32, i64, isize, u8, u16, u32, u64, usize);
381
382impl<T: RunxSchema> RunxSchema for Vec<T> {
383 fn json_schema() -> Value {
384 json!({ "type": "array", "items": T::json_schema() })
385 }
386}
387
388#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
391#[serde(transparent)]
392pub struct NonEmptyVec<T>(Vec<T>);
393
394impl<T> NonEmptyVec<T> {
395 pub fn new(value: Vec<T>) -> Option<Self> {
396 if value.is_empty() {
397 None
398 } else {
399 Some(Self(value))
400 }
401 }
402
403 pub fn as_slice(&self) -> &[T] {
404 &self.0
405 }
406
407 pub fn into_vec(self) -> Vec<T> {
408 self.0
409 }
410}
411
412impl<T> From<Vec<T>> for NonEmptyVec<T> {
413 fn from(value: Vec<T>) -> Self {
414 debug_assert!(
415 !value.is_empty(),
416 "NonEmptyVec::from received an empty outbound value"
417 );
418 Self(value)
419 }
420}
421
422impl<T> std::ops::Deref for NonEmptyVec<T> {
423 type Target = [T];
424
425 fn deref(&self) -> &Self::Target {
426 &self.0
427 }
428}
429
430impl<'de, T> Deserialize<'de> for NonEmptyVec<T>
431where
432 T: Deserialize<'de>,
433{
434 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
435 where
436 D: serde::Deserializer<'de>,
437 {
438 let value = Vec::<T>::deserialize(deserializer)?;
439 Self::new(value)
440 .ok_or_else(|| serde::de::Error::custom("array must contain at least one item"))
441 }
442}
443
444impl<T: RunxSchema> RunxSchema for NonEmptyVec<T> {
445 fn json_schema() -> Value {
446 let mut schema = Vec::<T>::json_schema();
447 if let Some(object) = schema.as_object_mut() {
448 object.insert("minItems".to_owned(), json!(1));
449 }
450 schema
451 }
452}
453
454impl<T: RunxSchema> RunxSchema for Option<T> {
455 fn json_schema() -> Value {
456 T::json_schema()
457 }
458}
459
460impl<T: RunxSchema> RunxSchema for Box<T> {
461 fn json_schema() -> Value {
462 T::json_schema()
463 }
464}
465
466impl<T: RunxSchema> RunxSchema for BTreeMap<String, T> {
467 fn json_schema() -> Value {
468 json!({ "type": "object", "additionalProperties": T::json_schema() })
469 }
470}
471
472#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
476#[serde(transparent)]
477pub struct NonEmptyString(String);
478
479impl NonEmptyString {
480 pub fn new(value: impl Into<String>) -> Option<Self> {
482 let value = value.into();
483 if value.is_empty() {
484 None
485 } else {
486 Some(Self(value))
487 }
488 }
489
490 pub fn as_str(&self) -> &str {
491 &self.0
492 }
493
494 pub fn into_string(self) -> String {
495 self.0
496 }
497}
498
499impl From<String> for NonEmptyString {
502 fn from(value: String) -> Self {
503 debug_assert!(
504 !value.is_empty(),
505 "NonEmptyString::from received an empty outbound value"
506 );
507 Self(value)
508 }
509}
510
511impl From<&str> for NonEmptyString {
512 fn from(value: &str) -> Self {
513 debug_assert!(
514 !value.is_empty(),
515 "NonEmptyString::from received an empty outbound value"
516 );
517 Self(value.to_owned())
518 }
519}
520
521impl PartialEq<String> for NonEmptyString {
522 fn eq(&self, other: &String) -> bool {
523 &self.0 == other
524 }
525}
526
527impl PartialEq<&str> for NonEmptyString {
528 fn eq(&self, other: &&str) -> bool {
529 self.0 == *other
530 }
531}
532
533impl PartialEq<NonEmptyString> for String {
534 fn eq(&self, other: &NonEmptyString) -> bool {
535 self == &other.0
536 }
537}
538
539impl PartialEq<NonEmptyString> for str {
540 fn eq(&self, other: &NonEmptyString) -> bool {
541 self == other.0.as_str()
542 }
543}
544
545impl std::ops::Deref for NonEmptyString {
546 type Target = str;
547 fn deref(&self) -> &str {
548 &self.0
549 }
550}
551
552impl AsRef<str> for NonEmptyString {
553 fn as_ref(&self) -> &str {
554 &self.0
555 }
556}
557
558impl std::fmt::Display for NonEmptyString {
559 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560 formatter.write_str(&self.0)
561 }
562}
563
564impl PartialEq<str> for NonEmptyString {
565 fn eq(&self, other: &str) -> bool {
566 self.0 == other
567 }
568}
569
570impl<'de> Deserialize<'de> for NonEmptyString {
571 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
572 where
573 D: serde::Deserializer<'de>,
574 {
575 let value = String::deserialize(deserializer)?;
576 Self::new(value).ok_or_else(|| serde::de::Error::custom("string must be non-empty"))
577 }
578}
579
580impl RunxSchema for NonEmptyString {
581 fn json_schema() -> Value {
582 json!({ "minLength": 1, "type": "string" })
583 }
584}
585
586pub const ISO_DATETIME_PATTERN: &str = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$";
589
590#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
594#[serde(transparent)]
595pub struct IsoDateTime(String);
596
597impl IsoDateTime {
598 pub fn new(value: impl Into<String>) -> Option<Self> {
599 let value = value.into();
600 if value.is_empty() {
601 None
602 } else {
603 Some(Self(value))
604 }
605 }
606
607 pub fn as_str(&self) -> &str {
608 &self.0
609 }
610
611 pub fn into_string(self) -> String {
612 self.0
613 }
614}
615
616impl From<String> for IsoDateTime {
617 fn from(value: String) -> Self {
618 Self(value)
619 }
620}
621
622impl From<&str> for IsoDateTime {
623 fn from(value: &str) -> Self {
624 Self(value.to_owned())
625 }
626}
627
628impl std::ops::Deref for IsoDateTime {
629 type Target = str;
630 fn deref(&self) -> &str {
631 &self.0
632 }
633}
634
635impl AsRef<str> for IsoDateTime {
636 fn as_ref(&self) -> &str {
637 &self.0
638 }
639}
640
641impl PartialEq<String> for IsoDateTime {
642 fn eq(&self, other: &String) -> bool {
643 &self.0 == other
644 }
645}
646
647impl PartialEq<&str> for IsoDateTime {
648 fn eq(&self, other: &&str) -> bool {
649 self.0 == *other
650 }
651}
652
653impl PartialEq<str> for IsoDateTime {
654 fn eq(&self, other: &str) -> bool {
655 self.0 == other
656 }
657}
658
659impl PartialEq<IsoDateTime> for String {
660 fn eq(&self, other: &IsoDateTime) -> bool {
661 self == &other.0
662 }
663}
664
665impl PartialEq<IsoDateTime> for str {
666 fn eq(&self, other: &IsoDateTime) -> bool {
667 self == other.0.as_str()
668 }
669}
670
671impl std::fmt::Display for IsoDateTime {
672 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
673 formatter.write_str(&self.0)
674 }
675}
676
677impl<'de> Deserialize<'de> for IsoDateTime {
678 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
679 where
680 D: serde::Deserializer<'de>,
681 {
682 let value = String::deserialize(deserializer)?;
683 Self::new(value).ok_or_else(|| serde::de::Error::custom("datetime must be non-empty"))
684 }
685}
686
687impl RunxSchema for IsoDateTime {
688 fn json_schema() -> Value {
689 json!({ "minLength": 1, "pattern": ISO_DATETIME_PATTERN, "type": "string" })
690 }
691}