Skip to content

Commit fec7851

Browse files
author
Oleg
committed
Add dependencies assertion
1 parent afd1385 commit fec7851

File tree

3 files changed

+355
-0
lines changed

3 files changed

+355
-0
lines changed

src/commonMain/kotlin/smirnov/oleg/json/schema/internal/SchemaLoader.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import smirnov.oleg.json.schema.internal.factories.number.ExclusiveMinimumAssert
2121
import smirnov.oleg.json.schema.internal.factories.number.MaximumAssertionFactory
2222
import smirnov.oleg.json.schema.internal.factories.number.MinimumAssertionFactory
2323
import smirnov.oleg.json.schema.internal.factories.number.MultipleOfAssertionFactory
24+
import smirnov.oleg.json.schema.internal.factories.`object`.DependenciesAssertionFactory
2425
import smirnov.oleg.json.schema.internal.factories.`object`.MaxPropertiesAssertionFactory
2526
import smirnov.oleg.json.schema.internal.factories.`object`.MinPropertiesAssertionFactory
2627
import smirnov.oleg.json.schema.internal.factories.`object`.PropertiesAssertionFactory
@@ -52,6 +53,7 @@ private val factories: List<AssertionFactory> = listOf(
5253
RequiredAssertionFactory,
5354
PropertiesAssertionFactory,
5455
PropertyNamesAssertionFactory,
56+
DependenciesAssertionFactory,
5557
)
5658

5759
class SchemaLoader {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package smirnov.oleg.json.schema.internal.factories.`object`
2+
3+
import kotlinx.serialization.json.JsonArray
4+
import kotlinx.serialization.json.JsonElement
5+
import kotlinx.serialization.json.JsonObject
6+
import kotlinx.serialization.json.JsonPrimitive
7+
import smirnov.oleg.json.pointer.JsonPointer
8+
import smirnov.oleg.json.schema.ErrorCollector
9+
import smirnov.oleg.json.schema.ValidationError
10+
import smirnov.oleg.json.schema.internal.AssertionContext
11+
import smirnov.oleg.json.schema.internal.JsonSchemaAssertion
12+
import smirnov.oleg.json.schema.internal.LoadingContext
13+
import smirnov.oleg.json.schema.internal.TrueSchemaAssertion
14+
import smirnov.oleg.json.schema.internal.factories.AbstractAssertionFactory
15+
16+
@Suppress("unused")
17+
internal object DependenciesAssertionFactory : AbstractAssertionFactory("dependencies") {
18+
override fun createFromProperty(element: JsonElement, context: LoadingContext): JsonSchemaAssertion {
19+
require(element is JsonObject) { "$property must be an object" }
20+
if (element.isEmpty()) {
21+
return TrueSchemaAssertion
22+
}
23+
val assertions = loadAssertions(element, context)
24+
return DependenciesAssertion(assertions)
25+
}
26+
27+
private fun loadAssertions(jsonObject: JsonObject, context: LoadingContext): Map<String, JsonSchemaAssertion> {
28+
return jsonObject.mapValues { (prop, element) ->
29+
val propContext = context.at(prop)
30+
when {
31+
context.isJsonSchema(element) -> propContext.schemaFrom(element)
32+
element is JsonArray -> createFromArray(prop, element, propContext)
33+
else -> throw IllegalArgumentException("$prop dependency must be either array of strings or valid JSON schema")
34+
}
35+
}
36+
}
37+
38+
private fun createFromArray(property: String, array: JsonArray, context: LoadingContext): JsonSchemaAssertion {
39+
require(array.all { it is JsonPrimitive && it.isString }) { "$property must contain only strings" }
40+
val uniqueRequiredProperties = array.mapTo(linkedSetOf()) { (it as JsonPrimitive).content }
41+
require(uniqueRequiredProperties.size == array.size) { "$property must consist of unique elements" }
42+
return if (uniqueRequiredProperties.isEmpty()) {
43+
TrueSchemaAssertion
44+
} else {
45+
ConditionalRequiredPropertiesAssertion(context.schemaPath, property, uniqueRequiredProperties)
46+
}
47+
}
48+
}
49+
50+
private class DependenciesAssertion(
51+
private val dependenciesAssertions: Map<String, JsonSchemaAssertion>,
52+
) : JsonSchemaAssertion {
53+
override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
54+
if (element !is JsonObject) {
55+
return true
56+
}
57+
var valid = true
58+
for ((dependency, assertion) in dependenciesAssertions) {
59+
if (dependency !in element) {
60+
continue
61+
}
62+
val res = assertion.validate(element, context, errorCollector)
63+
valid = valid and res
64+
}
65+
return valid
66+
}
67+
}
68+
69+
private class ConditionalRequiredPropertiesAssertion(
70+
private val path: JsonPointer,
71+
private val property: String,
72+
private val dependencies: Set<String>,
73+
) : JsonSchemaAssertion {
74+
override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
75+
if (element !is JsonObject) {
76+
return true
77+
}
78+
val missingProperties = dependencies.asSequence()
79+
.filter { !element.containsKey(it) }
80+
.toSet()
81+
if (missingProperties.isEmpty()) {
82+
return true
83+
}
84+
errorCollector.onError(
85+
ValidationError(
86+
schemaPath = path,
87+
objectPath = context.objectPath,
88+
message = "has '$property' property but missing required dependencies: $missingProperties",
89+
)
90+
)
91+
return false
92+
}
93+
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package smirnov.oleg.json.schema.assertions.`object`
2+
3+
import io.kotest.assertions.asClue
4+
import io.kotest.assertions.throwables.shouldThrow
5+
import io.kotest.core.spec.style.FunSpec
6+
import io.kotest.matchers.collections.shouldContainExactly
7+
import io.kotest.matchers.collections.shouldHaveSize
8+
import io.kotest.matchers.shouldBe
9+
import kotlinx.serialization.json.JsonPrimitive
10+
import kotlinx.serialization.json.buildJsonObject
11+
import smirnov.oleg.json.pointer.JsonPointer
12+
import smirnov.oleg.json.schema.JsonSchema
13+
import smirnov.oleg.json.schema.KEY
14+
import smirnov.oleg.json.schema.ValidationError
15+
16+
@Suppress("unused")
17+
class JsonSchemaDependenciesValidationTest : FunSpec() {
18+
init {
19+
JsonSchema.fromDescription(
20+
"""
21+
{
22+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
23+
"dependencies": {
24+
"trigger": {
25+
"properties": {
26+
"depend": {
27+
"type": "number"
28+
}
29+
}
30+
}
31+
}
32+
}
33+
""".trimIndent()
34+
).also { schema ->
35+
test("object without trigger JSON schema property passes validation") {
36+
val jsonObject = buildJsonObject {
37+
put("depend", JsonPrimitive("test"))
38+
}
39+
40+
val errors = mutableListOf<ValidationError>()
41+
val valid = schema.validate(jsonObject, errors::add)
42+
43+
jsonObject.asClue {
44+
valid shouldBe true
45+
errors shouldHaveSize 0
46+
}
47+
}
48+
49+
test("object with trigger JSON schema property passes validation") {
50+
val jsonObject = buildJsonObject {
51+
put("trigger", JsonPrimitive(42))
52+
put("depend", JsonPrimitive(42.5))
53+
}
54+
55+
val errors = mutableListOf<ValidationError>()
56+
val valid = schema.validate(jsonObject, errors::add)
57+
58+
jsonObject.asClue {
59+
valid shouldBe true
60+
errors shouldHaveSize 0
61+
}
62+
}
63+
64+
test("object with trigger JSON schema property fails validation") {
65+
val jsonObject = buildJsonObject {
66+
put("trigger", JsonPrimitive(42))
67+
put("depend", JsonPrimitive("test"))
68+
}
69+
70+
val errors = mutableListOf<ValidationError>()
71+
val valid = schema.validate(jsonObject, errors::add)
72+
73+
jsonObject.asClue {
74+
valid shouldBe false
75+
errors.shouldContainExactly(
76+
ValidationError(
77+
schemaPath = JsonPointer("/dependencies/trigger/properties/depend/type"),
78+
objectPath = JsonPointer("/depend"),
79+
message = "element is not a number",
80+
)
81+
)
82+
}
83+
}
84+
}
85+
86+
JsonSchema.fromDescription(
87+
"""
88+
{
89+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
90+
"dependencies": {
91+
"trigger": ["depend"]
92+
}
93+
}
94+
""".trimIndent()
95+
).also { schema ->
96+
test("object without trigger array property passes validation") {
97+
val jsonObject = buildJsonObject {
98+
put("depend", JsonPrimitive("test"))
99+
}
100+
101+
val errors = mutableListOf<ValidationError>()
102+
val valid = schema.validate(jsonObject, errors::add)
103+
104+
jsonObject.asClue {
105+
valid shouldBe true
106+
errors shouldHaveSize 0
107+
}
108+
}
109+
110+
test("object with trigger array property passes validation") {
111+
val jsonObject = buildJsonObject {
112+
put("trigger", JsonPrimitive(42))
113+
put("depend", JsonPrimitive(42.5))
114+
}
115+
116+
val errors = mutableListOf<ValidationError>()
117+
val valid = schema.validate(jsonObject, errors::add)
118+
119+
jsonObject.asClue {
120+
valid shouldBe true
121+
errors shouldHaveSize 0
122+
}
123+
}
124+
125+
test("object with trigger array property fails validation") {
126+
val jsonObject = buildJsonObject {
127+
put("trigger", JsonPrimitive(42))
128+
put("depend2", JsonPrimitive("test"))
129+
}
130+
131+
val errors = mutableListOf<ValidationError>()
132+
val valid = schema.validate(jsonObject, errors::add)
133+
134+
jsonObject.asClue {
135+
valid shouldBe false
136+
errors.shouldContainExactly(
137+
ValidationError(
138+
schemaPath = JsonPointer("/dependencies/trigger"),
139+
objectPath = JsonPointer.ROOT,
140+
message = "has 'trigger' property but missing required dependencies: [depend]",
141+
)
142+
)
143+
}
144+
}
145+
}
146+
147+
JsonSchema.fromDescription(
148+
"""
149+
{
150+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
151+
"dependencies": {
152+
"trigger1": {
153+
"properties": {
154+
"depend1": {
155+
"type": "number"
156+
}
157+
}
158+
},
159+
"trigger2": ["depend2"]
160+
}
161+
}
162+
""".trimIndent()
163+
).also { schema ->
164+
notAnObjectPasses(schema)
165+
}
166+
167+
JsonSchema.fromDescription(
168+
"""
169+
{
170+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
171+
"dependencies": {}
172+
}
173+
""".trimIndent()
174+
).also { schema ->
175+
test("object passes empty dependencies") {
176+
val jsonObject = buildJsonObject {
177+
put("trigger", JsonPrimitive(42))
178+
put("depend", JsonPrimitive(42.5))
179+
}
180+
181+
val errors = mutableListOf<ValidationError>()
182+
val valid = schema.validate(jsonObject, errors::add)
183+
184+
jsonObject.asClue {
185+
valid shouldBe true
186+
errors shouldHaveSize 0
187+
}
188+
}
189+
}
190+
191+
JsonSchema.fromDescription(
192+
"""
193+
{
194+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
195+
"dependencies": {
196+
"trigger": []
197+
}
198+
}
199+
""".trimIndent()
200+
).also { schema ->
201+
test("object passes empty dependencies array") {
202+
val jsonObject = buildJsonObject {
203+
put("trigger", JsonPrimitive(42))
204+
put("depend", JsonPrimitive(42.5))
205+
}
206+
207+
val errors = mutableListOf<ValidationError>()
208+
val valid = schema.validate(jsonObject, errors::add)
209+
210+
jsonObject.asClue {
211+
valid shouldBe true
212+
errors shouldHaveSize 0
213+
}
214+
}
215+
}
216+
217+
test("reports if dependency is neither array or object") {
218+
shouldThrow<IllegalArgumentException> {
219+
JsonSchema.fromDescription(
220+
"""
221+
{
222+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
223+
"dependencies": {
224+
"trigger": 42
225+
}
226+
}
227+
""".trimIndent()
228+
)
229+
}.message shouldBe "trigger dependency must be either array of strings or valid JSON schema"
230+
}
231+
232+
test("reports if not an object") {
233+
shouldThrow<IllegalArgumentException> {
234+
JsonSchema.fromDescription(
235+
"""
236+
{
237+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
238+
"dependencies": 42
239+
}
240+
""".trimIndent()
241+
)
242+
}.message shouldBe "dependencies must be an object"
243+
}
244+
245+
test("reports not unique elements") {
246+
shouldThrow<IllegalArgumentException> {
247+
JsonSchema.fromDescription(
248+
"""
249+
{
250+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
251+
"dependencies": {
252+
"trigger": ["a","b","a"]
253+
}
254+
}
255+
""".trimIndent()
256+
)
257+
}.message shouldBe "trigger must consist of unique elements"
258+
}
259+
}
260+
}

0 commit comments

Comments
 (0)