Skip to content

Commit 308fb07

Browse files
authored
Merge pull request #7 from NickSale/BooleanSchemaCombinations
Added support for boolean combination keywords
2 parents 802efdd + 23ca35b commit 308fb07

File tree

5 files changed

+298
-3
lines changed

5 files changed

+298
-3
lines changed

Project.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "JSONSchemaGenerator"
22
uuid = "b10a6b5e-eaef-4b72-b159-8e5005e98e8e"
33
authors = ["matthijscox <matthijs.cox@gmail.com> and contributors"]
4-
version = "0.2.0"
4+
version = "0.3.0"
55

66
[deps]
77
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
@@ -22,4 +22,4 @@ JSONSchema = "7d188eb4-7ad8-530c-ae41-71a32a6d4692"
2222
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
2323

2424
[targets]
25-
test = ["Test", "JSON", "JSON3", "JSONSchema"]
25+
test = ["Test", "JSON", "JSON3", "JSONSchema"]

README.md

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,111 @@ obj = NestedFieldSchema(
150150
json_dict = JSON3.write(obj) |> JSON.parse
151151

152152
JSONSchema.validate(JSONSchema.Schema(schema_dict), json_dict) === nothing
153-
```
153+
```
154+
155+
## Boolean Combination Keywords
156+
157+
JSONSchemaGenerator.jl provides a function `combinationkeywords(::Type)` which can be used to associate a struct with an array of special types `AllOf{T,S}`, `AnyOf{T,S}`, `OneOf{T,S}` and `Not{T}` that allow the corresponding JSON keyword to be generated in a schema (see [Boolean JSON Schema combination](https://json-schema.org/understanding-json-schema/reference/combining)). Note that more than two schemas can be combined by chaining: e.g. `AllOf{A, AllOf{B, C}}`.
158+
159+
In the following example we combine some schemas that check if fields are equal to specific values (using `Val` and `Tuple` types, noting that these do not serialize well and should only be used for validation purposes like this):
160+
```julia
161+
import JSONSchemaGenerator as JSG
162+
using JSONSchema, JSON3
163+
164+
struct ConstantInt1Schema
165+
int::Val{1}
166+
end
167+
168+
struct EnumInt2Or3Schema
169+
int::Tuple{2,3}
170+
end
171+
172+
struct ConstantBoolTrueSchema
173+
bool::Val{true}
174+
end
175+
176+
struct BooleanCombinationSchema
177+
int::Int
178+
bool::Bool
179+
end
180+
StructTypes.StructType(::Type{BooleanCombinationSchema}) = StructTypes.Struct()
181+
JSG.combinationkeywords(::Type{BooleanCombinationSchema}) = [
182+
JSG.AllOf{
183+
JSG.AnyOf{ConstantInt1Schema, EnumInt2Or3Schema},
184+
JSG.Not{ConstantBoolTrueSchema}
185+
}
186+
]
187+
188+
schema_dict = JSG.schema(BooleanCombinationSchema)
189+
190+
good_json = JSON3.write(BooleanCombinationSchema(2, false))
191+
bad_json = JSON3.write(BooleanCombinationSchema(5, true))
192+
193+
JSONSchema.validate(JSONSchema.Schema(schema_dict), good_json) === nothing
194+
JSONSchema.validate(JSONSchema.Schema(schema_dict), bad_json) !== nothing
195+
```
196+
197+
The printed schema looks as follows:
198+
```julia
199+
julia> JSON.print(schema_dict, 2)
200+
JSON.print(schema_dict, 2)
201+
{
202+
"type": "object",
203+
"properties": {
204+
"int": {
205+
"type": "integer"
206+
},
207+
"bool": {
208+
"type": "boolean"
209+
}
210+
},
211+
"required": [
212+
"int",
213+
"bool"
214+
],
215+
"allOf": [
216+
{
217+
"anyOf": [
218+
{
219+
"type": "object",
220+
"properties": {
221+
"int": {
222+
"const": 1
223+
}
224+
},
225+
"required": [
226+
"int"
227+
]
228+
},
229+
{
230+
"type": "object",
231+
"properties": {
232+
"int": {
233+
"enum": [
234+
2,
235+
3
236+
]
237+
}
238+
},
239+
"required": [
240+
"int"
241+
]
242+
}
243+
]
244+
},
245+
{
246+
"not": {
247+
"type": "object",
248+
"properties": {
249+
"bool": {
250+
"const": true
251+
}
252+
},
253+
"required": [
254+
"bool"
255+
]
256+
}
257+
}
258+
]
259+
}
260+
```

src/CombinationKeywordTypes.jl

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""
2+
AllOf{T}
3+
4+
Type introduced for generating the JSON schema `allOf` keyword.
5+
6+
Can be added to `combinationkeywords` for some Struct to add the keyword to the generated schema of that struct,
7+
or chained with other keywords.
8+
9+
# Example
10+
```julia
11+
combinationkeywords(MyStructType) = (AllOf{StructTypeA, StructTypeB}, Not{AllOf{StructTypeC, StructTypeD}})
12+
```
13+
"""
14+
struct AllOf{T,S} end
15+
16+
"""
17+
AnyOf{T}
18+
19+
Type introduced for generating the JSON schema `anyOf` keyword.
20+
21+
Can be added to `combinationkeywords` for some Struct to add the keyword to the generated schema of that struct,
22+
or chained with other keywords.
23+
24+
# Example
25+
```julia
26+
combinationkeywords(MyStructType) = (AnyOf{StructTypeA, StructTypeB}, Not{AnyOf{StructTypeC, StructTypeD}})
27+
```
28+
"""
29+
struct AnyOf{T,S} end
30+
31+
"""
32+
OneOf{T}
33+
34+
Type introduced for generating the JSON schema `oneOf` keyword.
35+
36+
Can be added to `combinationkeywords` for some Struct to add the keyword to the generated schema of that struct,
37+
or chained with other keywords.
38+
39+
# Example
40+
```julia
41+
combinationkeywords(MyStructType) = (OneOf{StructTypeA, StructTypeB}, Not{OneOf{StructTypeC, StructTypeD}})
42+
```
43+
"""
44+
struct OneOf{T,S} end
45+
46+
"""
47+
Not{T}
48+
49+
Type introduced for generating the JSON schema `not` keyword.
50+
51+
Can be added to `combinationkeywords` for some Struct to add the keyword to the generated schema of that struct,
52+
or chained with other keywords.
53+
54+
# Example
55+
```julia
56+
combinationkeywords(MyStructType) = (Not{StructA}, AnyOf{Not{StructB}, StructC})
57+
```
58+
"""
59+
struct Not{T} end
60+
61+
"""
62+
combinationkeywords(T::Type)::Tuple
63+
64+
Specifies which JSON boolean combination keywords will be included in the generated schema for a type.
65+
66+
Elements should be one of the following types: `AllOf{T,S}`, `AnyOf{T,S}`, `OneOf{T,S}`, `Not{T}`.
67+
68+
# Example
69+
```julia
70+
combinationkeywords(MyStructType) = (AllOf{SchemaA, SchemaB}, Not{SchemaC})
71+
```
72+
"""
73+
function combinationkeywords end
74+
75+
combinationkeywords(::Type) = ()

src/JSONSchemaGenerator.jl

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ if !isdefined(Base, :fieldtypes) && VERSION < v"1.1"
88
fieldtypes(T::Type) = (Any[fieldtype(T, i) for i in 1:fieldcount(T)]...,)
99
end
1010

11+
include("CombinationKeywordTypes.jl")
12+
1113
# by default we assume the type is a custom type, which should be a JSON object
1214
_json_type(::Type{<:Any}) = :object
1315
#_json_type(::Type{<:AbstractDict}) = :object
@@ -26,6 +28,12 @@ _json_type(::Type{Base.UUID}) = :string
2628
_json_type(::Type{T}) where {T <: Dates.TimeType} = :string
2729
_json_type(::Type{VersionNumber}) = :string
2830
_json_type(::Type{Base.Regex}) = :string
31+
_json_type(::Type{<:Val}) = :const
32+
_json_type(::Type{<:Tuple}) = :enum
33+
_json_type(::Type{<:AllOf}) = :keyword
34+
_json_type(::Type{<:AnyOf}) = :keyword
35+
_json_type(::Type{<:OneOf}) = :keyword
36+
_json_type(::Type{<:Not}) = :keyword
2937

3038
_is_nothing_union(::Type) = false
3139
_is_nothing_union(::Type{Nothing}) = false
@@ -130,6 +138,12 @@ function _generate_json_object(julia_type::Type, settings::SchemaSettings)
130138
if is_top_level && settings.use_references
131139
d["\$defs"] = _generate_json_reference_types(settings)
132140
end
141+
for combination_type in combinationkeywords(julia_type)
142+
_json_type(combination_type) == :keyword ? nothing : error("combinationkeywords($julia_type) should only contain valid keywords")
143+
keyword_dict = _generate_json_type_def(combination_type, settings)
144+
issubset(keys(keyword_dict), keys(d)) ? error("each keyword should only appear at most once in combinationkeywords($julia_type)") : nothing
145+
merge!(d, keyword_dict)
146+
end
133147
return d
134148
end
135149

@@ -154,12 +168,48 @@ function _generate_json_type_def(::Val{:array}, julia_type::Type{<:AbstractArray
154168
)
155169
end
156170

171+
function _generate_json_type_def(::Val{:const}, julia_type::Type{<:Val}, settings::SchemaSettings)
172+
return settings.dict_type{String, Any}(
173+
"const" => julia_type.parameters[1]
174+
)
175+
end
176+
177+
function _generate_json_type_def(::Val{:enum}, julia_type::Type{<:Tuple}, settings::SchemaSettings)
178+
return settings.dict_type{String, Any}(
179+
"enum" => [p isa Symbol ? String(p) : p for p in julia_type.parameters]
180+
)
181+
end
182+
157183
function _generate_json_type_def(::Val{:enum}, julia_type::Type, settings::SchemaSettings)
158184
return settings.dict_type{String, Any}(
159185
"enum" => string.(instances(julia_type))
160186
)
161187
end
162188

189+
function _generate_json_type_def(::Val{:keyword}, julia_type::Type{AllOf{T,S}}, settings::SchemaSettings) where {T,S}
190+
return settings.dict_type{String, Any}(
191+
"allOf" => [_generate_json_type_def(T, settings), _generate_json_type_def(S, settings)]
192+
)
193+
end
194+
195+
function _generate_json_type_def(::Val{:keyword}, julia_type::Type{AnyOf{T,S}}, settings::SchemaSettings) where {T,S}
196+
return settings.dict_type{String, Any}(
197+
"anyOf" => [_generate_json_type_def(T, settings), _generate_json_type_def(S, settings)]
198+
)
199+
end
200+
201+
function _generate_json_type_def(::Val{:keyword}, julia_type::Type{OneOf{T,S}}, settings::SchemaSettings) where {T,S}
202+
return settings.dict_type{String, Any}(
203+
"oneOf" => [_generate_json_type_def(T, settings), _generate_json_type_def(S, settings)]
204+
)
205+
end
206+
207+
function _generate_json_type_def(::Val{:keyword}, julia_type::Type{Not{T}}, settings::SchemaSettings) where {T}
208+
return settings.dict_type{String, Any}(
209+
"not" => _generate_json_type_def(T, settings)
210+
)
211+
end
212+
163213
function _generate_json_type_def(::Val, julia_type::Type, settings::SchemaSettings)
164214
return settings.dict_type{String, Any}(
165215
"type" => string(_json_type(julia_type))

test/runtests.jl

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ using StructTypes
99
module TestTypes
1010
using Dates
1111
using StructTypes
12+
import JSONSchemaGenerator
13+
const JSG = JSONSchemaGenerator
1214

1315
struct BasicSchema
1416
int::Int64
@@ -84,6 +86,42 @@ module TestTypes
8486
NestedSchema(),
8587
)
8688
end
89+
90+
struct ConstantInt1Schema
91+
int::Val{1}
92+
end
93+
struct EnumInt2Or3Schema
94+
int::Tuple{2,3}
95+
end
96+
struct ConstantBoolTrueSchema
97+
bool::Val{true}
98+
end
99+
struct BooleanCombinationSchema
100+
int::Int
101+
bool::Bool
102+
end
103+
JSG.combinationkeywords(::Type{BooleanCombinationSchema}) = [
104+
JSG.AllOf{
105+
JSG.AnyOf{ConstantInt1Schema, EnumInt2Or3Schema},
106+
JSG.Not{ConstantBoolTrueSchema}
107+
}
108+
]
109+
StructTypes.StructType(::Type{BooleanCombinationSchema}) = StructTypes.Struct()
110+
111+
struct BadBooleanCombinationSchema
112+
int::Int
113+
end
114+
StructTypes.StructType(::Type{BadBooleanCombinationSchema}) = StructTypes.Struct()
115+
JSG.combinationkeywords(::Type{BadBooleanCombinationSchema}) = [
116+
JSG.AllOf{ConstantInt1Schema, ConstantInt1Schema},
117+
JSG.AllOf{EnumInt2Or3Schema, EnumInt2Or3Schema}
118+
]
119+
120+
struct BadBooleanCombinationSchema2
121+
int::Int
122+
end
123+
StructTypes.StructType(::Type{BadBooleanCombinationSchema2}) = StructTypes.Struct()
124+
JSG.combinationkeywords(::Type{BadBooleanCombinationSchema2}) = [Int32]
87125
end
88126

89127
function test_json_schema_validation(obj::T) where T
@@ -269,3 +307,28 @@ end
269307
json_schema = JSONSchemaGenerator.schema(TestTypes.NestedSchema, use_references=true, dict_type=Dict)
270308
test_json_schema_validation(json_schema, TestTypes.NestedSchema())
271309
end
310+
311+
@testset "Boolean Combination of Schemas" begin
312+
@testset "Good weather" begin
313+
combo_schema = JSONSchemaGenerator.schema(TestTypes.BooleanCombinationSchema)
314+
constantint1_schema = JSONSchemaGenerator.schema(TestTypes.ConstantInt1Schema)
315+
enumint2or3_schema = JSONSchemaGenerator.schema(TestTypes.EnumInt2Or3Schema)
316+
constantbooltrue_schema = JSONSchemaGenerator.schema(TestTypes.ConstantBoolTrueSchema)
317+
318+
@test combo_schema["allOf"][1]["anyOf"][1] == constantint1_schema
319+
@test combo_schema["allOf"][1]["anyOf"][2] == enumint2or3_schema
320+
@test combo_schema["allOf"][2]["not"] == constantbooltrue_schema
321+
322+
test_json_schema_validation(TestTypes.BooleanCombinationSchema(1, false))
323+
test_json_schema_validation(TestTypes.BooleanCombinationSchema(2, false))
324+
test_json_schema_validation(TestTypes.BooleanCombinationSchema(3, false))
325+
end
326+
327+
@testset "Multiple uses of same keyword in one object" begin
328+
@test_throws Exception bad_combo_schema = JSONSchemaGenerator.schema(TestTypes.BadBooleanCombinationSchema)
329+
end
330+
331+
@testset "Use of incorrect type in combinationkeywords" begin
332+
@test_throws Exception bad_combo_schema2 = JSONSchemaGenerator.schema(TestTypes.BadBooleanCombinationSchema2)
333+
end
334+
end

0 commit comments

Comments
 (0)