├── .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 | [](https://github.com/violines/rest-bundle)
5 | [](https://codecov.io/gh/violines/rest-bundle/)
6 | [](https://dashboard.stryker-mutator.io/reports/github.com/violines/rest-bundle/master)
7 | [](https://shepherd.dev/github/violines/rest-bundle)
8 | [](https://sonarcloud.io/dashboard?id=violines_rest-bundle)
9 | [](LICENSE)
10 | [](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 |
--------------------------------------------------------------------------------