diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be571f77..b27b9832 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: include: - rust: 1.37.0 # exclude ui_test as the output is slightly different in rustc 1.37 - test_features: "--features impl_json_schema,chrono,indexmap,either,uuid,smallvec,arrayvec" + test_features: "--features impl_json_schema,chrono,indexmap,either,uuid,smallvec,arrayvec,enumset" allow_failure: false - rust: stable test_features: "--all-features" @@ -38,6 +38,8 @@ jobs: run: cargo check --verbose --no-default-features continue-on-error: ${{ matrix.allow_failure }} working-directory: ./schemars + - if: matrix.rust == '1.37.0' + run: cargo update -p indexmap --precise 1.6.2 - name: Run tests run: cargo test --verbose ${{ matrix.test_features }} --no-fail-fast continue-on-error: ${{ matrix.allow_failure }} diff --git a/.gitignore b/.gitignore index 088ba6ba..cc96bb34 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk +.idea diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c8f800..3e78341c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,31 @@ # Changelog -## [0.8.4] - **In-dev** +## [0.8.8] - 2021-11-25 +### Added: +- Implement `JsonSchema` for types from `rust_decimal` and `bigdecimal` crates (https://github.com/GREsau/schemars/pull/101) + +### Fixed: +- Fixes for internally tagged enums and flattening additional_properties (https://github.com/GREsau/schemars/pull/113) + +## [0.8.7] - 2021-11-14 +### Added: +- Implement `JsonSchema` for `EnumSet` (https://github.com/GREsau/schemars/pull/92) + +### Fixed: +- Do not cause compile error when using a default value that doesn't implement `Serialize` (https://github.com/GREsau/schemars/issues/115) + +## [0.8.6] - 2021-09-26 +### Changed: +- Use `oneOf` instead of `anyOf` for enums when possible (https://github.com/GREsau/schemars/issues/108) + +## [0.8.5] - 2021-09-20 +### Fixed: +- Allow fields with plain `#[validate]` attributes (https://github.com/GREsau/schemars/issues/109) + +## [0.8.4] - 2021-09-19 ### Added: - `#[schemars(schema_with = "...")]` attribute can now be set on enum variants. +- Deriving JsonSchema will now take into account `#[validate(...)]` attributes, compatible with the [validator](https://github.com/Keats/validator) crate (https://github.com/GREsau/schemars/pull/78) ## [0.8.3] - 2021-04-05 ### Added: diff --git a/README.md b/README.md index 571045dc..dbdcf8bb 100644 --- a/README.md +++ b/README.md @@ -274,3 +274,13 @@ Schemars can implement `JsonSchema` on types from several popular crates, enable - [`arrayvec`](https://crates.io/crates/arrayvec) (^0.5) - [`url`](https://crates.io/crates/url) (^2.0) - [`bytes`](https://crates.io/crates/bytes) (^1.0) +- [`enumset`](https://crates.io/crates/enumset) (^1.0) +- [`rust_decimal`](https://crates.io/crates/rust_decimal) (^1.0) +- [`bigdecimal`](https://crates.io/crates/bigdecimal) (^0.3) + +For example, to implement `JsonSchema` on types from `chrono`, enable it as a feature in the `schemars` dependency in your `Cargo.toml` like so: + +```toml +[dependencies] +schemars = { version = "0.8", features = ["chrono"] } +``` diff --git a/docs/1.1-attributes.md b/docs/1.1-attributes.md index 64d4d148..32d6bd7c 100644 --- a/docs/1.1-attributes.md +++ b/docs/1.1-attributes.md @@ -16,7 +16,9 @@ h3 code { You can add attributes to your types to customize Schemars's derived `JsonSchema` implementation. -Serde also allows setting `#[serde(...)]` attributes which change how types are serialized, and Schemars will generally respect these attributes to ensure that generated schemas will match how the type is serialized by serde_json. `#[serde(...)]` attributes can be overriden using `#[schemars(...)]` attributes, which behave identically (e.g. `#[schemars(rename_all = "camelCase")]`). You may find this useful if you want to change the generated schema without affecting Serde's behaviour, or if you're just not using Serde. +[Serde](https://serde.rs/) allows setting `#[serde(...)]` attributes which change how types are serialized, and Schemars will generally respect these attributes to ensure that generated schemas will match how the type is serialized by serde_json. `#[serde(...)]` attributes can be overriden using `#[schemars(...)]` attributes, which behave identically (e.g. `#[schemars(rename_all = "camelCase")]`). You may find this useful if you want to change the generated schema without affecting Serde's behaviour, or if you're just not using Serde. + +[Validator](https://github.com/Keats/validator) allows setting `#[validate(...)]` attributes to restrict valid values of particular fields, many of which will be used by Schemars to generate more accurate schemas. These can also be overridden by `#[schemars(...)]` attributes.
@@ -33,6 +35,13 @@ TABLE OF CONTENTS - [`skip_deserializing`](#skip_deserializing) - [`flatten`](#flatten) - [`with`](#with) +1. [Supported Validator Attributes](#supported-validator-attributes) + - [`email` / `phone` / `url`](#email-phone-url) + - [`length`](#length) + - [`range`](#range) + - [`regex`](#regex) + - [`contains`](#contains) + - [`required` / `required_nested`](#required) 1. [Other Attributes](#other-attributes) - [`schema_with`](#schema_with) - [`title` / `description`](#title-description) @@ -153,6 +162,73 @@ Serde docs: [container](https://serde.rs/container-attrs.html#transparent) +## Supported Validator Attributes + +
+ +

+ +`#[validate(email)]` / `#[schemars(email)]`
+`#[validate(phone)]` / `#[schemars(phone)]`
+`#[validate(url)]` / `#[schemars(url)]` +

+ +Sets the schema's `format` to `email`/`phone`/`uri`, as appropriate. Only one of these attributes may be present on a single field. + +Validator docs: [email](https://github.com/Keats/validator#email) / [phone](https://github.com/Keats/validator#phone) / [url](https://github.com/Keats/validator#url) + +

+ +`#[validate(length(min = 1, max = 10))]` / `#[schemars(length(min = 1, max = 10))]`
+`#[validate(length(equal = 10))]` / `#[schemars(length(equal = 10))]` +

+ +Sets the `minLength`/`maxLength` properties for string schemas, or the `minItems`/`maxItems` properties for array schemas. + +Validator docs: [length](https://github.com/Keats/validator#length) + +

+ +`#[validate(range(min = 1, max = 10))]` / `#[schemars(range(min = 1, max = 10))]` +

+ +Sets the `minimum`/`maximum` properties for number schemas. + +Validator docs: [range](https://github.com/Keats/validator#range) + +

+ +`#[validate(regex = "path::to::regex")]` / `#[schemars(regex = "path::to::regex")]` +`#[schemars(regex(pattern = r"^\d+$"))]` +

+ +Sets the `pattern` property for string schemas. The `path::to::regex` will typically refer to a [`Regex`](https://docs.rs/regex/*/regex/struct.Regex.html) instance, but Schemars allows it to be any value with a `to_string()` method. + +Providing an inline regex pattern using `regex(pattern = ...)` is a Schemars extension, and not currently supported by the Validator crate. When using this form, you may want to use a `r"raw string literal"` so that `\\` characters in the regex pattern are not interpreted as escape sequences in the string. + +Validator docs: [regex](https://github.com/Keats/validator#regex) + +

+ +`#[validate(contains = "string")]` / `#[schemars(contains = "string")]` +

+ +For string schemas, sets the `pattern` property to the given value, with any regex special characters escaped. For object schemas (e.g. when the attribute is set on a HashMap field), includes the value in the `required` property, indicating that the map must contain it as a key. + +Validator docs: [contains](https://github.com/Keats/validator#contains) + +

+ +`#[validate(required)]` / `#[schemars(required)]`
+`#[validate(required_nested)]` +

+ +When set on an `Option` field, this will create a schemas as though the field were a `T`. + +Validator docs: [required](https://github.com/Keats/validator#required) / [required_nested](https://github.com/Keats/validator#required_nested) + +
+ ## Other Attributes

diff --git a/docs/4-features.md b/docs/4-features.md index d0809084..a6aee04a 100644 --- a/docs/4-features.md +++ b/docs/4-features.md @@ -28,3 +28,6 @@ Schemars can implement `JsonSchema` on types from several popular crates, enable - [`arrayvec`](https://crates.io/crates/arrayvec) (^0.5) - [`url`](https://crates.io/crates/url) (^2.0) - [`bytes`](https://crates.io/crates/bytes) (^1.0) +- [`enumset`](https://crates.io/crates/enumset) (^1.0) +- [`rust_decimal`](https://crates.io/crates/rust_decimal) (^1.0) +- [`bigdecimal`](https://crates.io/crates/bigdecimal) (^0.3) diff --git a/docs/_includes/examples/custom_settings.schema.json b/docs/_includes/examples/custom_settings.schema.json index 59939be9..12ac7d59 100644 --- a/docs/_includes/examples/custom_settings.schema.json +++ b/docs/_includes/examples/custom_settings.schema.json @@ -25,7 +25,7 @@ }, "definitions": { "MyEnum": { - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ diff --git a/docs/_includes/examples/doc_comments.schema.json b/docs/_includes/examples/doc_comments.schema.json index 0f3405c7..121cdb42 100644 --- a/docs/_includes/examples/doc_comments.schema.json +++ b/docs/_includes/examples/doc_comments.schema.json @@ -33,7 +33,7 @@ "definitions": { "MyEnum": { "title": "My Amazing Enum", - "anyOf": [ + "oneOf": [ { "description": "A wrapper around a `String`", "type": "object", diff --git a/docs/_includes/examples/main.schema.json b/docs/_includes/examples/main.schema.json index 737bcbc2..ddbd9d33 100644 --- a/docs/_includes/examples/main.schema.json +++ b/docs/_includes/examples/main.schema.json @@ -27,7 +27,7 @@ }, "definitions": { "MyEnum": { - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ diff --git a/docs/_includes/examples/schemars_attrs.rs b/docs/_includes/examples/schemars_attrs.rs index f830e9f0..cd69b527 100644 --- a/docs/_includes/examples/schemars_attrs.rs +++ b/docs/_includes/examples/schemars_attrs.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; #[schemars(rename_all = "camelCase", deny_unknown_fields)] pub struct MyStruct { #[serde(rename = "thisIsOverridden")] - #[schemars(rename = "myNumber")] + #[schemars(rename = "myNumber", range(min = 1, max = 10))] pub my_int: i32, pub my_bool: bool, #[schemars(default)] @@ -15,8 +15,11 @@ pub struct MyStruct { #[derive(Deserialize, Serialize, JsonSchema)] #[schemars(untagged)] pub enum MyEnum { - StringNewType(String), - StructVariant { floats: Vec }, + StringNewType(#[schemars(phone)] String), + StructVariant { + #[schemars(length(min = 1, max = 100))] + floats: Vec, + }, } fn main() { diff --git a/docs/_includes/examples/schemars_attrs.schema.json b/docs/_includes/examples/schemars_attrs.schema.json index d0441932..958cb6bb 100644 --- a/docs/_includes/examples/schemars_attrs.schema.json +++ b/docs/_includes/examples/schemars_attrs.schema.json @@ -23,7 +23,9 @@ }, "myNumber": { "type": "integer", - "format": "int32" + "format": "int32", + "maximum": 10.0, + "minimum": 1.0 } }, "additionalProperties": false, @@ -31,7 +33,8 @@ "MyEnum": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "phone" }, { "type": "object", @@ -44,7 +47,9 @@ "items": { "type": "number", "format": "float" - } + }, + "maxItems": 100, + "minItems": 1 } } } diff --git a/docs/_includes/examples/validate.rs b/docs/_includes/examples/validate.rs new file mode 100644 index 00000000..41169765 --- /dev/null +++ b/docs/_includes/examples/validate.rs @@ -0,0 +1,24 @@ +use schemars::{schema_for, JsonSchema}; + +#[derive(JsonSchema)] +pub struct MyStruct { + #[validate(range(min = 1, max = 10))] + pub my_int: i32, + pub my_bool: bool, + #[validate(required)] + pub my_nullable_enum: Option, +} + +#[derive(JsonSchema)] +pub enum MyEnum { + StringNewType(#[validate(phone)] String), + StructVariant { + #[validate(length(min = 1, max = 100))] + floats: Vec, + }, +} + +fn main() { + let schema = schema_for!(MyStruct); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); +} diff --git a/docs/_includes/examples/validate.schema.json b/docs/_includes/examples/validate.schema.json new file mode 100644 index 00000000..1e45a969 --- /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": { + "oneOf": [ + { + "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/rust-toolchain b/rust-toolchain index d2d62553..f27a1d54 100644 --- a/rust-toolchain +++ b/rust-toolchain @@ -1 +1 @@ -1.51.0 \ No newline at end of file +1.57.0 \ No newline at end of file diff --git a/schemars/Cargo.toml b/schemars/Cargo.toml index 9b79bd34..1eb90ad9 100644 --- a/schemars/Cargo.toml +++ b/schemars/Cargo.toml @@ -3,7 +3,7 @@ name = "schemars" description = "Generate JSON Schemas from Rust code" homepage = "https://graham.cool/schemars/" repository = "https://github.com/GREsau/schemars" -version = "0.8.3" +version = "0.8.8" authors = ["Graham Esau "] edition = "2018" license = "MIT" @@ -13,7 +13,7 @@ categories = ["encoding"] build = "build.rs" [dependencies] -schemars_derive = { version = "=0.8.3", optional = true, path = "../schemars_derive" } +schemars_derive = { version = "=0.8.8", optional = true, path = "../schemars_derive" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" dyn-clone = "1.0" @@ -26,9 +26,11 @@ smallvec = { version = "1.0", optional = true } arrayvec = { version = "0.5", default-features = false, optional = true } url = { version = "2.0", default-features = false, optional = true } bytes = { version = "1.0", optional = true } -move-core-types = { git = "https://github.com/starcoinorg/diem", rev = "69ab01213a2e4128a1a8c8216bbf666c9ef90abd" } -diem-crypto = { package="diem-crypto", git = "https://github.com/starcoinorg/diem", rev="69ab01213a2e4128a1a8c8216bbf666c9ef90abd", features = ["fuzzing"] } multiaddr = { version = "0.13.0" } +rust_decimal = { version = "1", default-features = false, optional = true } +bigdecimal = { version = "0.3", default-features = false, optional = true } +enumset = { version = "1.0", optional = true } + [dev-dependencies] pretty_assertions = "0.6.1" trybuild = "1.0" @@ -89,5 +91,9 @@ required-features = ["ui_test"] name = "url" required-features = ["url"] +[[test]] +name = "enumset" +required-features = ["enumset"] + [package.metadata.docs.rs] all-features = true diff --git a/schemars/examples/custom_settings.schema.json b/schemars/examples/custom_settings.schema.json index 59939be9..12ac7d59 100644 --- a/schemars/examples/custom_settings.schema.json +++ b/schemars/examples/custom_settings.schema.json @@ -25,7 +25,7 @@ }, "definitions": { "MyEnum": { - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ diff --git a/schemars/examples/doc_comments.schema.json b/schemars/examples/doc_comments.schema.json index 0f3405c7..121cdb42 100644 --- a/schemars/examples/doc_comments.schema.json +++ b/schemars/examples/doc_comments.schema.json @@ -33,7 +33,7 @@ "definitions": { "MyEnum": { "title": "My Amazing Enum", - "anyOf": [ + "oneOf": [ { "description": "A wrapper around a `String`", "type": "object", diff --git a/schemars/examples/main.schema.json b/schemars/examples/main.schema.json index 737bcbc2..ddbd9d33 100644 --- a/schemars/examples/main.schema.json +++ b/schemars/examples/main.schema.json @@ -27,7 +27,7 @@ }, "definitions": { "MyEnum": { - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ diff --git a/schemars/examples/schemars_attrs.rs b/schemars/examples/schemars_attrs.rs index f830e9f0..cd69b527 100644 --- a/schemars/examples/schemars_attrs.rs +++ b/schemars/examples/schemars_attrs.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; #[schemars(rename_all = "camelCase", deny_unknown_fields)] pub struct MyStruct { #[serde(rename = "thisIsOverridden")] - #[schemars(rename = "myNumber")] + #[schemars(rename = "myNumber", range(min = 1, max = 10))] pub my_int: i32, pub my_bool: bool, #[schemars(default)] @@ -15,8 +15,11 @@ pub struct MyStruct { #[derive(Deserialize, Serialize, JsonSchema)] #[schemars(untagged)] pub enum MyEnum { - StringNewType(String), - StructVariant { floats: Vec }, + StringNewType(#[schemars(phone)] String), + StructVariant { + #[schemars(length(min = 1, max = 100))] + floats: Vec, + }, } fn main() { diff --git a/schemars/examples/schemars_attrs.schema.json b/schemars/examples/schemars_attrs.schema.json index d0441932..958cb6bb 100644 --- a/schemars/examples/schemars_attrs.schema.json +++ b/schemars/examples/schemars_attrs.schema.json @@ -23,7 +23,9 @@ }, "myNumber": { "type": "integer", - "format": "int32" + "format": "int32", + "maximum": 10.0, + "minimum": 1.0 } }, "additionalProperties": false, @@ -31,7 +33,8 @@ "MyEnum": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "phone" }, { "type": "object", @@ -44,7 +47,9 @@ "items": { "type": "number", "format": "float" - } + }, + "maxItems": 100, + "minItems": 1 } } } diff --git a/schemars/examples/validate.rs b/schemars/examples/validate.rs new file mode 100644 index 00000000..41169765 --- /dev/null +++ b/schemars/examples/validate.rs @@ -0,0 +1,24 @@ +use schemars::{schema_for, JsonSchema}; + +#[derive(JsonSchema)] +pub struct MyStruct { + #[validate(range(min = 1, max = 10))] + pub my_int: i32, + pub my_bool: bool, + #[validate(required)] + pub my_nullable_enum: Option, +} + +#[derive(JsonSchema)] +pub enum MyEnum { + StringNewType(#[validate(phone)] String), + StructVariant { + #[validate(length(min = 1, max = 100))] + floats: Vec, + }, +} + +fn main() { + let schema = schema_for!(MyStruct); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); +} diff --git a/schemars/examples/validate.schema.json b/schemars/examples/validate.schema.json new file mode 100644 index 00000000..1e45a969 --- /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": { + "oneOf": [ + { + "type": "object", + "required": [ + "StringNewType" + ], + "properties": { + "StringNewType": { + "type": "string", + "format": "phone" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "StructVariant" + ], + "properties": { + "StructVariant": { + "type": "object", + "required": [ + "floats" + ], + "properties": { + "floats": { + "type": "array", + "items": { + "type": "number", + "format": "float" + }, + "maxItems": 100, + "minItems": 1 + } + } + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/schemars/src/_private.rs b/schemars/src/_private.rs index 4d1c31f2..aa6b570a 100644 --- a/schemars/src/_private.rs +++ b/schemars/src/_private.rs @@ -2,11 +2,17 @@ use crate::flatten::Merge; use crate::gen::SchemaGenerator; use crate::schema::{Metadata, Schema, SchemaObject}; use crate::JsonSchema; +use serde::Serialize; +use serde_json::Value; // Helper for generating schemas for flattened `Option` fields. -pub fn json_schema_for_flatten(gen: &mut SchemaGenerator) -> Schema { +pub fn json_schema_for_flatten( + gen: &mut SchemaGenerator, + required: bool, +) -> Schema { let mut schema = T::_schemars_private_non_optional_json_schema(gen); - if T::_schemars_private_is_option() { + + if T::_schemars_private_is_option() && !required { if let Schema::Object(SchemaObject { object: Some(ref mut object_validation), .. @@ -15,35 +21,47 @@ pub fn json_schema_for_flatten(gen: &mut SchemaGenerator object_validation.required.clear(); } } + schema } -// Helper for generating schemas for `Option` fields. -pub fn add_schema_as_property( - gen: &mut SchemaGenerator, - parent: &mut SchemaObject, - name: String, - metadata: Option, - required: bool, -) { - let mut schema = gen.subschema_for::(); - schema = apply_metadata(schema, metadata); +pub fn apply_metadata(schema: Schema, metadata: Metadata) -> Schema { + if metadata == Metadata::default() { + schema + } else { + let mut schema_obj = schema.into_object(); + schema_obj.metadata = Some(Box::new(metadata)).merge(schema_obj.metadata); + Schema::Object(schema_obj) + } +} + +/// Hack to simulate specialization: +/// `MaybeSerializeWrapper(x).maybe_to_value()` will resolve to either +/// - The inherent method `MaybeSerializeWrapper::maybe_to_value(...)` if x is `Serialize` +/// - The trait method `NoSerialize::maybe_to_value(...)` from the blanket impl otherwise +#[doc(hidden)] +#[macro_export] +macro_rules! _schemars_maybe_to_value { + ($expression:expr) => {{ + #[allow(unused_imports)] + use $crate::_private::{MaybeSerializeWrapper, NoSerialize as _}; + + MaybeSerializeWrapper($expression).maybe_to_value() + }}; +} - let object = parent.object(); - if required && !T::_schemars_private_is_option() { - object.required.insert(name.clone()); +pub struct MaybeSerializeWrapper(pub T); + +pub trait NoSerialize: Sized { + fn maybe_to_value(self) -> Option { + None } - 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) - } +impl NoSerialize for T {} + +impl MaybeSerializeWrapper { + pub fn maybe_to_value(self) -> Option { + serde_json::value::to_value(self.0).ok() } } diff --git a/schemars/src/flatten.rs b/schemars/src/flatten.rs index 646f614f..35a734fa 100644 --- a/schemars/src/flatten.rs +++ b/schemars/src/flatten.rs @@ -38,6 +38,31 @@ macro_rules! impl_merge { }; } +// For ObjectValidation::additional_properties. +impl Merge for Option> { + fn merge(self, other: Self) -> Self { + match (self.map(|x| *x), other.map(|x| *x)) { + // Perfer permissive schemas. + (Some(Schema::Bool(true)), _) => Some(Box::new(true.into())), + (_, Some(Schema::Bool(true))) => Some(Box::new(true.into())), + (None, _) => None, + (_, None) => None, + + // Merge if we have two non-trivial schemas. + (Some(Schema::Object(s1)), Some(Schema::Object(s2))) => { + Some(Box::new(Schema::Object(s1.merge(s2)))) + } + + // Perfer the more permissive schema. + (Some(s1 @ Schema::Object(_)), Some(Schema::Bool(false))) => Some(Box::new(s1)), + (Some(Schema::Bool(false)), Some(s2 @ Schema::Object(_))) => Some(Box::new(s2)), + + // Default to the null schema. + (Some(Schema::Bool(false)), Some(Schema::Bool(false))) => Some(Box::new(false.into())), + } + } +} + impl_merge!(SchemaObject { merge: extensions instance_type enum_values metadata subschemas number string array object, @@ -76,8 +101,8 @@ impl_merge!(ArrayValidation { }); impl_merge!(ObjectValidation { - merge: required properties pattern_properties, - or: max_properties min_properties additional_properties property_names, + merge: required properties pattern_properties additional_properties, + or: max_properties min_properties property_names, }); impl Merge for Option { diff --git a/schemars/src/json_schema_impls/decimal.rs b/schemars/src/json_schema_impls/decimal.rs new file mode 100644 index 00000000..6058a7fd --- /dev/null +++ b/schemars/src/json_schema_impls/decimal.rs @@ -0,0 +1,31 @@ +use crate::gen::SchemaGenerator; +use crate::schema::*; +use crate::JsonSchema; + +macro_rules! decimal_impl { + ($type:ty) => { + decimal_impl!($type => Number, "Number"); + }; + ($type:ty => $instance_type:ident, $name:expr) => { + impl JsonSchema for $type { + no_ref_schema!(); + + fn schema_name() -> String { + $name.to_owned() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(InstanceType::$instance_type.into()), + ..Default::default() + } + .into() + } + } + }; +} + +#[cfg(feature="rust_decimal")] +decimal_impl!(rust_decimal::Decimal); +#[cfg(feature="bigdecimal")] +decimal_impl!(bigdecimal::BigDecimal); diff --git a/schemars/src/json_schema_impls/diem_types.rs b/schemars/src/json_schema_impls/diem_types.rs deleted file mode 100644 index 551ad475..00000000 --- a/schemars/src/json_schema_impls/diem_types.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::gen::SchemaGenerator; -use crate::schema::*; -use crate::JsonSchema; -use diem_crypto::HashValue; -use move_core_types::account_address::AccountAddress; -use multiaddr::Multiaddr; -impl JsonSchema for AccountAddress { - no_ref_schema!(); - - fn schema_name() -> String { - "AccountAddress".to_owned() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - SchemaObject { - instance_type: Some(InstanceType::String.into()), - format: Some("AccountAddress".to_owned()), - ..Default::default() - } - .into() - } -} -impl JsonSchema for HashValue { - no_ref_schema!(); - - fn schema_name() -> String { - "HashValue".to_owned() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - SchemaObject { - instance_type: Some(InstanceType::String.into()), - format: Some("HashValue".to_owned()), - ..Default::default() - } - .into() - } -} -impl JsonSchema for Multiaddr { - no_ref_schema!(); - - fn schema_name() -> String { - "Multiaddr".to_owned() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - SchemaObject { - instance_type: Some(InstanceType::String.into()), - format: Some("Multiaddr".to_owned()), - ..Default::default() - } - .into() - } -} diff --git a/schemars/src/json_schema_impls/enumset.rs b/schemars/src/json_schema_impls/enumset.rs new file mode 100644 index 00000000..22a3ebcf --- /dev/null +++ b/schemars/src/json_schema_impls/enumset.rs @@ -0,0 +1,6 @@ +use crate::gen::SchemaGenerator; +use crate::schema::*; +use crate::JsonSchema; +use enumset::{EnumSet, EnumSetType}; + +forward_impl!(( JsonSchema for EnumSet where T: EnumSetType + JsonSchema) => std::collections::BTreeSet); diff --git a/schemars/src/json_schema_impls/mod.rs b/schemars/src/json_schema_impls/mod.rs index 4e661156..c2108b1f 100644 --- a/schemars/src/json_schema_impls/mod.rs +++ b/schemars/src/json_schema_impls/mod.rs @@ -45,8 +45,12 @@ mod bytes; #[cfg(feature = "chrono")] mod chrono; mod core; +#[cfg(any(feature = "rust_decimal", feature="bigdecimal"))] +mod decimal; #[cfg(feature = "either")] mod either; +#[cfg(feature = "enumset")] +mod enumset; mod ffi; #[cfg(feature = "indexmap")] mod indexmap; @@ -65,5 +69,4 @@ mod url; #[cfg(feature = "uuid")] mod uuid; mod wrapper; - -mod diem_types; +mod multi_addr; diff --git a/schemars/src/json_schema_impls/multi_addr.rs b/schemars/src/json_schema_impls/multi_addr.rs new file mode 100644 index 00000000..f0a0f9f9 --- /dev/null +++ b/schemars/src/json_schema_impls/multi_addr.rs @@ -0,0 +1,20 @@ +use crate::gen::SchemaGenerator; +use crate::schema::*; +use crate::JsonSchema; +use multiaddr::Multiaddr; +impl JsonSchema for Multiaddr { + no_ref_schema!(); + + fn schema_name() -> String { + "Multiaddr".to_owned() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(InstanceType::String.into()), + format: Some("Multiaddr".to_owned()), + ..Default::default() + } + .into() + } +} \ No newline at end of file diff --git a/schemars/src/lib.rs b/schemars/src/lib.rs index e400ccf4..5ef0bdfa 100644 --- a/schemars/src/lib.rs +++ b/schemars/src/lib.rs @@ -269,6 +269,16 @@ Schemars can implement `JsonSchema` on types from several popular crates, enable - [`arrayvec`](https://crates.io/crates/arrayvec) (^0.5) - [`url`](https://crates.io/crates/url) (^2.0) - [`bytes`](https://crates.io/crates/bytes) (^1.0) +- [`enumset`](https://crates.io/crates/enumset) (^1.0) +- [`rust_decimal`](https://crates.io/crates/rust_decimal) (^1.0) +- [`bigdecimal`](https://crates.io/crates/bigdecimal) (^0.3) + +For example, to implement `JsonSchema` on types from `chrono`, enable it as a feature in the `schemars` dependency in your `Cargo.toml` like so: + +```toml +[dependencies] +schemars = { version = "0.8", features = ["chrono"] } +``` */ /// The map type used by schemars types. diff --git a/schemars/src/schema.rs b/schemars/src/schema.rs index f9f476cd..01fce865 100644 --- a/schemars/src/schema.rs +++ b/schemars/src/schema.rs @@ -9,6 +9,7 @@ use crate::JsonSchema; use crate::{Map, Set}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::ops::Deref; /// A JSON Schema. #[allow(clippy::large_enum_variant)] @@ -230,6 +231,17 @@ impl SchemaObject { self.reference.is_some() } + /// Returns `true` if `self` accepts values of the given type, according to the [`instance_type`] field. + /// + /// This is a basic check that always returns `true` if no `instance_type` is specified on the schema, + /// and does not check any subschemas. Because of this, both `{}` and `{"not": {}}` accept any type according + /// to this method. + pub fn has_type(&self, ty: InstanceType) -> bool { + self.instance_type + .as_ref() + .map_or(true, |x| x.contains(&ty)) + } + get_or_insert_default_fn!(metadata, Metadata); get_or_insert_default_fn!(subschemas, SubschemaValidation); get_or_insert_default_fn!(number, NumberValidation); @@ -512,3 +524,28 @@ impl From> for SingleOrVec { SingleOrVec::Vec(vec) } } + +impl SingleOrVec { + /// Returns `true` if `self` is either a `Single` equal to `x`, or a `Vec` containing `x`. + /// + /// # Examples + /// + /// ``` + /// use schemars::schema::SingleOrVec; + /// + /// let s = SingleOrVec::from(10); + /// assert!(s.contains(&10)); + /// assert!(!s.contains(&20)); + /// + /// let v = SingleOrVec::from(vec![10, 20]); + /// assert!(v.contains(&10)); + /// assert!(v.contains(&20)); + /// assert!(!v.contains(&30)); + /// ``` + pub fn contains(&self, x: &T) -> bool { + match self { + SingleOrVec::Single(s) => s.deref() == x, + SingleOrVec::Vec(v) => v.contains(x), + } + } +} diff --git a/schemars/tests/default.rs b/schemars/tests/default.rs index d91585a4..0d68d0e2 100644 --- a/schemars/tests/default.rs +++ b/schemars/tests/default.rs @@ -1,6 +1,5 @@ mod util; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; use util::*; fn is_default(value: &T) -> bool { @@ -25,7 +24,7 @@ where ser.collect_str(&format_args!("i:{} b:{}", value.my_int, value.my_bool)) } -#[derive(Default, Deserialize, Serialize, JsonSchema, Debug)] +#[derive(Default, JsonSchema, Debug)] #[serde(default)] pub struct MyStruct { pub my_int: i32, @@ -37,9 +36,10 @@ pub struct MyStruct { skip_serializing_if = "is_default" )] pub my_struct2_default_skipped: MyStruct2, + pub not_serialize: NotSerialize, } -#[derive(Default, Deserialize, Serialize, JsonSchema, Debug, PartialEq)] +#[derive(Default, JsonSchema, Debug, PartialEq)] #[serde(default = "ten_and_true")] pub struct MyStruct2 { #[serde(default = "six")] @@ -47,6 +47,9 @@ pub struct MyStruct2 { pub my_bool: bool, } +#[derive(Default, JsonSchema, Debug)] +pub struct NotSerialize; + #[test] fn schema_default_values() -> TestResult { test_default_generated_schema::("default") diff --git a/schemars/tests/enum.rs b/schemars/tests/enum.rs index ea34d06e..48e0d34a 100644 --- a/schemars/tests/enum.rs +++ b/schemars/tests/enum.rs @@ -99,3 +99,16 @@ pub enum Adjacent { fn enum_adjacent_tagged() -> TestResult { test_default_generated_schema::("enum-adjacent-tagged") } + +#[derive(Debug, JsonSchema)] +#[schemars(tag = "typeProperty")] +pub enum SimpleInternal { + A, + B, + C, +} + +#[test] +fn enum_simple_internal_tag() -> TestResult { + test_default_generated_schema::("enum-simple-internal") +} diff --git a/schemars/tests/enum_deny_unknown_fields.rs b/schemars/tests/enum_deny_unknown_fields.rs index bf0f4003..8720b729 100644 --- a/schemars/tests/enum_deny_unknown_fields.rs +++ b/schemars/tests/enum_deny_unknown_fields.rs @@ -104,3 +104,16 @@ pub enum Adjacent { fn enum_adjacent_tagged() -> TestResult { test_default_generated_schema::("enum-adjacent-tagged-duf") } + +#[derive(Debug, JsonSchema)] +#[schemars(tag = "typeProperty", deny_unknown_fields)] +pub enum SimpleInternal { + A, + B, + C, +} + +#[test] +fn enum_simple_internal_tag() -> TestResult { + test_default_generated_schema::("enum-simple-internal-duf") +} diff --git a/schemars/tests/enumset.rs b/schemars/tests/enumset.rs new file mode 100644 index 00000000..27f20307 --- /dev/null +++ b/schemars/tests/enumset.rs @@ -0,0 +1,15 @@ +mod util; +use enumset::{EnumSet, EnumSetType}; +use schemars::JsonSchema; +use util::*; + +#[derive(EnumSetType, JsonSchema)] +enum Foo { + Bar, + Baz, +} + +#[test] +fn enumset() -> TestResult { + test_default_generated_schema::>("enumset") +} diff --git a/schemars/tests/expected/default.json b/schemars/tests/expected/default.json index 6210986d..aefef83d 100644 --- a/schemars/tests/expected/default.json +++ b/schemars/tests/expected/default.json @@ -22,6 +22,9 @@ }, "my_struct2_default_skipped": { "$ref": "#/definitions/MyStruct2" + }, + "not_serialize": { + "$ref": "#/definitions/NotSerialize" } }, "definitions": { @@ -38,6 +41,9 @@ "type": "boolean" } } + }, + "NotSerialize": { + "type": "null" } } } \ No newline at end of file diff --git a/schemars/tests/expected/deprecated-enum.json b/schemars/tests/expected/deprecated-enum.json index f869be21..074310f3 100644 --- a/schemars/tests/expected/deprecated-enum.json +++ b/schemars/tests/expected/deprecated-enum.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "DeprecatedEnum", "deprecated": true, - "anyOf": [ + "oneOf": [ { "type": "string", "enum": [ @@ -24,13 +24,13 @@ "foo" ], "properties": { - "foo": { - "type": "integer", - "format": "int32" - }, "deprecated_field": { "deprecated": true, "type": "boolean" + }, + "foo": { + "type": "integer", + "format": "int32" } } } diff --git a/schemars/tests/expected/doc_comments_enum.json b/schemars/tests/expected/doc_comments_enum.json index edcb3391..0adc3801 100644 --- a/schemars/tests/expected/doc_comments_enum.json +++ b/schemars/tests/expected/doc_comments_enum.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "This is the enum's title", "description": "This is the enum's description.", - "anyOf": [ + "oneOf": [ { "type": "string", "enum": [ diff --git a/schemars/tests/expected/enum-adjacent-tagged-duf.json b/schemars/tests/expected/enum-adjacent-tagged-duf.json index 011b3782..c5b54c81 100644 --- a/schemars/tests/expected/enum-adjacent-tagged-duf.json +++ b/schemars/tests/expected/enum-adjacent-tagged-duf.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Adjacent", - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ @@ -24,17 +24,17 @@ "t" ], "properties": { - "t": { - "type": "string", - "enum": [ - "StringMap" - ] - }, "c": { "type": "object", "additionalProperties": { "type": "string" } + }, + "t": { + "type": "string", + "enum": [ + "StringMap" + ] } }, "additionalProperties": false @@ -46,14 +46,14 @@ "t" ], "properties": { + "c": { + "$ref": "#/definitions/UnitStruct" + }, "t": { "type": "string", "enum": [ "UnitStructNewType" ] - }, - "c": { - "$ref": "#/definitions/UnitStruct" } }, "additionalProperties": false @@ -65,14 +65,14 @@ "t" ], "properties": { + "c": { + "$ref": "#/definitions/Struct" + }, "t": { "type": "string", "enum": [ "StructNewType" ] - }, - "c": { - "$ref": "#/definitions/Struct" } }, "additionalProperties": false @@ -84,12 +84,6 @@ "t" ], "properties": { - "t": { - "type": "string", - "enum": [ - "Struct" - ] - }, "c": { "type": "object", "required": [ @@ -97,15 +91,21 @@ "foo" ], "properties": { + "bar": { + "type": "boolean" + }, "foo": { "type": "integer", "format": "int32" - }, - "bar": { - "type": "boolean" } }, "additionalProperties": false + }, + "t": { + "type": "string", + "enum": [ + "Struct" + ] } }, "additionalProperties": false @@ -117,12 +117,6 @@ "t" ], "properties": { - "t": { - "type": "string", - "enum": [ - "Tuple" - ] - }, "c": { "type": "array", "items": [ @@ -136,6 +130,12 @@ ], "maxItems": 2, "minItems": 2 + }, + "t": { + "type": "string", + "enum": [ + "Tuple" + ] } }, "additionalProperties": false @@ -162,24 +162,21 @@ "t" ], "properties": { + "c": { + "type": "integer", + "format": "int32" + }, "t": { "type": "string", "enum": [ "WithInt" ] - }, - "c": { - "type": "integer", - "format": "int32" } }, "additionalProperties": false } ], "definitions": { - "UnitStruct": { - "type": "null" - }, "Struct": { "type": "object", "required": [ @@ -187,14 +184,17 @@ "foo" ], "properties": { + "bar": { + "type": "boolean" + }, "foo": { "type": "integer", "format": "int32" - }, - "bar": { - "type": "boolean" } } + }, + "UnitStruct": { + "type": "null" } } } \ No newline at end of file diff --git a/schemars/tests/expected/enum-adjacent-tagged.json b/schemars/tests/expected/enum-adjacent-tagged.json index 4e0ca123..efe19dc8 100644 --- a/schemars/tests/expected/enum-adjacent-tagged.json +++ b/schemars/tests/expected/enum-adjacent-tagged.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Adjacent", - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ @@ -23,17 +23,17 @@ "t" ], "properties": { - "t": { - "type": "string", - "enum": [ - "StringMap" - ] - }, "c": { "type": "object", "additionalProperties": { "type": "string" } + }, + "t": { + "type": "string", + "enum": [ + "StringMap" + ] } } }, @@ -44,14 +44,14 @@ "t" ], "properties": { + "c": { + "$ref": "#/definitions/UnitStruct" + }, "t": { "type": "string", "enum": [ "UnitStructNewType" ] - }, - "c": { - "$ref": "#/definitions/UnitStruct" } } }, @@ -62,14 +62,14 @@ "t" ], "properties": { + "c": { + "$ref": "#/definitions/Struct" + }, "t": { "type": "string", "enum": [ "StructNewType" ] - }, - "c": { - "$ref": "#/definitions/Struct" } } }, @@ -80,12 +80,6 @@ "t" ], "properties": { - "t": { - "type": "string", - "enum": [ - "Struct" - ] - }, "c": { "type": "object", "required": [ @@ -93,14 +87,20 @@ "foo" ], "properties": { + "bar": { + "type": "boolean" + }, "foo": { "type": "integer", "format": "int32" - }, - "bar": { - "type": "boolean" } } + }, + "t": { + "type": "string", + "enum": [ + "Struct" + ] } } }, @@ -111,12 +111,6 @@ "t" ], "properties": { - "t": { - "type": "string", - "enum": [ - "Tuple" - ] - }, "c": { "type": "array", "items": [ @@ -130,6 +124,12 @@ ], "maxItems": 2, "minItems": 2 + }, + "t": { + "type": "string", + "enum": [ + "Tuple" + ] } } }, @@ -154,23 +154,20 @@ "t" ], "properties": { + "c": { + "type": "integer", + "format": "int32" + }, "t": { "type": "string", "enum": [ "WithInt" ] - }, - "c": { - "type": "integer", - "format": "int32" } } } ], "definitions": { - "UnitStruct": { - "type": "null" - }, "Struct": { "type": "object", "required": [ @@ -178,14 +175,17 @@ "foo" ], "properties": { + "bar": { + "type": "boolean" + }, "foo": { "type": "integer", "format": "int32" - }, - "bar": { - "type": "boolean" } } + }, + "UnitStruct": { + "type": "null" } } } \ No newline at end of file diff --git a/schemars/tests/expected/enum-external-duf.json b/schemars/tests/expected/enum-external-duf.json index 4bbe7e42..b6b7b99a 100644 --- a/schemars/tests/expected/enum-external-duf.json +++ b/schemars/tests/expected/enum-external-duf.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "External", - "anyOf": [ + "oneOf": [ { "type": "string", "enum": [ @@ -61,12 +61,12 @@ "foo" ], "properties": { + "bar": { + "type": "boolean" + }, "foo": { "type": "integer", "format": "int32" - }, - "bar": { - "type": "boolean" } }, "additionalProperties": false @@ -112,9 +112,6 @@ } ], "definitions": { - "UnitStruct": { - "type": "null" - }, "Struct": { "type": "object", "required": [ @@ -122,14 +119,17 @@ "foo" ], "properties": { + "bar": { + "type": "boolean" + }, "foo": { "type": "integer", "format": "int32" - }, - "bar": { - "type": "boolean" } } + }, + "UnitStruct": { + "type": "null" } } } \ No newline at end of file diff --git a/schemars/tests/expected/enum-external.json b/schemars/tests/expected/enum-external.json index cdc2fe45..cc721dfd 100644 --- a/schemars/tests/expected/enum-external.json +++ b/schemars/tests/expected/enum-external.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "External", - "anyOf": [ + "oneOf": [ { "type": "string", "enum": [ @@ -61,12 +61,12 @@ "foo" ], "properties": { + "bar": { + "type": "boolean" + }, "foo": { "type": "integer", "format": "int32" - }, - "bar": { - "type": "boolean" } } } @@ -111,9 +111,6 @@ } ], "definitions": { - "UnitStruct": { - "type": "null" - }, "Struct": { "type": "object", "required": [ @@ -121,14 +118,17 @@ "foo" ], "properties": { + "bar": { + "type": "boolean" + }, "foo": { "type": "integer", "format": "int32" - }, - "bar": { - "type": "boolean" } } + }, + "UnitStruct": { + "type": "null" } } } \ No newline at end of file diff --git a/schemars/tests/expected/enum-internal-duf.json b/schemars/tests/expected/enum-internal-duf.json index 8b6fef85..fc36644f 100644 --- a/schemars/tests/expected/enum-internal-duf.json +++ b/schemars/tests/expected/enum-internal-duf.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Internal", - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ @@ -14,7 +14,8 @@ "UnitOne" ] } - } + }, + "additionalProperties": false }, { "type": "object", @@ -45,7 +46,8 @@ "UnitStructNewType" ] } - } + }, + "additionalProperties": false }, { "type": "object", @@ -55,18 +57,18 @@ "typeProperty" ], "properties": { - "typeProperty": { - "type": "string", - "enum": [ - "StructNewType" - ] + "bar": { + "type": "boolean" }, "foo": { "type": "integer", "format": "int32" }, - "bar": { - "type": "boolean" + "typeProperty": { + "type": "string", + "enum": [ + "StructNewType" + ] } } }, @@ -78,18 +80,18 @@ "typeProperty" ], "properties": { - "typeProperty": { - "type": "string", - "enum": [ - "Struct" - ] + "bar": { + "type": "boolean" }, "foo": { "type": "integer", "format": "int32" }, - "bar": { - "type": "boolean" + "typeProperty": { + "type": "string", + "enum": [ + "Struct" + ] } }, "additionalProperties": false @@ -106,7 +108,8 @@ "UnitTwo" ] } - } + }, + "additionalProperties": false }, { "type": [ @@ -124,7 +127,8 @@ "WithInt" ] } - } + }, + "additionalProperties": false } ] } \ No newline at end of file diff --git a/schemars/tests/expected/enum-internal.json b/schemars/tests/expected/enum-internal.json index f3c15331..37739b09 100644 --- a/schemars/tests/expected/enum-internal.json +++ b/schemars/tests/expected/enum-internal.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Internal", - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ @@ -28,9 +28,6 @@ "StringMap" ] } - }, - "additionalProperties": { - "type": "string" } }, { @@ -55,18 +52,18 @@ "typeProperty" ], "properties": { - "typeProperty": { - "type": "string", - "enum": [ - "StructNewType" - ] + "bar": { + "type": "boolean" }, "foo": { "type": "integer", "format": "int32" }, - "bar": { - "type": "boolean" + "typeProperty": { + "type": "string", + "enum": [ + "StructNewType" + ] } } }, @@ -78,18 +75,18 @@ "typeProperty" ], "properties": { - "typeProperty": { - "type": "string", - "enum": [ - "Struct" - ] + "bar": { + "type": "boolean" }, "foo": { "type": "integer", "format": "int32" }, - "bar": { - "type": "boolean" + "typeProperty": { + "type": "string", + "enum": [ + "Struct" + ] } } }, diff --git a/schemars/tests/expected/enum-simple-internal-duf.json b/schemars/tests/expected/enum-simple-internal-duf.json new file mode 100644 index 00000000..833f7b73 --- /dev/null +++ b/schemars/tests/expected/enum-simple-internal-duf.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SimpleInternal", + "oneOf": [ + { + "type": "object", + "required": [ + "typeProperty" + ], + "properties": { + "typeProperty": { + "type": "string", + "enum": [ + "A" + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "typeProperty" + ], + "properties": { + "typeProperty": { + "type": "string", + "enum": [ + "B" + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "typeProperty" + ], + "properties": { + "typeProperty": { + "type": "string", + "enum": [ + "C" + ] + } + }, + "additionalProperties": false + } + ] +} \ No newline at end of file diff --git a/schemars/tests/expected/enum-simple-internal.json b/schemars/tests/expected/enum-simple-internal.json new file mode 100644 index 00000000..50cd62c1 --- /dev/null +++ b/schemars/tests/expected/enum-simple-internal.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SimpleInternal", + "oneOf": [ + { + "type": "object", + "required": [ + "typeProperty" + ], + "properties": { + "typeProperty": { + "type": "string", + "enum": [ + "A" + ] + } + } + }, + { + "type": "object", + "required": [ + "typeProperty" + ], + "properties": { + "typeProperty": { + "type": "string", + "enum": [ + "B" + ] + } + } + }, + { + "type": "object", + "required": [ + "typeProperty" + ], + "properties": { + "typeProperty": { + "type": "string", + "enum": [ + "C" + ] + } + } + } + ] +} \ No newline at end of file diff --git a/schemars/tests/expected/enumset.json b/schemars/tests/expected/enumset.json new file mode 100644 index 00000000..4950c939 --- /dev/null +++ b/schemars/tests/expected/enumset.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Set_of_Foo", + "type": "array", + "items": { + "$ref": "#/definitions/Foo" + }, + "uniqueItems": true, + "definitions": { + "Foo": { + "type": "string", + "enum": [ + "Bar", + "Baz" + ] + } + } +} diff --git a/schemars/tests/expected/macro_built_enum.json b/schemars/tests/expected/macro_built_enum.json index 8564ef70..d030787d 100644 --- a/schemars/tests/expected/macro_built_enum.json +++ b/schemars/tests/expected/macro_built_enum.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "OuterEnum", - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ @@ -17,7 +17,16 @@ ], "definitions": { "InnerStruct": { - "type": "object" + "type": "object", + "required": [ + "x" + ], + "properties": { + "x": { + "type": "integer", + "format": "int32" + } + } } } } \ No newline at end of file diff --git a/schemars/tests/expected/schema_with-enum-adjacent-tagged.json b/schemars/tests/expected/schema_with-enum-adjacent-tagged.json index f464511b..3ecba4c8 100644 --- a/schemars/tests/expected/schema_with-enum-adjacent-tagged.json +++ b/schemars/tests/expected/schema_with-enum-adjacent-tagged.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Adjacent", - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ @@ -9,12 +9,6 @@ "t" ], "properties": { - "t": { - "type": "string", - "enum": [ - "Struct" - ] - }, "c": { "type": "object", "required": [ @@ -25,6 +19,12 @@ "type": "boolean" } } + }, + "t": { + "type": "string", + "enum": [ + "Struct" + ] } } }, @@ -35,14 +35,14 @@ "t" ], "properties": { + "c": { + "type": "boolean" + }, "t": { "type": "string", "enum": [ "NewType" ] - }, - "c": { - "type": "boolean" } } }, @@ -53,12 +53,6 @@ "t" ], "properties": { - "t": { - "type": "string", - "enum": [ - "Tuple" - ] - }, "c": { "type": "array", "items": [ @@ -72,6 +66,12 @@ ], "maxItems": 2, "minItems": 2 + }, + "t": { + "type": "string", + "enum": [ + "Tuple" + ] } } }, @@ -82,14 +82,14 @@ "t" ], "properties": { + "c": { + "type": "boolean" + }, "t": { "type": "string", "enum": [ "Unit" ] - }, - "c": { - "type": "boolean" } } } diff --git a/schemars/tests/expected/schema_with-enum-external.json b/schemars/tests/expected/schema_with-enum-external.json index 92fb5a63..dea02199 100644 --- a/schemars/tests/expected/schema_with-enum-external.json +++ b/schemars/tests/expected/schema_with-enum-external.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "External", - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ diff --git a/schemars/tests/expected/schema_with-enum-internal.json b/schemars/tests/expected/schema_with-enum-internal.json index ea39afe7..7ede7e6e 100644 --- a/schemars/tests/expected/schema_with-enum-internal.json +++ b/schemars/tests/expected/schema_with-enum-internal.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Internal", - "anyOf": [ + "oneOf": [ { "type": "object", "required": [ @@ -9,14 +9,14 @@ "typeProperty" ], "properties": { + "foo": { + "type": "boolean" + }, "typeProperty": { "type": "string", "enum": [ "Struct" ] - }, - "foo": { - "type": "boolean" } } }, diff --git a/schemars/tests/expected/skip_enum_variants.json b/schemars/tests/expected/skip_enum_variants.json index 4acb2eb4..ba1bf23c 100644 --- a/schemars/tests/expected/skip_enum_variants.json +++ b/schemars/tests/expected/skip_enum_variants.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "MyEnum", - "anyOf": [ + "oneOf": [ { "type": "string", "enum": [ diff --git a/schemars/tests/expected/validate.json b/schemars/tests/expected/validate.json new file mode 100644 index 00000000..d4a14e3f --- /dev/null +++ b/schemars/tests/expected/validate.json @@ -0,0 +1,104 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Struct", + "type": "object", + "required": [ + "contains_str1", + "contains_str2", + "email_address", + "homepage", + "map_contains", + "min_max", + "min_max2", + "non_empty_str", + "non_empty_str2", + "pair", + "regex_str1", + "regex_str2", + "regex_str3", + "required_option", + "tel", + "x" + ], + "properties": { + "min_max": { + "type": "number", + "format": "float", + "maximum": 100.0, + "minimum": 0.01 + }, + "min_max2": { + "type": "number", + "format": "float", + "maximum": 1000.0, + "minimum": 1.0 + }, + "regex_str1": { + "type": "string", + "pattern": "^[Hh]ello\\b" + }, + "regex_str2": { + "type": "string", + "pattern": "^[Hh]ello\\b" + }, + "regex_str3": { + "type": "string", + "pattern": "^\\d+$" + }, + "contains_str1": { + "type": "string", + "pattern": "substring\\.\\.\\." + }, + "contains_str2": { + "type": "string", + "pattern": "substring\\.\\.\\." + }, + "email_address": { + "type": "string", + "format": "email" + }, + "tel": { + "type": "string", + "format": "phone" + }, + "homepage": { + "type": "string", + "format": "uri" + }, + "non_empty_str": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "non_empty_str2": { + "type": "string", + "maxLength": 1000, + "minLength": 1 + }, + "pair": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "maxItems": 2, + "minItems": 2 + }, + "map_contains": { + "type": "object", + "required": [ + "map_key" + ], + "additionalProperties": { + "type": "null" + } + }, + "required_option": { + "type": "boolean" + }, + "x": { + "type": "integer", + "format": "int32" + } + } +} \ No newline at end of file diff --git a/schemars/tests/expected/validate_newtype.json b/schemars/tests/expected/validate_newtype.json new file mode 100644 index 00000000..796aecde --- /dev/null +++ b/schemars/tests/expected/validate_newtype.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NewType", + "type": "integer", + "format": "uint8", + "maximum": 10.0, + "minimum": 0.0 +} \ No newline at end of file diff --git a/schemars/tests/expected/validate_schemars_attrs.json b/schemars/tests/expected/validate_schemars_attrs.json new file mode 100644 index 00000000..d4a14e3f --- /dev/null +++ b/schemars/tests/expected/validate_schemars_attrs.json @@ -0,0 +1,104 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Struct", + "type": "object", + "required": [ + "contains_str1", + "contains_str2", + "email_address", + "homepage", + "map_contains", + "min_max", + "min_max2", + "non_empty_str", + "non_empty_str2", + "pair", + "regex_str1", + "regex_str2", + "regex_str3", + "required_option", + "tel", + "x" + ], + "properties": { + "min_max": { + "type": "number", + "format": "float", + "maximum": 100.0, + "minimum": 0.01 + }, + "min_max2": { + "type": "number", + "format": "float", + "maximum": 1000.0, + "minimum": 1.0 + }, + "regex_str1": { + "type": "string", + "pattern": "^[Hh]ello\\b" + }, + "regex_str2": { + "type": "string", + "pattern": "^[Hh]ello\\b" + }, + "regex_str3": { + "type": "string", + "pattern": "^\\d+$" + }, + "contains_str1": { + "type": "string", + "pattern": "substring\\.\\.\\." + }, + "contains_str2": { + "type": "string", + "pattern": "substring\\.\\.\\." + }, + "email_address": { + "type": "string", + "format": "email" + }, + "tel": { + "type": "string", + "format": "phone" + }, + "homepage": { + "type": "string", + "format": "uri" + }, + "non_empty_str": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "non_empty_str2": { + "type": "string", + "maxLength": 1000, + "minLength": 1 + }, + "pair": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "maxItems": 2, + "minItems": 2 + }, + "map_contains": { + "type": "object", + "required": [ + "map_key" + ], + "additionalProperties": { + "type": "null" + } + }, + "required_option": { + "type": "boolean" + }, + "x": { + "type": "integer", + "format": "int32" + } + } +} \ No newline at end of file diff --git a/schemars/tests/expected/validate_tuple.json b/schemars/tests/expected/validate_tuple.json new file mode 100644 index 00000000..8ab6eaa6 --- /dev/null +++ b/schemars/tests/expected/validate_tuple.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Tuple", + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint8", + "maximum": 10.0, + "minimum": 0.0 + }, + { + "type": "boolean" + } + ], + "maxItems": 2, + "minItems": 2 +} \ No newline at end of file diff --git a/schemars/tests/macro.rs b/schemars/tests/macro.rs index 9991494c..ca7dee8f 100644 --- a/schemars/tests/macro.rs +++ b/schemars/tests/macro.rs @@ -56,7 +56,9 @@ build_enum!( #[derive(Debug, JsonSchema)] OuterEnum { #[derive(Debug, JsonSchema)] - InnerStruct {} + InnerStruct { + x: i32 + } } ); diff --git a/schemars/tests/ui/invalid_attrs.stderr b/schemars/tests/ui/invalid_attrs.stderr index fa3a4f46..48238591 100644 --- a/schemars/tests/ui/invalid_attrs.stderr +++ b/schemars/tests/ui/invalid_attrs.stderr @@ -22,7 +22,7 @@ error: duplicate serde attribute `deny_unknown_fields` 8 | #[schemars(default = 0, foo, deny_unknown_fields, deny_unknown_fields)] | ^^^^^^^^^^^^^^^^^^^ -error: unknown schemars container attribute `foo` +error: unknown schemars attribute `foo` --> $DIR/invalid_attrs.rs:8:25 | 8 | #[schemars(default = 0, foo, deny_unknown_fields, deny_unknown_fields)] diff --git a/schemars/tests/ui/invalid_validation_attrs.rs b/schemars/tests/ui/invalid_validation_attrs.rs new file mode 100644 index 00000000..be843625 --- /dev/null +++ b/schemars/tests/ui/invalid_validation_attrs.rs @@ -0,0 +1,35 @@ +use schemars::JsonSchema; + +#[derive(JsonSchema)] +pub struct Struct1(#[validate(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + +#[derive(JsonSchema)] +pub struct Struct2(#[schemars(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + +#[derive(JsonSchema)] +pub struct Struct3( + #[validate( + regex = "foo", + contains = "bar", + regex(path = "baz"), + phone, + email, + url + )] + String, +); + +#[derive(JsonSchema)] +pub struct Struct4( + #[schemars( + regex = "foo", + contains = "bar", + regex(path = "baz"), + phone, + email, + url + )] + String, +); + +fn main() {} diff --git a/schemars/tests/ui/invalid_validation_attrs.stderr b/schemars/tests/ui/invalid_validation_attrs.stderr new file mode 100644 index 00000000..933fd66f --- /dev/null +++ b/schemars/tests/ui/invalid_validation_attrs.stderr @@ -0,0 +1,59 @@ +error: expected validate regex attribute to be a string: `regex = "..."` + --> $DIR/invalid_validation_attrs.rs:4:39 + | +4 | pub struct Struct1(#[validate(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + | ^ + +error: unknown schemars attribute `foo` + --> $DIR/invalid_validation_attrs.rs:7:42 + | +7 | pub struct Struct2(#[schemars(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + | ^^^ + +error: expected schemars regex attribute to be a string: `regex = "..."` + --> $DIR/invalid_validation_attrs.rs:7:39 + | +7 | pub struct Struct2(#[schemars(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + | ^ + +error: schemars attribute cannot contain both `equal` and `min` + --> $DIR/invalid_validation_attrs.rs:7:63 + | +7 | pub struct Struct2(#[schemars(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + | ^^^^^ + +error: unknown item in schemars length attribute + --> $DIR/invalid_validation_attrs.rs:7:74 + | +7 | pub struct Struct2(#[schemars(regex = 0, foo, length(min = 1, equal = 2, bar))] String); + | ^^^ + +error: schemars attribute cannot contain both `contains` and `regex` + --> $DIR/invalid_validation_attrs.rs:26:9 + | +26 | contains = "bar", + | ^^^^^^^^ + +error: duplicate schemars attribute `regex` + --> $DIR/invalid_validation_attrs.rs:27:9 + | +27 | regex(path = "baz"), + | ^^^^^ + +error: schemars attribute cannot contain both `phone` and `email` + --> $DIR/invalid_validation_attrs.rs:29:9 + | +29 | email, + | ^^^^^ + +error: schemars attribute cannot contain both `phone` and `url` + --> $DIR/invalid_validation_attrs.rs:30:9 + | +30 | url + | ^^^ + +error[E0425]: cannot find value `foo` in this scope + --> $DIR/invalid_validation_attrs.rs:12:17 + | +12 | regex = "foo", + | ^^^^^ not found in this scope diff --git a/schemars/tests/ui/repr_missing.stderr b/schemars/tests/ui/repr_missing.stderr index 495c1778..a7016b2d 100644 --- a/schemars/tests/ui/repr_missing.stderr +++ b/schemars/tests/ui/repr_missing.stderr @@ -4,4 +4,4 @@ error: JsonSchema_repr: missing #[repr(...)] attribute 3 | #[derive(JsonSchema_repr)] | ^^^^^^^^^^^^^^^ | - = note: this error originates in a derive macro (in Nightly builds, run with -Z macro-backtrace for more info) + = note: this error originates in the derive macro `JsonSchema_repr` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/schemars/tests/ui/schema_for_arg_value.stderr b/schemars/tests/ui/schema_for_arg_value.stderr index c7879859..a3163065 100644 --- a/schemars/tests/ui/schema_for_arg_value.stderr +++ b/schemars/tests/ui/schema_for_arg_value.stderr @@ -4,4 +4,4 @@ error: This argument to `schema_for!` is not a type - did you mean to use `schem 4 | let _schema = schema_for!(123); | ^^^^^^^^^^^^^^^^ | - = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info) + = note: this error originates in the macro `schema_for` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/schemars/tests/util/mod.rs b/schemars/tests/util/mod.rs index 9f365d69..e83671f2 100644 --- a/schemars/tests/util/mod.rs +++ b/schemars/tests/util/mod.rs @@ -22,7 +22,7 @@ pub fn test_schema(actual: &RootSchema, file: &str) -> TestResult { let expected_json = match fs::read_to_string(format!("tests/expected/{}.json", file)) { Ok(j) => j, Err(e) => { - write_actual_to_file(&actual, file)?; + write_actual_to_file(actual, file)?; return Err(Box::from(e)); } }; @@ -32,7 +32,7 @@ pub fn test_schema(actual: &RootSchema, file: &str) -> TestResult { write_actual_to_file(actual, file)?; } - assert_eq!(actual, expected); + assert_eq!(expected, actual); Ok(()) } diff --git a/schemars/tests/validate.rs b/schemars/tests/validate.rs new file mode 100644 index 00000000..c8b68091 --- /dev/null +++ b/schemars/tests/validate.rs @@ -0,0 +1,121 @@ +mod util; +use schemars::JsonSchema; +use std::collections::HashMap; +use util::*; + +// In real code, this would typically be a Regex, potentially created in a `lazy_static!`. +static STARTS_WITH_HELLO: &'static str = r"^[Hh]ello\b"; + +const MIN: u32 = 1; +const MAX: u32 = 1000; + +#[derive(Debug, JsonSchema)] +pub struct Struct { + #[validate(range(min = 0.01, max = 100))] + min_max: f32, + #[validate(range(min = "MIN", max = "MAX"))] + min_max2: f32, + #[validate(regex = "STARTS_WITH_HELLO")] + regex_str1: String, + #[validate(regex(path = "STARTS_WITH_HELLO", code = "foo"))] + regex_str2: String, + #[validate(regex(pattern = r"^\d+$"))] + regex_str3: String, + #[validate(contains = "substring...")] + contains_str1: String, + #[validate(contains(pattern = "substring...", message = "bar"))] + contains_str2: String, + #[validate(email)] + email_address: String, + #[validate(phone)] + tel: String, + #[validate(url)] + homepage: String, + #[validate(length(min = 1, max = 100))] + non_empty_str: String, + #[validate(length(min = "MIN", max = "MAX"))] + non_empty_str2: String, + #[validate(length(equal = 2))] + pair: Vec, + #[validate(contains = "map_key")] + map_contains: HashMap, + #[validate(required)] + required_option: Option, + #[validate(required)] + #[validate] + #[serde(flatten)] + required_flattened: Option, +} + +#[derive(Debug, JsonSchema)] +pub struct Inner { + x: i32, +} + +#[test] +fn validate() -> TestResult { + test_default_generated_schema::("validate") +} + +#[derive(Debug, JsonSchema)] +pub struct Struct2 { + #[schemars(range(min = 0.01, max = 100))] + min_max: f32, + #[schemars(range(min = "MIN", max = "MAX"))] + min_max2: f32, + #[validate(regex = "overridden")] + #[schemars(regex = "STARTS_WITH_HELLO")] + regex_str1: String, + #[schemars(regex(path = "STARTS_WITH_HELLO"))] + regex_str2: String, + #[schemars(regex(pattern = r"^\d+$"))] + regex_str3: String, + #[validate(regex = "overridden")] + #[schemars(contains = "substring...")] + contains_str1: String, + #[schemars(contains(pattern = "substring..."))] + contains_str2: String, + #[schemars(email)] + email_address: String, + #[schemars(phone)] + tel: String, + #[schemars(url)] + homepage: String, + #[schemars(length(min = 1, max = 100))] + non_empty_str: String, + #[schemars(length(min = "MIN", max = "MAX"))] + non_empty_str2: String, + #[schemars(length(equal = 2))] + pair: Vec, + #[schemars(contains = "map_key")] + map_contains: HashMap, + #[schemars(required)] + required_option: Option, + #[schemars(required)] + #[serde(flatten)] + required_flattened: Option, +} + +#[test] +fn validate_schemars_attrs() -> TestResult { + test_default_generated_schema::("validate_schemars_attrs") +} + +#[derive(Debug, JsonSchema)] +pub struct Tuple( + #[validate(range(max = 10))] u8, + #[validate(required)] Option, +); + +#[test] +fn validate_tuple() -> TestResult { + test_default_generated_schema::("validate_tuple") +} + +#[derive(Debug, JsonSchema)] +pub struct NewType(#[validate(range(max = 10))] u8); + +#[test] +fn validate_newtype() -> TestResult { + test_default_generated_schema::("validate_newtype") +} diff --git a/schemars_derive/Cargo.toml b/schemars_derive/Cargo.toml index 0535f1dc..53a64088 100644 --- a/schemars_derive/Cargo.toml +++ b/schemars_derive/Cargo.toml @@ -3,7 +3,7 @@ name = "schemars_derive" description = "Macros for #[derive(JsonSchema)], for use with schemars" homepage = "https://graham.cool/schemars/" repository = "https://github.com/GREsau/schemars" -version = "0.8.3" +version = "0.8.8" authors = ["Graham Esau "] edition = "2018" license = "MIT" diff --git a/schemars_derive/src/ast/from_serde.rs b/schemars_derive/src/ast/from_serde.rs index 0d9add36..83bfcf30 100644 --- a/schemars_derive/src/ast/from_serde.rs +++ b/schemars_derive/src/ast/from_serde.rs @@ -73,6 +73,7 @@ impl<'a> FromSerde for Field<'a> { ty: serde.ty, original: serde.original, attrs: Attrs::new(&serde.original.attrs, errors), + validation_attrs: ValidationAttrs::new(&serde.original.attrs, errors), }) } } diff --git a/schemars_derive/src/ast/mod.rs b/schemars_derive/src/ast/mod.rs index a394acda..99fe1882 100644 --- a/schemars_derive/src/ast/mod.rs +++ b/schemars_derive/src/ast/mod.rs @@ -1,6 +1,6 @@ mod from_serde; -use crate::attr::Attrs; +use crate::attr::{Attrs, ValidationAttrs}; use from_serde::FromSerde; use serde_derive_internals::ast as serde_ast; use serde_derive_internals::{Ctxt, Derive}; @@ -34,6 +34,7 @@ pub struct Field<'a> { pub ty: &'a syn::Type, pub original: &'a syn::Field, pub attrs: Attrs, + pub validation_attrs: ValidationAttrs, } impl<'a> Container<'a> { diff --git a/schemars_derive/src/attr/mod.rs b/schemars_derive/src/attr/mod.rs index d36568d8..b1b9f04f 100644 --- a/schemars_derive/src/attr/mod.rs +++ b/schemars_derive/src/attr/mod.rs @@ -1,8 +1,11 @@ mod doc; mod schemars_to_serde; +mod validation; pub use schemars_to_serde::process_serde_attrs; +pub use validation::ValidationAttrs; +use crate::metadata::SchemaMetadata; use proc_macro2::{Group, Span, TokenStream, TokenTree}; use quote::ToTokens; use serde_derive_internals::Ctxt; @@ -51,6 +54,27 @@ impl Attrs { result } + pub fn as_metadata(&self) -> SchemaMetadata<'_> { + #[allow(clippy::ptr_arg)] + fn none_if_empty(s: &String) -> Option<&str> { + if s.is_empty() { + None + } else { + Some(s) + } + } + + SchemaMetadata { + title: self.title.as_ref().and_then(none_if_empty), + description: self.description.as_ref().and_then(none_if_empty), + deprecated: self.deprecated, + examples: &self.examples, + read_only: false, + write_only: false, + default: None, + } + } + fn populate( mut self, attrs: &[syn::Attribute], @@ -80,7 +104,7 @@ impl Attrs { for meta_item in attrs .iter() - .flat_map(|attr| get_meta_items(attr, attr_type, errors)) + .flat_map(|attr| get_meta_items(attr, attr_type, errors, ignore_errors)) .flatten() { match &meta_item { @@ -141,10 +165,7 @@ impl Attrs { _ if ignore_errors => {} Meta(meta_item) => { - let is_known_serde_keyword = schemars_to_serde::SERDE_KEYWORDS - .iter() - .any(|k| meta_item.path().is_ident(k)); - if !is_known_serde_keyword { + if !is_known_serde_or_validation_keyword(meta_item) { let path = meta_item .path() .into_token_stream() @@ -152,16 +173,13 @@ impl Attrs { .replace(' ', ""); errors.error_spanned_by( meta_item.path(), - format!("unknown schemars container attribute `{}`", path), + format!("unknown schemars attribute `{}`", path), ); } } Lit(lit) => { - errors.error_spanned_by( - lit, - "unexpected literal in schemars container attribute", - ); + errors.error_spanned_by(lit, "unexpected literal in schemars attribute"); } } } @@ -169,10 +187,21 @@ 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, errors: &Ctxt, + ignore_errors: bool, ) -> Result, ()> { if !attr.path.is_ident(attr_type) { return Ok(Vec::new()); @@ -181,11 +210,15 @@ fn get_meta_items( match attr.parse_meta() { Ok(List(meta)) => Ok(meta.nested.into_iter().collect()), Ok(other) => { - errors.error_spanned_by(other, format!("expected #[{}(...)]", attr_type)); + if !ignore_errors { + errors.error_spanned_by(other, format!("expected #[{}(...)]", attr_type)) + } Err(()) } Err(err) => { - errors.error_spanned_by(attr, err); + if !ignore_errors { + errors.error_spanned_by(attr, err) + } Err(()) } } diff --git a/schemars_derive/src/attr/validation.rs b/schemars_derive/src/attr/validation.rs new file mode 100644 index 00000000..1a93a2a4 --- /dev/null +++ b/schemars_derive/src/attr/validation.rs @@ -0,0 +1,498 @@ +use super::{get_lit_str, get_meta_items, parse_lit_into_path, parse_lit_str}; +use proc_macro2::TokenStream; +use serde_derive_internals::Ctxt; +use syn::{Expr, ExprLit, ExprPath, Lit, Meta, MetaNameValue, NestedMeta, Path}; + +pub(crate) static VALIDATION_KEYWORDS: &[&str] = &[ + "range", "regex", "contains", "email", "phone", "url", "length", "required", +]; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Format { + Email, + Uri, + Phone, +} + +impl Format { + fn attr_str(self) -> &'static str { + match self { + Format::Email => "email", + Format::Uri => "url", + Format::Phone => "phone", + } + } + + fn schema_str(self) -> &'static str { + match self { + Format::Email => "email", + Format::Uri => "uri", + Format::Phone => "phone", + } + } +} + +#[derive(Debug, Default)] +pub struct ValidationAttrs { + length_min: Option, + length_max: Option, + length_equal: Option, + range_min: Option, + range_max: Option, + regex: Option, + contains: Option, + required: bool, + format: Option, +} + +impl ValidationAttrs { + pub fn new(attrs: &[syn::Attribute], errors: &Ctxt) -> Self { + ValidationAttrs::default() + .populate(attrs, "schemars", false, errors) + .populate(attrs, "validate", true, errors) + } + + pub fn required(&self) -> bool { + self.required + } + + fn populate( + mut self, + attrs: &[syn::Attribute], + attr_type: &'static str, + ignore_errors: bool, + errors: &Ctxt, + ) -> Self { + let duplicate_error = |path: &Path| { + if !ignore_errors { + let msg = format!( + "duplicate schemars attribute `{}`", + path.get_ident().unwrap() + ); + errors.error_spanned_by(path, msg) + } + }; + let mutual_exclusive_error = |path: &Path, other: &str| { + if !ignore_errors { + let msg = format!( + "schemars attribute cannot contain both `{}` and `{}`", + path.get_ident().unwrap(), + other, + ); + errors.error_spanned_by(path, msg) + } + }; + let duplicate_format_error = |existing: Format, new: Format, path: &syn::Path| { + if !ignore_errors { + let msg = if existing == new { + format!("duplicate schemars attribute `{}`", existing.attr_str()) + } else { + format!( + "schemars attribute cannot contain both `{}` and `{}`", + existing.attr_str(), + new.attr_str(), + ) + }; + errors.error_spanned_by(path, msg) + } + }; + + for meta_item in attrs + .iter() + .flat_map(|attr| get_meta_items(attr, attr_type, errors, ignore_errors)) + .flatten() + { + match &meta_item { + NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("length") => { + for nested in meta_list.nested.iter() { + match nested { + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("min") => { + if self.length_min.is_some() { + duplicate_error(&nv.path) + } else if self.length_equal.is_some() { + mutual_exclusive_error(&nv.path, "equal") + } else { + self.length_min = str_or_num_to_expr(&errors, "min", &nv.lit); + } + } + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("max") => { + if self.length_max.is_some() { + duplicate_error(&nv.path) + } else if self.length_equal.is_some() { + mutual_exclusive_error(&nv.path, "equal") + } else { + self.length_max = str_or_num_to_expr(&errors, "max", &nv.lit); + } + } + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("equal") => { + if self.length_equal.is_some() { + duplicate_error(&nv.path) + } else if self.length_min.is_some() { + mutual_exclusive_error(&nv.path, "min") + } else if self.length_max.is_some() { + mutual_exclusive_error(&nv.path, "max") + } else { + self.length_equal = + str_or_num_to_expr(&errors, "equal", &nv.lit); + } + } + meta => { + if !ignore_errors { + errors.error_spanned_by( + meta, + format!("unknown item in schemars length attribute"), + ); + } + } + } + } + } + + NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("range") => { + for nested in meta_list.nested.iter() { + match nested { + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("min") => { + if self.range_min.is_some() { + duplicate_error(&nv.path) + } else { + self.range_min = str_or_num_to_expr(&errors, "min", &nv.lit); + } + } + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("max") => { + if self.range_max.is_some() { + duplicate_error(&nv.path) + } else { + self.range_max = str_or_num_to_expr(&errors, "max", &nv.lit); + } + } + meta => { + if !ignore_errors { + errors.error_spanned_by( + meta, + format!("unknown item in schemars range attribute"), + ); + } + } + } + } + } + + NestedMeta::Meta(Meta::Path(m)) + if m.is_ident("required") || m.is_ident("required_nested") => + { + self.required = true; + } + + NestedMeta::Meta(Meta::Path(p)) if p.is_ident(Format::Email.attr_str()) => { + match self.format { + Some(f) => duplicate_format_error(f, Format::Email, p), + None => self.format = Some(Format::Email), + } + } + NestedMeta::Meta(Meta::Path(p)) if p.is_ident(Format::Uri.attr_str()) => { + match self.format { + Some(f) => duplicate_format_error(f, Format::Uri, p), + None => self.format = Some(Format::Uri), + } + } + NestedMeta::Meta(Meta::Path(p)) if p.is_ident(Format::Phone.attr_str()) => { + match self.format { + Some(f) => duplicate_format_error(f, Format::Phone, p), + None => self.format = Some(Format::Phone), + } + } + + NestedMeta::Meta(Meta::NameValue(nv)) if nv.path.is_ident("regex") => { + match (&self.regex, &self.contains) { + (Some(_), _) => duplicate_error(&nv.path), + (None, Some(_)) => mutual_exclusive_error(&nv.path, "contains"), + (None, None) => { + self.regex = + parse_lit_into_expr_path(errors, attr_type, "regex", &nv.lit).ok() + } + } + } + + NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("regex") => { + match (&self.regex, &self.contains) { + (Some(_), _) => duplicate_error(&meta_list.path), + (None, Some(_)) => mutual_exclusive_error(&meta_list.path, "contains"), + (None, None) => { + for x in meta_list.nested.iter() { + match x { + NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit, + .. + })) if path.is_ident("path") => { + self.regex = + parse_lit_into_expr_path(errors, attr_type, "path", lit) + .ok() + } + NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit, + .. + })) if path.is_ident("pattern") => { + self.regex = get_lit_str(errors, attr_type, "pattern", lit) + .ok() + .map(|litstr| { + Expr::Lit(syn::ExprLit { + attrs: Vec::new(), + lit: Lit::Str(litstr.clone()), + }) + }) + } + meta => { + if !ignore_errors { + errors.error_spanned_by( + meta, + format!("unknown item in schemars regex attribute"), + ); + } + } + } + } + } + } + } + + NestedMeta::Meta(Meta::NameValue(MetaNameValue { path, lit, .. })) + if path.is_ident("contains") => + { + match (&self.contains, &self.regex) { + (Some(_), _) => duplicate_error(&path), + (None, Some(_)) => mutual_exclusive_error(&path, "regex"), + (None, None) => { + self.contains = get_lit_str(errors, attr_type, "contains", lit) + .map(|litstr| litstr.value()) + .ok() + } + } + } + + NestedMeta::Meta(Meta::List(meta_list)) if meta_list.path.is_ident("contains") => { + match (&self.contains, &self.regex) { + (Some(_), _) => duplicate_error(&meta_list.path), + (None, Some(_)) => mutual_exclusive_error(&meta_list.path, "regex"), + (None, None) => { + for x in meta_list.nested.iter() { + match x { + NestedMeta::Meta(Meta::NameValue(MetaNameValue { + path, + lit, + .. + })) if path.is_ident("pattern") => { + self.contains = + get_lit_str(errors, attr_type, "contains", lit) + .ok() + .map(|litstr| litstr.value()) + } + meta => { + if !ignore_errors { + errors.error_spanned_by( + meta, + format!( + "unknown item in schemars contains attribute" + ), + ); + } + } + } + } + } + } + } + + _ => {} + } + } + self + } + + pub fn apply_to_schema(&self, schema_expr: &mut TokenStream) { + let mut array_validation = Vec::new(); + let mut number_validation = Vec::new(); + let mut object_validation = Vec::new(); + let mut string_validation = Vec::new(); + + if let Some(length_min) = self + .length_min + .as_ref() + .or_else(|| self.length_equal.as_ref()) + { + string_validation.push(quote! { + validation.min_length = Some(#length_min as u32); + }); + array_validation.push(quote! { + validation.min_items = Some(#length_min as u32); + }); + } + + if let Some(length_max) = self + .length_max + .as_ref() + .or_else(|| self.length_equal.as_ref()) + { + string_validation.push(quote! { + validation.max_length = Some(#length_max as u32); + }); + array_validation.push(quote! { + validation.max_items = Some(#length_max as u32); + }); + } + + if let Some(range_min) = &self.range_min { + number_validation.push(quote! { + validation.minimum = Some(#range_min as f64); + }); + } + + if let Some(range_max) = &self.range_max { + number_validation.push(quote! { + validation.maximum = Some(#range_max as f64); + }); + } + + if let Some(regex) = &self.regex { + string_validation.push(quote! { + validation.pattern = Some(#regex.to_string()); + }); + } + + if let Some(contains) = &self.contains { + object_validation.push(quote! { + validation.required.insert(#contains.to_string()); + }); + + if self.regex.is_none() { + let pattern = crate::regex_syntax::escape(contains); + string_validation.push(quote! { + validation.pattern = Some(#pattern.to_string()); + }); + } + } + + let format = self.format.as_ref().map(|f| { + let f = f.schema_str(); + quote! { + schema_object.format = Some(#f.to_string()); + } + }); + + let array_validation = wrap_array_validation(array_validation); + let number_validation = wrap_number_validation(number_validation); + let object_validation = wrap_object_validation(object_validation); + let string_validation = wrap_string_validation(string_validation); + + if array_validation.is_some() + || number_validation.is_some() + || object_validation.is_some() + || string_validation.is_some() + || format.is_some() + { + *schema_expr = quote! { + { + let mut schema = #schema_expr; + if let schemars::schema::Schema::Object(schema_object) = &mut schema + { + #array_validation + #number_validation + #object_validation + #string_validation + #format + } + schema + } + } + } + } +} + +fn parse_lit_into_expr_path( + cx: &Ctxt, + attr_type: &'static str, + meta_item_name: &'static str, + lit: &syn::Lit, +) -> Result { + parse_lit_into_path(cx, attr_type, meta_item_name, lit).map(|path| { + Expr::Path(ExprPath { + attrs: Vec::new(), + qself: None, + path, + }) + }) +} + +fn wrap_array_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if schema_object.has_type(schemars::schema::InstanceType::Array) { + let validation = schema_object.array(); + #(#v)* + } + }) + } +} + +fn wrap_number_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if schema_object.has_type(schemars::schema::InstanceType::Integer) + || schema_object.has_type(schemars::schema::InstanceType::Number) { + let validation = schema_object.number(); + #(#v)* + } + }) + } +} + +fn wrap_object_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if schema_object.has_type(schemars::schema::InstanceType::Object) { + let validation = schema_object.object(); + #(#v)* + } + }) + } +} + +fn wrap_string_validation(v: Vec) -> Option { + if v.is_empty() { + None + } else { + Some(quote! { + if schema_object.has_type(schemars::schema::InstanceType::String) { + let validation = schema_object.string(); + #(#v)* + } + }) + } +} + +fn str_or_num_to_expr(cx: &Ctxt, meta_item_name: &str, lit: &Lit) -> Option { + match lit { + Lit::Str(s) => parse_lit_str::(s).ok().map(Expr::Path), + Lit::Int(_) | Lit::Float(_) => Some(Expr::Lit(ExprLit { + attrs: Vec::new(), + lit: lit.clone(), + })), + _ => { + cx.error_spanned_by( + lit, + format!( + "expected `{}` to be a string or number literal", + meta_item_name + ), + ); + None + } + } +} diff --git a/schemars_derive/src/lib.rs b/schemars_derive/src/lib.rs index d536488c..5fa4ac2f 100644 --- a/schemars_derive/src/lib.rs +++ b/schemars_derive/src/lib.rs @@ -9,13 +9,14 @@ extern crate proc_macro; mod ast; mod attr; mod metadata; +mod regex_syntax; mod schema_exprs; use ast::*; use proc_macro2::TokenStream; use syn::spanned::Spanned; -#[proc_macro_derive(JsonSchema, attributes(schemars, serde))] +#[proc_macro_derive(JsonSchema, attributes(schemars, serde, validate))] pub fn derive_json_schema_wrapper(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input = parse_macro_input!(input as syn::DeriveInput); derive_json_schema(input, false) @@ -50,7 +51,7 @@ fn derive_json_schema( let (impl_generics, ty_generics, where_clause) = cont.generics.split_for_impl(); if let Some(transparent_field) = cont.transparent_field() { - let (ty, type_def) = schema_exprs::type_for_field_schema(transparent_field, 0); + let (ty, type_def) = schema_exprs::type_for_field_schema(transparent_field); return Ok(quote! { const _: () = { #crate_alias diff --git a/schemars_derive/src/metadata.rs b/schemars_derive/src/metadata.rs index a84decad..32dbf671 100644 --- a/schemars_derive/src/metadata.rs +++ b/schemars_derive/src/metadata.rs @@ -1,7 +1,4 @@ -use crate::attr; -use attr::Attrs; -use proc_macro2::{Ident, Span, TokenStream}; -use quote::{ToTokens, TokenStreamExt}; +use proc_macro2::TokenStream; #[derive(Debug, Clone)] pub struct SchemaMetadata<'a> { @@ -14,42 +11,15 @@ pub struct SchemaMetadata<'a> { pub default: Option, } -impl ToTokens for SchemaMetadata<'_> { - fn to_tokens(&self, tokens: &mut TokenStream) { +impl<'a> SchemaMetadata<'a> { + pub fn apply_to_schema(&self, schema_expr: &mut TokenStream) { let setters = self.make_setters(); - if setters.is_empty() { - tokens.append(Ident::new("None", Span::call_site())) - } else { - tokens.extend(quote! { - Some({ - schemars::schema::Metadata { - #(#setters)* - ..Default::default() - } + if !setters.is_empty() { + *schema_expr = quote! { + schemars::_private::apply_metadata(#schema_expr, schemars::schema::Metadata { + #(#setters)* + ..Default::default() }) - }) - } - } -} - -impl<'a> SchemaMetadata<'a> { - pub fn from_attrs(attrs: &'a Attrs) -> Self { - SchemaMetadata { - title: attrs.title.as_ref().and_then(none_if_empty), - description: attrs.description.as_ref().and_then(none_if_empty), - deprecated: attrs.deprecated, - examples: &attrs.examples, - read_only: false, - write_only: false, - default: None, - } - } - - pub fn apply_to_schema(&self, schema_expr: TokenStream) -> TokenStream { - quote! { - { - let schema = #schema_expr; - schemars::_private::apply_metadata(schema, #self) } } } @@ -98,19 +68,10 @@ impl<'a> SchemaMetadata<'a> { if let Some(default) = &self.default { setters.push(quote! { - default: #default.and_then(|d| schemars::_serde_json::value::to_value(d).ok()), + default: #default.and_then(|d| schemars::_schemars_maybe_to_value!(d)), }); } setters } } - -#[allow(clippy::ptr_arg)] -fn none_if_empty(s: &String) -> Option<&str> { - if s.is_empty() { - None - } else { - Some(s) - } -} diff --git a/schemars_derive/src/regex_syntax.rs b/schemars_derive/src/regex_syntax.rs new file mode 100644 index 00000000..353bf8d3 --- /dev/null +++ b/schemars_derive/src/regex_syntax.rs @@ -0,0 +1,26 @@ +// Copied from regex_syntax crate to avoid pulling in the whole crate just for a utility function +// https://github.com/rust-lang/regex/blob/ff283badce21dcebd581909d38b81f2c8c9bfb54/regex-syntax/src/lib.rs + +pub fn escape(text: &str) -> String { + let mut quoted = String::new(); + escape_into(text, &mut quoted); + quoted +} + +fn escape_into(text: &str, buf: &mut String) { + buf.reserve(text.len()); + for c in text.chars() { + if is_meta_character(c) { + buf.push('\\'); + } + buf.push(c); + } +} + +fn is_meta_character(c: char) -> bool { + match c { + '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' + | '#' | '&' | '-' | '~' => true, + _ => false, + } +} diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs index f0f0c783..c5b1672d 100644 --- a/schemars_derive/src/schema_exprs.rs +++ b/schemars_derive/src/schema_exprs.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use crate::{ast::*, attr::WithAttr, metadata::SchemaMetadata}; use proc_macro2::{Span, TokenStream}; use serde_derive_internals::ast::Style; @@ -5,7 +7,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 +19,8 @@ pub fn expr_for_container(cont: &Container) -> TokenStream { Data::Enum(variants) => expr_for_enum(variants, &cont.serde_attrs), }; - let doc_metadata = SchemaMetadata::from_attrs(&cont.attrs); - doc_metadata.apply_to_schema(schema_expr) + cont.attrs.as_metadata().apply_to_schema(&mut schema_expr); + schema_expr } pub fn expr_for_repr(cont: &Container) -> Result { @@ -47,49 +49,52 @@ pub fn expr_for_repr(cont: &Container) -> Result { let enum_ident = &cont.ident; let variant_idents = variants.iter().map(|v| &v.ident); - let schema_expr = schema_object(quote! { + let mut schema_expr = schema_object(quote! { instance_type: Some(schemars::schema::InstanceType::Integer.into()), enum_values: Some(vec![#((#enum_ident::#variant_idents as #repr_type).into()),*]), }); - let doc_metadata = SchemaMetadata::from_attrs(&cont.attrs); - Ok(doc_metadata.apply_to_schema(schema_expr)) + cont.attrs.as_metadata().apply_to_schema(&mut schema_expr); + Ok(schema_expr) } fn expr_for_field(field: &Field, allow_ref: bool) -> TokenStream { - let (ty, type_def) = type_for_field_schema(field, 0); + let (ty, type_def) = type_for_field_schema(field); let span = field.original.span(); let gen = quote!(gen); - if allow_ref { + let mut schema_expr = if field.validation_attrs.required() { quote_spanned! {span=> - { - #type_def - #gen.subschema_for::<#ty>() - } + <#ty as schemars::JsonSchema>::_schemars_private_non_optional_json_schema(#gen) + } + } else if allow_ref { + quote_spanned! {span=> + #gen.subschema_for::<#ty>() } } else { quote_spanned! {span=> - { - #type_def - <#ty as schemars::JsonSchema>::json_schema(#gen) - } + <#ty as schemars::JsonSchema>::json_schema(#gen) } - } + }; + + prepend_type_def(type_def, &mut schema_expr); + field.validation_attrs.apply_to_schema(&mut schema_expr); + + schema_expr } -pub fn type_for_field_schema(field: &Field, local_id: usize) -> (syn::Type, Option) { +pub fn type_for_field_schema(field: &Field) -> (syn::Type, Option) { match &field.attrs.with { None => (field.ty.to_owned(), None), - Some(with_attr) => type_for_schema(with_attr, local_id), + Some(with_attr) => type_for_schema(with_attr), } } -fn type_for_schema(with_attr: &WithAttr, local_id: usize) -> (syn::Type, Option) { +fn type_for_schema(with_attr: &WithAttr) -> (syn::Type, Option) { match with_attr { WithAttr::Type(ty) => (ty.to_owned(), None), WithAttr::Function(fun) => { - let ty_name = format_ident!("_SchemarsSchemaWithFunction{}", local_id); + let ty_name = syn::Ident::new("_SchemarsSchemaWithFunction", Span::call_site()); let fn_name = fun.segments.last().unwrap().ident.to_string(); let type_def = quote_spanned! {fun.span()=> @@ -137,8 +142,14 @@ fn expr_for_external_tagged_enum<'a>( variants: impl Iterator>, deny_unknown_fields: bool, ) -> TokenStream { - let (unit_variants, complex_variants): (Vec<_>, Vec<_>) = - variants.partition(|v| v.is_unit() && v.attrs.with.is_none()); + let mut unique_names = HashSet::::new(); + let mut count = 0; + let (unit_variants, complex_variants): (Vec<_>, Vec<_>) = variants + .inspect(|v| { + unique_names.insert(v.name()); + count += 1; + }) + .partition(|v| v.is_unit() && v.attrs.with.is_none()); let unit_names = unit_variants.iter().map(|v| v.name()); let unit_schema = schema_object(quote! { @@ -159,7 +170,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: { @@ -172,20 +183,26 @@ fn expr_for_external_tagged_enum<'a>( required.insert(#name.to_owned()); required }, + // Externally tagged variants must prohibit additional + // properties irrespective of the disposition of + // `deny_unknown_fields`. If additional properties were allowed + // one could easily construct an object that validated against + // multiple variants since here it's the properties rather than + // the values of a property that distingish between variants. additional_properties: Some(Box::new(false.into())), ..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! { - subschemas: Some(Box::new(schemars::schema::SubschemaValidation { - any_of: Some(vec![#(#schemas),*]), - ..Default::default() - })), - }) + variant_subschemas(unique_names.len() == count, schemas) } fn expr_for_internal_tagged_enum<'a>( @@ -193,64 +210,81 @@ fn expr_for_internal_tagged_enum<'a>( tag_name: &str, deny_unknown_fields: bool, ) -> TokenStream { - let variant_schemas = variants.map(|variant| { - let name = variant.name(); - let type_schema = schema_object(quote! { - instance_type: Some(schemars::schema::InstanceType::String.into()), - enum_values: Some(vec![#name.into()]), - }); - - let tag_schema = schema_object(quote! { - instance_type: Some(schemars::schema::InstanceType::Object.into()), - object: Some(Box::new(schemars::schema::ObjectValidation { - properties: { - let mut props = schemars::Map::new(); - props.insert(#tag_name.to_owned(), #type_schema); - props - }, - required: { - let mut required = schemars::Set::new(); - required.insert(#tag_name.to_owned()); - required - }, - ..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, + let mut unique_names = HashSet::new(); + let mut count = 0; + let set_additional_properties = if deny_unknown_fields { + quote! { + additional_properties: Some(Box::new(false.into())), } - }); + } else { + TokenStream::new() + }; + let variant_schemas = variants + .map(|variant| { + unique_names.insert(variant.name()); + count += 1; + + let name = variant.name(); + let type_schema = schema_object(quote! { + instance_type: Some(schemars::schema::InstanceType::String.into()), + enum_values: Some(vec![#name.into()]), + }); + + let mut tag_schema = schema_object(quote! { + instance_type: Some(schemars::schema::InstanceType::Object.into()), + object: Some(Box::new(schemars::schema::ObjectValidation { + properties: { + let mut props = schemars::Map::new(); + props.insert(#tag_name.to_owned(), #type_schema); + props + }, + required: { + let mut required = schemars::Set::new(); + required.insert(#tag_name.to_owned()); + required + }, + // As we're creating a "wrapper" object, we can honor the + // disposition of deny_unknown_fields. + #set_additional_properties + ..Default::default() + })), + }); + + 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))) + } - schema_object(quote! { - subschemas: Some(Box::new(schemars::schema::SubschemaValidation { - any_of: Some(vec![#(#variant_schemas),*]), - ..Default::default() - })), - }) + tag_schema + }) + .collect(); + + variant_subschemas(unique_names.len() == count, variant_schemas) } fn expr_for_untagged_enum<'a>( variants: impl Iterator>, 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 schemas = variants + .map(|variant| { + let mut schema_expr = expr_for_untagged_enum_variant(variant, deny_unknown_fields); - schema_object(quote! { - subschemas: Some(Box::new(schemars::schema::SubschemaValidation { - any_of: Some(vec![#(#schemas),*]), - ..Default::default() - })), - }) + variant + .attrs + .as_metadata() + .apply_to_schema(&mut schema_expr); + + schema_expr + }) + .collect(); + + // Untagged enums can easily have variants whose schemas overlap; rather + // that checking the exclusivity of each subschema we simply us `any_of`. + variant_subschemas(false, schemas) } fn expr_for_adjacent_tagged_enum<'a>( @@ -259,78 +293,106 @@ fn expr_for_adjacent_tagged_enum<'a>( content_name: &str, deny_unknown_fields: bool, ) -> TokenStream { - let schemas = variants.map(|variant| { - let content_schema = if variant.is_unit() && variant.attrs.with.is_none() { - None - } else { - Some(expr_for_untagged_enum_variant(variant, deny_unknown_fields)) - }; + let mut unique_names = HashSet::new(); + let mut count = 0; + let schemas = variants + .map(|variant| { + unique_names.insert(variant.name()); + count += 1; + + let content_schema = if variant.is_unit() && variant.attrs.with.is_none() { + None + } else { + Some(expr_for_untagged_enum_variant(variant, deny_unknown_fields)) + }; - let (add_content_to_props, add_content_to_required) = content_schema - .map(|content_schema| { - ( - quote!(props.insert(#content_name.to_owned(), #content_schema);), - quote!(required.insert(#content_name.to_owned());), - ) - }) - .unwrap_or_default(); + let (add_content_to_props, add_content_to_required) = content_schema + .map(|content_schema| { + ( + quote!(props.insert(#content_name.to_owned(), #content_schema);), + quote!(required.insert(#content_name.to_owned());), + ) + }) + .unwrap_or_default(); + + let name = variant.name(); + let tag_schema = schema_object(quote! { + instance_type: Some(schemars::schema::InstanceType::String.into()), + enum_values: Some(vec![#name.into()]), + }); + + let set_additional_properties = if deny_unknown_fields { + quote! { + additional_properties: Some(Box::new(false.into())), + } + } else { + TokenStream::new() + }; - let name = variant.name(); - let tag_schema = schema_object(quote! { - instance_type: Some(schemars::schema::InstanceType::String.into()), - enum_values: Some(vec![#name.into()]), - }); + let mut outer_schema = schema_object(quote! { + instance_type: Some(schemars::schema::InstanceType::Object.into()), + object: Some(Box::new(schemars::schema::ObjectValidation { + properties: { + let mut props = schemars::Map::new(); + props.insert(#tag_name.to_owned(), #tag_schema); + #add_content_to_props + props + }, + required: { + let mut required = schemars::Set::new(); + required.insert(#tag_name.to_owned()); + #add_content_to_required + required + }, + // As we're creating a "wrapper" object, we can honor the + // disposition of deny_unknown_fields. + #set_additional_properties + ..Default::default() + })), + }); + + variant + .attrs + .as_metadata() + .apply_to_schema(&mut outer_schema); + + outer_schema + }) + .collect(); - let set_additional_properties = if deny_unknown_fields { - quote! { - additional_properties: Some(Box::new(false.into())), - } - } else { - TokenStream::new() - }; + variant_subschemas(unique_names.len() == count, schemas) +} - let outer_schema = schema_object(quote! { - instance_type: Some(schemars::schema::InstanceType::Object.into()), - object: Some(Box::new(schemars::schema::ObjectValidation { - properties: { - let mut props = schemars::Map::new(); - props.insert(#tag_name.to_owned(), #tag_schema); - #add_content_to_props - props - }, - required: { - let mut required = schemars::Set::new(); - required.insert(#tag_name.to_owned()); - #add_content_to_required - required - }, - #set_additional_properties +/// Callers must determine if all subschemas are mutually exclusive. This can +/// be done for most tagging regimes by checking that all tag names are unique. +fn variant_subschemas(unique: bool, schemas: Vec) -> TokenStream { + if unique { + schema_object(quote! { + subschemas: Some(Box::new(schemars::schema::SubschemaValidation { + one_of: Some(vec![#(#schemas),*]), ..Default::default() })), - }); - - let doc_metadata = SchemaMetadata::from_attrs(&variant.attrs); - doc_metadata.apply_to_schema(outer_schema) - }); - - schema_object(quote! { - subschemas: Some(Box::new(schemars::schema::SubschemaValidation { - any_of: Some(vec![#(#schemas),*]), - ..Default::default() - })), - }) + }) + } else { + schema_object(quote! { + subschemas: Some(Box::new(schemars::schema::SubschemaValidation { + any_of: Some(vec![#(#schemas),*]), + ..Default::default() + })), + }) + } } fn expr_for_untagged_enum_variant(variant: &Variant, deny_unknown_fields: bool) -> TokenStream { if let Some(with_attr) = &variant.attrs.with { - let (ty, type_def) = type_for_schema(with_attr, 0); + let (ty, type_def) = type_for_schema(with_attr); let gen = quote!(gen); - return quote_spanned! {variant.original.span()=> - { - #type_def - #gen.subschema_for::<#ty>() - } + let mut schema_expr = quote_spanned! {variant.original.span()=> + #gen.subschema_for::<#ty>() }; + + prepend_type_def(type_def, &mut schema_expr); + return schema_expr; } match variant.style { @@ -346,14 +408,14 @@ fn expr_for_untagged_enum_variant_for_flatten( deny_unknown_fields: bool, ) -> Option { if let Some(with_attr) = &variant.attrs.with { - let (ty, type_def) = type_for_schema(with_attr, 0); + let (ty, type_def) = type_for_schema(with_attr); let gen = quote!(gen); - return Some(quote_spanned! {variant.original.span()=> - { - #type_def - <#ty as schemars::JsonSchema>::json_schema(#gen) - } - }); + let mut schema_expr = quote_spanned! {variant.original.span()=> + <#ty as schemars::JsonSchema>::json_schema(#gen) + }; + + prepend_type_def(type_def, &mut schema_expr); + return Some(schema_expr); } Some(match variant.style { @@ -375,17 +437,25 @@ fn expr_for_newtype_struct(field: &Field) -> TokenStream { } fn expr_for_tuple_struct(fields: &[Field]) -> TokenStream { - let (types, type_defs): (Vec<_>, Vec<_>) = fields + let fields: Vec<_> = fields .iter() .filter(|f| !f.serde_attrs.skip_deserializing()) - .enumerate() - .map(|(i, f)| type_for_field_schema(f, i)) - .unzip(); + .map(|f| expr_for_field(f, true)) + .collect(); + let len = fields.len() as u32; + quote! { - { - #(#type_defs)* - gen.subschema_for::<(#(#types),*)>() - } + schemars::schema::Schema::Object( + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Array.into()), + array: Some(Box::new(schemars::schema::ArrayValidation { + items: Some(vec![#(#fields),*].into()), + max_items: Some(#len), + min_items: Some(#len), + ..Default::default() + })), + ..Default::default() + }) } } @@ -405,35 +475,55 @@ fn expr_for_struct( SerdeDefault::Path(path) => Some(quote!(let container_default = #path();)), }; - let mut type_defs = Vec::new(); - let properties: Vec<_> = property_fields .into_iter() .map(|field| { let name = field.name(); let default = field_default_expr(field, set_container_default.is_some()); - let required = match default { - Some(_) => quote!(false), - None => quote!(true), + let (ty, type_def) = type_for_field_schema(field); + + let maybe_insert_required = match (&default, field.validation_attrs.required()) { + (Some(_), _) => TokenStream::new(), + (None, false) => { + quote! { + if !<#ty as schemars::JsonSchema>::_schemars_private_is_option() { + object_validation.required.insert(#name.to_owned()); + } + } + } + (None, true) => quote! { + object_validation.required.insert(#name.to_owned()); + }, }; - let metadata = &SchemaMetadata { + let metadata = SchemaMetadata { read_only: field.serde_attrs.skip_deserializing(), write_only: field.serde_attrs.skip_serializing(), default, - ..SchemaMetadata::from_attrs(&field.attrs) + ..field.attrs.as_metadata() }; - let (ty, type_def) = type_for_field_schema(field, type_defs.len()); - if let Some(type_def) = type_def { - type_defs.push(type_def); - } + let gen = quote!(gen); + let mut schema_expr = if field.validation_attrs.required() { + quote_spanned! {ty.span()=> + <#ty as schemars::JsonSchema>::_schemars_private_non_optional_json_schema(#gen) + } + } else { + quote_spanned! {ty.span()=> + #gen.subschema_for::<#ty>() + } + }; - let args = quote!(gen, &mut schema_object, #name.to_owned(), #metadata, #required); + metadata.apply_to_schema(&mut schema_expr); + field.validation_attrs.apply_to_schema(&mut schema_expr); - quote_spanned! {ty.span()=> - schemars::_private::add_schema_as_property::<#ty>(#args); + quote! { + { + #type_def + object_validation.properties.insert(#name.to_owned(), #schema_expr); + #maybe_insert_required + } } }) .collect(); @@ -441,37 +531,39 @@ fn expr_for_struct( let flattens: Vec<_> = flattened_fields .into_iter() .map(|field| { - let (ty, type_def) = type_for_field_schema(field, type_defs.len()); - if let Some(type_def) = type_def { - type_defs.push(type_def); - } + let (ty, type_def) = type_for_field_schema(field); - let gen = quote!(gen); - quote_spanned! {ty.span()=> - .flatten(schemars::_private::json_schema_for_flatten::<#ty>(#gen)) - } + let required = field.validation_attrs.required(); + + let args = quote!(gen, #required); + let mut schema_expr = quote_spanned! {ty.span()=> + schemars::_private::json_schema_for_flatten::<#ty>(#args) + }; + + prepend_type_def(type_def, &mut schema_expr); + schema_expr }) .collect(); let set_additional_properties = if deny_unknown_fields { quote! { - schema_object.object().additional_properties = Some(Box::new(false.into())); + object_validation.additional_properties = Some(Box::new(false.into())); } } else { TokenStream::new() }; quote! { { - #(#type_defs)* #set_container_default let mut schema_object = schemars::schema::SchemaObject { instance_type: Some(schemars::schema::InstanceType::Object.into()), ..Default::default() }; + let object_validation = schema_object.object(); #set_additional_properties #(#properties)* schemars::schema::Schema::Object(schema_object) - #(#flattens)* + #(.flatten(#flattens))* } } } @@ -540,3 +632,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 + } + } + } +}