From 6ab567f3a5c0f7e79106cae90a3006758446437f Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Mon, 29 Mar 2021 16:38:55 +0100 Subject: [PATCH 01/13] Read #[validate(...)] attributes --- schemars/src/json_schema_impls/core.rs | 14 +- schemars/src/json_schema_impls/mod.rs | 2 +- schemars/src/lib.rs | 4 +- schemars/src/schema.rs | 26 ++- schemars/tests/expected/validate.json | 81 +++++++ schemars/tests/validate.rs | 40 ++++ schemars_derive/src/ast/from_serde.rs | 1 + schemars_derive/src/ast/mod.rs | 3 +- schemars_derive/src/attr/mod.rs | 2 + schemars_derive/src/attr/validation.rs | 308 +++++++++++++++++++++++++ schemars_derive/src/lib.rs | 5 +- schemars_derive/src/regex_syntax.rs | 26 +++ schemars_derive/src/schema_exprs.rs | 53 +++-- 13 files changed, 532 insertions(+), 33 deletions(-) create mode 100644 schemars/tests/expected/validate.json create mode 100644 schemars/tests/validate.rs create mode 100644 schemars_derive/src/attr/validation.rs create mode 100644 schemars_derive/src/regex_syntax.rs diff --git a/schemars/src/json_schema_impls/core.rs b/schemars/src/json_schema_impls/core.rs index 5cefbdd..647030f 100644 --- a/schemars/src/json_schema_impls/core.rs +++ b/schemars/src/json_schema_impls/core.rs @@ -62,13 +62,17 @@ impl JsonSchema for Option { parent: &mut SchemaObject, name: String, metadata: Option, - _required: bool, + required: Option, ) { - let mut schema = gen.subschema_for::(); - schema = gen.apply_metadata(schema, metadata); + if required == Some(true) { + T::add_schema_as_property(gen, parent, name, metadata, required) + } else { + let mut schema = gen.subschema_for::(); + schema = gen.apply_metadata(schema, metadata); - let object = parent.object(); - object.properties.insert(name, schema); + let object = parent.object(); + object.properties.insert(name, schema); + } } } diff --git a/schemars/src/json_schema_impls/mod.rs b/schemars/src/json_schema_impls/mod.rs index d493ea0..4d96ee2 100644 --- a/schemars/src/json_schema_impls/mod.rs +++ b/schemars/src/json_schema_impls/mod.rs @@ -30,7 +30,7 @@ macro_rules! forward_impl { parent: &mut crate::schema::SchemaObject, name: String, metadata: Option, - required: bool, + required: Option, ) { <$target>::add_schema_as_property(gen, parent, name, metadata, required) } diff --git a/schemars/src/lib.rs b/schemars/src/lib.rs index eb6d5ac..9a9e65c 100644 --- a/schemars/src/lib.rs +++ b/schemars/src/lib.rs @@ -375,13 +375,13 @@ pub trait JsonSchema { parent: &mut SchemaObject, name: String, metadata: Option, - required: bool, + required: Option, ) { let mut schema = gen.subschema_for::(); schema = gen.apply_metadata(schema, metadata); let object = parent.object(); - if required { + if required.unwrap_or(true) { object.required.insert(name.clone()); } object.properties.insert(name, schema); diff --git a/schemars/src/schema.rs b/schemars/src/schema.rs index a4c6e32..e9a0124 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,13 @@ impl SchemaObject { self.reference.is_some() } + // TODO document + 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 +520,13 @@ impl From> for SingleOrVec { SingleOrVec::Vec(vec) } } + +impl SingleOrVec { + // TODO document + 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/validate.json b/schemars/tests/expected/validate.json new file mode 100644 index 0000000..228f249 --- /dev/null +++ b/schemars/tests/expected/validate.json @@ -0,0 +1,81 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Struct", + "type": "object", + "required": [ + "contains_str1", + "contains_str2", + "email_address", + "homepage", + "map_contains", + "min_max", + "non_empty_str", + "pair", + "regex_str1", + "regex_str2", + "required_option", + "tel" + ], + "properties": { + "min_max": { + "type": "number", + "format": "float", + "maximum": 100.0, + "minimum": 0.01 + }, + "regex_str1": { + "type": "string", + "pattern": "^[Hh]ello\\b" + }, + "regex_str2": { + "type": "string", + "pattern": "^[Hh]ello\\b" + }, + "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 + }, + "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" + } + } +} \ No newline at end of file diff --git a/schemars/tests/validate.rs b/schemars/tests/validate.rs new file mode 100644 index 0000000..6d67531 --- /dev/null +++ b/schemars/tests/validate.rs @@ -0,0 +1,40 @@ +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"; + +#[derive(Debug, JsonSchema)] +pub struct Struct { + #[validate(range(min = 0.01, max = 100))] + min_max: f32, + #[validate(regex = "STARTS_WITH_HELLO")] + regex_str1: String, + #[validate(regex(path = "STARTS_WITH_HELLO", code = "foo"))] + regex_str2: 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(equal = 2))] + pair: Vec, + #[validate(contains = "map_key")] + map_contains: HashMap, + #[validate(required)] + required_option: Option, +} + +#[test] +fn validate() -> TestResult { + test_default_generated_schema::("validate") +} diff --git a/schemars_derive/src/ast/from_serde.rs b/schemars_derive/src/ast/from_serde.rs index 0d9add3..db2e092 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), }) } } diff --git a/schemars_derive/src/ast/mod.rs b/schemars_derive/src/ast/mod.rs index a394acd..99fe188 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 65667d9..d7a6628 100644 --- a/schemars_derive/src/attr/mod.rs +++ b/schemars_derive/src/attr/mod.rs @@ -1,7 +1,9 @@ mod doc; mod schemars_to_serde; +mod validation; pub use schemars_to_serde::process_serde_attrs; +pub use validation::ValidationAttrs; use proc_macro2::{Group, Span, TokenStream, TokenTree}; use quote::ToTokens; diff --git a/schemars_derive/src/attr/validation.rs b/schemars_derive/src/attr/validation.rs new file mode 100644 index 0000000..8f13168 --- /dev/null +++ b/schemars_derive/src/attr/validation.rs @@ -0,0 +1,308 @@ +use super::parse_lit_str; +use proc_macro2::TokenStream; +use syn::ExprLit; +use syn::NestedMeta; +use syn::{Expr, Lit, Meta, MetaNameValue, Path}; + +#[derive(Debug, Default)] +pub struct ValidationAttrs { + pub length_min: Option, + pub length_max: Option, + pub length_equal: Option, + pub range_min: Option, + pub range_max: Option, + pub regex: Option, + pub contains: Option, + pub required: bool, + pub format: Option<&'static str>, +} + +impl ValidationAttrs { + pub fn new(attrs: &[syn::Attribute]) -> Self { + // TODO allow setting "validate" attributes through #[schemars(...)] + ValidationAttrs::default().populate(attrs) + } + + fn populate(mut self, attrs: &[syn::Attribute]) -> Self { + // TODO don't silently ignore unparseable attributes + for meta_item in attrs + .iter() + .flat_map(|attr| get_meta_items(attr, "validate")) + .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") => { + self.length_min = str_or_num_to_expr(&nv.lit); + } + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("max") => { + self.length_max = str_or_num_to_expr(&nv.lit); + } + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("equal") => { + self.length_equal = str_or_num_to_expr(&nv.lit); + } + _ => {} + } + } + } + + 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") => { + self.range_min = str_or_num_to_expr(&nv.lit); + } + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("max") => { + self.range_max = str_or_num_to_expr(&nv.lit); + } + _ => {} + } + } + } + + NestedMeta::Meta(m) + if m.path().is_ident("required") || m.path().is_ident("required_nested") => + { + self.required = true; + } + + NestedMeta::Meta(m) if m.path().is_ident("email") => { + self.format = Some("email"); + } + + NestedMeta::Meta(m) if m.path().is_ident("url") => { + self.format = Some("uri"); + } + + NestedMeta::Meta(m) if m.path().is_ident("phone") => { + self.format = Some("phone"); + } + + NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit: Lit::Str(regex), + .. + })) if path.is_ident("regex") => self.regex = parse_lit_str(regex).ok(), + + NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("regex") => { + self.regex = meta_list.nested.iter().find_map(|x| match x { + NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit: Lit::Str(regex), + .. + })) if path.is_ident("path") => parse_lit_str(regex).ok(), + _ => None, + }); + } + + NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit: Lit::Str(contains), + .. + })) if path.is_ident("contains") => self.contains = Some(contains.value()), + + NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("contains") => { + self.contains = meta_list.nested.iter().find_map(|x| match x { + NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit: Lit::Str(contains), + .. + })) if path.is_ident("pattern") => Some(contains.value()), + _ => None, + }); + } + + _ => {} + } + } + self + } + + pub fn validation_statements(&self, field_name: &str) -> TokenStream { + // Assume that the result will be interpolated in a context with the local variable + // `schema_object` - the SchemaObject for the struct that contains this field. + let mut statements = Vec::new(); + + if self.required { + statements.push(quote! { + schema_object.object().required.insert(#field_name.to_owned()); + }); + } + + 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| { + quote! { + prop_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() + { + statements.push(quote! { + if let Some(schemars::schema::Schema::Object(prop_schema_object)) = schema_object + .object + .as_mut() + .and_then(|o| o.properties.get_mut(#field_name)) + { + #array_validation + #number_validation + #object_validation + #string_validation + #format + } + }); + } + + statements.into_iter().collect() + } +} + +fn wrap_array_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if prop_schema_object.has_type(schemars::schema::InstanceType::Array) { + let validation = prop_schema_object.array(); + #(#v)* + } + }) + } +} + +fn wrap_number_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if prop_schema_object.has_type(schemars::schema::InstanceType::Integer) + || prop_schema_object.has_type(schemars::schema::InstanceType::Number) { + let validation = prop_schema_object.number(); + #(#v)* + } + }) + } +} + +fn wrap_object_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if prop_schema_object.has_type(schemars::schema::InstanceType::Object) { + let validation = prop_schema_object.object(); + #(#v)* + } + }) + } +} + +fn wrap_string_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if prop_schema_object.has_type(schemars::schema::InstanceType::String) { + let validation = prop_schema_object.string(); + #(#v)* + } + }) + } +} + +fn get_meta_items( + attr: &syn::Attribute, + attr_type: &'static str, +) -> Result, ()> { + if !attr.path.is_ident(attr_type) { + return Ok(Vec::new()); + } + + match attr.parse_meta() { + Ok(Meta::List(meta)) => Ok(meta.nested.into_iter().collect()), + _ => Err(()), + } +} + +fn str_or_num_to_expr(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(), + })), + _ => None, + } +} diff --git a/schemars_derive/src/lib.rs b/schemars_derive/src/lib.rs index c81eb40..c737380 100644 --- a/schemars_derive/src/lib.rs +++ b/schemars_derive/src/lib.rs @@ -9,12 +9,13 @@ extern crate proc_macro; mod ast; mod attr; mod metadata; +mod regex_syntax; mod schema_exprs; use ast::*; use proc_macro2::TokenStream; -#[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) @@ -72,7 +73,7 @@ fn derive_json_schema( parent: &mut schemars::schema::SchemaObject, name: String, metadata: Option, - required: bool, + required: Option, ) { <#ty as schemars::JsonSchema>::add_schema_as_property(gen, parent, name, metadata, required) } diff --git a/schemars_derive/src/regex_syntax.rs b/schemars_derive/src/regex_syntax.rs new file mode 100644 index 0000000..353bf8d --- /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 a5cf2ca..4965fd5 100644 --- a/schemars_derive/src/schema_exprs.rs +++ b/schemars_derive/src/schema_exprs.rs @@ -390,32 +390,43 @@ fn expr_for_struct( 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 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 required = match (&default, field.validation_attrs.required) { + (Some(_), _) => quote!(Some(false)), + (None, false) => quote!(None), + (None, true) => quote!(Some(true)), + }; - let metadata = &SchemaMetadata { - read_only: field.serde_attrs.skip_deserializing(), - write_only: field.serde_attrs.skip_serializing(), - default, - ..SchemaMetadata::from_attrs(&field.attrs) - }; + let metadata = &SchemaMetadata { + read_only: field.serde_attrs.skip_deserializing(), + write_only: field.serde_attrs.skip_serializing(), + default, + ..SchemaMetadata::from_attrs(&field.attrs) + }; - let (ty, type_def) = type_for_schema(field, type_defs.len()); - if let Some(type_def) = type_def { - type_defs.push(type_def); - } + let (ty, type_def) = type_for_schema(field, type_defs.len()); + if let Some(type_def) = type_def { + type_defs.push(type_def); + } - quote_spanned! {ty.span()=> - <#ty as schemars::JsonSchema>::add_schema_as_property(gen, &mut schema_object, #name.to_owned(), #metadata, #required); - } + let validation = field.validation_attrs.validation_statements(&name); - }).collect(); + quote_spanned! {ty.span()=> + <#ty as schemars::JsonSchema>::add_schema_as_property( + gen, + &mut schema_object, + #name.to_owned(), + #metadata, + #required); + #validation + } + }) + .collect(); let flattens: Vec<_> = flattened_fields .into_iter() From 1a2dafc1a5cecea8022d0d0eddb1775f9c182b27 Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Thu, 15 Apr 2021 18:11:28 +0100 Subject: [PATCH 02/13] Handle required flattened Option fields --- schemars/src/_private.rs | 12 ++++++++++-- schemars/tests/expected/validate.json | 7 ++++++- schemars/tests/validate.rs | 8 ++++++++ schemars_derive/src/attr/validation.rs | 18 +++++------------- schemars_derive/src/schema_exprs.rs | 10 ++++++++-- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/schemars/src/_private.rs b/schemars/src/_private.rs index 8b8e2d1..8428370 100644 --- a/schemars/src/_private.rs +++ b/schemars/src/_private.rs @@ -4,9 +4,14 @@ 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: Option, +) -> Schema { let mut schema = T::_schemars_private_non_optional_json_schema(gen); - if T::_schemars_private_is_option() { + + let required = required.unwrap_or_else(|| !T::_schemars_private_is_option()); + if !required { if let Schema::Object(SchemaObject { object: Some(ref mut object_validation), .. @@ -15,6 +20,7 @@ pub fn json_schema_for_flatten(gen: &mut SchemaGenerator object_validation.required.clear(); } } + schema } @@ -28,11 +34,13 @@ pub fn add_schema_as_property( ) { let is_type_option = T::_schemars_private_is_option(); let required = required.unwrap_or(!is_type_option); + let mut schema = if required && is_type_option { T::_schemars_private_non_optional_json_schema(gen) } else { gen.subschema_for::() }; + schema = apply_metadata(schema, metadata); let object = parent.object(); diff --git a/schemars/tests/expected/validate.json b/schemars/tests/expected/validate.json index 228f249..f5a6fc2 100644 --- a/schemars/tests/expected/validate.json +++ b/schemars/tests/expected/validate.json @@ -14,7 +14,8 @@ "regex_str1", "regex_str2", "required_option", - "tel" + "tel", + "x" ], "properties": { "min_max": { @@ -76,6 +77,10 @@ }, "required_option": { "type": "boolean" + }, + "x": { + "type": "integer", + "format": "int32" } } } \ No newline at end of file diff --git a/schemars/tests/validate.rs b/schemars/tests/validate.rs index 6d67531..9992029 100644 --- a/schemars/tests/validate.rs +++ b/schemars/tests/validate.rs @@ -32,6 +32,14 @@ pub struct Struct { map_contains: HashMap, #[validate(required)] required_option: Option, + #[validate(required)] + #[serde(flatten)] + required_flattened: Option, +} + +#[derive(Debug, JsonSchema)] +pub struct Inner { + x: i32, } #[test] diff --git a/schemars_derive/src/attr/validation.rs b/schemars_derive/src/attr/validation.rs index a721ec5..043cccb 100644 --- a/schemars_derive/src/attr/validation.rs +++ b/schemars_derive/src/attr/validation.rs @@ -120,17 +120,9 @@ impl ValidationAttrs { self } - pub fn validation_statements(&self, field_name: &str) -> TokenStream { + pub fn validation_statements(&self, field_name: &str) -> Option { // Assume that the result will be interpolated in a context with the local variable // `schema_object` - the SchemaObject for the struct that contains this field. - let mut statements = Vec::new(); - - // if self.required { - // statements.push(quote! { - // schema_object.object().required.insert(#field_name.to_owned()); - // }); - // } - let mut array_validation = Vec::new(); let mut number_validation = Vec::new(); let mut object_validation = Vec::new(); @@ -210,7 +202,7 @@ impl ValidationAttrs { || string_validation.is_some() || format.is_some() { - statements.push(quote! { + Some(quote! { if let Some(schemars::schema::Schema::Object(prop_schema_object)) = schema_object .object .as_mut() @@ -222,10 +214,10 @@ impl ValidationAttrs { #string_validation #format } - }); + }) + } else { + None } - - statements.into_iter().collect() } } diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs index ed69873..5c68c52 100644 --- a/schemars_derive/src/schema_exprs.rs +++ b/schemars_derive/src/schema_exprs.rs @@ -449,9 +449,15 @@ fn expr_for_struct( type_defs.push(type_def); } - let gen = quote!(gen); + let required = if field.validation_attrs.required { + quote!(Some(true)) + } else { + quote!(None) + }; + + let args = quote!(gen, #required); quote_spanned! {ty.span()=> - .flatten(schemars::_private::json_schema_for_flatten::<#ty>(#gen)) + .flatten(schemars::_private::json_schema_for_flatten::<#ty>(#args)) } }) .collect(); From 60a98694482d1a11afc629aa6c7a93f193e7177e Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Fri, 16 Apr 2021 10:42:03 +0100 Subject: [PATCH 03/13] Refactor out `add_schema_as_property` --- schemars/src/_private.rs | 40 +++----------- schemars/tests/expected/macro_built_enum.json | 11 +++- schemars/tests/macro.rs | 4 +- schemars_derive/src/attr/validation.rs | 49 +++++++++-------- schemars_derive/src/metadata.rs | 34 ++++-------- schemars_derive/src/schema_exprs.rs | 52 +++++++++++++------ 6 files changed, 89 insertions(+), 101 deletions(-) diff --git a/schemars/src/_private.rs b/schemars/src/_private.rs index 8428370..1799f41 100644 --- a/schemars/src/_private.rs +++ b/schemars/src/_private.rs @@ -24,40 +24,12 @@ pub fn json_schema_for_flatten( 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: Option, -) { - let is_type_option = T::_schemars_private_is_option(); - let required = required.unwrap_or(!is_type_option); - - let mut schema = if required && is_type_option { - T::_schemars_private_non_optional_json_schema(gen) +pub fn apply_metadata(schema: Schema, metadata: Metadata) -> Schema { + if metadata == Metadata::default() { + schema } else { - gen.subschema_for::() - }; - - schema = apply_metadata(schema, metadata); - - let object = parent.object(); - if required { - object.required.insert(name.clone()); - } - object.properties.insert(name, 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) - } + 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/tests/expected/macro_built_enum.json b/schemars/tests/expected/macro_built_enum.json index 8564ef7..8a14a4b 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/macro.rs b/schemars/tests/macro.rs index 9991494..ca7dee8 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_derive/src/attr/validation.rs b/schemars_derive/src/attr/validation.rs index 043cccb..c1f0ca7 100644 --- a/schemars_derive/src/attr/validation.rs +++ b/schemars_derive/src/attr/validation.rs @@ -120,9 +120,7 @@ impl ValidationAttrs { self } - pub fn validation_statements(&self, field_name: &str) -> Option { - // Assume that the result will be interpolated in a context with the local variable - // `schema_object` - the SchemaObject for the struct that contains this field. + pub fn apply_to_schema(&self, schema_expr: TokenStream) -> TokenStream { let mut array_validation = Vec::new(); let mut number_validation = Vec::new(); let mut object_validation = Vec::new(); @@ -187,7 +185,7 @@ impl ValidationAttrs { let format = self.format.as_ref().map(|f| { quote! { - prop_schema_object.format = Some(#f.to_string()); + schema_object.format = Some(#f.to_string()); } }); @@ -202,21 +200,22 @@ impl ValidationAttrs { || string_validation.is_some() || format.is_some() { - Some(quote! { - if let Some(schemars::schema::Schema::Object(prop_schema_object)) = schema_object - .object - .as_mut() - .and_then(|o| o.properties.get_mut(#field_name)) + quote! { { - #array_validation - #number_validation - #object_validation - #string_validation - #format + 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 } - }) + } } else { - None + schema_expr } } } @@ -226,8 +225,8 @@ fn wrap_array_validation(v: Vec) -> Option { None } else { Some(quote! { - if prop_schema_object.has_type(schemars::schema::InstanceType::Array) { - let validation = prop_schema_object.array(); + if schema_object.has_type(schemars::schema::InstanceType::Array) { + let validation = schema_object.array(); #(#v)* } }) @@ -239,9 +238,9 @@ fn wrap_number_validation(v: Vec) -> Option { None } else { Some(quote! { - if prop_schema_object.has_type(schemars::schema::InstanceType::Integer) - || prop_schema_object.has_type(schemars::schema::InstanceType::Number) { - let validation = prop_schema_object.number(); + if schema_object.has_type(schemars::schema::InstanceType::Integer) + || schema_object.has_type(schemars::schema::InstanceType::Number) { + let validation = schema_object.number(); #(#v)* } }) @@ -253,8 +252,8 @@ fn wrap_object_validation(v: Vec) -> Option { None } else { Some(quote! { - if prop_schema_object.has_type(schemars::schema::InstanceType::Object) { - let validation = prop_schema_object.object(); + if schema_object.has_type(schemars::schema::InstanceType::Object) { + let validation = schema_object.object(); #(#v)* } }) @@ -266,8 +265,8 @@ fn wrap_string_validation(v: Vec) -> Option { None } else { Some(quote! { - if prop_schema_object.has_type(schemars::schema::InstanceType::String) { - let validation = prop_schema_object.string(); + if schema_object.has_type(schemars::schema::InstanceType::String) { + let validation = schema_object.string(); #(#v)* } }) diff --git a/schemars_derive/src/metadata.rs b/schemars_derive/src/metadata.rs index a84deca..05e5faf 100644 --- a/schemars_derive/src/metadata.rs +++ b/schemars_derive/src/metadata.rs @@ -1,7 +1,6 @@ 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,24 +13,6 @@ pub struct SchemaMetadata<'a> { pub default: Option, } -impl ToTokens for SchemaMetadata<'_> { - fn to_tokens(&self, tokens: &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() - } - }) - }) - } - } -} - impl<'a> SchemaMetadata<'a> { pub fn from_attrs(attrs: &'a Attrs) -> Self { SchemaMetadata { @@ -46,10 +27,15 @@ impl<'a> SchemaMetadata<'a> { } pub fn apply_to_schema(&self, schema_expr: TokenStream) -> TokenStream { - quote! { - { - let schema = #schema_expr; - schemars::_private::apply_metadata(schema, #self) + let setters = self.make_setters(); + if setters.is_empty() { + schema_expr + } else { + quote! { + schemars::_private::apply_metadata(#schema_expr, schemars::schema::Metadata { + #(#setters)* + ..Default::default() + }) } } } diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs index 5c68c52..d8740e4 100644 --- a/schemars_derive/src/schema_exprs.rs +++ b/schemars_derive/src/schema_exprs.rs @@ -413,30 +413,49 @@ fn expr_for_struct( let name = field.name(); let default = field_default_expr(field, set_container_default.is_some()); - let required = match (&default, field.validation_attrs.required) { - (Some(_), _) => quote!(Some(false)), - (None, false) => quote!(None), - (None, true) => quote!(Some(true)), + 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 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 metadata = &SchemaMetadata { + 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 { read_only: field.serde_attrs.skip_deserializing(), write_only: field.serde_attrs.skip_serializing(), default, ..SchemaMetadata::from_attrs(&field.attrs) }; - 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 schema_expr = metadata.apply_to_schema(schema_expr); + let schema_expr = field.validation_attrs.apply_to_schema(schema_expr); - let args = quote!(gen, &mut schema_object, #name.to_owned(), #metadata, #required); - let validation = field.validation_attrs.validation_statements(&name); - - quote_spanned! {ty.span()=> - schemars::_private::add_schema_as_property::<#ty>(#args); - #validation + quote! { + object_validation.properties.insert(#name.to_owned(), #schema_expr); + #maybe_insert_required } }) .collect(); @@ -464,7 +483,7 @@ fn expr_for_struct( 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() @@ -477,6 +496,7 @@ fn expr_for_struct( 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) From 31a5893d1004349887e129a8c3daba2929a047ee Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Fri, 16 Apr 2021 12:30:52 +0100 Subject: [PATCH 04/13] Process validation attributes in newtype structs --- schemars/tests/expected/validate_newtype.json | 8 ++++++++ schemars/tests/validate.rs | 8 ++++++++ schemars_derive/src/schema_exprs.rs | 4 ++-- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 schemars/tests/expected/validate_newtype.json diff --git a/schemars/tests/expected/validate_newtype.json b/schemars/tests/expected/validate_newtype.json new file mode 100644 index 0000000..796aecd --- /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/validate.rs b/schemars/tests/validate.rs index 9992029..8992df2 100644 --- a/schemars/tests/validate.rs +++ b/schemars/tests/validate.rs @@ -46,3 +46,11 @@ pub struct Inner { fn validate() -> TestResult { test_default_generated_schema::("validate") } + +#[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/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs index d8740e4..261d6bc 100644 --- a/schemars_derive/src/schema_exprs.rs +++ b/schemars_derive/src/schema_exprs.rs @@ -61,7 +61,7 @@ fn expr_for_field(field: &Field, allow_ref: bool) -> TokenStream { let span = field.original.span(); let gen = quote!(gen); - if allow_ref { + field.validation_attrs.apply_to_schema(if allow_ref { quote_spanned! {span=> { #type_def @@ -75,7 +75,7 @@ fn expr_for_field(field: &Field, allow_ref: bool) -> TokenStream { <#ty as schemars::JsonSchema>::json_schema(#gen) } } - } + }) } pub fn type_for_field_schema(field: &Field, local_id: usize) -> (syn::Type, Option) { From 9e507272dab27a65717e7a6f70ceb5e0ff9f2480 Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Fri, 16 Apr 2021 13:56:26 +0100 Subject: [PATCH 05/13] Process validation attributes in tuple structs --- schemars/tests/expected/validate_tuple.json | 18 +++++++++++ schemars/tests/validate.rs | 11 +++++++ schemars_derive/src/schema_exprs.rs | 36 +++++++++++++++------ 3 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 schemars/tests/expected/validate_tuple.json diff --git a/schemars/tests/expected/validate_tuple.json b/schemars/tests/expected/validate_tuple.json new file mode 100644 index 0000000..8ab6eaa --- /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/validate.rs b/schemars/tests/validate.rs index 8992df2..00d1b57 100644 --- a/schemars/tests/validate.rs +++ b/schemars/tests/validate.rs @@ -47,6 +47,17 @@ fn validate() -> TestResult { test_default_generated_schema::("validate") } +#[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); diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs index 261d6bc..739641c 100644 --- a/schemars_derive/src/schema_exprs.rs +++ b/schemars_derive/src/schema_exprs.rs @@ -61,7 +61,14 @@ fn expr_for_field(field: &Field, allow_ref: bool) -> TokenStream { let span = field.original.span(); let gen = quote!(gen); - field.validation_attrs.apply_to_schema(if allow_ref { + let schema_expr = if field.validation_attrs.required { + quote_spanned! {span=> + { + #type_def + <#ty as schemars::JsonSchema>::_schemars_private_non_optional_json_schema(#gen) + } + } + } else if allow_ref { quote_spanned! {span=> { #type_def @@ -75,7 +82,8 @@ fn expr_for_field(field: &Field, allow_ref: bool) -> TokenStream { <#ty as schemars::JsonSchema>::json_schema(#gen) } } - }) + }; + field.validation_attrs.apply_to_schema(schema_expr) } pub fn type_for_field_schema(field: &Field, local_id: usize) -> (syn::Type, Option) { @@ -375,17 +383,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() + }) } } From 4be21bd8119a0a229328c2ba44a5954344ec7781 Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Fri, 16 Apr 2021 14:23:10 +0100 Subject: [PATCH 06/13] Refactor out "local_id" for type definitions --- schemars_derive/src/lib.rs | 2 +- schemars_derive/src/schema_exprs.rs | 39 +++++++++++++---------------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/schemars_derive/src/lib.rs b/schemars_derive/src/lib.rs index 5325f92..5fa4ac2 100644 --- a/schemars_derive/src/lib.rs +++ b/schemars_derive/src/lib.rs @@ -51,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/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs index 739641c..68dcec6 100644 --- a/schemars_derive/src/schema_exprs.rs +++ b/schemars_derive/src/schema_exprs.rs @@ -57,7 +57,7 @@ pub fn expr_for_repr(cont: &Container) -> Result { } 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); @@ -86,18 +86,18 @@ fn expr_for_field(field: &Field, allow_ref: bool) -> TokenStream { field.validation_attrs.apply_to_schema(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()=> @@ -331,7 +331,7 @@ 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()=> { @@ -354,7 +354,7 @@ 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()=> { @@ -421,18 +421,13 @@ 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 (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); let schema_expr = if field.validation_attrs.required { @@ -470,8 +465,11 @@ fn expr_for_struct( let schema_expr = field.validation_attrs.apply_to_schema(schema_expr); quote! { - object_validation.properties.insert(#name.to_owned(), #schema_expr); - #maybe_insert_required + { + #type_def + object_validation.properties.insert(#name.to_owned(), #schema_expr); + #maybe_insert_required + } } }) .collect(); @@ -479,10 +477,7 @@ 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 required = if field.validation_attrs.required { quote!(Some(true)) @@ -492,7 +487,10 @@ fn expr_for_struct( let args = quote!(gen, #required); quote_spanned! {ty.span()=> - .flatten(schemars::_private::json_schema_for_flatten::<#ty>(#args)) + .flatten({ + #type_def + schemars::_private::json_schema_for_flatten::<#ty>(#args) + }) } }) .collect(); @@ -506,7 +504,6 @@ fn expr_for_struct( }; quote! { { - #(#type_defs)* #set_container_default let mut schema_object = schemars::schema::SchemaObject { instance_type: Some(schemars::schema::InstanceType::Object.into()), From 5f841f2e5ce9e8179dca58b2852ef11626c8af4c Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Fri, 16 Apr 2021 16:53:15 +0100 Subject: [PATCH 07/13] Refactoring --- schemars_derive/src/attr/mod.rs | 22 ++++ schemars_derive/src/attr/validation.rs | 6 +- schemars_derive/src/metadata.rs | 31 +---- schemars_derive/src/schema_exprs.rs | 159 ++++++++++++++----------- 4 files changed, 117 insertions(+), 101 deletions(-) diff --git a/schemars_derive/src/attr/mod.rs b/schemars_derive/src/attr/mod.rs index 10ed772..93f76c5 100644 --- a/schemars_derive/src/attr/mod.rs +++ b/schemars_derive/src/attr/mod.rs @@ -5,6 +5,7 @@ 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; @@ -53,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], diff --git a/schemars_derive/src/attr/validation.rs b/schemars_derive/src/attr/validation.rs index c1f0ca7..db72f31 100644 --- a/schemars_derive/src/attr/validation.rs +++ b/schemars_derive/src/attr/validation.rs @@ -120,7 +120,7 @@ impl ValidationAttrs { self } - pub fn apply_to_schema(&self, schema_expr: TokenStream) -> TokenStream { + 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(); @@ -200,7 +200,7 @@ impl ValidationAttrs { || string_validation.is_some() || format.is_some() { - quote! { + *schema_expr = quote! { { let mut schema = #schema_expr; if let schemars::schema::Schema::Object(schema_object) = &mut schema @@ -214,8 +214,6 @@ impl ValidationAttrs { schema } } - } else { - schema_expr } } } diff --git a/schemars_derive/src/metadata.rs b/schemars_derive/src/metadata.rs index 05e5faf..aefe243 100644 --- a/schemars_derive/src/metadata.rs +++ b/schemars_derive/src/metadata.rs @@ -1,5 +1,3 @@ -use crate::attr; -use attr::Attrs; use proc_macro2::TokenStream; #[derive(Debug, Clone)] @@ -14,24 +12,10 @@ pub struct SchemaMetadata<'a> { } 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 { + pub fn apply_to_schema(&self, schema_expr: &mut TokenStream) { let setters = self.make_setters(); - if setters.is_empty() { - schema_expr - } else { - quote! { + if !setters.is_empty() { + *schema_expr = quote! { schemars::_private::apply_metadata(#schema_expr, schemars::schema::Metadata { #(#setters)* ..Default::default() @@ -91,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/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs index 68dcec6..b85e64d 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,13 +47,13 @@ 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 { @@ -61,29 +61,24 @@ fn expr_for_field(field: &Field, allow_ref: bool) -> TokenStream { let span = field.original.span(); let gen = quote!(gen); - let schema_expr = if field.validation_attrs.required { + let mut schema_expr = if field.validation_attrs.required { quote_spanned! {span=> - { - #type_def - <#ty as schemars::JsonSchema>::_schemars_private_non_optional_json_schema(#gen) - } + <#ty as schemars::JsonSchema>::_schemars_private_non_optional_json_schema(#gen) } } else if allow_ref { quote_spanned! {span=> - { - #type_def - #gen.subschema_for::<#ty>() - } + #gen.subschema_for::<#ty>() } } else { quote_spanned! {span=> - { - #type_def - <#ty as schemars::JsonSchema>::json_schema(#gen) - } + <#ty as schemars::JsonSchema>::json_schema(#gen) } }; - field.validation_attrs.apply_to_schema(schema_expr) + + 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) -> (syn::Type, Option) { @@ -167,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: { @@ -184,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! { @@ -208,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: { @@ -224,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! { @@ -248,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! { @@ -297,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: { @@ -317,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! { @@ -333,12 +343,12 @@ fn expr_for_untagged_enum_variant(variant: &Variant, deny_unknown_fields: bool) if let Some(with_attr) = &variant.attrs.with { 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 { @@ -356,12 +366,12 @@ fn expr_for_untagged_enum_variant_for_flatten( if let Some(with_attr) = &variant.attrs.with { 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 { @@ -429,17 +439,6 @@ fn expr_for_struct( let (ty, type_def) = type_for_field_schema(field); - let gen = quote!(gen); - let 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 maybe_insert_required = match (&default, field.validation_attrs.required) { (Some(_), _) => TokenStream::new(), (None, false) => { @@ -458,11 +457,22 @@ fn expr_for_struct( 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 schema_expr = metadata.apply_to_schema(schema_expr); - let schema_expr = field.validation_attrs.apply_to_schema(schema_expr); + 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>() + } + }; + + metadata.apply_to_schema(&mut schema_expr); + field.validation_attrs.apply_to_schema(&mut schema_expr); quote! { { @@ -486,12 +496,12 @@ fn expr_for_struct( }; let args = quote!(gen, #required); - quote_spanned! {ty.span()=> - .flatten({ - #type_def - schemars::_private::json_schema_for_flatten::<#ty>(#args) - }) - } + 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(); @@ -513,7 +523,7 @@ fn expr_for_struct( #set_additional_properties #(#properties)* schemars::schema::Schema::Object(schema_object) - #(#flattens)* + #(.flatten(#flattens))* } } } @@ -582,3 +592,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 + } + } + } +} From c013052f599cb6442df3497cefc89644a631471e Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Fri, 16 Apr 2021 22:31:03 +0100 Subject: [PATCH 08/13] Support inline regex --- schemars/tests/expected/validate.json | 5 +++++ schemars/tests/validate.rs | 2 ++ schemars_derive/src/attr/validation.rs | 20 ++++++++++++++++---- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/schemars/tests/expected/validate.json b/schemars/tests/expected/validate.json index f5a6fc2..0878e84 100644 --- a/schemars/tests/expected/validate.json +++ b/schemars/tests/expected/validate.json @@ -13,6 +13,7 @@ "pair", "regex_str1", "regex_str2", + "regex_str3", "required_option", "tel", "x" @@ -32,6 +33,10 @@ "type": "string", "pattern": "^[Hh]ello\\b" }, + "regex_str3": { + "type": "string", + "pattern": "^\\d+$" + }, "contains_str1": { "type": "string", "pattern": "substring\\.\\.\\." diff --git a/schemars/tests/validate.rs b/schemars/tests/validate.rs index 00d1b57..ca704a7 100644 --- a/schemars/tests/validate.rs +++ b/schemars/tests/validate.rs @@ -14,6 +14,8 @@ pub struct Struct { 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"))] diff --git a/schemars_derive/src/attr/validation.rs b/schemars_derive/src/attr/validation.rs index db72f31..8594db1 100644 --- a/schemars_derive/src/attr/validation.rs +++ b/schemars_derive/src/attr/validation.rs @@ -2,7 +2,7 @@ use super::parse_lit_str; use proc_macro2::TokenStream; use syn::ExprLit; use syn::NestedMeta; -use syn::{Expr, Lit, Meta, MetaNameValue, Path}; +use syn::{Expr, Lit, Meta, MetaNameValue}; #[derive(Debug, Default)] pub struct ValidationAttrs { @@ -11,7 +11,7 @@ pub struct ValidationAttrs { pub length_equal: Option, pub range_min: Option, pub range_max: Option, - pub regex: Option, + pub regex: Option, pub contains: Option, pub required: bool, pub format: Option<&'static str>, @@ -84,7 +84,9 @@ impl ValidationAttrs { path, lit: Lit::Str(regex), .. - })) if path.is_ident("regex") => self.regex = parse_lit_str(regex).ok(), + })) if path.is_ident("regex") => { + self.regex = parse_lit_str::(regex).ok().map(Expr::Path) + } NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("regex") => { self.regex = meta_list.nested.iter().find_map(|x| match x { @@ -92,7 +94,17 @@ impl ValidationAttrs { path, lit: Lit::Str(regex), .. - })) if path.is_ident("path") => parse_lit_str(regex).ok(), + })) if path.is_ident("path") => { + parse_lit_str::(regex).ok().map(Expr::Path) + } + NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit: Lit::Str(regex), + .. + })) if path.is_ident("pattern") => Some(Expr::Lit(syn::ExprLit { + attrs: Vec::new(), + lit: Lit::Str(regex.clone()), + })), _ => None, }); } From 7914593d89fe703458e64dd12c5475796b883232 Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Sun, 18 Apr 2021 22:17:53 +0100 Subject: [PATCH 09/13] Allow setting validation attributes via #[schemars(...)] --- docs/_includes/examples/schemars_attrs.rs | 9 +- .../examples/schemars_attrs.schema.json | 11 +- docs/_includes/examples/validate.rs | 24 ++ docs/_includes/examples/validate.schema.json | 64 +++++ schemars/examples/schemars_attrs.rs | 9 +- schemars/examples/schemars_attrs.schema.json | 11 +- schemars/examples/validate.rs | 24 ++ schemars/examples/validate.schema.json | 64 +++++ schemars/tests/expected/validate.json | 13 + .../expected/validate_schemars_attrs.json | 104 +++++++ schemars/tests/ui/invalid_attrs.stderr | 2 +- schemars/tests/ui/invalid_validation_attrs.rs | 9 + .../tests/ui/invalid_validation_attrs.stderr | 29 ++ schemars/tests/validate.rs | 49 ++++ schemars_derive/src/ast/from_serde.rs | 2 +- schemars_derive/src/attr/mod.rs | 22 +- schemars_derive/src/attr/validation.rs | 260 +++++++++++++----- 17 files changed, 607 insertions(+), 99 deletions(-) create mode 100644 docs/_includes/examples/validate.rs create mode 100644 docs/_includes/examples/validate.schema.json create mode 100644 schemars/examples/validate.rs create mode 100644 schemars/examples/validate.schema.json create mode 100644 schemars/tests/expected/validate_schemars_attrs.json create mode 100644 schemars/tests/ui/invalid_validation_attrs.rs create mode 100644 schemars/tests/ui/invalid_validation_attrs.stderr diff --git a/docs/_includes/examples/schemars_attrs.rs b/docs/_includes/examples/schemars_attrs.rs index f830e9f..cd69b52 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 d044193..958cb6b 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 0000000..4116976 --- /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 0000000..e8ed35e --- /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 f830e9f..cd69b52 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 d044193..958cb6b 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 0000000..4116976 --- /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 0000000..e8ed35e --- /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/tests/expected/validate.json b/schemars/tests/expected/validate.json index 0878e84..d4a14e3 100644 --- a/schemars/tests/expected/validate.json +++ b/schemars/tests/expected/validate.json @@ -9,7 +9,9 @@ "homepage", "map_contains", "min_max", + "min_max2", "non_empty_str", + "non_empty_str2", "pair", "regex_str1", "regex_str2", @@ -25,6 +27,12 @@ "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" @@ -62,6 +70,11 @@ "maxLength": 100, "minLength": 1 }, + "non_empty_str2": { + "type": "string", + "maxLength": 1000, + "minLength": 1 + }, "pair": { "type": "array", "items": { diff --git a/schemars/tests/expected/validate_schemars_attrs.json b/schemars/tests/expected/validate_schemars_attrs.json new file mode 100644 index 0000000..d4a14e3 --- /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/ui/invalid_attrs.stderr b/schemars/tests/ui/invalid_attrs.stderr index fa3a4f4..4823859 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 0000000..144ddaa --- /dev/null +++ b/schemars/tests/ui/invalid_validation_attrs.rs @@ -0,0 +1,9 @@ +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); + +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 0000000..3d68082 --- /dev/null +++ b/schemars/tests/ui/invalid_validation_attrs.stderr @@ -0,0 +1,29 @@ +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); + | ^^^ diff --git a/schemars/tests/validate.rs b/schemars/tests/validate.rs index ca704a7..825b558 100644 --- a/schemars/tests/validate.rs +++ b/schemars/tests/validate.rs @@ -6,10 +6,15 @@ 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"))] @@ -28,6 +33,8 @@ pub struct Struct { 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")] @@ -49,6 +56,48 @@ 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, + #[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, + #[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, diff --git a/schemars_derive/src/ast/from_serde.rs b/schemars_derive/src/ast/from_serde.rs index db2e092..83bfcf3 100644 --- a/schemars_derive/src/ast/from_serde.rs +++ b/schemars_derive/src/ast/from_serde.rs @@ -73,7 +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), + validation_attrs: ValidationAttrs::new(&serde.original.attrs, errors), }) } } diff --git a/schemars_derive/src/attr/mod.rs b/schemars_derive/src/attr/mod.rs index 93f76c5..cc81a92 100644 --- a/schemars_derive/src/attr/mod.rs +++ b/schemars_derive/src/attr/mod.rs @@ -165,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() @@ -176,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"); } } } @@ -193,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 index 8594db1..9b807b3 100644 --- a/schemars_derive/src/attr/validation.rs +++ b/schemars_derive/src/attr/validation.rs @@ -1,8 +1,11 @@ -use super::parse_lit_str; +use super::{get_lit_str, get_meta_items, parse_lit_into_path, parse_lit_str}; use proc_macro2::TokenStream; -use syn::ExprLit; -use syn::NestedMeta; -use syn::{Expr, Lit, Meta, MetaNameValue}; +use serde_derive_internals::Ctxt; +use syn::{Expr, ExprLit, ExprPath, Lit, Meta, MetaNameValue, NestedMeta}; + +pub(crate) static VALIDATION_KEYWORDS: &[&str] = &[ + "range", "regex", "contains", "email", "phone", "url", "length", "required", +]; #[derive(Debug, Default)] pub struct ValidationAttrs { @@ -18,16 +21,43 @@ pub struct ValidationAttrs { } impl ValidationAttrs { - pub fn new(attrs: &[syn::Attribute]) -> Self { + pub fn new(attrs: &[syn::Attribute], errors: &Ctxt) -> Self { // TODO allow setting "validate" attributes through #[schemars(...)] - ValidationAttrs::default().populate(attrs) + ValidationAttrs::default() + .populate(attrs, "schemars", false, errors) + .populate(attrs, "validate", true, errors) } - fn populate(mut self, attrs: &[syn::Attribute]) -> Self { - // TODO don't silently ignore unparseable attributes + fn populate( + mut self, + attrs: &[syn::Attribute], + attr_type: &'static str, + ignore_errors: bool, + errors: &Ctxt, + ) -> Self { + let duplicate_error = |meta: &MetaNameValue| { + if !ignore_errors { + let msg = format!( + "duplicate schemars attribute `{}`", + meta.path.get_ident().unwrap() + ); + errors.error_spanned_by(meta, msg) + } + }; + let mutual_exclusive_error = |meta: &MetaNameValue, other: &str| { + if !ignore_errors { + let msg = format!( + "schemars attribute cannot contain both `{}` and `{}`", + meta.path.get_ident().unwrap(), + other, + ); + errors.error_spanned_by(meta, msg) + } + }; + for meta_item in attrs .iter() - .flat_map(|attr| get_meta_items(attr, "validate")) + .flat_map(|attr| get_meta_items(attr, attr_type, errors)) .flatten() { match &meta_item { @@ -35,15 +65,43 @@ impl ValidationAttrs { for nested in meta_list.nested.iter() { match nested { NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("min") => { - self.length_min = str_or_num_to_expr(&nv.lit); + if self.length_min.is_some() { + duplicate_error(nv) + } else if self.length_equal.is_some() { + mutual_exclusive_error(nv, "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") => { - self.length_max = str_or_num_to_expr(&nv.lit); + if self.length_max.is_some() { + duplicate_error(nv) + } else if self.length_equal.is_some() { + mutual_exclusive_error(nv, "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") => { - self.length_equal = str_or_num_to_expr(&nv.lit); + if self.length_equal.is_some() { + duplicate_error(nv) + } else if self.length_min.is_some() { + mutual_exclusive_error(nv, "min") + } else if self.length_max.is_some() { + mutual_exclusive_error(nv, "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"), + ); + } } - _ => {} } } } @@ -52,78 +110,118 @@ impl ValidationAttrs { for nested in meta_list.nested.iter() { match nested { NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("min") => { - self.range_min = str_or_num_to_expr(&nv.lit); + if self.range_min.is_some() { + duplicate_error(nv) + } else { + self.range_min = str_or_num_to_expr(&errors, "min", &nv.lit); + } } NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("max") => { - self.range_max = str_or_num_to_expr(&nv.lit); + if self.range_max.is_some() { + duplicate_error(nv) + } 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(m) - if m.path().is_ident("required") || m.path().is_ident("required_nested") => + NestedMeta::Meta(Meta::Path(m)) + if m.is_ident("required") || m.is_ident("required_nested") => { self.required = true; } - NestedMeta::Meta(m) if m.path().is_ident("email") => { + // TODO cause compile error if format is already Some + // FIXME #[validate(...)] overrides #[schemars(...)] - should be other way around! + NestedMeta::Meta(Meta::Path(m)) if m.is_ident("email") => { self.format = Some("email"); } - - NestedMeta::Meta(m) if m.path().is_ident("url") => { + NestedMeta::Meta(Meta::Path(m)) if m.is_ident("url") => { self.format = Some("uri"); } - - NestedMeta::Meta(m) if m.path().is_ident("phone") => { + NestedMeta::Meta(Meta::Path(m)) if m.is_ident("phone") => { self.format = Some("phone"); } - NestedMeta::Meta(Meta::NameValue(MetaNameValue { - path, - lit: Lit::Str(regex), - .. - })) if path.is_ident("regex") => { - self.regex = parse_lit_str::(regex).ok().map(Expr::Path) + // TODO cause compile error if regex/contains are specified more than once + // FIXME #[validate(...)] overrides #[schemars(...)] - should be other way around! + NestedMeta::Meta(Meta::NameValue(MetaNameValue { path, lit, .. })) + if path.is_ident("regex") => + { + self.regex = parse_lit_into_expr_path(errors, attr_type, "regex", lit).ok() } NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("regex") => { - self.regex = meta_list.nested.iter().find_map(|x| match x { - NestedMeta::Meta(Meta::NameValue(MetaNameValue { - path, - lit: Lit::Str(regex), - .. - })) if path.is_ident("path") => { - parse_lit_str::(regex).ok().map(Expr::Path) + 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: Lit::Str(regex), - .. - })) if path.is_ident("pattern") => Some(Expr::Lit(syn::ExprLit { - attrs: Vec::new(), - lit: Lit::Str(regex.clone()), - })), - _ => None, - }); + } } - NestedMeta::Meta(Meta::NameValue(MetaNameValue { - path, - lit: Lit::Str(contains), - .. - })) if path.is_ident("contains") => self.contains = Some(contains.value()), + NestedMeta::Meta(Meta::NameValue(MetaNameValue { path, lit, .. })) + if path.is_ident("contains") => + { + self.contains = get_lit_str(errors, attr_type, "contains", lit) + .ok() + .map(|litstr| litstr.value()) + } NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("contains") => { - self.contains = meta_list.nested.iter().find_map(|x| match x { - NestedMeta::Meta(Meta::NameValue(MetaNameValue { - path, - lit: Lit::Str(contains), - .. - })) if path.is_ident("pattern") => Some(contains.value()), - _ => 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"), + ); + } + } + } + } } _ => {} @@ -230,6 +328,21 @@ impl ValidationAttrs { } } +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 @@ -283,27 +396,22 @@ fn wrap_string_validation(v: Vec) -> Option { } } -fn get_meta_items( - attr: &syn::Attribute, - attr_type: &'static str, -) -> Result, ()> { - if !attr.path.is_ident(attr_type) { - return Ok(Vec::new()); - } - - match attr.parse_meta() { - Ok(Meta::List(meta)) => Ok(meta.nested.into_iter().collect()), - _ => Err(()), - } -} - -fn str_or_num_to_expr(lit: &Lit) -> Option { +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::Str(s) => parse_lit_str::(s).ok().map(Expr::Path), Lit::Int(_) | Lit::Float(_) => Some(Expr::Lit(ExprLit { attrs: Vec::new(), lit: lit.clone(), })), - _ => None, + _ => { + cx.error_spanned_by( + lit, + format!( + "expected `{}` to be a string or number literal", + meta_item_name + ), + ); + None + } } } From d99a96fc8aa7e8cd546ed0cd8b86e4b6f1d9857f Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Sat, 24 Apr 2021 11:46:07 +0100 Subject: [PATCH 10/13] Add some doc comments --- schemars/src/_private.rs | 5 ++--- schemars/src/schema.rs | 21 +++++++++++++++++++-- schemars_derive/src/attr/validation.rs | 1 - schemars_derive/src/schema_exprs.rs | 6 +----- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/schemars/src/_private.rs b/schemars/src/_private.rs index 1799f41..b914699 100644 --- a/schemars/src/_private.rs +++ b/schemars/src/_private.rs @@ -6,12 +6,11 @@ use crate::JsonSchema; // Helper for generating schemas for flattened `Option` fields. pub fn json_schema_for_flatten( gen: &mut SchemaGenerator, - required: Option, + required: bool, ) -> Schema { let mut schema = T::_schemars_private_non_optional_json_schema(gen); - let required = required.unwrap_or_else(|| !T::_schemars_private_is_option()); - if !required { + if T::_schemars_private_is_option() && !required { if let Schema::Object(SchemaObject { object: Some(ref mut object_validation), .. diff --git a/schemars/src/schema.rs b/schemars/src/schema.rs index e9a0124..d358a44 100644 --- a/schemars/src/schema.rs +++ b/schemars/src/schema.rs @@ -231,7 +231,11 @@ impl SchemaObject { self.reference.is_some() } - // TODO document + /// 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() @@ -522,7 +526,20 @@ impl From> for SingleOrVec { } impl SingleOrVec { - // TODO document + /// Returns `true` if `self` is either a `Single` equal to `x`, or a `Vec` containing `x`. + /// + /// # Examples + /// + /// ``` + /// let s = SingleOrVec::Single(10); + /// assert!(s.contains(&10)); + /// assert!(!s.contains(&20)); + /// + /// let v = SingleOrVec::Vec(vec![10, 20]); + /// assert!(s.contains(&10)); + /// assert!(s.contains(&20)); + /// assert!(!s.contains(&30)); + /// ``` pub fn contains(&self, x: &T) -> bool { match self { SingleOrVec::Single(s) => s.deref() == x, diff --git a/schemars_derive/src/attr/validation.rs b/schemars_derive/src/attr/validation.rs index 9b807b3..1dccc6d 100644 --- a/schemars_derive/src/attr/validation.rs +++ b/schemars_derive/src/attr/validation.rs @@ -22,7 +22,6 @@ pub struct ValidationAttrs { impl ValidationAttrs { pub fn new(attrs: &[syn::Attribute], errors: &Ctxt) -> Self { - // TODO allow setting "validate" attributes through #[schemars(...)] ValidationAttrs::default() .populate(attrs, "schemars", false, errors) .populate(attrs, "validate", true, errors) diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs index b85e64d..c50f332 100644 --- a/schemars_derive/src/schema_exprs.rs +++ b/schemars_derive/src/schema_exprs.rs @@ -489,11 +489,7 @@ fn expr_for_struct( .map(|field| { let (ty, type_def) = type_for_field_schema(field); - let required = if field.validation_attrs.required { - quote!(Some(true)) - } else { - quote!(None) - }; + let required = field.validation_attrs.required; let args = quote!(gen, #required); let mut schema_expr = quote_spanned! {ty.span()=> From af69a8ea11905ff8496f67d3052074cc44d962ad Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Sat, 24 Apr 2021 13:43:45 +0100 Subject: [PATCH 11/13] Fix doc test --- schemars/src/schema.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/schemars/src/schema.rs b/schemars/src/schema.rs index d358a44..01fce86 100644 --- a/schemars/src/schema.rs +++ b/schemars/src/schema.rs @@ -531,14 +531,16 @@ impl SingleOrVec { /// # Examples /// /// ``` - /// let s = SingleOrVec::Single(10); + /// use schemars::schema::SingleOrVec; + /// + /// let s = SingleOrVec::from(10); /// assert!(s.contains(&10)); /// assert!(!s.contains(&20)); /// - /// let v = SingleOrVec::Vec(vec![10, 20]); - /// assert!(s.contains(&10)); - /// assert!(s.contains(&20)); - /// assert!(!s.contains(&30)); + /// 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 { From 605db3bba8a6b91632478a8626d08dac1bb8df7d Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Fri, 17 Sep 2021 22:57:51 +0100 Subject: [PATCH 12/13] Emit compilation errors for duplicate validation attributes --- schemars/tests/ui/invalid_validation_attrs.rs | 26 ++ .../tests/ui/invalid_validation_attrs.stderr | 32 ++- schemars/tests/ui/repr_missing.stderr | 2 +- schemars/tests/ui/schema_for_arg_value.stderr | 2 +- schemars/tests/validate.rs | 2 + schemars_derive/src/attr/validation.rs | 248 ++++++++++++------ schemars_derive/src/schema_exprs.rs | 8 +- 7 files changed, 230 insertions(+), 90 deletions(-) diff --git a/schemars/tests/ui/invalid_validation_attrs.rs b/schemars/tests/ui/invalid_validation_attrs.rs index 144ddaa..be84362 100644 --- a/schemars/tests/ui/invalid_validation_attrs.rs +++ b/schemars/tests/ui/invalid_validation_attrs.rs @@ -6,4 +6,30 @@ pub struct Struct1(#[validate(regex = 0, foo, length(min = 1, equal = 2, bar))] #[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 index 3d68082..933fd66 100644 --- a/schemars/tests/ui/invalid_validation_attrs.stderr +++ b/schemars/tests/ui/invalid_validation_attrs.stderr @@ -20,10 +20,40 @@ 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 495c177..a7016b2 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 c787985..a316306 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 index 825b558..c2060e0 100644 --- a/schemars/tests/validate.rs +++ b/schemars/tests/validate.rs @@ -62,12 +62,14 @@ pub struct Struct2 { 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..."))] diff --git a/schemars_derive/src/attr/validation.rs b/schemars_derive/src/attr/validation.rs index 1dccc6d..398fa59 100644 --- a/schemars_derive/src/attr/validation.rs +++ b/schemars_derive/src/attr/validation.rs @@ -1,23 +1,48 @@ 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}; +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 { - pub length_min: Option, - pub length_max: Option, - pub length_equal: Option, - pub range_min: Option, - pub range_max: Option, - pub regex: Option, - pub contains: Option, - pub required: bool, - pub format: Option<&'static str>, + 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 { @@ -27,6 +52,10 @@ impl ValidationAttrs { .populate(attrs, "validate", true, errors) } + pub fn required(&self) -> bool { + self.required + } + fn populate( mut self, attrs: &[syn::Attribute], @@ -34,23 +63,37 @@ impl ValidationAttrs { ignore_errors: bool, errors: &Ctxt, ) -> Self { - let duplicate_error = |meta: &MetaNameValue| { + let duplicate_error = |path: &Path| { if !ignore_errors { let msg = format!( "duplicate schemars attribute `{}`", - meta.path.get_ident().unwrap() + path.get_ident().unwrap() ); - errors.error_spanned_by(meta, msg) + errors.error_spanned_by(path, msg) } }; - let mutual_exclusive_error = |meta: &MetaNameValue, other: &str| { + let mutual_exclusive_error = |path: &Path, other: &str| { if !ignore_errors { let msg = format!( "schemars attribute cannot contain both `{}` and `{}`", - meta.path.get_ident().unwrap(), + path.get_ident().unwrap(), other, ); - errors.error_spanned_by(meta, msg) + 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) } }; @@ -65,29 +108,29 @@ impl ValidationAttrs { match nested { NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("min") => { if self.length_min.is_some() { - duplicate_error(nv) + duplicate_error(&nv.path) } else if self.length_equal.is_some() { - mutual_exclusive_error(nv, "equal") + 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) + duplicate_error(&nv.path) } else if self.length_equal.is_some() { - mutual_exclusive_error(nv, "equal") + 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) + duplicate_error(&nv.path) } else if self.length_min.is_some() { - mutual_exclusive_error(nv, "min") + mutual_exclusive_error(&nv.path, "min") } else if self.length_max.is_some() { - mutual_exclusive_error(nv, "max") + mutual_exclusive_error(&nv.path, "max") } else { self.length_equal = str_or_num_to_expr(&errors, "equal", &nv.lit); @@ -110,14 +153,14 @@ impl ValidationAttrs { match nested { NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("min") => { if self.range_min.is_some() { - duplicate_error(nv) + 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) + duplicate_error(&nv.path) } else { self.range_max = str_or_num_to_expr(&errors, "max", &nv.lit); } @@ -140,53 +183,74 @@ impl ValidationAttrs { self.required = true; } - // TODO cause compile error if format is already Some - // FIXME #[validate(...)] overrides #[schemars(...)] - should be other way around! - NestedMeta::Meta(Meta::Path(m)) if m.is_ident("email") => { - self.format = Some("email"); + 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(m)) if m.is_ident("url") => { - self.format = Some("uri"); + 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(m)) if m.is_ident("phone") => { - self.format = Some("phone"); + 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), + } } - // TODO cause compile error if regex/contains are specified more than once - // FIXME #[validate(...)] overrides #[schemars(...)] - should be other way around! - NestedMeta::Meta(Meta::NameValue(MetaNameValue { path, lit, .. })) - if path.is_ident("regex") => - { - self.regex = parse_lit_into_expr_path(errors, attr_type, "regex", lit).ok() + 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") => { - 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"), - ); + 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"), + ); + } + } } } } @@ -196,27 +260,44 @@ impl ValidationAttrs { NestedMeta::Meta(Meta::NameValue(MetaNameValue { path, lit, .. })) if path.is_ident("contains") => { - self.contains = get_lit_str(errors, attr_type, "contains", lit) - .ok() - .map(|litstr| litstr.value()) + 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") => { - 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"), - ); + 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" + ), + ); + } + } } } } @@ -293,6 +374,7 @@ impl ValidationAttrs { } let format = self.format.as_ref().map(|f| { + let f = f.schema_str(); quote! { schema_object.format = Some(#f.to_string()); } diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs index c50f332..f2c76e9 100644 --- a/schemars_derive/src/schema_exprs.rs +++ b/schemars_derive/src/schema_exprs.rs @@ -61,7 +61,7 @@ fn expr_for_field(field: &Field, allow_ref: bool) -> TokenStream { let span = field.original.span(); let gen = quote!(gen); - let mut schema_expr = if field.validation_attrs.required { + let mut schema_expr = if field.validation_attrs.required() { quote_spanned! {span=> <#ty as schemars::JsonSchema>::_schemars_private_non_optional_json_schema(#gen) } @@ -439,7 +439,7 @@ fn expr_for_struct( let (ty, type_def) = type_for_field_schema(field); - let maybe_insert_required = match (&default, field.validation_attrs.required) { + let maybe_insert_required = match (&default, field.validation_attrs.required()) { (Some(_), _) => TokenStream::new(), (None, false) => { quote! { @@ -461,7 +461,7 @@ fn expr_for_struct( }; let gen = quote!(gen); - let mut schema_expr = if field.validation_attrs.required { + let mut schema_expr = if field.validation_attrs.required() { quote_spanned! {ty.span()=> <#ty as schemars::JsonSchema>::_schemars_private_non_optional_json_schema(#gen) } @@ -489,7 +489,7 @@ fn expr_for_struct( .map(|field| { let (ty, type_def) = type_for_field_schema(field); - let required = field.validation_attrs.required; + let required = field.validation_attrs.required(); let args = quote!(gen, #required); let mut schema_expr = quote_spanned! {ty.span()=> From 63b3055e7b451ec2432778745f0356297da0bf23 Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Fri, 17 Sep 2021 23:53:46 +0100 Subject: [PATCH 13/13] Fix indexmap tests for rust 1.37 --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be571f7..90f4f72 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 }}