From 93a4644a887d4a3bcbb3350e3124cb58e466004a Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Tue, 23 Mar 2021 18:55:07 +0000 Subject: [PATCH 1/7] Implement schema_for_value!(...) macro --- docs/_includes/examples/from_value.rs | 24 + .../_includes/examples/from_value.schema.json | 22 + schemars/examples/from_value.rs | 24 + schemars/examples/from_value.schema.json | 22 + schemars/src/flatten.rs | 3 + schemars/src/gen.rs | 62 +++ schemars/src/json_schema_impls/core.rs | 1 + schemars/src/lib.rs | 1 + schemars/src/macros.rs | 13 + schemars/src/ser.rs | 511 ++++++++++++++++++ .../tests/expected/from_value_2019_09.json | 80 +++ .../tests/expected/from_value_draft07.json | 80 +++ .../tests/expected/from_value_openapi3.json | 80 +++ schemars/tests/from_value.rs | 77 +++ schemars/tests/ui/schema_for_arg_value.rs | 5 + schemars/tests/ui/schema_for_arg_value.stderr | 7 + schemars/tests/util/mod.rs | 2 +- 17 files changed, 1013 insertions(+), 1 deletion(-) create mode 100644 docs/_includes/examples/from_value.rs create mode 100644 docs/_includes/examples/from_value.schema.json create mode 100644 schemars/examples/from_value.rs create mode 100644 schemars/examples/from_value.schema.json create mode 100644 schemars/src/ser.rs create mode 100644 schemars/tests/expected/from_value_2019_09.json create mode 100644 schemars/tests/expected/from_value_draft07.json create mode 100644 schemars/tests/expected/from_value_openapi3.json create mode 100644 schemars/tests/from_value.rs create mode 100644 schemars/tests/ui/schema_for_arg_value.rs create mode 100644 schemars/tests/ui/schema_for_arg_value.stderr diff --git a/docs/_includes/examples/from_value.rs b/docs/_includes/examples/from_value.rs new file mode 100644 index 00000000..114ba257 --- /dev/null +++ b/docs/_includes/examples/from_value.rs @@ -0,0 +1,24 @@ +use schemars::schema_for_value; +use serde::Serialize; + +#[derive(Serialize)] +pub struct MyStruct { + pub my_int: i32, + pub my_bool: bool, + pub my_nullable_enum: Option, +} + +#[derive(Serialize)] +pub enum MyEnum { + StringNewType(String), + StructVariant { floats: Vec }, +} + +fn main() { + let schema = schema_for_value!(MyStruct { + my_int: 123, + my_bool: true, + my_nullable_enum: None + }); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); +} diff --git a/docs/_includes/examples/from_value.schema.json b/docs/_includes/examples/from_value.schema.json new file mode 100644 index 00000000..ca5f2803 --- /dev/null +++ b/docs/_includes/examples/from_value.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MyStruct", + "examples": [ + { + "my_bool": true, + "my_int": 123, + "my_nullable_enum": null + } + ], + "type": "object", + "properties": { + "my_bool": { + "type": "boolean" + }, + "my_int": { + "type": "integer", + "format": "int32" + }, + "my_nullable_enum": true + } +} diff --git a/schemars/examples/from_value.rs b/schemars/examples/from_value.rs new file mode 100644 index 00000000..114ba257 --- /dev/null +++ b/schemars/examples/from_value.rs @@ -0,0 +1,24 @@ +use schemars::schema_for_value; +use serde::Serialize; + +#[derive(Serialize)] +pub struct MyStruct { + pub my_int: i32, + pub my_bool: bool, + pub my_nullable_enum: Option, +} + +#[derive(Serialize)] +pub enum MyEnum { + StringNewType(String), + StructVariant { floats: Vec }, +} + +fn main() { + let schema = schema_for_value!(MyStruct { + my_int: 123, + my_bool: true, + my_nullable_enum: None + }); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); +} diff --git a/schemars/examples/from_value.schema.json b/schemars/examples/from_value.schema.json new file mode 100644 index 00000000..ca5f2803 --- /dev/null +++ b/schemars/examples/from_value.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MyStruct", + "examples": [ + { + "my_bool": true, + "my_int": 123, + "my_nullable_enum": null + } + ], + "type": "object", + "properties": { + "my_bool": { + "type": "boolean" + }, + "my_int": { + "type": "integer", + "format": "int32" + }, + "my_nullable_enum": true + } +} diff --git a/schemars/src/flatten.rs b/schemars/src/flatten.rs index 1e7dab3a..646f614f 100644 --- a/schemars/src/flatten.rs +++ b/schemars/src/flatten.rs @@ -2,6 +2,9 @@ use crate::schema::*; use crate::{Map, Set}; impl Schema { + /// This function is only public for use by schemars_derive. + /// + /// It should not be considered part of the public API. #[doc(hidden)] pub fn flatten(self, other: Self) -> Schema { if is_null_type(&self) { diff --git a/schemars/src/gen.rs b/schemars/src/gen.rs index aff686c8..909d0536 100644 --- a/schemars/src/gen.rs +++ b/schemars/src/gen.rs @@ -11,6 +11,7 @@ use crate::flatten::Merge; use crate::schema::*; use crate::{visit::*, JsonSchema, Map}; use dyn_clone::DynClone; +use serde::Serialize; use std::{any::Any, collections::HashSet, fmt::Debug}; /// Settings to customize how Schemas are generated. @@ -314,6 +315,67 @@ impl SchemaGenerator { root } + // TODO document + pub fn root_schema_for_value( + &mut self, + value: &T, + ) -> Result { + let mut schema = value + .serialize(crate::ser::Serializer { + gen: self, + include_title: true, + }) + .map_err(|e| e.0)? + .into_object(); + + if let Ok(example) = serde_json::to_value(value) { + schema.metadata().examples.push(example); + } + + let mut root = RootSchema { + meta_schema: self.settings.meta_schema.clone(), + definitions: self.definitions.clone(), + schema, + }; + + for visitor in &mut self.settings.visitors { + visitor.visit_root_schema(&mut root) + } + + Ok(root) + } + + // TODO document + // TODO consider using a different Error type, maybe just use serde_json::error::Error? + pub fn into_root_schema_for_value( + mut self, + value: &T, + ) -> Result { + let mut schema = value + .serialize(crate::ser::Serializer { + gen: &mut self, + include_title: true, + }) + .map_err(|e| e.0)? + .into_object(); + + if let Ok(example) = serde_json::to_value(value) { + schema.metadata().examples.push(example); + } + + let mut root = RootSchema { + meta_schema: self.settings.meta_schema, + definitions: self.definitions, + schema, + }; + + for visitor in &mut self.settings.visitors { + visitor.visit_root_schema(&mut root) + } + + Ok(root) + } + /// Attemps to find the schema that the given `schema` is referencing. /// /// If the given `schema` has a [`$ref`](../schema/struct.SchemaObject.html#structfield.reference) property which refers diff --git a/schemars/src/json_schema_impls/core.rs b/schemars/src/json_schema_impls/core.rs index 2c5b4b6d..9d5fee02 100644 --- a/schemars/src/json_schema_impls/core.rs +++ b/schemars/src/json_schema_impls/core.rs @@ -25,6 +25,7 @@ impl JsonSchema for Option { schema } schema => SchemaObject { + // TODO technically the schema already accepts null, so this may be unnecessary subschemas: Some(Box::new(SubschemaValidation { any_of: Some(vec![schema, <()>::json_schema(gen)]), ..Default::default() diff --git a/schemars/src/lib.rs b/schemars/src/lib.rs index a4372f8f..f31eb17f 100644 --- a/schemars/src/lib.rs +++ b/schemars/src/lib.rs @@ -236,6 +236,7 @@ pub type MapEntry<'a, K, V> = indexmap::map::Entry<'a, K, V>; mod flatten; mod json_schema_impls; +mod ser; #[macro_use] mod macros; diff --git a/schemars/src/macros.rs b/schemars/src/macros.rs index 226f2cc7..11ae7c69 100644 --- a/schemars/src/macros.rs +++ b/schemars/src/macros.rs @@ -18,4 +18,17 @@ macro_rules! schema_for { ($type:ty) => { $crate::gen::SchemaGenerator::default().into_root_schema_for::<$type>() }; + ($_:expr) => { + compile_error!("This argument to `schema_for!` is not a type - did you mean to use `schema_for_value!` instead?") + }; +} + +// TODO document +#[macro_export] +macro_rules! schema_for_value { + ($value:expr) => { + $crate::gen::SchemaGenerator::default() + .into_root_schema_for_value(&$value) + .unwrap() + }; } diff --git a/schemars/src/ser.rs b/schemars/src/ser.rs new file mode 100644 index 00000000..2fbc7ff4 --- /dev/null +++ b/schemars/src/ser.rs @@ -0,0 +1,511 @@ +use crate::schema::*; +use crate::JsonSchema; +use crate::{gen::SchemaGenerator, Map}; +use serde_json::Value; +use std::{convert::TryInto, fmt::Display}; + +pub(crate) struct Serializer<'a> { + pub(crate) gen: &'a mut SchemaGenerator, + pub(crate) include_title: bool, +} + +pub(crate) struct SerializeSeq<'a> { + gen: &'a mut SchemaGenerator, + items: Option, +} + +pub(crate) struct SerializeTuple<'a> { + gen: &'a mut SchemaGenerator, + items: Vec, + title: &'static str, +} + +pub(crate) struct SerializeMap<'a> { + gen: &'a mut SchemaGenerator, + properties: Map, + current_key: Option, + title: &'static str, +} + +#[derive(Debug)] +pub(crate) struct Error(pub(crate) String); + +impl serde::ser::Error for Error { + fn custom(msg: T) -> Self { + Self(msg.to_string()) + } +} + +impl std::error::Error for Error {} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +macro_rules! forward_to_subschema_for { + ($fn:ident, $ty:ty) => { + fn $fn(self, _value: $ty) -> Result { + Ok(self.gen.subschema_for::<$ty>()) + } + }; +} + +impl<'a> serde::Serializer for Serializer<'a> { + type Ok = Schema; + type Error = Error; + + type SerializeSeq = SerializeSeq<'a>; + type SerializeTuple = SerializeTuple<'a>; + type SerializeTupleStruct = SerializeTuple<'a>; + type SerializeTupleVariant = Self; + type SerializeMap = SerializeMap<'a>; + type SerializeStruct = SerializeMap<'a>; + type SerializeStructVariant = Self; + + forward_to_subschema_for!(serialize_bool, bool); + forward_to_subschema_for!(serialize_i8, i8); + forward_to_subschema_for!(serialize_i16, i16); + forward_to_subschema_for!(serialize_i32, i32); + forward_to_subschema_for!(serialize_i64, i64); + forward_to_subschema_for!(serialize_i128, i128); + forward_to_subschema_for!(serialize_u8, u8); + forward_to_subschema_for!(serialize_u16, u16); + forward_to_subschema_for!(serialize_u32, u32); + forward_to_subschema_for!(serialize_u64, u64); + forward_to_subschema_for!(serialize_u128, u128); + forward_to_subschema_for!(serialize_f32, f32); + forward_to_subschema_for!(serialize_f64, f64); + forward_to_subschema_for!(serialize_char, char); + forward_to_subschema_for!(serialize_str, &str); + forward_to_subschema_for!(serialize_bytes, &[u8]); + + fn collect_str(self, _value: &T) -> Result + where + T: Display, + { + Ok(self.gen.subschema_for::()) + } + + fn collect_map(self, iter: I) -> Result + where + K: serde::Serialize, + V: serde::Serialize, + I: IntoIterator, + { + let value_schema = iter + .into_iter() + .try_fold(None, |acc, (_, v)| { + if acc == Some(Schema::Bool(true)) { + return Ok(acc); + } + + let schema = v.serialize(Serializer { + gen: self.gen, + include_title: false, + })?; + Ok(match &acc { + None => Some(schema), + Some(items) if items != &schema => Some(Schema::Bool(true)), + _ => acc, + }) + })? + .unwrap_or(Schema::Bool(true)); + + Ok(SchemaObject { + instance_type: Some(InstanceType::Object.into()), + object: Some(Box::new(ObjectValidation { + additional_properties: Some(Box::new(value_schema)), + ..ObjectValidation::default() + })), + ..SchemaObject::default() + } + .into()) + } + + fn serialize_none(self) -> Result { + Ok(self.gen.subschema_for::>()) + } + + fn serialize_some(mut self, value: &T) -> Result + where + T: serde::Serialize, + { + // FIXME nasty duplication of `impl JsonSchema for Option` + fn add_null_type(instance_type: &mut SingleOrVec) { + match instance_type { + SingleOrVec::Single(ty) if **ty != InstanceType::Null => { + *instance_type = vec![**ty, InstanceType::Null].into() + } + SingleOrVec::Vec(ty) if !ty.contains(&InstanceType::Null) => { + ty.push(InstanceType::Null) + } + _ => {} + }; + } + + let mut schema = value.serialize(Serializer { + gen: self.gen, + include_title: false, + })?; + + if self.gen.settings().option_add_null_type { + schema = match schema { + Schema::Bool(true) => Schema::Bool(true), + Schema::Bool(false) => <()>::json_schema(&mut self.gen), + Schema::Object(SchemaObject { + instance_type: Some(ref mut instance_type), + .. + }) => { + add_null_type(instance_type); + schema + } + schema => SchemaObject { + subschemas: Some(Box::new(SubschemaValidation { + any_of: Some(vec![schema, <()>::json_schema(&mut self.gen)]), + ..Default::default() + })), + ..Default::default() + } + .into(), + } + } + + if self.gen.settings().option_nullable { + let mut schema_obj = schema.into_object(); + schema_obj + .extensions + .insert("nullable".to_owned(), serde_json::json!(true)); + schema = Schema::Object(schema_obj); + }; + + Ok(schema) + } + + fn serialize_unit(self) -> Result { + Ok(self.gen.subschema_for::<()>()) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + self.serialize_unit() + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + ) -> Result { + Ok(Schema::Bool(true)) + } + + fn serialize_newtype_struct( + self, + name: &'static str, + value: &T, + ) -> Result + where + T: serde::Serialize, + { + let include_title = self.include_title; + let mut result = value.serialize(self); + + if include_title { + if let Ok(Schema::Object(ref mut object)) = result { + object.metadata().title = Some(name.to_string()); + } + } + + result + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _value: &T, + ) -> Result + where + T: serde::Serialize, + { + Ok(Schema::Bool(true)) + } + + fn serialize_seq(self, _len: Option) -> Result { + Ok(SerializeSeq { + gen: self.gen, + items: None, + }) + } + + fn serialize_tuple(self, len: usize) -> Result { + Ok(SerializeTuple { + gen: self.gen, + items: Vec::with_capacity(len), + title: "", + }) + } + + fn serialize_tuple_struct( + self, + name: &'static str, + len: usize, + ) -> Result { + let title = if self.include_title { name } else { "" }; + Ok(SerializeTuple { + gen: self.gen, + items: Vec::with_capacity(len), + title, + }) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Ok(self) + } + + fn serialize_map(self, _len: Option) -> Result { + Ok(SerializeMap { + gen: self.gen, + properties: Map::new(), + current_key: None, + title: "", + }) + } + + fn serialize_struct( + self, + name: &'static str, + _len: usize, + ) -> Result { + let title = if self.include_title { name } else { "" }; + Ok(SerializeMap { + gen: self.gen, + properties: Map::new(), + current_key: None, + title, + }) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Ok(self) + } +} + +impl serde::ser::SerializeTupleVariant for Serializer<'_> { + type Ok = Schema; + type Error = Error; + + fn serialize_field(&mut self, _value: &T) -> Result<(), Self::Error> + where + T: serde::Serialize, + { + Ok(()) + } + + fn end(self) -> Result { + Ok(Schema::Bool(true)) + } +} + +impl serde::ser::SerializeStructVariant for Serializer<'_> { + type Ok = Schema; + type Error = Error; + + fn serialize_field( + &mut self, + _key: &'static str, + _value: &T, + ) -> Result<(), Self::Error> + where + T: serde::Serialize, + { + Ok(()) + } + + fn end(self) -> Result { + Ok(Schema::Bool(true)) + } +} + +impl serde::ser::SerializeSeq for SerializeSeq<'_> { + type Ok = Schema; + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> + where + T: serde::Serialize, + { + if self.items != Some(Schema::Bool(true)) { + let schema = value.serialize(Serializer { + gen: self.gen, + include_title: false, + })?; + match &self.items { + None => self.items = Some(schema), + Some(items) => { + if items != &schema { + self.items = Some(Schema::Bool(true)) + } + } + } + } + + Ok(()) + } + + fn end(self) -> Result { + let items = self.items.unwrap_or(Schema::Bool(true)); + Ok(SchemaObject { + instance_type: Some(InstanceType::Array.into()), + array: Some(Box::new(ArrayValidation { + items: Some(items.into()), + ..ArrayValidation::default() + })), + ..SchemaObject::default() + } + .into()) + } +} + +impl serde::ser::SerializeTuple for SerializeTuple<'_> { + type Ok = Schema; + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> + where + T: serde::Serialize, + { + let schema = value.serialize(Serializer { + gen: self.gen, + include_title: false, + })?; + self.items.push(schema); + Ok(()) + } + + fn end(self) -> Result { + let len = self.items.len().try_into().ok(); + let mut schema = SchemaObject { + instance_type: Some(InstanceType::Array.into()), + array: Some(Box::new(ArrayValidation { + items: Some(SingleOrVec::Vec(self.items)), + max_items: len, + min_items: len, + ..ArrayValidation::default() + })), + ..SchemaObject::default() + }; + + if !self.title.is_empty() { + schema.metadata().title = Some(self.title.to_owned()); + } + + Ok(schema.into()) + } +} + +impl serde::ser::SerializeTupleStruct for SerializeTuple<'_> { + type Ok = Schema; + type Error = Error; + + fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> + where + T: serde::Serialize, + { + serde::ser::SerializeTuple::serialize_element(self, value) + } + + fn end(self) -> Result { + serde::ser::SerializeTuple::end(self) + } +} + +impl serde::ser::SerializeMap for SerializeMap<'_> { + type Ok = Schema; + type Error = Error; + + fn serialize_key(&mut self, key: &T) -> Result<(), Self::Error> + where + T: serde::Serialize, + { + let json = serde_json::to_string(key).map_err(|e| Error(e.to_string()))?; + self.current_key = Some( + json.trim_start_matches('"') + .trim_end_matches('"') + .to_string(), + ); + + Ok(()) + } + + fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> + where + T: serde::Serialize, + { + let key = self.current_key.take().unwrap_or_default(); + let schema = value.serialize(Serializer { + gen: self.gen, + include_title: false, + })?; + self.properties.insert(key, schema); + + Ok(()) + } + + fn end(self) -> Result { + let mut schema = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + object: Some(Box::new(ObjectValidation { + properties: self.properties, + ..ObjectValidation::default() + })), + ..SchemaObject::default() + }; + + if !self.title.is_empty() { + schema.metadata().title = Some(self.title.to_owned()); + } + + Ok(schema.into()) + } +} + +impl serde::ser::SerializeStruct for SerializeMap<'_> { + type Ok = Schema; + type Error = Error; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> + where + T: serde::Serialize, + { + let prop_schema = value.serialize(Serializer { + gen: self.gen, + include_title: false, + })?; + self.properties.insert(key.to_string(), prop_schema); + + Ok(()) + } + + fn end(self) -> Result { + serde::ser::SerializeMap::end(self) + } +} diff --git a/schemars/tests/expected/from_value_2019_09.json b/schemars/tests/expected/from_value_2019_09.json new file mode 100644 index 00000000..77e8953b --- /dev/null +++ b/schemars/tests/expected/from_value_2019_09.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "MyStruct", + "examples": [ + { + "myBool": true, + "myInnerStruct": { + "my_empty_map": {}, + "my_empty_vec": [], + "my_map": { + "": 0.0 + }, + "my_tuple": [ + "💩", + 42 + ], + "my_vec": [ + "hello", + "world" + ] + }, + "myInt": 123, + "myNullableEnum": null + } + ], + "type": "object", + "properties": { + "myInt": { + "type": "integer", + "format": "int32" + }, + "myBool": { + "type": "boolean" + }, + "myNullableEnum": true, + "myInnerStruct": { + "type": "object", + "properties": { + "my_map": { + "type": "object", + "additionalProperties": { + "type": "number", + "format": "double" + } + }, + "my_vec": { + "type": "array", + "items": { + "type": "string" + } + }, + "my_empty_map": { + "type": "object", + "additionalProperties": true + }, + "my_empty_vec": { + "type": "array", + "items": true + }, + "my_tuple": { + "type": "array", + "items": [ + { + "type": "string", + "maxLength": 1, + "minLength": 1 + }, + { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + } + } + } +} \ No newline at end of file diff --git a/schemars/tests/expected/from_value_draft07.json b/schemars/tests/expected/from_value_draft07.json new file mode 100644 index 00000000..936eeaf7 --- /dev/null +++ b/schemars/tests/expected/from_value_draft07.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MyStruct", + "examples": [ + { + "myBool": true, + "myInnerStruct": { + "my_empty_map": {}, + "my_empty_vec": [], + "my_map": { + "": 0.0 + }, + "my_tuple": [ + "💩", + 42 + ], + "my_vec": [ + "hello", + "world" + ] + }, + "myInt": 123, + "myNullableEnum": null + } + ], + "type": "object", + "properties": { + "myInt": { + "type": "integer", + "format": "int32" + }, + "myBool": { + "type": "boolean" + }, + "myNullableEnum": true, + "myInnerStruct": { + "type": "object", + "properties": { + "my_map": { + "type": "object", + "additionalProperties": { + "type": "number", + "format": "double" + } + }, + "my_vec": { + "type": "array", + "items": { + "type": "string" + } + }, + "my_empty_map": { + "type": "object", + "additionalProperties": true + }, + "my_empty_vec": { + "type": "array", + "items": true + }, + "my_tuple": { + "type": "array", + "items": [ + { + "type": "string", + "maxLength": 1, + "minLength": 1 + }, + { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + } + } + } +} \ No newline at end of file diff --git a/schemars/tests/expected/from_value_openapi3.json b/schemars/tests/expected/from_value_openapi3.json new file mode 100644 index 00000000..52514e2f --- /dev/null +++ b/schemars/tests/expected/from_value_openapi3.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://spec.openapis.org/oas/3.0/schema/2019-04-02#/definitions/Schema", + "title": "MyStruct", + "type": "object", + "properties": { + "myInt": { + "type": "integer", + "format": "int32" + }, + "myBool": { + "type": "boolean" + }, + "myNullableEnum": { + "nullable": true + }, + "myInnerStruct": { + "type": "object", + "properties": { + "my_map": { + "type": "object", + "additionalProperties": { + "type": "number", + "format": "double" + } + }, + "my_vec": { + "type": "array", + "items": { + "type": "string" + } + }, + "my_empty_map": { + "type": "object", + "additionalProperties": true + }, + "my_empty_vec": { + "type": "array", + "items": {} + }, + "my_tuple": { + "type": "array", + "items": [ + { + "type": "string", + "maxLength": 1, + "minLength": 1 + }, + { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + } + } + }, + "example": { + "myBool": true, + "myInnerStruct": { + "my_empty_map": {}, + "my_empty_vec": [], + "my_map": { + "": 0.0 + }, + "my_tuple": [ + "💩", + 42 + ], + "my_vec": [ + "hello", + "world" + ] + }, + "myInt": 123, + "myNullableEnum": null + } +} \ No newline at end of file diff --git a/schemars/tests/from_value.rs b/schemars/tests/from_value.rs new file mode 100644 index 00000000..9e6943f5 --- /dev/null +++ b/schemars/tests/from_value.rs @@ -0,0 +1,77 @@ +mod util; +use std::collections::HashMap; + +use schemars::gen::SchemaSettings; +use serde::Serialize; +use util::*; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MyStruct { + pub my_int: i32, + pub my_bool: bool, + pub my_nullable_enum: Option, + pub my_inner_struct: MyInnerStruct, + #[serde(skip)] + pub skip: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_if_none: Option, +} + +#[derive(Serialize)] +pub struct MyInnerStruct { + pub my_map: HashMap, + pub my_vec: Vec<&'static str>, + pub my_empty_map: HashMap, + pub my_empty_vec: Vec<&'static str>, + pub my_tuple: (char, u8), +} + +#[derive(Serialize)] +pub enum MyEnum { + StringNewType(String), + StructVariant { floats: Vec }, +} + +fn make_value() -> MyStruct { + let mut value = MyStruct { + my_int: 123, + my_bool: true, + my_nullable_enum: None, + my_inner_struct: MyInnerStruct { + my_map: HashMap::new(), + my_vec: vec!["hello", "world"], + my_empty_map: HashMap::new(), + my_empty_vec: vec![], + my_tuple: ('💩', 42), + }, + skip: 123, + skip_if_none: None, + }; + value.my_inner_struct.my_map.insert(String::new(), 0.0); + value +} + +#[test] +fn schema_from_value_matches_draft07() -> TestResult { + let gen = SchemaSettings::draft07().into_generator(); + let actual = gen.into_root_schema_for_value(&make_value())?; + + test_schema(&actual, "from_value_draft07") +} + +#[test] +fn schema_from_value_matches_2019_09() -> TestResult { + let gen = SchemaSettings::draft2019_09().into_generator(); + let actual = gen.into_root_schema_for_value(&make_value())?; + + test_schema(&actual, "from_value_2019_09") +} + +#[test] +fn schema_from_value_matches_openapi3() -> TestResult { + let gen = SchemaSettings::openapi3().into_generator(); + let actual = gen.into_root_schema_for_value(&make_value())?; + + test_schema(&actual, "from_value_openapi3") +} diff --git a/schemars/tests/ui/schema_for_arg_value.rs b/schemars/tests/ui/schema_for_arg_value.rs new file mode 100644 index 00000000..b5ead754 --- /dev/null +++ b/schemars/tests/ui/schema_for_arg_value.rs @@ -0,0 +1,5 @@ +use schemars::schema_for; + +fn main() { + let _schema = schema_for!(123); +} diff --git a/schemars/tests/ui/schema_for_arg_value.stderr b/schemars/tests/ui/schema_for_arg_value.stderr new file mode 100644 index 00000000..c7879859 --- /dev/null +++ b/schemars/tests/ui/schema_for_arg_value.stderr @@ -0,0 +1,7 @@ +error: This argument to `schema_for!` is not a type - did you mean to use `schema_for_value!` instead? + --> $DIR/schema_for_arg_value.rs:4:19 + | +4 | let _schema = schema_for!(123); + | ^^^^^^^^^^^^^^^^ + | + = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/schemars/tests/util/mod.rs b/schemars/tests/util/mod.rs index fa191fe9..9f365d69 100644 --- a/schemars/tests/util/mod.rs +++ b/schemars/tests/util/mod.rs @@ -18,7 +18,7 @@ pub fn test_default_generated_schema(file: &str) -> TestResult { test_schema(&actual, file) } -fn test_schema(actual: &RootSchema, file: &str) -> TestResult { +pub fn test_schema(actual: &RootSchema, file: &str) -> TestResult { let expected_json = match fs::read_to_string(format!("tests/expected/{}.json", file)) { Ok(j) => j, Err(e) => { From cde1ba3a12b8a79f0ba475d814e5a8670c2dbcaf Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Tue, 23 Mar 2021 19:48:26 +0000 Subject: [PATCH 2/7] Don't run trybuild tests on rustc 1.37 --- .github/workflows/ci.yml | 10 +++++----- schemars/Cargo.toml | 6 ------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af30d0fc..6c444cb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,16 +14,16 @@ jobs: - nightly include: - rust: 1.37.0 - test_features: "--all-features" + test_args: "-- trybuild=SKIP" allow_failure: false - rust: stable - test_features: "--all-features" + test_args: "" allow_failure: false - rust: beta - test_features: "--all-features" + test_args: "" allow_failure: false - rust: nightly - test_features: "--all-features" + test_args: "" allow_failure: true fail-fast: false steps: @@ -38,7 +38,7 @@ jobs: continue-on-error: ${{ matrix.allow_failure }} working-directory: ./schemars - name: Run tests - run: cargo test --verbose ${{ matrix.test_features }} --no-fail-fast + run: cargo test --verbose --no-fail-fast --all-features ${{ matrix.test_args }} continue-on-error: ${{ matrix.allow_failure }} working-directory: ./schemars - name: Run derive tests diff --git a/schemars/Cargo.toml b/schemars/Cargo.toml index 2c5d7903..63198df5 100644 --- a/schemars/Cargo.toml +++ b/schemars/Cargo.toml @@ -43,8 +43,6 @@ impl_json_schema = ["derive"] # derive_json_schema will be removed in a later version derive_json_schema = ["impl_json_schema"] -ui_test = [] - [[test]] name = "chrono" required-features = ["chrono"] @@ -73,9 +71,5 @@ required-features = ["arrayvec"] name = "schema_for_schema" required-features = ["impl_json_schema"] -[[test]] -name = "ui" -required-features = ["ui_test"] - [package.metadata.docs.rs] all-features = true From a56deb0740ebf85a1db62ad8115248e8b67b4aa4 Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Wed, 24 Mar 2021 14:12:32 +0000 Subject: [PATCH 3/7] Use serde_json::Error as schema_for_value error type --- schemars/src/gen.rs | 11 ++++------- schemars/src/ser.rs | 23 ++++------------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/schemars/src/gen.rs b/schemars/src/gen.rs index 909d0536..c44662af 100644 --- a/schemars/src/gen.rs +++ b/schemars/src/gen.rs @@ -319,13 +319,12 @@ impl SchemaGenerator { pub fn root_schema_for_value( &mut self, value: &T, - ) -> Result { + ) -> Result { let mut schema = value .serialize(crate::ser::Serializer { gen: self, include_title: true, - }) - .map_err(|e| e.0)? + })? .into_object(); if let Ok(example) = serde_json::to_value(value) { @@ -346,17 +345,15 @@ impl SchemaGenerator { } // TODO document - // TODO consider using a different Error type, maybe just use serde_json::error::Error? pub fn into_root_schema_for_value( mut self, value: &T, - ) -> Result { + ) -> Result { let mut schema = value .serialize(crate::ser::Serializer { gen: &mut self, include_title: true, - }) - .map_err(|e| e.0)? + })? .into_object(); if let Ok(example) = serde_json::to_value(value) { diff --git a/schemars/src/ser.rs b/schemars/src/ser.rs index 2fbc7ff4..f70ae3e4 100644 --- a/schemars/src/ser.rs +++ b/schemars/src/ser.rs @@ -1,7 +1,7 @@ use crate::schema::*; use crate::JsonSchema; use crate::{gen::SchemaGenerator, Map}; -use serde_json::Value; +use serde_json::{Error, Value}; use std::{convert::TryInto, fmt::Display}; pub(crate) struct Serializer<'a> { @@ -27,23 +27,6 @@ pub(crate) struct SerializeMap<'a> { title: &'static str, } -#[derive(Debug)] -pub(crate) struct Error(pub(crate) String); - -impl serde::ser::Error for Error { - fn custom(msg: T) -> Self { - Self(msg.to_string()) - } -} - -impl std::error::Error for Error {} - -impl Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - macro_rules! forward_to_subschema_for { ($fn:ident, $ty:ty) => { fn $fn(self, _value: $ty) -> Result { @@ -442,7 +425,9 @@ impl serde::ser::SerializeMap for SerializeMap<'_> { where T: serde::Serialize, { - let json = serde_json::to_string(key).map_err(|e| Error(e.to_string()))?; + // FIXME this is too lenient - we should return an error if serde_json + // doesn't allow T to be a key of a map. + let json = serde_json::to_string(key)?; self.current_key = Some( json.trim_start_matches('"') .trim_end_matches('"') From 8d1102c9a664931101728573a02e1b5439bd9625 Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Wed, 24 Mar 2021 14:54:21 +0000 Subject: [PATCH 4/7] Update doc comments --- schemars/src/gen.rs | 12 ++++++++--- schemars/src/macros.rs | 46 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/schemars/src/gen.rs b/schemars/src/gen.rs index c44662af..72c17005 100644 --- a/schemars/src/gen.rs +++ b/schemars/src/gen.rs @@ -1,7 +1,7 @@ /*! JSON Schema generator and settings. -This module is useful if you want more control over how the schema generated then the [`schema_for!`] macro gives you. +This module is useful if you want more control over how the schema generated than the [`schema_for!`] macro gives you. There are two main types in this module:two main types in this module: * [`SchemaSettings`], which defines what JSON Schema features should be used when generating schemas (for example, how `Option`s should be represented). * [`SchemaGenerator`], which manages the generation of a schema document. @@ -315,7 +315,10 @@ impl SchemaGenerator { root } - // TODO document + /// Generates a root JSON Schema for the given example value. + /// + /// If the value implements [`JsonSchema`](crate::JsonSchema), then prefer using the [`root_schema_for()`](Self::root_schema_for()) + /// function which will generally produce a more precise schema, particularly when the value contains any enums. pub fn root_schema_for_value( &mut self, value: &T, @@ -344,7 +347,10 @@ impl SchemaGenerator { Ok(root) } - // TODO document + /// Consumes `self` and generates a root JSON Schema for the given example value. + /// + /// If the value implements [`JsonSchema`](crate::JsonSchema), then prefer using the [`into_root_schema_for()!`](Self::into_root_schema_for()) + /// function which will generally produce a more precise schema, particularly when the value contains any enums. pub fn into_root_schema_for_value( mut self, value: &T, diff --git a/schemars/src/macros.rs b/schemars/src/macros.rs index 11ae7c69..18a6810c 100644 --- a/schemars/src/macros.rs +++ b/schemars/src/macros.rs @@ -13,6 +13,30 @@ /// /// let my_schema = schema_for!(MyStruct); /// ``` +#[cfg(doc)] +#[macro_export] +macro_rules! schema_for { + ($type:ty) => { + $crate::gen::SchemaGenerator::default().into_root_schema_for::<$type>() + }; +} + +/// Generates a [`RootSchema`](crate::schema::RootSchema) for the given type using default settings. +/// +/// The type must implement [`JsonSchema`](crate::JsonSchema). +/// +/// # Example +/// ``` +/// use schemars::{schema_for, JsonSchema}; +/// +/// #[derive(JsonSchema)] +/// struct MyStruct { +/// foo: i32, +/// } +/// +/// let my_schema = schema_for!(MyStruct); +/// ``` +#[cfg(not(doc))] #[macro_export] macro_rules! schema_for { ($type:ty) => { @@ -23,7 +47,27 @@ macro_rules! schema_for { }; } -// TODO document +/// Generates a [`RootSchema`](crate::schema::RootSchema) for the given example value using default settings. +/// +/// The value must implement [`Serialize`](serde::Serialize). If the value also implements [`JsonSchema`](crate::JsonSchema), +/// then prefer using the [`schema_for!`](schema_for) macro which will generally produce a more precise schema, +/// particularly when the value contains any enums. +/// +/// If the `Serialize` implementation of the value decides to fail, this macro will panic. +/// For a non-panicking alternative, create a [`SchemaGenerator`](crate::gen::SchemaGenerator) and use +/// its [`into_root_schema_for_value`](crate::gen::SchemaGenerator::into_root_schema_for_value) method. +/// +/// # Example +/// ``` +/// use schemars::schema_for_value; +/// +/// #[derive(serde::Serialize)] +/// struct MyStruct { +/// foo: i32, +/// } +/// +/// let my_schema = schema_for_value!(MyStruct { foo: 123 }); +/// ``` #[macro_export] macro_rules! schema_for_value { ($value:expr) => { From c0213268712d3aeddc5817ac8c2c8f1b27a078ab Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Wed, 24 Mar 2021 16:02:37 +0000 Subject: [PATCH 5/7] Update readme --- README.md | 62 +++++++++++++++++ docs/3-generating.md | 4 +- docs/_includes/examples/from_value.rs | 2 +- .../_includes/examples/from_value.schema.json | 4 +- schemars/examples/from_value.rs | 2 +- schemars/examples/from_value.schema.json | 4 +- schemars/src/lib.rs | 66 ++++++++++++++++++- 7 files changed, 137 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 324b26db..0ce37984 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,68 @@ println!("{}", serde_json::to_string_pretty(&schema).unwrap()); `#[serde(...)]` attributes can be overriden using `#[schemars(...)]` attributes, which behave identically (e.g. `#[schemars(rename_all = "camelCase")]`). You may find this useful if you want to change the generated schema without affecting Serde's behaviour, or if you're just not using Serde. +### Schema from Example Values + +If you want a schema for a type that can't/doesn't implement `JsonSchema`, but does implement `serde::Serialize`, then you can generate a JSON schema from a value of that type. However, this schema will generally be less precise than if the type implemented `JsonSchema` - particularly when it involves enums, since schemars will not make any assumptions about the structure of an enum based on a single variant. + +```rust +use schemars::schema_for_value; +use serde::Serialize; + +#[derive(Serialize)] +pub struct MyStruct { + pub my_int: i32, + pub my_bool: bool, + pub my_nullable_enum: Option, +} + +#[derive(Serialize)] +pub enum MyEnum { + StringNewType(String), + StructVariant { floats: Vec }, +} + +fn main() { + let schema = schema_for_value!(MyStruct { + my_int: 123, + my_bool: true, + my_nullable_enum: Some(MyEnum::StringNewType("foo".to_string())) + }); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); +} +``` + +
+Click to see the output JSON schema... + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MyStruct", + "examples": [ + { + "my_bool": true, + "my_int": 123, + "my_nullable_enum": { + "StringNewType": "foo" + } + } + ], + "type": "object", + "properties": { + "my_bool": { + "type": "boolean" + }, + "my_int": { + "type": "integer", + "format": "int32" + }, + "my_nullable_enum": true + } +} +``` +
+ ## Feature Flags - `derive` (enabled by default) - provides `#[derive(JsonSchema)]` macro - `impl_json_schema` - implements `JsonSchema` for Schemars types themselves diff --git a/docs/3-generating.md b/docs/3-generating.md index f5c2d832..29222f06 100644 --- a/docs/3-generating.md +++ b/docs/3-generating.md @@ -20,6 +20,8 @@ If you want more control over how the schema is generated, you can use the [`gen See the API documentation for more info on how to use those types for custom schema generation. - diff --git a/docs/_includes/examples/from_value.rs b/docs/_includes/examples/from_value.rs index 114ba257..9d509613 100644 --- a/docs/_includes/examples/from_value.rs +++ b/docs/_includes/examples/from_value.rs @@ -18,7 +18,7 @@ fn main() { let schema = schema_for_value!(MyStruct { my_int: 123, my_bool: true, - my_nullable_enum: None + my_nullable_enum: Some(MyEnum::StringNewType("foo".to_string())) }); println!("{}", serde_json::to_string_pretty(&schema).unwrap()); } diff --git a/docs/_includes/examples/from_value.schema.json b/docs/_includes/examples/from_value.schema.json index ca5f2803..e7ca4577 100644 --- a/docs/_includes/examples/from_value.schema.json +++ b/docs/_includes/examples/from_value.schema.json @@ -5,7 +5,9 @@ { "my_bool": true, "my_int": 123, - "my_nullable_enum": null + "my_nullable_enum": { + "StringNewType": "foo" + } } ], "type": "object", diff --git a/schemars/examples/from_value.rs b/schemars/examples/from_value.rs index 114ba257..9d509613 100644 --- a/schemars/examples/from_value.rs +++ b/schemars/examples/from_value.rs @@ -18,7 +18,7 @@ fn main() { let schema = schema_for_value!(MyStruct { my_int: 123, my_bool: true, - my_nullable_enum: None + my_nullable_enum: Some(MyEnum::StringNewType("foo".to_string())) }); println!("{}", serde_json::to_string_pretty(&schema).unwrap()); } diff --git a/schemars/examples/from_value.schema.json b/schemars/examples/from_value.schema.json index ca5f2803..e7ca4577 100644 --- a/schemars/examples/from_value.schema.json +++ b/schemars/examples/from_value.schema.json @@ -5,7 +5,9 @@ { "my_bool": true, "my_int": 123, - "my_nullable_enum": null + "my_nullable_enum": { + "StringNewType": "foo" + } } ], "type": "object", diff --git a/schemars/src/lib.rs b/schemars/src/lib.rs index f31eb17f..e77558b7 100644 --- a/schemars/src/lib.rs +++ b/schemars/src/lib.rs @@ -195,9 +195,71 @@ println!("{}", serde_json::to_string_pretty(&schema).unwrap()); `#[serde(...)]` attributes can be overriden using `#[schemars(...)]` attributes, which behave identically (e.g. `#[schemars(rename_all = "camelCase")]`). You may find this useful if you want to change the generated schema without affecting Serde's behaviour, or if you're just not using Serde. +### Schema from Example Values + +If you want a schema for a type that can't/doesn't implement `JsonSchema`, but does implement `serde::Serialize`, then you can generate a JSON schema from a value of that type. However, this schema will generally be less precise than if the type implemented `JsonSchema` - particularly when it involves enums, since schemars will not make any assumptions about the structure of an enum based on a single variant. + +```rust +use schemars::schema_for_value; +use serde::Serialize; + +#[derive(Serialize)] +pub struct MyStruct { + pub my_int: i32, + pub my_bool: bool, + pub my_nullable_enum: Option, +} + +#[derive(Serialize)] +pub enum MyEnum { + StringNewType(String), + StructVariant { floats: Vec }, +} + +fn main() { + let schema = schema_for_value!(MyStruct { + my_int: 123, + my_bool: true, + my_nullable_enum: Some(MyEnum::StringNewType("foo".to_string())) + }); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); +} +``` + +
+Click to see the output JSON schema... + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MyStruct", + "examples": [ + { + "my_bool": true, + "my_int": 123, + "my_nullable_enum": { + "StringNewType": "foo" + } + } + ], + "type": "object", + "properties": { + "my_bool": { + "type": "boolean" + }, + "my_int": { + "type": "integer", + "format": "int32" + }, + "my_nullable_enum": true + } +} +``` +
+ ## Feature Flags -- `derive` (enabled by default) - provides `#[derive(JsonSchema)]` macro. -- `impl_json_schema` - implements `JsonSchema` for Schemars types themselves. +- `derive` (enabled by default) - provides `#[derive(JsonSchema)]` macro +- `impl_json_schema` - implements `JsonSchema` for Schemars types themselves - `preserve_order` - keep the order of struct fields in `Schema` and `SchemaObject` ## Optional Dependencies From 75ac4c449d7c9549dc2d276799ae86f739936de6 Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Thu, 25 Mar 2021 18:22:18 +0000 Subject: [PATCH 6/7] Restore ui_test feature to fix rust 1.37 build --- schemars/Cargo.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/schemars/Cargo.toml b/schemars/Cargo.toml index 5667915b..c2257902 100644 --- a/schemars/Cargo.toml +++ b/schemars/Cargo.toml @@ -43,6 +43,8 @@ impl_json_schema = ["derive"] # derive_json_schema will be removed in a later version derive_json_schema = ["impl_json_schema"] +ui_test = [] + [[test]] name = "chrono" required-features = ["chrono"] @@ -71,5 +73,9 @@ required-features = ["arrayvec"] name = "schema_for_schema" required-features = ["impl_json_schema"] +[[test]] +name = "ui" +required-features = ["ui_test"] + [package.metadata.docs.rs] all-features = true From b14c1460e48181ebb4aaa540f6ef8ebc604e4204 Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Thu, 25 Mar 2021 18:26:58 +0000 Subject: [PATCH 7/7] Fix CI --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9822bd28..be571f77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,13 +18,13 @@ jobs: test_features: "--features impl_json_schema,chrono,indexmap,either,uuid,smallvec,arrayvec" allow_failure: false - rust: stable - test_args: "" + test_features: "--all-features" allow_failure: false - rust: beta - test_args: "" + test_features: "--all-features" allow_failure: false - rust: nightly - test_args: "" + test_features: "--all-features" allow_failure: true fail-fast: false steps: @@ -39,7 +39,7 @@ jobs: continue-on-error: ${{ matrix.allow_failure }} working-directory: ./schemars - name: Run tests - run: cargo test --verbose --no-fail-fast --all-features ${{ matrix.test_args }} + run: cargo test --verbose ${{ matrix.test_features }} --no-fail-fast continue-on-error: ${{ matrix.allow_failure }} working-directory: ./schemars - name: Run derive tests