Skip to content

Rework the way the circled references are determine to avoid false-positive results #63

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,15 @@ internal fun JsonPointer.dropLast(): JsonPointer? {
return EmptyPointer
}
return JsonPointer.compile(fullPath.substring(0, lastPathPart))
}

internal fun JsonPointer.lastSegment(): String? {
var cur: JsonPointer? = this
while (cur != EmptyPointer) {
if (cur is SegmentPointer && cur.next is EmptyPointer) {
return cur.propertyName
}
cur = cur?.next
}
return null
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ package io.github.optimumcode.json.schema.internal

import com.eygraber.uri.Uri
import io.github.optimumcode.json.pointer.JsonPointer
import io.github.optimumcode.json.pointer.contains
import io.github.optimumcode.json.pointer.internal.dropLast
import io.github.optimumcode.json.pointer.internal.lastSegment
import io.github.optimumcode.json.pointer.startsWith

internal object ReferenceValidator {
Expand Down Expand Up @@ -34,8 +35,8 @@ internal object ReferenceValidator {
checkCircledReferences(usedRef, referencesWithPath)
}

private val alwaysRunAssertions = hashSetOf("allOf", "anyOf", "oneOf")
private val mightNotRun = hashSetOf("properties")
private val alwaysRunInPlaceApplicators = hashSetOf("allOf", "anyOf", "oneOf")
private val definitionProperties = hashSetOf("definitions", "\$defs")

private fun checkCircledReferences(
usedRefs: Set<ReferenceLocation>,
Expand All @@ -48,9 +49,12 @@ internal object ReferenceValidator {

val circledReferences = hashSetOf<CircledReference>()

fun checkRunAlways(path: JsonPointer): Boolean {
return alwaysRunAssertions.any { path.contains(it) } && mightNotRun.none { path.contains(it) }
}
val refsByBaseId: Map<Uri, Set<JsonPointer>> =
referencesWithPath
.entries
.groupingBy { it.value.baseId }
.fold(hashSetOf()) { acc, el -> acc.apply { add(el.value.pointer) } }

for ((location, refId) in locationToRef) {
val schemaLocation: PointerWithBaseId = referencesWithPath.getValue(refId)

Expand All @@ -64,7 +68,12 @@ internal object ReferenceValidator {
) {
continue
}
if (checkRunAlways(location) && checkRunAlways(otherLocation) && location != otherLocation) {
val refsForBaseId = refsByBaseId[schemaLocation.baseId] ?: emptySet()
if (checkRunAlways(
location,
refsForBaseId,
) && checkRunAlways(otherLocation, refsForBaseId) && location != otherLocation
) {
circledReferences +=
CircledReference(
firstLocation = location,
Expand All @@ -83,6 +92,31 @@ internal object ReferenceValidator {
}
}

private fun checkRunAlways(
path: JsonPointer,
schemaLocations: Set<JsonPointer>,
): Boolean {
var curPath: JsonPointer? = path
while (curPath != null) {
val parentPath = curPath.dropLast()
// The idea here is the following:
// 'parentPath in schemaLocations' returns true only if the last segment is a schema keyword.
// If this is the case we should check if this keyword is not applied in-place.
// We also check that this keyword is not a definition as this would be incorrect.
// If this all is 'true' this is not a circled reference
if (
parentPath in schemaLocations &&
curPath.lastSegment()?.let {
it !in alwaysRunInPlaceApplicators && it !in definitionProperties
} == true
) {
return false
}
curPath = parentPath
}
return true
}

private class CircledReference(
val firstLocation: JsonPointer,
val firstRef: JsonPointer,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package io.github.optimumcode.json.schema.base

import io.github.optimumcode.json.schema.JsonSchema
import io.kotest.assertions.asClue
import io.kotest.assertions.throwables.shouldNotThrowAny
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

class JsonSchemaCircledReferencesTest : FunSpec() {
init {

listOf(
"oneOf",
"allOf",
"anyOf",
).forEach {
test("reports circled references $it") {
shouldThrow<IllegalArgumentException> {
JsonSchema.fromDefinition(
"""
{
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"alice": {
"$it": [
{ "${KEY}ref": "#/definitions/bob" }
]
},
"bob": {
"$it": [
{ "${KEY}ref": "#/definitions/alice" }
]
}
},
"properties": {
"alice": {
"${KEY}ref": "#/definitions/alice"
},
"bob": {
"${KEY}ref": "#/definitions/bob"
}
}
}
""".trimIndent(),
)
}.message shouldBe "circled references: /definitions/alice/$it/0 ref to /definitions/bob" +
" and /definitions/bob/$it/0 ref to /definitions/alice"
}
}

test("reports if properties is not the applicator") {
shouldThrow<IllegalArgumentException> {
JsonSchema.fromDefinition(
"""
{
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"alice": {
"allOf": [
{ "${KEY}ref": "#/definitions/properties" }
]
},
"properties": {
"allOf": [
{ "${KEY}ref": "#/definitions/alice" }
]
}
},
"properties": {
"alice": {
"${KEY}ref": "#/definitions/alice"
},
"bob": {
"${KEY}ref": "#/definitions/properties"
}
}
}
""".trimIndent(),
)
}.message shouldBe "circled references: /definitions/alice/allOf/0 ref to /definitions/properties" +
" and /definitions/properties/allOf/0 ref to /definitions/alice"
}

test("does not report circled references if definitions have similar names") {
shouldNotThrowAny {
JsonSchema.fromDefinition(
"""
{
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"nonNegativeInteger": {
"type": "integer",
"minimum": 0
},
"nonNegativeIntegerDefault0": {
"allOf": [
{ "${KEY}ref": "#/definitions/nonNegativeInteger" },
{ "default": 0 }
]
}
}
}
""".trimIndent(),
)
}
}

test("does not report circled references if one is defined inside properties applicator") {
"""
{
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"alice": {
"allOf": [
{ "${KEY}ref": "#/definitions/bob" }
]
},
"bob": {
"properties": {
"test": {
"allOf": [
{ "${KEY}ref": "#/definitions/alice" }
]
}
}
}
},
"properties": {
"alice": {
"${KEY}ref": "#/definitions/alice"
},
"bob": {
"${KEY}ref": "#/definitions/bob"
}
}
}
""".trimIndent().asClue {
shouldNotThrowAny {
JsonSchema.fromDefinition(it)
}
}

"""
{
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"alice": {
"properties": {
"test": {
"allOf": [
{ "${KEY}ref": "#/definitions/alice" }
]
}
}
},
"bob": {
"allOf": [
{ "${KEY}ref": "#/definitions/bob" }
]
}
},
"properties": {
"alice": {
"${KEY}ref": "#/definitions/alice"
},
"bob": {
"${KEY}ref": "#/definitions/bob"
}
}
}
""".trimIndent().asClue {
shouldNotThrowAny {
JsonSchema.fromDefinition(it)
}
}
}

test("does not report circled references if property match allOf") {
"""
{
"${KEY}schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"alice": {
"allOf": [
{ "${KEY}ref": "#/definitions/bob" }
]
},
"bob": {
"properties": {
"allOf": { "${KEY}ref": "#/definitions/alice" }
}
}
},
"properties": {
"alice": {
"${KEY}ref": "#/definitions/alice"
},
"bob": {
"${KEY}ref": "#/definitions/bob"
}
}
}
""".trimIndent().asClue {
shouldNotThrowAny {
JsonSchema.fromDefinition(it)
}
}
}
}
}
Loading