Skip to content
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

Contracts pipeline fix #1458

Closed
wants to merge 16 commits into from
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ abstract class BackwardCompatibilityCheckBaseCommand : Callable<Unit> {
).filter {
File(it).exists() && File(it).isValidSpec()
}.toSet().also {
if(it.isEmpty()) exitWithMessage("No specs were changed, skipping the check.")
it.takeIf { it.isEmpty() }?.run {
logger.log("$newLine No specs were changed, skipping the check.$newLine")
exitProcess(0)
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ dependencies {

testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version"
implementation 'com.github.mifmif:generex:1.0.2'

implementation 'dk.brics:automaton:1.12-1'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
testImplementation 'org.assertj:assertj-core:3.26.3'
testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
Expand Down
80 changes: 57 additions & 23 deletions core/src/main/kotlin/io/specmatic/core/pattern/StringPattern.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.specmatic.core.pattern

import com.mifmif.common.regex.Generex
import dk.brics.automaton.Automaton
import dk.brics.automaton.RegExp
import io.specmatic.core.Resolver
import io.specmatic.core.Result
import io.specmatic.core.mismatchResult
Expand All @@ -11,7 +13,7 @@ import io.specmatic.core.value.Value
import java.nio.charset.StandardCharsets
import java.util.*

data class StringPattern (
data class StringPattern(
override val typeAlias: String? = null,
val minLength: Int? = null,
val maxLength: Int? = null,
Expand All @@ -22,6 +24,35 @@ data class StringPattern (
if (minLength != null && maxLength != null && minLength > maxLength) {
throw IllegalArgumentException("maxLength cannot be less than minLength")
}
regex?.let {
val regexWithoutCaretAndDollar = it.removePrefix("^").removeSuffix("$")
regexMinLengthValidation(regexWithoutCaretAndDollar)
regexMaxLengthValidation(regexWithoutCaretAndDollar)
}

}

private fun regexMinLengthValidation(it: String) {
val automaton = RegExp(it).toAutomaton()

minLength?.let { minLen ->
val min = automaton.getShortestExample(true).length
if (min < minLen) {
throw IllegalArgumentException("Invalid Regex - min cannot be less than regex least size")
} else if (maxLength != null && min > maxLength) {
throw IllegalArgumentException("Invalid Regex - min cannot be more than regex max size")
}
}
}

private fun regexMaxLengthValidation(it: String) {
maxLength?.let { maxLen ->
val generatedString = generateFromRegex(it, maxLen+1)

if (generatedString.length > maxLen) {
throw IllegalArgumentException("Invalid Regex - max cannot be more than regex max size")
}
}
}

override fun matches(sampleData: Value?, resolver: Resolver): Result {
Expand All @@ -40,7 +71,7 @@ data class StringPattern (
sampleData, resolver.mismatchMessages
)

if(regex != null && !Regex(regex).matches(sampleData.toStringLiteral())) {
if (regex != null && !Regex(regex).matches(sampleData.toStringLiteral())) {
return mismatchResult(
"""string that matches regex /$regex/""",
sampleData,
Expand All @@ -50,6 +81,7 @@ data class StringPattern (

return Result.Success()
}

else -> mismatchResult("string", sampleData, resolver.mismatchMessages)
}
}
Expand All @@ -73,30 +105,23 @@ data class StringPattern (
maxLength != null && 5 > maxLength -> maxLength
else -> 5
}

override fun generate(resolver: Resolver): Value {
val defaultExample: Value? = resolver.resolveExample(example, this)

if (regex != null) {
if(defaultExample == null)
return StringValue(Generex(regex.removePrefix("^").removeSuffix("$")).random(randomStringLength))

val defaultExampleMatchResult = matches(defaultExample, resolver)

if(defaultExampleMatchResult.isSuccess())
return defaultExample

throw ContractException("Schema example ${defaultExample.toStringLiteral()} does not match pattern $regex")
}

if(defaultExample != null) {
if(defaultExample !is StringValue)
throw ContractException("Schema example ${defaultExample.toStringLiteral()} is not a string")
override fun generate(resolver: Resolver): Value {
val defaultExample = resolver.resolveExample(example, this)

return defaultExample
defaultExample?.let {
if (matches(it, resolver).isSuccess()) {
return it
}
throw ContractException("Schema example ${it.toStringLiteral()} does not match pattern $regex")
}

return StringValue(randomString(randomStringLength))
return regex?.let {
val regexWithoutCaretAndDollar = regex.removePrefix("^").removeSuffix("$")
regexMinLengthValidation(regexWithoutCaretAndDollar)
regexMaxLengthValidation(regexWithoutCaretAndDollar)
StringValue(generateFromRegex(regexWithoutCaretAndDollar, randomStringLength, maxLength))
} ?: StringValue(randomString(randomStringLength))
}

override fun newBasedOn(row: Row, resolver: Resolver): Sequence<ReturnValue<Pattern>> {
Expand All @@ -115,7 +140,11 @@ data class StringPattern (

override fun newBasedOn(resolver: Resolver): Sequence<Pattern> = sequenceOf(this)

override fun negativeBasedOn(row: Row, resolver: Resolver, config: NegativePatternConfiguration): Sequence<ReturnValue<Pattern>> {
override fun negativeBasedOn(
row: Row,
resolver: Resolver,
config: NegativePatternConfiguration
): Sequence<ReturnValue<Pattern>> {
val current = this

return sequence {
Expand Down Expand Up @@ -157,6 +186,11 @@ data class StringPattern (

override val pattern: Any = "(string)"
override fun toString(): String = pattern.toString()

private fun generateFromRegex(regexWithoutCaretAndDollar: String, minLength: Int, maxLength: Int? = null): String =
maxLength?.let {
Generex(regexWithoutCaretAndDollar).random(minLength, it)
} ?: Generex(regexWithoutCaretAndDollar).random(minLength)
}

fun randomString(length: Int = 5): String {
Expand Down
13 changes: 2 additions & 11 deletions core/src/test/kotlin/io/specmatic/conversions/RegexSupportTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class RegexSupportTest {

@Test
fun `invalid regex results in exception`() {
assertThatThrownBy {
val feature = OpenApiSpecification.fromYAML(
"""
---
Expand All @@ -46,17 +47,7 @@ class RegexSupportTest {
204:
description: "Get person by id"
content: {}
""".trimIndent(), "").toFeature()

val executor = object : TestExecutor {
override fun execute(request: HttpRequest): HttpResponse {
return HttpResponse(204)
}
}

assertThatThrownBy {
feature.executeTests(executor)
}.satisfies(Consumer {
""".trimIndent(), "").toFeature()}.satisfies(Consumer {
assertThat(it).isInstanceOf(ContractException::class.java)
})
}
Expand Down
97 changes: 81 additions & 16 deletions core/src/test/kotlin/io/specmatic/core/pattern/StringPatternTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,28 +72,47 @@ internal class StringPatternTest {

companion object {
@JvmStatic
fun lengthTestValues(): Stream<Arguments> {
fun minLengthMaxLengthAndExpectedLength(): Stream<Arguments> {
return Stream.of(
Arguments.of(null, 10, 5),
Arguments.of(null, 4, 4),
Arguments.of(1, 10, 5),
Arguments.of(1, 4, 4),
Arguments.of(1, 5, 5),
Arguments.of(null, 4, 1),
Arguments.of(1, 10, 1),
Arguments.of(5, 10, 5),
Arguments.of(6, 10, 6),
Arguments.of(6, null, 6),
Arguments.of(3, null, 5),
Arguments.of(null, null, 5)
)
}

@JvmStatic
fun regexMinLengthAndMaxLengthAndExpectedLength(): Stream<Arguments> {
return Stream.of(
Arguments.of("^[a-z]*\$", null, null, 5),
Arguments.of("^[a-z0-9]{6,10}\$", 6, 10, 6),
Arguments.of(null, 1, 10, 1),
)
}
}

@ParameterizedTest
@MethodSource("lengthTestValues")
fun `generate string value of appropriate length matching minLength and maxLength parameters`(min: Int?, max: Int?, length: Int) {
@MethodSource("minLengthMaxLengthAndExpectedLength")
fun `generate string value as per minLength and maxLength`(min: Int?, max: Int?, expectedLengthOfGeneratedValue: Int) {
val result = StringPattern(minLength = min, maxLength = max).generate(Resolver()) as StringValue
val generatedLength = result.string.length

assertThat(generatedLength).isGreaterThanOrEqualTo(expectedLengthOfGeneratedValue)
max?.let { assertThat(generatedLength).isLessThanOrEqualTo(it) }
}

assertThat(result.string.length).isEqualTo(length)
@ParameterizedTest
@MethodSource("regexMinLengthAndMaxLengthAndExpectedLength")
fun `generate string value as per regex in conjunction with minLength and maxLength`(regex: String?, min: Int?, max: Int?, expectedLength: Int) {
val result = StringPattern(minLength = min, maxLength = max, regex = regex).generate(Resolver()) as StringValue
val generatedString = result.string
val generatedLength = generatedString.length

assertThat(generatedLength).isGreaterThanOrEqualTo(expectedLength)
max?.let { assertThat(generatedLength).isLessThanOrEqualTo(it) }
regex?.let { assertThat(generatedString).matches(regex) }
}

@Test
Expand Down Expand Up @@ -180,32 +199,70 @@ internal class StringPatternTest {
@Test
@Tag(GENERATION)
fun `negative value for regex should be generated when regex is provided`() {
val minLength = 10
val minLength = 2
val maxLength = 20

val result = StringPattern(
minLength = minLength,
maxLength = maxLength,
regex = "^[^0-9]*$"
regex = "^[^0-9]{15}$"
).negativeBasedOn(Row(), Resolver()).map { it.value }.toList()

assertThat(
result.filterIsInstance<StringPattern>().filter {
it.regex == "^[^0-9]*\$_"
it.regex == "^[^0-9]{15}\$_"
}
).hasSize(1)
}

@Test
@Tag(GENERATION)
fun `regex sould throw validation issue for patterns less than min size as per api contract`() {
val minLength = 2
val maxLength = 15

val result = runCatching {
StringPattern(
minLength = minLength,
maxLength = maxLength,
regex = "^.{0,4}$"
).negativeBasedOn(Row(), Resolver()).map { it.value }.toList()
}

result.onFailure { exception ->
assertThat(exception.message).isEqualTo("Invalid Regex - min cannot be less than regex least size")
}
}

@Test
@Tag(GENERATION)
fun `regex sould throw validation issue for patterns more than max size as per api contract`() {
val minLength = 2
val maxLength = 15

val result = runCatching {
StringPattern(
minLength = minLength,
maxLength = maxLength,
regex = "^.{2,14}$"
).negativeBasedOn(Row(), Resolver()).map { it.value }.toList()
}

result.onFailure { exception ->
assertThat(exception.message).isEqualTo("Invalid Regex - max cannot be more than regex max size")
}
}

@Test
@Tag(GENERATION)
fun `should exclude data type based negatives when withDataTypeNegatives config is false`() {
val minLength = 10
val minLength = 2
val maxLength = 20

val result = StringPattern(
minLength = minLength,
maxLength = maxLength,
regex = "^[^0-9]*$"
regex = "^[^0-9]{15}$"
).negativeBasedOn(
Row(),
Resolver(),
Expand All @@ -220,7 +277,7 @@ internal class StringPatternTest {
).hasSize(0)

assertThat(
result.filterIsInstance<StringPattern>().filter { it.regex == "^[^0-9]*\$_" }
result.filterIsInstance<StringPattern>().filter { it.regex == "^[^0-9]{15}\$_" }
).hasSize(1)

assertThat(
Expand All @@ -240,4 +297,12 @@ internal class StringPatternTest {
fun `string pattern encompasses email`() {
assertThat(StringPattern().encompasses(EmailPattern(), Resolver(), Resolver())).isInstanceOf(Result.Success::class.java)
}

@Test
fun `should fail to generate string when maxLength is less than minLength`() {
val exception = assertThrows<IllegalArgumentException> {
StringPattern(minLength = 6, maxLength = 4)
}
assertThat(exception.message).isEqualTo("maxLength cannot be less than minLength")
}
}