diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd7983d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml,neon}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..622c1b9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto eol=lf +*.pp eol=lf linguist-language=EBNF +*.pp2 eol=lf linguist-language=EBNF + +.editorconfig export-ignore +.php-cs-fixer.php export-ignore +.gitattributes export-ignore +.gitignore export-ignore + +phpunit.xml export-ignore +psalm.xml export-ignore +rector.php export-ignore + +/.github export-ignore +/tests export-ignore diff --git a/.github/workflows/codestyle.yml b/.github/workflows/codestyle.yml new file mode 100644 index 0000000..db97a49 --- /dev/null +++ b/.github/workflows/codestyle.yml @@ -0,0 +1,45 @@ +name: codestyle + +on: + push: + pull_request: + +jobs: + psalm: + name: Code Style + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + php: [ '8.3' ] + os: [ ubuntu-latest ] + steps: + - name: Set Git To Use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + - name: Validate Composer + run: composer validate + - name: Get Composer Cache Directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Restore Composer Cache + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-${{ matrix.php }}-composer- + - name: Install Dependencies + uses: nick-invision/retry@v2 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --prefer-dist --no-interaction --no-progress + - name: Check Code Style + run: composer phpcs:check diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..3019d6c --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,49 @@ +name: security + +on: + push: + pull_request: + schedule: + - cron: '0 0 * * *' + +jobs: + security: + name: Security + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + php: [ '8.3' ] + os: [ ubuntu-latest ] + steps: + - name: Set Git To Use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + - name: Validate Composer + run: composer validate + - name: Get Composer Cache Directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Restore Composer Cache + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-${{ matrix.php }}-composer- + - name: Install Dependencies + uses: nick-invision/retry@v2 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --prefer-dist --no-interaction --no-progress + - name: Composer Audit + run: composer audit + - name: Security Advisories + run: composer require --dev roave/security-advisories:dev-latest diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..3d93fd2 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,45 @@ +name: static-analysis + +on: + push: + pull_request: + +jobs: + psalm: + name: Psalm + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + php: [ '8.3' ] + os: [ ubuntu-latest ] + steps: + - name: Set Git To Use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + - name: Validate Composer + run: composer validate + - name: Get Composer Cache Directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Restore Composer Cache + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-${{ matrix.php }}-composer- + - name: Install Dependencies + uses: nick-invision/retry@v2 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --prefer-dist --no-interaction --no-progress + - name: Static Analysis + run: composer linter:check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d263d01 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,52 @@ +name: tests + +on: + push: + pull_request: + schedule: + - cron: '0 0 * * *' + +jobs: + tests: + name: Tests (${{matrix.php}}, ${{ matrix.os }}, ${{ matrix.stability }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + php: [ '8.3' ] + os: [ ubuntu-latest, macos-latest, windows-latest ] + stability: [ prefer-lowest, prefer-stable ] + steps: + - name: Set Git To Use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: pecl + ini-values: "memory_limit=-1" + - name: Validate Composer + run: composer validate + - name: Get Composer Cache Directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Restore Composer Cache + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-${{ matrix.php }}-composer- + - name: Install Dependencies + uses: nick-invision/retry@v2 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --${{ matrix.stability }} --ignore-platform-reqs --prefer-dist --no-interaction --no-progress + - name: Execute Unit Tests + run: composer test:unit + - name: Execute Functional Tests + run: composer test:functional diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5fffcf --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# IDE +/.idea/ + +# Composer +/composer.lock +/vendor/ + +# Testing +test.php diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..6061ed1 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,16 @@ +in([__DIR__ . '/src']); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PER-CS2.0' => true, + '@PER-CS2.0:risky' => true, + 'strict_param' => true, + 'array_syntax' => [ + 'syntax' => 'short', + ], + ]) + ->setCacheFile(__DIR__ . '/vendor/.cache.php-cs-fixer') + ->setFinder($files); diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4602ec1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Nesmeyanov Kirill + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbeaaca --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +

+ +

+ +

+ PHP 8.3+ + Latest Stable Version + Latest Unstable Version + License MIT +

+

+ + + + +

+ +# HTTP Factory + +A set of drivers for encoding HTTP responses and decoding HTTP requests. + +## Installation + +PewPew HTTP Factory is available as Composer repository and can be installed +using the following command in a root of your project: + +```bash +$ composer require pew-pew/http-factory +``` + +More detailed installation [instructions are here](https://getcomposer.org/doc/01-basic-usage.md). + +## Usage + +### Decoder + +```php +// Symfony Request +$request = new \Symfony\Component\HttpFoundation\Request(); + +// Requests Factory +$requests = new \PewPew\HttpFactory\RequestDecoderFactory([ + new \PewPew\HttpFactory\Driver\JsonDriver(), +]); + +$payload = $requests + ->createDecoder($request) // Detect passed "content-type" header and + // create decoder if available. + ?->decode($request->getContent(true)); // Decode request body. +``` + +### Encoder + +```php +// Symfony Request +$request = new \Symfony\Component\HttpFoundation\Request(); + +// Responses Factory +$responses = new \PewPew\HttpFactory\ResponseEncoderFactory([ + new \PewPew\HttpFactory\Driver\JsonDriver(), +]); + +$response = $responses + ->createEncoder($request) // Detect passed "accept" header and create + // encoder if available. + ?->encode(['some' => 'any'], 200); // Encode payload and create response. +``` + +### Symfony Integration + +Add the bundle to your `bundles.php` file: + +```php +// bundles.php +return [ + // ... + PewPew\HttpFactory\HttpFactoryBundle::class => ['all' => true], +]; +``` + +Use `ResponseEncoderFactoryInterface` and `RequestDecoderFactoryInterface` +in your services: + +```php +use PewPew\HttpFactory\ResponseEncoderFactoryInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Request; + +final readonly class ExampleController +{ + public function __construct( + private ResponseEncoderFactoryInterface $responses, + ) {} + + public function someAction(Request $request): Response + { + $encoder = $this->responses->createEncoder($request); + + if ($encoder === null) { + throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException( + 'Unsupported "accept" request header', + ); + } + + return $encoder->encode([ + 'status' => 'ok' + ], Response::HTTP_OK); + } +} +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b72df11 --- /dev/null +++ b/composer.json @@ -0,0 +1,60 @@ +{ + "name": "pew-pew/http-factory", + "license": "MIT", + "description": "HTTP factory for decoding request and encoding responses with symfony integration", + "type": "library", + "require": { + "php": "^8.3", + "symfony/http-foundation": "^5.4|^6.0|^7.0" + }, + "autoload": { + "psr-4": { + "PewPew\\HttpFactory\\": "src" + } + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.49", + "phpunit/phpunit": "^10.5", + "symfony/var-dumper": "^5.4|^6.0|^7.0", + "vimeo/psalm": "^5.21" + }, + "autoload-dev": { + "psr-4": { + "PewPew\\HttpFactory\\Tests\\": "tests" + } + }, + "suggest": { + "ext-json": "Adds JSON decoder and encoder", + "symfony/yaml": "Adds YAML decoder and encoder", + "rybakit/msgpack": "Adds MSGPACK decoder and encoder", + "symfony/http-kernel": "Adds Symfony Bundle support (PewPew\\HttpFactory\\HttpFactoryBundle)" + }, + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev", + "dev-main": "1.0.x-dev" + } + }, + "config": { + "sort-packages": true, + "platform-check": true, + "bin-compat": "full", + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + } + }, + "scripts": { + "test": ["@test:unit", "@test:functional"], + "test:unit": "phpunit --testdox", + "test:functional": "phpunit --testdox --testsuite=functional", + "linter": "@linter:check", + "linter:check": "psalm --no-cache", + "linter:fix": "psalm --no-cache --alter", + "phpcs": "@phpcs:check", + "phpcs:check": "php-cs-fixer fix --config=.php-cs-fixer.php --allow-risky=yes --dry-run --verbose --diff", + "phpcs:fix": "php-cs-fixer fix --config=.php-cs-fixer.php --allow-risky=yes --verbose --diff" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ea677ba --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,33 @@ + + + + + tests/Unit + + + tests/Functional + + + + + + + + src + + + + + + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..c1e7281 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/src/Driver/Driver.php b/src/Driver/Driver.php new file mode 100644 index 0000000..2c99cf4 --- /dev/null +++ b/src/Driver/Driver.php @@ -0,0 +1,128 @@ +toString($data)); + $response->setStatusCode($code); + + $response->headers->set('Cache-Control', 'no-cache'); + $response->headers->set('Vary', 'Accept-Encoding'); + + if ($this->shouldSetContentType($response, $this->getSupportedContentTypes())) { + $response->headers->set('Content-Type', $this->getDefaultContentType()); + } + + return $response; + } + + public function decode(string $payload): object|array + { + if ($payload === '') { + return []; + } + + return $this->fromString($payload); + } + + /** + * @param list $acceptable + */ + private function shouldSetContentType(Response $response, array $acceptable = []): bool + { + $actual = $response->headers->get('Content-Type'); + + return !\in_array($actual, $acceptable, true); + } + + public function isAcceptable(Request $request): bool + { + return $this->isAvailable() + && $this->accepts($request, $this->getSupportedContentTypes()); + } + + /** + * Returns {@see true} in case of HTTP request provides something like + * "application/json" or "application/vnd.api+json" accept header. + * + * @param list $acceptable + */ + final protected function accepts(Request $request, array $acceptable = []): bool + { + foreach ($request->getAcceptableContentTypes() as $contentType) { + if (\in_array($contentType, $acceptable, true)) { + return true; + } + } + + return false; + } + + public function isProvides(Request $request): bool + { + return $this->isAvailable() + && $this->provides($request, $this->getSupportedContentTypes()); + } + + /** + * Returns {@see true} in case of HTTP request provides something like + * "application/json" content-type header. + * + * @param list $contentTypes + */ + final protected function provides(Request $request, array $contentTypes = []): bool + { + foreach ($request->headers->all('content-type') as $contentType) { + if (\in_array($contentType, $contentTypes, true)) { + return true; + } + } + + return false; + } + + /** + * Returns list of the supported content-types for the given driver. + * + * @return non-empty-list + */ + abstract protected function getSupportedContentTypes(): array; + + /** + * @return non-empty-string + */ + protected function getDefaultContentType(): string + { + $contentTypes = $this->getSupportedContentTypes(); + + return \reset($contentTypes); + } + + /** + * Returns {@see true} in case of given driver is available. + */ + protected function isAvailable(): bool + { + return true; + } + + /** + * Transforms variant payload to response body string. + */ + abstract protected function toString(mixed $data): string; + + /** + * Transforms request's body string to variant response payload. + */ + abstract protected function fromString(string $data): array|object; +} diff --git a/src/Driver/JsonDriver.php b/src/Driver/JsonDriver.php new file mode 100644 index 0000000..828be46 --- /dev/null +++ b/src/Driver/JsonDriver.php @@ -0,0 +1,77 @@ + + */ + private const array SUPPORTED_CONTENT_TYPES_JSON = [ + 'application/json', + 'application/vnd.api+json', + 'text/javascript', + ]; + + /** + * Contains {@see true} in case of `ext-json` is available. + */ + private bool $available; + + public function __construct( + private bool $debug = false, + ) { + $this->available = \extension_loaded('json'); + } + + /** + * @return non-empty-list + */ + protected function getSupportedContentTypes(): array + { + return self::SUPPORTED_CONTENT_TYPES_JSON; + } + + protected function isAvailable(): bool + { + return $this->available; + } + + /** + * @throws \InvalidArgumentException + */ + protected function fromString(string $data): array + { + try { + return (array) \json_decode($data, true, depth: 32, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $message = 'An error occurred while parsing request json payload: ' . $e->getMessage(); + throw new \InvalidArgumentException($message, $e->getCode()); + } + } + + /** + * @throws \JsonException + */ + protected function toString(mixed $data): string + { + $flags = self::DEFAULT_JSON_ENCODE_FLAGS; + + if ($this->debug) { + $flags |= \JSON_PRETTY_PRINT; + } + + /** @var non-empty-string */ + return \json_encode($data, $flags | \JSON_THROW_ON_ERROR); + } +} diff --git a/src/Driver/MessagePackDriver.php b/src/Driver/MessagePackDriver.php new file mode 100644 index 0000000..d99d5db --- /dev/null +++ b/src/Driver/MessagePackDriver.php @@ -0,0 +1,66 @@ + + */ + private const array SUPPORTED_CONTENT_TYPES_MSGPACK = [ + 'application/msgpack', + 'application/x-msgpack', + ]; + + /** + * Contains {@see true} in case of `rybakit/msgpack` is available. + */ + private bool $available; + + public function __construct() + { + $this->available = \class_exists(MessagePack::class); + } + + /** + * @return non-empty-list + */ + protected function getSupportedContentTypes(): array + { + return self::SUPPORTED_CONTENT_TYPES_MSGPACK; + } + + protected function isAvailable(): bool + { + return $this->available; + } + + protected function fromString(string $data): array|object + { + try { + /** @psalm-suppress MixedAssignment */ + $result = MessagePack::unpack($data); + } catch (\Throwable $e) { + $message = 'An error occurred while parsing request msgpack payload: ' . $e->getMessage(); + throw new \InvalidArgumentException($message, (int) $e->getCode()); + } + + if (\is_object($result) || \is_array($result)) { + return $result; + } + + return (array) $result; + } + + protected function toString(mixed $data): string + { + return MessagePack::pack($data); + } +} diff --git a/src/Driver/YamlDriver.php b/src/Driver/YamlDriver.php new file mode 100644 index 0000000..0c5b1e8 --- /dev/null +++ b/src/Driver/YamlDriver.php @@ -0,0 +1,71 @@ + + */ + private const array SUPPORTED_CONTENT_TYPES_YAML = [ + 'application/yaml', + 'application/yml', + 'application/x-yaml', + 'application/x-yml', + 'text/yaml', + 'text/yml', + 'text/x-yaml', + ]; + + /** + * Contains {@see true} in case of `symfony/yaml` is available. + */ + private bool $available; + + public function __construct() + { + $this->available = \class_exists(Yaml::class); + } + + /** + * @return non-empty-list + */ + protected function getSupportedContentTypes(): array + { + return self::SUPPORTED_CONTENT_TYPES_YAML; + } + + protected function isAvailable(): bool + { + return $this->available; + } + + protected function fromString(string $data): array|object + { + try { + /** @var mixed $result */ + $result = Yaml::parse($data, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE); + } catch (\Throwable $e) { + $message = 'An error occurred while parsing request yaml payload: ' . $e->getMessage(); + throw new \InvalidArgumentException($message, (int) $e->getCode()); + } + + if (\is_object($result) || \is_array($result)) { + return $result; + } + + return (array) $result; + } + + protected function toString(mixed $data): string + { + return Yaml::dump($data, 4, 2); + } +} diff --git a/src/HttpFactoryBundle.php b/src/HttpFactoryBundle.php new file mode 100644 index 0000000..ed10eb8 --- /dev/null +++ b/src/HttpFactoryBundle.php @@ -0,0 +1,54 @@ +registerBuiltinDrivers($container); + $this->registerFactories($container); + } + + private function registerBuiltinDrivers(ContainerBuilder $container): void + { + if (\class_exists(MessagePack::class)) { + $container->register(MessagePackDriver::class) + ->addTag('pew-pew.request.decoder') + ->addTag('pew-pew.request.encoder'); + } + + if (\class_exists(Yaml::class)) { + $container->register(YamlDriver::class) + ->addTag('pew-pew.request.decoder') + ->addTag('pew-pew.request.encoder'); + } + + $container->register(JsonDriver::class) + ->setArgument('$debug', '%kernel.debug%') + ->addTag('pew-pew.request.decoder') + ->addTag('pew-pew.request.encoder'); + } + + private function registerFactories(ContainerBuilder $container): void + { + $container->register(RequestDecoderFactoryInterface::class, RequestDecoderFactory::class) + ->setArgument('$decoders', new TaggedIterator('pew-pew.request.decoder')); + + $container->register(ResponseEncoderFactoryInterface::class, ResponseEncoderFactory::class) + ->setArgument('$encoders', new TaggedIterator('pew-pew.request.encoder')); + } +} diff --git a/src/RequestDecoderFactory.php b/src/RequestDecoderFactory.php new file mode 100644 index 0000000..9aba189 --- /dev/null +++ b/src/RequestDecoderFactory.php @@ -0,0 +1,34 @@ + + */ + private array $decoders; + + /** + * @param iterable $decoders + */ + public function __construct(iterable $decoders = []) + { + $this->decoders = \array_values([...$decoders]); + } + + public function createDecoder(Request $request): ?RequestDecoderInterface + { + foreach ($this->decoders as $decoder) { + if ($decoder->isProvides($request)) { + return $decoder; + } + } + + return null; + } +} diff --git a/src/RequestDecoderFactoryInterface.php b/src/RequestDecoderFactoryInterface.php new file mode 100644 index 0000000..089a4d3 --- /dev/null +++ b/src/RequestDecoderFactoryInterface.php @@ -0,0 +1,12 @@ + + */ + private array $encoders; + + /** + * @param iterable $encoders + */ + public function __construct(iterable $encoders = []) + { + $this->encoders = \array_values([...$encoders]); + } + + public function createEncoder(Request $request): ?ResponseEncoderInterface + { + foreach ($this->encoders as $factory) { + if ($factory->isAcceptable($request)) { + return $factory; + } + } + + return null; + } +} diff --git a/src/ResponseEncoderFactoryInterface.php b/src/ResponseEncoderFactoryInterface.php new file mode 100644 index 0000000..2b38b67 --- /dev/null +++ b/src/ResponseEncoderFactoryInterface.php @@ -0,0 +1,12 @@ + + */ + public const int DEFAULT_HTTP_CODE = Response::HTTP_OK; + + /** + * @param int<100, 599> $code + */ + public function encode(mixed $data, int $code = self::DEFAULT_HTTP_CODE): Response; +} diff --git a/src/ResponseMatcherInterface.php b/src/ResponseMatcherInterface.php new file mode 100644 index 0000000..56cbf02 --- /dev/null +++ b/src/ResponseMatcherInterface.php @@ -0,0 +1,16 @@ +