Skip to content

Commit

Permalink
Merge pull request #597 from tienvx/combined-matchers
Browse files Browse the repository at this point in the history
feat: Allow combining matchers
  • Loading branch information
tienvx authored May 10, 2024
2 parents 544eb79 + 623bb43 commit 4b15877
Show file tree
Hide file tree
Showing 15 changed files with 330 additions and 10 deletions.
19 changes: 19 additions & 0 deletions example/matchers/consumer/tests/Service/MatchersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,24 @@ public function testGetMatchers(): void
[$this->matcher->regex(null, 'car|bike|motorbike')]
),
'url' => $this->matcher->url('http://localhost:8080/users/1234/posts/latest', '.*(\\/users\\/\\d+\\/posts\\/latest)$', false),
'matchAll' => $this->matcher->matchAll(
['desktop' => '2000 usd'],
[
$this->matcher->eachKey(
['laptop' => '1500 usd'],
[$this->matcher->regex(null, 'laptop|desktop|mobile|tablet')]
),
$this->matcher->eachValue(
['mobile' => '500 usd'],
[
$this->matcher->includes('usd'),
$this->matcher->regex(null, '\d+ \w{3}')
],
),
$this->matcher->atLeast(2),
$this->matcher->atMost(3),
],
),

// Don't mind this. This is for demonstrating what query values provider will received.
'query' => [
Expand Down Expand Up @@ -199,6 +217,7 @@ public function testGetMatchers(): void
'vehicle 1' => 'car',
],
'url' => 'http://localhost:8080/users/1234/posts/latest',
'matchAll' => ['desktop' => '2000 usd'],

// Don't mind this. This is for demonstrating what query values provider will received.
'query' => [
Expand Down
40 changes: 40 additions & 0 deletions example/matchers/pacts/matchersConsumer-matchersProvider.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@
"likeInt": 13,
"likeNull": null,
"likeString": "some string",
"matchAll": {
"desktop": "2000 usd"
},
"notEmpty": [
"1",
"2",
Expand Down Expand Up @@ -529,6 +532,43 @@
}
]
},
"$.matchAll": {
"combine": "AND",
"matchers": [
{
"match": "eachKey",
"rules": [
{
"match": "regex",
"regex": "laptop|desktop|mobile|tablet"
}
],
"value": "{\"laptop\":\"1500 usd\"}"
},
{
"match": "eachValue",
"rules": [
{
"match": "include",
"value": "usd"
},
{
"match": "regex",
"regex": "\\d+ \\w{3}"
}
],
"value": "{\"mobile\":\"500 usd\"}"
},
{
"match": "type",
"min": 2
},
{
"match": "type",
"max": 3
}
]
},
"$.notEmpty": {
"combine": "AND",
"matchers": [
Expand Down
4 changes: 4 additions & 0 deletions example/matchers/provider/public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@
'item 2' => 'motorbike',
],
'url' => 'https://www.example.com/users/1234/posts/latest',
'matchAll' => [
'tablet' => '300 usd',
'laptop' => '1200 usd',
],
'query' => $request->getQueryParams(),
]));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace PhpPact\Consumer\Matcher\Formatters;

use PhpPact\Consumer\Matcher\Model\CombinedMatchersInterface;
use PhpPact\Consumer\Matcher\Model\MatcherInterface;

class CombinedMatchersFormatter extends ValueOptionalFormatter
{
/**
* @return array<string, mixed>
*/
public function format(MatcherInterface $matcher): array
{
if ($matcher instanceof CombinedMatchersInterface) {
return [
'pact:matcher:type' => $matcher->getMatchers(),
'value' => $matcher->getValue(),
];
}

return parent::format($matcher);
}
}
33 changes: 32 additions & 1 deletion src/PhpPact/Consumer/Matcher/Formatters/PluginFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
namespace PhpPact\Consumer\Matcher\Formatters;

use PhpPact\Consumer\Matcher\Exception\GeneratorNotRequiredException;
use PhpPact\Consumer\Matcher\Exception\InvalidValueException;
use PhpPact\Consumer\Matcher\Exception\MatcherNotSupportedException;
use PhpPact\Consumer\Matcher\Exception\MatchingExpressionException;
use PhpPact\Consumer\Matcher\Matchers\AbstractDateTime;
use PhpPact\Consumer\Matcher\Matchers\ContentType;
use PhpPact\Consumer\Matcher\Matchers\EachKey;
use PhpPact\Consumer\Matcher\Matchers\EachValue;
use PhpPact\Consumer\Matcher\Matchers\MatchAll;
use PhpPact\Consumer\Matcher\Matchers\MatchingField;
use PhpPact\Consumer\Matcher\Matchers\MaxType;
use PhpPact\Consumer\Matcher\Matchers\MinType;
use PhpPact\Consumer\Matcher\Matchers\NotEmpty;
use PhpPact\Consumer\Matcher\Matchers\NullValue;
use PhpPact\Consumer\Matcher\Matchers\Regex;
Expand Down Expand Up @@ -40,13 +44,22 @@ public function format(MatcherInterface $matcher): string
if ($matcher instanceof NullValue) {
return $this->formatMatchersWithoutConfig(new Type(null));
}
if ($matcher instanceof MinType) {
return $this->formatMinTypeMatcher($matcher);
}
if ($matcher instanceof MaxType) {
return $this->formatMaxTypeMatcher($matcher);
}

if (in_array($matcher->getType(), self::MATCHERS_WITHOUT_CONFIG)) {
return $this->formatMatchersWithoutConfig($matcher);
}
if ($matcher instanceof AbstractDateTime || $matcher instanceof Regex || $matcher instanceof ContentType) {
return $this->formatMatchersWithConfig($matcher);
}
if ($matcher instanceof MatchAll) {
return $this->formatMatchAllMatchers($matcher);
}

throw new MatcherNotSupportedException(sprintf("Matcher '%s' is not supported by plugin", $matcher->getType()));
}
Expand Down Expand Up @@ -90,10 +103,28 @@ private function formatEachKeyAndEachValueMatchers(EachKey|EachValue $matcher):
return sprintf('%s(%s)', $matcher->getType(), $this->format($rule));
}

private function formatMatchAllMatchers(MatchAll $matcher): string
{
return implode(', ', array_map(fn (MatcherInterface $rule) => $this->format($rule), $matcher->getMatchers()));
}

private function formatMinTypeMatcher(MinType $matcher): string
{
return sprintf('atLeast(%s)', $this->normalize($matcher->getAttributes()->get('min')));
}

private function formatMaxTypeMatcher(MaxType $matcher): string
{
return sprintf('atMost(%s)', $this->normalize($matcher->getAttributes()->get('max')));
}

private function normalize(mixed $value): string
{
if (is_string($value) && str_contains($value, "'")) {
throw new InvalidValueException(sprintf('String value "%s" should not contains single quote', $value));
}
return match (gettype($value)) {
'string' => sprintf("'%s'", str_replace("'", "\\'", $value)),
'string' => sprintf("'%s'", $value),
'boolean' => $value ? 'true' : 'false',
'integer' => (string) $value,
'double' => (string) $value,
Expand Down
23 changes: 21 additions & 2 deletions src/PhpPact/Consumer/Matcher/Matcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use PhpPact\Consumer\Matcher\Matchers\Equality;
use PhpPact\Consumer\Matcher\Matchers\Includes;
use PhpPact\Consumer\Matcher\Matchers\Integer;
use PhpPact\Consumer\Matcher\Matchers\MatchAll;
use PhpPact\Consumer\Matcher\Matchers\MatchingField;
use PhpPact\Consumer\Matcher\Matchers\MaxType;
use PhpPact\Consumer\Matcher\Matchers\MinMaxType;
Expand Down Expand Up @@ -426,7 +427,7 @@ public function contentType(string $contentType): MatcherInterface
* Allows defining matching rules to apply to the keys in a map
*
* @param array<string, mixed> $values
* @param array<mixed> $rules
* @param MatcherInterface[] $rules
*/
public function eachKey(array $values, array $rules): MatcherInterface
{
Expand All @@ -437,7 +438,7 @@ public function eachKey(array $values, array $rules): MatcherInterface
* Allows defining matching rules to apply to the values in a collection. For maps, delgates to the Values matcher.
*
* @param array<string, mixed> $values
* @param array<mixed> $rules
* @param MatcherInterface[] $rules
*/
public function eachValue(array $values, array $rules): MatcherInterface
{
Expand Down Expand Up @@ -466,6 +467,24 @@ public function matchingField(string $fieldName): MatcherInterface
return $this->withFormatter(new MatchingField($fieldName));
}

/**
* @param MatcherInterface[] $matchers
*/
public function matchAll(mixed $value, array $matchers): MatcherInterface
{
return $this->withFormatter(new MatchAll($value, $matchers));
}

public function atLeast(int $min): MatcherInterface
{
return $this->atLeastLike(null, $min);
}

public function atMost(int $max): MatcherInterface
{
return $this->atMostLike(null, $max);
}

private function withFormatter(MatcherInterface&FormatterAwareInterface $matcher): MatcherInterface
{
if ($this->formatter) {
Expand Down
42 changes: 42 additions & 0 deletions src/PhpPact/Consumer/Matcher/Matchers/CombinedMatchers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace PhpPact\Consumer\Matcher\Matchers;

use PhpPact\Consumer\Matcher\Exception\MatcherNotSupportedException;
use PhpPact\Consumer\Matcher\Formatters\CombinedMatchersFormatter;
use PhpPact\Consumer\Matcher\Model\CombinedMatchersInterface;
use PhpPact\Consumer\Matcher\Model\MatcherInterface;
use PhpPact\Consumer\Matcher\Trait\MatchersTrait;

abstract class CombinedMatchers extends AbstractMatcher implements CombinedMatchersInterface
{
use MatchersTrait;

/**
* @param array<mixed>|object $value
* @param MatcherInterface[] $matchers
*/
public function __construct(private object|array $value, array $matchers)
{
foreach ($matchers as $matcher) {
if ($matcher instanceof CombinedMatchersInterface) {
throw new MatcherNotSupportedException('Nested combined matchers are not supported');
}
$this->addMatcher($matcher);
}
$this->setFormatter(new CombinedMatchersFormatter());
}

protected function getAttributesData(): array
{
return [];
}

/**
* @return array<mixed>|object
*/
public function getValue(): object|array
{
return $this->value;
}
}
11 changes: 11 additions & 0 deletions src/PhpPact/Consumer/Matcher/Matchers/MatchAll.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace PhpPact\Consumer\Matcher\Matchers;

class MatchAll extends CombinedMatchers
{
public function getType(): string
{
return 'matchAll';
}
}
11 changes: 11 additions & 0 deletions src/PhpPact/Consumer/Matcher/Model/CombinedMatchersInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace PhpPact\Consumer\Matcher\Model;

interface CombinedMatchersInterface extends MatcherInterface
{
/**
* @return MatcherInterface[]
*/
public function getMatchers(): array;
}
26 changes: 26 additions & 0 deletions src/PhpPact/Consumer/Matcher/Trait/MatchersTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace PhpPact\Consumer\Matcher\Trait;

use PhpPact\Consumer\Matcher\Model\MatcherInterface;

trait MatchersTrait
{
/**
* @var MatcherInterface[]
*/
private array $matchers = [];

public function addMatcher(MatcherInterface $matcher): void
{
$this->matchers[] = $matcher;
}

/**
* @return MatcherInterface[]
*/
public function getMatchers(): array
{
return $this->matchers;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace PhpPactTest\Consumer\Matcher\Formatters;

use PhpPact\Consumer\Matcher\Formatters\CombinedMatchersFormatter;
use PhpPact\Consumer\Matcher\Matchers\EachKey;
use PhpPact\Consumer\Matcher\Matchers\EachValue;
use PhpPact\Consumer\Matcher\Matchers\Includes;
use PhpPact\Consumer\Matcher\Matchers\Integer;
use PhpPact\Consumer\Matcher\Matchers\MatchAll;
use PhpPact\Consumer\Matcher\Matchers\MaxType;
use PhpPact\Consumer\Matcher\Matchers\MinType;
use PHPUnit\Framework\TestCase;

class CombinedMatchersFormatterTest extends TestCase
{
public function testFormat(): void
{
$matcher = new MatchAll(['test 123' => 123], [
new MinType([], 1),
new MaxType([], 2),
new EachKey([], [new Includes('test')]),
new EachValue([], [new Integer(123)]),
]);
$formatter = new CombinedMatchersFormatter();
$jsonEncoded = json_encode($formatter->format($matcher));
$this->assertIsString($jsonEncoded);
$this->assertJsonStringEqualsJsonString('{"pact:matcher:type":[{"pact:matcher:type":"type","min":1,"value":[]},{"pact:matcher:type":"type","max":2,"value":[]},{"pact:matcher:type":"eachKey","rules":[{"pact:matcher:type":"include","value":"test"}],"value":[]},{"pact:matcher:type":"eachValue","rules":[{"pact:matcher:type":"integer","value":123}],"value":[]}],"value":{"test 123":123}}', $jsonEncoded);
}
}
Loading

0 comments on commit 4b15877

Please sign in to comment.