diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be571f77..90f4f720 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: run: cargo check --verbose --no-default-features continue-on-error: ${{ matrix.allow_failure }} working-directory: ./schemars + - if: matrix.rust == '1.37.0' + run: cargo update -p indexmap --precise 1.6.2 - name: Run tests run: cargo test --verbose ${{ matrix.test_features }} --no-fail-fast continue-on-error: ${{ matrix.allow_failure }} diff --git a/docs/_includes/examples/schemars_attrs.rs b/docs/_includes/examples/schemars_attrs.rs index f830e9f0..cd69b527 100644 --- a/docs/_includes/examples/schemars_attrs.rs +++ b/docs/_includes/examples/schemars_attrs.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; #[schemars(rename_all = "camelCase", deny_unknown_fields)] pub struct MyStruct { #[serde(rename = "thisIsOverridden")] - #[schemars(rename = "myNumber")] + #[schemars(rename = "myNumber", range(min = 1, max = 10))] pub my_int: i32, pub my_bool: bool, #[schemars(default)] @@ -15,8 +15,11 @@ pub struct MyStruct { #[derive(Deserialize, Serialize, JsonSchema)] #[schemars(untagged)] pub enum MyEnum { - StringNewType(String), - StructVariant { floats: Vec }, + StringNewType(#[schemars(phone)] String), + StructVariant { + #[schemars(length(min = 1, max = 100))] + floats: Vec, + }, } fn main() { diff --git a/docs/_includes/examples/schemars_attrs.schema.json b/docs/_includes/examples/schemars_attrs.schema.json index d0441932..958cb6bb 100644 --- a/docs/_includes/examples/schemars_attrs.schema.json +++ b/docs/_includes/examples/schemars_attrs.schema.json @@ -23,7 +23,9 @@ }, "myNumber": { "type": "integer", - "format": "int32" + "format": "int32", + "maximum": 10.0, + "minimum": 1.0 } }, "additionalProperties": false, @@ -31,7 +33,8 @@ "MyEnum": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "phone" }, { "type": "object", @@ -44,7 +47,9 @@ "items": { "type": "number", "format": "float" - } + }, + "maxItems": 100, + "minItems": 1 } } } diff --git a/docs/_includes/examples/validate.rs b/docs/_includes/examples/validate.rs new file mode 100644 index 00000000..41169765 --- /dev/null +++ b/docs/_includes/examples/validate.rs @@ -0,0 +1,24 @@ +use schemars::{schema_for, JsonSchema}; + +#[derive(JsonSchema)] +pub struct MyStruct { + #[validate(range(min = 1, max = 10))] + pub my_int: i32, + pub my_bool: bool, + #[validate(required)] + pub my_nullable_enum: Option, +} + +#[derive(JsonSchema)] +pub enum MyEnum { + StringNewType(#[validate(phone)] String), + StructVariant { + #[validate(length(min = 1, max = 100))] + floats: Vec, + }, +} + +fn main() { + let schema = schema_for!(MyStruct); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); +} diff --git a/docs/_includes/examples/validate.schema.json b/docs/_includes/examples/validate.schema.json new file mode 100644 index 00000000..e8ed35e6 --- /dev/null +++ b/docs/_includes/examples/validate.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MyStruct", + "type": "object", + "required": [ + "my_bool", + "my_int", + "my_nullable_enum" + ], + "properties": { + "my_bool": { + "type": "boolean" + }, + "my_int": { + "type": "integer", + "format": "int32", + "maximum": 10.0, + "minimum": 1.0 + }, + "my_nullable_enum": { + "anyOf": [ + { + "type": "object", + "required": [ + "StringNewType" + ], + "properties": { + "StringNewType": { + "type": "string", + "format": "phone" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "StructVariant" + ], + "properties": { + "StructVariant": { + "type": "object", + "required": [ + "floats" + ], + "properties": { + "floats": { + "type": "array", + "items": { + "type": "number", + "format": "float" + }, + "maxItems": 100, + "minItems": 1 + } + } + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/schemars/examples/schemars_attrs.rs b/schemars/examples/schemars_attrs.rs index f830e9f0..cd69b527 100644 --- a/schemars/examples/schemars_attrs.rs +++ b/schemars/examples/schemars_attrs.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; #[schemars(rename_all = "camelCase", deny_unknown_fields)] pub struct MyStruct { #[serde(rename = "thisIsOverridden")] - #[schemars(rename = "myNumber")] + #[schemars(rename = "myNumber", range(min = 1, max = 10))] pub my_int: i32, pub my_bool: bool, #[schemars(default)] @@ -15,8 +15,11 @@ pub struct MyStruct { #[derive(Deserialize, Serialize, JsonSchema)] #[schemars(untagged)] pub enum MyEnum { - StringNewType(String), - StructVariant { floats: Vec }, + StringNewType(#[schemars(phone)] String), + StructVariant { + #[schemars(length(min = 1, max = 100))] + floats: Vec, + }, } fn main() { diff --git a/schemars/examples/schemars_attrs.schema.json b/schemars/examples/schemars_attrs.schema.json index d0441932..958cb6bb 100644 --- a/schemars/examples/schemars_attrs.schema.json +++ b/schemars/examples/schemars_attrs.schema.json @@ -23,7 +23,9 @@ }, "myNumber": { "type": "integer", - "format": "int32" + "format": "int32", + "maximum": 10.0, + "minimum": 1.0 } }, "additionalProperties": false, @@ -31,7 +33,8 @@ "MyEnum": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "phone" }, { "type": "object", @@ -44,7 +47,9 @@ "items": { "type": "number", "format": "float" - } + }, + "maxItems": 100, + "minItems": 1 } } } diff --git a/schemars/examples/validate.rs b/schemars/examples/validate.rs new file mode 100644 index 00000000..41169765 --- /dev/null +++ b/schemars/examples/validate.rs @@ -0,0 +1,24 @@ +use schemars::{schema_for, JsonSchema}; + +#[derive(JsonSchema)] +pub struct MyStruct { + #[validate(range(min = 1, max = 10))] + pub my_int: i32, + pub my_bool: bool, + #[validate(required)] + pub my_nullable_enum: Option, +} + +#[derive(JsonSchema)] +pub enum MyEnum { + StringNewType(#[validate(phone)] String), + StructVariant { + #[validate(length(min = 1, max = 100))] + floats: Vec, + }, +} + +fn main() { + let schema = schema_for!(MyStruct); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); +} diff --git a/schemars/examples/validate.schema.json b/schemars/examples/validate.schema.json new file mode 100644 index 00000000..e8ed35e6 --- /dev/null +++ b/schemars/examples/validate.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MyStruct", + "type": "object", + "required": [ + "my_bool", + "my_int", + "my_nullable_enum" + ], + "properties": { + "my_bool": { + "type": "boolean" + }, + "my_int": { + "type": "integer", + "format": "int32", + "maximum": 10.0, + "minimum": 1.0 + }, + "my_nullable_enum": { + "anyOf": [ + { + "type": "object", + "required": [ + "StringNewType" + ], + "properties": { + "StringNewType": { + "type": "string", + "format": "phone" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "StructVariant" + ], + "properties": { + "StructVariant": { + "type": "object", + "required": [ + "floats" + ], + "properties": { + "floats": { + "type": "array", + "items": { + "type": "number", + "format": "float" + }, + "maxItems": 100, + "minItems": 1 + } + } + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/schemars/src/_private.rs b/schemars/src/_private.rs index 4d1c31f2..b914699f 100644 --- a/schemars/src/_private.rs +++ b/schemars/src/_private.rs @@ -4,9 +4,13 @@ use crate::schema::{Metadata, Schema, SchemaObject}; use crate::JsonSchema; // Helper for generating schemas for flattened `Option` fields. -pub fn json_schema_for_flatten(gen: &mut SchemaGenerator) -> Schema { +pub fn json_schema_for_flatten( + gen: &mut SchemaGenerator, + required: bool, +) -> Schema { let mut schema = T::_schemars_private_non_optional_json_schema(gen); - if T::_schemars_private_is_option() { + + if T::_schemars_private_is_option() && !required { if let Schema::Object(SchemaObject { object: Some(ref mut object_validation), .. @@ -15,35 +19,16 @@ pub fn json_schema_for_flatten(gen: &mut SchemaGenerator object_validation.required.clear(); } } - schema -} -// Helper for generating schemas for `Option` fields. -pub fn add_schema_as_property( - gen: &mut SchemaGenerator, - parent: &mut SchemaObject, - name: String, - metadata: Option, - required: bool, -) { - let mut schema = gen.subschema_for::(); - schema = apply_metadata(schema, metadata); - - let object = parent.object(); - if required && !T::_schemars_private_is_option() { - object.required.insert(name.clone()); - } - object.properties.insert(name, schema); + schema } -pub fn apply_metadata(schema: Schema, metadata: Option) -> Schema { - match metadata { - None => schema, - Some(ref metadata) if *metadata == Metadata::default() => schema, - Some(metadata) => { - let mut schema_obj = schema.into_object(); - schema_obj.metadata = Some(Box::new(metadata)).merge(schema_obj.metadata); - Schema::Object(schema_obj) - } +pub fn apply_metadata(schema: Schema, metadata: Metadata) -> Schema { + if metadata == Metadata::default() { + schema + } else { + let mut schema_obj = schema.into_object(); + schema_obj.metadata = Some(Box::new(metadata)).merge(schema_obj.metadata); + Schema::Object(schema_obj) } } diff --git a/schemars/src/schema.rs b/schemars/src/schema.rs index a4c6e32b..01fce865 100644 --- a/schemars/src/schema.rs +++ b/schemars/src/schema.rs @@ -9,6 +9,7 @@ use crate::JsonSchema; use crate::{Map, Set}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::ops::Deref; /// A JSON Schema. #[allow(clippy::large_enum_variant)] @@ -191,7 +192,13 @@ where macro_rules! get_or_insert_default_fn { ($name:ident, $ret:ty) => { get_or_insert_default_fn!( - concat!("Returns a mutable reference to this schema's [`", stringify!($ret), "`](#structfield.", stringify!($name), "), creating it if it was `None`."), + concat!( + "Returns a mutable reference to this schema's [`", + stringify!($ret), + "`](#structfield.", + stringify!($name), + "), creating it if it was `None`." + ), $name, $ret ); @@ -224,6 +231,17 @@ impl SchemaObject { self.reference.is_some() } + /// Returns `true` if `self` accepts values of the given type, according to the [`instance_type`] field. + /// + /// This is a basic check that always returns `true` if no `instance_type` is specified on the schema, + /// and does not check any subschemas. Because of this, both `{}` and `{"not": {}}` accept any type according + /// to this method. + pub fn has_type(&self, ty: InstanceType) -> bool { + self.instance_type + .as_ref() + .map_or(true, |x| x.contains(&ty)) + } + get_or_insert_default_fn!(metadata, Metadata); get_or_insert_default_fn!(subschemas, SubschemaValidation); get_or_insert_default_fn!(number, NumberValidation); @@ -506,3 +524,28 @@ impl From> for SingleOrVec { SingleOrVec::Vec(vec) } } + +impl SingleOrVec { + /// Returns `true` if `self` is either a `Single` equal to `x`, or a `Vec` containing `x`. + /// + /// # Examples + /// + /// ``` + /// use schemars::schema::SingleOrVec; + /// + /// let s = SingleOrVec::from(10); + /// assert!(s.contains(&10)); + /// assert!(!s.contains(&20)); + /// + /// let v = SingleOrVec::from(vec![10, 20]); + /// assert!(v.contains(&10)); + /// assert!(v.contains(&20)); + /// assert!(!v.contains(&30)); + /// ``` + pub fn contains(&self, x: &T) -> bool { + match self { + SingleOrVec::Single(s) => s.deref() == x, + SingleOrVec::Vec(v) => v.contains(x), + } + } +} diff --git a/schemars/tests/expected/macro_built_enum.json b/schemars/tests/expected/macro_built_enum.json index 8564ef70..8a14a4b3 100644 --- a/schemars/tests/expected/macro_built_enum.json +++ b/schemars/tests/expected/macro_built_enum.json @@ -17,7 +17,16 @@ ], "definitions": { "InnerStruct": { - "type": "object" + "type": "object", + "required": [ + "x" + ], + "properties": { + "x": { + "type": "integer", + "format": "int32" + } + } } } } \ No newline at end of file diff --git a/schemars/tests/expected/validate.json b/schemars/tests/expected/validate.json new file mode 100644 index 00000000..d4a14e3f --- /dev/null +++ b/schemars/tests/expected/validate.json @@ -0,0 +1,104 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Struct", + "type": "object", + "required": [ + "contains_str1", + "contains_str2", + "email_address", + "homepage", + "map_contains", + "min_max", + "min_max2", + "non_empty_str", + "non_empty_str2", + "pair", + "regex_str1", + "regex_str2", + "regex_str3", + "required_option", + "tel", + "x" + ], + "properties": { + "min_max": { + "type": "number", + "format": "float", + "maximum": 100.0, + "minimum": 0.01 + }, + "min_max2": { + "type": "number", + "format": "float", + "maximum": 1000.0, + "minimum": 1.0 + }, + "regex_str1": { + "type": "string", + "pattern": "^[Hh]ello\\b" + }, + "regex_str2": { + "type": "string", + "pattern": "^[Hh]ello\\b" + }, + "regex_str3": { + "type": "string", + "pattern": "^\\d+$" + }, + "contains_str1": { + "type": "string", + "pattern": "substring\\.\\.\\." + }, + "contains_str2": { + "type": "string", + "pattern": "substring\\.\\.\\." + }, + "email_address": { + "type": "string", + "format": "email" + }, + "tel": { + "type": "string", + "format": "phone" + }, + "homepage": { + "type": "string", + "format": "uri" + }, + "non_empty_str": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "non_empty_str2": { + "type": "string", + "maxLength": 1000, + "minLength": 1 + }, + "pair": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "maxItems": 2, + "minItems": 2 + }, + "map_contains": { + "type": "object", + "required": [ + "map_key" + ], + "additionalProperties": { + "type": "null" + } + }, + "required_option": { + "type": "boolean" + }, + "x": { + "type": "integer", + "format": "int32" + } + } +} \ No newline at end of file diff --git a/schemars/tests/expected/validate_newtype.json b/schemars/tests/expected/validate_newtype.json new file mode 100644 index 00000000..796aecde --- /dev/null +++ b/schemars/tests/expected/validate_newtype.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NewType", + "type": "integer", + "format": "uint8", + "maximum": 10.0, + "minimum": 0.0 +} \ No newline at end of file diff --git a/schemars/tests/expected/validate_schemars_attrs.json b/schemars/tests/expected/validate_schemars_attrs.json new file mode 100644 index 00000000..d4a14e3f --- /dev/null +++ b/schemars/tests/expected/validate_schemars_attrs.json @@ -0,0 +1,104 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Struct", + "type": "object", + "required": [ + "contains_str1", + "contains_str2", + "email_address", + "homepage", + "map_contains", + "min_max", + "min_max2", + "non_empty_str", + "non_empty_str2", + "pair", + "regex_str1", + "regex_str2", + "regex_str3", + "required_option", + "tel", + "x" + ], + "properties": { + "min_max": { + "type": "number", + "format": "float", + "maximum": 100.0, + "minimum": 0.01 + }, + "min_max2": { + "type": "number", + "format": "float", + "maximum": 1000.0, + "minimum": 1.0 + }, + "regex_str1": { + "type": "string", + "pattern": "^[Hh]ello\\b" + }, + "regex_str2": { + "type": "string", + "pattern": "^[Hh]ello\\b" + }, + "regex_str3": { + "type": "string", + "pattern": "^\\d+$" + }, + "contains_str1": { + "type": "string", + "pattern": "substring\\.\\.\\." + }, + "contains_str2": { + "type": "string", + "pattern": "substring\\.\\.\\." + }, + "email_address": { + "type": "string", + "format": "email" + }, + "tel": { + "type": "string", + "format": "phone" + }, + "homepage": { + "type": "string", + "format": "uri" + }, + "non_empty_str": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "non_empty_str2": { + "type": "string", + "maxLength": 1000, + "minLength": 1 + }, + "pair": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "maxItems": 2, + "minItems": 2 + }, + "map_contains": { + "type": "object", + "required": [ + "map_key" + ], + "additionalProperties": { + "type": "null" + } + }, + "required_option": { + "type": "boolean" + }, + "x": { + "type": "integer", + "format": "int32" + } + } +} \ No newline at end of file diff --git a/schemars/tests/expected/validate_tuple.json b/schemars/tests/expected/validate_tuple.json new file mode 100644 index 00000000..8ab6eaa6 --- /dev/null +++ b/schemars/tests/expected/validate_tuple.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Tuple", + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint8", + "maximum": 10.0, + "minimum": 0.0 + }, + { + "type": "boolean" + } + ], + "maxItems": 2, + "minItems": 2 +} \ No newline at end of file diff --git a/schemars/tests/macro.rs b/schemars/tests/macro.rs index 9991494c..ca7dee8f 100644 --- a/schemars/tests/macro.rs +++ b/schemars/tests/macro.rs @@ -56,7 +56,9 @@ build_enum!( #[derive(Debug, JsonSchema)] OuterEnum { #[derive(Debug, JsonSchema)] - InnerStruct {} + InnerStruct { + x: i32 + } } ); diff --git a/schemars/tests/ui/invalid_attrs.stderr b/schemars/tests/ui/invalid_attrs.stderr index fa3a4f46..48238591 100644 --- a/schemars/tests/ui/invalid_attrs.stderr +++ b/schemars/tests/ui/invalid_attrs.stderr @@ -22,7 +22,7 @@ error: duplicate serde attribute `deny_unknown_fields` 8 | #[schemars(default = 0, foo, deny_unknown_fields, deny_unknown_fields)] | ^^^^^^^^^^^^^^^^^^^ -error: unknown schemars container attribute `foo` +error: unknown schemars attribute `foo` --> $DIR/invalid_attrs.rs:8:25 | 8 | #[schemars(default = 0, foo, deny_unknown_fields, deny_unknown_fields)] diff --git a/schemars/tests/ui/invalid_validation_attrs.rs b/schemars/tests/ui/invalid_validation_attrs.rs new file mode 100644 index 00000000..be843625 --- /dev/null +++ b/schemars/tests/ui/invalid_validation_attrs.rs @@ -0,0 +1,35 @@ +use schemars::JsonSchema; + +#[derive(JsonSchema)] +pub struct Struct1(#[validate(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + +#[derive(JsonSchema)] +pub struct Struct2(#[schemars(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + +#[derive(JsonSchema)] +pub struct Struct3( + #[validate( + regex = "foo", + contains = "bar", + regex(path = "baz"), + phone, + email, + url + )] + String, +); + +#[derive(JsonSchema)] +pub struct Struct4( + #[schemars( + regex = "foo", + contains = "bar", + regex(path = "baz"), + phone, + email, + url + )] + String, +); + +fn main() {} diff --git a/schemars/tests/ui/invalid_validation_attrs.stderr b/schemars/tests/ui/invalid_validation_attrs.stderr new file mode 100644 index 00000000..933fd66f --- /dev/null +++ b/schemars/tests/ui/invalid_validation_attrs.stderr @@ -0,0 +1,59 @@ +error: expected validate regex attribute to be a string: `regex = "..."` + --> $DIR/invalid_validation_attrs.rs:4:39 + | +4 | pub struct Struct1(#[validate(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + | ^ + +error: unknown schemars attribute `foo` + --> $DIR/invalid_validation_attrs.rs:7:42 + | +7 | pub struct Struct2(#[schemars(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + | ^^^ + +error: expected schemars regex attribute to be a string: `regex = "..."` + --> $DIR/invalid_validation_attrs.rs:7:39 + | +7 | pub struct Struct2(#[schemars(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + | ^ + +error: schemars attribute cannot contain both `equal` and `min` + --> $DIR/invalid_validation_attrs.rs:7:63 + | +7 | pub struct Struct2(#[schemars(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + | ^^^^^ + +error: unknown item in schemars length attribute + --> $DIR/invalid_validation_attrs.rs:7:74 + | +7 | pub struct Struct2(#[schemars(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + | ^^^ + +error: schemars attribute cannot contain both `contains` and `regex` + --> $DIR/invalid_validation_attrs.rs:26:9 + | +26 | contains = "bar", + | ^^^^^^^^ + +error: duplicate schemars attribute `regex` + --> $DIR/invalid_validation_attrs.rs:27:9 + | +27 | regex(path = "baz"), + | ^^^^^ + +error: schemars attribute cannot contain both `phone` and `email` + --> $DIR/invalid_validation_attrs.rs:29:9 + | +29 | email, + | ^^^^^ + +error: schemars attribute cannot contain both `phone` and `url` + --> $DIR/invalid_validation_attrs.rs:30:9 + | +30 | url + | ^^^ + +error[E0425]: cannot find value `foo` in this scope + --> $DIR/invalid_validation_attrs.rs:12:17 + | +12 | regex = "foo", + | ^^^^^ not found in this scope diff --git a/schemars/tests/ui/repr_missing.stderr b/schemars/tests/ui/repr_missing.stderr index 495c1778..a7016b2d 100644 --- a/schemars/tests/ui/repr_missing.stderr +++ b/schemars/tests/ui/repr_missing.stderr @@ -4,4 +4,4 @@ error: JsonSchema_repr: missing #[repr(...)] attribute 3 | #[derive(JsonSchema_repr)] | ^^^^^^^^^^^^^^^ | - = note: this error originates in a derive macro (in Nightly builds, run with -Z macro-backtrace for more info) + = note: this error originates in the derive macro `JsonSchema_repr` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/schemars/tests/ui/schema_for_arg_value.stderr b/schemars/tests/ui/schema_for_arg_value.stderr index c7879859..a3163065 100644 --- a/schemars/tests/ui/schema_for_arg_value.stderr +++ b/schemars/tests/ui/schema_for_arg_value.stderr @@ -4,4 +4,4 @@ error: This argument to `schema_for!` is not a type - did you mean to use `schem 4 | let _schema = schema_for!(123); | ^^^^^^^^^^^^^^^^ | - = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info) + = note: this error originates in the macro `schema_for` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/schemars/tests/validate.rs b/schemars/tests/validate.rs new file mode 100644 index 00000000..c2060e0a --- /dev/null +++ b/schemars/tests/validate.rs @@ -0,0 +1,120 @@ +mod util; +use schemars::JsonSchema; +use std::collections::HashMap; +use util::*; + +// In real code, this would typically be a Regex, potentially created in a `lazy_static!`. +static STARTS_WITH_HELLO: &'static str = r"^[Hh]ello\b"; + +const MIN: u32 = 1; +const MAX: u32 = 1000; + +#[derive(Debug, JsonSchema)] +pub struct Struct { + #[validate(range(min = 0.01, max = 100))] + min_max: f32, + #[validate(range(min = "MIN", max = "MAX"))] + min_max2: f32, + #[validate(regex = "STARTS_WITH_HELLO")] + regex_str1: String, + #[validate(regex(path = "STARTS_WITH_HELLO", code = "foo"))] + regex_str2: String, + #[validate(regex(pattern = r"^\d+$"))] + regex_str3: String, + #[validate(contains = "substring...")] + contains_str1: String, + #[validate(contains(pattern = "substring...", message = "bar"))] + contains_str2: String, + #[validate(email)] + email_address: String, + #[validate(phone)] + tel: String, + #[validate(url)] + homepage: String, + #[validate(length(min = 1, max = 100))] + non_empty_str: String, + #[validate(length(min = "MIN", max = "MAX"))] + non_empty_str2: String, + #[validate(length(equal = 2))] + pair: Vec, + #[validate(contains = "map_key")] + map_contains: HashMap, + #[validate(required)] + required_option: Option, + #[validate(required)] + #[serde(flatten)] + required_flattened: Option, +} + +#[derive(Debug, JsonSchema)] +pub struct Inner { + x: i32, +} + +#[test] +fn validate() -> TestResult { + test_default_generated_schema::("validate") +} + +#[derive(Debug, JsonSchema)] +pub struct Struct2 { + #[schemars(range(min = 0.01, max = 100))] + min_max: f32, + #[schemars(range(min = "MIN", max = "MAX"))] + min_max2: f32, + #[validate(regex = "overridden")] + #[schemars(regex = "STARTS_WITH_HELLO")] + regex_str1: String, + #[schemars(regex(path = "STARTS_WITH_HELLO"))] + regex_str2: String, + #[schemars(regex(pattern = r"^\d+$"))] + regex_str3: String, + #[validate(regex = "overridden")] + #[schemars(contains = "substring...")] + contains_str1: String, + #[schemars(contains(pattern = "substring..."))] + contains_str2: String, + #[schemars(email)] + email_address: String, + #[schemars(phone)] + tel: String, + #[schemars(url)] + homepage: String, + #[schemars(length(min = 1, max = 100))] + non_empty_str: String, + #[schemars(length(min = "MIN", max = "MAX"))] + non_empty_str2: String, + #[schemars(length(equal = 2))] + pair: Vec, + #[schemars(contains = "map_key")] + map_contains: HashMap, + #[schemars(required)] + required_option: Option, + #[schemars(required)] + #[serde(flatten)] + required_flattened: Option, +} + +#[test] +fn validate_schemars_attrs() -> TestResult { + test_default_generated_schema::("validate_schemars_attrs") +} + +#[derive(Debug, JsonSchema)] +pub struct Tuple( + #[validate(range(max = 10))] u8, + #[validate(required)] Option, +); + +#[test] +fn validate_tuple() -> TestResult { + test_default_generated_schema::("validate_tuple") +} + +#[derive(Debug, JsonSchema)] +pub struct NewType(#[validate(range(max = 10))] u8); + +#[test] +fn validate_newtype() -> TestResult { + test_default_generated_schema::("validate_newtype") +} diff --git a/schemars_derive/src/ast/from_serde.rs b/schemars_derive/src/ast/from_serde.rs index 0d9add36..83bfcf30 100644 --- a/schemars_derive/src/ast/from_serde.rs +++ b/schemars_derive/src/ast/from_serde.rs @@ -73,6 +73,7 @@ impl<'a> FromSerde for Field<'a> { ty: serde.ty, original: serde.original, attrs: Attrs::new(&serde.original.attrs, errors), + validation_attrs: ValidationAttrs::new(&serde.original.attrs, errors), }) } } diff --git a/schemars_derive/src/ast/mod.rs b/schemars_derive/src/ast/mod.rs index a394acda..99fe1882 100644 --- a/schemars_derive/src/ast/mod.rs +++ b/schemars_derive/src/ast/mod.rs @@ -1,6 +1,6 @@ mod from_serde; -use crate::attr::Attrs; +use crate::attr::{Attrs, ValidationAttrs}; use from_serde::FromSerde; use serde_derive_internals::ast as serde_ast; use serde_derive_internals::{Ctxt, Derive}; @@ -34,6 +34,7 @@ pub struct Field<'a> { pub ty: &'a syn::Type, pub original: &'a syn::Field, pub attrs: Attrs, + pub validation_attrs: ValidationAttrs, } impl<'a> Container<'a> { diff --git a/schemars_derive/src/attr/mod.rs b/schemars_derive/src/attr/mod.rs index d36568d8..cc81a920 100644 --- a/schemars_derive/src/attr/mod.rs +++ b/schemars_derive/src/attr/mod.rs @@ -1,8 +1,11 @@ mod doc; mod schemars_to_serde; +mod validation; pub use schemars_to_serde::process_serde_attrs; +pub use validation::ValidationAttrs; +use crate::metadata::SchemaMetadata; use proc_macro2::{Group, Span, TokenStream, TokenTree}; use quote::ToTokens; use serde_derive_internals::Ctxt; @@ -51,6 +54,27 @@ impl Attrs { result } + pub fn as_metadata(&self) -> SchemaMetadata<'_> { + #[allow(clippy::ptr_arg)] + fn none_if_empty(s: &String) -> Option<&str> { + if s.is_empty() { + None + } else { + Some(s) + } + } + + SchemaMetadata { + title: self.title.as_ref().and_then(none_if_empty), + description: self.description.as_ref().and_then(none_if_empty), + deprecated: self.deprecated, + examples: &self.examples, + read_only: false, + write_only: false, + default: None, + } + } + fn populate( mut self, attrs: &[syn::Attribute], @@ -141,10 +165,7 @@ impl Attrs { _ if ignore_errors => {} Meta(meta_item) => { - let is_known_serde_keyword = schemars_to_serde::SERDE_KEYWORDS - .iter() - .any(|k| meta_item.path().is_ident(k)); - if !is_known_serde_keyword { + if !is_known_serde_or_validation_keyword(meta_item) { let path = meta_item .path() .into_token_stream() @@ -152,16 +173,13 @@ impl Attrs { .replace(' ', ""); errors.error_spanned_by( meta_item.path(), - format!("unknown schemars container attribute `{}`", path), + format!("unknown schemars attribute `{}`", path), ); } } Lit(lit) => { - errors.error_spanned_by( - lit, - "unexpected literal in schemars container attribute", - ); + errors.error_spanned_by(lit, "unexpected literal in schemars attribute"); } } } @@ -169,6 +187,16 @@ impl Attrs { } } +fn is_known_serde_or_validation_keyword(meta: &syn::Meta) -> bool { + let mut known_keywords = schemars_to_serde::SERDE_KEYWORDS + .iter() + .chain(validation::VALIDATION_KEYWORDS); + meta.path() + .get_ident() + .map(|i| known_keywords.any(|k| i == k)) + .unwrap_or(false) +} + fn get_meta_items( attr: &syn::Attribute, attr_type: &'static str, diff --git a/schemars_derive/src/attr/validation.rs b/schemars_derive/src/attr/validation.rs new file mode 100644 index 00000000..398fa591 --- /dev/null +++ b/schemars_derive/src/attr/validation.rs @@ -0,0 +1,498 @@ +use super::{get_lit_str, get_meta_items, parse_lit_into_path, parse_lit_str}; +use proc_macro2::TokenStream; +use serde_derive_internals::Ctxt; +use syn::{Expr, ExprLit, ExprPath, Lit, Meta, MetaNameValue, NestedMeta, Path}; + +pub(crate) static VALIDATION_KEYWORDS: &[&str] = &[ + "range", "regex", "contains", "email", "phone", "url", "length", "required", +]; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Format { + Email, + Uri, + Phone, +} + +impl Format { + fn attr_str(self) -> &'static str { + match self { + Format::Email => "email", + Format::Uri => "url", + Format::Phone => "phone", + } + } + + fn schema_str(self) -> &'static str { + match self { + Format::Email => "email", + Format::Uri => "uri", + Format::Phone => "phone", + } + } +} + +#[derive(Debug, Default)] +pub struct ValidationAttrs { + length_min: Option, + length_max: Option, + length_equal: Option, + range_min: Option, + range_max: Option, + regex: Option, + contains: Option, + required: bool, + format: Option, +} + +impl ValidationAttrs { + pub fn new(attrs: &[syn::Attribute], errors: &Ctxt) -> Self { + ValidationAttrs::default() + .populate(attrs, "schemars", false, errors) + .populate(attrs, "validate", true, errors) + } + + pub fn required(&self) -> bool { + self.required + } + + fn populate( + mut self, + attrs: &[syn::Attribute], + attr_type: &'static str, + ignore_errors: bool, + errors: &Ctxt, + ) -> Self { + let duplicate_error = |path: &Path| { + if !ignore_errors { + let msg = format!( + "duplicate schemars attribute `{}`", + path.get_ident().unwrap() + ); + errors.error_spanned_by(path, msg) + } + }; + let mutual_exclusive_error = |path: &Path, other: &str| { + if !ignore_errors { + let msg = format!( + "schemars attribute cannot contain both `{}` and `{}`", + path.get_ident().unwrap(), + other, + ); + errors.error_spanned_by(path, msg) + } + }; + let duplicate_format_error = |existing: Format, new: Format, path: &syn::Path| { + if !ignore_errors { + let msg = if existing == new { + format!("duplicate schemars attribute `{}`", existing.attr_str()) + } else { + format!( + "schemars attribute cannot contain both `{}` and `{}`", + existing.attr_str(), + new.attr_str(), + ) + }; + errors.error_spanned_by(path, msg) + } + }; + + for meta_item in attrs + .iter() + .flat_map(|attr| get_meta_items(attr, attr_type, errors)) + .flatten() + { + match &meta_item { + NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("length") => { + for nested in meta_list.nested.iter() { + match nested { + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("min") => { + if self.length_min.is_some() { + duplicate_error(&nv.path) + } else if self.length_equal.is_some() { + mutual_exclusive_error(&nv.path, "equal") + } else { + self.length_min = str_or_num_to_expr(&errors, "min", &nv.lit); + } + } + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("max") => { + if self.length_max.is_some() { + duplicate_error(&nv.path) + } else if self.length_equal.is_some() { + mutual_exclusive_error(&nv.path, "equal") + } else { + self.length_max = str_or_num_to_expr(&errors, "max", &nv.lit); + } + } + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("equal") => { + if self.length_equal.is_some() { + duplicate_error(&nv.path) + } else if self.length_min.is_some() { + mutual_exclusive_error(&nv.path, "min") + } else if self.length_max.is_some() { + mutual_exclusive_error(&nv.path, "max") + } else { + self.length_equal = + str_or_num_to_expr(&errors, "equal", &nv.lit); + } + } + meta => { + if !ignore_errors { + errors.error_spanned_by( + meta, + format!("unknown item in schemars length attribute"), + ); + } + } + } + } + } + + NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("range") => { + for nested in meta_list.nested.iter() { + match nested { + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("min") => { + if self.range_min.is_some() { + duplicate_error(&nv.path) + } else { + self.range_min = str_or_num_to_expr(&errors, "min", &nv.lit); + } + } + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("max") => { + if self.range_max.is_some() { + duplicate_error(&nv.path) + } else { + self.range_max = str_or_num_to_expr(&errors, "max", &nv.lit); + } + } + meta => { + if !ignore_errors { + errors.error_spanned_by( + meta, + format!("unknown item in schemars range attribute"), + ); + } + } + } + } + } + + NestedMeta::Meta(Meta::Path(m)) + if m.is_ident("required") || m.is_ident("required_nested") => + { + self.required = true; + } + + NestedMeta::Meta(Meta::Path(p)) if p.is_ident(Format::Email.attr_str()) => { + match self.format { + Some(f) => duplicate_format_error(f, Format::Email, p), + None => self.format = Some(Format::Email), + } + } + NestedMeta::Meta(Meta::Path(p)) if p.is_ident(Format::Uri.attr_str()) => { + match self.format { + Some(f) => duplicate_format_error(f, Format::Uri, p), + None => self.format = Some(Format::Uri), + } + } + NestedMeta::Meta(Meta::Path(p)) if p.is_ident(Format::Phone.attr_str()) => { + match self.format { + Some(f) => duplicate_format_error(f, Format::Phone, p), + None => self.format = Some(Format::Phone), + } + } + + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("regex") => { + match (&self.regex, &self.contains) { + (Some(_), _) => duplicate_error(&nv.path), + (None, Some(_)) => mutual_exclusive_error(&nv.path, "contains"), + (None, None) => { + self.regex = + parse_lit_into_expr_path(errors, attr_type, "regex", &nv.lit).ok() + } + } + } + + NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("regex") => { + match (&self.regex, &self.contains) { + (Some(_), _) => duplicate_error(&meta_list.path), + (None, Some(_)) => mutual_exclusive_error(&meta_list.path, "contains"), + (None, None) => { + for x in meta_list.nested.iter() { + match x { + NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit, + .. + })) if path.is_ident("path") => { + self.regex = + parse_lit_into_expr_path(errors, attr_type, "path", lit) + .ok() + } + NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit, + .. + })) if path.is_ident("pattern") => { + self.regex = get_lit_str(errors, attr_type, "pattern", lit) + .ok() + .map(|litstr| { + Expr::Lit(syn::ExprLit { + attrs: Vec::new(), + lit: Lit::Str(litstr.clone()), + }) + }) + } + meta => { + if !ignore_errors { + errors.error_spanned_by( + meta, + format!("unknown item in schemars regex attribute"), + ); + } + } + } + } + } + } + } + + NestedMeta::Meta(Meta::NameValue(MetaNameValue { path, lit, .. })) + if path.is_ident("contains") => + { + match (&self.contains, &self.regex) { + (Some(_), _) => duplicate_error(&path), + (None, Some(_)) => mutual_exclusive_error(&path, "regex"), + (None, None) => { + self.contains = get_lit_str(errors, attr_type, "contains", lit) + .map(|litstr| litstr.value()) + .ok() + } + } + } + + NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("contains") => { + match (&self.contains, &self.regex) { + (Some(_), _) => duplicate_error(&meta_list.path), + (None, Some(_)) => mutual_exclusive_error(&meta_list.path, "regex"), + (None, None) => { + for x in meta_list.nested.iter() { + match x { + NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit, + .. + })) if path.is_ident("pattern") => { + self.contains = + get_lit_str(errors, attr_type, "contains", lit) + .ok() + .map(|litstr| litstr.value()) + } + meta => { + if !ignore_errors { + errors.error_spanned_by( + meta, + format!( + "unknown item in schemars contains attribute" + ), + ); + } + } + } + } + } + } + } + + _ => {} + } + } + self + } + + pub fn apply_to_schema(&self, schema_expr: &mut TokenStream) { + let mut array_validation = Vec::new(); + let mut number_validation = Vec::new(); + let mut object_validation = Vec::new(); + let mut string_validation = Vec::new(); + + if let Some(length_min) = self + .length_min + .as_ref() + .or_else(|| self.length_equal.as_ref()) + { + string_validation.push(quote! { + validation.min_length = Some(#length_min as u32); + }); + array_validation.push(quote! { + validation.min_items = Some(#length_min as u32); + }); + } + + if let Some(length_max) = self + .length_max + .as_ref() + .or_else(|| self.length_equal.as_ref()) + { + string_validation.push(quote! { + validation.max_length = Some(#length_max as u32); + }); + array_validation.push(quote! { + validation.max_items = Some(#length_max as u32); + }); + } + + if let Some(range_min) = &self.range_min { + number_validation.push(quote! { + validation.minimum = Some(#range_min as f64); + }); + } + + if let Some(range_max) = &self.range_max { + number_validation.push(quote! { + validation.maximum = Some(#range_max as f64); + }); + } + + if let Some(regex) = &self.regex { + string_validation.push(quote! { + validation.pattern = Some(#regex.to_string()); + }); + } + + if let Some(contains) = &self.contains { + object_validation.push(quote! { + validation.required.insert(#contains.to_string()); + }); + + if self.regex.is_none() { + let pattern = crate::regex_syntax::escape(contains); + string_validation.push(quote! { + validation.pattern = Some(#pattern.to_string()); + }); + } + } + + let format = self.format.as_ref().map(|f| { + let f = f.schema_str(); + quote! { + schema_object.format = Some(#f.to_string()); + } + }); + + let array_validation = wrap_array_validation(array_validation); + let number_validation = wrap_number_validation(number_validation); + let object_validation = wrap_object_validation(object_validation); + let string_validation = wrap_string_validation(string_validation); + + if array_validation.is_some() + || number_validation.is_some() + || object_validation.is_some() + || string_validation.is_some() + || format.is_some() + { + *schema_expr = quote! { + { + let mut schema = #schema_expr; + if let schemars::schema::Schema::Object(schema_object) = &mut schema + { + #array_validation + #number_validation + #object_validation + #string_validation + #format + } + schema + } + } + } + } +} + +fn parse_lit_into_expr_path( + cx: &Ctxt, + attr_type: &'static str, + meta_item_name: &'static str, + lit: &syn::Lit, +) -> Result { + parse_lit_into_path(cx, attr_type, meta_item_name, lit).map(|path| { + Expr::Path(ExprPath { + attrs: Vec::new(), + qself: None, + path, + }) + }) +} + +fn wrap_array_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if schema_object.has_type(schemars::schema::InstanceType::Array) { + let validation = schema_object.array(); + #(#v)* + } + }) + } +} + +fn wrap_number_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if schema_object.has_type(schemars::schema::InstanceType::Integer) + || schema_object.has_type(schemars::schema::InstanceType::Number) { + let validation = schema_object.number(); + #(#v)* + } + }) + } +} + +fn wrap_object_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if schema_object.has_type(schemars::schema::InstanceType::Object) { + let validation = schema_object.object(); + #(#v)* + } + }) + } +} + +fn wrap_string_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if schema_object.has_type(schemars::schema::InstanceType::String) { + let validation = schema_object.string(); + #(#v)* + } + }) + } +} + +fn str_or_num_to_expr(cx: &Ctxt, meta_item_name: &str, lit: &Lit) -> Option { + match lit { + Lit::Str(s) => parse_lit_str::(s).ok().map(Expr::Path), + Lit::Int(_) | Lit::Float(_) => Some(Expr::Lit(ExprLit { + attrs: Vec::new(), + lit: lit.clone(), + })), + _ => { + cx.error_spanned_by( + lit, + format!( + "expected `{}` to be a string or number literal", + meta_item_name + ), + ); + None + } + } +} diff --git a/schemars_derive/src/lib.rs b/schemars_derive/src/lib.rs index d536488c..5fa4ac2f 100644 --- a/schemars_derive/src/lib.rs +++ b/schemars_derive/src/lib.rs @@ -9,13 +9,14 @@ extern crate proc_macro; mod ast; mod attr; mod metadata; +mod regex_syntax; mod schema_exprs; use ast::*; use proc_macro2::TokenStream; use syn::spanned::Spanned; -#[proc_macro_derive(JsonSchema, attributes(schemars, serde))] +#[proc_macro_derive(JsonSchema, attributes(schemars, serde, validate))] pub fn derive_json_schema_wrapper(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input = parse_macro_input!(input as syn::DeriveInput); derive_json_schema(input, false) @@ -50,7 +51,7 @@ fn derive_json_schema( let (impl_generics, ty_generics, where_clause) = cont.generics.split_for_impl(); if let Some(transparent_field) = cont.transparent_field() { - let (ty, type_def) = schema_exprs::type_for_field_schema(transparent_field, 0); + let (ty, type_def) = schema_exprs::type_for_field_schema(transparent_field); return Ok(quote! { const _: () = { #crate_alias diff --git a/schemars_derive/src/metadata.rs b/schemars_derive/src/metadata.rs index a84decad..aefe243e 100644 --- a/schemars_derive/src/metadata.rs +++ b/schemars_derive/src/metadata.rs @@ -1,7 +1,4 @@ -use crate::attr; -use attr::Attrs; -use proc_macro2::{Ident, Span, TokenStream}; -use quote::{ToTokens, TokenStreamExt}; +use proc_macro2::TokenStream; #[derive(Debug, Clone)] pub struct SchemaMetadata<'a> { @@ -14,42 +11,15 @@ pub struct SchemaMetadata<'a> { pub default: Option, } -impl ToTokens for SchemaMetadata<'_> { - fn to_tokens(&self, tokens: &mut TokenStream) { +impl<'a> SchemaMetadata<'a> { + pub fn apply_to_schema(&self, schema_expr: &mut TokenStream) { let setters = self.make_setters(); - if setters.is_empty() { - tokens.append(Ident::new("None", Span::call_site())) - } else { - tokens.extend(quote! { - Some({ - schemars::schema::Metadata { - #(#setters)* - ..Default::default() - } + if !setters.is_empty() { + *schema_expr = quote! { + schemars::_private::apply_metadata(#schema_expr, schemars::schema::Metadata { + #(#setters)* + ..Default::default() }) - }) - } - } -} - -impl<'a> SchemaMetadata<'a> { - pub fn from_attrs(attrs: &'a Attrs) -> Self { - SchemaMetadata { - title: attrs.title.as_ref().and_then(none_if_empty), - description: attrs.description.as_ref().and_then(none_if_empty), - deprecated: attrs.deprecated, - examples: &attrs.examples, - read_only: false, - write_only: false, - default: None, - } - } - - pub fn apply_to_schema(&self, schema_expr: TokenStream) -> TokenStream { - quote! { - { - let schema = #schema_expr; - schemars::_private::apply_metadata(schema, #self) } } } @@ -105,12 +75,3 @@ impl<'a> SchemaMetadata<'a> { setters } } - -#[allow(clippy::ptr_arg)] -fn none_if_empty(s: &String) -> Option<&str> { - if s.is_empty() { - None - } else { - Some(s) - } -} diff --git a/schemars_derive/src/regex_syntax.rs b/schemars_derive/src/regex_syntax.rs new file mode 100644 index 00000000..353bf8d3 --- /dev/null +++ b/schemars_derive/src/regex_syntax.rs @@ -0,0 +1,26 @@ +// Copied from regex_syntax crate to avoid pulling in the whole crate just for a utility function +// https://github.com/rust-lang/regex/blob/ff283badce21dcebd581909d38b81f2c8c9bfb54/regex-syntax/src/lib.rs + +pub fn escape(text: &str) -> String { + let mut quoted = String::new(); + escape_into(text, &mut quoted); + quoted +} + +fn escape_into(text: &str, buf: &mut String) { + buf.reserve(text.len()); + for c in text.chars() { + if is_meta_character(c) { + buf.push('\\'); + } + buf.push(c); + } +} + +fn is_meta_character(c: char) -> bool { + match c { + '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' + | '#' | '&' | '-' | '~' => true, + _ => false, + } +} diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs index f0f0c783..f2c76e90 100644 --- a/schemars_derive/src/schema_exprs.rs +++ b/schemars_derive/src/schema_exprs.rs @@ -5,7 +5,7 @@ use serde_derive_internals::attr::{self as serde_attr, Default as SerdeDefault, use syn::spanned::Spanned; pub fn expr_for_container(cont: &Container) -> TokenStream { - let schema_expr = match &cont.data { + let mut schema_expr = match &cont.data { Data::Struct(Style::Unit, _) => expr_for_unit_struct(), Data::Struct(Style::Newtype, fields) => expr_for_newtype_struct(&fields[0]), Data::Struct(Style::Tuple, fields) => expr_for_tuple_struct(fields), @@ -17,8 +17,8 @@ pub fn expr_for_container(cont: &Container) -> TokenStream { Data::Enum(variants) => expr_for_enum(variants, &cont.serde_attrs), }; - let doc_metadata = SchemaMetadata::from_attrs(&cont.attrs); - doc_metadata.apply_to_schema(schema_expr) + cont.attrs.as_metadata().apply_to_schema(&mut schema_expr); + schema_expr } pub fn expr_for_repr(cont: &Container) -> Result { @@ -47,49 +47,52 @@ pub fn expr_for_repr(cont: &Container) -> Result { let enum_ident = &cont.ident; let variant_idents = variants.iter().map(|v| &v.ident); - let schema_expr = schema_object(quote! { + let mut schema_expr = schema_object(quote! { instance_type: Some(schemars::schema::InstanceType::Integer.into()), enum_values: Some(vec![#((#enum_ident::#variant_idents as #repr_type).into()),*]), }); - let doc_metadata = SchemaMetadata::from_attrs(&cont.attrs); - Ok(doc_metadata.apply_to_schema(schema_expr)) + cont.attrs.as_metadata().apply_to_schema(&mut schema_expr); + Ok(schema_expr) } fn expr_for_field(field: &Field, allow_ref: bool) -> TokenStream { - let (ty, type_def) = type_for_field_schema(field, 0); + let (ty, type_def) = type_for_field_schema(field); let span = field.original.span(); let gen = quote!(gen); - if allow_ref { + let mut schema_expr = if field.validation_attrs.required() { quote_spanned! {span=> - { - #type_def - #gen.subschema_for::<#ty>() - } + <#ty as schemars::JsonSchema>::_schemars_private_non_optional_json_schema(#gen) + } + } else if allow_ref { + quote_spanned! {span=> + #gen.subschema_for::<#ty>() } } else { quote_spanned! {span=> - { - #type_def - <#ty as schemars::JsonSchema>::json_schema(#gen) - } + <#ty as schemars::JsonSchema>::json_schema(#gen) } - } + }; + + prepend_type_def(type_def, &mut schema_expr); + field.validation_attrs.apply_to_schema(&mut schema_expr); + + schema_expr } -pub fn type_for_field_schema(field: &Field, local_id: usize) -> (syn::Type, Option) { +pub fn type_for_field_schema(field: &Field) -> (syn::Type, Option) { match &field.attrs.with { None => (field.ty.to_owned(), None), - Some(with_attr) => type_for_schema(with_attr, local_id), + Some(with_attr) => type_for_schema(with_attr), } } -fn type_for_schema(with_attr: &WithAttr, local_id: usize) -> (syn::Type, Option) { +fn type_for_schema(with_attr: &WithAttr) -> (syn::Type, Option) { match with_attr { WithAttr::Type(ty) => (ty.to_owned(), None), WithAttr::Function(fun) => { - let ty_name = format_ident!("_SchemarsSchemaWithFunction{}", local_id); + let ty_name = syn::Ident::new("_SchemarsSchemaWithFunction", Span::call_site()); let fn_name = fun.segments.last().unwrap().ident.to_string(); let type_def = quote_spanned! {fun.span()=> @@ -159,7 +162,7 @@ fn expr_for_external_tagged_enum<'a>( let name = variant.name(); let sub_schema = expr_for_untagged_enum_variant(variant, deny_unknown_fields); - let schema_expr = schema_object(quote! { + let mut schema_expr = schema_object(quote! { instance_type: Some(schemars::schema::InstanceType::Object.into()), object: Some(Box::new(schemars::schema::ObjectValidation { properties: { @@ -176,8 +179,13 @@ fn expr_for_external_tagged_enum<'a>( ..Default::default() })), }); - let doc_metadata = SchemaMetadata::from_attrs(&variant.attrs); - doc_metadata.apply_to_schema(schema_expr) + + variant + .attrs + .as_metadata() + .apply_to_schema(&mut schema_expr); + + schema_expr })); schema_object(quote! { @@ -200,7 +208,7 @@ fn expr_for_internal_tagged_enum<'a>( enum_values: Some(vec![#name.into()]), }); - let tag_schema = schema_object(quote! { + let mut tag_schema = schema_object(quote! { instance_type: Some(schemars::schema::InstanceType::Object.into()), object: Some(Box::new(schemars::schema::ObjectValidation { properties: { @@ -216,15 +224,16 @@ fn expr_for_internal_tagged_enum<'a>( ..Default::default() })), }); - let doc_metadata = SchemaMetadata::from_attrs(&variant.attrs); - let tag_schema = doc_metadata.apply_to_schema(tag_schema); - - match expr_for_untagged_enum_variant_for_flatten(&variant, deny_unknown_fields) { - Some(variant_schema) => quote! { - #tag_schema.flatten(#variant_schema) - }, - None => tag_schema, + + variant.attrs.as_metadata().apply_to_schema(&mut tag_schema); + + if let Some(variant_schema) = + expr_for_untagged_enum_variant_for_flatten(&variant, deny_unknown_fields) + { + tag_schema.extend(quote!(.flatten(#variant_schema))) } + + tag_schema }); schema_object(quote! { @@ -240,9 +249,14 @@ fn expr_for_untagged_enum<'a>( deny_unknown_fields: bool, ) -> TokenStream { let schemas = variants.map(|variant| { - let schema_expr = expr_for_untagged_enum_variant(variant, deny_unknown_fields); - let doc_metadata = SchemaMetadata::from_attrs(&variant.attrs); - doc_metadata.apply_to_schema(schema_expr) + let mut schema_expr = expr_for_untagged_enum_variant(variant, deny_unknown_fields); + + variant + .attrs + .as_metadata() + .apply_to_schema(&mut schema_expr); + + schema_expr }); schema_object(quote! { @@ -289,7 +303,7 @@ fn expr_for_adjacent_tagged_enum<'a>( TokenStream::new() }; - let outer_schema = schema_object(quote! { + let mut outer_schema = schema_object(quote! { instance_type: Some(schemars::schema::InstanceType::Object.into()), object: Some(Box::new(schemars::schema::ObjectValidation { properties: { @@ -309,8 +323,12 @@ fn expr_for_adjacent_tagged_enum<'a>( })), }); - let doc_metadata = SchemaMetadata::from_attrs(&variant.attrs); - doc_metadata.apply_to_schema(outer_schema) + variant + .attrs + .as_metadata() + .apply_to_schema(&mut outer_schema); + + outer_schema }); schema_object(quote! { @@ -323,14 +341,14 @@ fn expr_for_adjacent_tagged_enum<'a>( fn expr_for_untagged_enum_variant(variant: &Variant, deny_unknown_fields: bool) -> TokenStream { if let Some(with_attr) = &variant.attrs.with { - let (ty, type_def) = type_for_schema(with_attr, 0); + let (ty, type_def) = type_for_schema(with_attr); let gen = quote!(gen); - return quote_spanned! {variant.original.span()=> - { - #type_def - #gen.subschema_for::<#ty>() - } + let mut schema_expr = quote_spanned! {variant.original.span()=> + #gen.subschema_for::<#ty>() }; + + prepend_type_def(type_def, &mut schema_expr); + return schema_expr; } match variant.style { @@ -346,14 +364,14 @@ fn expr_for_untagged_enum_variant_for_flatten( deny_unknown_fields: bool, ) -> Option { if let Some(with_attr) = &variant.attrs.with { - let (ty, type_def) = type_for_schema(with_attr, 0); + let (ty, type_def) = type_for_schema(with_attr); let gen = quote!(gen); - return Some(quote_spanned! {variant.original.span()=> - { - #type_def - <#ty as schemars::JsonSchema>::json_schema(#gen) - } - }); + let mut schema_expr = quote_spanned! {variant.original.span()=> + <#ty as schemars::JsonSchema>::json_schema(#gen) + }; + + prepend_type_def(type_def, &mut schema_expr); + return Some(schema_expr); } Some(match variant.style { @@ -375,17 +393,25 @@ fn expr_for_newtype_struct(field: &Field) -> TokenStream { } fn expr_for_tuple_struct(fields: &[Field]) -> TokenStream { - let (types, type_defs): (Vec<_>, Vec<_>) = fields + let fields: Vec<_> = fields .iter() .filter(|f| !f.serde_attrs.skip_deserializing()) - .enumerate() - .map(|(i, f)| type_for_field_schema(f, i)) - .unzip(); + .map(|f| expr_for_field(f, true)) + .collect(); + let len = fields.len() as u32; + quote! { - { - #(#type_defs)* - gen.subschema_for::<(#(#types),*)>() - } + schemars::schema::Schema::Object( + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Array.into()), + array: Some(Box::new(schemars::schema::ArrayValidation { + items: Some(vec![#(#fields),*].into()), + max_items: Some(#len), + min_items: Some(#len), + ..Default::default() + })), + ..Default::default() + }) } } @@ -405,35 +431,55 @@ fn expr_for_struct( SerdeDefault::Path(path) => Some(quote!(let container_default = #path();)), }; - let mut type_defs = Vec::new(); - let properties: Vec<_> = property_fields .into_iter() .map(|field| { let name = field.name(); let default = field_default_expr(field, set_container_default.is_some()); - let required = match default { - Some(_) => quote!(false), - None => quote!(true), + let (ty, type_def) = type_for_field_schema(field); + + let maybe_insert_required = match (&default, field.validation_attrs.required()) { + (Some(_), _) => TokenStream::new(), + (None, false) => { + quote! { + if !<#ty as schemars::JsonSchema>::_schemars_private_is_option() { + object_validation.required.insert(#name.to_owned()); + } + } + } + (None, true) => quote! { + object_validation.required.insert(#name.to_owned()); + }, }; - let metadata = &SchemaMetadata { + let metadata = SchemaMetadata { read_only: field.serde_attrs.skip_deserializing(), write_only: field.serde_attrs.skip_serializing(), default, - ..SchemaMetadata::from_attrs(&field.attrs) + ..field.attrs.as_metadata() }; - let (ty, type_def) = type_for_field_schema(field, type_defs.len()); - if let Some(type_def) = type_def { - type_defs.push(type_def); - } + let gen = quote!(gen); + let mut schema_expr = if field.validation_attrs.required() { + quote_spanned! {ty.span()=> + <#ty as schemars::JsonSchema>::_schemars_private_non_optional_json_schema(#gen) + } + } else { + quote_spanned! {ty.span()=> + #gen.subschema_for::<#ty>() + } + }; - let args = quote!(gen, &mut schema_object, #name.to_owned(), #metadata, #required); + metadata.apply_to_schema(&mut schema_expr); + field.validation_attrs.apply_to_schema(&mut schema_expr); - quote_spanned! {ty.span()=> - schemars::_private::add_schema_as_property::<#ty>(#args); + quote! { + { + #type_def + object_validation.properties.insert(#name.to_owned(), #schema_expr); + #maybe_insert_required + } } }) .collect(); @@ -441,37 +487,39 @@ fn expr_for_struct( let flattens: Vec<_> = flattened_fields .into_iter() .map(|field| { - let (ty, type_def) = type_for_field_schema(field, type_defs.len()); - if let Some(type_def) = type_def { - type_defs.push(type_def); - } + let (ty, type_def) = type_for_field_schema(field); - let gen = quote!(gen); - quote_spanned! {ty.span()=> - .flatten(schemars::_private::json_schema_for_flatten::<#ty>(#gen)) - } + let required = field.validation_attrs.required(); + + let args = quote!(gen, #required); + let mut schema_expr = quote_spanned! {ty.span()=> + schemars::_private::json_schema_for_flatten::<#ty>(#args) + }; + + prepend_type_def(type_def, &mut schema_expr); + schema_expr }) .collect(); let set_additional_properties = if deny_unknown_fields { quote! { - schema_object.object().additional_properties = Some(Box::new(false.into())); + object_validation.additional_properties = Some(Box::new(false.into())); } } else { TokenStream::new() }; quote! { { - #(#type_defs)* #set_container_default let mut schema_object = schemars::schema::SchemaObject { instance_type: Some(schemars::schema::InstanceType::Object.into()), ..Default::default() }; + let object_validation = schema_object.object(); #set_additional_properties #(#properties)* schemars::schema::Schema::Object(schema_object) - #(#flattens)* + #(.flatten(#flattens))* } } } @@ -540,3 +588,14 @@ fn schema_object(properties: TokenStream) -> TokenStream { }) } } + +fn prepend_type_def(type_def: Option, schema_expr: &mut TokenStream) { + if let Some(type_def) = type_def { + *schema_expr = quote! { + { + #type_def + #schema_expr + } + } + } +}