├── .editorconfig ├── .github └── workflows │ └── pull-request.yaml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── docker ├── .env.dist └── docker-compose.yml ├── infection.json.dist ├── phpcs.xml.dist ├── phpunit.xml.dist ├── psalm.xml ├── rector.php ├── src ├── DependencyInjection │ ├── Configuration.php │ └── ViolinesRestExtension.php ├── Error │ ├── Error.php │ ├── ErrorInterface.php │ ├── ErrorListener.php │ ├── NotAcceptableListener.php │ ├── ValidationException.php │ └── ValidationExceptionListener.php ├── HttpApi │ ├── AnnotationReaderNotInstalledException.php │ ├── HttpApi.php │ ├── HttpApiParameterException.php │ ├── HttpApiReader.php │ ├── MissingHttpApiException.php │ └── RequestInfoSource.php ├── Negotiation │ ├── ContentNegotiator.php │ ├── MimeType.php │ └── NotNegotiableException.php ├── Request │ ├── AcceptHeader.php │ ├── BodyArgumentResolver.php │ ├── ContentTypeHeader.php │ ├── EmptyBodyException.php │ ├── QueryStringArgumentResolver.php │ └── SupportsException.php ├── Resources │ └── config │ │ └── service.xml ├── Response │ ├── ContentTypeHeader.php │ ├── ErrorResponseResolver.php │ ├── ResponseBuilder.php │ ├── ResponseListener.php │ └── SuccessResponseResolver.php ├── Serialize │ ├── DeserializeEvent.php │ ├── DeserializerType.php │ ├── FormatException.php │ ├── FormatMapper.php │ ├── SerializeEvent.php │ ├── Serializer.php │ └── SerializerInterface.php ├── Type │ ├── ObjectList.php │ └── TypeException.php ├── Validation │ └── Validator.php └── ViolinesRestBundle.php └── tests ├── DependencyInjection ├── ConfigurationTest.php └── ViolinesRestExtensionTest.php ├── Error ├── ErrorListenerTest.php ├── ErrorTest.php ├── NotAcceptableListenerTest.php ├── ValidationExceptionListenerTest.php └── ValidationExceptionTest.php ├── Fake ├── ConstraintViolationFake.php ├── ConstraintViolationListFake.php ├── SymfonyEventDispatcherFake.php └── SymfonySerializerFake.php ├── Functional └── ControllerTest.php ├── HttpApi ├── AnnotationReaderNotInstalledExceptionTest.php ├── HttpApiParameterExceptionTest.php ├── HttpApiReaderTest.php ├── HttpApiTest.php ├── MissingHttpApiExceptionTest.php └── RequestInfoSourceTest.php ├── Negotiation ├── ContentNegotiatorTest.php ├── MimeTypeTest.php └── NotNegotiableExceptionTest.php ├── Request ├── AcceptHeaderTest.php ├── BodyArgumentResolverTest.php ├── ContentTypeHeaderTest.php ├── EmptyBodyExceptionTest.php ├── QueryStringArgumentResolverTest.php └── SupportsExceptionTest.php ├── Response ├── ContentTypeHeaderTest.php ├── ResponseBuilderTest.php └── ResponseListenerTest.php ├── Serialize ├── DeserializeEventTest.php ├── DeserializerTypeTest.php ├── FormatExceptionTest.php ├── FormatMapperTest.php ├── SerializeEventTest.php └── SerializerTest.php ├── Stub ├── Config.php └── MimeTypes.php └── Type ├── ObjectListTest.php └── TypeExceptionTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 4 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.json] 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | on: 3 | push: 4 | branches: 5 | - 0.x 6 | pull_request: ~ 7 | jobs: 8 | codestyle: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Install PHP 13 | uses: shivammathur/setup-php@v2 14 | with: 15 | php-version: 8.0 16 | - name: Cache Composer packages 17 | id: composer-cache 18 | uses: actions/cache@v2 19 | with: 20 | path: vendor 21 | key: ${{ runner.os }}-php8.0-${{ hashFiles('**/composer.lock') }} 22 | restore-keys: | 23 | ${{ runner.os }}-php8.0- 24 | - name: Install dependencies 25 | run: composer update --no-interaction --no-progress --prefer-stable 26 | - name: Run php cs fixer 27 | run: vendor/bin/php-cs-fixer fix --allow-risky=yes --dry-run 28 | static-analysis: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v1 32 | - name: Install PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: 8.0 36 | - name: Cache Composer packages 37 | id: composer-cache 38 | uses: actions/cache@v2 39 | with: 40 | path: vendor 41 | key: ${{ runner.os }}-php8.0-${{ hashFiles('**/composer.lock') }} 42 | restore-keys: | 43 | ${{ runner.os }}-php8.0- 44 | - name: Install dependencies 45 | run: composer update --no-interaction --no-progress --prefer-stable 46 | - name: Run psalm 47 | run: vendor/bin/psalm --no-cache --show-info=true --shepherd 48 | tests: 49 | runs-on: ubuntu-latest 50 | strategy: 51 | matrix: 52 | include: 53 | - php-version: 8.0 54 | composer-flags: "--prefer-stable" 55 | symfony-require: "5.4.*" 56 | - php-version: 8.0 57 | composer-flags: "--prefer-stable" 58 | symfony-require: "" 59 | - php-version: 8.1 60 | composer-flags: "--ignore-platform-req=php" 61 | symfony-require: "" 62 | steps: 63 | - uses: actions/checkout@v1 64 | - name: Install PHP 65 | uses: shivammathur/setup-php@v2 66 | with: 67 | php-version: ${{ matrix.php-version }} 68 | coverage: xdebug 69 | - name: Cache Composer packages 70 | id: composer-cache 71 | uses: actions/cache@v2 72 | with: 73 | path: vendor 74 | key: ${{ runner.os }}-php${{ matrix.php-version }}-symfony${{ matrix.symfony-require }}-${{ hashFiles('**/composer.lock') }} 75 | restore-keys: | 76 | ${{ runner.os }}-php${{ matrix.php-version }}-symfony${{ matrix.symfony-require }}- 77 | - name: Install dependencies 78 | env: 79 | SYMFONY_REQUIRE: ${{ matrix.symfony-require }} 80 | run: | 81 | composer global require --no-progress --no-scripts --no-plugins symfony/flex 82 | composer update --no-interaction --no-progress ${{ matrix.composer-flags }} 83 | - name: Run testsuites 84 | run: vendor/bin/phpunit --configuration=phpunit.xml.dist --coverage-clover=coverage.xml 85 | - name: Send code coverage report to Codecov.io 86 | if: matrix.php-version == '8.1' 87 | env: 88 | CODECOV_TOKEN: ${{ secrets.CODECOVIO_TOKEN }} 89 | run: bash <(curl -s https://codecov.io/bash) 90 | - name: Run infection 91 | if: matrix.php-version == '8.1' 92 | env: 93 | INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }} 94 | run: vendor/bin/infection 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docker/.env 2 | /vendor/ 3 | .idea 4 | .vscode 5 | .php_cs.cache 6 | .phpunit.cache 7 | .phpunit.result.cache 8 | .DS_Store 9 | composer.lock 10 | infection.log 11 | phpunit.xml 12 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in([__DIR__ . '/src', __DIR__ . '/tests']); 5 | 6 | $config = new PhpCsFixer\Config(); 7 | $config 8 | ->setRules([ 9 | '@PSR2' => true, 10 | '@Symfony' => true, 11 | 'cast_spaces' => ['space' => 'none'], 12 | 'concat_space' => ['spacing' => 'one'], 13 | 'native_function_invocation' => ['include' => ['@all']], 14 | 'php_unit_set_up_tear_down_visibility' => true, 15 | 'ordered_imports' => [ 16 | 'sort_algorithm' => 'alpha', 17 | 'imports_order' => ['const', 'class', 'function'], 18 | ] 19 | ]) 20 | ->setFinder($finder); 21 | 22 | return $config; 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome. Here are a few guidelines to be aware of: 4 | 5 | - Include tests for new behaviours introduced by PRs. 6 | - Add tests for fixed bugs to avoid future regressions. 7 | - Check if all CI checks passed. 8 | - For new functionality please raise an issue first and include docs. 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Simon Schubert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About 2 | violines/rest-bundle is a Symfony Bundle to create REST APIs. It focusses on HTTP standards and integrates the symfony/serializer and symfony/validator. 3 | 4 | [![build](https://github.com/violines/rest-bundle/workflows/build/badge.svg)](https://github.com/violines/rest-bundle) 5 | [![Code Coverage](https://codecov.io/gh/violines/rest-bundle/branch/master/graph/badge.svg)](https://codecov.io/gh/violines/rest-bundle/) 6 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fviolines%2Frest-bundle%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/violines/rest-bundle/master) 7 | [![type coverage](https://shepherd.dev/github/violines/rest-bundle/coverage.svg)](https://shepherd.dev/github/violines/rest-bundle) 8 | [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=violines_rest-bundle&metric=sqale_index)](https://sonarcloud.io/dashboard?id=violines_rest-bundle) 9 | [![Software License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) 10 | [![Wiki Docs](https://img.shields.io/badge/wiki-docs-B29700)](https://github.com/violines/rest-bundle/wiki) 11 | 12 | ### Features 13 | * Request body or query string to object conversion 14 | * Response building from objects 15 | * Configurable content negotiation 16 | * Events to control symfony/serializer context 17 | * Integration of symfony/validator 18 | * Error Handling 19 | * Optional Logging 20 | 21 | ### Compatible with... 22 | * Symfony 5.4 + 6 23 | * PHP 8 + 8.1 24 | 25 | ### Designed for... 26 | modern architectures that apply Domain Driven Design principles, hexagonal architecture or similar concepts. 27 | 28 | ### Install 29 | ```sh 30 | composer require violines/rest-bundle 31 | ``` 32 | 33 | ### How does it work? 34 | 1. Create any PHP class (Entity, DTO, Command, Query, etc) and add the `#[HttpApi]` attribute or `@HttpApi` annotation 35 | 1. Use any property attributes/annotations from symfony/serializer or symfony/validator 36 | 1. Declare this PHP class as type of a controller argument 37 | 1. Return an instance of this PHP class in the controller 38 | 39 | ### Show Case 40 | Find a sample of usage under: https://github.com/violines/rest-bundle-showcase. 41 | 42 | ## Example 43 | 44 | ```php 45 | orderRepository->findOrders(); 88 | } 89 | 90 | #[Route('/order/{id}', methods: ['GET'], name: 'find_order')] 91 | public function findOrder(int $id): Order 92 | { 93 | $order = $this->orderRepository->find($id); 94 | 95 | if (null === $order) { 96 | throw NotFoundException::id($id); 97 | } 98 | 99 | return $order; 100 | } 101 | 102 | /** 103 | * @param Order[] $orders 104 | */ 105 | #[Route('/orders/create', methods: ['POST'], name: 'create_orders')] 106 | public function createOrders(Order ...$orders): Response 107 | { 108 | $this->orderRepository->createOrders($orders); 109 | 110 | return new Response(null, Response::HTTP_CREATED); 111 | } 112 | 113 | #[Route('/order/create', methods: ['POST'], name: 'create_order')] 114 | public function createOrder(Order $order): Response 115 | { 116 | $this->orderRepository->create($order); 117 | 118 | return new Response(null, Response::HTTP_CREATED); 119 | } 120 | } 121 | ``` 122 | 123 | ### Wiki 124 | For more details please check [violines/rest-bundle Wiki](https://github.com/violines/rest-bundle/wiki). 125 | 126 | ## Development setup 127 | 1. copy docker/.env.dist to docker/.env and adjust to your needs 128 | 1. cd docker/ 129 | 1. pull latest image(s): docker-compose pull 130 | 1. create the container(s): docker-compose up -d 131 | 1. run tests with `docker-compose exec php80 composer run test` 132 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "violines/rest-bundle", 3 | "type": "symfony-bundle", 4 | "description": "violines/rest-bundle is a Symfony Bundle to create REST APIs. It focusses on HTTP standards and integrates the symfony/serializer and symfony/validator.", 5 | "license": "MIT", 6 | "require": { 7 | "php": "^8.0", 8 | "psr/log": "^1.1 || ^2.0 || ^3.0", 9 | "symfony/config": "^5.4 || ^6.0", 10 | "symfony/contracts": "^2.5 || ^3.0", 11 | "symfony/dependency-injection": "^5.4 || ^6.0", 12 | "symfony/http-kernel": "^5.4 || ^6.0", 13 | "symfony/http-foundation": "^5.4 || ^6.0", 14 | "symfony/serializer": "^5.4 || ^6.0", 15 | "symfony/validator": "^5.4 || ^6.0" 16 | }, 17 | "require-dev": { 18 | "doctrine/annotations": "^1.11", 19 | "friendsofphp/php-cs-fixer": "^2.16", 20 | "infection/infection": "^0.25.6", 21 | "matthiasnoback/symfony-dependency-injection-test": "^4.1", 22 | "phpunit/phpunit": "^9.3.0", 23 | "rector/rector": "^0.12.13", 24 | "symfony/property-access": "^5.4 || ^6.0", 25 | "vimeo/psalm": "^4.0", 26 | "symfony/framework-bundle": "^5.4 || ^6.0", 27 | "symfony/filesystem": "^5.4 || ^6.0", 28 | "mikey179/vfsstream": "^1.6", 29 | "phpspec/prophecy-phpunit": "^2.0" 30 | }, 31 | "suggest": { 32 | "doctrine/annotations": "^1.11", 33 | "symfony/property-access": "^5.4 || ^6.0" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Violines\\RestBundle\\": "src/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Violines\\RestBundle\\Tests\\": "tests/" 43 | } 44 | }, 45 | "scripts": { 46 | "test": [ 47 | "XDEBUG_MODE=coverage phpunit", 48 | "psalm --no-cache --show-info=true", 49 | "infection" 50 | ], 51 | "test-debug": [ 52 | "XDEBUG_MODE=debug,coverage phpunit" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /docker/.env.dist: -------------------------------------------------------------------------------- 1 | PHP_IDE_CONFIG="serverName=rest-bundle" 2 | XDEBUG_IDE_KEY=PHPSTORM 3 | XDEBUG_MODE="off" 4 | XDEBUG_CLIENT_HOST="host.docker.internal" 5 | XDEBUG_START_WITH_REQUEST=1 6 | php.error_reporting="E_ALL & ~E_DEPRECATED" 7 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.1" 2 | services: 3 | php80: 4 | image: webdevops/php-dev:8.0 5 | container_name: rest-php80 6 | working_dir: /app 7 | env_file: 8 | - ./.env 9 | volumes: 10 | - ..:/app 11 | php81: 12 | image: webdevops/php-dev:8.1 13 | container_name: rest-php81 14 | working_dir: /app 15 | env_file: 16 | - ./.env 17 | volumes: 18 | - ..:/app 19 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 10, 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ], 7 | "excludes": [ 8 | "DependencyInjection", 9 | "Resources" 10 | ] 11 | }, 12 | "logs": { 13 | "text": "infection.log", 14 | "badge": { 15 | "branch": "0.x" 16 | } 17 | }, 18 | "mutators": { 19 | "@default": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | src 4 | tests 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | src 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | tests 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | services(); 7 | }; 8 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode() 20 | ->children() 21 | // serialize 22 | ->arrayNode('serialize') 23 | ->addDefaultsIfNotSet() 24 | ->children() 25 | // formats 26 | ->arrayNode('formats') 27 | ->addDefaultsIfNotSet() 28 | ->children() 29 | // json 30 | ->arrayNode('json') 31 | ->scalarPrototype()->end() 32 | ->defaultValue(['application/json']) 33 | ->end() 34 | // xml 35 | ->arrayNode('xml') 36 | ->scalarPrototype()->end() 37 | ->defaultValue(['application/xml']) 38 | ->end() 39 | ->end() 40 | ->end() 41 | ->scalarNode('format_default')->defaultValue('application/json') 42 | ->end() 43 | ->end() 44 | ->end(); 45 | 46 | return $treeBuilder; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/DependencyInjection/ViolinesRestExtension.php: -------------------------------------------------------------------------------- 1 | load('service.xml'); 21 | 22 | /** @var array>> $processedConfigs */ 23 | $processedConfigs = $this->processConfiguration(new Configuration(), $configs); 24 | 25 | $container->getDefinition('violines_rest.negotiation.content_negotiator')->replaceArgument(0, $processedConfigs['serialize']['formats']); 26 | $container->getDefinition('violines_rest.negotiation.content_negotiator')->replaceArgument(1, $processedConfigs['serialize']['format_default']); 27 | $container->getDefinition('violines_rest.serialize.format_mapper')->replaceArgument(0, $processedConfigs['serialize']['formats']); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Error/Error.php: -------------------------------------------------------------------------------- 1 | detail = $detail; 26 | $this->title = $title; 27 | $this->type = $type; 28 | } 29 | 30 | public static function new(string $detail, string $title = self::DEFAULT_TITLE, string $type = self::DEFAULT_TYPE): self 31 | { 32 | return new self($detail, $title, $type); 33 | } 34 | 35 | public function getType(): string 36 | { 37 | return $this->type; 38 | } 39 | 40 | public function getTitle(): string 41 | { 42 | return $this->title; 43 | } 44 | 45 | public function getDetail(): string 46 | { 47 | return $this->detail; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Error/ErrorInterface.php: -------------------------------------------------------------------------------- 1 | httpApiReader = $httpApiReader; 24 | $this->errorResponseResolver = $errorResponseResolver; 25 | } 26 | 27 | public function handle(ExceptionEvent $event): void 28 | { 29 | $exception = $event->getThrowable(); 30 | 31 | if (!$exception instanceof ErrorInterface) { 32 | return; 33 | } 34 | 35 | $this->httpApiReader->read(\get_class($exception->getContent())); 36 | 37 | $event->setResponse($this->createResponse($exception, $event->getRequest())); 38 | } 39 | 40 | private function createResponse(ErrorInterface $exception, Request $request): Response 41 | { 42 | return $this->errorResponseResolver->resolve($exception, $request); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Error/NotAcceptableListener.php: -------------------------------------------------------------------------------- 1 | logger = $logger ?? new NullLogger(); 27 | $this->responseBuilder = $responseBuilder; 28 | } 29 | 30 | public function handle(ExceptionEvent $event): void 31 | { 32 | $exception = $event->getThrowable(); 33 | 34 | if (!$exception instanceof NotNegotiableException && !$exception instanceof FormatException) { 35 | return; 36 | } 37 | 38 | $this->logger->log(LogLevel::DEBUG, $exception->getMessage()); 39 | 40 | $event->setResponse($this->createResponse()); 41 | } 42 | 43 | private function createResponse(): Response 44 | { 45 | return $this->responseBuilder->setStatus(Response::HTTP_NOT_ACCEPTABLE)->getResponse(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Error/ValidationException.php: -------------------------------------------------------------------------------- 1 | violationList = $violationList; 21 | } 22 | 23 | public static function fromViolationList(ConstraintViolationListInterface $violationList): self 24 | { 25 | return new self($violationList); 26 | } 27 | 28 | public function getViolationList(): ConstraintViolationListInterface 29 | { 30 | return $this->violationList; 31 | } 32 | 33 | public function getStatusCode(): int 34 | { 35 | return Response::HTTP_BAD_REQUEST; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Error/ValidationExceptionListener.php: -------------------------------------------------------------------------------- 1 | contentNegotiator = $contentNegotiator; 28 | $this->responseBuilder = $responseBuilder; 29 | $this->serializer = $serializer; 30 | } 31 | 32 | public function handle(ExceptionEvent $event): void 33 | { 34 | $exception = $event->getThrowable(); 35 | 36 | if (!$exception instanceof ValidationException) { 37 | return; 38 | } 39 | 40 | $event->setResponse($this->createResponse($event->getRequest(), $exception)); 41 | } 42 | 43 | private function createResponse(Request $request, ValidationException $exception): Response 44 | { 45 | $acceptHeader = AcceptHeader::fromString((string)$request->headers->get(AcceptHeader::NAME, '')); 46 | $preferredMimeType = $this->contentNegotiator->negotiate($acceptHeader); 47 | 48 | return $this->responseBuilder 49 | ->setContent($this->serializer->serialize($exception->getViolationList(), $preferredMimeType)) 50 | ->setStatus($exception->getStatusCode()) 51 | ->setContentType(ContentTypeHeader::fromString($preferredMimeType->toString())) 52 | ->getResponse(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/HttpApi/AnnotationReaderNotInstalledException.php: -------------------------------------------------------------------------------- 1 | $data 21 | */ 22 | public function __construct(array $data = null, ?string $requestInfoSource = null) 23 | { 24 | $this->requestInfoSource = RequestInfoSource::fromString($requestInfoSource ?? $data['requestInfoSource'] ?? self::BODY); 25 | } 26 | 27 | public function getRequestInfoSource(): string 28 | { 29 | return $this->requestInfoSource->toString(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/HttpApi/HttpApiParameterException.php: -------------------------------------------------------------------------------- 1 | reader = $reader; 19 | } 20 | 21 | /** 22 | * @param class-string $className 23 | * 24 | * @throws AnnotationReaderNotInstalledException when PHP < 8.0 and annotation reader not installed 25 | * @throws MissingHttpApiException when the #[HttpApi] or @HttpApi was not found in the class 26 | */ 27 | public function read(string $className): HttpApi 28 | { 29 | $reflectionClass = new \ReflectionClass($className); 30 | 31 | if (80000 <= \PHP_VERSION_ID) { 32 | foreach ($reflectionClass->getAttributes(HttpApi::class) as $attribute) { 33 | return $attribute->newInstance(); 34 | } 35 | } 36 | 37 | if (null === $this->reader) { 38 | throw AnnotationReaderNotInstalledException::doctrine(); 39 | } 40 | 41 | $annotation = $this->reader->getClassAnnotation($reflectionClass, HttpApi::class); 42 | 43 | if (null !== $annotation) { 44 | return $annotation; 45 | } 46 | 47 | throw MissingHttpApiException::className($className); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/HttpApi/MissingHttpApiException.php: -------------------------------------------------------------------------------- 1 | requestInfoSource = $requestInfoSource; 19 | } 20 | 21 | public static function fromString(string $requestInfoSource): self 22 | { 23 | if (!\in_array($requestInfoSource, self::ENUM_VALUES)) { 24 | throw HttpApiParameterException::enum('requestInfoSource', $requestInfoSource, self::ENUM_VALUES); 25 | } 26 | 27 | return new self($requestInfoSource); 28 | } 29 | 30 | public function toString(): string 31 | { 32 | return $this->requestInfoSource; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Negotiation/ContentNegotiator.php: -------------------------------------------------------------------------------- 1 | */ 17 | private array $defaults = []; 18 | 19 | /** 20 | * @param array> $serializeformats 21 | * 22 | * @throws NotNegotiableException when the accept header cannot be matched with any mimeType 23 | */ 24 | public function __construct(array $serializeformats, string $defaultFormat) 25 | { 26 | foreach ($serializeformats as $mimeTypes) { 27 | foreach ($mimeTypes as $mimeType) { 28 | $this->availables[] = $mimeType; 29 | } 30 | } 31 | 32 | foreach (self::DEFAULT_KEYS as $key) { 33 | $this->defaults[$key] = $defaultFormat; 34 | } 35 | } 36 | 37 | public function negotiate(AcceptHeader $header): MimeType 38 | { 39 | $headerString = '' !== $header->toString() ? $header->toString() : $this->defaults['*']; 40 | 41 | $headerMimeTypes = \explode( 42 | ',', 43 | \strtr( 44 | \preg_replace('@[  ]@u', '', $headerString), 45 | $this->defaults 46 | ) 47 | ); 48 | 49 | $resultMimeTypes = []; 50 | foreach ($headerMimeTypes as $mimeType) { 51 | $splited = \explode(';', $mimeType); 52 | $key = $splited[1] ?? 'q=1.0'; 53 | if (\in_array($splited[0], $this->availables) && !\array_key_exists($key, $resultMimeTypes)) { 54 | $resultMimeTypes[$key] = $splited[0]; 55 | } 56 | } 57 | 58 | if ([] === $resultMimeTypes) { 59 | throw NotNegotiableException::notConfigured($header->toString()); 60 | } 61 | 62 | \krsort($resultMimeTypes); 63 | 64 | return MimeType::fromString(\current($resultMimeTypes)); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Negotiation/MimeType.php: -------------------------------------------------------------------------------- 1 | mimeType = $mimeType; 17 | } 18 | 19 | public static function fromString(string $mimeType): self 20 | { 21 | return new self($mimeType); 22 | } 23 | 24 | public function toString(): string 25 | { 26 | return $this->mimeType; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Negotiation/NotNegotiableException.php: -------------------------------------------------------------------------------- 1 | formats', $mimeTypes)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Request/AcceptHeader.php: -------------------------------------------------------------------------------- 1 | accept = $accept; 18 | } 19 | 20 | public static function fromString(string $accept): self 21 | { 22 | return new self($accept); 23 | } 24 | 25 | public function toString(): string 26 | { 27 | return $this->accept; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Request/BodyArgumentResolver.php: -------------------------------------------------------------------------------- 1 | httpApiReader = $httpApiReader; 29 | $this->serializer = $serializer; 30 | $this->validator = $validator; 31 | } 32 | 33 | public function supports(Request $request, ArgumentMetadata $argument): bool 34 | { 35 | $className = $argument->getType(); 36 | if (null === $className || !\class_exists($className) || ('' == (string)$request->getContent() && $argument->isNullable())) { 37 | return false; 38 | } 39 | 40 | try { 41 | $httpApi = $this->httpApiReader->read($className); 42 | } catch (MissingHttpApiException $e) { 43 | return false; 44 | } 45 | 46 | return HttpApi::BODY === $httpApi->getRequestInfoSource(); 47 | } 48 | 49 | /** 50 | * @throws SupportsException when $this->supports should have returned false 51 | * @throws EmptyBodyException when $request->getContent() is false|null|'' 52 | */ 53 | public function resolve(Request $request, ArgumentMetadata $argument): \Generator 54 | { 55 | $className = $argument->getType(); 56 | $content = (string)$request->getContent(); 57 | 58 | if (null === $className || !\class_exists($className) || ('' == $content && $argument->isNullable())) { 59 | throw SupportsException::covered(); 60 | } 61 | 62 | if ('' === $content) { 63 | throw EmptyBodyException::required(); 64 | } 65 | 66 | $type = $argument->isVariadic() ? DeserializerType::array($className) : DeserializerType::object($className); 67 | $contentType = ContentTypeHeader::fromString((string)$request->headers->get(ContentTypeHeader::NAME, '')); 68 | 69 | $deserialized = $this->serializer->deserialize($content, $type, $contentType->toMimeType()); 70 | 71 | $this->validator->validate($deserialized); 72 | 73 | yield from !\is_array($deserialized) ? [$deserialized] : $deserialized; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Request/ContentTypeHeader.php: -------------------------------------------------------------------------------- 1 | contentType = $contentType; 20 | } 21 | 22 | public static function fromString(string $contentType): self 23 | { 24 | return new self($contentType); 25 | } 26 | 27 | public function toString(): string 28 | { 29 | return $this->contentType; 30 | } 31 | 32 | public function toMimeType(): MimeType 33 | { 34 | return MimeType::fromString($this->contentType); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Request/EmptyBodyException.php: -------------------------------------------------------------------------------- 1 | getMessage()); 19 | } 20 | 21 | public function getStatusCode(): int 22 | { 23 | return Response::HTTP_BAD_REQUEST; 24 | } 25 | 26 | public static function required(): self 27 | { 28 | return new self('The request body cannot be empty.'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Request/QueryStringArgumentResolver.php: -------------------------------------------------------------------------------- 1 | httpApiReader = $httpApiReader; 31 | $this->denormalizer = $denormalizer; 32 | $this->validator = $validator; 33 | } 34 | 35 | public function supports(Request $request, ArgumentMetadata $argument): bool 36 | { 37 | $className = $argument->getType(); 38 | if (null === $className || !\class_exists($className) || ([] === $request->query->all() && $argument->isNullable())) { 39 | return false; 40 | } 41 | 42 | try { 43 | $httpApi = $this->httpApiReader->read($className); 44 | } catch (MissingHttpApiException $e) { 45 | return false; 46 | } 47 | 48 | return HttpApi::QUERY_STRING === $httpApi->getRequestInfoSource(); 49 | } 50 | 51 | /** 52 | * @throws SupportsException when $this->supports should have returned false 53 | */ 54 | public function resolve(Request $request, ArgumentMetadata $argument): \Generator 55 | { 56 | $className = $argument->getType(); 57 | if (null === $className || !\class_exists($className)) { 58 | throw SupportsException::covered(); 59 | } 60 | 61 | /** @var object $denormalized */ 62 | $denormalized = $this->denormalizer->denormalize($request->query->all(), $className); 63 | 64 | $this->validator->validate($denormalized); 65 | 66 | yield $denormalized; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Request/SupportsException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/Response/ContentTypeHeader.php: -------------------------------------------------------------------------------- 1 | contentType = $contentType; 19 | } 20 | 21 | public static function fromString(string $contentType): self 22 | { 23 | return new self($contentType); 24 | } 25 | 26 | public function toString(): string 27 | { 28 | return $this->contentType; 29 | } 30 | 31 | public function toStringWithProblem(): string 32 | { 33 | return $this->withProblem($this->contentType); 34 | } 35 | 36 | private function withProblem(string $contentType): string 37 | { 38 | $problemContentType = ''; 39 | 40 | $parts = \explode('/', $contentType); 41 | 42 | $limit = \count($parts) - 1; 43 | 44 | for ($i = $limit; $i >= 0; --$i) { 45 | if ($i !== $limit) { 46 | $problemContentType = '/' . $problemContentType; 47 | } 48 | 49 | $problemContentType = $parts[$i] . $problemContentType; 50 | 51 | if ($i === $limit) { 52 | $problemContentType = self::PROBLEM . $problemContentType; 53 | } 54 | } 55 | 56 | return $problemContentType; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Response/ErrorResponseResolver.php: -------------------------------------------------------------------------------- 1 | contentNegotiator = $contentNegotiator; 23 | $this->responseBuilder = $responseBuilder; 24 | $this->serializer = $serializer; 25 | } 26 | 27 | public function resolve(ErrorInterface $e, Request $request): Response 28 | { 29 | $acceptHeader = AcceptHeader::fromString((string)$request->headers->get(AcceptHeader::NAME, '')); 30 | $preferredMimeType = $this->contentNegotiator->negotiate($acceptHeader); 31 | 32 | return $this->responseBuilder 33 | ->setContent($this->serializer->serialize($e->getContent(), $preferredMimeType)) 34 | ->setContentType(ContentTypeHeader::fromString($preferredMimeType->toString())) 35 | ->setStatus($e->getStatusCode()) 36 | ->getResponse(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Response/ResponseBuilder.php: -------------------------------------------------------------------------------- 1 | content, $this->status, $this->generateHeaders()); 21 | } 22 | 23 | public function setContent(string $content): self 24 | { 25 | $this->content = $content; 26 | 27 | return $this; 28 | } 29 | 30 | public function setStatus(int $status): self 31 | { 32 | $this->status = $status; 33 | 34 | return $this; 35 | } 36 | 37 | public function setContentType(ContentTypeHeader $contentType): self 38 | { 39 | $this->contentType = $contentType; 40 | 41 | return $this; 42 | } 43 | 44 | /** 45 | * @return array 46 | */ 47 | private function generateHeaders(): array 48 | { 49 | /** @var array $headers */ 50 | $headers = []; 51 | 52 | if (null !== $this->contentType) { 53 | $headers[ContentTypeHeader::NAME] = 400 <= $this->status && 500 > $this->status 54 | ? $this->contentType->toStringWithProblem() 55 | : $this->contentType->toString(); 56 | } 57 | 58 | return $headers; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Response/ResponseListener.php: -------------------------------------------------------------------------------- 1 | httpApiReader = $httpApiReader; 26 | $this->successResponseResolver = $successResponseResolver; 27 | } 28 | 29 | public function transform(ViewEvent $viewEvent): void 30 | { 31 | /** @var mixed $controllerResult */ 32 | $controllerResult = $viewEvent->getControllerResult(); 33 | 34 | if (\is_object($controllerResult)) { 35 | try { 36 | $this->httpApiReader->read(\get_class($controllerResult)); 37 | } catch (MissingHttpApiException $e) { 38 | return; 39 | } 40 | 41 | $viewEvent->setResponse($this->createResponse($controllerResult, $viewEvent->getRequest())); 42 | } 43 | 44 | if (\is_array($controllerResult)) { 45 | try { 46 | $collection = ObjectList::fromArray($controllerResult); 47 | if (false !== $firstElement = $collection->first()) { 48 | $this->httpApiReader->read(\get_class($firstElement)); 49 | } 50 | } catch (TypeException | MissingHttpApiException $e) { 51 | return; 52 | } 53 | 54 | $viewEvent->setResponse($this->createResponse($collection->toArray(), $viewEvent->getRequest())); 55 | } 56 | } 57 | 58 | /** 59 | * @param object[]|object $data 60 | */ 61 | private function createResponse($data, Request $request): Response 62 | { 63 | return $this->successResponseResolver->resolve($data, $request); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Response/SuccessResponseResolver.php: -------------------------------------------------------------------------------- 1 | contentNegotiator = $contentNegotiator; 22 | $this->responseBuilder = $responseBuilder; 23 | $this->serializer = $serializer; 24 | } 25 | 26 | /** 27 | * @param object[]|object $data 28 | */ 29 | public function resolve($data, Request $request): Response 30 | { 31 | $acceptHeader = AcceptHeader::fromString((string)$request->headers->get(AcceptHeader::NAME, '')); 32 | $preferredMimeType = $this->contentNegotiator->negotiate($acceptHeader); 33 | 34 | return $this->responseBuilder 35 | ->setContent($this->serializer->serialize($data, $preferredMimeType)) 36 | ->setContentType(ContentTypeHeader::fromString($preferredMimeType->toString())) 37 | ->getResponse(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Serialize/DeserializeEvent.php: -------------------------------------------------------------------------------- 1 | data = $data; 17 | $this->format = $format; 18 | } 19 | 20 | /** 21 | * @internal 22 | */ 23 | public static function from(string $data, string $format): self 24 | { 25 | return new self($data, $format); 26 | } 27 | 28 | public function getData(): string 29 | { 30 | return $this->data; 31 | } 32 | 33 | public function getFormat(): string 34 | { 35 | return $this->format; 36 | } 37 | 38 | public function getContext(): array 39 | { 40 | return $this->context; 41 | } 42 | 43 | public function mergeToContext(array $context): void 44 | { 45 | $this->context = \array_merge($this->context, $context); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Serialize/DeserializerType.php: -------------------------------------------------------------------------------- 1 | type = $type; 17 | } 18 | 19 | /** 20 | * @param class-string $fqcn 21 | */ 22 | public static function object(string $fqcn): self 23 | { 24 | return new self($fqcn); 25 | } 26 | 27 | /** 28 | * @param class-string $fqcn 29 | */ 30 | public static function array(string $fqcn): self 31 | { 32 | return new self($fqcn . '[]'); 33 | } 34 | 35 | public function toString(): string 36 | { 37 | return $this->type; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Serialize/FormatException.php: -------------------------------------------------------------------------------- 1 | formats', $mimeType->toString())); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Serialize/FormatMapper.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private array $map = []; 18 | 19 | /** 20 | * @param array> $serializeFormats 21 | */ 22 | public function __construct(array $serializeFormats) 23 | { 24 | foreach ($serializeFormats as $format => $mimeTypes) { 25 | foreach ($mimeTypes as $mimeType) { 26 | $this->map[$mimeType] = $format; 27 | } 28 | } 29 | } 30 | 31 | /** 32 | * @throws FormatException when the mimeType was not mapped to a format in config 33 | */ 34 | public function byMimeType(MimeType $mimeType): string 35 | { 36 | if (!isset($this->map[$mimeType->toString()])) { 37 | throw FormatException::notConfigured($mimeType); 38 | } 39 | 40 | return $this->map[$mimeType->toString()]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Serialize/SerializeEvent.php: -------------------------------------------------------------------------------- 1 | data = $data; 24 | $this->format = $format; 25 | } 26 | 27 | /** 28 | * @param object[]|object $data 29 | * 30 | * @internal 31 | */ 32 | public static function from($data, string $format): self 33 | { 34 | return new self($data, $format); 35 | } 36 | 37 | /** 38 | * @return object[]|object 39 | */ 40 | public function getData() 41 | { 42 | return $this->data; 43 | } 44 | 45 | public function getFormat(): string 46 | { 47 | return $this->format; 48 | } 49 | 50 | public function getContext(): array 51 | { 52 | return $this->context; 53 | } 54 | 55 | public function mergeToContext(array $context): void 56 | { 57 | $this->context = \array_merge($this->context, $context); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Serialize/Serializer.php: -------------------------------------------------------------------------------- 1 | eventDispatcher = $eventDispatcher; 23 | $this->serializer = $serializer; 24 | $this->formatMapper = $formatMapper; 25 | } 26 | 27 | /** 28 | * @param object[]|object $data 29 | */ 30 | public function serialize($data, MimeType $mimeType): string 31 | { 32 | $format = $this->formatMapper->byMimeType($mimeType); 33 | 34 | /** @var SerializeEvent $serializeEvent */ 35 | $serializeEvent = $this->eventDispatcher->dispatch(SerializeEvent::from($data, $format), SerializeEvent::NAME); 36 | 37 | return $this->serializer->serialize($data, $format, $serializeEvent->getContext()); 38 | } 39 | 40 | /** 41 | * @return object[]|object 42 | */ 43 | public function deserialize(string $data, DeserializerType $type, MimeType $mimeType) 44 | { 45 | $format = $this->formatMapper->byMimeType($mimeType); 46 | 47 | /** @var DeserializeEvent $deserializeEvent */ 48 | $deserializeEvent = $this->eventDispatcher->dispatch(DeserializeEvent::from($data, $format), DeserializeEvent::NAME); 49 | 50 | /** @var object[]|object $deserialized */ 51 | $deserialized = $this->serializer->deserialize($data, $type->toString(), $format, $deserializeEvent->getContext()); 52 | 53 | return $deserialized; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Serialize/SerializerInterface.php: -------------------------------------------------------------------------------- 1 | list = $array; 23 | } 24 | 25 | /** 26 | * @param array $array 27 | */ 28 | public static function fromArray(array $array): self 29 | { 30 | if ([] === $array) { 31 | return new self([]); 32 | } 33 | 34 | $first = \current($array); 35 | 36 | if (!\is_object($first)) { 37 | throw TypeException::notObject(); 38 | } 39 | 40 | $refClass = \get_class($first); 41 | 42 | $objectsArray = []; 43 | foreach ($array as $object) { 44 | if (!\is_object($object)) { 45 | throw TypeException::notObject(); 46 | } 47 | 48 | if ($refClass !== \get_class($object)) { 49 | throw TypeException::notSameClass(); 50 | } 51 | 52 | $objectsArray[] = $object; 53 | } 54 | 55 | return new self($objectsArray); 56 | } 57 | 58 | /** 59 | * @return object[] 60 | */ 61 | public function toArray(): array 62 | { 63 | return $this->list; 64 | } 65 | 66 | /** 67 | * @return object|false 68 | */ 69 | public function first() 70 | { 71 | if ([] === $this->list) { 72 | return false; 73 | } 74 | 75 | return $this->list[0]; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Type/TypeException.php: -------------------------------------------------------------------------------- 1 | validator = $validator; 20 | } 21 | 22 | /** 23 | * @param object[]|object $data 24 | * 25 | * @throws ValidationException when the validator returns any violation 26 | */ 27 | public function validate($data): void 28 | { 29 | $violations = $this->validator->validate($data); 30 | 31 | if (0 < \count($violations)) { 32 | throw ValidationException::fromViolationList($violations); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ViolinesRestBundle.php: -------------------------------------------------------------------------------- 1 | getConfigTreeBuilder() 24 | ->buildTree(); 25 | 26 | $finalized = $node->finalize($node->normalize($input)); 27 | 28 | $this->assertEquals($expected, $finalized); 29 | } 30 | 31 | public function providerShouldCheckConfiguration(): array 32 | { 33 | return [ 34 | [ 35 | [], 36 | [ 37 | 'serialize' => [ 38 | 'formats' => [ 39 | 'json' => ['application/json'], 40 | 'xml' => ['application/xml'], 41 | ], 42 | 'format_default' => 'application/json', 43 | ], 44 | ], 45 | ], 46 | [ 47 | [ 48 | 'serialize' => [ 49 | 'formats' => [ 50 | 'json' => [ 51 | 'application/json', 52 | 'application/json+ld', 53 | ], 54 | 'xml' => [ 55 | 'application/xml', 56 | 'text/html', 57 | ], 58 | ], 59 | 'format_default' => 'application/json', 60 | ], 61 | ], 62 | [ 63 | 'serialize' => [ 64 | 'formats' => [ 65 | 'json' => [ 66 | 'application/json', 67 | 'application/json+ld', 68 | ], 69 | 'xml' => [ 70 | 'application/xml', 71 | 'text/html', 72 | ], 73 | ], 74 | 'format_default' => 'application/json', 75 | ], 76 | ], 77 | ], 78 | ]; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/DependencyInjection/ViolinesRestExtensionTest.php: -------------------------------------------------------------------------------- 1 | load(); 21 | 22 | $this->assertContainerBuilderHasService($serviceId); 23 | } 24 | 25 | public function providerForEntryPointServiceIds(): array 26 | { 27 | return [ 28 | [ 29 | 'violines_rest.error.validation_exception_listener', 30 | ], 31 | [ 32 | 'violines_rest.error.error_listener', 33 | ], 34 | [ 35 | 'violines_rest.http_api.http_api_reader', 36 | ], 37 | [ 38 | 'violines_rest.negotiation.content_negotiator', 39 | ], 40 | [ 41 | 'violines_rest.response.error_response_resolver', 42 | ], 43 | [ 44 | 'violines_rest.response.response_builder', 45 | ], 46 | [ 47 | 'violines_rest.response.response_listener', 48 | ], 49 | [ 50 | 'violines_rest.response.success_response_resolver', 51 | ], 52 | [ 53 | 'violines_rest.request.body_argument_resolver', 54 | ], 55 | [ 56 | 'violines_rest.request.query_string_argument_resolver', 57 | ], 58 | [ 59 | 'violines_rest.serialize.format_mapper', 60 | ], 61 | [ 62 | 'violines_rest.serialize.serializer', 63 | ], 64 | [ 65 | 'violines_rest.validation.validator', 66 | ], 67 | ]; 68 | } 69 | 70 | /** 71 | * @dataProvider providerShouldCheckServiceConfigurationArguments 72 | */ 73 | public function testShouldCheckServiceConfigurationArguments(string $serviceId, int $argNo, $expected): void 74 | { 75 | $this->load(); 76 | 77 | $this->assertContainerBuilderHasServiceDefinitionWithArgument($serviceId, $argNo, $expected); 78 | } 79 | 80 | public function providerShouldCheckServiceConfigurationArguments(): array 81 | { 82 | return [ 83 | [ 84 | 'violines_rest.negotiation.content_negotiator', 85 | 0, 86 | [ 87 | 'json' => ['application/json'], 88 | 'xml' => ['application/xml'], 89 | ], 90 | ], 91 | [ 92 | 'violines_rest.negotiation.content_negotiator', 93 | 1, 94 | 'application/json', 95 | ], 96 | [ 97 | 'violines_rest.serialize.format_mapper', 98 | 0, 99 | [ 100 | 'json' => ['application/json'], 101 | 'xml' => ['application/xml'], 102 | ], 103 | ], 104 | ]; 105 | } 106 | 107 | protected function getContainerExtensions(): array 108 | { 109 | return [new ViolinesRestExtension()]; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/Error/ErrorListenerTest.php: -------------------------------------------------------------------------------- 1 | errorListener = new ErrorListener( 46 | new HttpApiReader(new AnnotationReader()), 47 | new ErrorResponseResolver( 48 | new ContentNegotiator(Config::SERIALIZE_FORMATS, Config::SERIALIZE_FORMAT_DEFAULT), 49 | new ResponseBuilder(), 50 | new Serializer(new SymfonyEventDispatcherFake(), new SymfonySerializerFake(), new FormatMapper(Config::SERIALIZE_FORMATS)) 51 | ) 52 | ); 53 | } 54 | 55 | public function testShouldCreateErrorJson(): void 56 | { 57 | $expectedJson = '{"message": "Test 400"}'; 58 | $exception = new ErrorException(); 59 | $exception->setContent(new Error('Test 400')); 60 | 61 | $exceptionEvent = new ExceptionEvent( 62 | $this->prophesize(HttpKernel::class)->reveal(), 63 | $this->createMockRequestWithHeaders()->reveal(), 64 | HttpKernelInterface::MASTER_REQUEST, 65 | $exception 66 | ); 67 | 68 | $this->errorListener->handle($exceptionEvent); 69 | 70 | $response = $exceptionEvent->getResponse(); 71 | 72 | $this->assertJsonStringEqualsJsonString($expectedJson, $response->getContent()); 73 | $this->assertEquals($exception->getStatusCode(), $response->getStatusCode()); 74 | } 75 | 76 | public function testShouldSkipListener(): void 77 | { 78 | $exception = new \Exception(); 79 | 80 | $exceptionEvent = new ExceptionEvent( 81 | $this->prophesize(HttpKernel::class)->reveal(), 82 | $this->createMockRequestWithHeaders()->reveal(), 83 | HttpKernelInterface::MASTER_REQUEST, 84 | $exception 85 | ); 86 | 87 | $this->errorListener->handle($exceptionEvent); 88 | 89 | $this->assertNull($exceptionEvent->getResponse()); 90 | } 91 | 92 | public function testShouldThrowMissingHttpApiException(): void 93 | { 94 | $this->expectException(MissingHttpApiException::class); 95 | 96 | $exception = new ErrorException(); 97 | $exception->setContent(new Gum()); 98 | 99 | $exceptionEvent = new ExceptionEvent( 100 | $this->prophesize(HttpKernel::class)->reveal(), 101 | $this->createMockRequestWithHeaders()->reveal(), 102 | HttpKernelInterface::MASTER_REQUEST, 103 | $exception 104 | ); 105 | 106 | $this->errorListener->handle($exceptionEvent); 107 | } 108 | 109 | private function createMockRequestWithHeaders() 110 | { 111 | $request = $this->prophesize(HttpFoundationRequest::class); 112 | 113 | $request->headers = new HeaderBag([ 114 | 'Accept' => 'application/pdf, application/json, application/xml', 115 | 'Content-Type' => 'application/json', 116 | ]); 117 | 118 | return $request; 119 | } 120 | } 121 | 122 | class ErrorException extends \LogicException implements \Throwable, ErrorInterface 123 | { 124 | private $content; 125 | 126 | public function getContent(): object 127 | { 128 | return $this->content; 129 | } 130 | 131 | public function getStatusCode(): int 132 | { 133 | return 400; 134 | } 135 | 136 | public function setContent(object $content): void 137 | { 138 | $this->content = $content; 139 | } 140 | } 141 | 142 | /** 143 | * @HttpApi 144 | */ 145 | class Error 146 | { 147 | public $message; 148 | 149 | public function __construct(string $message) 150 | { 151 | $this->message = $message; 152 | } 153 | } 154 | 155 | class Gum 156 | { 157 | public int $weight; 158 | 159 | public bool $tastesGood; 160 | } 161 | -------------------------------------------------------------------------------- /tests/Error/ErrorTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('about:blank', $content->getType()); 23 | $this->assertEquals('General Error', $content->getTitle()); 24 | $this->assertEquals($message, $content->getDetail()); 25 | } 26 | 27 | /** 28 | * @requires PHP >= 8.0 29 | */ 30 | public function testShouldHaveAttribute(): void 31 | { 32 | $reflectionClass = new \ReflectionClass(Error::class); 33 | 34 | $this->assertInstanceOf(HttpApi::class, $reflectionClass->getAttributes(HttpApi::class)[0]->newInstance()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Error/NotAcceptableListenerTest.php: -------------------------------------------------------------------------------- 1 | prophesize(LoggerInterface::class); 40 | $logger->log('debug', $expectedLogMessage)->shouldBeCalled(); 41 | $notAcceptableListener = new NotAcceptableListener(new ResponseBuilder(), $logger->reveal()); 42 | 43 | $exceptionEvent = new ExceptionEvent($this->prophesize(HttpKernel::class)->reveal(), $this->prophesize(HttpFoundationRequest::class)->reveal(), HttpKernelInterface::MASTER_REQUEST, $givenException); 44 | 45 | $notAcceptableListener->handle($exceptionEvent); 46 | $response = $exceptionEvent->getResponse(); 47 | $this->assertEquals(Response::HTTP_NOT_ACCEPTABLE, $response->getStatusCode()); 48 | } 49 | 50 | public function providerShouldReturnNotAcceptableAndLog(): array 51 | { 52 | return [ 53 | [ 54 | FormatException::notConfigured(MimeType::fromString('text/html')), 55 | 'MimeType text/html was not configured for any Format. Check configuration under serialize > formats', 56 | ], 57 | [ 58 | NotNegotiableException::notConfigured('application/atom+xml'), 59 | 'None of the accepted mimetypes application/atom+xml are configured for any Format. Check configuration under serialize > formats', 60 | ], 61 | ]; 62 | } 63 | 64 | public function testShouldReturnNotAcceptableAndNullLog(): void 65 | { 66 | $exceptionEvent = new ExceptionEvent($this->prophesize(HttpKernel::class)->reveal(), $this->prophesize(HttpFoundationRequest::class)->reveal(), HttpKernelInterface::MASTER_REQUEST, NotNegotiableException::notConfigured('application/atom+xml')); 67 | 68 | $listenerWithNullLogger = new NotAcceptableListener(new ResponseBuilder(), null); 69 | 70 | $listenerWithNullLogger->handle($exceptionEvent); 71 | 72 | $this->assertEquals(Response::HTTP_NOT_ACCEPTABLE, $exceptionEvent->getResponse()->getStatusCode()); 73 | } 74 | 75 | public function testShouldSkipListener(): void 76 | { 77 | $exception = new \Exception(); 78 | 79 | $exceptionEvent = new ExceptionEvent( 80 | $this->prophesize(HttpKernel::class)->reveal(), 81 | $this->prophesize(HttpFoundationRequest::class)->reveal(), 82 | HttpKernelInterface::MASTER_REQUEST, 83 | $exception 84 | ); 85 | 86 | $listenerWithNullLogger = new NotAcceptableListener(new ResponseBuilder(), null); 87 | $listenerWithNullLogger->handle($exceptionEvent); 88 | 89 | $this->assertNull($exceptionEvent->getResponse()); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tests/Error/ValidationExceptionListenerTest.php: -------------------------------------------------------------------------------- 1 | listener = new ValidationExceptionListener( 43 | new ContentNegotiator(Config::SERIALIZE_FORMATS, Config::SERIALIZE_FORMAT_DEFAULT), 44 | new ResponseBuilder(), 45 | new Serializer(new SymfonyEventDispatcherFake(), new SymfonySerializerFake(), new FormatMapper(Config::SERIALIZE_FORMATS)) 46 | ); 47 | } 48 | 49 | public function testShouldCreateViolationResponse(): void 50 | { 51 | $expectedEncodedError = '[{"message":"message","messageTemplate":"message_tpl","parameters":[],"plural":null,"root":null,"propertyPath":"path","invalidValue":null,"code":null}]'; 52 | 53 | $violationList = new ConstraintViolationListFake(); 54 | $violationList->add(new ConstraintViolationFake()); 55 | $exception = ValidationException::fromViolationList($violationList); 56 | 57 | $exceptionEvent = new ExceptionEvent( 58 | $this->prophesize(HttpKernel::class)->reveal(), 59 | $this->createMockRequestWithHeaders()->reveal(), 60 | HttpKernelInterface::MASTER_REQUEST, 61 | $exception 62 | ); 63 | 64 | $this->listener->handle($exceptionEvent); 65 | 66 | $this->assertSame($expectedEncodedError, $exceptionEvent->getResponse()->getContent()); 67 | } 68 | 69 | public function testShouldSkipListener(): void 70 | { 71 | $exception = new \Exception(); 72 | 73 | $exceptionEvent = new ExceptionEvent( 74 | $this->prophesize(HttpKernel::class)->reveal(), 75 | $this->createMockRequestWithHeaders()->reveal(), 76 | HttpKernelInterface::MASTER_REQUEST, 77 | $exception 78 | ); 79 | 80 | $this->listener->handle($exceptionEvent); 81 | 82 | $this->assertNull($exceptionEvent->getResponse()); 83 | } 84 | 85 | private function createMockRequestWithHeaders() 86 | { 87 | $request = $this->prophesize(HttpFoundationRequest::class); 88 | 89 | $request->headers = new HeaderBag([ 90 | 'Accept' => 'application/pdf, application/json, application/xml', 91 | 'Content-Type' => 'application/json', 92 | ]); 93 | 94 | return $request; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/Error/ValidationExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(ValidationException::class, $exception); 21 | } 22 | 23 | public function testShouldReturnViolationList(): void 24 | { 25 | $violationList = new ConstraintViolationListFake(); 26 | 27 | $exception = ValidationException::fromViolationList($violationList); 28 | 29 | $this->assertEquals($violationList, $exception->getViolationList()); 30 | } 31 | 32 | public function testExceptionShouldReturnBadRequestHttpCode(): void 33 | { 34 | $exception = ValidationException::fromViolationList(new ConstraintViolationListFake()); 35 | 36 | $this->assertEquals(400, $exception->getStatusCode()); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Fake/ConstraintViolationFake.php: -------------------------------------------------------------------------------- 1 | violations[] = $violation; 19 | } 20 | 21 | public function addAll(ConstraintViolationListInterface $otherList) 22 | { 23 | // test 24 | } 25 | 26 | public function get(int $offset): ConstraintViolationInterface 27 | { 28 | return $this->violations[$offset]; 29 | } 30 | 31 | public function has(int $offset): bool 32 | { 33 | return isset($this->violations[$offset]); 34 | } 35 | 36 | public function set(int $offset, ConstraintViolationInterface $violation) 37 | { 38 | $this->violations[$offset] = $violation; 39 | } 40 | 41 | public function remove(int $offset) 42 | { 43 | unset($this->violations[$offset]); 44 | } 45 | 46 | public function rewind() 47 | { 48 | $this->count = 0; 49 | } 50 | 51 | public function current() 52 | { 53 | return $this->violations[$this->count]; 54 | } 55 | 56 | public function key() 57 | { 58 | return $this->count; 59 | } 60 | 61 | public function next() 62 | { 63 | return $this->count++; 64 | } 65 | 66 | public function valid() 67 | { 68 | return isset($this->violations[$this->count]); 69 | } 70 | 71 | public function offsetExists($offset): bool 72 | { 73 | return isset($this->violations[$offset]); 74 | } 75 | 76 | public function offsetGet($offset) 77 | { 78 | // test 79 | } 80 | 81 | public function offsetSet($offset, $value): void 82 | { 83 | // test 84 | } 85 | 86 | public function offsetUnset($offset): void 87 | { 88 | // test 89 | } 90 | 91 | public function count() 92 | { 93 | return \count($this->violations); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/Fake/SymfonyEventDispatcherFake.php: -------------------------------------------------------------------------------- 1 | serializer = new SymfonySerializer( 28 | [new ObjectNormalizer($classMetadataFactory, $metadataAwareNameConverter), new ArrayDenormalizer()], 29 | ['json' => new JsonEncoder()] 30 | ); 31 | } 32 | 33 | public function serialize(mixed $data, string $format, array $context = []): string 34 | { 35 | return $this->serializer->serialize($data, $format, $context); 36 | } 37 | 38 | public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed 39 | { 40 | return $this->serializer->deserialize($data, $type, $format, $context); 41 | } 42 | 43 | public function normalize($object, $format = null, $context = []) 44 | { 45 | return $this->serializer->normalize($object, $format, $context); 46 | } 47 | 48 | public function supportsNormalization($data, $format = null) 49 | { 50 | return true; 51 | } 52 | 53 | public function denormalize($data, $type, $format = null, $context = []) 54 | { 55 | return $this->serializer->denormalize($data, $type, $format, $context); 56 | } 57 | 58 | public function supportsDenormalization($data, $type, $format = null) 59 | { 60 | return true; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Functional/ControllerTest.php: -------------------------------------------------------------------------------- 1 | boot(); 31 | } 32 | 33 | public function testReturnsOne(): void 34 | { 35 | $request = Request::create('/returnsOne'); 36 | $request->headers->set('Accept', MimeTypes::APPLICATION_JSON); 37 | 38 | $response = static::$app->handle($request); 39 | 40 | self::assertSame(MimeTypes::APPLICATION_JSON, $response->headers->get('Content-Type')); 41 | self::assertJsonStringEqualsJsonString(<<getContent()); 44 | } 45 | 46 | public function testReturnsMany(): void 47 | { 48 | $request = Request::create('/returnsMany'); 49 | $request->headers->set('Accept', MimeTypes::APPLICATION_JSON); 50 | 51 | $response = static::$app->handle($request); 52 | 53 | self::assertSame(MimeTypes::APPLICATION_JSON, $response->headers->get('Content-Type')); 54 | self::assertJsonStringEqualsJsonString(<<getContent()); 60 | } 61 | 62 | public function testReconstitutesOne(): void 63 | { 64 | $submitted = <<createPostRequest('/reconstitutesOne', $submitted); 69 | $response = static::$app->handle($request); 70 | 71 | self::assertJsonStringEqualsJsonString($submitted, $response->getContent()); 72 | } 73 | 74 | public function testReconstitutesMultiple(): void 75 | { 76 | $submitted = <<createPostRequest('reconstitutesMany', $submitted); 84 | 85 | $response = static::$app->handle($request); 86 | 87 | self::assertJsonStringEqualsJsonString($submitted, $response->getContent()); 88 | } 89 | 90 | private function createPostRequest(string $uri, string $body): Request 91 | { 92 | $request = Request::create($uri, Request::METHOD_POST, [], [], [], [], $body); 93 | $request->headers->set('Content-Type', MimeTypes::APPLICATION_JSON); 94 | $request->headers->set('Accept', MimeTypes::APPLICATION_JSON); 95 | 96 | return $request; 97 | } 98 | } 99 | 100 | /** @internal */ 101 | final class TestKernel extends Kernel 102 | { 103 | use MicroKernelTrait; 104 | private vfsStreamDirectory $fileStreamRoot; 105 | 106 | public function __construct() 107 | { 108 | parent::__construct('test', false); 109 | $this->fileStreamRoot = vfsStream::setup(); 110 | } 111 | 112 | public function registerBundles(): array 113 | { 114 | return [ 115 | new FrameworkBundle(), 116 | new ViolinesRestBundle(), 117 | ]; 118 | } 119 | 120 | protected function configureRoutes(RoutingConfigurator $routes): void 121 | { 122 | $pathControllerMap = [ 123 | '/returnsOne' => [HugController::class, 'returnsOne'], 124 | '/returnsMany' => [HugController::class, 'returnsMany'], 125 | '/reconstitutesOne' => [HugController::class, 'reconstitutesOne'], 126 | '/reconstitutesMany' => [HugController::class, 'reconstitutesMany'], 127 | ]; 128 | 129 | foreach ($pathControllerMap as $path => $controller) { 130 | $routes->add($path, $path)->controller($controller); 131 | } 132 | } 133 | 134 | protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void 135 | { 136 | } 137 | 138 | public function getCacheDir(): string 139 | { 140 | return $this->fileStreamRoot->url() . '/cache/'; 141 | } 142 | 143 | public function getLogDir(): string 144 | { 145 | return $this->fileStreamRoot->url() . '/logs'; 146 | } 147 | } 148 | 149 | final class HugController 150 | { 151 | public function returnsOne(): Hug 152 | { 153 | return new Hug('Forest'); 154 | } 155 | 156 | public function returnsMany(): array 157 | { 158 | return [ 159 | new Hug('Jenny'), 160 | new Hug('Forest'), 161 | ]; 162 | } 163 | 164 | public function reconstitutesOne(Hug $hug): Hug 165 | { 166 | return $hug; 167 | } 168 | 169 | /** 170 | * @return iterable 171 | */ 172 | public function reconstitutesMany(Hug ...$hugs): iterable 173 | { 174 | return $hugs; 175 | } 176 | } 177 | 178 | /** 179 | * @internal 180 | * @Violines\RestBundle\HttpApi\HttpApi 181 | */ 182 | final class Hug 183 | { 184 | /** 185 | * @psalm-readonly 186 | */ 187 | public string $to; 188 | 189 | public function __construct(string $to) 190 | { 191 | $this->to = $to; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /tests/HttpApi/AnnotationReaderNotInstalledExceptionTest.php: -------------------------------------------------------------------------------- 1 | getMessage()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/HttpApi/HttpApiParameterExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(HttpApiParameterException::class, $exception); 20 | $this->assertEquals('The value wrongValue for the parameter \'properyName\' for \'#[HttpApi]\' or \'@HttpApi\' is not allowed. Expected values: ["expected1","expected2"].', $exception->getMessage()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/HttpApi/HttpApiReaderTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(HttpApi::class, $httpApiReader->read(HttpApiDefault::class)); 24 | } 25 | 26 | public function testShouldThrowAnnotationReaderNotInstalledException(): void 27 | { 28 | $this->expectException(AnnotationReaderNotInstalledException::class); 29 | 30 | $httpApiReader = new HttpApiReader(); 31 | 32 | $httpApiReader->read(AnnotationOnly::class); 33 | } 34 | 35 | public function testShouldThrowMissingHttpApiException(): void 36 | { 37 | $httpApiReader = new HttpApiReader(new AnnotationReader()); 38 | 39 | $this->expectException(MissingHttpApiException::class); 40 | 41 | $httpApiReader->read(FakeAnnotation::class); 42 | } 43 | 44 | /** 45 | * @requires PHP >= 8.0 46 | */ 47 | public function testShouldReturnHttpApiAttribute(): void 48 | { 49 | $httpApiReader = new HttpApiReader(); 50 | 51 | $this->assertInstanceOf(HttpApi::class, $httpApiReader->read(HttpApiQueryString::class)); 52 | } 53 | } 54 | 55 | /** 56 | * @HttpApi 57 | * @AnyAnnotation 58 | */ 59 | class HttpApiDefault 60 | { 61 | public int $weight; 62 | 63 | public string $name; 64 | } 65 | 66 | /** 67 | * @AnyAnnotation 68 | */ 69 | class FakeAnnotation 70 | { 71 | public int $weight; 72 | 73 | public bool $tastesGood; 74 | } 75 | 76 | /** 77 | * @HttpApi 78 | */ 79 | class AnnotationOnly 80 | { 81 | public int $weight; 82 | 83 | public bool $tastesGood; 84 | } 85 | 86 | #[HttpApi(requestInfoSource: 'query_string')] 87 | class HttpApiQueryString 88 | { 89 | public int $id; 90 | 91 | public string $name; 92 | } 93 | 94 | /** 95 | * @Annotation 96 | * @Target("CLASS") 97 | */ 98 | class AnyAnnotation 99 | { 100 | } 101 | -------------------------------------------------------------------------------- /tests/HttpApi/HttpApiTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(HttpApi::class, $httpApi); 23 | $this->assertEquals($expected, $httpApi->getRequestInfoSource()); 24 | } 25 | 26 | public function providerShouldGenerateFromAssocArray(): array 27 | { 28 | return [ 29 | [ 30 | 'body', 31 | [], 32 | ], 33 | [ 34 | 'body', 35 | ['requestInfoSource' => 'body'], 36 | ], 37 | ]; 38 | } 39 | 40 | public function testShouldGenerateDefault() 41 | { 42 | $httpApi = new HttpApi(); 43 | 44 | $this->assertInstanceOf(HttpApi::class, $httpApi); 45 | $this->assertEquals('body', $httpApi->getRequestInfoSource()); 46 | } 47 | 48 | /** 49 | * @dataProvider providerShouldGenerateFromStringParams 50 | */ 51 | public function testShouldGenerateFromStringParams($expected, $requestInfoSource) 52 | { 53 | $httpApi = new HttpApi(null, $requestInfoSource); 54 | 55 | $this->assertInstanceOf(HttpApi::class, $httpApi); 56 | $this->assertEquals($expected, $httpApi->getRequestInfoSource()); 57 | } 58 | 59 | public function providerShouldGenerateFromStringParams(): array 60 | { 61 | return [ 62 | [ 63 | 'body', 64 | 'body', 65 | ], 66 | [ 67 | 'query_string', 68 | 'query_string', 69 | ], 70 | ]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/HttpApi/MissingHttpApiExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(MissingHttpApiException::class, $exception); 20 | $this->assertEquals('\'#[HttpApi]\' or \'@HttpApi\' for CustomClass not found.', $exception->getMessage()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/HttpApi/RequestInfoSourceTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(RequestInfoSource::class, $requestInfoSource); 22 | $this->assertEquals(HttpApi::QUERY_STRING, $requestInfoSource->toString()); 23 | } 24 | 25 | public function testShouldThrowHttpApiParameterException(): void 26 | { 27 | $this->expectException(HttpApiParameterException::class); 28 | 29 | RequestInfoSource::fromString('test'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Negotiation/ContentNegotiatorTest.php: -------------------------------------------------------------------------------- 1 | contentNegotiator = new ContentNegotiator(Config::SERIALIZE_FORMATS, Config::SERIALIZE_FORMAT_DEFAULT); 29 | } 30 | 31 | /** 32 | * @dataProvider providerShouldNegotiateContentType 33 | */ 34 | public function testShouldNegotiateContentType(string $expected, string $accept): void 35 | { 36 | $accept = AcceptHeader::fromString($accept); 37 | 38 | $this->assertEquals($expected, $this->contentNegotiator->negotiate($accept)->toString()); 39 | } 40 | 41 | public function providerShouldNegotiateContentType(): array 42 | { 43 | return [ 44 | [MimeTypes::APPLICATION_XML, 'application/pdf, application/xml'], 45 | [MimeTypes::APPLICATION_JSON, '*/*'], 46 | [MimeTypes::APPLICATION_JSON, 'random/random, */*'], 47 | [MimeTypes::APPLICATION_JSON, 'application/*, random/random'], 48 | [MimeTypes::APPLICATION_JSON, 'application/xml;q=0.9,application/json;q=1.0,*/*;q=0.8'], 49 | [MimeTypes::APPLICATION_JSON, 'application/xml;q=0.9,application/json,*/*;q=0.8'], 50 | [MimeTypes::APPLICATION_JSON, 'application/xml;q=0.9,text/html;q=0.8,*/*'], 51 | [MimeTypes::APPLICATION_JSON, ''], 52 | ]; 53 | } 54 | 55 | /** 56 | * @dataProvider providerShouldThrowNotNegotiatableException 57 | */ 58 | public function testShouldThrowNotNegotiatableException(string $accept): void 59 | { 60 | $this->expectException(NotNegotiableException::class); 61 | 62 | $accept = AcceptHeader::fromString($accept); 63 | 64 | $this->contentNegotiator->negotiate($accept)->toString(); 65 | } 66 | 67 | public function providerShouldThrowNotNegotiatableException(): array 68 | { 69 | return [ 70 | ['randomstringButNotEmpty'], 71 | ['application/random'], 72 | ]; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/Negotiation/MimeTypeTest.php: -------------------------------------------------------------------------------- 1 | toString()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Negotiation/NotNegotiableExceptionTest.php: -------------------------------------------------------------------------------- 1 | formats', $exception->getMessage()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Request/AcceptHeaderTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(AcceptHeader::class, $accept); 22 | $this->assertEquals(self::ACCEPT, $accept->toString()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Request/BodyArgumentResolverTest.php: -------------------------------------------------------------------------------- 1 | resolver = $this->createResolver($this->prophesize(ValidatorInterface::class)->reveal()); 43 | } 44 | 45 | /** 46 | * @dataProvider providerSupportsShouldReturnFalse 47 | */ 48 | public function testSupportsShouldReturnFalse($type, $content, $isNullable): void 49 | { 50 | $argument = $this->prophesize(ArgumentMetadata::class); 51 | $argument->getType()->willReturn($type); 52 | $argument->isNullable()->willReturn($isNullable); 53 | 54 | $request = $this->createMockRequestWithHeaders(); 55 | $request->getContent()->willReturn($content); 56 | 57 | self::assertFalse($this->resolver->supports($request->reveal(), $argument->reveal())); 58 | } 59 | 60 | public function providerSupportsShouldReturnFalse(): array 61 | { 62 | return [ 63 | ['string', '{}', false], 64 | [null, '{}', false], 65 | [WithoutHttpApi::class, '{}', false], 66 | [DefaultHttpApi::class, false, true], 67 | [DefaultHttpApi::class, null, true], 68 | [DefaultHttpApi::class, '', true], 69 | ]; 70 | } 71 | 72 | public function testSupportsShouldReturnTrue(): void 73 | { 74 | $argument = $this->prophesize(ArgumentMetadata::class); 75 | $argument->getType()->willReturn(DefaultHttpApi::class); 76 | $argument->isNullable()->willReturn(false); 77 | 78 | $request = $this->createMockRequestWithHeaders(); 79 | 80 | self::assertTrue($this->resolver->supports($request->reveal(), $argument->reveal())); 81 | } 82 | 83 | /** 84 | * @dataProvider providerResolveShouldThrowException 85 | */ 86 | public function testResolveShouldThrowException($type, $content, $isNullable): void 87 | { 88 | $this->expectException(SupportsException::class); 89 | 90 | $argument = $this->prophesize(ArgumentMetadata::class); 91 | $argument->getType()->willReturn($type); 92 | $argument->isNullable()->willReturn($isNullable); 93 | 94 | $request = $this->createMockRequestWithHeaders(); 95 | $request->getContent()->willReturn($content); 96 | 97 | $result = $this->resolver->resolve($request->reveal(), $argument->reveal()); 98 | $result->current(); 99 | } 100 | 101 | public function providerResolveShouldThrowException(): array 102 | { 103 | return [ 104 | ['string', '{}', false], 105 | [null, '{}', false], 106 | [DefaultHttpApi::class, false, true], 107 | [DefaultHttpApi::class, null, true], 108 | [DefaultHttpApi::class, '', true], 109 | ]; 110 | } 111 | 112 | /** 113 | * @dataProvider providerResolveShouldThrowValidationException 114 | */ 115 | public function testResolveShouldThrowValidationException($expected): void 116 | { 117 | $this->expectException(ValidationException::class); 118 | 119 | $content = \json_encode($expected); 120 | 121 | $argument = $this->prophesize(ArgumentMetadata::class); 122 | $argument->getType()->willReturn(DefaultHttpApi::class); 123 | $argument->isVariadic()->willReturn(\is_array($expected)); 124 | 125 | $request = $this->createMockRequestWithHeaders(); 126 | $request->getContent()->willReturn($content); 127 | 128 | $violationList = new ConstraintViolationList(); 129 | $violationList->add(new ConstraintViolation('test', null, [], null, null, null)); 130 | 131 | $validator = $this->prophesize(ValidatorInterface::class); 132 | $validator->validate($expected)->willReturn($violationList); 133 | 134 | $resolver = $this->createResolver($validator->reveal()); 135 | 136 | $result = $resolver->resolve($request->reveal(), $argument->reveal()); 137 | $result->current(); 138 | } 139 | 140 | public function providerResolveShouldThrowValidationException(): array 141 | { 142 | return [ 143 | [ 144 | [new DefaultHttpApi(), new DefaultHttpApi()], 145 | ], 146 | [ 147 | new DefaultHttpApi(), 148 | ], 149 | ]; 150 | } 151 | 152 | /** 153 | * @dataProvider providerResolveShouldYield 154 | */ 155 | public function testResolveShouldYield($expected): void 156 | { 157 | $content = \json_encode($expected); 158 | 159 | $argument = $this->prophesize(ArgumentMetadata::class); 160 | $argument->getType()->willReturn(DefaultHttpApi::class); 161 | $argument->isVariadic()->willReturn(\is_array($expected)); 162 | 163 | $request = $this->createMockRequestWithHeaders(); 164 | $request->getContent()->willReturn($content); 165 | 166 | $violationList = new ConstraintViolationList(); 167 | $validator = $this->prophesize(ValidatorInterface::class); 168 | $validator->validate($expected)->willReturn($violationList); 169 | 170 | $resolver = $this->createResolver($validator->reveal()); 171 | $result = $resolver->resolve($request->reveal(), $argument->reveal()); 172 | 173 | $this->assertInstanceOf(DefaultHttpApi::class, $result->current()); 174 | } 175 | 176 | public function providerResolveShouldYield(): array 177 | { 178 | return [ 179 | [ 180 | [new DefaultHttpApi(), new DefaultHttpApi()], 181 | ], 182 | [ 183 | new DefaultHttpApi(), 184 | ], 185 | ]; 186 | } 187 | 188 | /** 189 | * @dataProvider providerResolveShouldThrowEmptyBodyException 190 | */ 191 | public function testResolveShouldThrowEmptyBodyException($content): void 192 | { 193 | $this->expectException(EmptyBodyException::class); 194 | 195 | $argument = $this->prophesize(ArgumentMetadata::class); 196 | $argument->getType()->willReturn(DefaultHttpApi::class); 197 | $argument->isNullable()->willReturn(false); 198 | 199 | $request = $this->createMockRequestWithHeaders(); 200 | $request->getContent()->willReturn($content); 201 | 202 | $this->resolver->resolve($request->reveal(), $argument->reveal())->current(); 203 | } 204 | 205 | public function providerResolveShouldThrowEmptyBodyException(): array 206 | { 207 | return [ 208 | [false], 209 | [null], 210 | [''], 211 | ]; 212 | } 213 | 214 | private function createResolver(ValidatorInterface $validator): BodyArgumentResolver 215 | { 216 | return new BodyArgumentResolver( 217 | new HttpApiReader(new AnnotationReader()), 218 | new Serializer(new SymfonyEventDispatcherFake(), new SymfonySerializerFake(), new FormatMapper(Config::SERIALIZE_FORMATS)), 219 | new Validator($validator) 220 | ); 221 | } 222 | 223 | private function createMockRequestWithHeaders() 224 | { 225 | $request = $this->prophesize(HttpFoundationRequest::class); 226 | 227 | $request->headers = new HeaderBag(['Content-Type' => 'application/json']); 228 | 229 | return $request; 230 | } 231 | } 232 | 233 | /** 234 | * @HttpApi 235 | */ 236 | class DefaultHttpApi 237 | { 238 | public int $int = 1; 239 | public string $name = 'name'; 240 | public bool $is_true = true; 241 | } 242 | -------------------------------------------------------------------------------- /tests/Request/ContentTypeHeaderTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(ContentTypeHeader::class, $contentType); 23 | $this->assertEquals(self::CONTENT_TYPE, $contentType->toString()); 24 | 25 | $this->assertInstanceOf(MimeType::class, $contentType->toMimeType()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Request/EmptyBodyExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(EmptyBodyException::class, EmptyBodyException::required()); 19 | } 20 | 21 | public function testShouldReturnError(): void 22 | { 23 | $error = EmptyBodyException::required()->getContent(); 24 | 25 | $this->assertInstanceOf(Error::class, $error); 26 | $this->assertEquals('The request body cannot be empty.', $error->getDetail()); 27 | } 28 | 29 | public function testExceptionShouldReturnBadRequestHttpCode(): void 30 | { 31 | $exception = EmptyBodyException::required(); 32 | 33 | $this->assertEquals(400, $exception->getStatusCode()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Request/QueryStringArgumentResolverTest.php: -------------------------------------------------------------------------------- 1 | prophesize(ValidatorInterface::class); 37 | $validator->validate(Argument::any())->willReturn(new ConstraintViolationList()); 38 | 39 | $this->resolver = new QueryStringArgumentResolver( 40 | new HttpApiReader(new AnnotationReader()), 41 | new SymfonySerializerFake(), 42 | new Validator($validator->reveal()) 43 | ); 44 | } 45 | 46 | /** 47 | * @dataProvider providerSupportsShouldReturnFalse 48 | */ 49 | public function testSupportsShouldReturnFalse($type, array $query, bool $isNullable): void 50 | { 51 | $request = $this->prophesize(HttpFoundationRequest::class); 52 | $request->query = new ParameterBag($query); 53 | 54 | $argument = $this->prophesize(ArgumentMetadata::class); 55 | $argument->getType()->willReturn($type); 56 | $argument->isNullable()->willReturn($isNullable); 57 | 58 | self::assertFalse($this->resolver->supports($request->reveal(), $argument->reveal())); 59 | } 60 | 61 | public function providerSupportsShouldReturnFalse(): \Generator 62 | { 63 | yield ['string', ['param1' => 'value1'], false]; 64 | yield [null, ['param1' => 'value1'], false]; 65 | yield [WithoutHttpApi::class, ['param1' => 'value1'], false]; 66 | yield [QueryStringHttpApi::class, [], true]; 67 | } 68 | 69 | /** 70 | * @dataProvider providerSupportsShouldReturnTrue 71 | */ 72 | public function testSupportsShouldReturnTrue(array $query, bool $isNullable): void 73 | { 74 | $request = $this->prophesize(HttpFoundationRequest::class); 75 | $request->query = new ParameterBag($query); 76 | 77 | $argument = $this->prophesize(ArgumentMetadata::class); 78 | $argument->getType()->willReturn(QueryStringHttpApi::class); 79 | $argument->isNullable()->willReturn($isNullable); 80 | 81 | self::assertTrue($this->resolver->supports($request->reveal(), $argument->reveal())); 82 | } 83 | 84 | public function providerSupportsShouldReturnTrue(): \Generator 85 | { 86 | yield [[], false]; 87 | yield [['param1' => 'value1'], true]; 88 | } 89 | 90 | /** 91 | * @dataProvider providerResolveShouldThrowException 92 | */ 93 | public function testResolveShouldThrowException(?string $type): void 94 | { 95 | $this->expectException(SupportsException::class); 96 | 97 | $request = $this->prophesize(HttpFoundationRequest::class); 98 | 99 | $argument = $this->prophesize(ArgumentMetadata::class); 100 | $argument->getType()->willReturn($type); 101 | 102 | $result = $this->resolver->resolve($request->reveal(), $argument->reveal()); 103 | $result->current(); 104 | } 105 | 106 | public function providerResolveShouldThrowException(): \Generator 107 | { 108 | yield ['string']; 109 | yield [null]; 110 | } 111 | 112 | public function testShouldYield(): void 113 | { 114 | $request = $this->prophesize(HttpFoundationRequest::class); 115 | $request->query = new ParameterBag(['priceFrom' => 1000, 'priceTo' => 9000]); 116 | 117 | $argument = $this->prophesize(ArgumentMetadata::class); 118 | $argument->getType()->willReturn(QueryStringHttpApi::class); 119 | 120 | $result = $this->resolver->resolve($request->reveal(), $argument->reveal()); 121 | $resolved = $result->current(); 122 | 123 | self::assertEquals(1000, $resolved->priceFrom); 124 | self::assertEquals(9000, $resolved->priceTo); 125 | } 126 | } 127 | 128 | /** 129 | * @HttpApi(requestInfoSource=HttpApi::QUERY_STRING) 130 | */ 131 | class QueryStringHttpApi 132 | { 133 | public $priceFrom; 134 | public $priceTo; 135 | } 136 | 137 | class WithoutHttpApi 138 | { 139 | } 140 | -------------------------------------------------------------------------------- /tests/Request/SupportsExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(SupportsException::class, SupportsException::covered()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Response/ContentTypeHeaderTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(ContentTypeHeader::class, $contentType); 22 | $this->assertEquals(self::CONTENT_TYPE, $contentType->toString()); 23 | $this->assertEquals('application/problem+json', $contentType->toStringWithProblem()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Response/ResponseBuilderTest.php: -------------------------------------------------------------------------------- 1 | responseBuilder = new ResponseBuilder(); 26 | } 27 | 28 | public function testShouldReturnEmptyResponse(): void 29 | { 30 | $this->assertInstanceOf(Response::class, $this->responseBuilder->getResponse()); 31 | } 32 | 33 | public function testShouldReturnResponseWithContent(): void 34 | { 35 | $content = '{"text": "i am a string"}'; 36 | 37 | $response = $this->responseBuilder 38 | ->setContent($content) 39 | ->getResponse(); 40 | 41 | $this->assertEquals($content, $response->getContent()); 42 | $this->assertEquals(null, $response->headers->get('content-type')); 43 | $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); 44 | } 45 | 46 | public function testShouldResponseWithCustomStatusCode(): void 47 | { 48 | $response = $this->responseBuilder->setStatus(Response::HTTP_CREATED)->getResponse(); 49 | 50 | $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode()); 51 | } 52 | 53 | public function testShouldResponseWithHeaders(): void 54 | { 55 | $response = $this->responseBuilder 56 | ->setContentType(ContentTypeHeader::fromString('application/json')) 57 | ->getResponse(); 58 | 59 | $this->assertEquals('application/json', $response->headers->get('content-type')); 60 | } 61 | 62 | /** 63 | * @dataProvider providerShouldResponseWithProblem 64 | */ 65 | public function testShouldResponseWithProblem(int $status, string $expected): void 66 | { 67 | $response = $this->responseBuilder 68 | ->setContentType(ContentTypeHeader::fromString('application/json')) 69 | ->setStatus($status) 70 | ->getResponse(); 71 | 72 | $this->assertEquals($expected, $response->headers->get('content-type')); 73 | } 74 | 75 | public function providerShouldResponseWithProblem(): array 76 | { 77 | return [ 78 | [400, 'application/problem+json'], 79 | [403, 'application/problem+json'], 80 | [500, 'application/json'], 81 | ]; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/Response/ResponseListenerTest.php: -------------------------------------------------------------------------------- 1 | listener = new ResponseListener( 44 | new HttpApiReader(new AnnotationReader()), 45 | new SuccessResponseResolver( 46 | new ContentNegotiator(Config::SERIALIZE_FORMATS, Config::SERIALIZE_FORMAT_DEFAULT), 47 | new ResponseBuilder(), 48 | new Serializer(new SymfonyEventDispatcherFake(), new SymfonySerializerFake(), new FormatMapper(Config::SERIALIZE_FORMATS)) 49 | ) 50 | ); 51 | } 52 | 53 | /** 54 | * @dataProvider providerShouldPassControllerResultToSerializer 55 | */ 56 | public function testShouldPassControllerResultToSerializer($controllerResult, string $expected): void 57 | { 58 | $viewEvent = new ViewEvent( 59 | $this->prophesize(HttpKernel::class)->reveal(), 60 | $this->createMockRequestWithHeaders()->reveal(), 61 | HttpKernelInterface::MASTER_REQUEST, 62 | $controllerResult 63 | ); 64 | 65 | $this->listener->transform($viewEvent); 66 | 67 | $this->assertEquals($expected, $viewEvent->getResponse()->getContent()); 68 | } 69 | 70 | public function providerShouldPassControllerResultToSerializer(): array 71 | { 72 | return [ 73 | [[new Ok()], '[{"message":"Everything is fine."}]'], 74 | ]; 75 | } 76 | 77 | /** 78 | * @dataProvider providerShouldSkipListener 79 | */ 80 | public function testShouldSkipListener($controllerResult): void 81 | { 82 | $viewEvent = new ViewEvent( 83 | $this->prophesize(HttpKernel::class)->reveal(), 84 | $this->createMockRequestWithHeaders()->reveal(), 85 | HttpKernelInterface::MASTER_REQUEST, 86 | $controllerResult 87 | ); 88 | 89 | $this->listener->transform($viewEvent); 90 | 91 | $this->assertNull($viewEvent->getResponse()); 92 | } 93 | 94 | public function providerShouldSkipListener(): array 95 | { 96 | return [ 97 | [null], 98 | [new Gum()], 99 | ['key' => 'value'], 100 | [['key' => 'value']], 101 | ]; 102 | } 103 | 104 | private function createMockRequestWithHeaders() 105 | { 106 | $request = $this->prophesize(HttpFoundationRequest::class); 107 | 108 | $request->headers = new HeaderBag([ 109 | 'Accept' => 'application/pdf, application/json, application/xml', 110 | 'Content-Type' => 'application/json', 111 | ]); 112 | 113 | return $request; 114 | } 115 | } 116 | 117 | /** 118 | * @HttpApi 119 | */ 120 | class Ok 121 | { 122 | public $message = 'Everything is fine.'; 123 | 124 | public static function create(): self 125 | { 126 | return new self(); 127 | } 128 | } 129 | 130 | class Gum 131 | { 132 | public int $weight; 133 | 134 | public bool $tastesGood; 135 | } 136 | -------------------------------------------------------------------------------- /tests/Serialize/DeserializeEventTest.php: -------------------------------------------------------------------------------- 1 | 'firstVal', 'secondkey' => 'secondVal']; 20 | 21 | $serializeContextEvent = DeserializeEvent::from($data, $format); 22 | 23 | $serializeContextEvent->mergeToContext(['firstKey' => 'firstVal']); 24 | $serializeContextEvent->mergeToContext(['secondkey' => 'secondVal']); 25 | 26 | $this->assertEquals($data, $serializeContextEvent->getData()); 27 | $this->assertEquals($format, $serializeContextEvent->getFormat()); 28 | $this->assertEquals($context, $serializeContextEvent->getContext()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Serialize/DeserializerTypeTest.php: -------------------------------------------------------------------------------- 1 | toString()); 20 | } 21 | 22 | public function testShouldCreateForArray(): void 23 | { 24 | $type = DeserializerType::array(TestObject::class); 25 | 26 | self::assertSame(TestObject::class . '[]', $type->toString()); 27 | } 28 | } 29 | 30 | final class TestObject 31 | { 32 | } 33 | -------------------------------------------------------------------------------- /tests/Serialize/FormatExceptionTest.php: -------------------------------------------------------------------------------- 1 | formats', $exception->getMessage()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Serialize/FormatMapperTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedFormat, $formatMapper->byMimeType(MimeType::fromString($givenMimeType))); 26 | } 27 | 28 | public function providerShouldMapMimeTypeToFormat(): array 29 | { 30 | return [ 31 | [ 32 | [ 33 | 'json' => [ 34 | MimeTypes::APPLICATION_JSON, 35 | ], 36 | ], 37 | MimeTypes::APPLICATION_JSON, 38 | 'json', 39 | ], 40 | [ 41 | [ 42 | 'xml' => [ 43 | MimeTypes::APPLICATION_XML, 44 | 'application/atom+xml', 45 | ], 46 | ], 47 | MimeTypes::APPLICATION_XML, 48 | 'xml', 49 | ], 50 | ]; 51 | } 52 | 53 | /** 54 | * @dataProvider providerShouldThrowException 55 | */ 56 | public function testShouldThrowException(array $serializeFormats, $givenMimeType): void 57 | { 58 | $this->expectException(FormatException::class); 59 | 60 | $formatMapper = new FormatMapper($serializeFormats); 61 | 62 | $formatMapper->byMimeType(MimeType::fromString($givenMimeType)); 63 | } 64 | 65 | public function providerShouldThrowException(): array 66 | { 67 | return [ 68 | [ 69 | [ 70 | 'json' => [ 71 | MimeTypes::APPLICATION_JSON, 72 | ], 73 | ], 74 | MimeTypes::APPLICATION_XML, 75 | ], 76 | [ 77 | [], 78 | MimeTypes::APPLICATION_XML, 79 | ], 80 | ]; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Serialize/SerializeEventTest.php: -------------------------------------------------------------------------------- 1 | 'firstVal', 'secondkey' => 'secondVal']; 20 | 21 | $serializeEvent = SerializeEvent::from($data, $format); 22 | 23 | $serializeEvent->mergeToContext(['firstKey' => 'firstVal']); 24 | $serializeEvent->mergeToContext(['secondkey' => 'secondVal']); 25 | 26 | $this->assertEquals($data, $serializeEvent->getData()); 27 | $this->assertEquals($format, $serializeEvent->getFormat()); 28 | $this->assertEquals($context, $serializeEvent->getContext()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Serialize/SerializerTest.php: -------------------------------------------------------------------------------- 1 | 'ctxValue']; 35 | $mimeType = MimeType::fromString('application/json'); 36 | 37 | $serializeContextEvent = SerializeEvent::from($data, 'json'); 38 | $serializeContextEvent->mergeToContext($context); 39 | 40 | $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); 41 | $eventDispatcher->dispatch(Argument::type(SerializeEvent::class), SerializeEvent::NAME)->willReturn($serializeContextEvent); 42 | 43 | $symfonySerializer = $this->prophesize(SerializerInterface::class); 44 | $symfonySerializer->serialize($data, 'json', $context)->willReturn('string'); 45 | $symfonySerializer->serialize($data, 'json', $context)->shouldBeCalled(); 46 | 47 | $serializer = new Serializer($eventDispatcher->reveal(), $symfonySerializer->reveal(), new FormatMapper(Config::SERIALIZE_FORMATS)); 48 | $serializer->serialize($data, $mimeType); 49 | } 50 | 51 | public function testShouldVerifyContextMergeOnDeserialize(): void 52 | { 53 | $data = '{"weight": 100, "name": "Bonbon", "tastesGood": true}'; 54 | $type = DeserializerType::object(Product::class); 55 | $context = ['ctxkey' => 'ctxValue']; 56 | $mimeType = MimeType::fromString('application/json'); 57 | 58 | $deserializeEvent = DeserializeEvent::from($data, 'json'); 59 | $deserializeEvent->mergeToContext($context); 60 | 61 | $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); 62 | $eventDispatcher->dispatch(Argument::type(DeserializeEvent::class), DeserializeEvent::NAME)->willReturn($deserializeEvent); 63 | 64 | $symfonySerializer = $this->prophesize(SerializerInterface::class); 65 | $symfonySerializer->deserialize($data, $type->toString(), 'json', $context)->willReturn(new Product(100, 'Bonbon', true)); 66 | $symfonySerializer->deserialize($data, $type->toString(), 'json', $context)->shouldBeCalled(); 67 | 68 | $serializer = new Serializer($eventDispatcher->reveal(), $symfonySerializer->reveal(), new FormatMapper(Config::SERIALIZE_FORMATS)); 69 | $serializer->deserialize($data, $type, $mimeType); 70 | } 71 | } 72 | 73 | /** 74 | * @HttpApi 75 | */ 76 | final class Product 77 | { 78 | public int $weight; 79 | public string $name; 80 | public bool $tastes_good; 81 | 82 | public function __construct(int $weight, string $name, bool $tastesGood) 83 | { 84 | $this->weight = $weight; 85 | $this->name = $name; 86 | $this->tastes_good = $tastesGood; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/Stub/Config.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'application/json', 12 | ], 13 | 'xml' => [ 14 | 'application/xml', 15 | ], 16 | ]; 17 | 18 | public const SERIALIZE_FORMAT_DEFAULT = 'application/json'; 19 | } 20 | -------------------------------------------------------------------------------- /tests/Stub/MimeTypes.php: -------------------------------------------------------------------------------- 1 | toArray()); 23 | } 24 | 25 | public function providerShouldReturnObjectList(): array 26 | { 27 | return [ 28 | [ 29 | [], 30 | [], 31 | ], 32 | [ 33 | [ 34 | new stdClass(), 35 | new stdClass(), 36 | ], 37 | [ 38 | new stdClass(), 39 | new stdClass(), 40 | ], 41 | ], 42 | [ 43 | [ 44 | new stdClass(), 45 | new stdClass(), 46 | ], 47 | [ 48 | 'a' => new stdClass(), 49 | 'b' => new stdClass(), 50 | ], 51 | ], 52 | ]; 53 | } 54 | 55 | /** 56 | * @dataProvider providerShouldReturnObjectListsFirstItem 57 | */ 58 | public function testShouldReturnObjectListsFirstItem(array $givenArray): void 59 | { 60 | $list = ObjectList::fromArray($givenArray); 61 | 62 | self::assertEquals(0, $list->first()->number); 63 | } 64 | 65 | public function providerShouldReturnObjectListsFirstItem() 66 | { 67 | return [ 68 | [ 69 | [ 70 | ListItem::from(0), 71 | ListItem::from(1), 72 | ], 73 | ], 74 | ]; 75 | } 76 | 77 | public function testShouldReturnFalseOnFirst(): void 78 | { 79 | self::assertFalse(ObjectList::fromArray([])->first()); 80 | } 81 | 82 | /** 83 | * @dataProvider providerShouldThrowTypeExceptions 84 | */ 85 | public function testShouldThrowTypeExceptions(array $givenArray): void 86 | { 87 | $this->expectException(TypeException::class); 88 | 89 | ObjectList::fromArray($givenArray); 90 | } 91 | 92 | public function providerShouldThrowTypeExceptions(): array 93 | { 94 | return [ 95 | [ 96 | ['test' => 'test'], 97 | ], 98 | [ 99 | [ 100 | new stdClass(), 101 | 'test' => 'test', 102 | ], 103 | ], 104 | [ 105 | [ 106 | ListItem::from(0), 107 | new stdClass(), 108 | ], 109 | ], 110 | ]; 111 | } 112 | } 113 | 114 | class ListItem 115 | { 116 | public $number; 117 | 118 | private function __construct(int $number) 119 | { 120 | $this->number = $number; 121 | } 122 | 123 | public static function from(int $number): self 124 | { 125 | return new self($number); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tests/Type/TypeExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(TypeException::class, TypeException::notObject()); 18 | $this->assertInstanceOf(TypeException::class, TypeException::notSameClass()); 19 | } 20 | } 21 | --------------------------------------------------------------------------------