Draft 7 of JSON Schema introduced a nice way to include some conditional constraints into your schemas. The most common way that people use these is to apply different constraints to various properties based on the value of another property. This is similar to the discriminator
keyword offered by Open API.
The idea is that if myObj.X == "foo"
then apply some constraints to myObj.Y
, such as minLength
or make it required.
In JSON Schema, the recommended pattern for this kind of conditional looks something like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
"type": "object",
"properties": {
"type": { "type": "string" },
"intProp": { "type": "integer" },
"stringProp": { "type": "string" }
},
"required": [ "type" ],
"allOf": [
{
"if": {
"properties": {
"type": { "const": "int" }
}
},
"then": {
"required": [ "intProp" ]
}
},
{
"if": {
"properties": {
"type": { "const": "string" }
}
},
"then": {
"required": [ "stringProp" ]
}
}
]
}
Here we can see that depending on the value of type
, we either want intProp
or stringProp
to also be required.
As of v3.3.0, JsonSchema.Net.Generation includes the ability to define conditional constraints like these and more.
Condition groups
The first step to defining conditional constraints is to set up some condition groups. Condition groups allow you to define one or more conditions into groups, and for each group, every condition must be met before the constraints for that group can apply. (See below to learn how to apply constraints conditionally.)
Condition groups can be defined with one of the [If*]
attributes, which are special attributes that are applied to the type itself.
[If]
The [If]
attribute creates a condition involving a discrete value. It takes three parameters:
propertyName
- This is the name of the property on the object. Ideally, you’ll want to use thenameof()
C# keyword for this to support compiler checking even if you’re using a different naming method.value
- This is the expected value for the property you named above. The condition will apply when the property equals this value. The value can be any compiler-constant value, but really should be JSON-compatible. So strings, numbers, and booleans are generally best. Enum values will work, too, but[IfEnum]
may be a better option if you’re using an enum property.group
- This is a key that identifies a group for this condition. It can be any compiler-constant value.
On its own, a single [If]
attribute will create an if
keyword containing a const
keyword and the value you specify. However, the attribute can also be repeated with different values under the same group in order to create an enum
keyword containing all of the values in that group. An example of the enum
generation can be found here. (Note that this is slightly different from the [IfEnum]
described below.)
Example
The example starts by defining a person which contains a couple properties that we want to constrain conditionally.
1
2
3
4
5
6
7
8
9
10
[If(nameof(AgeCategory), "child", "isChild")]
[If(nameof(AgeCategory), "adult", "isAdult")]
[If(nameof(AgeCategory), "senior", "isSenior")]
public class Person
{
public string Name { get; set; }
public string AgeCategory { get; set; }
public int Age { get; set; }
public bool CanVote { get; set; }
}
Eventually, we want to restrict Age
to a valid range given a specific AgeCategory
. For now, we need to set up different condition groups, one for each AgeCategory
value that we care about.
Here, we’ve defined
- the group
"isChild"
for whenAgeCategory == "child"
- the group
"isAdult"
for whenAgeCategory == "adult"
- the group
"isSenior"
for whenAgeCategory == "senior"
These groups are identified by strings, but they don’t have to be. We could just as well have used integers or any compile-time constant we wanted.
In JSON Schema, these translate to the if
keywords that you can see in the example at the top of the page.
We’ll define constraints that apply to these groups in the next section.
[IfEnum]
The [IfEnum]
attribute takes only one parameter: propertyName
from above, but it must be an enum property.
This attribute will generate a group for each of the values defined by the enum, and the key for that group is the enum value itself.
Example
If we know that these are all of the values that AgeCategory
can be, we might consider changing our model for AgeCategory
to an enum to enforce that. This also allows us to use the [IfEnum]
attribute and makes things a little cleaner.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public enum AgeCategory
{
Child,
Adult,
Senior
}
[IfEnum(nameof(AgeCategory))]
public class Person
{
public string Name { get; set; }
public AgeCategory AgeCategory { get; set; }
public int Age { get; set; }
public bool CanVote { get; set; }
}
Now this will create the following groups:
AgeCategory.Child
for whenAgeCategory == "child"
AgeCategory.Adult
for whenAgeCategory == "adult"
AgeCategory.Senior
for whenAgeCategory == "senior"
The group keys are auto-generated as the enum values.
[IfMin]
and [IfMax]
In addition to conditions that depend on discrete values, you can also create conditions that accept ranges of values. For these cases, the [IfMin]
and [IfMax]
are your friends.
These attributes, which take the same parameters as [If]
, will add different keywords depending on the type of the target property.
- For numeric types, they will add
minimum
andmaximum
. There is an optionalIsExclusive
property as well that will instead addexclusiveMinimum
andexclusiveMaximum
. - For strings, they will add
minLength
andmaxLength
. - For arrays and other non-dictionary enumerables, they will add
minItems
andmaxItems
. - For dictionaries and other objects, they will add
minProperties
andmaxProperties
.
The value for these attributes is a double
, however for any non-numeric types, if the value is less than zero, the attributes will not be added as the associated keywords are lengths and counts where negatives don’t make sense.
Examples
Let’s first take a look at creating a condition on a range of values for an integer property.
1
2
3
4
5
6
7
8
9
10
[IfMin(nameof(Value), 10, "group")]
[IfMax(nameof(Value), 20, "group", IsExclusive = true)]
public class NumberRangeConditions
{
[Required]
public int Value { get; set; }
[Required(ConditionGroup = "group")]
public string Required { get; set; }
}
In this case, we only want Required
to be required if Value
is 10 up to (but not including) 20. The generated schema is
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"type": "object",
"properties": {
"Value": { "type": "integer" },
"Required": { "type": "string" }
},
"required": [ "Value" ],
"if": {
"properties": {
"Value": {
"minimum": 10,
"exclusiveMaximum": 20
}
},
"required": [ "Value" ]
},
"then": {
"required": [ "Required" ]
}
}
There are two other things to take note of in this example:
- Specifying multiple conditions with the same group ID combines them into a single
if
keyword. - If there’s only one condition group, the condition and constraints are expressed without using an
allOf
.
Now let’s see what happens when we change Value
to a string.
1
2
3
4
5
6
7
8
9
10
[IfMin(nameof(Value), 10, "group")]
[IfMax(nameof(Value), 20, "group")]
public class NumberRangeConditions
{
[Required]
public string Value { get; set; }
[Required(ConditionGroup = "group")]
public string Required { get; set; }
}
This generates
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"type": "object",
"properties": {
"Value": { "type": "string" },
"Required": { "type": "string" }
},
"required": [ "Value" ],
"if": {
"properties": {
"Value": {
"minLength": 10,
"maxLength": 20
}
},
"required": [ "Value" ]
},
"then": {
"required": [ "Required" ]
}
}
You can see the minimum
and exclusiveMaximum
keywords now render as minLength
and maxLength
.
Conditional constraints
Now that we have some groups defined, we can use them to define constraints that should apply when each group is active.
Most of the constraint attributes now expose a ConditionGroup
property that can be used to set the group for that constraint. If the constraint group is not specified (or is explicitly set to null), the attribute will apply globally instead of inside a conditional constraint set.
Going back to the Person
example, we wanted to limit the value range for the Age
property. To do that, we’ll use [Minimum]
and [Maximum]
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[If(nameof(AgeCategory), "child", "isChild")]
[If(nameof(AgeCategory), "adult", "isAdult")]
[If(nameof(AgeCategory), "senior", "isSenior")]
public class SplitAgeRanges
{
[Required]
public string Name { get; set; }
[Required]
public string AgeCategory { get; set; }
[Required]
[Minimum(0, ConditionGroup = "isChild")]
[Maximum(17, ConditionGroup = "isChild")]
[Minimum(18, ConditionGroup = "isAdult")]
[Maximum(64, ConditionGroup = "isAdult")]
[Minimum(65, ConditionGroup = "isSenior")]
public int Age { get; set; }
[Required]
[Const(false, ConditionGroup = "isChild")]
[Const(true, ConditionGroup = "isAdult")]
[Const(true, ConditionGroup = "isSenior")]
public bool CanVote { get; set; }
}
The above shows the first case, where we’ve used a string for
AgeCategory
. If we wanted to use the enum approach, theConditionGroup
would need to be the associated enum value:
1 [Minimum(0, ConditionGroup = AgeCategory.Child)]
The above sets multiple minimums and maximums that each apply for different groups.
In JSON Schema, these translate to the then
keywords that you can see in the example at the top of the page. The JSON Schema that is generated from this example is below:
Expand for example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
{
"type": "object",
"properties": {
"Name": { "type": "string" },
"AgeCategory": { "type": "string" },
"Age": { "type": "integer" },
"CanVote": { "type": "boolean" }
},
"required": [ "Name", "AgeCategory", "Age", "CanVote" ],
"allOf": [
{
"if": {
"properties": {
"AgeCategory": { "const": "child" }
},
"required": [ "AgeCategory" ]
},
"then": {
"properties": {
"Age": {
"minimum": 0,
"maximum": 17
},
"CanVote": { "const": false }
}
}
},
{
"if": {
"properties": {
"AgeCategory": { "const": "adult" }
},
"required": [ "AgeCategory" ]
},
"then": {
"properties": {
"Age": {
"minimum": 18,
"maximum": 64
},
"CanVote": { "const": true }
}
}
},
{
"if": {
"properties": {
"AgeCategory": { "const": "senior" }
},
"required": [ "AgeCategory" ]
},
"then": {
"properties": {
"Age": { "minimum": 65 },
"CanVote": { "const": true }
}
}
}
]
}
Strict generation
Sometimes it’s not enough to say that you want to have certain constraints on a property under some circumstances. Sometimes you want to forbid that property from existing at all unless your condition is true.
For these cases, the SchemaGeneratorConfiguration.StrictConditionals
option has been added. Let’s continue with the Person
example and replace CanVote
with a new string property called DriversLicenseNumber
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[If(nameof(AgeCategory), "child", "isChild")]
[If(nameof(AgeCategory), "adult", "isAdult")]
[If(nameof(AgeCategory), "senior", "isSenior")]
public class SplitAgeRanges
{
[Required]
public string Name { get; set; }
[Required]
public string AgeCategory { get; set; }
[Required]
[Minimum(0, ConditionGroup = "isChild")]
[Maximum(17, ConditionGroup = "isChild")]
[Minimum(18, ConditionGroup = "isAdult")]
[Maximum(64, ConditionGroup = "isAdult")]
[Minimum(65, ConditionGroup = "isSenior")]
public int Age { get; set; }
[Required(ConditionGroup = "isAdult")]
[Required(ConditionGroup = "isSenior")]
public bool DriversLicenseNumber { get; set; }
}
This generates the following schema
Expand for example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
{
"type": "object",
"properties": {
"Name": { "type": "string" },
"AgeCategory": { "type": "string" }
},
"required": [ "Name", "AgeCategory", "Age" ],
"allOf": [
{
"if": {
"properties": {
"AgeCategory": { "const": "child" }
},
"required": [ "AgeCategory" ]
},
"then": {
"properties": {
"Age": {
"minimum": 0,
"maximum": 17
}
}
}
},
{
"if": {
"properties": {
"AgeCategory": { "const": "adult" }
},
"required": [ "AgeCategory" ]
},
"then": {
"properties": {
"Age": {
"minimum": 18,
"maximum": 64
},
"DriversLicenseNumber": { "type": "string" }
},
"required": [ "DriversLicenseNumber" ]
}
},
{
"if": {
"properties": {
"AgeCategory": { "const": "senior" }
},
"required": [ "AgeCategory" ]
},
"then": {
"properties": {
"Age": { "minimum": 65 },
"DriversLicenseNumber": { "type": "string" }
},
"required": [ "DriversLicenseNumber" ]
}
}
],
"unevaluatedProperties": false
}
Notable differences:
Age
is no longer listed in the top-levelproperties
, but it’s still listed inrequired
. Instead it is listed in each of thethen
subschemas.DriversLicenseNumber
also is not listed in the top-levelproperties
. It, too, is listed in each of thethen
subschemas.unevaluatedProperties : false
has been added at the top level.
The effect of these changes means that DriversLicenseNumber
is only a valid property if AgeCategory
is adult
or senior
. If AgeCategory
is child
, then the mere presence of DriversLicenseNumber
is invalid.