From 840315b2ddcb670b50d0e91940c61452f95ab32f Mon Sep 17 00:00:00 2001 From: Graham Esau Date: Wed, 5 Jun 2024 21:09:52 +0100 Subject: [PATCH] Add `#[schemars(extend("key" = value))]` attribute (#297) --- schemars/tests/expected/default.json | 4 +- .../tests/expected/extend_enum_adjacent.json | 101 ++++++++++++++++++ .../tests/expected/extend_enum_external.json | 73 +++++++++++++ .../tests/expected/extend_enum_internal.json | 55 ++++++++++ .../tests/expected/extend_enum_untagged.json | 46 ++++++++ schemars/tests/expected/extend_struct.json | 27 +++++ .../tests/expected/from_value_2019_09.json | 6 +- .../tests/expected/from_value_draft07.json | 6 +- .../tests/expected/from_value_openapi3.json | 6 +- .../expected/schema_settings-2019_09.json | 6 +- .../expected/schema_settings-openapi3.json | 6 +- schemars/tests/expected/schema_settings.json | 6 +- schemars/tests/extend.rs | 96 +++++++++++++++++ schemars/tests/ui/invalid_extend.rs | 11 ++ schemars/tests/ui/invalid_extend.stderr | 35 ++++++ schemars_derive/src/attr/mod.rs | 54 +++++++++- schemars_derive/src/metadata.rs | 7 ++ schemars_derive/src/schema_exprs.rs | 8 +- 18 files changed, 527 insertions(+), 26 deletions(-) create mode 100644 schemars/tests/expected/extend_enum_adjacent.json create mode 100644 schemars/tests/expected/extend_enum_external.json create mode 100644 schemars/tests/expected/extend_enum_internal.json create mode 100644 schemars/tests/expected/extend_enum_untagged.json create mode 100644 schemars/tests/expected/extend_struct.json create mode 100644 schemars/tests/extend.rs create mode 100644 schemars/tests/ui/invalid_extend.rs create mode 100644 schemars/tests/ui/invalid_extend.stderr diff --git a/schemars/tests/expected/default.json b/schemars/tests/expected/default.json index d70fb57f..72e580d4 100644 --- a/schemars/tests/expected/default.json +++ b/schemars/tests/expected/default.json @@ -13,11 +13,11 @@ "default": false }, "my_optional_string": { - "default": null, "type": [ "string", "null" - ] + ], + "default": null }, "my_struct2": { "$ref": "#/$defs/MyStruct2", diff --git a/schemars/tests/expected/extend_enum_adjacent.json b/schemars/tests/expected/extend_enum_adjacent.json new file mode 100644 index 00000000..6241e079 --- /dev/null +++ b/schemars/tests/expected/extend_enum_adjacent.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Adjacent", + "oneOf": [ + { + "type": "object", + "properties": { + "t": { + "type": "string", + "enum": [ + "Unit" + ] + } + }, + "required": [ + "t" + ], + "foo": "bar" + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "enum": [ + "NewType" + ] + }, + "c": true + }, + "required": [ + "t", + "c" + ], + "foo": "bar" + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "enum": [ + "Tuple" + ] + }, + "c": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "format": "int32" + }, + { + "type": "boolean" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "t", + "c" + ], + "foo": "bar" + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "enum": [ + "Struct" + ] + }, + "c": { + "type": "object", + "properties": { + "i": { + "type": "integer", + "format": "int32" + }, + "b": { + "type": "boolean" + } + }, + "required": [ + "i", + "b" + ] + } + }, + "required": [ + "t", + "c" + ], + "foo": "bar" + } + ], + "foo": "bar" +} \ No newline at end of file diff --git a/schemars/tests/expected/extend_enum_external.json b/schemars/tests/expected/extend_enum_external.json new file mode 100644 index 00000000..c15d47f5 --- /dev/null +++ b/schemars/tests/expected/extend_enum_external.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "External", + "oneOf": [ + { + "type": "string", + "const": "Unit", + "foo": "bar" + }, + { + "type": "object", + "properties": { + "NewType": true + }, + "required": [ + "NewType" + ], + "additionalProperties": false, + "foo": "bar" + }, + { + "type": "object", + "properties": { + "Tuple": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "format": "int32" + }, + { + "type": "boolean" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "Tuple" + ], + "additionalProperties": false, + "foo": "bar" + }, + { + "type": "object", + "properties": { + "Struct": { + "type": "object", + "properties": { + "i": { + "type": "integer", + "format": "int32" + }, + "b": { + "type": "boolean" + } + }, + "required": [ + "i", + "b" + ] + } + }, + "required": [ + "Struct" + ], + "additionalProperties": false, + "foo": "bar" + } + ], + "foo": "bar" +} \ No newline at end of file diff --git a/schemars/tests/expected/extend_enum_internal.json b/schemars/tests/expected/extend_enum_internal.json new file mode 100644 index 00000000..0dee8174 --- /dev/null +++ b/schemars/tests/expected/extend_enum_internal.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Internal", + "oneOf": [ + { + "type": "object", + "properties": { + "typeProperty": { + "type": "string", + "const": "Unit" + } + }, + "required": [ + "typeProperty" + ], + "foo": "bar" + }, + { + "type": "object", + "properties": { + "typeProperty": { + "type": "string", + "const": "NewType" + } + }, + "required": [ + "typeProperty" + ], + "foo": "bar" + }, + { + "type": "object", + "properties": { + "i": { + "type": "integer", + "format": "int32" + }, + "b": { + "type": "boolean" + }, + "typeProperty": { + "type": "string", + "const": "Struct" + } + }, + "required": [ + "typeProperty", + "i", + "b" + ], + "foo": "bar" + } + ], + "foo": "bar" +} \ No newline at end of file diff --git a/schemars/tests/expected/extend_enum_untagged.json b/schemars/tests/expected/extend_enum_untagged.json new file mode 100644 index 00000000..4f733fe9 --- /dev/null +++ b/schemars/tests/expected/extend_enum_untagged.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Untagged", + "anyOf": [ + { + "type": "null", + "foo": "bar" + }, + { + "foo": "bar" + }, + { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "format": "int32" + }, + { + "type": "boolean" + } + ], + "minItems": 2, + "maxItems": 2, + "foo": "bar" + }, + { + "type": "object", + "properties": { + "i": { + "type": "integer", + "format": "int32" + }, + "b": { + "type": "boolean" + } + }, + "required": [ + "i", + "b" + ], + "foo": "bar" + } + ], + "foo": "bar" +} \ No newline at end of file diff --git a/schemars/tests/expected/extend_struct.json b/schemars/tests/expected/extend_struct.json new file mode 100644 index 00000000..fc7dd50f --- /dev/null +++ b/schemars/tests/expected/extend_struct.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Struct", + "type": "object", + "properties": { + "value": { + "foo": "bar" + }, + "int": { + "type": "overridden", + "format": "int32" + } + }, + "required": [ + "value", + "int" + ], + "msg": "hello world", + "obj": { + "array": [ + null, + null + ] + }, + "3": 3.0, + "pi": 3.14 +} \ No newline at end of file diff --git a/schemars/tests/expected/from_value_2019_09.json b/schemars/tests/expected/from_value_2019_09.json index 52c05243..939410da 100644 --- a/schemars/tests/expected/from_value_2019_09.json +++ b/schemars/tests/expected/from_value_2019_09.json @@ -35,6 +35,8 @@ }, "my_tuple": { "type": "array", + "minItems": 2, + "maxItems": 2, "items": [ { "type": "string", @@ -44,9 +46,7 @@ { "type": "integer" } - ], - "maxItems": 2, - "minItems": 2 + ] } } } diff --git a/schemars/tests/expected/from_value_draft07.json b/schemars/tests/expected/from_value_draft07.json index de89fada..721e8716 100644 --- a/schemars/tests/expected/from_value_draft07.json +++ b/schemars/tests/expected/from_value_draft07.json @@ -35,6 +35,8 @@ }, "my_tuple": { "type": "array", + "minItems": 2, + "maxItems": 2, "items": [ { "type": "string", @@ -44,9 +46,7 @@ { "type": "integer" } - ], - "maxItems": 2, - "minItems": 2 + ] } } } diff --git a/schemars/tests/expected/from_value_openapi3.json b/schemars/tests/expected/from_value_openapi3.json index 4e9dd2cc..88f08a79 100644 --- a/schemars/tests/expected/from_value_openapi3.json +++ b/schemars/tests/expected/from_value_openapi3.json @@ -37,6 +37,8 @@ }, "my_tuple": { "type": "array", + "minItems": 2, + "maxItems": 2, "items": [ { "type": "string", @@ -46,9 +48,7 @@ { "type": "integer" } - ], - "maxItems": 2, - "minItems": 2 + ] } } } diff --git a/schemars/tests/expected/schema_settings-2019_09.json b/schemars/tests/expected/schema_settings-2019_09.json index 6b6dc614..bc99f155 100644 --- a/schemars/tests/expected/schema_settings-2019_09.json +++ b/schemars/tests/expected/schema_settings-2019_09.json @@ -30,6 +30,8 @@ "type": "array", "items": { "type": "array", + "maxItems": 2, + "minItems": 2, "items": [ { "type": "integer", @@ -40,9 +42,7 @@ "type": "integer", "format": "int64" } - ], - "minItems": 2, - "maxItems": 2 + ] } } }, diff --git a/schemars/tests/expected/schema_settings-openapi3.json b/schemars/tests/expected/schema_settings-openapi3.json index e5032f0d..8365822f 100644 --- a/schemars/tests/expected/schema_settings-openapi3.json +++ b/schemars/tests/expected/schema_settings-openapi3.json @@ -25,6 +25,8 @@ "type": "array", "items": { "type": "array", + "maxItems": 2, + "minItems": 2, "items": [ { "type": "integer", @@ -35,9 +37,7 @@ "type": "integer", "format": "int64" } - ], - "minItems": 2, - "maxItems": 2 + ] } } }, diff --git a/schemars/tests/expected/schema_settings.json b/schemars/tests/expected/schema_settings.json index a836cd68..4c435ad7 100644 --- a/schemars/tests/expected/schema_settings.json +++ b/schemars/tests/expected/schema_settings.json @@ -30,6 +30,8 @@ "type": "array", "items": { "type": "array", + "maxItems": 2, + "minItems": 2, "items": [ { "type": "integer", @@ -40,9 +42,7 @@ "type": "integer", "format": "int64" } - ], - "minItems": 2, - "maxItems": 2 + ] } } }, diff --git a/schemars/tests/extend.rs b/schemars/tests/extend.rs new file mode 100644 index 00000000..08f42fa9 --- /dev/null +++ b/schemars/tests/extend.rs @@ -0,0 +1,96 @@ +mod util; +use schemars::JsonSchema; +use serde_json::Value; +use util::*; + +const THREE: f64 = 3.0; + +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars(extend("msg" = concat!("hello ", "world"), "obj" = {"array": [null, ()]}))] +#[schemars(extend("3" = THREE), extend("pi" = THREE + 0.14))] +struct Struct { + #[schemars(extend("foo" = "bar"))] + value: Value, + #[schemars(extend("type" = "overridden"))] + int: i32, +} + +#[test] +fn doc_comments_struct() -> TestResult { + test_default_generated_schema::("extend_struct") +} + +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars(extend("foo" = "bar"))] +enum External { + #[schemars(extend("foo" = "bar"))] + Unit, + #[schemars(extend("foo" = "bar"))] + NewType(Value), + #[schemars(extend("foo" = "bar"))] + Tuple(i32, bool), + #[schemars(extend("foo" = "bar"))] + Struct { i: i32, b: bool }, +} + +#[test] +fn doc_comments_enum_external() -> TestResult { + test_default_generated_schema::("extend_enum_external") +} + +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars(tag = "typeProperty", extend("foo" = "bar"))] +enum Internal { + #[schemars(extend("foo" = "bar"))] + Unit, + #[schemars(extend("foo" = "bar"))] + NewType(Value), + #[schemars(extend("foo" = "bar"))] + Struct { i: i32, b: bool }, +} + +#[test] +fn doc_comments_enum_internal() -> TestResult { + test_default_generated_schema::("extend_enum_internal") +} + +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars(untagged, extend("foo" = "bar"))] +enum Untagged { + #[schemars(extend("foo" = "bar"))] + Unit, + #[schemars(extend("foo" = "bar"))] + NewType(Value), + #[schemars(extend("foo" = "bar"))] + Tuple(i32, bool), + #[schemars(extend("foo" = "bar"))] + Struct { i: i32, b: bool }, +} + +#[test] +fn doc_comments_enum_untagged() -> TestResult { + test_default_generated_schema::("extend_enum_untagged") +} + +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars(tag = "t", content = "c", extend("foo" = "bar"))] +enum Adjacent { + #[schemars(extend("foo" = "bar"))] + Unit, + #[schemars(extend("foo" = "bar"))] + NewType(Value), + #[schemars(extend("foo" = "bar"))] + Tuple(i32, bool), + #[schemars(extend("foo" = "bar"))] + Struct { i: i32, b: bool }, +} + +#[test] +fn doc_comments_enum_adjacent() -> TestResult { + test_default_generated_schema::("extend_enum_adjacent") +} diff --git a/schemars/tests/ui/invalid_extend.rs b/schemars/tests/ui/invalid_extend.rs new file mode 100644 index 00000000..b7295f73 --- /dev/null +++ b/schemars/tests/ui/invalid_extend.rs @@ -0,0 +1,11 @@ +use schemars::JsonSchema; + +#[derive(JsonSchema)] +#[schemars(extend(x))] +#[schemars(extend("x"))] +#[schemars(extend("x" = ))] +#[schemars(extend("y" = "ok!", "y" = "duplicated!"), extend("y" = "duplicated!"))] +#[schemars(extend("y" = "duplicated!"))] +pub struct Struct; + +fn main() {} diff --git a/schemars/tests/ui/invalid_extend.stderr b/schemars/tests/ui/invalid_extend.stderr new file mode 100644 index 00000000..d7d21798 --- /dev/null +++ b/schemars/tests/ui/invalid_extend.stderr @@ -0,0 +1,35 @@ +error: expected string literal + --> tests/ui/invalid_extend.rs:4:19 + | +4 | #[schemars(extend(x))] + | ^ + +error: expected `=` + --> tests/ui/invalid_extend.rs:5:22 + | +5 | #[schemars(extend("x"))] + | ^ + +error: Expected extension value + --> tests/ui/invalid_extend.rs:6:25 + | +6 | #[schemars(extend("x" = ))] + | ^ + +error: Duplicate extension key 'y' + --> tests/ui/invalid_extend.rs:7:32 + | +7 | #[schemars(extend("y" = "ok!", "y" = "duplicated!"), extend("y" = "duplicated!"))] + | ^^^ + +error: Duplicate extension key 'y' + --> tests/ui/invalid_extend.rs:7:61 + | +7 | #[schemars(extend("y" = "ok!", "y" = "duplicated!"), extend("y" = "duplicated!"))] + | ^^^ + +error: Duplicate extension key 'y' + --> tests/ui/invalid_extend.rs:8:19 + | +8 | #[schemars(extend("y" = "duplicated!"))] + | ^^^ diff --git a/schemars_derive/src/attr/mod.rs b/schemars_derive/src/attr/mod.rs index 86c93498..108cedc4 100644 --- a/schemars_derive/src/attr/mod.rs +++ b/schemars_derive/src/attr/mod.rs @@ -10,7 +10,7 @@ use proc_macro2::{Group, Span, TokenStream, TokenTree}; use quote::ToTokens; use serde_derive_internals::Ctxt; use syn::parse::{self, Parse}; -use syn::{Meta, MetaNameValue}; +use syn::{LitStr, Meta, MetaNameValue}; // FIXME using the same struct for containers+variants+fields means that // with/schema_with are accepted (but ignored) on containers, and @@ -26,6 +26,7 @@ pub struct Attrs { pub repr: Option, pub crate_name: Option, pub is_renamed: bool, + pub extensions: Vec<(String, TokenStream)>, } #[derive(Debug)] @@ -68,6 +69,7 @@ impl Attrs { description: self.description.as_ref().and_then(none_if_empty), deprecated: self.deprecated, examples: &self.examples, + extensions: &self.extensions, read_only: false, write_only: false, default: None, @@ -162,6 +164,29 @@ impl Attrs { } } + Meta::List(m) if m.path.is_ident("extend") && attr_type == "schemars" => { + let parser = + syn::punctuated::Punctuated::::parse_terminated; + match m.parse_args_with(parser) { + Ok(extensions) => { + for extension in extensions { + let key = extension.key.value(); + // This is O(n^2) but should be fine with the typically small number of extensions. + // If this does become a problem, it can be changed to use IndexMap, or a separate Map with cloned keys. + if self.extensions.iter().any(|e| e.0 == key) { + errors.error_spanned_by( + extension.key, + format!("Duplicate extension key '{}'", key), + ); + } else { + self.extensions.push((key, extension.value)); + } + } + } + Err(err) => errors.syn_error(err), + } + } + _ if ignore_errors => {} Meta::List(m) if m.path.is_ident("inner") && attr_type == "schemars" => { @@ -198,7 +223,8 @@ impl Attrs { repr: None, crate_name: None, is_renamed: _, - } if examples.is_empty()) + extensions, + } if examples.is_empty() && extensions.is_empty()) } } @@ -322,3 +348,27 @@ fn respan_token_tree(mut token: TokenTree, span: Span) -> TokenTree { token.set_span(span); token } + +#[derive(Debug)] +struct Extension { + key: LitStr, + value: TokenStream, +} + +impl Parse for Extension { + fn parse(input: parse::ParseStream) -> syn::Result { + let key = input.parse::()?; + input.parse::()?; + let mut value = TokenStream::new(); + + while !input.is_empty() && !input.peek(Token![,]) { + value.extend([input.parse::()?]); + } + + if value.is_empty() { + return Err(syn::Error::new(input.span(), "Expected extension value")); + } + + Ok(Extension { key, value }) + } +} diff --git a/schemars_derive/src/metadata.rs b/schemars_derive/src/metadata.rs index 6de68343..6a3808c3 100644 --- a/schemars_derive/src/metadata.rs +++ b/schemars_derive/src/metadata.rs @@ -9,6 +9,7 @@ pub struct SchemaMetadata<'a> { pub write_only: bool, pub examples: &'a [syn::Path], pub default: Option, + pub extensions: &'a [(String, TokenStream)], } impl<'a> SchemaMetadata<'a> { @@ -74,6 +75,12 @@ impl<'a> SchemaMetadata<'a> { }); } + for (k, v) in self.extensions { + setters.push(quote! { + obj.insert(#k.to_owned(), schemars::_serde_json::json!(#v)); + }); + } + setters } } diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs index 48da5eff..1f5df99c 100644 --- a/schemars_derive/src/schema_exprs.rs +++ b/schemars_derive/src/schema_exprs.rs @@ -232,13 +232,13 @@ fn expr_for_internal_tagged_enum<'a>( let name = variant.name(); let mut schema_expr = expr_for_internal_tagged_enum_variant(variant, deny_unknown_fields); - variant.attrs.as_metadata().apply_to_schema(&mut schema_expr); - - quote!({ + schema_expr = quote!({ let mut schema = #schema_expr; schemars::_private::apply_internal_enum_variant_tag(&mut schema, #tag_name, #name, #deny_unknown_fields); schema - }) + }); + variant.attrs.as_metadata().apply_to_schema(&mut schema_expr); + schema_expr }) .collect();