Skip to content

Commit

Permalink
fix: Update matching rule loading code to support correct + incorrect…
Browse files Browse the repository at this point in the history
… formatted date/time matchers #1617
  • Loading branch information
rholshausen committed Dec 23, 2022
1 parent 45c4cb4 commit aba556c
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 65 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package au.com.dius.pact.core.model.matchingrules

import au.com.dius.pact.core.model.ContentType
import au.com.dius.pact.core.model.PactSpecVersion
import au.com.dius.pact.core.support.Utils.toInt
import mu.KLogging
import java.lang.IllegalArgumentException

Expand All @@ -16,8 +17,94 @@ enum class RuleLogic {
* Matching rule
*/
interface MatchingRule {
/**
* Converts this rule into a Map that can be serialised to JSON
*/
fun toMap(spec: PactSpecVersion): Map<String, Any?>

/**
* If this rule can be applied to the content type
*/
fun canMatch(contentType: ContentType): Boolean = false


companion object : KLogging() {
private const val MATCH = "match"
private const val MIN = "min"
private const val MAX = "max"
private const val REGEX = "regex"
private const val TIMESTAMP = "timestamp"
private const val TIME = "time"
private const val DATE = "date"

@JvmStatic
fun ruleFromMap(map: Map<String, Any?>): MatchingRule {
return when {
map.containsKey(MATCH) -> create(map[MATCH].toString(), map)
map.containsKey(REGEX) -> RegexMatcher(map[REGEX].toString())
map.containsKey(MIN) -> MinTypeMatcher(toInt(map[MIN]))
map.containsKey(MAX) -> MaxTypeMatcher(toInt(map[MAX]))
map.containsKey(TIMESTAMP) -> TimestampMatcher(map[TIMESTAMP].toString())
map.containsKey(TIME) -> TimeMatcher(map[TIME].toString())
map.containsKey(DATE) -> DateMatcher(map[DATE].toString())
else -> {
MatchingRuleGroup.logger.warn { "Unrecognised matcher definition $map, defaulting to equality matching" }
EqualsMatcher
}
}
}

@Suppress("LongMethod", "ComplexMethod")
fun create(type: String, values: Map<String, Any?>): MatchingRule {
logger.trace { "MatchingRule.create($type, $values)" }
return when (type) {
REGEX -> RegexMatcher(values[REGEX].toString())
"equality" -> EqualsMatcher
"null" -> NullMatcher
"include" -> IncludeMatcher(values["value"].toString())
"type" -> ruleForType(values)
"number" -> NumberTypeMatcher(NumberTypeMatcher.NumberType.NUMBER)
"integer" -> NumberTypeMatcher(NumberTypeMatcher.NumberType.INTEGER)
"decimal" -> NumberTypeMatcher(NumberTypeMatcher.NumberType.DECIMAL)
"real" -> {
MatchingRuleGroup.logger.warn { "The 'real' type matcher is deprecated, use 'decimal' instead" }
NumberTypeMatcher(NumberTypeMatcher.NumberType.DECIMAL)
}
MIN -> MinTypeMatcher(toInt(values[MIN]))
MAX -> MaxTypeMatcher(toInt(values[MAX]))
TIMESTAMP, "datetime" ->
if (values.containsKey("format")) TimestampMatcher(values["format"].toString())
else if (values.containsKey("timestamp")) TimestampMatcher(values["timestamp"].toString())
else TimestampMatcher()
TIME ->
if (values.containsKey("format")) TimeMatcher(values["format"].toString())
else if (values.containsKey("time")) TimeMatcher(values["time"].toString())
else TimeMatcher()
DATE ->
if (values.containsKey("format")) DateMatcher(values["format"].toString())
else if (values.containsKey("date")) DateMatcher(values["date"].toString())
else DateMatcher()
"values" -> ValuesMatcher
"contentType", "content-type" -> ContentTypeMatcher(values["value"].toString())
else -> {
MatchingRuleGroup.logger.warn { "Unrecognised matcher ${values[MATCH]}, defaulting to equality matching" }
EqualsMatcher
}
}
}

private fun ruleForType(map: Map<String, Any?>): MatchingRule {
return if (map.containsKey(MIN) && map.containsKey(MAX)) {
MinMaxTypeMatcher(toInt(map[MIN]), toInt(map[MAX]))
} else if (map.containsKey(MIN)) {
MinTypeMatcher(toInt(map[MIN]))
} else if (map.containsKey(MAX)) {
MaxTypeMatcher(toInt(map[MAX]))
} else {
TypeMatcher
}
}
}
}

/**
Expand Down Expand Up @@ -126,7 +213,7 @@ object ValuesMatcher : MatchingRule {
/**
* Content type matcher. Matches the content type of binary data
*/
data class ContentTypeMatcher @JvmOverloads constructor (val contentType: String) : MatchingRule {
data class ContentTypeMatcher(val contentType: String) : MatchingRule {
override fun toMap(spec: PactSpecVersion) = mapOf("match" to "contentType", "value" to contentType)
override fun canMatch(contentType: ContentType) = true
}
Expand Down Expand Up @@ -174,74 +261,12 @@ data class MatchingRuleGroup @JvmOverloads constructor(
return MatchingRuleGroup(rules, ruleLogic)
}

private const val MATCH = "match"
private const val MIN = "min"
private const val MAX = "max"
private const val REGEX = "regex"
private const val TIMESTAMP = "timestamp"
private const val TIME = "time"
private const val DATE = "date"

private fun mapEntryToInt(map: Map<String, Any?>, field: String) =
if (map[field] is Int) map[field] as Int
else Integer.parseInt(map[field]!!.toString())

@JvmStatic
fun ruleFromMap(map: Map<String, Any?>): MatchingRule {
return when {
map.containsKey(MATCH) -> when (map[MATCH]) {
REGEX -> RegexMatcher(map[REGEX] as String)
"equality" -> EqualsMatcher
"null" -> NullMatcher
"include" -> IncludeMatcher(map["value"].toString())
"type" -> {
if (map.containsKey(MIN) && map.containsKey(MAX)) {
MinMaxTypeMatcher(mapEntryToInt(map, MIN), mapEntryToInt(map, MAX))
} else if (map.containsKey(MIN)) {
MinTypeMatcher(mapEntryToInt(map, MIN))
} else if (map.containsKey(MAX)) {
MaxTypeMatcher(mapEntryToInt(map, MAX))
} else {
TypeMatcher
}
}
"number" -> NumberTypeMatcher(NumberTypeMatcher.NumberType.NUMBER)
"integer" -> NumberTypeMatcher(NumberTypeMatcher.NumberType.INTEGER)
"decimal" -> NumberTypeMatcher(NumberTypeMatcher.NumberType.DECIMAL)
"real" -> {
logger.warn { "The 'real' type matcher is deprecated, use 'decimal' instead" }
NumberTypeMatcher(NumberTypeMatcher.NumberType.DECIMAL)
}
MIN -> MinTypeMatcher(mapEntryToInt(map, MIN))
MAX -> MaxTypeMatcher(mapEntryToInt(map, MAX))
TIMESTAMP ->
if (map.containsKey(TIMESTAMP)) TimestampMatcher(map[TIMESTAMP].toString())
else TimestampMatcher()
TIME ->
if (map.containsKey(TIME)) TimeMatcher(map[TIME].toString())
else TimeMatcher()
DATE ->
if (map.containsKey(DATE)) DateMatcher(map[DATE].toString())
else DateMatcher()
"values" -> ValuesMatcher
"contentType" -> ContentTypeMatcher(map["value"].toString())
else -> {
logger.warn { "Unrecognised matcher ${map[MATCH]}, defaulting to equality matching" }
EqualsMatcher
}
}
map.containsKey(REGEX) -> RegexMatcher(map[REGEX] as String)
map.containsKey(MIN) -> MinTypeMatcher(mapEntryToInt(map, MIN))
map.containsKey(MAX) -> MaxTypeMatcher(mapEntryToInt(map, MAX))
map.containsKey(TIMESTAMP) -> TimestampMatcher(map[TIMESTAMP] as String)
map.containsKey(TIME) -> TimeMatcher(map[TIME] as String)
map.containsKey(DATE) -> DateMatcher(map[DATE] as String)
else -> {
logger.warn { "Unrecognised matcher definition $map, defaulting to equality matching" }
EqualsMatcher
}
}
}
fun ruleFromMap(map: Map<String, Any?>) = MatchingRule.ruleFromMap(map)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ class MatchingRuleGroupSpec extends Specification {
[match: 'values'] | ValuesMatcher | 'if the matcher type is values'
}

@Unroll
def 'from JSON'() {
expect:
MatchingRuleGroup.Companion.newInstance().fromMap(json) == value

where:

json | value
[:] | new MatchingRuleGroup()
[other: 'value'] | new MatchingRuleGroup()
[matchers: [[match: 'equality']]] | new MatchingRuleGroup([EqualsMatcher.INSTANCE])
[matchers: [[match: 'equality']], combine: 'AND'] | new MatchingRuleGroup([EqualsMatcher.INSTANCE])
[matchers: [[match: 'equality']], combine: 'OR'] | new MatchingRuleGroup([EqualsMatcher.INSTANCE], RuleLogic.OR)
[matchers: [[match: 'equality']], combine: 'BAD'] | new MatchingRuleGroup([EqualsMatcher.INSTANCE])
}

def 'defaults to AND for combining rules'() {
expect:
new MatchingRuleGroup().ruleLogic == RuleLogic.AND
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import au.com.dius.pact.core.support.Json
import au.com.dius.pact.core.support.json.JsonValue
import spock.lang.Issue
import spock.lang.Specification
import spock.lang.Unroll

class MatchingRulesSpec extends Specification {

Expand Down Expand Up @@ -123,6 +124,42 @@ class MatchingRulesSpec extends Specification {
])
}

@Unroll
@SuppressWarnings('LineLength')
def 'matchers fromJson returns #matcherClass.simpleName #condition'() {
expect:
MatchingRule.ruleFromMap(map).class == matcherClass

where:
map | matcherClass | condition
[:] | EqualsMatcher | 'if the definition is empty'
[other: 'value'] | EqualsMatcher | 'if the definition is invalid'
[match: 'something'] | EqualsMatcher | 'if the matcher type is unknown'
[match: 'equality'] | EqualsMatcher | 'if the matcher type is equality'
[match: 'regex', regex: '.*'] | RegexMatcher | 'if the matcher type is regex'
[regex: '\\w+'] | RegexMatcher | 'if the matcher definition contains a regex'
[match: 'type'] | TypeMatcher | 'if the matcher type is \'type\' and there is no min or max'
[match: 'number'] | NumberTypeMatcher | 'if the matcher type is \'number\''
[match: 'integer'] | NumberTypeMatcher | 'if the matcher type is \'integer\''
[match: 'real'] | NumberTypeMatcher | 'if the matcher type is \'real\''
[match: 'decimal'] | NumberTypeMatcher | 'if the matcher type is \'decimal\''
[match: 'type', min: 1] | MinTypeMatcher | 'if the matcher type is \'type\' and there is a min'
[match: 'min', min: 1] | MinTypeMatcher | 'if the matcher type is \'min\''
[min: 1] | MinTypeMatcher | 'if the matcher definition contains a min'
[match: 'type', max: 1] | MaxTypeMatcher | 'if the matcher type is \'type\' and there is a max'
[match: 'max', max: 1] | MaxTypeMatcher | 'if the matcher type is \'max\''
[max: 1] | MaxTypeMatcher | 'if the matcher definition contains a max'
[match: 'type', max: 3, min: 2] | MinMaxTypeMatcher | 'if the matcher definition contains both a min and max'
[match: 'timestamp'] | TimestampMatcher | 'if the matcher type is \'timestamp\''
[timestamp: '1'] | TimestampMatcher | 'if the matcher definition contains a timestamp'
[match: 'time'] | TimeMatcher | 'if the matcher type is \'time\''
[time: '1'] | TimeMatcher | 'if the matcher definition contains a time'
[match: 'date'] | DateMatcher | 'if the matcher type is \'date\''
[date: '1'] | DateMatcher | 'if the matcher definition contains a date'
[match: 'include', include: 'A'] | IncludeMatcher | 'if the matcher type is include'
[match: 'values'] | ValuesMatcher | 'if the matcher type is values'
}

@Issue('#743')
def 'loads matching rules affected by defect #743'() {
given:
Expand Down Expand Up @@ -182,4 +219,21 @@ class MatchingRulesSpec extends Specification {
]
}

@Unroll
def 'Loading Date/Time matchers'() {
expect:
MatchingRule.ruleFromMap(map) == matcher

where:
map | matcher
[match: 'timestamp'] | new TimestampMatcher()
[match: 'timestamp', timestamp: 'yyyy-MM-dd'] | new TimestampMatcher('yyyy-MM-dd')
[match: 'timestamp', format: 'yyyy-MM-dd'] | new TimestampMatcher('yyyy-MM-dd')
[match: 'date'] | new DateMatcher()
[match: 'date', date: 'yyyy-MM-dd'] | new DateMatcher('yyyy-MM-dd')
[match: 'date', format: 'yyyy-MM-dd'] | new DateMatcher('yyyy-MM-dd')
[match: 'time'] | new TimeMatcher()
[match: 'time', time: 'HH:mm'] | new TimeMatcher('HH:mm')
[match: 'time', format: 'HH:mm'] | new TimeMatcher('HH:mm')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import kotlin.reflect.full.declaredMemberProperties
/**
* Common utility functions
*/
@Suppress("TooManyFunctions")
object Utils : KLogging() {
/**
* Recursively extracts a sequence of keys from a recursive Map structure
Expand Down Expand Up @@ -66,7 +67,8 @@ object Utils : KLogging() {
}

/**
* Determines if the given port number is available. Does this by trying to open a socket and then immediately closing it.
* Determines if the given port number is available. Does this by trying to open a socket and then
* immediately closing it.
*/
fun portAvailable(p: Int): Boolean {
var socket: ServerSocket? = null
Expand Down Expand Up @@ -187,4 +189,20 @@ object Utils : KLogging() {
* Convert a value to snake-case form (a.b.c -> A_B_C)
*/
private fun snakeCase(key: String) = key.split('.').joinToString("_") { it.toUpperCase() }

/**
* Try to convert an any to an Int, throwing an exception if the conversion can't happen
*/
@Suppress("TooGenericExceptionThrown")
fun toInt(any: Any?): Int {
if (any == null) {
throw RuntimeException("Required an integer value, but got a NULL")
} else {
return when (any) {
is Number -> any.toInt()
is String -> any.toInt()
else -> throw RuntimeException("Required an integer value, but got a $any")
}
}
}
}

0 comments on commit aba556c

Please sign in to comment.