Skip to content

Commit 4f2e808

Browse files
authored
Add output collectors to support output described in the draft 2020-12 (#128)
Provides API to get a structured validation output Resolves #119
1 parent 7207c36 commit 4f2e808

File tree

70 files changed

+2820
-713
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+2820
-713
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,25 @@ val elementToValidate: JsonElement = loadJsonToValidate()
134134
val valid = schema.validate(elementToValidate, errors::add)
135135
```
136136

137+
You can also use predefined `ValidationOutput`s to collect the results.
138+
Output formats are defined in [draft 2020-12](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01#section-12.4).
139+
The most performance can be achieved by using either `flag` or `basic` collectors.
140+
The `detailed` and `verbose` provide more structured information but this adds additional cost to the validation process
141+
(because they collect hierarchical output).
142+
143+
```kotlin
144+
import io.github.optimumcode.json.schema.JsonSchema
145+
import io.github.optimumcode.json.schema.OutputCollector
146+
import io.github.optimumcode.json.schema.ValidationOutput.Flag
147+
import io.github.optimumcode.json.schema.ValidationOutput.Basic
148+
import io.github.optimumcode.json.schema.ValidationOutput.OutputUnit
149+
150+
val flag: Flag = schema.validate(elementToValidate, OutputCollector.flag())
151+
val basic: Basic = schema.validate(elementToValidate, OutputCollector.basic())
152+
val detailed: OutputUnit = schema.validate(elementToValidate, OutputCollector.detailed())
153+
val verbose: OutputUnit = schema.validate(elementToValidate, OutputCollector.verbose())
154+
```
155+
137156
If you need to use more than one schema, and they have references to other schemas you should use `JsonSchemaLoader` class.
138157

139158
```kotlin

api/json-schema-validator.api

Lines changed: 149 additions & 6 deletions
Large diffs are not rendered by default.

benchmark/src/commonMain/kotlin/io/github/optimumcode/json/schema/benchmark/AbstractCommonBenchmark.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.github.optimumcode.json.schema.benchmark
22

33
import io.github.optimumcode.json.schema.ErrorCollector
44
import io.github.optimumcode.json.schema.JsonSchema
5+
import io.github.optimumcode.json.schema.OutputCollector
56
import kotlinx.benchmark.Benchmark
67
import kotlinx.benchmark.Blackhole
78
import kotlinx.benchmark.Setup
@@ -49,4 +50,24 @@ abstract class AbstractCommonBenchmark {
4950
fun validate(bh: Blackhole) {
5051
bh.consume(schema.validate(document, ErrorCollector.EMPTY))
5152
}
53+
54+
@Benchmark
55+
fun validateFlag(bh: Blackhole) {
56+
bh.consume(schema.validate(document, OutputCollector.flag()))
57+
}
58+
59+
@Benchmark
60+
fun validateBasic(bh: Blackhole) {
61+
bh.consume(schema.validate(document, OutputCollector.basic()))
62+
}
63+
64+
@Benchmark
65+
fun validateDetailed(bh: Blackhole) {
66+
bh.consume(schema.validate(document, OutputCollector.detailed()))
67+
}
68+
69+
@Benchmark
70+
fun validateVerbose(bh: Blackhole) {
71+
bh.consume(schema.validate(document, OutputCollector.verbose()))
72+
}
5273
}

benchmark/src/jvmMain/kotlin/io/github/optimumcode/json/schema/benchmark/AbstractComparisonBenchmark.kt

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ import com.networknt.schema.JsonSchemaFactory
66
import com.networknt.schema.OutputFormat
77
import com.networknt.schema.SchemaValidatorsConfig
88
import com.networknt.schema.SpecVersion.VersionFlag.V7
9+
import com.networknt.schema.output.OutputFlag
10+
import com.networknt.schema.output.OutputUnit
911
import io.github.optimumcode.json.schema.ErrorCollector
12+
import io.github.optimumcode.json.schema.OutputCollector
1013
import io.github.optimumcode.json.schema.ValidationError
14+
import io.github.optimumcode.json.schema.ValidationOutput
1115
import io.github.optimumcode.json.schema.fromStream
1216
import io.openapiprocessor.jackson.JacksonConverter
1317
import io.openapiprocessor.jsonschema.reader.UriReader
@@ -18,8 +22,8 @@ import io.openapiprocessor.jsonschema.schema.Output.FLAG
1822
import io.openapiprocessor.jsonschema.schema.SchemaStore
1923
import io.openapiprocessor.jsonschema.validator.Validator
2024
import io.openapiprocessor.jsonschema.validator.ValidatorSettings
25+
import io.openapiprocessor.jsonschema.validator.steps.ValidationStep
2126
import kotlinx.benchmark.Benchmark
22-
import kotlinx.benchmark.Blackhole
2327
import kotlinx.benchmark.Setup
2428
import kotlinx.serialization.ExperimentalSerializationApi
2529
import kotlinx.serialization.json.Json
@@ -95,29 +99,49 @@ abstract class AbstractComparisonBenchmark {
9599
}
96100

97101
@Benchmark
98-
fun validateOpenApi(bh: Blackhole) {
99-
bh.consume(openapiValidator.validate(openapiSchema, openapiDocument))
102+
fun validateOpenApi(): ValidationStep {
103+
return openapiValidator.validate(openapiSchema, openapiDocument)
100104
}
101105

102106
@Benchmark
103-
fun validateNetworkntFlag(bh: Blackhole) {
104-
bh.consume(networkntSchema.validate(networkntDocument, OutputFormat.FLAG))
107+
fun validateNetworkntFlag(): OutputFlag? {
108+
return networkntSchema.validate(networkntDocument, OutputFormat.FLAG)
105109
}
106110

107111
@Benchmark
108-
fun validateKmpFlag(bh: Blackhole) {
109-
bh.consume(schema.validate(document, ErrorCollector.EMPTY))
112+
fun validateNetworkntDetailed(): OutputUnit {
113+
return networkntSchema.validate(networkntDocument, OutputFormat.LIST)
110114
}
111115

112116
@Benchmark
113-
fun validateNetworkntCollectErrors(bh: Blackhole) {
114-
bh.consume(networkntSchema.validate(networkntDocument, OutputFormat.LIST))
117+
fun validateNetworkntVerbose(): OutputUnit {
118+
return networkntSchema.validate(networkntDocument, OutputFormat.HIERARCHICAL)
115119
}
116120

117121
@Benchmark
118-
fun validateKmpCollectErrors(bh: Blackhole) {
122+
fun validateKmpEmptyCollector(): Boolean {
123+
return schema.validate(document, ErrorCollector.EMPTY)
124+
}
125+
126+
@Benchmark
127+
fun validateKmpCollectErrors(): List<ValidationError> {
119128
val errors = arrayListOf<ValidationError>()
120129
schema.validate(document, errors::add)
121-
bh.consume(errors)
130+
return errors
131+
}
132+
133+
@Benchmark
134+
fun validateKmpFlag(): ValidationOutput.Flag {
135+
return schema.validate(document, OutputCollector.flag())
136+
}
137+
138+
@Benchmark
139+
fun validateKmpDetailed(): ValidationOutput.OutputUnit {
140+
return schema.validate(document, OutputCollector.detailed())
141+
}
142+
143+
@Benchmark
144+
fun validateKmpVerbose(): ValidationOutput.OutputUnit {
145+
return schema.validate(document, OutputCollector.verbose())
122146
}
123147
}

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ kotlin {
179179

180180
dependencies {
181181
api(libs.kotlin.serialization.json)
182-
implementation(libs.uri)
182+
api(libs.uri)
183183
// When using approach like above you won't be able to add because block
184184
implementation(libs.kotlin.codepoints.get().toString()) {
185185
because("simplifies work with unicode codepoints")

config/detekt/detekt.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ complexity:
133133
ignoreArgumentsMatchingNames: false
134134
NestedBlockDepth:
135135
active: true
136-
threshold: 4
136+
threshold: 5
137137
NestedScopeFunctions:
138138
active: false
139139
threshold: 1

src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.optimumcode.json.pointer
22

3+
import kotlinx.serialization.Serializable
34
import kotlin.jvm.JvmField
45
import kotlin.jvm.JvmStatic
56

@@ -13,6 +14,7 @@ public fun JsonPointer(path: String): JsonPointer = JsonPointer.compile(path)
1314
* Implementation of a JSON pointer described in the specification
1415
* [RFC6901](https://datatracker.ietf.org/doc/html/rfc6901).
1516
*/
17+
@Serializable(JsonPointerSerializer::class)
1618
public sealed class JsonPointer(
1719
internal open val next: JsonPointer? = null,
1820
) {
@@ -29,7 +31,13 @@ public sealed class JsonPointer(
2931
*/
3032
public fun atIndex(index: Int): JsonPointer {
3133
require(index >= 0) { "negative index: $index" }
32-
return atProperty(index.toString())
34+
return insertLast(
35+
SegmentPointer(
36+
propertyName = index.toString(),
37+
depth = 1,
38+
index = index,
39+
),
40+
)
3341
}
3442

3543
/**
@@ -40,7 +48,10 @@ public sealed class JsonPointer(
4048
* val pointer = JsonPointer.ROOT.atProperty("prop1").atProperty("prop2") // "/prop1/prop2"
4149
* ```
4250
*/
43-
public fun atProperty(property: String): JsonPointer = insertLast(SegmentPointer(property))
51+
public fun atProperty(property: String): JsonPointer =
52+
insertLast(
53+
SegmentPointer(depth = 1, propertyName = property),
54+
)
4455

4556
override fun toString(): String {
4657
val str = asString
@@ -66,19 +77,59 @@ public sealed class JsonPointer(
6677
if (this !is SegmentPointer) {
6778
return last
6879
}
69-
var parent: PointerParent? = null
70-
var node: JsonPointer = this
71-
while (node is SegmentPointer) {
72-
parent =
73-
PointerParent(
74-
parent,
75-
node.propertyName,
80+
if (depth < MAX_POINTER_DEPTH_FOR_RECURSIVE_INSERT) {
81+
return insertLastDeepCopy(this, last)
82+
}
83+
// avoid recursion when pointer depth is greater than a specified limit
84+
// this should help with avoiding stack-overflow error
85+
// when this method called for a pointer that has too many segments
86+
//
87+
// Using queue is less efficient than recursion (around 10%) but saves us from crash
88+
val queue = ArrayDeque<SegmentPointer>(depth)
89+
var cur: JsonPointer = this
90+
while (cur is SegmentPointer) {
91+
queue.add(cur)
92+
cur = cur.next
93+
}
94+
val additionalDepth = last.depth
95+
var result = last
96+
while (queue.isNotEmpty()) {
97+
val segment = queue.removeLast()
98+
result =
99+
SegmentPointer(
100+
propertyName = segment.propertyName,
101+
depth = segment.depth + additionalDepth,
102+
index = segment.index,
103+
next = result,
76104
)
77-
node = node.next
78105
}
79-
return buildPath(last, parent)
106+
return result
80107
}
81108

109+
// there might be an issue with stack in case this function is called deep on the stack
110+
private fun insertLastDeepCopy(
111+
pointer: SegmentPointer,
112+
last: SegmentPointer,
113+
): JsonPointer =
114+
with(pointer) {
115+
val additionalDepth = last.depth
116+
if (next is SegmentPointer) {
117+
SegmentPointer(
118+
propertyName = propertyName,
119+
depth = depth + additionalDepth,
120+
index = index,
121+
next = insertLastDeepCopy(next, last),
122+
)
123+
} else {
124+
SegmentPointer(
125+
propertyName = propertyName,
126+
depth = depth + additionalDepth,
127+
index = index,
128+
next = last,
129+
)
130+
}
131+
}
132+
82133
private fun escapeJsonPointer(propertyName: String): String {
83134
if (propertyName.contains(SEPARATOR) || propertyName.contains(QUOTATION)) {
84135
return buildString(capacity = propertyName.length + 1) {
@@ -132,6 +183,7 @@ public sealed class JsonPointer(
132183
}
133184

134185
public companion object {
186+
private const val MAX_POINTER_DEPTH_FOR_RECURSIVE_INSERT = 20
135187
internal const val SEPARATOR: Char = '/'
136188
internal const val QUOTATION: Char = '~'
137189
internal const val QUOTATION_ESCAPE: Char = '0'
@@ -174,13 +226,15 @@ public sealed class JsonPointer(
174226
lastSegment: SegmentPointer,
175227
parent: PointerParent?,
176228
): JsonPointer {
229+
var depth = lastSegment.depth
177230
var curr = lastSegment
178231
var parentValue = parent
179232
while (parentValue != null) {
180233
curr =
181234
parentValue.run {
182235
SegmentPointer(
183236
segment,
237+
++depth,
184238
curr,
185239
)
186240
}
@@ -269,12 +323,11 @@ private fun StringBuilder.appendEscaped(ch: Char) {
269323
internal object EmptyPointer : JsonPointer()
270324

271325
internal class SegmentPointer(
272-
segment: String,
326+
val propertyName: String,
327+
val depth: Int = 1,
273328
override val next: JsonPointer = EmptyPointer,
329+
val index: Int = parseIndex(propertyName),
274330
) : JsonPointer(next) {
275-
val propertyName: String = segment
276-
val index: Int = parseIndex(segment)
277-
278331
companion object {
279332
private const val NO_INDEX: Int = -1
280333
private const val LONG_LENGTH_THRESHOLD = 10
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.github.optimumcode.json.pointer
2+
3+
import kotlinx.serialization.KSerializer
4+
import kotlinx.serialization.descriptors.PrimitiveKind
5+
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
6+
import kotlinx.serialization.descriptors.SerialDescriptor
7+
import kotlinx.serialization.encoding.Decoder
8+
import kotlinx.serialization.encoding.Encoder
9+
10+
internal object JsonPointerSerializer : KSerializer<JsonPointer> {
11+
override val descriptor: SerialDescriptor =
12+
PrimitiveSerialDescriptor(
13+
"io.github.optimumcode.json.pointer.JsonPointer",
14+
PrimitiveKind.STRING,
15+
)
16+
17+
override fun deserialize(decoder: Decoder): JsonPointer = JsonPointer(decoder.decodeString())
18+
19+
override fun serialize(
20+
encoder: Encoder,
21+
value: JsonPointer,
22+
) {
23+
encoder.encodeString(value.toString())
24+
}
25+
}

src/commonMain/kotlin/io/github/optimumcode/json/pointer/extensions.kt

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,23 @@ public operator fun JsonPointer.plus(otherPointer: JsonPointer): JsonPointer {
7272
* @throws IllegalArgumentException when [other] is an empty pointer
7373
*/
7474
public fun JsonPointer.relative(other: JsonPointer): JsonPointer {
75-
if (this is EmptyPointer) {
75+
if (this !is SegmentPointer) {
7676
return other
7777
}
78-
require(other !is EmptyPointer) { "empty pointer is not relative to any" }
79-
val currentValue = this.toString()
80-
val otherValue = other.toString()
81-
val relative = otherValue.substringAfter(currentValue)
82-
return if (relative == otherValue) {
83-
other
78+
require(other is SegmentPointer) { "empty pointer is not relative to any" }
79+
var currentValue: JsonPointer = this
80+
var otherValue: JsonPointer = other
81+
while (currentValue is SegmentPointer && otherValue is SegmentPointer) {
82+
if (currentValue.propertyName != otherValue.propertyName) {
83+
return other
84+
}
85+
currentValue = currentValue.next
86+
otherValue = otherValue.next
87+
}
88+
return if (currentValue is EmptyPointer) {
89+
otherValue
8490
} else {
85-
JsonPointer(relative)
91+
other
8692
}
8793
}
8894

0 commit comments

Comments
 (0)