diff --git a/README.md b/README.md index 22cce19a..a0257cad 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ val valid = schema.validate(elementToValidate, errors::add) ## Format assertion -The library supports `format` assertion. For now only a few formats are supported: +The library supports `format` assertion. Not all formats are supported yet. The supported formats are: * date * time * date-time @@ -344,6 +344,11 @@ The library supports `format` assertion. For now only a few formats are supporte * uuid * hostname * idn-hostname +* uri +* uri-reference +* uri-template +* iri +* iri-reference 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. @@ -360,7 +365,7 @@ You can implement custom assertions and use them. Read more [here](docs/custom_a This library uses official [JSON schema test suites](https://github.com/json-schema-org/JSON-Schema-Test-Suite) as a part of the CI to make sure the validation meet the expected behavior. Not everything is supported right now but the missing functionality might be added in the future. -The test are located [here](test-suites). +The tests are located [here](test-suites). **NOTE:** _Python 3.* is required to run test-suites._ diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/FormatAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/FormatAssertionFactory.kt index f1a6dc17..95b70f89 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/FormatAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/FormatAssertionFactory.kt @@ -20,9 +20,14 @@ import io.github.optimumcode.json.schema.internal.formats.HostnameFormatValidato import io.github.optimumcode.json.schema.internal.formats.IdnHostnameFormatValidator 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.IriFormatValidator +import io.github.optimumcode.json.schema.internal.formats.IriReferenceFormatValidator 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 +import io.github.optimumcode.json.schema.internal.formats.UriFormatValidator +import io.github.optimumcode.json.schema.internal.formats.UriReferenceFormatValidator +import io.github.optimumcode.json.schema.internal.formats.UriTemplateFormatValidator import io.github.optimumcode.json.schema.internal.formats.UuidFormatValidator import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive @@ -72,6 +77,11 @@ internal sealed class FormatAssertionFactory( "uuid" to UuidFormatValidator, "hostname" to HostnameFormatValidator, "idn-hostname" to IdnHostnameFormatValidator, + "uri" to UriFormatValidator, + "uri-reference" to UriReferenceFormatValidator, + "iri" to IriFormatValidator, + "iri-reference" to IriReferenceFormatValidator, + "uri-template" to UriTemplateFormatValidator, ) } } diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/IriFormatValidator.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/IriFormatValidator.kt new file mode 100644 index 00000000..5d78ea7f --- /dev/null +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/IriFormatValidator.kt @@ -0,0 +1,13 @@ +package io.github.optimumcode.json.schema.internal.formats + +import io.github.optimumcode.json.schema.FormatValidationResult + +internal object IriFormatValidator : AbstractStringFormatValidator() { + override fun validate(value: String): FormatValidationResult { + if (value.isEmpty()) { + return UriFormatValidator.validate(value) + } + val uri = IriSpec.covertToUri(value) + return UriFormatValidator.validate(uri) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/IriReferenceFormatValidator.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/IriReferenceFormatValidator.kt new file mode 100644 index 00000000..f5e0f7ed --- /dev/null +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/IriReferenceFormatValidator.kt @@ -0,0 +1,13 @@ +package io.github.optimumcode.json.schema.internal.formats + +import io.github.optimumcode.json.schema.FormatValidationResult + +internal object IriReferenceFormatValidator : AbstractStringFormatValidator() { + override fun validate(value: String): FormatValidationResult { + if (value.isEmpty()) { + return UriReferenceFormatValidator.validate(value) + } + val uri = IriSpec.covertToUri(value) + return UriReferenceFormatValidator.validate(uri) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/IriSpec.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/IriSpec.kt new file mode 100644 index 00000000..267c3c8a --- /dev/null +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/IriSpec.kt @@ -0,0 +1,22 @@ +package io.github.optimumcode.json.schema.internal.formats + +internal object IriSpec { + private const val BITS_SHIFT = 4 + private const val LOWER_BITS = 0x0F + private const val HEX_DECIMAL = "0123456789ABCDEF" + + fun covertToUri(iri: String): String { + return buildString { + for (byte in iri.encodeToByteArray()) { + if (byte >= 0) { + append(byte.toInt().toChar()) + } else { + val unsignedInt = byte.toUByte().toInt() + append('%') + append(HEX_DECIMAL[unsignedInt shr BITS_SHIFT]) + append(HEX_DECIMAL[unsignedInt and LOWER_BITS]) + } + } + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriFormatValidator.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriFormatValidator.kt new file mode 100644 index 00000000..f7f85866 --- /dev/null +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriFormatValidator.kt @@ -0,0 +1,65 @@ +package io.github.optimumcode.json.schema.internal.formats + +import io.github.optimumcode.json.schema.FormatValidationResult +import io.github.optimumcode.json.schema.FormatValidator +import io.github.optimumcode.json.schema.internal.formats.UriSpec.FRAGMENT_DELIMITER +import io.github.optimumcode.json.schema.internal.formats.UriSpec.QUERY_DELIMITER +import io.github.optimumcode.json.schema.internal.formats.UriSpec.SCHEMA_DELIMITER + +internal object UriFormatValidator : AbstractStringFormatValidator() { + @Suppress("detekt:ReturnCount") + override fun validate(value: String): FormatValidationResult { + if (value.isEmpty()) { + return FormatValidator.Invalid() + } + + val schemaEndIndex = value.indexOf(SCHEMA_DELIMITER) + if (schemaEndIndex < 0 || schemaEndIndex == value.lastIndex) { + return FormatValidator.Invalid() + } + + val schema = value.substring(0, schemaEndIndex) + if (!UriSpec.isValidSchema(schema)) { + return FormatValidator.Invalid() + } + + val fragmentDelimiterIndex = value.indexOf(FRAGMENT_DELIMITER) + val queryDelimiterIndex = + value.indexOf(QUERY_DELIMITER) + .takeUnless { fragmentDelimiterIndex in 0.. 0 -> + value.substring(schemaEndIndex + 1, queryDelimiterIndex) + fragmentDelimiterIndex > 0 -> + value.substring(schemaEndIndex + 1, fragmentDelimiterIndex) + else -> + value.substring(schemaEndIndex + 1) + } + if (!UriSpec.isValidHierPart(hierPart)) { + return FormatValidator.Invalid() + } + + if (queryDelimiterIndex > 0 && queryDelimiterIndex < value.lastIndex) { + val query = + if (fragmentDelimiterIndex > 0) { + value.substring(queryDelimiterIndex + 1, fragmentDelimiterIndex) + } else { + value.substring(queryDelimiterIndex + 1) + } + if (!UriSpec.isValidQuery(query)) { + return FormatValidator.Invalid() + } + } + + if (fragmentDelimiterIndex > 0 && fragmentDelimiterIndex < value.lastIndex) { + val fragment = value.substring(fragmentDelimiterIndex + 1) + if (!UriSpec.isValidFragment(fragment)) { + return FormatValidator.Invalid() + } + } + + return FormatValidator.Valid() + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriReferenceFormatValidator.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriReferenceFormatValidator.kt new file mode 100644 index 00000000..589801af --- /dev/null +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriReferenceFormatValidator.kt @@ -0,0 +1,53 @@ +package io.github.optimumcode.json.schema.internal.formats + +import io.github.optimumcode.json.schema.FormatValidationResult +import io.github.optimumcode.json.schema.FormatValidator +import io.github.optimumcode.json.schema.internal.formats.UriSpec.FRAGMENT_DELIMITER +import io.github.optimumcode.json.schema.internal.formats.UriSpec.QUERY_DELIMITER + +internal object UriReferenceFormatValidator : AbstractStringFormatValidator() { + @Suppress("detekt:ReturnCount") + override fun validate(value: String): FormatValidationResult { + if (UriFormatValidator.validate(value).isValid()) { + return FormatValidator.Valid() + } + + val fragmentDelimiterIndex = value.indexOf(FRAGMENT_DELIMITER) + val queryDelimiterIndex = + value.indexOf(QUERY_DELIMITER) + .takeUnless { fragmentDelimiterIndex in 0..= 0 -> + value.substring(0, queryDelimiterIndex) + fragmentDelimiterIndex >= 0 -> + value.substring(0, fragmentDelimiterIndex) + else -> value + } + if (!UriSpec.isValidRelativePart(relativePart)) { + return FormatValidator.Invalid() + } + + if (queryDelimiterIndex >= 0 && queryDelimiterIndex < value.lastIndex) { + val query = + if (fragmentDelimiterIndex > 0) { + value.substring(queryDelimiterIndex + 1, fragmentDelimiterIndex) + } else { + value.substring(queryDelimiterIndex + 1) + } + if (!UriSpec.isValidQuery(query)) { + return FormatValidator.Invalid() + } + } + + if (fragmentDelimiterIndex >= 0 && fragmentDelimiterIndex < value.lastIndex) { + val fragment = value.substring(fragmentDelimiterIndex + 1) + if (!UriSpec.isValidFragment(fragment)) { + return FormatValidator.Invalid() + } + } + + return FormatValidator.Valid() + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriSpec.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriSpec.kt new file mode 100644 index 00000000..47db8b98 --- /dev/null +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriSpec.kt @@ -0,0 +1,284 @@ +package io.github.optimumcode.json.schema.internal.formats + +internal object UriSpec { + const val SCHEMA_DELIMITER = ':' + const val QUERY_DELIMITER = '?' + const val FRAGMENT_DELIMITER = '#' + + fun isValidFragment(fragment: String): Boolean = isValidFragmentOrQuery(fragment) + + fun isValidQuery(query: String): Boolean = isValidFragmentOrQuery(query) + + fun isValidHierPart(hierPart: String): Boolean { + if (hierPart.isEmpty()) { + return true + } + return when { + hierPart.startsWith("//") -> + isValidAuthorityWithPath(hierPart.substring(2)) + hierPart.startsWith("/") -> + isValidAbsolutePath(hierPart.substring(1)) + else -> + isValidRootlessPath(hierPart) + } + } + + fun isValidRelativePart(relativePart: String): Boolean { + if (relativePart.isEmpty()) { + return true + } + return when { + relativePart.startsWith("//") -> + isValidAuthorityWithPath(relativePart.substring(2)) + relativePart.startsWith("/") -> + isValidAbsolutePath(relativePart.substring(1)) + else -> + isValidNoschemaPath(relativePart) + } + } + + private fun isValidNoschemaPath(relativePart: String): Boolean { + val segmentSeparator = relativePart.indexOf('/') + val segmentWithoutColon = + if (segmentSeparator < 0) { + relativePart + } else { + relativePart.substring(0, segmentSeparator) + } + val validSegmentWithoutColon = + hasValidCharsOrPctEncoded(segmentWithoutColon) { + isUnreserved(it) || isSubDelimiter(it) || it == '@' + } + return validSegmentWithoutColon && + (segmentSeparator < 0 || isValidSegments(relativePart.substring(segmentSeparator))) + } + + private fun isValidRootlessPath(rootlessPath: String): Boolean = isValidSegments(rootlessPath) + + private fun isValidAbsolutePath(absolutePath: String): Boolean { + if (absolutePath.isEmpty()) { + return true + } + + return isValidSegments(absolutePath) + } + + private fun isValidSegments(segments: String): Boolean { + var lastSep = -1 + for ((index, value) in segments.withIndex()) { + if (value == '/') { + if (!hasOnlyPChars(segments.substring(lastSep + 1, index))) { + return false + } + lastSep = index + } + } + + return hasOnlyPChars(segments.substring(lastSep + 1)) + } + + @Suppress("detekt:ReturnCount") + private fun isValidAuthorityWithPath(authorityWithPath: String): Boolean { + if (authorityWithPath.isEmpty()) { + return false + } + val userInfoSeparatorIndex = authorityWithPath.indexOf('@') + if (userInfoSeparatorIndex >= 0) { + if (!isValidUserInfo(authorityWithPath.substring(0, userInfoSeparatorIndex))) { + return false + } + } + val ipV6EndIndex = authorityWithPath.lastIndexOf(']') + val portSeparatorIndex = + authorityWithPath.indexOf( + ':', + startIndex = + when { + ipV6EndIndex > 0 -> ipV6EndIndex + userInfoSeparatorIndex > 0 -> userInfoSeparatorIndex + else -> 0 + }, + ) + + val segmentSeparatorIndex = authorityWithPath.indexOf('/') + val hostEndIndex = + when { + portSeparatorIndex > 0 -> portSeparatorIndex + segmentSeparatorIndex > 0 -> segmentSeparatorIndex + else -> authorityWithPath.length + } + val hostStartIndex = + if (userInfoSeparatorIndex >= 0) { + userInfoSeparatorIndex + 1 + } else { + 0 + } + val host = authorityWithPath.substring(hostStartIndex, hostEndIndex) + if (!isValidHost(host)) { + return false + } + if (portSeparatorIndex > 0 && portSeparatorIndex < authorityWithPath.lastIndex) { + val portEndIndex = + if (segmentSeparatorIndex > 0) { + segmentSeparatorIndex + } else { + authorityWithPath.length + } + // empty port part + return isValidPort(authorityWithPath.substring(portSeparatorIndex + 1, portEndIndex)) + } + return segmentSeparatorIndex < 0 || isValidSegments(authorityWithPath.substring(segmentSeparatorIndex)) + } + + private fun isValidPort(port: String): Boolean { + if (port.isEmpty()) { + return true + } + + for (ch in port) { + if (!isDigit(ch)) { + return false + } + } + + return true + } + + private fun isValidHost(host: String): Boolean { + if (host.isEmpty()) { + return false + } + if (IpV4FormatValidator.validate(host).isValid()) { + return true + } + if (host.startsWith('[') && host.endsWith(']')) { + val substr = host.substring(1, host.lastIndex) + return IpV6FormatValidator.validate(substr).isValid() || isValidIPvFuture(substr) + } + return isRegName(host) + } + + @Suppress("detekt:ReturnCount") + private fun isValidIPvFuture(ipVFuture: String): Boolean { + if (ipVFuture.isEmpty()) { + return false + } + if (ipVFuture[0] != 'v') { + return false + } + val dotIndex = ipVFuture.indexOf('.') + if (dotIndex < 0) { + return false + } + val firstPart = ipVFuture.substring(1, dotIndex) + val secondPart = ipVFuture.substring(dotIndex + 1) + if (firstPart.isEmpty() || secondPart.isEmpty()) { + return false + } + for (ch in firstPart) { + if (isHexDigit(ch)) { + continue + } + return false + } + for (ch in secondPart) { + if (isUnreserved(ch) || isSubDelimiter(ch) || ch == ':') { + continue + } + return false + } + return true + } + + private fun isRegName(host: String): Boolean = + hasValidCharsOrPctEncoded(host) { + isSubDelimiter(it) || isUnreserved(it) + } + + private fun isValidUserInfo(userInfo: String): Boolean = + hasValidCharsOrPctEncoded(userInfo) { + it == ':' || isSubDelimiter(it) || isUnreserved(it) + } + + fun isValidSchema(schema: String): Boolean { + if (schema.isEmpty()) { + return false + } + + if (!isAlpha(schema[0])) { + return false + } + + for (i in 1..schema.lastIndex) { + val char = schema[i] + @Suppress("detekt:ComplexCondition") + if (isAlpha(char) || isDigit(char) || char == '+' || char == '-' || char == '.') { + continue + } + return false + } + + return true + } + + private fun isValidFragmentOrQuery(part: String): Boolean { + if (part.isEmpty()) { + return true + } + + return hasValidCharsOrPctEncoded(part) { + it == '/' || it == '?' || isPChar(it) + } + } + + private fun hasOnlyPChars(part: String): Boolean = hasValidCharsOrPctEncoded(part, ::isPChar) + + inline fun hasValidCharsOrPctEncoded( + part: String, + isValidChar: (Char) -> Boolean, + ): Boolean { + var i = 0 + var valid = true + while (i < part.length) { + val char = part[i] + if (char != '%' && !isValidChar(char)) { + valid = false + break + } + if (char == '%') { + if (!isPctEncoded(i, part)) { + valid = false + break + } + i += 2 + } + i += 1 + } + + return valid + } + + fun isPctEncoded( + index: Int, + str: String, + ): Boolean { + if (index + 2 >= str.length) { + return false + } + return str[index] == '%' && isHexDigit(str[index + 1]) && isHexDigit(str[index + 2]) + } + + fun isAlpha(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z' + + fun isDigit(c: Char): Boolean = c in '0'..'9' + + private fun isPChar(c: Char): Boolean = isUnreserved(c) || isSubDelimiter(c) || c == ':' || c == '@' + + private fun isUnreserved(c: Char): Boolean = isAlpha(c) || isDigit(c) || c == '_' || c == '-' || c == '.' || c == '~' + + private fun isSubDelimiter(c: Char): Boolean = + c == '!' || c == '$' || c == '&' || c == '\'' || c == '(' || c == ')' || + c == '*' || c == '+' || c == ',' || c == ';' || c == '=' + + private fun isHexDigit(c: Char): Boolean = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' +} \ No newline at end of file diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriTemplateFormatValidator.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriTemplateFormatValidator.kt new file mode 100644 index 00000000..230c3855 --- /dev/null +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/formats/UriTemplateFormatValidator.kt @@ -0,0 +1,203 @@ +package io.github.optimumcode.json.schema.internal.formats + +import de.cketti.codepoints.CodePoints +import de.cketti.codepoints.codePointAt +import io.github.optimumcode.json.schema.FormatValidationResult +import io.github.optimumcode.json.schema.FormatValidator + +internal object UriTemplateFormatValidator : AbstractStringFormatValidator() { + private const val EXPRESSION_START = '{'.code + private const val EXPRESSION_END = '}'.code + private const val PCT_ENCODING_START = '%'.code + private const val EXPLODE_MODIFIER = '*' + private const val PREFIX_MODIFIER = ':' + private const val MAX_LENGTH_UPPER_LIMIT = 4 + + @Suppress("detekt:ReturnCount") + override fun validate(value: String): FormatValidationResult { + if (value.isEmpty()) { + return FormatValidator.Valid() + } + var index = 0 + var expressionStartIndex = -1 + var inExpression = false + while (index < value.length) { + val code = value.codePointAt(index) + when { + code == EXPRESSION_START -> { + if (inExpression) { + return FormatValidator.Invalid() + } + inExpression = true + expressionStartIndex = index + } + code == EXPRESSION_END -> { + if (!inExpression) { + return FormatValidator.Invalid() + } + inExpression = false + if (!isValidExpression(value.substring(expressionStartIndex + 1, index))) { + return FormatValidator.Invalid() + } + } + !inExpression -> + if (!isValidLiteral(code, value, index)) { + return FormatValidator.Invalid() + } + } + index += CodePoints.charCount(code) + } + return if (inExpression) { + FormatValidator.Invalid() + } else { + FormatValidator.Valid() + } + } + + private fun isValidLiteral( + code: Int, + value: String, + index: Int, + ): Boolean { + if (isSimpleChar(code)) { + return true + } + if (code == PCT_ENCODING_START && UriSpec.isPctEncoded(index, value)) { + return true + } + return isUcsChar(code) || isIPrivate(code) + } + + @Suppress("detekt:MagicNumber") + private fun isSimpleChar(code: Int) = + code == 0x21 || code in 0x23..0x24 || code in 0x26..0x3B || + code == 0x3D || code in 0x3F..0x5B || code == 0x5D || code == 0x5F || + code in 0x61..0x7A || code == 0x7E + + private fun isIPrivate(code: Int): Boolean = + @Suppress("detekt:MagicNumber") + when (code) { + in 0xE000..0xF8FF, + in 0xF0000..0xFFFFD, + in 0x100000..0x10FFFD, + -> true + else -> false + } + + private fun isUcsChar(code: Int): Boolean = + @Suppress("detekt:MagicNumber") + when (code) { + in 0xA0..0xD7FF, + in 0xF900..0xFDCF, + in 0xFDF0..0xFFEF, + in 0x10000..0x1FFFD, + in 0x20000..0x2FFFD, + in 0x30000..0x3FFFD, + in 0x40000..0x4FFFD, + in 0x50000..0x5FFFD, + in 0x60000..0x6FFFD, + in 0x70000..0x7FFFD, + in 0x80000..0x8FFFD, + in 0x90000..0x9FFFD, + in 0xA0000..0xAFFFD, + in 0xB0000..0xBFFFD, + in 0xC0000..0xCFFFD, + in 0xD0000..0xDFFFD, + in 0xE1000..0xEFFFD, + -> true + else -> false + } + + private fun isValidExpression(expression: String): Boolean { + if (expression.isEmpty()) { + return false + } + val varList = + if (isOperator(expression[0])) { + expression.substring(1) + } else { + expression + } + return eachSeparatedPart(varList, separator = ',', ::isValidVarSpec) + } + + private inline fun eachSeparatedPart( + value: String, + separator: Char, + isValid: (String) -> Boolean, + ): Boolean { + var lastSeparator = -1 + do { + val separatorIndex = value.indexOf(separator, startIndex = lastSeparator + 1) + val part = + if (separatorIndex < 0) { + value.substring(lastSeparator + 1) + } else { + value.substring(lastSeparator + 1, separatorIndex) + } + if (!isValid(part)) { + return false + } + lastSeparator = separatorIndex + } while (separatorIndex > 0) + return true + } + + private fun isValidVarSpec(varSpec: String): Boolean { + if (varSpec.isEmpty()) { + return false + } + + val prefixModifierIndex = varSpec.indexOf(PREFIX_MODIFIER) + val varName: String = + when { + prefixModifierIndex >= 0 -> + varSpec.substring(0, prefixModifierIndex) + varSpec.endsWith(EXPLODE_MODIFIER) -> + varSpec.substring(0, varSpec.length - 1) + else -> + varSpec + } + + if (varName.isEmpty()) { + return false + } + + if (prefixModifierIndex > 0) { + if (prefixModifierIndex == varSpec.lastIndex || !isValidMaxLength(varSpec.substring(prefixModifierIndex + 1))) { + return false + } + } + + return eachSeparatedPart(varName, separator = '.') { part -> + part.isNotEmpty() && + UriSpec.hasValidCharsOrPctEncoded(part) { + UriSpec.isAlpha(it) || UriSpec.isDigit(it) || it == '_' + } + } + } + + private fun isValidMaxLength(maxLength: String): Boolean { + if (maxLength[0] == '0') { + // no leading zeroes allowed + return false + } + if (maxLength.length > MAX_LENGTH_UPPER_LIMIT) { + // to long value + return false + } + return maxLength.all(UriSpec::isDigit) + } + + private fun isOperator(char: Char): Boolean = + when (char) { + // op-level2 + '+', '#', + // op-level3 + '.', '/', ';', '?', '&', + // op-reserve + '=', ',', '!', '@', '|', + -> true + else -> false + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaIriFormatValidationTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaIriFormatValidationTest.kt new file mode 100644 index 00000000..68478a16 --- /dev/null +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaIriFormatValidationTest.kt @@ -0,0 +1,25 @@ +package io.github.optimumcode.json.schema.assertions.general.format + +import io.kotest.core.spec.style.FunSpec + +class JsonSchemaIriFormatValidationTest : FunSpec() { + init { + formatValidationTestSuite( + format = "iri", + validTestCases = + listOf( + "https://example.com/test?query#fragment", + "https://путь.рф", + "ftp:/абсолютный_путь", + "https://exceed\u0080maxbyte", + ), + invalidTestCases = + listOf( + TestCase("", "empty"), + TestCase("нет-схемы", "missing schema"), + TestCase("https://\u0000zero", "zero byte is not pct encoded and invalid"), + TestCase("https://\u007f127", "127 byte is not pct encoded and invalid"), + ), + ) + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaIriReferenceFormatValidationTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaIriReferenceFormatValidationTest.kt new file mode 100644 index 00000000..41bf680d --- /dev/null +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaIriReferenceFormatValidationTest.kt @@ -0,0 +1,23 @@ +package io.github.optimumcode.json.schema.assertions.general.format + +import io.kotest.core.spec.style.FunSpec + +class JsonSchemaIriReferenceFormatValidationTest : FunSpec() { + init { + formatValidationTestSuite( + format = "iri-reference", + validTestCases = + listOf( + "", + "/localhost?query=5#fragment", + "/относительный_референс?квери#фрагмент", + ), + invalidTestCases = + listOf( + TestCase("schema:", "schema"), + TestCase("?квер[и", "invalid query"), + TestCase("#фрагме[нт", "invalid fragment"), + ), + ) + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaUriFormatValidationTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaUriFormatValidationTest.kt new file mode 100644 index 00000000..5ab13011 --- /dev/null +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaUriFormatValidationTest.kt @@ -0,0 +1,50 @@ +package io.github.optimumcode.json.schema.assertions.general.format + +import io.kotest.core.spec.style.FunSpec + +class JsonSchemaUriFormatValidationTest : FunSpec() { + init { + formatValidationTestSuite( + format = "uri", + validTestCases = + listOf( + "https://example.com:443/", + "https://example.com:443", + "https://example.com", + "https://[v6.fe80::a_(en1)]", + "ftp:/absolute/schema", + "https://locahost/%20%4d%2F", + "https://locahost:", + "https://locahost:/", + "https://localhost?", + "https://localhost#", + "h://localhost", + "https://locahost#frag?ment", + ), + invalidTestCases = + listOf( + TestCase("", "empty"), + TestCase("https:///", "empty hostname"), + TestCase("2http://localhost", "invalid schema"), + TestCase("https://example.com:44a/", "invalid port"), + TestCase("https:", "only schema"), + TestCase("https://example.com?invalid=[", "invalid query"), + TestCase("https://example.com#invalid[Fragment", "invalid fragment"), + TestCase("https://te[st@localhost", "invalid username"), + TestCase("https://%2G@localhost", "invalid pct encoded in userinfo"), + TestCase("https://%2locahost", "invalid pct encoded in host"), + TestCase("https://locahost/test%2", "invalid pct encoded in last segment"), + TestCase("https://locahost/test%2/t", "invalid pct encoded in segment"), + TestCase("https://[v6.fe80::a_(en1)", "invalid ip feature hostname"), + TestCase("https://[6.fe80::a_(en1)]", "invalid start for ip feature hostname"), + TestCase("https://[v6fe80::a_(en1)]", "no dot for ip feature hostname"), + TestCase("https://[vG.fe80::a_(en1)]", "invalid first part for ip feature hostname"), + TestCase("https://[v6.fe80::a[(en1)]", "invalid second part for ip feature hostname"), + TestCase("https://[v.]", "empty parts in ip feature hostname"), + TestCase("https://[v1.]", "empty first part in ip feature hostname"), + TestCase("https://[v.a]", "empty second part in ip feature hostname"), + TestCase("https://[]", "empty ip feature hostname"), + ), + ) + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaUriReferenceFormatValidationTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaUriReferenceFormatValidationTest.kt new file mode 100644 index 00000000..11e38333 --- /dev/null +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaUriReferenceFormatValidationTest.kt @@ -0,0 +1,31 @@ +package io.github.optimumcode.json.schema.assertions.general.format + +import io.kotest.core.spec.style.FunSpec + +class JsonSchemaUriReferenceFormatValidationTest : FunSpec() { + init { + formatValidationTestSuite( + format = "uri-reference", + validTestCases = + listOf( + "", + "/localhost?query=5#fragment", + "//user@localhost?query=3#fragment", + "//user@localhost:42/?query=3#fragment", + "?query=3#fragment", + "?que/?ry@=(4:2)", + "#fr@/?gment", + "noschem@/test%20", + "te(t", + "?", + "#", + ), + invalidTestCases = + listOf( + TestCase("schema:", "schema"), + TestCase("?quer[ry", "invalid query"), + TestCase("#fragme[t", "invalid fragment"), + ), + ) + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaUriTemplateFormatValidationTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaUriTemplateFormatValidationTest.kt new file mode 100644 index 00000000..5ebf15f2 --- /dev/null +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/format/JsonSchemaUriTemplateFormatValidationTest.kt @@ -0,0 +1,40 @@ +package io.github.optimumcode.json.schema.assertions.general.format + +import io.kotest.core.spec.style.FunSpec + +class JsonSchemaUriTemplateFormatValidationTest : FunSpec() { + init { + formatValidationTestSuite( + format = "uri-template", + validTestCases = + listOf( + "", + "https://example.com/{test}", + "https://example.com/{test:1}", + "https://test{?query*,number:9999}", + "https://simple.uri", + "https://test%20uri.com", + "https://testname/{first%20name}", + "https://testname/{first.name}", + "https://\u00a0\ud7ff\uf900\ufdcf\ufdf0\uffef\uf8ff", + ), + invalidTestCases = + listOf( + TestCase("https://example.com/{}", "empty expression"), + TestCase("https://example.com/{,}", "empty expression with var delimiter"), + TestCase("https://example.com/{test.}", "empty expression with name delimiter"), + TestCase("https://example.com/}", "end expression without start"), + TestCase("https://example.com/{t{e}st}", "expression inside expression"), + TestCase("https://example.com/{test:0}", "leading zero"), + TestCase("https://example.com/{test:10000}", "out of limit max length"), + TestCase("https://example.com/{test:-999}", "negative max length"), + TestCase("https://example.com/{:-999}", "no var before max length"), + TestCase("https://\udfffexample.com", "invalid literal"), + TestCase("https://exa%2Gmple.com", "invalid pct encoded literal"), + TestCase("https://example.com/{te%2Gst}", "invalid pct encoded var name"), + TestCase("https://example.com/{test:}", "empty max length in the end"), + TestCase("https://example.com/{test:,another}", "empty max length in the middle"), + ), + ) + } +} \ No newline at end of file diff --git a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt index 78023939..89b90f95 100644 --- a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt +++ b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt @@ -49,12 +49,7 @@ internal val COMMON_FORMAT_FILTER = mapOf( "email" to emptySet(), "idn-email" to emptySet(), - "iri" to emptySet(), - "iri-reference" to emptySet(), "regex" to emptySet(), - "uri" to emptySet(), - "uri-reference" to emptySet(), - "uri-template" to emptySet(), ), )