diff --git a/README.md b/README.md index 01e2112..ce02f5b 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,9 @@ In order to use unsupported serializer you should create a decorator which imple - [x] `replyWithCallback` - reply using specified callback. - [x] `replyWithFactory` - reply using specified response factory (provide corresponding interface). - [x] Compare XML bodies using `DOMDocument`, fallback to text comparison in case of problems. -- [ ] Regexp matchers for body, query and path. -- [ ] Form Data body matcher (partial & exact) -- [ ] Multipart form body matcher (partial & exact) +- [x] Regexp matchers for body, query, URI and path. +- [x] Form Data body matcher (partial & exact) +- [x] Multipart form body matcher (just like callback matcher but parses the body as a multipart form data) - [ ] `symfony/http-client` support. - [ ] Real network response for mocked & unmatched requests. - [ ] Document everything (with examples if it’s feasible). diff --git a/composer.json b/composer.json index 01028f6..c7e8445 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ "psr/http-client": "^1.0", "psr/http-message": "^1.0", "php-http/httplug": "^1.0 || ^2.0", - "nyholm/psr7": "^1.4" + "nyholm/psr7": "^1.4", + "riverline/multipart-parser": "^2.0" }, "require-dev": { "squizlabs/php_codesniffer": "^3.6", @@ -47,7 +48,8 @@ "jms/serializer": "^2 | ^3.12", "symfony/phpunit-bridge": "^5.2", "symfony/serializer": "^5.2", - "symfony/property-access": "^5.2" + "symfony/property-access": "^5.2", + "php-http/multipart-stream-builder": "^1.2" }, "provide": { "psr/http-client-implementation": "1.0", diff --git a/src/Matchers/AbstractRegExpMatcher.php b/src/Matchers/AbstractRegExpMatcher.php new file mode 100644 index 0000000..6cf3cba --- /dev/null +++ b/src/Matchers/AbstractRegExpMatcher.php @@ -0,0 +1,41 @@ +expression = $expression; + $this->flags = $flags; + } + + protected function matchRegExp(string $content): bool + { + $matches = []; + return 1 === preg_match($this->expression, $content, $matches, $this->flags); + } +} diff --git a/src/Matchers/ExactFormDataMatcher.php b/src/Matchers/ExactFormDataMatcher.php new file mode 100644 index 0000000..416363e --- /dev/null +++ b/src/Matchers/ExactFormDataMatcher.php @@ -0,0 +1,40 @@ +getBody())); + + if (empty($query)) { + return false; + } + + return ComparatorLocator::get(RecursiveArrayComparator::class)->compare($this->query, $query); + } +} diff --git a/src/Matchers/FormDataMatcher.php b/src/Matchers/FormDataMatcher.php new file mode 100644 index 0000000..ebddaa8 --- /dev/null +++ b/src/Matchers/FormDataMatcher.php @@ -0,0 +1,40 @@ +getBody())); + + if (empty($query)) { + return false; + } + + return ComparatorLocator::get(RecursiveLtrArrayComparator::class)->compare($this->query, $query); + } +} diff --git a/src/Matchers/MultipartFormDataMatcher.php b/src/Matchers/MultipartFormDataMatcher.php new file mode 100644 index 0000000..eb1ab8c --- /dev/null +++ b/src/Matchers/MultipartFormDataMatcher.php @@ -0,0 +1,57 @@ +callback = $callback; + } + + /** + * @inheritDoc + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public function matches(RequestInterface $request): bool + { + try { + $part = PSR7::convert($request); + $request->getBody()->rewind(); + } catch (InvalidArgumentException $exception) { + return false; + } + + return call_user_func($this->callback, $part); + } +} diff --git a/src/Matchers/RegExpBodyMatcher.php b/src/Matchers/RegExpBodyMatcher.php new file mode 100644 index 0000000..3545094 --- /dev/null +++ b/src/Matchers/RegExpBodyMatcher.php @@ -0,0 +1,32 @@ +matchRegExp(static::getStreamData($request->getBody())); + } +} diff --git a/src/Matchers/RegExpPathMatcher.php b/src/Matchers/RegExpPathMatcher.php new file mode 100644 index 0000000..f01d98a --- /dev/null +++ b/src/Matchers/RegExpPathMatcher.php @@ -0,0 +1,29 @@ +matchRegExp($request->getUri()->getPath()); + } +} diff --git a/src/Matchers/RegExpQueryMatcher.php b/src/Matchers/RegExpQueryMatcher.php new file mode 100644 index 0000000..5afcf2c --- /dev/null +++ b/src/Matchers/RegExpQueryMatcher.php @@ -0,0 +1,29 @@ +matchRegExp($request->getUri()->getQuery()); + } +} diff --git a/src/Matchers/RegExpUriMatcher.php b/src/Matchers/RegExpUriMatcher.php new file mode 100644 index 0000000..8254c06 --- /dev/null +++ b/src/Matchers/RegExpUriMatcher.php @@ -0,0 +1,29 @@ +matchRegExp((string) $request->getUri()); + } +} diff --git a/src/PockBuilder.php b/src/PockBuilder.php index 52d56dd..9b596da 100644 --- a/src/PockBuilder.php +++ b/src/PockBuilder.php @@ -9,22 +9,20 @@ namespace Pock; -use Diff\ArrayComparer\StrictArrayComparer; use DOMDocument; -use Pock\Enum\RequestMethod; -use Pock\Enum\RequestScheme; use Pock\Exception\PockClientException; use Pock\Exception\PockNetworkException; use Pock\Exception\PockRequestException; -use Pock\Exception\XmlException; use Pock\Factory\CallbackReplyFactory; use Pock\Factory\ReplyFactoryInterface; use Pock\Matchers\AnyRequestMatcher; use Pock\Matchers\BodyMatcher; use Pock\Matchers\CallbackRequestMatcher; +use Pock\Matchers\ExactFormDataMatcher; use Pock\Matchers\ExactHeaderMatcher; use Pock\Matchers\ExactHeadersMatcher; use Pock\Matchers\ExactQueryMatcher; +use Pock\Matchers\FormDataMatcher; use Pock\Matchers\HeaderLineMatcher; use Pock\Matchers\HeaderLineRegexpMatcher; use Pock\Matchers\HeaderMatcher; @@ -32,9 +30,14 @@ use Pock\Matchers\HeadersMatcher; use Pock\Matchers\HostMatcher; use Pock\Matchers\JsonBodyMatcher; use Pock\Matchers\MethodMatcher; +use Pock\Matchers\MultipartFormDataMatcher; use Pock\Matchers\MultipleMatcher; use Pock\Matchers\PathMatcher; use Pock\Matchers\QueryMatcher; +use Pock\Matchers\RegExpBodyMatcher; +use Pock\Matchers\RegExpPathMatcher; +use Pock\Matchers\RegExpQueryMatcher; +use Pock\Matchers\RegExpUriMatcher; use Pock\Matchers\RequestMatcherInterface; use Pock\Matchers\SchemeMatcher; use Pock\Matchers\UriMatcher; @@ -55,6 +58,7 @@ use Throwable; * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.TooManyPublicMethods) * @SuppressWarnings(PHPMD.TooManyMethods) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) */ class PockBuilder { @@ -169,6 +173,19 @@ class PockBuilder return $this->addMatcher(new UriMatcher($uri)); } + /** + * Matches request by the whole URI using regular expression. + * + * @param string $expression + * @param int $flags + * + * @return self + */ + public function matchUriRegExp(string $expression, int $flags = 0): self + { + return $this->addMatcher(new RegExpUriMatcher($expression, $flags)); + } + /** * Matches request by header value or several values. Header can have other values which are not specified here. * @see PockBuilder::matchExactHeader() if you want to match exact header values. @@ -262,6 +279,20 @@ class PockBuilder return $this->addMatcher(new PathMatcher($path)); } + /** + * Match request by its path using regular expression. This matcher doesn't care about prefix slash + * since it's pretty easy to do it using regular expression. + * + * @param string $expression + * @param int $flags + * + * @return self + */ + public function matchPathRegExp(string $expression, int $flags = 0): self + { + return $this->addMatcher(new RegExpPathMatcher($expression, $flags)); + } + /** * Match request by its query. Request can contain other query variables. * @see PockBuilder::matchExactQuery() if you want to match an entire query string. @@ -275,6 +306,19 @@ class PockBuilder return $this->addMatcher(new QueryMatcher($query)); } + /** + * Match request by its query using regular expression. + * + * @param string $expression + * @param int $flags + * + * @return self + */ + public function matchQueryRegExp(string $expression, int $flags = 0): self + { + return $this->addMatcher(new RegExpQueryMatcher($expression, $flags)); + } + /** * Match request by its query. Additional query parameters aren't allowed. * @@ -287,6 +331,44 @@ class PockBuilder return $this->addMatcher(new ExactQueryMatcher($query)); } + /** + * Match request with form-data. + * + * @param array $formFields + * + * @return self + */ + public function matchFormData(array $formFields): self + { + return $this->addMatcher(new FormDataMatcher($formFields)); + } + + /** + * Match request with form-data. Additional fields aren't allowed. + * + * @param array $formFields + * + * @return self + */ + public function matchExactFormData(array $formFields): self + { + return $this->addMatcher(new ExactFormDataMatcher($formFields)); + } + + /** + * Match request multipart form data. Will not match the request if body is not multipart. + * Uses third-party library to parse the data. + * + * @param callable $callback Accepts Riverline\MultiPartParser\StreamedPart as an argument, returns true if matched. + * + * @return self + * @see https://github.com/Riverline/multipart-parser#usage + */ + public function matchMultipartFormData(callable $callback): self + { + return $this->addMatcher(new MultipartFormDataMatcher($callback)); + } + /** * Match entire request body. * @@ -299,6 +381,19 @@ class PockBuilder return $this->addMatcher(new BodyMatcher($data)); } + /** + * Match entire request body using provided regular expression. + * + * @param string $expression + * @param int $flags + * + * @return self + */ + public function matchBodyRegExp(string $expression, int $flags = 0): self + { + return $this->addMatcher(new RegExpBodyMatcher($expression, $flags)); + } + /** * Match JSON request body. * @@ -373,7 +468,7 @@ class PockBuilder * Match request using provided callback. Callback should receive RequestInterface and return boolean. * If returned value is true then request is matched. * - * @param callable $callback + * @param callable $callback Callable that accepts PSR-7 RequestInterface as it's first argument. * * @return self */ diff --git a/tests/src/Matchers/ExactFormDataMatcherTest.php b/tests/src/Matchers/ExactFormDataMatcherTest.php new file mode 100644 index 0000000..137f4ac --- /dev/null +++ b/tests/src/Matchers/ExactFormDataMatcherTest.php @@ -0,0 +1,62 @@ + 'value3']); + $request = self::getRequestWithBody('doesn\'t look like form-data at all'); + + self::assertFalse($matcher->matches($request)); + } + + public function testNoMatches(): void + { + $matcher = new ExactFormDataMatcher(['field3' => 'value3']); + $request = self::getRequestWithBody('field1=value1&field2=value2'); + + self::assertFalse($matcher->matches($request)); + } + + public function testNoMatchesByValue(): void + { + $matcher = new ExactFormDataMatcher(['field1' => 'value2']); + $request = self::getRequestWithBody('field1=value1&field2=value2'); + + self::assertFalse($matcher->matches($request)); + } + + public function testNoMatchesRedundantParam(): void + { + $matcher = new ExactFormDataMatcher(['field2' => 'value2']); + $request = self::getRequestWithBody('field1=value1&field2=value2'); + + self::assertFalse($matcher->matches($request)); + } + + public function testMatches(): void + { + $matcher = new ExactFormDataMatcher(['field1' => 'value1', 'field2' => 'value2']); + $request = self::getRequestWithBody('field1=value1&field2=value2'); + + self::assertTrue($matcher->matches($request)); + } +} diff --git a/tests/src/Matchers/FormDataMatcherTest.php b/tests/src/Matchers/FormDataMatcherTest.php new file mode 100644 index 0000000..d20e486 --- /dev/null +++ b/tests/src/Matchers/FormDataMatcherTest.php @@ -0,0 +1,54 @@ + 'value3']); + $request = self::getRequestWithBody('doesn\'t look like form-data at all'); + + self::assertFalse($matcher->matches($request)); + } + + public function testNoMatches(): void + { + $matcher = new FormDataMatcher(['field3' => 'value3']); + $request = self::getRequestWithBody('field1=value1&field2=value2'); + + self::assertFalse($matcher->matches($request)); + } + + public function testNoMatchesByValue(): void + { + $matcher = new FormDataMatcher(['field1' => 'value2']); + $request = self::getRequestWithBody('field1=value1&field2=value2'); + + self::assertFalse($matcher->matches($request)); + } + + public function testMatches(): void + { + $matcher = new FormDataMatcher(['field2' => 'value2']); + $request = self::getRequestWithBody('field1=value1&field2=value2'); + + self::assertTrue($matcher->matches($request)); + } +} diff --git a/tests/src/Matchers/MultipartFormDataMatcherTest.php b/tests/src/Matchers/MultipartFormDataMatcherTest.php new file mode 100644 index 0000000..dfdfb2e --- /dev/null +++ b/tests/src/Matchers/MultipartFormDataMatcherTest.php @@ -0,0 +1,59 @@ +isMultiPart(); + }); + + self::assertFalse($matcher->matches(self::getTestRequest())); + self::assertFalse($matcher->matches(self::getRequestWithBody('param=value¶m2=value'))); + } + + public function testMatches(): void + { + $matcher = new MultipartFormDataMatcher(function (StreamedPart $part) { + return $part->isMultiPart() && + 1 === count($part->getPartsByName('param1')) && + 1 === count($part->getPartsByName('param2')) && + 'value1' === $part->getPartsByName('param1')[0]->getBody() && + 'value2' === $part->getPartsByName('param2')[0]->getBody() && + 'text/plain' === $part->getPartsByName('param1')[0]->getHeader('Content-Type'); + }); + $builder = new MultipartStreamBuilder(self::getPsr17Factory()); + $builder->addResource('param1', 'value1', ['headers' => ['Content-Type' => 'text/plain']]) + ->addResource('param2', 'value2'); + + self::assertTrue($matcher->matches(self::getMultipartRequest($builder))); + } + + private static function getMultipartRequest(MultipartStreamBuilder $builder): RequestInterface + { + return self::getPsr17Factory()->createRequest('POST', 'https://example.com') + ->withHeader('Content-Type', 'multipart/form-data; boundary="' . $builder->getBoundary() . '"') + ->withBody($builder->build()); + } +} diff --git a/tests/src/Matchers/RegExpBodyMatcherTest.php b/tests/src/Matchers/RegExpBodyMatcherTest.php new file mode 100644 index 0000000..e7961a3 --- /dev/null +++ b/tests/src/Matchers/RegExpBodyMatcherTest.php @@ -0,0 +1,38 @@ +matches($request)); + } + + public function testMatches(): void + { + $matcher = new RegExpBodyMatcher('/\d+-\d+/m', PREG_UNMATCHED_AS_NULL); + $request = static::getRequestWithBody('23-900'); + + self::assertTrue($matcher->matches($request)); + } +} diff --git a/tests/src/Matchers/RegExpPathMatcherTest.php b/tests/src/Matchers/RegExpPathMatcherTest.php new file mode 100644 index 0000000..b2dfe93 --- /dev/null +++ b/tests/src/Matchers/RegExpPathMatcherTest.php @@ -0,0 +1,38 @@ +withUri(static::getPsr17Factory()->createUri('https://test.com/test')); + + self::assertFalse($matcher->matches($request)); + } + + public function testMatches(): void + { + $matcher = new RegExpPathMatcher('/\d+-\d+/m', PREG_UNMATCHED_AS_NULL); + $request = static::getTestRequest()->withUri(static::getPsr17Factory()->createUri('https://test.com/23-900')); + + self::assertTrue($matcher->matches($request)); + } +} diff --git a/tests/src/Matchers/RegExpQueryMatcherTest.php b/tests/src/Matchers/RegExpQueryMatcherTest.php new file mode 100644 index 0000000..ebf5d5b --- /dev/null +++ b/tests/src/Matchers/RegExpQueryMatcherTest.php @@ -0,0 +1,44 @@ +withUri( + static::getPsr17Factory()->createUri('https://test.com') + ->withQuery('param=value') + ); + + self::assertFalse($matcher->matches($request)); + } + + public function testMatches(): void + { + $matcher = new RegExpQueryMatcher('/\d+-\d+/m', PREG_UNMATCHED_AS_NULL); + $request = static::getTestRequest()->withUri( + static::getPsr17Factory()->createUri('https://test.com') + ->withQuery('param=23-900') + ); + + self::assertTrue($matcher->matches($request)); + } +} diff --git a/tests/src/Matchers/RegExpUriMatcherTest.php b/tests/src/Matchers/RegExpUriMatcherTest.php new file mode 100644 index 0000000..9a13d68 --- /dev/null +++ b/tests/src/Matchers/RegExpUriMatcherTest.php @@ -0,0 +1,41 @@ +matches($request)); + } + + public function testMatches(): void + { + $matcher = new RegExpUriMatcher('/https\:\/\/\w+\.com\/\d+-\d+\?param=\d+-\d+/m', PREG_UNMATCHED_AS_NULL); + $request = static::getTestRequest()->withUri( + static::getPsr17Factory()->createUri('https://example.com/23-900') + ->withQuery('param=23-900') + ); + + self::assertTrue($matcher->matches($request)); + } +} diff --git a/tests/src/PockBuilderTest.php b/tests/src/PockBuilderTest.php index 3c97f1a..a8b8be2 100644 --- a/tests/src/PockBuilderTest.php +++ b/tests/src/PockBuilderTest.php @@ -10,6 +10,7 @@ namespace Pock\Tests; use DOMDocument; +use Http\Message\MultipartStream\MultipartStreamBuilder; use Pock\Enum\RequestMethod; use Pock\Enum\RequestScheme; use Pock\Exception\UnsupportedRequestException; @@ -23,6 +24,7 @@ use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\NetworkExceptionInterface; use Psr\Http\Client\RequestExceptionInterface; use Psr\Http\Message\RequestInterface; +use Riverline\MultiPartParser\StreamedPart; use RuntimeException; /** @@ -579,6 +581,127 @@ EOF; self::assertEquals($xml, $response->getBody()->getContents()); } + public function testMultipartFormDataMock(): void + { + $builder = new PockBuilder(); + $builder->matchMethod(RequestMethod::POST) + ->matchScheme(RequestScheme::HTTPS) + ->matchHost(self::TEST_HOST) + ->matchMultipartFormData(function (StreamedPart $part) { + return $part->isMultiPart() && + 1 === count($part->getPartsByName('param1')) && + 1 === count($part->getPartsByName('param2')) && + 'value1' === $part->getPartsByName('param1')[0]->getBody() && + 'value2' === $part->getPartsByName('param2')[0]->getBody() && + 'text/plain' === $part->getPartsByName('param1')[0]->getHeader('Content-Type'); + })->reply(200) + ->withHeader('Content-Type', 'text/plain') + ->withBody('ok'); + + $streamBuilder = (new MultipartStreamBuilder(self::getPsr17Factory())) + ->addResource('param1', 'value1', ['headers' => ['Content-Type' => 'text/plain']]) + ->addResource('param2', 'value2'); + $response = $builder->getClient()->sendRequest( + self::getPsr17Factory() + ->createRequest(RequestMethod::POST, self::TEST_URI) + ->withHeader('Content-Type', 'multipart/form-data; boundary="' . $streamBuilder->getBoundary() . '"') + ->withBody($streamBuilder->build()) + ); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals(['Content-Type' => ['text/plain']], $response->getHeaders()); + self::assertEquals('ok', $response->getBody()->getContents()); + } + + public function testMatchBodyRegExp(): void + { + $builder = new PockBuilder(); + $builder->matchMethod(RequestMethod::GET) + ->matchUri(self::TEST_URI) + ->matchBodyRegExp('/\d+-\d+/') + ->reply(200); + + $response = $builder->getClient()->sendRequest(static::getRequestWithBody('test matchable 23-900')); + + self::assertEquals(200, $response->getStatusCode()); + } + + public function testMatchPathRegExp(): void + { + $builder = new PockBuilder(); + $builder->matchMethod(RequestMethod::GET) + ->matchOrigin(self::TEST_HOST) + ->matchPathRegExp('/^\/?test$/') + ->reply(200); + + $response = $builder->getClient()->sendRequest( + static::getTestRequest()->withUri(static::getPsr17Factory()->createUri('https://test.com/test')) + ); + + self::assertEquals(200, $response->getStatusCode()); + } + + public function testMatchQueryRegExp(): void + { + $builder = new PockBuilder(); + $builder->matchMethod(RequestMethod::GET) + ->matchOrigin(self::TEST_HOST) + ->matchQueryRegExp('/\d+-\d+/') + ->reply(200); + + $response = $builder->getClient()->sendRequest( + static::getTestRequest()->withUri( + static::getPsr17Factory()->createUri(self::TEST_URI) + ->withQuery('param=23-900') + ) + ); + + self::assertEquals(200, $response->getStatusCode()); + } + + public function testMatchUriRegExp(): void + { + $builder = new PockBuilder(); + $builder->matchMethod(RequestMethod::GET) + ->matchUriRegExp('/https\:\/\/\w+\.com\/\d+-\d+\?param=\d+-\d+/') + ->reply(200); + + $response = $builder->getClient()->sendRequest( + static::getTestRequest()->withUri( + static::getPsr17Factory()->createUri('https://example.com/23-900') + ->withQuery('param=23-900') + ) + ); + + self::assertEquals(200, $response->getStatusCode()); + } + + public function testMatchFormData(): void + { + $builder = new PockBuilder(); + $builder->matchMethod(RequestMethod::GET) + ->matchUri(self::TEST_URI) + ->matchFormData(['field2' => 'value2']) + ->reply(200); + + $response = $builder->getClient()->sendRequest(self::getRequestWithBody('field1=value1&field2=value2')); + + self::assertEquals(200, $response->getStatusCode()); + } + + public function testMatchExactFormData(): void + { + $builder = new PockBuilder(); + $builder->matchMethod(RequestMethod::GET) + ->matchUri(self::TEST_URI) + ->matchExactFormData(['field2' => 'value2']) + ->reply(200); + + $response = $builder->getClient()->sendRequest(self::getRequestWithBody('field2=value2')); + + self::assertEquals(200, $response->getStatusCode()); + } + public function testFirstExampleApiMock(): void { $data = [