Skip to content

Commit 95023c2

Browse files
authored
Avoid inlining schemas in internally-tagged enum newtype variants (#355)
Schemas are still inlined in some cases, e.g. when the inner type has `deny_unknown_fields`, because then `$ref` would cause an unsatisfiable schema due to the variant tag not being allowed
1 parent e516881 commit 95023c2

9 files changed

+274
-58
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
- MSRV is now 1.70
1212
- [The `example` attribute](https://graham.cool/schemars/deriving/attributes/#example) value is now an arbitrary expression, rather than a string literal identifying a function to call. To avoid silent behaviour changes, the expression must not be a string literal where the value can be parsed as a function path - e.g. `#[schemars(example = "foo")]` is now a compile error, but `#[schemars(example = foo())]` is allowed (as is `#[schemars(example = &"foo")]` if you want the the literal string value `"foo"` to be the example).
13+
- For newtype variants of internally-tagged enums, prefer referencing the inner type's schema via `$ref` instead of always inlining the schema (https://github.com/GREsau/schemars/pull/355)
1314

1415
### Fixed
1516

schemars/src/_private/mod.rs

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::_alloc_prelude::*;
2-
use crate::transform::transform_immediate_subschemas;
2+
use crate::transform::{transform_immediate_subschemas, Transform};
33
use crate::{JsonSchema, Schema, SchemaGenerator};
44
use serde::Serialize;
55
use serde_json::{json, map::Entry, Map, Value};
@@ -12,6 +12,39 @@ pub extern crate serde_json;
1212

1313
pub use rustdoc::get_title_and_description;
1414

15+
pub fn json_schema_for_internally_tagged_enum_newtype_variant<T: ?Sized + JsonSchema>(
16+
generator: &mut SchemaGenerator,
17+
) -> Schema {
18+
let mut schema = T::json_schema(generator);
19+
20+
// Inline the newtype's inner schema if any of:
21+
// - The type specifies that its schema should always be inlined
22+
// - The generator settings specify that all schemas should be inlined
23+
// - The inner type is a unit struct, which would cause an unsatisfiable schema due to mismatched `type`.
24+
// In this case, we replace its type with "object" in `apply_internal_enum_variant_tag`
25+
// - The inner schema specified `"additionalProperties": false` or `"unevaluatedProperties": false`,
26+
// since that would disallow the variant tag. If additional/unevaluatedProperties is in the top-level
27+
// schema, then we can leave it there, because it will "see" the variant tag property. But if it is
28+
// nested e.g. in an `allOf`, then it must be removed, which is why we run `AllowUnknownProperties`
29+
// but only on immediate subschemas.
30+
31+
let mut transform = AllowUnknownProperties::default();
32+
transform_immediate_subschemas(&mut transform, &mut schema);
33+
34+
if T::always_inline_schema()
35+
|| generator.settings().inline_subschemas
36+
|| schema.get("type").and_then(Value::as_str) == Some("null")
37+
|| schema.get("additionalProperties").and_then(Value::as_bool) == Some(false)
38+
|| schema.get("unevaluatedProperties").and_then(Value::as_bool) == Some(false)
39+
|| transform.did_modify
40+
{
41+
return schema;
42+
}
43+
44+
// ...otherwise, we can freely refer to the schema via a `$ref`
45+
generator.subschema_for::<T>()
46+
}
47+
1548
// Helper for generating schemas for flattened `Option` fields.
1649
pub fn json_schema_for_flatten<T: ?Sized + JsonSchema>(
1750
generator: &mut SchemaGenerator,
@@ -25,20 +58,29 @@ pub fn json_schema_for_flatten<T: ?Sized + JsonSchema>(
2558

2659
// Always allow aditional/unevaluated properties, because the outer struct determines
2760
// whether it denies unknown fields.
28-
allow_unknown_properties(&mut schema);
61+
AllowUnknownProperties::default().transform(&mut schema);
2962

3063
schema
3164
}
3265

33-
fn allow_unknown_properties(schema: &mut Schema) {
34-
if schema.get("additionalProperties").and_then(Value::as_bool) == Some(false) {
35-
schema.remove("additionalProperties");
36-
}
37-
if schema.get("unevaluatedProperties").and_then(Value::as_bool) == Some(false) {
38-
schema.remove("unevaluatedProperties");
39-
}
66+
#[derive(Default)]
67+
struct AllowUnknownProperties {
68+
did_modify: bool,
69+
}
4070

41-
transform_immediate_subschemas(&mut allow_unknown_properties, schema);
71+
impl Transform for AllowUnknownProperties {
72+
fn transform(&mut self, schema: &mut Schema) {
73+
if schema.get("additionalProperties").and_then(Value::as_bool) == Some(false) {
74+
schema.remove("additionalProperties");
75+
self.did_modify = true;
76+
}
77+
if schema.get("unevaluatedProperties").and_then(Value::as_bool) == Some(false) {
78+
schema.remove("unevaluatedProperties");
79+
self.did_modify = true;
80+
}
81+
82+
transform_immediate_subschemas(self, schema);
83+
}
4284
}
4385

4486
/// Hack to simulate specialization:

schemars/tests/integration/enums_deny_unknown_fields.rs

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ macro_rules! fn_values {
1515
foo: 123,
1616
bar: true,
1717
}),
18+
Self::StructDenyUnknownFieldsNewType(StructDenyUnknownFields {
19+
baz: 123,
20+
foobar: true,
21+
}),
1822
Self::Struct {
1923
foo: 123,
2024
bar: true,
@@ -30,12 +34,20 @@ struct Struct {
3034
bar: bool,
3135
}
3236

37+
#[derive(JsonSchema, Deserialize, Serialize, Default)]
38+
#[serde(deny_unknown_fields)]
39+
struct StructDenyUnknownFields {
40+
baz: i32,
41+
foobar: bool,
42+
}
43+
3344
#[derive(JsonSchema, Deserialize, Serialize)]
3445
#[serde(deny_unknown_fields)]
3546
enum External {
3647
Unit,
3748
StringMap(BTreeMap<String, String>),
3849
StructNewType(Struct),
50+
StructDenyUnknownFieldsNewType(StructDenyUnknownFields),
3951
Struct { foo: i32, bar: bool },
4052
}
4153

@@ -49,6 +61,7 @@ enum Internal {
4961
Unit,
5062
StringMap(BTreeMap<String, String>),
5163
StructNewType(Struct),
64+
StructDenyUnknownFieldsNewType(StructDenyUnknownFields),
5265
Struct { foo: i32, bar: bool },
5366
}
5467

@@ -62,6 +75,7 @@ enum Adjacent {
6275
Unit,
6376
StringMap(BTreeMap<String, String>),
6477
StructNewType(Struct),
78+
StructDenyUnknownFieldsNewType(StructDenyUnknownFields),
6579
Struct { foo: i32, bar: bool },
6680
}
6781

@@ -75,6 +89,7 @@ enum Untagged {
7589
Unit,
7690
StringMap(BTreeMap<String, String>),
7791
StructNewType(Struct),
92+
StructDenyUnknownFieldsNewType(StructDenyUnknownFields),
7893
Struct { foo: i32, bar: bool },
7994
}
8095

@@ -88,13 +103,22 @@ fn externally_tagged_enum() {
88103
.assert_snapshot()
89104
.assert_allows_ser_roundtrip(External::values())
90105
.assert_matches_de_roundtrip(arbitrary_values())
91-
.assert_rejects_de([json!({
92-
"Struct": {
93-
"foo": 123,
94-
"bar": true,
95-
"extra": null
96-
}
97-
})])
106+
.assert_rejects_de([
107+
json!({
108+
"Struct": {
109+
"foo": 123,
110+
"bar": true,
111+
"extra": null
112+
}
113+
}),
114+
json!({
115+
"StructDenyUnknownFieldsNewType": {
116+
"baz": 123,
117+
"foobar": true,
118+
"extra": null
119+
}
120+
}),
121+
])
98122
.assert_allows_de_roundtrip([json!({
99123
"StructNewType": {
100124
"foo": 123,
@@ -110,12 +134,20 @@ fn internally_tagged_enum() {
110134
.assert_snapshot()
111135
.assert_allows_ser_roundtrip(Internal::values())
112136
.assert_matches_de_roundtrip(arbitrary_values())
113-
.assert_rejects_de([json!({
114-
"tag": "Struct",
115-
"foo": 123,
116-
"bar": true,
117-
"extra": null
118-
})])
137+
.assert_rejects_de([
138+
json!({
139+
"tag": "Struct",
140+
"foo": 123,
141+
"bar": true,
142+
"extra": null
143+
}),
144+
json!({
145+
"tag": "StructDenyUnknownFieldsNewType",
146+
"baz": 123,
147+
"foobar": true,
148+
"extra": null
149+
}),
150+
])
119151
.assert_allows_de_roundtrip([json!({
120152
"tag": "StructNewType",
121153
"foo": 123,
@@ -130,14 +162,24 @@ fn adjacently_tagged_enum() {
130162
.assert_snapshot()
131163
.assert_allows_ser_roundtrip(Adjacent::values())
132164
.assert_matches_de_roundtrip(arbitrary_values())
133-
.assert_rejects_de([json!({
134-
"tag": "Struct",
135-
"content": {
136-
"foo": 123,
137-
"bar": true,
138-
"extra": null
139-
}
140-
})])
165+
.assert_rejects_de([
166+
json!({
167+
"tag": "Struct",
168+
"content": {
169+
"foo": 123,
170+
"bar": true,
171+
"extra": null
172+
}
173+
}),
174+
json!({
175+
"tag": "StructDenyUnknownFieldsNewType",
176+
"content": {
177+
"baz": 123,
178+
"foobar": true,
179+
"extra": null
180+
}
181+
}),
182+
])
141183
.assert_allows_de_roundtrip([json!({
142184
"tag": "StructNewType",
143185
"content": {
@@ -154,6 +196,11 @@ fn untagged_enum() {
154196
.assert_snapshot()
155197
.assert_allows_ser_roundtrip(Untagged::values())
156198
.assert_matches_de_roundtrip(arbitrary_values())
199+
.assert_rejects_de([json!({
200+
"baz": 123,
201+
"foobar": true,
202+
"extra": null
203+
})])
157204
.assert_allows_de_roundtrip([json!({
158205
"foo": 123,
159206
"bar": true,

schemars/tests/integration/snapshots/schemars/tests/integration/enums.rs~internally_tagged_enum.json

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,14 @@
4444
{
4545
"type": "object",
4646
"properties": {
47-
"foo": {
48-
"type": "integer",
49-
"format": "int32"
50-
},
51-
"bar": {
52-
"type": "boolean"
53-
},
5447
"tag": {
5548
"type": "string",
5649
"const": "StructNewType"
5750
}
5851
},
52+
"$ref": "#/$defs/Struct",
5953
"required": [
60-
"tag",
61-
"foo",
62-
"bar"
54+
"tag"
6355
]
6456
},
6557
{
@@ -95,5 +87,23 @@
9587
"tag"
9688
]
9789
}
98-
]
90+
],
91+
"$defs": {
92+
"Struct": {
93+
"type": "object",
94+
"properties": {
95+
"foo": {
96+
"type": "integer",
97+
"format": "int32"
98+
},
99+
"bar": {
100+
"type": "boolean"
101+
}
102+
},
103+
"required": [
104+
"foo",
105+
"bar"
106+
]
107+
}
108+
}
99109
}

schemars/tests/integration/snapshots/schemars/tests/integration/enums_deny_unknown_fields.rs~adjacently_tagged_enum.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@
5252
],
5353
"additionalProperties": false
5454
},
55+
{
56+
"type": "object",
57+
"properties": {
58+
"tag": {
59+
"type": "string",
60+
"const": "StructDenyUnknownFieldsNewType"
61+
},
62+
"content": {
63+
"$ref": "#/$defs/StructDenyUnknownFields"
64+
}
65+
},
66+
"required": [
67+
"tag",
68+
"content"
69+
],
70+
"additionalProperties": false
71+
},
5572
{
5673
"type": "object",
5774
"properties": {
@@ -100,6 +117,23 @@
100117
"foo",
101118
"bar"
102119
]
120+
},
121+
"StructDenyUnknownFields": {
122+
"type": "object",
123+
"properties": {
124+
"baz": {
125+
"type": "integer",
126+
"format": "int32"
127+
},
128+
"foobar": {
129+
"type": "boolean"
130+
}
131+
},
132+
"additionalProperties": false,
133+
"required": [
134+
"baz",
135+
"foobar"
136+
]
103137
}
104138
}
105139
}

schemars/tests/integration/snapshots/schemars/tests/integration/enums_deny_unknown_fields.rs~externally_tagged_enum.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@
3535
],
3636
"additionalProperties": false
3737
},
38+
{
39+
"type": "object",
40+
"properties": {
41+
"StructDenyUnknownFieldsNewType": {
42+
"$ref": "#/$defs/StructDenyUnknownFields"
43+
}
44+
},
45+
"required": [
46+
"StructDenyUnknownFieldsNewType"
47+
],
48+
"additionalProperties": false
49+
},
3850
{
3951
"type": "object",
4052
"properties": {
@@ -78,6 +90,23 @@
7890
"foo",
7991
"bar"
8092
]
93+
},
94+
"StructDenyUnknownFields": {
95+
"type": "object",
96+
"properties": {
97+
"baz": {
98+
"type": "integer",
99+
"format": "int32"
100+
},
101+
"foobar": {
102+
"type": "boolean"
103+
}
104+
},
105+
"additionalProperties": false,
106+
"required": [
107+
"baz",
108+
"foobar"
109+
]
81110
}
82111
}
83112
}

0 commit comments

Comments
 (0)