Skip to content

Commit ab0e2b0

Browse files
author
Oleg
committed
Add properties assertions
1 parent 46077c6 commit ab0e2b0

File tree

3 files changed

+369
-0
lines changed

3 files changed

+369
-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
@@ -23,6 +23,7 @@ import smirnov.oleg.json.schema.internal.factories.number.MinimumAssertionFactor
2323
import smirnov.oleg.json.schema.internal.factories.number.MultipleOfAssertionFactory
2424
import smirnov.oleg.json.schema.internal.factories.`object`.MaxPropertiesAssertionFactory
2525
import smirnov.oleg.json.schema.internal.factories.`object`.MinPropertiesAssertionFactory
26+
import smirnov.oleg.json.schema.internal.factories.`object`.PropertiesAssertionFactory
2627
import smirnov.oleg.json.schema.internal.factories.`object`.RequiredAssertionFactory
2728
import smirnov.oleg.json.schema.internal.factories.string.MaxLengthAssertionFactory
2829
import smirnov.oleg.json.schema.internal.factories.string.MinLengthAssertionFactory
@@ -48,6 +49,7 @@ private val factories: List<AssertionFactory> = listOf(
4849
MaxPropertiesAssertionFactory,
4950
MinPropertiesAssertionFactory,
5051
RequiredAssertionFactory,
52+
PropertiesAssertionFactory,
5153
)
5254

5355
class SchemaLoader {
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package smirnov.oleg.json.schema.internal.factories.`object`
2+
3+
import kotlinx.serialization.json.JsonElement
4+
import kotlinx.serialization.json.JsonObject
5+
import smirnov.oleg.json.schema.ErrorCollector
6+
import smirnov.oleg.json.schema.internal.AssertionContext
7+
import smirnov.oleg.json.schema.internal.AssertionFactory
8+
import smirnov.oleg.json.schema.internal.JsonSchemaAssertion
9+
import smirnov.oleg.json.schema.internal.LoadingContext
10+
11+
@Suppress("unused")
12+
internal object PropertiesAssertionFactory : AssertionFactory {
13+
private const val propertiesProperty: String = "properties"
14+
private const val patternPropertiesProperty: String = "patternProperties"
15+
private const val additionalPropertiesProperty: String = "additionalProperties"
16+
17+
override fun isApplicable(element: JsonElement): Boolean {
18+
return element is JsonObject && element.run {
19+
containsKey(propertiesProperty)
20+
|| containsKey(patternPropertiesProperty)
21+
|| containsKey(additionalPropertiesProperty)
22+
}
23+
}
24+
25+
override fun create(element: JsonElement, context: LoadingContext): JsonSchemaAssertion {
26+
require(element is JsonObject) { "cannot extract properties from ${element::class.simpleName}" }
27+
val propertiesAssertions: Map<String, JsonSchemaAssertion> = extractPropertiesAssertions(element, context)
28+
val patternAssertions: Map<Regex, JsonSchemaAssertion> = extractPatternAssertions(element, context)
29+
val additionalProperties: JsonSchemaAssertion? = extractAdditionalProperties(element, context)
30+
return PropertiesAssertion(
31+
propertiesAssertions,
32+
patternAssertions,
33+
additionalProperties,
34+
)
35+
}
36+
37+
private fun extractAdditionalProperties(jsonObject: JsonObject, context: LoadingContext): JsonSchemaAssertion? {
38+
if (jsonObject.isEmpty()) {
39+
return null
40+
}
41+
val additionalElement = jsonObject[additionalPropertiesProperty] ?: return null
42+
require(context.isJsonSchema(additionalElement)) { "$additionalPropertiesProperty must be a valid JSON schema" }
43+
return context.at(additionalPropertiesProperty).schemaFrom(additionalElement)
44+
}
45+
46+
private fun extractPatternAssertions(
47+
jsonObject: JsonObject,
48+
context: LoadingContext
49+
): Map<Regex, JsonSchemaAssertion> {
50+
if (jsonObject.isEmpty()) {
51+
return emptyMap()
52+
}
53+
val propertiesElement = jsonObject[patternPropertiesProperty] ?: return emptyMap()
54+
require(propertiesElement is JsonObject) { "$patternPropertiesProperty must be an object" }
55+
if (propertiesElement.isEmpty()) {
56+
return emptyMap()
57+
}
58+
val propContext = context.at(patternPropertiesProperty)
59+
return propertiesElement.map { (pattern, element) ->
60+
require(propContext.isJsonSchema(element)) { "$pattern must be a valid JSON schema" }
61+
val regex = try {
62+
pattern.toRegex()
63+
} catch (ex: Throwable) { // because of JsError
64+
throw IllegalArgumentException("$pattern must be a valid regular expression", ex)
65+
}
66+
regex to propContext.at(pattern).schemaFrom(element)
67+
}.toMap()
68+
}
69+
70+
private fun extractPropertiesAssertions(
71+
jsonObject: JsonObject,
72+
context: LoadingContext
73+
): Map<String, JsonSchemaAssertion> {
74+
if (jsonObject.isEmpty()) {
75+
return emptyMap()
76+
}
77+
val propertiesElement = jsonObject[propertiesProperty] ?: return emptyMap()
78+
require(propertiesElement is JsonObject) { "$propertiesProperty must be an object" }
79+
if (propertiesElement.isEmpty()) {
80+
return emptyMap()
81+
}
82+
val propertiesContext = context.at(propertiesProperty)
83+
return propertiesElement.mapValues { (prop, element) ->
84+
require(propertiesContext.isJsonSchema(element)) { "$prop must be a valid JSON schema" }
85+
propertiesContext.at(prop).schemaFrom(element)
86+
}
87+
}
88+
89+
}
90+
91+
private class PropertiesAssertion(
92+
private val propertiesAssertions: Map<String, JsonSchemaAssertion>,
93+
private val patternAssertions: Map<Regex, JsonSchemaAssertion>,
94+
private val additionalProperties: JsonSchemaAssertion?
95+
) : JsonSchemaAssertion {
96+
override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
97+
if (element !is JsonObject) {
98+
return true
99+
}
100+
var valid = true
101+
for ((prop, value) in element) {
102+
val propContext = context.at(prop)
103+
var triggered = false
104+
var res = propertiesAssertions[prop]?.run {
105+
triggered = true
106+
validate(
107+
value,
108+
propContext,
109+
errorCollector,
110+
)
111+
} ?: true
112+
valid = valid and res
113+
114+
for ((pattern, assertion) in patternAssertions) {
115+
if (pattern.find(prop) != null) {
116+
triggered = true
117+
res = assertion.validate(
118+
value,
119+
propContext,
120+
errorCollector,
121+
)
122+
valid = valid and res
123+
}
124+
}
125+
if (triggered) {
126+
continue
127+
}
128+
res = additionalProperties?.validate(value, propContext, errorCollector) ?: true
129+
valid = valid and res
130+
}
131+
return valid
132+
}
133+
134+
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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 JsonSchemaPropertiesValidationsTest : FunSpec() {
18+
init {
19+
JsonSchema.fromDescription(
20+
"""
21+
{
22+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
23+
"properties": {
24+
"prop1": {
25+
"type": "number"
26+
}
27+
}
28+
}
29+
""".trimIndent()
30+
).also { schema ->
31+
test("object passes properties validation") {
32+
val jsonObject = buildJsonObject {
33+
put("prop1", JsonPrimitive(42))
34+
put("prop2", JsonPrimitive("test"))
35+
}
36+
val errors = mutableListOf<ValidationError>()
37+
val valid = schema.validate(jsonObject, errors::add)
38+
jsonObject.asClue {
39+
valid shouldBe true
40+
errors shouldHaveSize 0
41+
}
42+
}
43+
44+
test("object fails properties validation") {
45+
val jsonObject = buildJsonObject {
46+
put("prop1", JsonPrimitive("test"))
47+
}
48+
val errors = mutableListOf<ValidationError>()
49+
val valid = schema.validate(jsonObject, errors::add)
50+
jsonObject.asClue {
51+
valid shouldBe false
52+
errors.shouldContainExactly(
53+
ValidationError(
54+
schemaPath = JsonPointer("/properties/prop1/type"),
55+
objectPath = JsonPointer("/prop1"),
56+
message = "element is not a number",
57+
)
58+
)
59+
}
60+
}
61+
}
62+
63+
JsonSchema.fromDescription(
64+
"""
65+
{
66+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
67+
"patternProperties": {
68+
"^foo\\d$": {
69+
"type": "number"
70+
}
71+
}
72+
}
73+
""".trimIndent()
74+
).also { schema ->
75+
test("object passes patternProperties validation") {
76+
val jsonObject = buildJsonObject {
77+
put("foo1", JsonPrimitive(42))
78+
put("foo2", JsonPrimitive(42.5))
79+
put("test", JsonPrimitive("string"))
80+
}
81+
val errors = mutableListOf<ValidationError>()
82+
val valid = schema.validate(jsonObject, errors::add)
83+
jsonObject.asClue {
84+
valid shouldBe true
85+
errors shouldHaveSize 0
86+
}
87+
}
88+
89+
test("object fails patternProperties validation") {
90+
val jsonObject = buildJsonObject {
91+
put("foo1", JsonPrimitive("test"))
92+
}
93+
val errors = mutableListOf<ValidationError>()
94+
val valid = schema.validate(jsonObject, errors::add)
95+
jsonObject.asClue {
96+
valid shouldBe false
97+
errors.shouldContainExactly(
98+
ValidationError(
99+
schemaPath = JsonPointer("/patternProperties/^foo\\d\$/type"),
100+
objectPath = JsonPointer("/foo1"),
101+
message = "element is not a number",
102+
)
103+
)
104+
}
105+
}
106+
}
107+
108+
JsonSchema.fromDescription(
109+
"""
110+
{
111+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
112+
"additionalProperties": {
113+
"type": "number"
114+
}
115+
}
116+
""".trimIndent()
117+
).also { schema ->
118+
test("object passes additionalProperties validation") {
119+
val jsonObject = buildJsonObject {
120+
put("foo1", JsonPrimitive(42))
121+
put("foo2", JsonPrimitive(42.5))
122+
}
123+
val errors = mutableListOf<ValidationError>()
124+
val valid = schema.validate(jsonObject, errors::add)
125+
jsonObject.asClue {
126+
valid shouldBe true
127+
errors shouldHaveSize 0
128+
}
129+
}
130+
131+
test("object fails additionalProperties validation") {
132+
val jsonObject = buildJsonObject {
133+
put("foo1", JsonPrimitive("test"))
134+
}
135+
val errors = mutableListOf<ValidationError>()
136+
val valid = schema.validate(jsonObject, errors::add)
137+
jsonObject.asClue {
138+
valid shouldBe false
139+
errors.shouldContainExactly(
140+
ValidationError(
141+
schemaPath = JsonPointer("/additionalProperties/type"),
142+
objectPath = JsonPointer("/foo1"),
143+
message = "element is not a number",
144+
)
145+
)
146+
}
147+
}
148+
}
149+
150+
JsonSchema.fromDescription(
151+
"""
152+
{
153+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
154+
"properties": {
155+
"test": {
156+
"type": "string"
157+
}
158+
},
159+
"patternProperties": {
160+
"^foo\\d$": {
161+
"type": "number"
162+
}
163+
},
164+
"additionalProperties": false
165+
}
166+
""".trimIndent()
167+
).also { schema ->
168+
test("false additionalProperties reports all unknown properties") {
169+
val jsonObject = buildJsonObject {
170+
put("test", JsonPrimitive("value"))
171+
put("foo2", JsonPrimitive(42.5))
172+
put("unknown", JsonPrimitive(42.5))
173+
}
174+
175+
val errors = mutableListOf<ValidationError>()
176+
val valid = schema.validate(jsonObject, errors::add)
177+
jsonObject.asClue {
178+
valid shouldBe false
179+
errors.shouldContainExactly(
180+
ValidationError(
181+
schemaPath = JsonPointer("/additionalProperties"),
182+
objectPath = JsonPointer("/unknown"),
183+
message = "all values fail against the false schema",
184+
)
185+
)
186+
}
187+
}
188+
189+
notAnObjectPasses(schema)
190+
}
191+
192+
JsonSchema.fromDescription(
193+
"""
194+
{
195+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
196+
"properties": {},
197+
"patternProperties": {}
198+
}
199+
""".trimIndent()
200+
).also { schema ->
201+
test("object passes schema with empty properties and patternProperties") {
202+
val jsonObject = buildJsonObject {
203+
put("test", JsonPrimitive("value"))
204+
put("foo2", JsonPrimitive(42.5))
205+
put("unknown", JsonPrimitive(42.5))
206+
}
207+
val errors = mutableListOf<ValidationError>()
208+
val valid = schema.validate(jsonObject, errors::add)
209+
jsonObject.asClue {
210+
valid shouldBe true
211+
errors shouldHaveSize 0
212+
}
213+
}
214+
}
215+
216+
test("reports invalid regular expression") {
217+
shouldThrow<IllegalArgumentException> {
218+
JsonSchema.fromDescription(
219+
"""
220+
{
221+
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
222+
"patternProperties": {
223+
"*^foo\\d$": {
224+
"type": "number"
225+
}
226+
}
227+
}
228+
""".trimIndent()
229+
)
230+
}.message shouldBe "*^foo\\d\$ must be a valid regular expression"
231+
}
232+
}
233+
}

0 commit comments

Comments
 (0)