Skip to content

Add ipv4 and ipv6 format validators #74

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 4 commits into from
Mar 6, 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ The library supports `format` assertion. For now only a few formats are supporte
* duration
* json-pointer
* relative-json-pointer
* ipv4
* ipv6

But there is an API to implement the user's defined format validation.
The [FormatValidator](src/commonMain/kotlin/io/github/optimumcode/json/schema/ValidationError.kt) interface can be user for that.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFac
import io.github.optimumcode.json.schema.internal.formats.DateFormatValidator
import io.github.optimumcode.json.schema.internal.formats.DateTimeFormatValidator
import io.github.optimumcode.json.schema.internal.formats.DurationFormatValidator
import io.github.optimumcode.json.schema.internal.formats.IpV4FormatValidator
import io.github.optimumcode.json.schema.internal.formats.IpV6FormatValidator
import io.github.optimumcode.json.schema.internal.formats.JsonPointerFormatValidator
import io.github.optimumcode.json.schema.internal.formats.RelativeJsonPointerFormatValidator
import io.github.optimumcode.json.schema.internal.formats.TimeFormatValidator
Expand Down Expand Up @@ -62,6 +64,8 @@ internal sealed class FormatAssertionFactory(
"duration" to DurationFormatValidator,
"json-pointer" to JsonPointerFormatValidator,
"relative-json-pointer" to RelativeJsonPointerFormatValidator,
"ipv4" to IpV4FormatValidator,
"ipv6" to IpV6FormatValidator,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.github.optimumcode.json.schema.internal.formats

import io.github.optimumcode.json.schema.FormatValidationResult
import io.github.optimumcode.json.schema.FormatValidator

internal object IpV4FormatValidator : AbstractStringFormatValidator() {
// 0.0.0.0 the shortest IPv4
private const val SHORTEST_IP_V4 = 7
private const val MAX_IP_COMPONENT = 255
private val groups: Set<String> = setOf("a", "b", "c", "d")
private val ipRegex = Regex("(?<a>\\d{1,3})\\.(?<b>\\d{1,3})\\.(?<c>\\d{1,3})\\.(?<d>\\d{1,3})")

override fun validate(value: String): FormatValidationResult {
if (value.isEmpty() || value.length < SHORTEST_IP_V4) {
return FormatValidator.Invalid()
}
val result = ipRegex.matchEntire(value) ?: return FormatValidator.Invalid()
return if (validate(result)) {
FormatValidator.Valid()
} else {
FormatValidator.Invalid()
}
}

private fun validate(result: MatchResult): Boolean {
for (group in groups) {
val ipPart = result.groups[group]!!.value
if (ipPart[0] == '0' && ipPart.length > 1) {
return false
}
if (ipPart.toInt() > MAX_IP_COMPONENT) {
return false
}
}
return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package io.github.optimumcode.json.schema.internal.formats

import io.github.optimumcode.json.schema.FormatValidationResult
import io.github.optimumcode.json.schema.FormatValidator

internal object IpV6FormatValidator : AbstractStringFormatValidator() {
private const val BLOCK_OCTETS_LENGTH = 4
private const val V6_DELIMITER = ':'
private const val SHORTEST_IP_V6 = "$V6_DELIMITER$V6_DELIMITER"
private const val V4_DELIMITER = '.'
private const val MAX_V6_BLOCKS = 8
private const val MAX_V6_WITH_V4_BLOCKS = 6

@Suppress("detekt:ReturnCount")
override fun validate(value: String): FormatValidationResult {
if (value.isEmpty() || value.length < SHORTEST_IP_V6.length) {
return FormatValidator.Invalid()
}
if (value == SHORTEST_IP_V6) {
return FormatValidator.Valid()
}
var blocks = 0
var blockStartIndex = 0
var isCompressedFormAppeared = false
for ((index, symbol) in value.withIndex()) {
when (symbol) {
V6_DELIMITER -> {
val blockSize = index - blockStartIndex
val compressed = blockStartIndex > 0 && blockSize == 0
if (compressed && isCompressedFormAppeared) {
// can have only one '::'
return FormatValidator.Invalid()
}
if (!checkBlock(index, value, blockStartIndex, blockSize)) {
return FormatValidator.Invalid()
}
isCompressedFormAppeared = isCompressedFormAppeared or compressed
blockStartIndex = index + 1
blocks += 1
}
in '0'..'9', in 'A'..'F', in 'a'..'f', V4_DELIMITER -> continue
// unexpected character
else -> return FormatValidator.Invalid()
}
}
val lastBlockSize = value.length - blockStartIndex
// normal ipv6 block
// don't count ip block
if (lastBlockSize in 1..BLOCK_OCTETS_LENGTH) {
blocks += 1
}
return checkLastBlock(value, blocks, lastBlockSize, isCompressedFormAppeared, blockStartIndex)
}

private fun checkLastBlock(
value: String,
blocks: Int,
lastBlockSize: Int,
isCompressedFormAppeared: Boolean,
blockStartIndex: Int,
): FormatValidationResult {
if (lastBlockSize == 0 && !isCompressedFormAppeared) {
// last block cannot be empty
return FormatValidator.Invalid()
}
if (blocks > MAX_V6_BLOCKS || blocks > MAX_V6_WITH_V4_BLOCKS && lastBlockSize > BLOCK_OCTETS_LENGTH) {
return FormatValidator.Invalid()
}
if (!isCompressedFormAppeared && blocks != MAX_V6_BLOCKS && blocks != MAX_V6_WITH_V4_BLOCKS) {
return FormatValidator.Invalid()
}
return if (checkBlockValue(
value.substring(blockStartIndex),
mustBeIp = blocks == MAX_V6_WITH_V4_BLOCKS && !isCompressedFormAppeared,
)
) {
FormatValidator.Valid()
} else {
FormatValidator.Invalid()
}
}

private fun checkBlock(
index: Int,
value: String,
blockStartIndex: Int,
blockSize: Int,
): Boolean {
if (blockSize > BLOCK_OCTETS_LENGTH) {
return false
}

if (blockStartIndex == 0 && blockSize == 0 && value[index + 1] != V6_DELIMITER) {
// first block cannot be empty if the next part is not compressed
return false
}
if (blockSize > 0 && !checkBlockValue(value.substring(blockStartIndex, index))) {
return false
}
return true
}

private fun checkBlockValue(
block: String,
mustBeIp: Boolean = false,
): Boolean {
if (mustBeIp || block.length > BLOCK_OCTETS_LENGTH) {
return IpV4FormatValidator.validate(block).isValid()
}
return V4_DELIMITER !in block
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,104 +19,113 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject

class FormatValidationTestSuite(
private val format: String,
private val validTestCases: List<String>,
private val invalidTestCases: List<TestCase>,
data class TestCase(val value: String, val description: String)

internal fun FunSpec.formatValidationTestSuite(
format: String,
validTestCases: List<String>,
invalidTestCases: List<TestCase>,
) {
data class TestCase(val value: String, val description: String)
fun FunSpec.notStringPasses(
schemaType: SchemaType,
format: String,
schema: JsonSchema,
) {
listOf(
JsonPrimitive(42),
JsonPrimitive(42.5),
JsonPrimitive(true),
JsonNull,
buildJsonArray { },
buildJsonObject { },
).forEach {
test("$schemaType '$it' passes validation for '$format'") {
val errors = mutableListOf<ValidationError>()
val valid = schema.validate(it, errors::add)
assertSoftly {
valid shouldBe true
errors shouldHaveSize 0
}
}
}
}

val loaderWithFormatAssertions =
JsonSchemaLoader.create()
.withSchemaOption(SchemaOption.FORMAT_BEHAVIOR_OPTION, ANNOTATION_AND_ASSERTION)
val loaderWithFormatAnnotation =
JsonSchemaLoader.create()
.withSchemaOption(SchemaOption.FORMAT_BEHAVIOR_OPTION, ANNOTATION_ONLY)
for (schemaType in SchemaType.entries) {
loaderWithFormatAssertions.fromDefinition(
"""
{
"${KEY}schema": "${schemaType.schemaId}",
"format": "$format"
}
""".trimIndent(),
draft = schemaType,
).also { schema ->
notStringPasses(schemaType, format, schema)

fun FunSpec.testFormat() {
fun FunSpec.notStringPasses(
schemaType: SchemaType,
format: String,
schema: JsonSchema,
) {
listOf(
JsonPrimitive(42),
JsonPrimitive(42.5),
JsonPrimitive(true),
JsonNull,
buildJsonArray { },
buildJsonObject { },
).forEach {
test("$schemaType '$it' passes validation for '$format'") {
validTestCases.forEach {
test("$schemaType valid $format '$it' passes".escapeCharacterForWindows()) {
val errors = mutableListOf<ValidationError>()
val valid = schema.validate(it, errors::add)
val valid = schema.validate(JsonPrimitive(it), errors::add)
assertSoftly {
valid shouldBe true
errors shouldHaveSize 0
}
}
}
}

val loaderWithFormatAssertions =
JsonSchemaLoader.create()
.withSchemaOption(SchemaOption.FORMAT_BEHAVIOR_OPTION, ANNOTATION_AND_ASSERTION)
val loaderWithFormatAnnotation =
JsonSchemaLoader.create()
.withSchemaOption(SchemaOption.FORMAT_BEHAVIOR_OPTION, ANNOTATION_ONLY)
for (schemaType in SchemaType.entries) {
loaderWithFormatAssertions.fromDefinition(
"""
{
"${KEY}schema": "${schemaType.schemaId}",
"format": "$format"
}
""".trimIndent(),
draft = schemaType,
).also { schema ->
notStringPasses(schemaType, format, schema)

validTestCases.forEach {
test("$schemaType valid $format '$it' passes") {
val errors = mutableListOf<ValidationError>()
val valid = schema.validate(JsonPrimitive(it), errors::add)
assertSoftly {
valid shouldBe true
errors shouldHaveSize 0
}
}
}

invalidTestCases.forEach { (element, description) ->
test("$schemaType invalid $format '$element' with '$description' fails validation") {
val errors = mutableListOf<ValidationError>()
val valid = schema.validate(JsonPrimitive(element), errors::add)
assertSoftly {
valid shouldBe false
errors.shouldContainExactly(
ValidationError(
schemaPath = JsonPointer("/format"),
objectPath = JsonPointer.ROOT,
message = "value does not match '$format' format",
),
)
}
invalidTestCases.forEach { (element, description) ->
test(
"$schemaType invalid $format '$element' with '$description' fails validation".escapeCharacterForWindows(),
) {
val errors = mutableListOf<ValidationError>()
val valid = schema.validate(JsonPrimitive(element), errors::add)
assertSoftly {
valid shouldBe false
errors.shouldContainExactly(
ValidationError(
schemaPath = JsonPointer("/format"),
objectPath = JsonPointer.ROOT,
message = "value does not match '$format' format",
),
)
}
}
}
loaderWithFormatAnnotation.fromDefinition(
"""
{
"${KEY}schema": "${schemaType.schemaId}",
"format": "$format"
}
""".trimIndent(),
draft = schemaType,
).also { schema ->
invalidTestCases.forEach { (element, description) ->
test("$schemaType invalid $format '$element' with '$description' passes annotation only mode") {
val errors = mutableListOf<ValidationError>()
val valid = schema.validate(JsonPrimitive(element), errors::add)
assertSoftly {
valid shouldBe true
errors shouldHaveSize 0
}
}
loaderWithFormatAnnotation.fromDefinition(
"""
{
"${KEY}schema": "${schemaType.schemaId}",
"format": "$format"
}
""".trimIndent(),
draft = schemaType,
).also { schema ->
invalidTestCases.forEach { (element, description) ->
test(
"$schemaType invalid $format '$element' with '$description' passes annotation only mode"
.escapeCharacterForWindows(),
) {
val errors = mutableListOf<ValidationError>()
val valid = schema.validate(JsonPrimitive(element), errors::add)
assertSoftly {
valid shouldBe true
errors shouldHaveSize 0
}
}
}
}
}
}

private val WINDOWS_PROHIBITED_CHARACTER = Regex("[:]")

private fun String.escapeCharacterForWindows(): String {
return replace(WINDOWS_PROHIBITED_CHARACTER, "_")
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package io.github.optimumcode.json.schema.assertions.general.format

import io.github.optimumcode.json.schema.assertions.general.format.FormatValidationTestSuite.TestCase
import io.kotest.core.spec.style.FunSpec

class JsonSchemaDateFormatValidationTest : FunSpec() {
init {
FormatValidationTestSuite(
formatValidationTestSuite(
format = "date",
validTestCases =
listOf(
Expand All @@ -31,6 +30,6 @@ class JsonSchemaDateFormatValidationTest : FunSpec() {
TestCase("2023/02/28", "invalid delimiter"),
TestCase("not a date", "invalid format"),
),
).run { testFormat() }
)
}
}
Loading