Skip to content

Commit 6c0b049

Browse files
authored
Add ipv4 and ipv6 format validators (#74)
1 parent 173b2e1 commit 6c0b049

File tree

14 files changed

+334
-102
lines changed

14 files changed

+334
-102
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,8 @@ The library supports `format` assertion. For now only a few formats are supporte
295295
* duration
296296
* json-pointer
297297
* relative-json-pointer
298+
* ipv4
299+
* ipv6
298300

299301
But there is an API to implement the user's defined format validation.
300302
The [FormatValidator](src/commonMain/kotlin/io/github/optimumcode/json/schema/ValidationError.kt) interface can be user for that.

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/FormatAssertionFactory.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFac
1616
import io.github.optimumcode.json.schema.internal.formats.DateFormatValidator
1717
import io.github.optimumcode.json.schema.internal.formats.DateTimeFormatValidator
1818
import io.github.optimumcode.json.schema.internal.formats.DurationFormatValidator
19+
import io.github.optimumcode.json.schema.internal.formats.IpV4FormatValidator
20+
import io.github.optimumcode.json.schema.internal.formats.IpV6FormatValidator
1921
import io.github.optimumcode.json.schema.internal.formats.JsonPointerFormatValidator
2022
import io.github.optimumcode.json.schema.internal.formats.RelativeJsonPointerFormatValidator
2123
import io.github.optimumcode.json.schema.internal.formats.TimeFormatValidator
@@ -62,6 +64,8 @@ internal sealed class FormatAssertionFactory(
6264
"duration" to DurationFormatValidator,
6365
"json-pointer" to JsonPointerFormatValidator,
6466
"relative-json-pointer" to RelativeJsonPointerFormatValidator,
67+
"ipv4" to IpV4FormatValidator,
68+
"ipv6" to IpV6FormatValidator,
6569
)
6670
}
6771
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package io.github.optimumcode.json.schema.internal.formats
2+
3+
import io.github.optimumcode.json.schema.FormatValidationResult
4+
import io.github.optimumcode.json.schema.FormatValidator
5+
6+
internal object IpV4FormatValidator : AbstractStringFormatValidator() {
7+
// 0.0.0.0 the shortest IPv4
8+
private const val SHORTEST_IP_V4 = 7
9+
private const val MAX_IP_COMPONENT = 255
10+
private val groups: Set<String> = setOf("a", "b", "c", "d")
11+
private val ipRegex = Regex("(?<a>\\d{1,3})\\.(?<b>\\d{1,3})\\.(?<c>\\d{1,3})\\.(?<d>\\d{1,3})")
12+
13+
override fun validate(value: String): FormatValidationResult {
14+
if (value.isEmpty() || value.length < SHORTEST_IP_V4) {
15+
return FormatValidator.Invalid()
16+
}
17+
val result = ipRegex.matchEntire(value) ?: return FormatValidator.Invalid()
18+
return if (validate(result)) {
19+
FormatValidator.Valid()
20+
} else {
21+
FormatValidator.Invalid()
22+
}
23+
}
24+
25+
private fun validate(result: MatchResult): Boolean {
26+
for (group in groups) {
27+
val ipPart = result.groups[group]!!.value
28+
if (ipPart[0] == '0' && ipPart.length > 1) {
29+
return false
30+
}
31+
if (ipPart.toInt() > MAX_IP_COMPONENT) {
32+
return false
33+
}
34+
}
35+
return true
36+
}
37+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package io.github.optimumcode.json.schema.internal.formats
2+
3+
import io.github.optimumcode.json.schema.FormatValidationResult
4+
import io.github.optimumcode.json.schema.FormatValidator
5+
6+
internal object IpV6FormatValidator : AbstractStringFormatValidator() {
7+
private const val BLOCK_OCTETS_LENGTH = 4
8+
private const val V6_DELIMITER = ':'
9+
private const val SHORTEST_IP_V6 = "$V6_DELIMITER$V6_DELIMITER"
10+
private const val V4_DELIMITER = '.'
11+
private const val MAX_V6_BLOCKS = 8
12+
private const val MAX_V6_WITH_V4_BLOCKS = 6
13+
14+
@Suppress("detekt:ReturnCount")
15+
override fun validate(value: String): FormatValidationResult {
16+
if (value.isEmpty() || value.length < SHORTEST_IP_V6.length) {
17+
return FormatValidator.Invalid()
18+
}
19+
if (value == SHORTEST_IP_V6) {
20+
return FormatValidator.Valid()
21+
}
22+
var blocks = 0
23+
var blockStartIndex = 0
24+
var isCompressedFormAppeared = false
25+
for ((index, symbol) in value.withIndex()) {
26+
when (symbol) {
27+
V6_DELIMITER -> {
28+
val blockSize = index - blockStartIndex
29+
val compressed = blockStartIndex > 0 && blockSize == 0
30+
if (compressed && isCompressedFormAppeared) {
31+
// can have only one '::'
32+
return FormatValidator.Invalid()
33+
}
34+
if (!checkBlock(index, value, blockStartIndex, blockSize)) {
35+
return FormatValidator.Invalid()
36+
}
37+
isCompressedFormAppeared = isCompressedFormAppeared or compressed
38+
blockStartIndex = index + 1
39+
blocks += 1
40+
}
41+
in '0'..'9', in 'A'..'F', in 'a'..'f', V4_DELIMITER -> continue
42+
// unexpected character
43+
else -> return FormatValidator.Invalid()
44+
}
45+
}
46+
val lastBlockSize = value.length - blockStartIndex
47+
// normal ipv6 block
48+
// don't count ip block
49+
if (lastBlockSize in 1..BLOCK_OCTETS_LENGTH) {
50+
blocks += 1
51+
}
52+
return checkLastBlock(value, blocks, lastBlockSize, isCompressedFormAppeared, blockStartIndex)
53+
}
54+
55+
private fun checkLastBlock(
56+
value: String,
57+
blocks: Int,
58+
lastBlockSize: Int,
59+
isCompressedFormAppeared: Boolean,
60+
blockStartIndex: Int,
61+
): FormatValidationResult {
62+
if (lastBlockSize == 0 && !isCompressedFormAppeared) {
63+
// last block cannot be empty
64+
return FormatValidator.Invalid()
65+
}
66+
if (blocks > MAX_V6_BLOCKS || blocks > MAX_V6_WITH_V4_BLOCKS && lastBlockSize > BLOCK_OCTETS_LENGTH) {
67+
return FormatValidator.Invalid()
68+
}
69+
if (!isCompressedFormAppeared && blocks != MAX_V6_BLOCKS && blocks != MAX_V6_WITH_V4_BLOCKS) {
70+
return FormatValidator.Invalid()
71+
}
72+
return if (checkBlockValue(
73+
value.substring(blockStartIndex),
74+
mustBeIp = blocks == MAX_V6_WITH_V4_BLOCKS && !isCompressedFormAppeared,
75+
)
76+
) {
77+
FormatValidator.Valid()
78+
} else {
79+
FormatValidator.Invalid()
80+
}
81+
}
82+
83+
private fun checkBlock(
84+
index: Int,
85+
value: String,
86+
blockStartIndex: Int,
87+
blockSize: Int,
88+
): Boolean {
89+
if (blockSize > BLOCK_OCTETS_LENGTH) {
90+
return false
91+
}
92+
93+
if (blockStartIndex == 0 && blockSize == 0 && value[index + 1] != V6_DELIMITER) {
94+
// first block cannot be empty if the next part is not compressed
95+
return false
96+
}
97+
if (blockSize > 0 && !checkBlockValue(value.substring(blockStartIndex, index))) {
98+
return false
99+
}
100+
return true
101+
}
102+
103+
private fun checkBlockValue(
104+
block: String,
105+
mustBeIp: Boolean = false,
106+
): Boolean {
107+
if (mustBeIp || block.length > BLOCK_OCTETS_LENGTH) {
108+
return IpV4FormatValidator.validate(block).isValid()
109+
}
110+
return V4_DELIMITER !in block
111+
}
112+
}

src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/FormatValidationTestSuite.kt

Lines changed: 91 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -19,104 +19,113 @@ import kotlinx.serialization.json.JsonPrimitive
1919
import kotlinx.serialization.json.buildJsonArray
2020
import kotlinx.serialization.json.buildJsonObject
2121

22-
class FormatValidationTestSuite(
23-
private val format: String,
24-
private val validTestCases: List<String>,
25-
private val invalidTestCases: List<TestCase>,
22+
data class TestCase(val value: String, val description: String)
23+
24+
internal fun FunSpec.formatValidationTestSuite(
25+
format: String,
26+
validTestCases: List<String>,
27+
invalidTestCases: List<TestCase>,
2628
) {
27-
data class TestCase(val value: String, val description: String)
29+
fun FunSpec.notStringPasses(
30+
schemaType: SchemaType,
31+
format: String,
32+
schema: JsonSchema,
33+
) {
34+
listOf(
35+
JsonPrimitive(42),
36+
JsonPrimitive(42.5),
37+
JsonPrimitive(true),
38+
JsonNull,
39+
buildJsonArray { },
40+
buildJsonObject { },
41+
).forEach {
42+
test("$schemaType '$it' passes validation for '$format'") {
43+
val errors = mutableListOf<ValidationError>()
44+
val valid = schema.validate(it, errors::add)
45+
assertSoftly {
46+
valid shouldBe true
47+
errors shouldHaveSize 0
48+
}
49+
}
50+
}
51+
}
52+
53+
val loaderWithFormatAssertions =
54+
JsonSchemaLoader.create()
55+
.withSchemaOption(SchemaOption.FORMAT_BEHAVIOR_OPTION, ANNOTATION_AND_ASSERTION)
56+
val loaderWithFormatAnnotation =
57+
JsonSchemaLoader.create()
58+
.withSchemaOption(SchemaOption.FORMAT_BEHAVIOR_OPTION, ANNOTATION_ONLY)
59+
for (schemaType in SchemaType.entries) {
60+
loaderWithFormatAssertions.fromDefinition(
61+
"""
62+
{
63+
"${KEY}schema": "${schemaType.schemaId}",
64+
"format": "$format"
65+
}
66+
""".trimIndent(),
67+
draft = schemaType,
68+
).also { schema ->
69+
notStringPasses(schemaType, format, schema)
2870

29-
fun FunSpec.testFormat() {
30-
fun FunSpec.notStringPasses(
31-
schemaType: SchemaType,
32-
format: String,
33-
schema: JsonSchema,
34-
) {
35-
listOf(
36-
JsonPrimitive(42),
37-
JsonPrimitive(42.5),
38-
JsonPrimitive(true),
39-
JsonNull,
40-
buildJsonArray { },
41-
buildJsonObject { },
42-
).forEach {
43-
test("$schemaType '$it' passes validation for '$format'") {
71+
validTestCases.forEach {
72+
test("$schemaType valid $format '$it' passes".escapeCharacterForWindows()) {
4473
val errors = mutableListOf<ValidationError>()
45-
val valid = schema.validate(it, errors::add)
74+
val valid = schema.validate(JsonPrimitive(it), errors::add)
4675
assertSoftly {
4776
valid shouldBe true
4877
errors shouldHaveSize 0
4978
}
5079
}
5180
}
52-
}
53-
54-
val loaderWithFormatAssertions =
55-
JsonSchemaLoader.create()
56-
.withSchemaOption(SchemaOption.FORMAT_BEHAVIOR_OPTION, ANNOTATION_AND_ASSERTION)
57-
val loaderWithFormatAnnotation =
58-
JsonSchemaLoader.create()
59-
.withSchemaOption(SchemaOption.FORMAT_BEHAVIOR_OPTION, ANNOTATION_ONLY)
60-
for (schemaType in SchemaType.entries) {
61-
loaderWithFormatAssertions.fromDefinition(
62-
"""
63-
{
64-
"${KEY}schema": "${schemaType.schemaId}",
65-
"format": "$format"
66-
}
67-
""".trimIndent(),
68-
draft = schemaType,
69-
).also { schema ->
70-
notStringPasses(schemaType, format, schema)
71-
72-
validTestCases.forEach {
73-
test("$schemaType valid $format '$it' passes") {
74-
val errors = mutableListOf<ValidationError>()
75-
val valid = schema.validate(JsonPrimitive(it), errors::add)
76-
assertSoftly {
77-
valid shouldBe true
78-
errors shouldHaveSize 0
79-
}
80-
}
81-
}
8281

83-
invalidTestCases.forEach { (element, description) ->
84-
test("$schemaType invalid $format '$element' with '$description' fails validation") {
85-
val errors = mutableListOf<ValidationError>()
86-
val valid = schema.validate(JsonPrimitive(element), errors::add)
87-
assertSoftly {
88-
valid shouldBe false
89-
errors.shouldContainExactly(
90-
ValidationError(
91-
schemaPath = JsonPointer("/format"),
92-
objectPath = JsonPointer.ROOT,
93-
message = "value does not match '$format' format",
94-
),
95-
)
96-
}
82+
invalidTestCases.forEach { (element, description) ->
83+
test(
84+
"$schemaType invalid $format '$element' with '$description' fails validation".escapeCharacterForWindows(),
85+
) {
86+
val errors = mutableListOf<ValidationError>()
87+
val valid = schema.validate(JsonPrimitive(element), errors::add)
88+
assertSoftly {
89+
valid shouldBe false
90+
errors.shouldContainExactly(
91+
ValidationError(
92+
schemaPath = JsonPointer("/format"),
93+
objectPath = JsonPointer.ROOT,
94+
message = "value does not match '$format' format",
95+
),
96+
)
9797
}
9898
}
9999
}
100-
loaderWithFormatAnnotation.fromDefinition(
101-
"""
102-
{
103-
"${KEY}schema": "${schemaType.schemaId}",
104-
"format": "$format"
105-
}
106-
""".trimIndent(),
107-
draft = schemaType,
108-
).also { schema ->
109-
invalidTestCases.forEach { (element, description) ->
110-
test("$schemaType invalid $format '$element' with '$description' passes annotation only mode") {
111-
val errors = mutableListOf<ValidationError>()
112-
val valid = schema.validate(JsonPrimitive(element), errors::add)
113-
assertSoftly {
114-
valid shouldBe true
115-
errors shouldHaveSize 0
116-
}
100+
}
101+
loaderWithFormatAnnotation.fromDefinition(
102+
"""
103+
{
104+
"${KEY}schema": "${schemaType.schemaId}",
105+
"format": "$format"
106+
}
107+
""".trimIndent(),
108+
draft = schemaType,
109+
).also { schema ->
110+
invalidTestCases.forEach { (element, description) ->
111+
test(
112+
"$schemaType invalid $format '$element' with '$description' passes annotation only mode"
113+
.escapeCharacterForWindows(),
114+
) {
115+
val errors = mutableListOf<ValidationError>()
116+
val valid = schema.validate(JsonPrimitive(element), errors::add)
117+
assertSoftly {
118+
valid shouldBe true
119+
errors shouldHaveSize 0
117120
}
118121
}
119122
}
120123
}
121124
}
125+
}
126+
127+
private val WINDOWS_PROHIBITED_CHARACTER = Regex("[:]")
128+
129+
private fun String.escapeCharacterForWindows(): String {
130+
return replace(WINDOWS_PROHIBITED_CHARACTER, "_")
122131
}

src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaDateFormatValidationTest.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
package io.github.optimumcode.json.schema.assertions.general.format
22

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

65
class JsonSchemaDateFormatValidationTest : FunSpec() {
76
init {
8-
FormatValidationTestSuite(
7+
formatValidationTestSuite(
98
format = "date",
109
validTestCases =
1110
listOf(
@@ -31,6 +30,6 @@ class JsonSchemaDateFormatValidationTest : FunSpec() {
3130
TestCase("2023/02/28", "invalid delimiter"),
3231
TestCase("not a date", "invalid format"),
3332
),
34-
).run { testFormat() }
33+
)
3534
}
3635
}

0 commit comments

Comments
 (0)