From b77abe78aa10d1d31ebd04ea9d35dd4563f925bb Mon Sep 17 00:00:00 2001 From: Alen Pokos Date: Thu, 10 Dec 2020 00:05:12 +0100 Subject: [PATCH] Update from internal 0.18 release --- CHANGELOG.md | 54 +++ composer.json | 45 +- phpunit.xml.dist | 2 +- src/Bridge/Doctrine/DoctrineRepository.php | 98 ++++- src/Bridge/Doctrine/RepositoryFactory.php | 8 +- src/Config/Annotation/Config.php | 15 + .../Annotation/UpdateRelationshipConfig.php | 23 + src/Config/ApiConfig.php | 49 ++- src/Config/Config.php | 14 +- src/Config/UpdateRelationshipConfig.php | 48 +++ src/Contracts/Config/ApiConfigInterface.php | 16 + src/Contracts/Config/ConfigInterface.php | 5 + .../UpdateRelationshipConfigInterface.php | 23 + .../ModelTools/ModelMetaDataInterface.php | 2 +- .../RelationshipDoesNotExistException.php | 13 + .../RelationshipRepositoryInterface.php | 21 + src/Contracts/RepositoryInterface.php | 2 +- .../RequestBodyValidatorInterface.php | 10 +- .../ResourceDoesNotExistException.php | 9 + src/Controller/AbstractController.php | 1 - .../IndexActionEnabledInterface.php | 6 +- src/Controller/Traits/Actions/CreateTrait.php | 54 +-- src/Controller/Traits/Actions/IndexTrait.php | 26 +- src/Controller/Traits/Actions/ShowTrait.php | 2 +- .../Actions/UpdateRelationshipTrait.php | 173 ++++++++ src/Controller/Traits/Actions/UpdateTrait.php | 11 +- src/Controller/Traits/CreateActionTrait.php | 3 +- src/Controller/Traits/IndexActionTrait.php | 2 +- .../SymfonyAutowiredServicesTrait.php | 16 + src/Controller/Traits/ShowActionTrait.php | 2 +- .../Traits/UpdateRelationshipActionTrait.php | 33 ++ src/DependencyInjection/Configuration.php | 19 + .../TrikoderJsonApiExtension.php | 29 +- .../JsonApiEnabledControllerDetectorTrait.php | 37 +- src/Listener/KernelListener.php | 36 +- src/Model/ModelFactoryResolver.php | 7 +- src/Repository/RepositoryResolver.php | 11 +- src/Resources/config/services.yml | 48 ++- .../doc/configuration/configuration.md | 42 ++ .../doc/configuration/examples/example.yml | 3 + .../examples/exampleAnnotation.php | 4 + src/Resources/doc/flow/write_actions.md | 10 +- .../doc/getting_started/advanced_usage.md | 4 + .../examples/ExampleController.php | 2 + .../getting_started/json_api_validators.md | 97 +++++ src/Response/CreatedResponse.php | 19 + src/Response/DataResponse.php | 3 - src/Response/Header.php | 34 ++ src/Response/HttpAwareDataResponse.php | 44 ++ src/Schema/Builtin/GenericSchema.php | 166 ++++++++ src/Schema/Builtin/ResourceInterface.php | 8 + src/Services/Client/ResponseBodyDecoder.php | 165 ++++++++ src/Services/ConfigBuilder.php | 74 +++- src/Services/JsonApiResponseLinter.php | 17 + src/Services/JsonResponseLinter.php | 17 + .../AbstractFormModelInputHandler.php | 3 +- .../ModelInput/DoctrineModelMetaData.php | 2 +- .../GenericFormModelInputHandler.php | 9 + .../ModelInput/GenericModelMetaData.php | 2 +- src/Services/ModelInput/ModelValidator.php | 2 +- .../ConstraintViolationToErrorTransformer.php | 7 +- .../Traits/FormErrorToErrorTransformer.php | 62 ++- src/Services/Neomerx/Container.php | 53 ++- src/Services/Neomerx/EncoderService.php | 2 +- src/Services/Neomerx/FactoryService.php | 1 - src/Services/Neomerx/ServiceContainer.php | 6 +- .../RelationshipRequestBodyDecoder.php | 24 ++ .../RelationshipValidatorAdapter.php | 74 ++++ .../RequestBodyDecoderService.php | 29 +- .../RequestDecoder/RequestBodyValidator.php | 40 -- .../RequestDecoder/RequestDecoder.php | 82 +++- .../SymfonyValidatorAdapter.php | 111 +++++ src/Services/ResponseFactoryService.php | 10 +- src/Services/ResponseLinterInterface.php | 10 + .../CreateActionGenericModelTest.php | 7 +- .../Controller/CreateActionTest.php | 134 ++++++ .../Controller/DeleteActionTest.php | 22 +- .../Demo/CustomMetaResponseActionTest.php | 4 +- .../Demo/SimpleFileUploadControllerTest.php | 3 +- .../Functional/Controller/IndexActionTest.php | 22 +- .../Controller/PaginatedIndexActionTest.php | 64 ++- .../Functional/Controller/ShowActionTest.php | 96 ++++- .../Controller/UpdateActionTest.php | 70 +-- .../UpdateRelationshipActionTest.php | 399 ++++++++++++++++++ .../ActionTraits/CreateTraitTest.php | 48 --- tests/Integration/ExtensionTest.php | 64 +++ .../GenericFormInputHandlerTest.php | 62 +++ tests/Integration/RequestDecoderTest.php | 168 +++++++- tests/Integration/ResponseBodyDecoderTest.php | 338 +++++++++++++++ ...ToErrorTransformerTraitIntegrationTest.php | 114 +++++ ...ToErrorTransformerTraitIntegrationTest.php | 189 +++++++++ .../RelationshipValidatorAdapterTest.php | 108 +++++ .../SymfonyValidatorAdapterTest.php | 110 +++++ .../Api/Test/VersionedUserController.php | 30 ++ .../Api/User/CustomerController.php | 1 - .../Api/User/GenericSchemaController.php | 38 ++ .../Controller/Api/User/UserController.php | 5 + .../DataFixtures/ORM/AbstractBaseFixture.php | 2 +- .../ORM/Configuration/LoadTagData.php | 34 ++ .../ORM/Configuration/LoadUserWithTag.php | 29 ++ tests/Resources/Entity/Cart.php | 91 ++++ tests/Resources/Entity/Post.php | 47 ++- tests/Resources/Entity/Product.php | 23 +- tests/Resources/Entity/Tag.php | 48 +++ tests/Resources/Entity/User.php | 84 +++- tests/Resources/Form/ContactInfoType.php | 29 ++ .../Form/ContactInfoTypeWithGroup.php | 30 ++ tests/Resources/Form/PhoneNumberType.php | 37 ++ tests/Resources/JsonApi/Schema/CartSchema.php | 62 +++ tests/Resources/Model/ContactInfoModel.php | 68 +++ tests/Resources/Model/PhoneNumberModel.php | 80 ++++ tests/Resources/app/config/jsonapi.yml | 5 +- tests/Resources/app/config/services.yml | 20 + tests/Resources/docker/bin/setup_fixtures.sh | 2 +- .../SchemaAutoMapCompilerPassTest.php | 2 +- tests/Unit/Config/ConfigBuilderTest.php | 20 +- tests/Unit/Config/ConfigurationTest.php | 5 + .../Unit/Controller/Traits/IndexTraitTest.php | 2 +- ...nApiEnabledControllerDetectorTraitTest.php | 39 ++ tests/Unit/Response/CreatedResponseTest.php | 49 +++ tests/Unit/ResponseFactoryServiceTest.php | 54 ++- .../CustomFormModelInputHandlerTest.php | 2 +- .../FormErrorToErrorTransformerTraitTest.php | 92 +++- .../Neomerx/ContainerAutowiringTest.php | 8 +- .../RequestBodyDecoderServiceTest.php | 3 +- .../Services/RequestBodyValidatorTest.php | 58 ++- .../RelationshipRequestBodyDecoderTest.php | 56 +++ tests/Unit/Services/RequestDecoderTest.php | 235 ++++++++++- 128 files changed, 5154 insertions(+), 469 deletions(-) create mode 100644 src/Config/Annotation/UpdateRelationshipConfig.php create mode 100644 src/Config/UpdateRelationshipConfig.php create mode 100644 src/Contracts/Config/UpdateRelationshipConfigInterface.php create mode 100644 src/Contracts/RelationshipDoesNotExistException.php create mode 100644 src/Contracts/RelationshipRepositoryInterface.php create mode 100644 src/Contracts/ResourceDoesNotExistException.php create mode 100644 src/Controller/Traits/Actions/UpdateRelationshipTrait.php create mode 100644 src/Controller/Traits/UpdateRelationshipActionTrait.php create mode 100644 src/Resources/doc/getting_started/json_api_validators.md create mode 100644 src/Response/CreatedResponse.php create mode 100644 src/Response/Header.php create mode 100644 src/Response/HttpAwareDataResponse.php create mode 100644 src/Schema/Builtin/GenericSchema.php create mode 100644 src/Schema/Builtin/ResourceInterface.php create mode 100644 src/Services/Client/ResponseBodyDecoder.php create mode 100644 src/Services/JsonApiResponseLinter.php create mode 100644 src/Services/JsonResponseLinter.php create mode 100644 src/Services/RequestDecoder/RelationshipRequestBodyDecoder.php create mode 100644 src/Services/RequestDecoder/RelationshipValidatorAdapter.php delete mode 100644 src/Services/RequestDecoder/RequestBodyValidator.php create mode 100644 src/Services/RequestDecoder/SymfonyValidatorAdapter.php create mode 100644 src/Services/ResponseLinterInterface.php create mode 100644 tests/Functional/Controller/UpdateRelationshipActionTest.php delete mode 100644 tests/Integration/ActionTraits/CreateTraitTest.php create mode 100644 tests/Integration/ExtensionTest.php create mode 100644 tests/Integration/GenericFormInputHandlerTest.php create mode 100644 tests/Integration/ResponseBodyDecoderTest.php create mode 100644 tests/Integration/Services/ModelInput/ConstraintViolationToErrorTransformerTraitIntegrationTest.php create mode 100644 tests/Integration/Services/ModelInput/FormErrorToErrorTransformerTraitIntegrationTest.php create mode 100644 tests/Integration/Services/RequestDecoder/RelationshipValidatorAdapterTest.php create mode 100644 tests/Integration/Services/RequestDecoder/SymfonyValidatorAdapterTest.php create mode 100644 tests/Resources/Controller/Api/Test/VersionedUserController.php create mode 100644 tests/Resources/Controller/Api/User/GenericSchemaController.php create mode 100644 tests/Resources/DataFixtures/ORM/Configuration/LoadTagData.php create mode 100644 tests/Resources/DataFixtures/ORM/Configuration/LoadUserWithTag.php create mode 100644 tests/Resources/Entity/Cart.php create mode 100644 tests/Resources/Entity/Tag.php create mode 100644 tests/Resources/Form/ContactInfoType.php create mode 100644 tests/Resources/Form/ContactInfoTypeWithGroup.php create mode 100644 tests/Resources/Form/PhoneNumberType.php create mode 100644 tests/Resources/JsonApi/Schema/CartSchema.php create mode 100644 tests/Resources/Model/ContactInfoModel.php create mode 100644 tests/Resources/Model/PhoneNumberModel.php create mode 100644 tests/Unit/Response/CreatedResponseTest.php create mode 100644 tests/Unit/Services/RequestDecoder/RelationshipRequestBodyDecoderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7565a9f..3a33485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,60 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased +### Changed +- Form validation errors now generate pointers from cause instead of origin + +### Fixed +- Form validation errors pointers now correctly target deep properties +- Specified includes now get properly included on create requests + +## [0.17.5] 2019-11-15 + +### Added +- Added CI checks for symfony 4.3 + +### Fixed +- Show routes with additional parameters now generate properly +- CI checks for Symfony 3.4 now uses same version router component + +### Changed +- Show route calculations no longer use route collection but instead use convention naming +- Self url calculation no longer uses route collection but instead uses current route parameters + +## [0.17.4] 2019-10-21 + +### Added +- Support for relationship editing trait, see `src/Resources/doc/flow/write_actions.md#updaterelationship` + +### Depretacted +- dropped support for symfony versions 3.1, 3.2, 3.3 + +## [0.17.3] 2019-09-16 + +### Changed +- default content type in responses changed to `application/vnd.api+json` + +## [0.17.2] 2019-08-28 + +### Fixed +- Create trait uses property accessor to get ID instead of expecting getId method + +## [0.17.1] 2019-08-22 + +### Fixed +- JsonApi controller check to allow for callable +- Form errors now provide correct code from violation + +## [0.17.0] 2019-08-09 + +### Added +- The `\Trikoder\JsonApiBundle\Listener\KernelListener` listener priorities for the +`kernel.view` and `kernel.exception` events can now be configured via the extension's +`kernel_listener_on_kernel_view_priority` and `kernel_listener_on_kernel_exception_priority` +keys respectively. +- `Trikoder\JsonApiBundle\Schema\Builtin\GenericSchema` to be used for exposing 1:1 any resource +- `Trikoder\JsonApiBundle\Services\Client\ResponseBodyDecoder` to be used as client for jsonapi response + ## [0.16.0] 2019-02-14 ### Added diff --git a/composer.json b/composer.json index 5d9b381..19afdd2 100644 --- a/composer.json +++ b/composer.json @@ -4,38 +4,39 @@ "type": "symfony-bundle", "require": { "php": ">=7.0.0|>=7.1.0", - "symfony/framework-bundle": ">=3.1 <5", - "symfony/dependency-injection": ">=3.1 <5", - "symfony/config": ">=3.1 <5", - "sensio/framework-extra-bundle": ">3.0|^5.1|^5.2", - "symfony/http-foundation": ">=3.1 <5", - "symfony/form": ">=3.1 <5.0", - "symfony/routing": ">=3.1 <4.1.8", - "symfony/security-bundle": "^3.1|^4.0", - "symfony/translation": ">=3.1 <5", - "symfony/validator": ">=3.1 <5.0", - "symfony/monolog-bundle": ">=3.1 <5.0", + "symfony/framework-bundle": "^3.4|^4.4", + "symfony/dependency-injection": "^3.4|^4.4", + "symfony/config": "^3.4|^4.4", + "sensio/framework-extra-bundle": "^5.3", + "symfony/http-foundation": "^3.4|^4.4", + "symfony/form": "^3.4|^4.4", + "symfony/routing": "^3.4|^4.4", + "symfony/security-bundle": "^3.4|^4.4", + "symfony/translation": "^3.4|^4.4", + "symfony/validator": "^3.4|^4.4", + "symfony/monolog-bundle": "^3.4", "neomerx/json-api": "^1.0", "doctrine/orm": "^2.4", "doctrine/common": "^2", - "doctrine/doctrine-bundle": "~1.6|^1.8|^1.9", - "ext-json": "*" + "doctrine/doctrine-bundle": "^1.11|^2.0", + "ext-json": "*", + "symfony/property-access": "^3.4|^4.4" }, "require-dev": { "phpunit/phpunit": "^5.7", "phpunit/php-code-coverage": "^4.0", - "symfony/phpunit-bridge": "^3.0|^4.0", + "symfony/phpunit-bridge": "^3.4|^4.4", "justinrainbow/json-schema": "^1.6", - "doctrine/doctrine-fixtures-bundle": "^2.3", + "doctrine/doctrine-fixtures-bundle": "^3.3", "fzaninotto/faker": "^1.5", - "sensiolabs/security-checker": "^4.1", - "symfony/twig-bundle": "^3.3|^4.0", + "sensiolabs/security-checker": "^5", + "symfony/twig-bundle": "^3.4|^4.4", "doctrine/cache": "^1.6", - "symfony/debug-bundle": "^3.3|^4.0", - "symfony/web-profiler-bundle": "^3.3|^4.0", - "symfony/web-server-bundle": "^3.3|^4.0", - "symfony/browser-kit": "^3.3|^4.0", - "friendsofphp/php-cs-fixer": "2.13.0" + "symfony/debug-bundle": "^3.4|^4.4", + "symfony/web-profiler-bundle": "^3.4|^4.4", + "symfony/web-server-bundle": "^3.4|^4.4", + "symfony/browser-kit": "^3.4|^4.4", + "friendsofphp/php-cs-fixer": "^2.13" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3c9c32e..da8c477 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,7 +11,7 @@ - + diff --git a/src/Bridge/Doctrine/DoctrineRepository.php b/src/Bridge/Doctrine/DoctrineRepository.php index e7beaea..fab2bc7 100644 --- a/src/Bridge/Doctrine/DoctrineRepository.php +++ b/src/Bridge/Doctrine/DoctrineRepository.php @@ -4,12 +4,17 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Mapping\MappingException; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Trikoder\JsonApiBundle\Contracts\RelationshipDoesNotExistException; +use Trikoder\JsonApiBundle\Contracts\RelationshipRepositoryInterface; use Trikoder\JsonApiBundle\Contracts\RepositoryInterface; +use Trikoder\JsonApiBundle\Contracts\ResourceDoesNotExistException; /** * Class DoctrineRepository */ -class DoctrineRepository implements RepositoryInterface +class DoctrineRepository implements RepositoryInterface, RelationshipRepositoryInterface { /** * @var EntityRepository @@ -20,13 +25,22 @@ class DoctrineRepository implements RepositoryInterface */ protected $entityManager; + /** + * @var PropertyAccessorInterface + */ + protected $propertyAccessor; + /** * DoctrineRepository constructor. */ - public function __construct(EntityRepository $entityRepository, EntityManager $entityManager) - { + public function __construct( + EntityRepository $entityRepository, + EntityManager $entityManager, + PropertyAccessorInterface $propertyAccessor + ) { $this->entityRepository = $entityRepository; $this->entityManager = $entityManager; + $this->propertyAccessor = $propertyAccessor; } /** @@ -75,4 +89,82 @@ public function remove($model) $this->entityManager->remove($model); $this->entityManager->flush(); } + + /** + * {@inheritdoc} + */ + public function addToRelationship($model, string $relationshipName, array $relationshipData) + { + $modelMeta = $this->entityManager->getClassMetadata($this->entityRepository->getClassName()); + + try { + $relationshipModelClassName = $modelMeta->getAssociationMapping($relationshipName)['targetEntity']; + } catch (MappingException $e) { + throw new RelationshipDoesNotExistException($relationshipName); + } + + $relationshipModelMeta = $this->entityManager->getClassMetadata($relationshipModelClassName); + $relationshipIdentifier = $relationshipModelMeta->getSingleIdentifierFieldName(); + + $repository = $this->entityManager->getRepository($relationshipModelClassName); + $currentRelationshipModels = $this->propertyAccessor->getValue($model, $relationshipName); + + $relationshipModels = []; + + //add current relationship resources to array + foreach ($currentRelationshipModels as $data) { + $relationshipModels[$this->propertyAccessor->getValue($data, $relationshipIdentifier)] = $data; + } + + $newRelationshipModels = $repository->findBy([$relationshipIdentifier => array_column($relationshipData, 'id')]); + + /* + * @see https://gitlab.trikoder.net/trikoder/jsonapibundle/merge_requests/102#note_251076 + */ + if (\count($newRelationshipModels) !== \count($relationshipData)) { + throw new ResourceDoesNotExistException(); + } + + //add new relationship resources to array + foreach ($newRelationshipModels as $data) { + $relationshipModels[$this->propertyAccessor->getValue($data, $relationshipIdentifier)] = $data; + } + + $this->propertyAccessor->setValue($model, $relationshipName, $relationshipModels); + + return $this->save($model); + } + + /** + * {@inheritdoc} + */ + public function removeFromRelationship($model, string $relationshipName, array $relationshipData) + { + $modelMeta = $this->entityManager->getClassMetadata($this->entityRepository->getClassName()); + + try { + $relationshipModelClassName = $modelMeta->getAssociationMapping($relationshipName)['targetEntity']; + } catch (MappingException $e) { + throw new RelationshipDoesNotExistException($relationshipName); + } + + $relationshipModelMeta = $this->entityManager->getClassMetadata($relationshipModelClassName); + $relationshipIdentifier = $relationshipModelMeta->getSingleIdentifierFieldName(); + + $relationshipModels = $this->propertyAccessor->getValue($model, $relationshipName); + $relationshipIds = array_column($relationshipData, 'id'); + + $this->propertyAccessor->setValue( + $model, + $relationshipName, + array_filter( + iterator_to_array($relationshipModels), + function ($data) use ($relationshipIdentifier, $relationshipIds) { + return !\in_array($this->propertyAccessor->getValue($data, $relationshipIdentifier), $relationshipIds); + } + ) + ); + + return $this->save($model); + } } diff --git a/src/Bridge/Doctrine/RepositoryFactory.php b/src/Bridge/Doctrine/RepositoryFactory.php index cd09ac4..1fb115c 100644 --- a/src/Bridge/Doctrine/RepositoryFactory.php +++ b/src/Bridge/Doctrine/RepositoryFactory.php @@ -3,6 +3,7 @@ namespace Trikoder\JsonApiBundle\Bridge\Doctrine; use Doctrine\ORM\EntityManager; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Trikoder\JsonApiBundle\Contracts\RepositoryInterface; use Trikoder\JsonApiBundle\Repository\RepositoryFactoryInterface; @@ -15,13 +16,15 @@ class RepositoryFactory implements RepositoryFactoryInterface * @var EntityManager */ private $entityManager; + private $propertyAccessor; /** * RepositoryFactory constructor. */ - public function __construct(EntityManager $entityManager) + public function __construct(EntityManager $entityManager, PropertyAccessorInterface $propertyAccessor) { $this->entityManager = $entityManager; + $this->propertyAccessor = $propertyAccessor; } /** @@ -31,7 +34,8 @@ public function create(string $modelClass): RepositoryInterface { return new DoctrineRepository( $this->entityManager->getRepository($modelClass), - $this->entityManager + $this->entityManager, + $this->propertyAccessor ); } } diff --git a/src/Config/Annotation/Config.php b/src/Config/Annotation/Config.php index 2336326..1ccf067 100644 --- a/src/Config/Annotation/Config.php +++ b/src/Config/Annotation/Config.php @@ -35,6 +35,16 @@ class Config */ public $requestBodyDecoder; + /** + * @var string + */ + public $relationshipRequestBodyDecoder; + + /** + * @var string + */ + public $requestBodyValidator; + /** * @var bool */ @@ -59,4 +69,9 @@ class Config * @var Trikoder\JsonApiBundle\Config\Annotation\DeleteConfig */ public $delete; + + /** + * @var Trikoder\JsonApiBundle\Config\Annotation\UpdateRelationshipConfig + */ + public $updateRelationship; } diff --git a/src/Config/Annotation/UpdateRelationshipConfig.php b/src/Config/Annotation/UpdateRelationshipConfig.php new file mode 100644 index 0000000..53e93e5 --- /dev/null +++ b/src/Config/Annotation/UpdateRelationshipConfig.php @@ -0,0 +1,23 @@ +modelClass = $modelClass; $this->fixedFiltering = $fixedFiltering; @@ -64,6 +78,9 @@ public function __construct( $this->requestBodyDecoder = $requestBodyDecoder; $this->allowExtraParams = $allowExtraParams; $this->repository = $repository; + $this->requestBodyValidator = $requestBodyValidator; + $this->relationshipRequestBodyValidator = $relationshipRequestBodyValidator; + $this->relationshipBodyDecoder = $relationshipBodyDecoder; } /** @@ -106,6 +123,14 @@ public function getRequestBodyDecoder() return $this->requestBodyDecoder; } + /** + * @return RequestBodyDecoderInterface + */ + public function getRelationshipRequestBodyDecoder() + { + return $this->relationshipBodyDecoder; + } + /** * @return bool */ @@ -113,4 +138,20 @@ public function getAllowExtraParams() { return $this->allowExtraParams; } + + /** + * @internal + */ + public function getRequestBodyValidator() + { + return $this->requestBodyValidator; + } + + /** + * @internal + */ + public function getRelationshipRequestBodyValidator() + { + return $this->relationshipRequestBodyValidator; + } } diff --git a/src/Config/Config.php b/src/Config/Config.php index ed35887..9f48786 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -8,6 +8,7 @@ use Trikoder\JsonApiBundle\Contracts\Config\DeleteConfigInterface; use Trikoder\JsonApiBundle\Contracts\Config\IndexConfigInterface; use Trikoder\JsonApiBundle\Contracts\Config\UpdateConfigInterface; +use Trikoder\JsonApiBundle\Contracts\Config\UpdateRelationshipConfigInterface; /** * Class Config @@ -38,6 +39,7 @@ final class Config implements ConfigInterface * @var DeleteConfigInterface */ private $delete; + private $updateRelationship; /** * Config constructor. @@ -47,13 +49,15 @@ public function __construct( CreateConfigInterface $create, IndexConfigInterface $index, UpdateConfigInterface $update, - DeleteConfigInterface $delete + DeleteConfigInterface $delete, + UpdateRelationshipConfigInterface $updateRelationship ) { $this->api = $api; $this->create = $create; $this->index = $index; $this->update = $update; $this->delete = $delete; + $this->updateRelationship = $updateRelationship; } /** @@ -95,4 +99,12 @@ public function getDelete() { return $this->delete; } + + /** + * @return UpdateRelationshipConfigInterface + */ + public function getUpdateRelationship() + { + return $this->updateRelationship; + } } diff --git a/src/Config/UpdateRelationshipConfig.php b/src/Config/UpdateRelationshipConfig.php new file mode 100644 index 0000000..fdc123f --- /dev/null +++ b/src/Config/UpdateRelationshipConfig.php @@ -0,0 +1,48 @@ +allowedRelationships = $allowedRelationships; + $this->requiredRoles = $requiredRoles; + } + + /** + * @return array|null + */ + public function getAllowedRelationships() + { + return $this->allowedRelationships; + } + + /** + * @return array|null + */ + public function getRequiredRoles() + { + return $this->requiredRoles; + } +} diff --git a/src/Contracts/Config/ApiConfigInterface.php b/src/Contracts/Config/ApiConfigInterface.php index faae50f..29a926e 100644 --- a/src/Contracts/Config/ApiConfigInterface.php +++ b/src/Contracts/Config/ApiConfigInterface.php @@ -4,6 +4,7 @@ use Trikoder\JsonApiBundle\Contracts\RepositoryInterface; use Trikoder\JsonApiBundle\Contracts\RequestBodyDecoderInterface; +use Trikoder\JsonApiBundle\Contracts\RequestBodyValidatorInterface; /** * Interface ApiConfigInterface @@ -43,6 +44,21 @@ public function getAllowedIncludePaths(); */ public function getRequestBodyDecoder(); + /** + * @return RequestBodyDecoderInterface + */ + public function getRelationshipRequestBodyDecoder(); + + /** + * @return RequestBodyValidatorInterface + */ + public function getRequestBodyValidator(); + + /** + * @return RequestBodyValidatorInterface + */ + public function getRelationshipRequestBodyValidator(); + /** * Flag if we allow extra params in request, if false, only params that are recognized by JsonApi are allowed * diff --git a/src/Contracts/Config/ConfigInterface.php b/src/Contracts/Config/ConfigInterface.php index 4d17e9d..3329afe 100644 --- a/src/Contracts/Config/ConfigInterface.php +++ b/src/Contracts/Config/ConfigInterface.php @@ -31,4 +31,9 @@ public function getUpdate(); * @return DeleteConfigInterface */ public function getDelete(); + + /** + * @return UpdateRelationshipConfigInterface + */ + public function getUpdateRelationship(); } diff --git a/src/Contracts/Config/UpdateRelationshipConfigInterface.php b/src/Contracts/Config/UpdateRelationshipConfigInterface.php new file mode 100644 index 0000000..35561f4 --- /dev/null +++ b/src/Contracts/Config/UpdateRelationshipConfigInterface.php @@ -0,0 +1,23 @@ +request->all(); if ($request->files->count() > 0) { foreach ($request->files->all() as $filesKey => $filesValue) { - if (array_key_exists($filesKey, $modelInput)) { - throw new RuntimeException(sprintf('Conflict with request files, duplicate param found in request and files %s', - $filesKey)); + if (\array_key_exists($filesKey, $modelInput)) { + throw new RuntimeException(sprintf('Conflict with request files, duplicate param found in request and files %s', $filesKey)); } $modelInput[$filesKey] = $filesValue; } @@ -137,7 +134,7 @@ protected function createModelFromRequest(Request $request) } /** - * @return \Symfony\Component\HttpFoundation\Response + * @return \Symfony\Component\HttpFoundation\Response|CreatedResponse */ protected function createCreatedFromRequest(Request $request) { @@ -147,8 +144,6 @@ protected function createCreatedFromRequest(Request $request) $responseFactory = $this->getJsonApiResponseFactory(); /** @var EncoderService $encoder */ $encoder = $this->getJsonApiEncoder(); - /** @var SchemaClassMapProviderInterface $schemaProvider */ - $schemaProvider = $this->getSchemaClassMapProvider(); try { $model = $this->createModelFromRequest($request); @@ -168,48 +163,39 @@ protected function createCreatedFromRequest(Request $request) $showLocation = null; if (true === method_exists($this, 'getRouter')) { - $showRouteName = $this->findShowRouteName(); + $showRouteName = $this->findShowRouteName($request); if (null !== $showRouteName) { - $showLocation = $this->getRouter()->generate($showRouteName, ['id' => $model->getId()], RouterInterface::ABSOLUTE_URL); + $showRouteParameters = ['id' => $this->getPropertyAccessor()->getValue($model, 'id')] + $request->attributes->get('_route_params', []); + $showLocation = $this->getRouter()->generate($showRouteName, $showRouteParameters, RouterInterface::ABSOLUTE_URL); } } - $response = $responseFactory->createCreated( - $encoder->encode($schemaProvider, $model), - $showLocation - ); - return $response; + return new CreatedResponse($model, [], [], $showLocation); } /** * Find showAction route to this controller. * Returns null if route cannot be found * - * @internal - * * @return string|null + * + * @internal */ - protected function findShowRouteName() + protected function findShowRouteName(Request $request = null) { - if (true !== method_exists($this, 'getRouter')) { + if (true !== method_exists($this, 'showAction')) { return null; } - /** @var Router $router */ - $router = $this->getRouter(); - $controllerName = \get_class($this) . '::showAction'; - $showRouteName = null; - /** @var Route $route */ - foreach ($router->getRouteCollection() as $routeName => $route) { - $defaults = $route->getDefaults(); - if (isset($defaults['_controller']) && $defaults['_controller'] == $controllerName) { - $showRouteName = $routeName; - break; + if (null !== $request) { + // guess route name from convention + $createRouteName = $request->get('_route'); + + // we test and calculate by known symfony convention + if ('_create' == substr($createRouteName, -7)) { + return sprintf('%s_show', substr($createRouteName, 0, -7)); } } - if (null === $showRouteName) { - return null; - } - return $showRouteName; + return null; } } diff --git a/src/Controller/Traits/Actions/IndexTrait.php b/src/Controller/Traits/Actions/IndexTrait.php index d02fe19..d8568b2 100644 --- a/src/Controller/Traits/Actions/IndexTrait.php +++ b/src/Controller/Traits/Actions/IndexTrait.php @@ -15,7 +15,7 @@ trait IndexTrait { /** - * @return array|null|\Trikoder\JsonApiBundle\Contracts\ObjectListCollectionInterface + * @return array|\Trikoder\JsonApiBundle\Contracts\ObjectListCollectionInterface|null */ private function createCollectionFromRequest(Request $request) { @@ -59,13 +59,7 @@ private function generateSelfUrlFromRequest(RouterInterface $router, Request $re $routeParams = $request->query->all(); // figure out request attributes from route - $compiledRoute = $router->getRouteCollection()->get($routeName)->compile(); - $compiledRouteVariables = $compiledRoute->getVariables(); - foreach ($compiledRouteVariables as $compiledRouteVariable) { - if ($request->attributes->has($compiledRouteVariable)) { - $routeParams[$compiledRouteVariable] = $request->attributes->get($compiledRouteVariable); - } - } + $routeParams += $request->attributes->get('_route_params', []); $routeParams = array_merge($routeParams, $overrideParams); @@ -101,13 +95,13 @@ private function createPaginatedDataResponseFromCollection( ) { // check for required values if ( - false === array_key_exists('strategy', $paginationParams) || - false === array_key_exists('limit', $paginationParams) || - false === array_key_exists('offset', $paginationParams) + false === \array_key_exists('strategy', $paginationParams) || + false === \array_key_exists('limit', $paginationParams) || + false === \array_key_exists('offset', $paginationParams) ) { throw new \InvalidArgumentException(); } - if (false === array_key_exists('limit', $paginationParams) || null === $paginationParams['limit']) { + if (false === \array_key_exists('limit', $paginationParams) || null === $paginationParams['limit']) { throw new \RuntimeException('Limit values for pagination is missing. Did you forget to configure defaults?'); } @@ -211,24 +205,24 @@ private function resolvePaginationArguments($arguments = null) if (true === \is_array($arguments)) { // calculate limit first // page size strategy - if (true === array_key_exists('size', $arguments)) { + if (true === \array_key_exists('size', $arguments)) { $pagination['limit'] = (int) $arguments['size']; $pagination['strategy'] = IndexConfigInterface::PAGINATION_STRATEGY_PAGE_SIZE; } else { // offset limit strategy - if (true === array_key_exists('limit', $arguments)) { + if (true === \array_key_exists('limit', $arguments)) { $pagination['limit'] = (int) $arguments['limit']; $pagination['strategy'] = IndexConfigInterface::PAGINATION_STRATEGY_LIMIT_OFFSET; } } // page size strategy - if (true === array_key_exists('number', $arguments)) { + if (true === \array_key_exists('number', $arguments)) { $pagination['offset'] = ((int) $arguments['number'] - 1) * $pagination['limit']; $pagination['strategy'] = IndexConfigInterface::PAGINATION_STRATEGY_PAGE_SIZE; } else { // offset limit strategy - if (true === array_key_exists('offset', $arguments)) { + if (true === \array_key_exists('offset', $arguments)) { $pagination['offset'] = (int) $arguments['offset']; $pagination['strategy'] = IndexConfigInterface::PAGINATION_STRATEGY_LIMIT_OFFSET; } diff --git a/src/Controller/Traits/Actions/ShowTrait.php b/src/Controller/Traits/Actions/ShowTrait.php index 219d87a..5c6586a 100644 --- a/src/Controller/Traits/Actions/ShowTrait.php +++ b/src/Controller/Traits/Actions/ShowTrait.php @@ -13,7 +13,7 @@ trait ShowTrait /** * @param $id * - * @return null|object + * @return object|null */ public function getModelById($id) { diff --git a/src/Controller/Traits/Actions/UpdateRelationshipTrait.php b/src/Controller/Traits/Actions/UpdateRelationshipTrait.php new file mode 100644 index 0000000..1b279c0 --- /dev/null +++ b/src/Controller/Traits/Actions/UpdateRelationshipTrait.php @@ -0,0 +1,173 @@ +getJsonApiConfig(); + + if (false === $this->validateRequestRelationshipName( + $relationshipName, $config->getUpdateRelationship()->getAllowedRelationships() + ) + ) { + /** @var ResponseFactoryInterface $responseFactory */ + $responseFactory = $this->getJsonApiResponseFactory(); + + return $responseFactory->createErrorFromException($this->getForbiddenRelationshipException($relationshipName)); + } + + /** @var RepositoryInterface $repository */ + $repository = $config->getApi()->getRepository(); + + // load object + $model = $repository->getOne($id, $config->getApi()->getFixedFiltering()); + + // check if model is loaded + if (null === $model) { + throw new NotFoundHttpException(); + } + + return $this->updateModelFromRequestUsingModelAndRelationshipName($request, $model, $relationshipName); + } + + /** + * @param $model + * + * @return object + * + * @throws ModelValidationException + * @throws UnhandleableModelInputException + * + * @internal + */ + protected function updateModelFromRequestUsingModelAndRelationshipName( + Request $request, + $model, + string $relationshipName + ) { + /** @var ConfigInterface $config */ + $config = $this->getJsonApiConfig(); + + //TODO make sure request has data in array + + // TODO - check if model correct class? + + /** @var RelationshipRepositoryInterface $repository */ + $repository = $config->getApi()->getRepository(); + + if (!$repository instanceof RelationshipRepositoryInterface) { + throw new \Exception(sprintf('Expected instance of "%s%", "%s" given', RelationshipRepositoryInterface::class, \get_class($repository))); + } + + /** @var ResponseFactoryInterface $responseFactory */ + $responseFactory = $this->getJsonApiResponseFactory(); + + try { + switch ($method = $request->getMethod()) { + case Request::METHOD_POST: + $saveResult = $repository->addToRelationship($model, $relationshipName, $request->request->all()); + break; + case Request::METHOD_DELETE: + $saveResult = $repository->removeFromRelationship($model, $relationshipName, + $request->request->all()); + break; + default: + // 405 instead? + return $responseFactory->createErrorFromException(new BadRequestHttpException(sprintf('Unsupported method %s', $method))); + } + } catch (RelationshipDoesNotExistException $exception) { + return $responseFactory->createErrorFromException($this->getForbiddenRelationshipException($relationshipName)); + } catch (ResourceDoesNotExistException $exception) { + return $responseFactory->createErrorFromException(new ConflictHttpException('One or more referenced resources does not exist')); + } + + // if repository returned result, we take it as new model + if (null !== $saveResult) { + // if repository returned different class for model, we consider it error + if (false === ($saveResult instanceof $model)) { + throw new \LogicException(sprintf('Repository result is not a valid, expected object of type %s, got %s', \get_class($model), \get_class($saveResult))); + } + + return $saveResult; + } + + // return resulting + return $model; + } + + /** + * @param $id + * + * @return object|\Symfony\Component\HttpFoundation\Response|null + */ + protected function updateRequestFromRelationshipRequest(Request $request, $id, string $relationshipName) + { + // TODO change to injected + /** @var ResponseFactoryInterface $responseFactory */ + $responseFactory = $this->getJsonApiResponseFactory(); + /** @var EncoderService $encoder */ + $encoder = $this->getJsonApiEncoder(); + + try { + $model = $this->updateModelFromRequestUsingIdAndRelationshipName($request, $id, $relationshipName); + } catch (ModelValidationException $modelValidationException) { + $response = $responseFactory->createConflict($encoder->encodeErrors($modelValidationException->getViolations())); + + return $response; + } catch (UnhandleableModelInputException $unhandleableModelInputException) { + if ($unhandleableModelInputException->getPrevious() instanceof ModelValidationException && $unhandleableModelInputException->getPrevious()->hasViolations()) { + $response = $responseFactory->createConflict($encoder->encodeErrors($unhandleableModelInputException->getPrevious()->getViolations())); + } else { + $response = $responseFactory->createErrorFromException(new BadRequestHttpException($unhandleableModelInputException->getMessage())); + } + + return $response; + } + + return $model; + } + + private function validateRequestRelationshipName(string $relationshipName, $allowedRelationships): bool + { + return + null === $allowedRelationships + || + \in_array($relationshipName, $allowedRelationships, true); + } + + private function getForbiddenRelationshipException($relationshipName) + { + return new AccessDeniedHttpException(sprintf('Forbidden relationship %s', $relationshipName)); + } +} diff --git a/src/Controller/Traits/Actions/UpdateTrait.php b/src/Controller/Traits/Actions/UpdateTrait.php index 637da74..e290a66 100644 --- a/src/Controller/Traits/Actions/UpdateTrait.php +++ b/src/Controller/Traits/Actions/UpdateTrait.php @@ -23,7 +23,7 @@ trait UpdateTrait /** * @param $id * - * @return null|object + * @return object|null * * @throws ModelValidationException * @throws UnhandleableModelInputException @@ -38,7 +38,7 @@ protected function updateModelFromRequest(Request $request, $id) /** * @param $id * - * @return null|object + * @return object|null * * @throws ModelValidationException * @throws UnhandleableModelInputException @@ -96,9 +96,8 @@ protected function handleUpdateModelInputFromRequest(ConfigInterface $config, $m $modelInput = $request->request->all(); if ($request->files->count() > 0) { foreach ($request->files->all() as $filesKey => $filesValue) { - if (array_key_exists($filesKey, $modelInput)) { - throw new RuntimeException(sprintf('Conflict with request files, duplicate param found in request and files %s', - $filesKey)); + if (\array_key_exists($filesKey, $modelInput)) { + throw new RuntimeException(sprintf('Conflict with request files, duplicate param found in request and files %s', $filesKey)); } $modelInput[$filesKey] = $filesValue; } @@ -189,7 +188,7 @@ protected function updateModelFromRequestUsingModel(Request $request, $model) /** * @param $id * - * @return null|object|\Symfony\Component\HttpFoundation\Response + * @return object|\Symfony\Component\HttpFoundation\Response|null */ protected function updateRequestFromRequest(Request $request, $id) { diff --git a/src/Controller/Traits/CreateActionTrait.php b/src/Controller/Traits/CreateActionTrait.php index 3b003ac..66da497 100644 --- a/src/Controller/Traits/CreateActionTrait.php +++ b/src/Controller/Traits/CreateActionTrait.php @@ -4,6 +4,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; +use Trikoder\JsonApiBundle\Response\CreatedResponse; /** * Class CreateActionTrait @@ -15,7 +16,7 @@ trait CreateActionTrait /** * @Route("{trailingSlash}", requirements={"trailingSlash": "[/]{0,1}"}, defaults={"trailingSlash": ""}, methods={"POST"}) * - * @return \Symfony\Component\HttpFoundation\Response + * @return \Symfony\Component\HttpFoundation\Response|CreatedResponse */ public function createAction(Request $request) { diff --git a/src/Controller/Traits/IndexActionTrait.php b/src/Controller/Traits/IndexActionTrait.php index ec478cf..0395adb 100644 --- a/src/Controller/Traits/IndexActionTrait.php +++ b/src/Controller/Traits/IndexActionTrait.php @@ -15,7 +15,7 @@ trait IndexActionTrait /** * @Route("{trailingSlash}", requirements={"trailingSlash": "[/]{0,1}"}, defaults={"trailingSlash": ""}, methods={"GET"}) * - * @return array|null|\Trikoder\JsonApiBundle\Contracts\ObjectListCollectionInterface + * @return array|\Trikoder\JsonApiBundle\Contracts\ObjectListCollectionInterface|null */ public function indexAction(Request $request) { diff --git a/src/Controller/Traits/Polyfill/SymfonyAutowiredServicesTrait.php b/src/Controller/Traits/Polyfill/SymfonyAutowiredServicesTrait.php index 1f07e54..9e1dd2c 100644 --- a/src/Controller/Traits/Polyfill/SymfonyAutowiredServicesTrait.php +++ b/src/Controller/Traits/Polyfill/SymfonyAutowiredServicesTrait.php @@ -2,6 +2,7 @@ namespace Trikoder\JsonApiBundle\Controller\Traits\Polyfill; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Trikoder\JsonApiBundle\Contracts\ResponseFactoryInterface; @@ -20,6 +21,8 @@ trait SymfonyAutowiredServicesTrait protected $authorizationChecker; + protected $propertyAccessor; + /** * @required */ @@ -84,4 +87,17 @@ public function getAuthorizationChecker(): AuthorizationCheckerInterface { return $this->authorizationChecker; } + + /** + * @required + */ + public function setPropertyAccessor(PropertyAccessorInterface $propertyAccessor) + { + $this->propertyAccessor = $propertyAccessor; + } + + public function getPropertyAccessor(): PropertyAccessorInterface + { + return $this->propertyAccessor; + } } diff --git a/src/Controller/Traits/ShowActionTrait.php b/src/Controller/Traits/ShowActionTrait.php index 7b5f717..748f7f9 100644 --- a/src/Controller/Traits/ShowActionTrait.php +++ b/src/Controller/Traits/ShowActionTrait.php @@ -15,7 +15,7 @@ trait ShowActionTrait /** * @Route("/{id}{trailingSlash}", requirements={"trailingSlash": "[/]{0,1}"}, defaults={"trailingSlash": ""}, methods={"GET"}) * - * @return null|object + * @return object|null */ public function showAction(Request $request, $id) { diff --git a/src/Controller/Traits/UpdateRelationshipActionTrait.php b/src/Controller/Traits/UpdateRelationshipActionTrait.php new file mode 100644 index 0000000..9dea1b5 --- /dev/null +++ b/src/Controller/Traits/UpdateRelationshipActionTrait.php @@ -0,0 +1,33 @@ +evaluateRequiredRole($this->getJsonApiConfig()->getUpdateRelationship()->getRequiredRoles()); + + $result = $this->updateRequestFromRelationshipRequest($request, $id, $relationshipName); + + if ($result instanceof Response) { + return $result; + } + + return $this->getJsonApiResponseFactory()->createNoContent(); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 6357c51..7c441dd 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,6 +4,8 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\HttpKernel\KernelEvents; +use Trikoder\JsonApiBundle\Listener\KernelListener; class Configuration implements ConfigurationInterface { @@ -37,6 +39,15 @@ public function getConfigTreeBuilder() ->scalarNode('request_body_decoder') ->defaultValue('trikoder.jsonapi.request_body_decoder') ->end() + ->scalarNode('relationship_request_body_decoder') + ->defaultValue('trikoder.jsonapi.relationship_request_body_decoder') + ->end() + ->scalarNode('request_body_validator') + ->defaultValue('trikoder.jsonapi.request_body_validator') + ->end() + ->scalarNode('relationship_request_body_validator') + ->defaultValue('trikoder.jsonapi.relationship_request_body_validator') + ->end() ->variableNode('fixed_filtering')->defaultValue([])->end() ->variableNode('allowed_include_paths')->defaultNull()->end() ->booleanNode('allow_extra_params')->defaultFalse()->end() @@ -75,6 +86,14 @@ public function getConfigTreeBuilder() ->arrayNode('schema_automap_scan_patterns') ->scalarPrototype()->end()->defaultValue([]) ->end() + ->integerNode('kernel_listener_on_kernel_view_priority') + ->info(sprintf('Priority for "%s" listener - "%s" event.', KernelListener::class, KernelEvents::VIEW)) + ->defaultValue(0) + ->end() + ->integerNode('kernel_listener_on_kernel_exception_priority') + ->info(sprintf('Priority for "%s" listener - "%s" event.', KernelListener::class, KernelEvents::EXCEPTION)) + ->defaultValue(0) + ->end() ->end(); $rootNode->addDefaultsIfNotSet(); diff --git a/src/DependencyInjection/TrikoderJsonApiExtension.php b/src/DependencyInjection/TrikoderJsonApiExtension.php index d67e199..9bd3061 100644 --- a/src/DependencyInjection/TrikoderJsonApiExtension.php +++ b/src/DependencyInjection/TrikoderJsonApiExtension.php @@ -4,9 +4,11 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\HttpKernel\KernelEvents; /** * This is the class that loads and manages your bundle configuration. @@ -30,5 +32,30 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container // inject defaults to config builder $configBuilderDefinition = $container->getDefinition('trikoder.jsonapi.config_builder'); $configBuilderDefinition->replaceArgument(0, $mergedConfig); + + $listenerDefinition = new Definition(); + $listenerDefinition->setClass('%trikoder.jsonapi.request_listener.class%')->setArguments([ + new Reference('trikoder.jsonapi.factory'), + new Reference('trikoder.jsonapi.request_body_decoder'), + new Reference('trikoder.jsonapi.response_factory'), + new Reference('trikoder.jsonapi.encoder'), + new Reference('logger'), + ])->addTag('kernel.event_listener', [ + 'event' => KernelEvents::CONTROLLER, + 'priority' => 16, + ])->addTag('kernel.event_listener', [ + 'event' => KernelEvents::CONTROLLER_ARGUMENTS, + 'priority' => -10, + ])->addTag('kernel.event_listener', [ + 'event' => KernelEvents::VIEW, + 'priority' => $mergedConfig['kernel_listener_on_kernel_view_priority'], + ])->addTag('kernel.event_listener', [ + 'event' => KernelEvents::RESPONSE, + ])->addTag('kernel.event_listener', [ + 'event' => KernelEvents::EXCEPTION, + 'priority' => $mergedConfig['kernel_listener_on_kernel_exception_priority'], + ]); + + $container->setDefinition('trikoder.jsonapi.request_listener', $listenerDefinition); } } diff --git a/src/Listener/JsonApiEnabledControllerDetectorTrait.php b/src/Listener/JsonApiEnabledControllerDetectorTrait.php index e682683..3e29681 100644 --- a/src/Listener/JsonApiEnabledControllerDetectorTrait.php +++ b/src/Listener/JsonApiEnabledControllerDetectorTrait.php @@ -3,6 +3,7 @@ namespace Trikoder\JsonApiBundle\Listener; use Closure; +use LogicException; use Trikoder\JsonApiBundle\Controller\JsonApiEnabledInterface; trait JsonApiEnabledControllerDetectorTrait @@ -15,38 +16,36 @@ trait JsonApiEnabledControllerDetectorTrait protected function isJsonApiEnabledController($controller) { // we cannot support Closure as we cannot look inside it safely + if (true === \is_object($controller)) { + return $controller instanceof JsonApiEnabledInterface; + } + if (true === \is_callable($controller) && false === ($controller instanceof Closure)) { - if ($controller[0] instanceof JsonApiEnabledInterface) { - return true; - } else { - return false; - } - } elseif (true === \is_object($controller)) { - if ($controller instanceof JsonApiEnabledInterface) { - return true; - } else { - return false; - } + return $controller[0] instanceof JsonApiEnabledInterface; } - throw new \LogicException('Unsupported type provided as controller'); + throw new LogicException(sprintf('Unsupported type provided as controller: %s', \gettype($controller))); } /** * @param $eventController * - * @return null|object + * @return object|null */ protected function resolveControllerFromEventController($eventController) { - if (true === \is_callable($eventController) && false === ($eventController instanceof Closure)) { - return $eventController[0]; - } elseif (true === \is_callable($eventController) && true === ($eventController instanceof Closure)) { + if (true === ($eventController instanceof Closure)) { return null; - } elseif (true === \is_object($eventController)) { + } + + if (true === \is_object($eventController)) { return $eventController; - } else { - return null; } + + if (true === \is_callable($eventController)) { + return $eventController[0]; + } + + return null; } } diff --git a/src/Listener/KernelListener.php b/src/Listener/KernelListener.php index df48c6b..1e807f4 100644 --- a/src/Listener/KernelListener.php +++ b/src/Listener/KernelListener.php @@ -16,6 +16,8 @@ use Trikoder\JsonApiBundle\Contracts\SchemaClassMapProviderInterface; use Trikoder\JsonApiBundle\Controller\JsonApiEnabledInterface; use Trikoder\JsonApiBundle\Response\DataResponse; +use Trikoder\JsonApiBundle\Response\Header; +use Trikoder\JsonApiBundle\Response\HttpAwareDataResponse; use Trikoder\JsonApiBundle\Services\Neomerx\EncoderService; use Trikoder\JsonApiBundle\Services\Neomerx\FactoryService; use Trikoder\JsonApiBundle\Services\RequestDecoder\RequestDecoder; @@ -118,7 +120,6 @@ public function onKernelController(FilterControllerEvent $event) /** * Transforms controller result to valid json api response if possible * - * * @throws \Exception */ public function onKernelView(GetResponseForControllerResultEvent $event) @@ -160,7 +161,6 @@ private function getResponseFromControllerResult($controllerResult, array $resul // TODO - this should never happen here? Response is called in kernelResponse break; - case $controllerResult instanceof DataResponse: $resultMeta = array_merge($resultMeta, $controllerResult->getMeta()); @@ -169,12 +169,24 @@ private function getResponseFromControllerResult($controllerResult, array $resul // allow null value data response if (null !== $controllerResult->getData()) { // as data inside can be anything, we extract extra info from response and pass all down for another round of decoding - return $this->getResponseFromControllerResult($controllerResult->getData(), $resultMeta, - $resultLinks); + $response = $this->getResponseFromControllerResult( + $controllerResult->getData(), $resultMeta, $resultLinks + ); + } else { + $response = $this->responseFactory->createResponse( + $this->encode(null, $resultMeta, $resultLinks) + ); } - $response = $this->responseFactory->createResponse($this->encode(null, $resultMeta, - $resultLinks)); + if ($controllerResult instanceof HttpAwareDataResponse) { + if (null !== $controllerResult->getStatusCode()) { + $response->setStatusCode($controllerResult->getStatusCode()); + } + + if (!empty($controllerResult->getHeaders())) { + $this->addHeadersToResponse($response, $controllerResult->getHeaders()); + } + } return $response; @@ -223,7 +235,7 @@ private function getResponseFromControllerResult($controllerResult, array $resul } /** - * @param array|Iterator|null|object|string $data + * @param array|Iterator|object|string|null $data * * @return string */ @@ -296,4 +308,14 @@ public function onKernelException(GetResponseForExceptionEvent $event) ]); } } + + /** + * @param Header[] $headers + */ + private function addHeadersToResponse(Response $response, array $headers) + { + foreach ($headers as $header) { + $response->headers->set($header->getKey(), $header->getValue()); + } + } } diff --git a/src/Model/ModelFactoryResolver.php b/src/Model/ModelFactoryResolver.php index 806902e..7a99b6d 100644 --- a/src/Model/ModelFactoryResolver.php +++ b/src/Model/ModelFactoryResolver.php @@ -24,13 +24,12 @@ class ModelFactoryResolver implements ModelFactoryResolverInterface */ public function resolve(string $modelClass): ModelFactoryInterface { - if (array_key_exists($modelClass, $this->registry)) { + if (\array_key_exists($modelClass, $this->registry)) { return $this->registry[$modelClass]; } elseif (null !== $this->defaultFactory) { return $this->defaultFactory; } else { - throw new RuntimeException(sprintf('No factory defined for model %s (no default factory also). Did you forget to register any in ModelFactoryInterface?', - $modelClass)); + throw new RuntimeException(sprintf('No factory defined for model %s (no default factory also). Did you forget to register any in ModelFactoryInterface?', $modelClass)); } } @@ -40,7 +39,7 @@ public function registerFactory(ModelFactoryInterface $factory, string $modelCla { if (null === $modelClass) { $this->defaultFactory = $factory; - } elseif (true === array_key_exists($modelClass, $this->registry)) { + } elseif (true === \array_key_exists($modelClass, $this->registry)) { throw new RuntimeException(sprintf('Factory for model %s is already defined', $modelClass)); } else { $this->registry[$modelClass] = $factory; diff --git a/src/Repository/RepositoryResolver.php b/src/Repository/RepositoryResolver.php index 2d65401..caf7376 100644 --- a/src/Repository/RepositoryResolver.php +++ b/src/Repository/RepositoryResolver.php @@ -33,11 +33,11 @@ public function resolve(string $modelClass): RepositoryInterface // first check for repository $resolvedRepository = null; - if (true === array_key_exists($modelClass, $this->repositoryRegistry)) { + if (true === \array_key_exists($modelClass, $this->repositoryRegistry)) { $resolvedRepository = $this->repositoryRegistry[$modelClass]; } - if (null === $resolvedRepository && true === array_key_exists($modelClass, $this->factoryRegistry)) { + if (null === $resolvedRepository && true === \array_key_exists($modelClass, $this->factoryRegistry)) { $resolvedRepository = $this->factoryRegistry[$modelClass]->create($modelClass); $this->registerRepository($resolvedRepository, $modelClass); } @@ -48,8 +48,7 @@ public function resolve(string $modelClass): RepositoryInterface } if (null === $resolvedRepository) { - throw new RuntimeException(sprintf('No repository found for model %s (no defaults also). Did you forget to register any in RepositoryResolverInterface?', - $modelClass)); + throw new RuntimeException(sprintf('No repository found for model %s (no defaults also). Did you forget to register any in RepositoryResolverInterface?', $modelClass)); } return $resolvedRepository; @@ -59,7 +58,7 @@ public function resolve(string $modelClass): RepositoryInterface */ public function registerRepository(RepositoryInterface $repository, string $modelClass) { - if (true === array_key_exists($modelClass, $this->repositoryRegistry)) { + if (true === \array_key_exists($modelClass, $this->repositoryRegistry)) { throw new RuntimeException(sprintf('Repository for model %s is already defined', $modelClass)); } else { $this->repositoryRegistry[$modelClass] = $repository; @@ -73,7 +72,7 @@ public function registerFactory(RepositoryFactoryInterface $factory, string $mod { if (true === (null === $modelClass)) { $this->defaultFactory = $factory; - } elseif (true === array_key_exists($modelClass, $this->factoryRegistry)) { + } elseif (true === \array_key_exists($modelClass, $this->factoryRegistry)) { throw new RuntimeException(sprintf('Repository factory for model %s is already defined', $modelClass)); } else { $this->factoryRegistry[$modelClass] = $factory; diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml index 399e176..012cbfe 100644 --- a/src/Resources/config/services.yml +++ b/src/Resources/config/services.yml @@ -3,7 +3,9 @@ parameters: trikoder.jsonapi.schema_class_map_provider.class: 'Trikoder\JsonApiBundle\Services\SchemaClassMapService' trikoder.jsonapi.factory.class: 'Trikoder\JsonApiBundle\Services\Neomerx\FactoryService' trikoder.jsonapi.request_body_decoder.class: 'Trikoder\JsonApiBundle\Services\RequestDecoder\RequestBodyDecoderService' - trikoder.jsonapi.request_body_validator.class: 'Trikoder\JsonApiBundle\Services\RequestDecoder\RequestBodyValidator' + trikoder.jsonapi.relationship_request_body_decoder.class: 'Trikoder\JsonApiBundle\Services\RequestDecoder\RelationshipRequestBodyDecoder' + trikoder.jsonapi.request_body_validator.class: 'Trikoder\JsonApiBundle\Services\RequestDecoder\SymfonyValidatorAdapter' + trikoder.jsonapi.relationship_request_body_validator.class: 'Trikoder\JsonApiBundle\Services\RequestDecoder\RelationshipValidatorAdapter' trikoder.jsonapi.response_factory.class: 'Trikoder\JsonApiBundle\Services\ResponseFactoryService' trikoder.jsonapi.encoder.class: 'Trikoder\JsonApiBundle\Services\Neomerx\EncoderService' trikoder.jsonapi.model_tools_factory.class: 'Trikoder\JsonApiBundle\Services\ModelInput\ModelToolsFactory' @@ -16,21 +18,6 @@ parameters: trikoder.jsonapi.simple_model_factory.class: 'Trikoder\JsonApiBundle\Model\Factory\SimpleModelFactory' services: - trikoder.jsonapi.request_listener: - class: "%trikoder.jsonapi.request_listener.class%" - arguments: - - "@trikoder.jsonapi.factory" - - "@trikoder.jsonapi.request_body_decoder" - - "@trikoder.jsonapi.response_factory" - - "@trikoder.jsonapi.encoder" - - "@logger" - tags: - - { name: kernel.event_listener, event: kernel.controller, priority: 16 } - - { name: kernel.event_listener, event: kernel.controller_arguments, priority: -10 } - - { name: kernel.event_listener, event: kernel.view } - - { name: kernel.event_listener, event: kernel.response } - - { name: kernel.event_listener, event: kernel.exception } - trikoder.jsonapi.controller_config_listener: class: "%trikoder.jsonapi.controller_config_listener.class%" arguments: @@ -50,20 +37,34 @@ services: trikoder.jsonapi.factory: class: "%trikoder.jsonapi.factory.class%" arguments: ['@Trikoder\JsonApiBundle\Services\Neomerx\ServiceContainer', "@logger"] + public: true + + Trikoder\JsonApiBundle\Services\Neomerx\FactoryService: "@trikoder.jsonapi.factory" trikoder.jsonapi.request_body_decoder: class: "%trikoder.jsonapi.request_body_decoder.class%" - arguments: ['@Trikoder\JsonApiBundle\Contracts\RequestBodyValidatorInterface'] public: true - Trikoder\JsonApiBundle\Contracts\RequestBodyValidatorInterface: + trikoder.jsonapi.relationship_request_body_decoder: + class: "%trikoder.jsonapi.relationship_request_body_decoder.class%" + public: true + + trikoder.jsonapi.request_body_validator: class: '%trikoder.jsonapi.request_body_validator.class%' + arguments: ['@validator'] + public: true + + trikoder.jsonapi.relationship_request_body_validator: + class: '%trikoder.jsonapi.relationship_request_body_validator.class%' + arguments: ['@validator'] + public: true Trikoder\JsonApiBundle\Contracts\ResponseFactoryInterface: class: "%trikoder.jsonapi.response_factory.class%" arguments: - "@trikoder.jsonapi.encoder" - "@trikoder.jsonapi.error_factory" + - '@Trikoder\JsonApiBundle\Services\ResponseLinterInterface' public: true trikoder.jsonapi.response_factory: '@Trikoder\JsonApiBundle\Contracts\ResponseFactoryInterface' @@ -89,6 +90,7 @@ services: Trikoder\JsonApiBundle\Services\ModelInput\ModelMetaDataFactory: class: "%trikoder.jsonapi.model_meta_data_factory.class%" arguments: ["@doctrine.orm.entity_manager"] + public: true trikoder.jsonapi.model_meta_data_factory: '@Trikoder\JsonApiBundle\Services\ModelInput\ModelMetaDataFactory' @@ -101,7 +103,7 @@ services: trikoder.jsonapi.doctrine_repository_factory: class: "%trikoder.jsonapi.doctrine_repository_factory.class%" - arguments: ["@doctrine.orm.entity_manager"] + arguments: ["@doctrine.orm.entity_manager", "@property_accessor"] public: true trikoder.jsonapi.simple_model_factory: @@ -112,3 +114,11 @@ services: class: "%trikoder.jsonapi.model_factory_resolver.class%" calls: - [registerFactory, ['@trikoder.jsonapi.simple_model_factory']] + + Trikoder\JsonApiBundle\Services\JsonResponseLinter: + class: 'Trikoder\JsonApiBundle\Services\JsonResponseLinter' + + Trikoder\JsonApiBundle\Services\JsonApiResponseLinter: + class: 'Trikoder\JsonApiBundle\Services\JsonApiResponseLinter' + + Trikoder\JsonApiBundle\Services\ResponseLinterInterface: '@Trikoder\JsonApiBundle\Services\JsonApiResponseLinter' \ No newline at end of file diff --git a/src/Resources/doc/configuration/configuration.md b/src/Resources/doc/configuration/configuration.md index 67f0280..5bb63fd 100644 --- a/src/Resources/doc/configuration/configuration.md +++ b/src/Resources/doc/configuration/configuration.md @@ -7,6 +7,16 @@ For needs of this document, term Field means any model attribute or relation. ## Configuration directives +### kernel_listener_on_kernel_view_priority + +This configures the `\Trikoder\JsonApiBundle\Listener\KernelListener` listener +`kernel.view` event priority. The value must be an integer. + +### kernel_listener_on_kernel_exception_priority + +This configures the `\Trikoder\JsonApiBundle\Listener\KernelListener` listener +`kernel.exception` event priority. The value must be an integer. + ### model class Model class that this controller is responsible of. Defaults to `\stdClass`. @@ -29,6 +39,27 @@ You can remap fields, change formats, etc. Defaults to `\Trikoder\JsonApiBundle\Services\RequestDecoder\RequestBodyDecoderService` . +### relationship_request_body_decoder + +Decoder which is used when accessing relationship endpoint. +Must implement `\Trikoder\JsonApiBundle\Contracts\RequestBodyDecoderInterface`. + +Defaults to `trikoder.jsonapi.relationship_request_body_decoder` which is `\Trikoder\JsonApiBundle\Services\RequestDecoder\RelationshipRequestBodyDecoder` by default. + +### request_body_validator + +Validator which is used when accessing non-relationship endpoint. +Must implement `\Trikoder\JsonApiBundle\Contracts\RequestBodyValidatorInterface`. + +Defaults to `trikoder.jsonapi.request_body_validator` which is `\Trikoder\JsonApiBundle\Services\RequestDecoder\RequestBodyDecoderService` by default. + +### relationship_request_body_validator + +Validator which is used when accessing relationship endpoint. +Must implement `\Trikoder\JsonApiBundle\Contracts\RequestBodyValidatorInterface`. + +Defaults to `trikoder.jsonapi.relationship_request_body_validator` which is `\Trikoder\JsonApiBundle\Services\RequestDecoder\RelationshipValidatorAdapter` by default. + ### fixed filtering Array of fixed filtering defined for this controller. It is applied to all load action from repository (index, show, update, delete). Defaults to empty array. @@ -146,3 +177,14 @@ Each action of CRUD has corresponding trait (or several of them for simple/speci ### Delete #### DeleteActionTrait + +#### UpdateRelationshipTrait + +Trait which adds support for [updating To-Many Relationships](https://jsonapi.org/format/#crud-updating-to-many-relationships) with `POST` and `DELETE` methods. +In order to use it, controller attribute `_jsonapibundle_relationship_endpoint` must be set to `true` so code outside controller can treat request as relationship request. +To configure which relationships can be updated see [example](https://gitlab.trikoder.net/trikoder/jsonapibundle/blob/master/src/Resources/doc/configuration/examples/exampleAnnotation.php). + +#### UpdateRelationshipActionTrait + +Creates `/relationships/` endpoints for model and uses `UpdateRelationshipTrait`. +If `UpdateRelationshipTrait` returns model, return [204 No Content](https://jsonapi.org/format/#crud-updating-relationship-responses-204) response, otherwise return response received from `UpdateRelationshipTrait`. diff --git a/src/Resources/doc/configuration/examples/example.yml b/src/Resources/doc/configuration/examples/example.yml index c7650a3..9cb7cb7 100644 --- a/src/Resources/doc/configuration/examples/example.yml +++ b/src/Resources/doc/configuration/examples/example.yml @@ -2,6 +2,7 @@ trikoder_json_api: model_class: '\stdClass' repository: "trikoder.jsonapi.doctrine_repository_factory" request_body_decoder: "trikoder.jsonapi.request_body_decoder" + relationship_request_body_decoder: "trikoder.jsonapi.relationship_request_body_decoder" fixed_filtering: {} allowed_include_paths: null allow_extra_params: false @@ -22,3 +23,5 @@ trikoder_json_api: delete: required_roles: null schema_automap_scan_patterns: ['src/*Schema/'] + kernel_listener_on_kernel_view_priority: 0 + kernel_listener_on_kernel_exception_priority: 0 diff --git a/src/Resources/doc/configuration/examples/exampleAnnotation.php b/src/Resources/doc/configuration/examples/exampleAnnotation.php index 2c1ac9a..02186f2 100644 --- a/src/Resources/doc/configuration/examples/exampleAnnotation.php +++ b/src/Resources/doc/configuration/examples/exampleAnnotation.php @@ -29,6 +29,10 @@ * ), * delete=@JsonApiConfig\DeleteConfig( * requiredRoles=null + * ), + * updateRelationship=@JsonApiConfig\UpdateRelationshipConfig( + * allowedRelationships={"tags"}, + * requiredRoles=null * ) * ) */ diff --git a/src/Resources/doc/flow/write_actions.md b/src/Resources/doc/flow/write_actions.md index 04f0305..81fcb60 100644 --- a/src/Resources/doc/flow/write_actions.md +++ b/src/Resources/doc/flow/write_actions.md @@ -2,7 +2,7 @@ ## Write process -Write process in jsonapi bundle is provided by Create, Update and Delete action traits. +Write process in jsonapi bundle is provided by Create, Update, UpdateRelationship and Delete action traits. ## Create and update Create and update of model follows similar flow with few differences in start and end points that are described in next chapter. @@ -19,6 +19,14 @@ There are two differences in create and update actions: 1. starting model in create action is aquired from create factory while update action get it's from repository 2. create's proper response is redirect to show action while update will return updated resource +## UpdateRelationship +Adds or removes resources from relationship. + +Process for both is same: +1. validate that relationship is allowed to be updated (by using `JsonApiConfig\UpdateRelationshipConfig`) +1. get starting model +1. use `\Trikoder\JsonApiBundle\Contracts\RelationshipRepositoryInterface` to add or remove resources from relationship +1. return `\Symfony\Component\HttpFoundation\Response` response if something went wrong or return model if it was successfully changed ## Delete Delete action will use repository to delete loaded model. diff --git a/src/Resources/doc/getting_started/advanced_usage.md b/src/Resources/doc/getting_started/advanced_usage.md index d961bfc..3db4637 100644 --- a/src/Resources/doc/getting_started/advanced_usage.md +++ b/src/Resources/doc/getting_started/advanced_usage.md @@ -24,3 +24,7 @@ public function getSchemaClassMapProvider() return $mapService; } ``` + +## JSON API VALIDATORS + +To see available JSON API VALIDATORS open [this document](json_api_validators.md) \ No newline at end of file diff --git a/src/Resources/doc/getting_started/examples/ExampleController.php b/src/Resources/doc/getting_started/examples/ExampleController.php index f0e64c4..87e1ef6 100644 --- a/src/Resources/doc/getting_started/examples/ExampleController.php +++ b/src/Resources/doc/getting_started/examples/ExampleController.php @@ -10,6 +10,7 @@ use Trikoder\JsonApiBundle\Controller\Traits\IndexActionTrait; use Trikoder\JsonApiBundle\Controller\Traits\ShowActionTrait; use Trikoder\JsonApiBundle\Controller\Traits\UpdateActionTrait; +use Trikoder\JsonApiBundle\Controller\Traits\UpdateRelationshipActionTrait; /** * @Route("/example") @@ -25,4 +26,5 @@ class ExampleController extends JsonApiController use CreateActionTrait; use UpdateActionTrait; use DeleteActionTrait; + use UpdateRelationshipActionTrait; } diff --git a/src/Resources/doc/getting_started/json_api_validators.md b/src/Resources/doc/getting_started/json_api_validators.md new file mode 100644 index 0000000..b82c1c4 --- /dev/null +++ b/src/Resources/doc/getting_started/json_api_validators.md @@ -0,0 +1,97 @@ +# Validators +By default JsonApiBundle will use `SymfonyValidatorAdapter` to validate JSON request. + +## Symfony validator +This validator uses `SymfonyValidatorAdapter` class. +This validator is going to validate following structures and mark them as valid: +``` +{ + "data": { + "type": "articles", + "id": "1", + "relationships": { + "author": { + "data": { "type": "people", "id": "9" } + }, + } + } +} +``` +``` +{ + "data": { + "type": "articles", + "id": "1", + "relationships": { + "author": { + "data": [ + { "type": "people", "id": "9" }, + { "type": "people", "id": "10" } + ] + }, + } + } +} +``` + +``` +{ + "data": { + "type": "articles", + "id": "1", + "relationships": { + "author": { + "data": { "id": "9" } + }, + } + } +} +``` +``` +{ + "data": { + "type": "articles", + "id": "1", + "relationships": { + "author": { + "data": null + }, + } + } +} +``` +``` +{ + "data": { + "type": "articles", + "id": "1", + "relationships": { + "author": { + "data": [] + }, + } + } +} +``` + + +## Relationship validators +This validator uses `RelationshipValidatorAdapter` class. +In order to use this validator you need to set it to action within controller, for example +``` +/** + * @Route("", name="some_name", methods={"POST"}, defaults={"_jsonapibundle_relationship_endpoint": true}) + */ +public function sampleAction() {} +``` + +This validator is going to validate following structures and mark them as valid: + +``` +{ + "data": [ + { "type": "tags", "id": "2" }, + { "type": "tags", "id": "3" } + ] +} +``` \ No newline at end of file diff --git a/src/Response/CreatedResponse.php b/src/Response/CreatedResponse.php new file mode 100644 index 0000000..7f35722 --- /dev/null +++ b/src/Response/CreatedResponse.php @@ -0,0 +1,19 @@ +data; diff --git a/src/Response/Header.php b/src/Response/Header.php new file mode 100644 index 0000000..df03299 --- /dev/null +++ b/src/Response/Header.php @@ -0,0 +1,34 @@ +key = $key; + $this->value = $value; + } + + public function getKey(): string + { + return $this->key; + } + + public function getValue(): string + { + return $this->value; + } +} diff --git a/src/Response/HttpAwareDataResponse.php b/src/Response/HttpAwareDataResponse.php new file mode 100644 index 0000000..196fac3 --- /dev/null +++ b/src/Response/HttpAwareDataResponse.php @@ -0,0 +1,44 @@ +statusCode = $statusCode; + $this->headers = $headers; + parent::__construct($data, $meta, $links); + } + + /** + * @return int|null + */ + public function getStatusCode() + { + return $this->statusCode; + } + + /** + * @return Header[] + */ + public function getHeaders(): array + { + return $this->headers; + } +} diff --git a/src/Schema/Builtin/GenericSchema.php b/src/Schema/Builtin/GenericSchema.php new file mode 100644 index 0000000..cbef3a6 --- /dev/null +++ b/src/Schema/Builtin/GenericSchema.php @@ -0,0 +1,166 @@ +modelMetaDataFactory = $modelMetaDataFactory; + $this->setModelClassName($className); + + parent::__construct($factory); + + $this->propertyAccessor = PropertyAccess::createPropertyAccessorBuilder() + ->enableMagicCall() + ->enableExceptionOnInvalidIndex() + //->enableExceptionOnInvalidPropertyPath() + ->getPropertyAccessor(); + } + + private function setModelClassName(string $className): void + { + if (\in_array(ResourceInterface::class, class_implements($className))) { + $resourceType = \call_user_func([$className, 'getJsonApiResourceType']); + } else { + $resourceType = strtolower($className); + } + + $this->setResourceType($resourceType); + + $this->modelMetaData = $this->modelMetaDataFactory->getMetaDataForModel($className); + } + + private function setResourceType(string $resourceType): void + { + if (null !== $this->resourceType) { + throw new RuntimeException('Change of resource type for generic schema in runtime is not allowed'); + } + $this->resourceType = $resourceType; + } + + private function prepareAttributeAndRelationshipsList(): void + { + $this->attributesList = []; + $this->relationshipList = []; + + foreach ($this->modelMetaData->getAllFields() as $fieldName) { + $fieldType = $this->modelMetaData->getTypeForField($fieldName); + + if (null != $fieldType) { + $this->attributesList[] = $fieldName; + } else { + $this->relationshipList[] = $fieldName; + } + } + } + + /** + * Get resource identity. + * + * @param object $resource + */ + public function getId($resource): string + { + return (string) $this->propertyAccessor->getValue($resource, 'id'); + } + + /** + * Get resource attributes. + * + * @param object $resource + * + * @return array + */ + public function getAttributes($resource) + { + if (null === $this->attributesList) { + $this->prepareAttributeAndRelationshipsList(); + } + + /** @var object $resource */ + $attributes = []; + + foreach ($this->attributesList as $fieldName) { + if ('id' === $fieldName) { + continue; + } + $attributes[$fieldName] = $this->parseAttributeValue($this->propertyAccessor->getValue($resource, $fieldName)); + } + + return $attributes; + } + + public function getRelationships($resource, $isPrimary, array $includeRelationships) + { + /** @var Post $resource */ + $relationships = []; + + foreach ($this->relationshipList as $relationName) { + $relationships[$relationName] = [ + self::DATA => function () use ($resource, $relationName) { + return $this->propertyAccessor->getValue($resource, $relationName); + }, + ]; + } + + return $relationships; + } + + public function getIncludePaths() + { + return $this->relationshipList; + } + + private function parseAttributeValue($value) + { + //handle special cases such as datetime... + + // datetime classes + if ($value instanceof DateTimeInterface) { + /* @var DateTimeInterface $value */ + return $value->format($value::ATOM); + } + + // Serializable + if ($value instanceof Serializable) { + /* @var Serializable $value */ + return $value->serialize(); + } + + return $value; + } +} diff --git a/src/Schema/Builtin/ResourceInterface.php b/src/Schema/Builtin/ResourceInterface.php new file mode 100644 index 0000000..134c1dd --- /dev/null +++ b/src/Schema/Builtin/ResourceInterface.php @@ -0,0 +1,8 @@ +factoryService = $factoryService; + $this->schemaClassMap = $schemaClassMap; + $this->resourceMap = $this->schemaClassMapToResourceMap($schemaClassMap); + + if (null === $propertyAccessor) { + $this->propertyAccessor = PropertyAccess::createPropertyAccessorBuilder() + ->enableMagicCall() + ->enableExceptionOnInvalidIndex() + //->enableExceptionOnInvalidPropertyPath() + ->getPropertyAccessor(); + } else { + $this->propertyAccessor = $propertyAccessor; + } + } + + /** + * @return object + */ + public function decode(string $json, SchemaClassMapProviderInterface $schemaClassMap = null) + { + if (null !== $schemaClassMap) { + $resourceMap = $this->schemaClassMapToResourceMap($schemaClassMap); + } else { + $resourceMap = $this->resourceMap; + } + + $body = json_decode($json, true); + + if (null === $body['data']) { + return null; + } + + if (empty($body['data']) && !\is_array($body['data'])) { + throw new InvalidArgumentException('Provided JsonApi payload does not contain any data'); + } + + // get main resource + if (\array_key_exists('included', $body)) { + // reorganize includedes to be type-id keyed + $included = []; + foreach ($body['included'] as $includedItem) { + if (empty($includedItem['id'])) { + throw new InvalidArgumentException('Included resource must have identifier'); + } + $includedKey = sprintf('%s-%s', $includedItem['type'], $includedItem['id']); + $included[$includedKey] = $includedItem; + } + } else { + $included = null; + } + + // if one or multiple? + if (\array_key_exists('type', $body['data'])) { + $resourceType = $this->getResourceForType($body['data']['type'], $resourceMap); + $resource = $this->populateResource($body['data'], $resourceType, $included, $resourceMap); + } else { + $resource = []; + foreach ($body['data'] as $resourceItem) { + $resourceType = $this->getResourceForType($resourceItem['type'], $resourceMap); + $resource[] = $this->populateResource($resourceItem, $resourceType, $included, $resourceMap); + } + } + + return $resource; + } + + private function getResourceForType(string $type, array $resourceMap): string + { + return $resourceMap[$type]; + } + + private function populateResource(array $payload, $resourceClass, array $included = null, $resourceMap) + { + $resource = new $resourceClass(); + + // populate id + if (\array_key_exists('id', $payload)) { + $this->propertyAccessor->setValue($resource, 'id', $payload['id']); + } else { + throw new InvalidArgumentException('Resource must have identifier'); + } + + // populate attributes + if (\array_key_exists('attributes', $payload)) { + foreach ($payload['attributes'] as $fieldName => $fieldValue) { + $this->propertyAccessor->setValue($resource, $fieldName, $fieldValue); + } + } + + // populate relations + if (\array_key_exists('relationships', $payload)) { + foreach ($payload['relationships'] as $relationName => $relationPayload) { + if (!empty($relationPayload['data'])) { + // if one or many? if array is numeric indexed then it is list of objects, if assoc, then it is one object + if (\array_key_exists(0, $relationPayload['data'])) { + // many + $relationResource = []; + + foreach ($relationPayload['data'] as $relationItemPayload) { + $relationResourceId = $relationItemPayload['id']; + $relationResourceType = $this->getResourceForType($relationItemPayload['type'], $resourceMap); + $relationResourcePayload = \array_key_exists(sprintf('%s-%s', $relationItemPayload['type'], $relationResourceId), $included) ? $included[sprintf('%s-%s', $relationItemPayload['type'], $relationResourceId)] : $relationItemPayload; + $relationResource[] = $this->populateResource($relationResourcePayload, $relationResourceType, $included, $resourceMap); + } + } else { + // one + $relationResourceId = $relationPayload['data']['id']; + $relationResourceType = $this->getResourceForType($relationPayload['data']['type'], $resourceMap); + $relationResourcePayload = \array_key_exists(sprintf('%s-%s', $relationPayload['data']['type'], $relationResourceId), $included) ? $included[sprintf('%s-%s', $relationPayload['data']['type'], $relationResourceId)] : $relationPayload['data']; + $relationResource = $this->populateResource($relationResourcePayload, $relationResourceType, $included, $resourceMap); + } + + $this->propertyAccessor->setValue($resource, $relationName, $relationResource); + } + } + } + + return $resource; + } + + private function schemaClassMapToResourceMap(SchemaClassMapProviderInterface $schemaClassMap): array + { + $resourceMap = []; + $container = $this->factoryService->createContainer($schemaClassMap->getMap()); + + foreach ($schemaClassMap->getMap() as $modelClass => $schemaClass) { + /** @var AbstractSchema $schema */ + $schema = $container->getSchemaByType($modelClass); + + $resourceType = $schema->getResourceType(); + + $resourceMap[$resourceType] = $modelClass; + } + + return $resourceMap; + } +} diff --git a/src/Services/ConfigBuilder.php b/src/Services/ConfigBuilder.php index 4b36456..e778d65 100644 --- a/src/Services/ConfigBuilder.php +++ b/src/Services/ConfigBuilder.php @@ -11,6 +11,7 @@ use Trikoder\JsonApiBundle\Config\DeleteConfig; use Trikoder\JsonApiBundle\Config\IndexConfig; use Trikoder\JsonApiBundle\Config\UpdateConfig; +use Trikoder\JsonApiBundle\Config\UpdateRelationshipConfig; use Trikoder\JsonApiBundle\Contracts\Config\ApiConfigInterface; use Trikoder\JsonApiBundle\Model\ModelFactoryResolverInterface; use Trikoder\JsonApiBundle\Repository\RepositoryFactoryInterface; @@ -44,7 +45,6 @@ public function __construct(array $defaults, ContainerInterface $container) /** * Creates config using provided annotation, if non supplied, config is with defaults * - * * @return Config */ public function fromAnnotation(Annotation\Config $configAnnotation = null) @@ -62,14 +62,19 @@ public function fromAnnotation(Annotation\Config $configAnnotation = null) $configAnnotation->delete = new Annotation\DeleteConfig(); } + if (null === $configAnnotation->updateRelationship) { + $configAnnotation->updateRelationship = new Annotation\UpdateRelationshipConfig(); + } + // prepare config parts $configApi = $this->createApiConfig($configAnnotation); $configIndex = $this->createIndexConfig($configAnnotation); // TODO - review index name for retreive (R of CRUD) $configCreate = $this->createCreateConfig($configAnnotation, $configApi); $configUpdate = $this->createUpdateConfig($configAnnotation); $configDelete = $this->createDeleteConfig($configAnnotation); + $configUpdateRelationship = $this->createUpdateRelationshipConfig($configAnnotation); - $config = new Config($configApi, $configCreate, $configIndex, $configUpdate, $configDelete); + $config = new Config($configApi, $configCreate, $configIndex, $configUpdate, $configDelete, $configUpdateRelationship); return $config; } @@ -90,8 +95,7 @@ protected function createApiConfig(Annotation\Config $configAnnotation = null) if ($this->serviceContainer->has($repository)) { $repository = $this->serviceContainer->get($repository); } else { - throw new \RuntimeException(sprintf('Value for repository setting must be valid service, given value: %s', - $repository)); + throw new \RuntimeException(sprintf('Value for repository setting must be valid service, given value: %s', $repository)); } } // if resolver or factory, put closure to resolve the repo @@ -116,8 +120,40 @@ protected function createApiConfig(Annotation\Config $configAnnotation = null) if ($this->serviceContainer->has($requestBodyDecoder)) { $requestBodyDecoder = $this->serviceContainer->get($requestBodyDecoder); } else { - throw new \RuntimeException(sprintf('String value for RequestBodyDecoder setting must be valid service, given value: %s', - $requestBodyDecoder)); + throw new \RuntimeException(sprintf('String value for RequestBodyDecoder setting must be valid service, given value: %s', $requestBodyDecoder)); + } + } + + $relationshipRequestBodyDecoder = $this->annotationValueIfNotNull($configAnnotation, function ($configAnnotation) { + return $configAnnotation->relationshipRequestBodyDecoder; + }, $this->defaults['relationship_request_body_decoder']); + if (true === \is_string($relationshipRequestBodyDecoder)) { + if ($this->serviceContainer->has($relationshipRequestBodyDecoder)) { + $relationshipRequestBodyDecoder = $this->serviceContainer->get($relationshipRequestBodyDecoder); + } else { + throw new \RuntimeException(sprintf('String value for RequestBodyDecoder setting must be valid service, given value: %s', $relationshipRequestBodyDecoder)); + } + } + + $requestBodyValidator = $this->annotationValueIfNotNull($configAnnotation, function ($configAnnotation) { + return $configAnnotation->requestBodyValidator; + }, $this->defaults['request_body_validator']); + if (true === \is_string($requestBodyValidator)) { + if ($this->serviceContainer->has($requestBodyValidator)) { + $requestBodyValidator = $this->serviceContainer->get($requestBodyValidator); + } else { + throw new \RuntimeException(sprintf('String value for RequestBodyValidator setting must be valid service, given value: %s', $requestBodyValidator)); + } + } + + $relationshipRequestBodyValidator = $this->annotationValueIfNotNull($configAnnotation, function ($configAnnotation) { + return $configAnnotation->requestBodyValidator; + }, $this->defaults['relationship_request_body_validator']); + if (true === \is_string($relationshipRequestBodyValidator)) { + if ($this->serviceContainer->has($relationshipRequestBodyValidator)) { + $relationshipRequestBodyValidator = $this->serviceContainer->get($relationshipRequestBodyValidator); + } else { + throw new \RuntimeException(sprintf('String value for RequestBodyValidator setting must be valid service, given value: %s', $relationshipRequestBodyValidator)); } } @@ -139,7 +175,10 @@ protected function createApiConfig(Annotation\Config $configAnnotation = null) $fixedFiltering, $allowedIncludePaths, $requestBodyDecoder, - $allowExtraParams + $allowExtraParams, + $requestBodyValidator, + $relationshipRequestBodyValidator, + $relationshipRequestBodyDecoder ); return $config; @@ -200,8 +239,7 @@ protected function createCreateConfig( if ($this->serviceContainer->has($factory)) { $factory = $this->serviceContainer->get($factory); } else { - throw new \RuntimeException(sprintf('String value for create factory setting must be valid service, given value: %s', - $factory)); + throw new \RuntimeException(sprintf('String value for create factory setting must be valid service, given value: %s', $factory)); } } if (true === ($factory instanceof ModelFactoryResolverInterface)) { @@ -253,6 +291,24 @@ protected function createDeleteConfig(Annotation\Config $configAnnotation = null return $config; } + /** + * @return UpdateRelationshipConfig + */ + protected function createUpdateRelationshipConfig(Annotation\Config $configAnnotation = null) + { + $allowedRelationships = $this->annotationValueIfNotNull($configAnnotation, function ($configAnnotation) { + return $configAnnotation->updateRelationship->allowedRelationships; + }, null); + + $requiredRoles = $this->annotationValueIfNotNull($configAnnotation, function ($configAnnotation) { + return $configAnnotation->updateRelationship->requiredRoles; + }, null); + + $config = new UpdateRelationshipConfig($allowedRelationships, $requiredRoles); + + return $config; + } + /** * Helper method to select between config options * diff --git a/src/Services/JsonApiResponseLinter.php b/src/Services/JsonApiResponseLinter.php new file mode 100644 index 0000000..2c3e349 --- /dev/null +++ b/src/Services/JsonApiResponseLinter.php @@ -0,0 +1,17 @@ +headers->set('Content-type', self::CONTENT_TYPE); + + return $response; + } +} diff --git a/src/Services/JsonResponseLinter.php b/src/Services/JsonResponseLinter.php new file mode 100644 index 0000000..6ceb27e --- /dev/null +++ b/src/Services/JsonResponseLinter.php @@ -0,0 +1,17 @@ +headers->set('Content-type', self::CONTENT_TYPE); + + return $response; + } +} diff --git a/src/Services/ModelInput/AbstractFormModelInputHandler.php b/src/Services/ModelInput/AbstractFormModelInputHandler.php index b6c9b9c..292d5f0 100644 --- a/src/Services/ModelInput/AbstractFormModelInputHandler.php +++ b/src/Services/ModelInput/AbstractFormModelInputHandler.php @@ -82,8 +82,7 @@ public function handle(array $input) } throw new UnhandleableModelInputException($extraFields); } else { - throw new UnhandleableModelInputException([], - new ModelValidationException($this->convertFormErrorsToErrors($formErrors))); + throw new UnhandleableModelInputException([], new ModelValidationException($this->convertFormErrorsToErrors($formErrors))); } } diff --git a/src/Services/ModelInput/DoctrineModelMetaData.php b/src/Services/ModelInput/DoctrineModelMetaData.php index ddabec7..4dac5bd 100644 --- a/src/Services/ModelInput/DoctrineModelMetaData.php +++ b/src/Services/ModelInput/DoctrineModelMetaData.php @@ -32,7 +32,7 @@ public function getAllFields(): array } /** - * @return null|string + * @return string|null */ public function getTypeForField(string $fieldName) { diff --git a/src/Services/ModelInput/GenericFormModelInputHandler.php b/src/Services/ModelInput/GenericFormModelInputHandler.php index 0d5c2d9..2ad3148 100644 --- a/src/Services/ModelInput/GenericFormModelInputHandler.php +++ b/src/Services/ModelInput/GenericFormModelInputHandler.php @@ -2,6 +2,7 @@ namespace Trikoder\JsonApiBundle\Services\ModelInput; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\FormType; @@ -105,6 +106,14 @@ private function createForm() $fieldOptions['allow_delete'] = true; $fieldOptions['delete_empty'] = true; break; + case 'bool': + $type = CheckboxType::class; + break; + default: + // if we do not have doctrine entity, we cannot leave form to autoguess + if ($this->modelMetaData instanceof GenericModelMetaData) { + $type = TextType::class; + } } $formBuilder->add($fieldName, $type, $fieldOptions); diff --git a/src/Services/ModelInput/GenericModelMetaData.php b/src/Services/ModelInput/GenericModelMetaData.php index caeb13d..0572a2c 100644 --- a/src/Services/ModelInput/GenericModelMetaData.php +++ b/src/Services/ModelInput/GenericModelMetaData.php @@ -28,7 +28,7 @@ public function getAllFields(): array } /** - * @return null|string + * @return string|null */ public function getTypeForField(string $fieldName) { diff --git a/src/Services/ModelInput/ModelValidator.php b/src/Services/ModelInput/ModelValidator.php index c589f06..65ea0b0 100644 --- a/src/Services/ModelInput/ModelValidator.php +++ b/src/Services/ModelInput/ModelValidator.php @@ -17,7 +17,7 @@ class ModelValidator implements ModelValidatorInterface */ protected $validator; - /** @var object $model */ + /** @var object */ protected $model; /** diff --git a/src/Services/ModelInput/Traits/ConstraintViolationToErrorTransformer.php b/src/Services/ModelInput/Traits/ConstraintViolationToErrorTransformer.php index eade346..e54b645 100644 --- a/src/Services/ModelInput/Traits/ConstraintViolationToErrorTransformer.php +++ b/src/Services/ModelInput/Traits/ConstraintViolationToErrorTransformer.php @@ -40,11 +40,8 @@ protected function convertViolationToError(ConstraintViolationInterface $violati $source = []; if ($violation->getPropertyPath()) { // TODO - make diff between attributes and relationships - $source['pointer'] = '/data/attributes/' . $violation->getPropertyPath(); - } - if (2 === $code) { - // TODO - parameter should be string? maybe we should send this in meta? - $source['parameter'] = $violation->getParameters(); + $source['pointer'] = '/data/attributes/' . str_replace('.', '/', $violation->getPropertyPath()); + $source['parameter'] = (string) $violation->getInvalidValue(); } return new Error( diff --git a/src/Services/ModelInput/Traits/FormErrorToErrorTransformer.php b/src/Services/ModelInput/Traits/FormErrorToErrorTransformer.php index 242d2ed..52b26a9 100644 --- a/src/Services/ModelInput/Traits/FormErrorToErrorTransformer.php +++ b/src/Services/ModelInput/Traits/FormErrorToErrorTransformer.php @@ -5,7 +5,11 @@ use Neomerx\JsonApi\Document\Error; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormErrorIterator; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationInterface; /** * Trait FormErrorToErrorTransformer @@ -35,20 +39,70 @@ protected function convertFormErrorToError(FormError $violation) $title = $violation->getMessage(); $detail = sprintf('Form error "%s"', $violation->getMessage()); $source = []; - if ($violation->getOrigin()) { + + if ($violation->getCause() && $violation->getCause() instanceof ConstraintViolationInterface) { + $source['pointer'] = $this->parsePointerFromViolation($violation); + $source['parameter'] = (string) $violation->getCause()->getInvalidValue(); + } elseif ($violation->getOrigin()) { // TODO - make diff between attributes and relationships - $source['pointer'] = '/data/attributes/' . $violation->getOrigin()->getName(); - $source['parameter'] = $violation->getOrigin()->getData(); + $source['pointer'] = $this->parsePointerFromViolation($violation); + $source['parameter'] = (string) $violation->getOrigin()->getData(); } return new Error( null, null, Response::HTTP_CONFLICT, - null, + $this->getCodeFromViolation($violation), $title, $detail, $source ); } + + /** + * @return string|null + */ + private function parsePointerFromViolation(FormError $violation) + { + $propertyPath = $this->getPropertyPathFromOrigin($violation->getOrigin()); + // TODO - make diff between attributes and relationships + return '/data/attributes/' . $propertyPath; + } + + /** + * @return string + */ + protected function getCodeFromViolation(FormError $violation) + { + if ( + $violation->getCause() instanceof ConstraintViolation + && + $violation->getCause()->getConstraint() instanceof Constraint + ) { + return $violation->getCause()->getConstraint()->payload['code'] ?? null; + } + + return null; + } + + /** + * @param FormInterface|null $form + */ + private function getPropertyPathFromOrigin($form): string + { + if (null === $form) { + return ''; + } + + if ( + $form->isRoot() + || + (null !== $form->getParent() && $form->getParent()->isRoot()) + ) { + return $form->getName(); + } + + return $this->getPropertyPathFromOrigin($form->getParent()) . '/' . $form->getName(); + } } diff --git a/src/Services/Neomerx/Container.php b/src/Services/Neomerx/Container.php index ff238fa..1b92e44 100644 --- a/src/Services/Neomerx/Container.php +++ b/src/Services/Neomerx/Container.php @@ -3,13 +3,18 @@ namespace Trikoder\JsonApiBundle\Services\Neomerx; use Doctrine\Common\Persistence\Proxy; +use InvalidArgumentException; use Neomerx\JsonApi\Contracts\Schema\SchemaFactoryInterface; +use Neomerx\JsonApi\Contracts\Schema\SchemaProviderInterface; +use Neomerx\JsonApi\Factories\Exceptions; +use Neomerx\JsonApi\I18n\Translator as T; use Neomerx\JsonApi\Schema\Container as BaseContainer; use ReflectionClass; use ReflectionParameter; use RuntimeException; use Symfony\Component\DependencyInjection\ContainerInterface; use Trikoder\JsonApiBundle\Schema\Autowire\Exception\UnresolvedDependencyException; +use Trikoder\JsonApiBundle\Schema\Builtin\GenericSchema; /** * Class Container @@ -45,7 +50,42 @@ protected function getResourceType($resource) return \get_class($resource); } + public function getSchemaByType($type) + { + true === \is_string($type) ?: Exceptions::throwInvalidArgument('type', $type); + + if (true === $this->hasCreatedProvider($type)) { + return $this->getCreatedProvider($type); + } + + if (false === $this->hasProviderMapping($type)) { + throw new InvalidArgumentException(T::t('Schema is not registered for type \'%s\'.', [$type])); + } + + $classNameOrCallable = $this->getProviderMapping($type); + if (true === \is_string($classNameOrCallable)) { + $schema = $this->createSchemaFromClassNameWithClassName($classNameOrCallable, $type); + } else { + $schema = $this->createSchemaFromCallable($classNameOrCallable); + } + $this->setCreatedProvider($type, $schema); + + /* @var SchemaProviderInterface $schema */ + + $this->setResourceToJsonTypeMapping($schema->getResourceType(), $type); + + return $schema; + } + + /** + * @deprecated + */ protected function createSchemaFromClassName($className) + { + return $this->createSchemaFromClassNameWithClassName($className, null); + } + + protected function createSchemaFromClassNameWithClassName($className, $modelClassName = null) { $callArguments = []; @@ -54,14 +94,19 @@ protected function createSchemaFromClassName($className) // see if there are any additional dependencies $constructorArguments = $reflector->getConstructor()->getParameters(); + + if (GenericSchema::class === $className) { + // remove first argument as it is fixted to className + array_shift($constructorArguments); + } + /** @var ReflectionParameter $constructorArgument */ foreach ($constructorArguments as $argumentIndex => $constructorArgument) { $argumentClassHint = $constructorArgument->getClass(); // non type hinted arguments cannot be autowired if (null === $argumentClassHint) { - throw new RuntimeException(sprintf('Argument %s for schema %s is not type hinted and cannot be autowired!', - $argumentIndex, $className)); + throw new RuntimeException(sprintf('Argument %s for schema %s is not type hinted and cannot be autowired!', $argumentIndex, $className)); } $resolvedDependacy = $this->resolveSchemaClassDependancy($argumentClassHint); @@ -72,6 +117,10 @@ protected function createSchemaFromClassName($className) $callArguments[$argumentIndex] = $resolvedDependacy; } + if (GenericSchema::class === $className) { + array_unshift($callArguments, $modelClassName); + } + // create schema $schema = new $className(...$callArguments); diff --git a/src/Services/Neomerx/EncoderService.php b/src/Services/Neomerx/EncoderService.php index 7833572..6419fe6 100644 --- a/src/Services/Neomerx/EncoderService.php +++ b/src/Services/Neomerx/EncoderService.php @@ -39,7 +39,7 @@ public function __construct(FactoryService $factoryService) } /** - * @param array|Iterator|null|object|string $data + * @param array|Iterator|object|string|null $data * * @return string */ diff --git a/src/Services/Neomerx/FactoryService.php b/src/Services/Neomerx/FactoryService.php index b5e12e6..2f516ed 100644 --- a/src/Services/Neomerx/FactoryService.php +++ b/src/Services/Neomerx/FactoryService.php @@ -41,7 +41,6 @@ public function __construct(ServiceContainer $serviceContainer, LoggerInterface * This is symfony way for creating with factory as service, * neomerx way is @see \Neomerx\JsonApi\Encoder\Encoder::instance * - * * @return Encoder */ public function createEncoderInstance(array $schemas = [], EncoderOptions $encoderOptions = null) diff --git a/src/Services/Neomerx/ServiceContainer.php b/src/Services/Neomerx/ServiceContainer.php index 10eef69..4c492b9 100644 --- a/src/Services/Neomerx/ServiceContainer.php +++ b/src/Services/Neomerx/ServiceContainer.php @@ -50,7 +50,7 @@ public function set($id, $service) */ public function get($id, $invalidBehavior = self::EXCEPTION_ON_INVALID_REFERENCE) { - if (array_key_exists($id, $this->services)) { + if (\array_key_exists($id, $this->services)) { return $this->services[$id]; } else { if (null !== $this->fallbackContainer) { @@ -70,7 +70,7 @@ public function get($id, $invalidBehavior = self::EXCEPTION_ON_INVALID_REFERENCE */ public function has($id) { - if (array_key_exists($id, $this->services)) { + if (\array_key_exists($id, $this->services)) { return true; } else { if (null !== $this->fallbackContainer) { @@ -90,7 +90,7 @@ public function has($id) */ public function initialized($id) { - if (array_key_exists($id, $this->services)) { + if (\array_key_exists($id, $this->services)) { return true; } else { if (null !== $this->fallbackContainer) { diff --git a/src/Services/RequestDecoder/RelationshipRequestBodyDecoder.php b/src/Services/RequestDecoder/RelationshipRequestBodyDecoder.php new file mode 100644 index 0000000..77dc63f --- /dev/null +++ b/src/Services/RequestDecoder/RelationshipRequestBodyDecoder.php @@ -0,0 +1,24 @@ +validator = $validator; + } + + /** + * Validate: + * { + * "data": [ + * { "type": "tags", "id": "2" }, + * { "type": "tags", "id": "3" } + * ] + * } + */ + public function validate(array $body) + { + /* + * Symfony validator doesn't have support for multiple type validation, i.e. validating that "data" is either + * a collection or null, so we have to do this explicit check up front. + * This got implemented in 4.4, @see https://github.com/symfony/symfony/pull/31351 + */ + if (\array_key_exists('data', $body) && null === $body['data']) { + return new ConstraintViolationList(); + } + + $validation = $this->validator->validate($body, new Collection([ + 'data' => new Required([ + new Type('array'), + new NotBlank(), + new All([ + new Type('array'), + new Collection([ + 'type' => new Required($this->createMemberNameConstraints()), + 'id' => new Required($this->createMemberNameConstraints()), + ]), + ]), + ]), + ])); + + return $validation; + } + + /** + * @todo this isn't quite right, could be improved, but will do for now + * + * @see https://jsonapi.org/format/#document-member-names-allowed-characters + */ + private function createMemberNameConstraints() + { + return [ + new NotBlank(), + new Type('string'), + ]; + } +} diff --git a/src/Services/RequestDecoder/RequestBodyDecoderService.php b/src/Services/RequestDecoder/RequestBodyDecoderService.php index 083476a..d96f76e 100644 --- a/src/Services/RequestDecoder/RequestBodyDecoderService.php +++ b/src/Services/RequestDecoder/RequestBodyDecoderService.php @@ -3,36 +3,19 @@ namespace Trikoder\JsonApiBundle\Services\RequestDecoder; use Trikoder\JsonApiBundle\Contracts\RequestBodyDecoderInterface; -use Trikoder\JsonApiBundle\Contracts\RequestBodyValidatorInterface; -use Trikoder\JsonApiBundle\Services\RequestDecoder\Exception\InvalidBodyForMethodException; class RequestBodyDecoderService implements RequestBodyDecoderInterface { - /** - * @var RequestBodyValidatorInterface - */ - private $requestBodyValidator; - - public function __construct(RequestBodyValidatorInterface $requestBodyValidator) - { - $this->requestBodyValidator = $requestBodyValidator; - } - /** * Takes array representation of jsonapi body payload and returnes flat array as would be expected by simple POST * - * * @return array - * - * @throws InvalidBodyForMethodException; */ public function decode(string $requestMethod, array $body = []) { - $this->requestBodyValidator->validate($requestMethod, $body); - $decoded = []; - if (!array_key_exists('data', $body) || null === $body['data']) { + if (!\array_key_exists('data', $body) || null === $body['data']) { return $decoded; } @@ -40,18 +23,18 @@ public function decode(string $requestMethod, array $body = []) $data = $body['data']; // parse relations first - if (array_key_exists('relationships', $data)) { + if (\array_key_exists('relationships', $data)) { $relationships = $data['relationships']; if (true === \is_array($relationships)) { foreach ($relationships as $relationshipName => $relationshipData) { // needs data - if (true === array_key_exists('data', $relationshipData)) { + if (true === \array_key_exists('data', $relationshipData)) { $relationshipData = $relationshipData['data']; // if data is numeric array, then it is list of items // if data has type key then it is one object $relationshipIsMultiple = (true === isset($relationshipData[0])); // we need default value - if (false === array_key_exists($relationshipName, $decoded)) { + if (false === \array_key_exists($relationshipName, $decoded)) { if ($relationshipIsMultiple) { $decoded[$relationshipName] = []; } @@ -79,7 +62,7 @@ public function decode(string $requestMethod, array $body = []) } // parse data for attributes - if (true === array_key_exists('attributes', $data)) { + if (true === \array_key_exists('attributes', $data)) { $attributes = $data['attributes']; if (false === empty($attributes)) { // TODO - check if attributes is array @@ -90,7 +73,7 @@ public function decode(string $requestMethod, array $body = []) } } - if (true === array_key_exists('id', $data)) { + if (true === \array_key_exists('id', $data)) { $decoded['id'] = $data['id']; } diff --git a/src/Services/RequestDecoder/RequestBodyValidator.php b/src/Services/RequestDecoder/RequestBodyValidator.php deleted file mode 100644 index 0a291a2..0000000 --- a/src/Services/RequestDecoder/RequestBodyValidator.php +++ /dev/null @@ -1,40 +0,0 @@ -jsonApiFactory = $jsonApiFactory; $this->controller = $controller; } @@ -68,13 +76,13 @@ function () use ($currentRequest, $config) { // should resulting fields list be product of query fields and config fields? or leave it to underlaying checkers? $configFields = $config->getIndex()->getIndexAllowedFields(); // if all request fields are empty so we apply for all types - $currentRequestQueryFieldsEmpty = (false === array_key_exists('fields', + $currentRequestQueryFieldsEmpty = (false === \array_key_exists('fields', $currentRequestQuery) || false === \is_array($currentRequestQuery['fields'])); if (true === $currentRequestQueryFieldsEmpty) { $currentRequestQuery['fields'] = []; } foreach ($configFields as $type => $fields) { - if (false === array_key_exists($type, $currentRequestQuery['fields'])) { + if (false === \array_key_exists($type, $currentRequestQuery['fields'])) { $currentRequestQuery['fields'][$type] = implode(',', $fields); } } @@ -96,7 +104,11 @@ function () use ($currentRequest, $config) { // TODO - this throws exception - need to check if we wanna handle it here or let it bubble up to controllerException event // TODO - should we recheck built params also? - $queryChecker->checkQuery($this->parsedRequestParameters); + try { + $queryChecker->checkQuery($this->parsedRequestParameters); + } catch (JsonApiException $jsonApiException) { + throw new BadRequestHttpException('Invalid or not allowed query parameters'); + } // TODO add header checker, might cause problems with clients, should be configurable (checker eg. \Neomerx\JsonApi\Http\Headers\RestrictiveHeadersChecker) @@ -140,12 +152,46 @@ function () use ($currentRequest, $config) { // TODO - check if correct type is sent (find it by schema type in class map by model) - // decode content - try { - $decodedContent = $config->getApi()->getRequestBodyDecoder()->decode($currentRequest->getMethod(), - $requestContentPrepared); - } catch (InvalidBodyForMethodException $exception) { - throw new BadRequestHttpException('Body must contain data to be a valid JSON API payload.', $exception); + $decodedContent = []; + + if ( + $currentRequest->attributes->has('_jsonapibundle_relationship_endpoint') + && + true === $currentRequest->attributes->get('_jsonapibundle_relationship_endpoint') + && + \in_array($currentRequest->getMethod(), [Request::METHOD_POST, Request::METHOD_DELETE]) + ) { + $validator = $config->getApi()->getRelationshipRequestBodyValidator(); + + try { + if ($validator->validate($requestContentPrepared)->count()) { + throw new BadRequestHttpException('Body must contain data to be a valid JSON API payload.'); + } + } catch (UnexpectedTypeException $exception) { + //inconsistency in Symfony before 4.2.0 causes payload like \Trikoder\JsonApiBundle\Tests\Integration\RequestDecoderTest::testJsonPostPayloadWithDefaultsAttributeAndInvalidStructure to fail with exception + //@see https://github.com/symfony/symfony/pull/27917 + throw new BadRequestHttpException('Body does not contain valid data'); + } + + $decodedContent = $config->getApi()->getRelationshipRequestBodyDecoder()->decode( + $currentRequest->getMethod(), + $requestContentPrepared + ); + } elseif (\in_array($currentRequest->getMethod(), self::METHODS_WITH_BODY, true)) { + try { + if ($config->getApi()->getRequestBodyValidator()->validate($requestContentPrepared)->count()) { + throw new BadRequestHttpException('Body must contain data to be a valid JSON API payload.'); + } + } catch (UnexpectedTypeException $exception) { + //inconsistency in Symfony before 4.2.0 causes payload like \Trikoder\JsonApiBundle\Tests\Integration\RequestDecoderTest::testJsonPostPayloadWithDefaultsAttributeAndInvalidStructure to fail with exception + //@see https://github.com/symfony/symfony/pull/27917 + throw new BadRequestHttpException('Body does not contain valid data'); + } + + $decodedContent = $config->getApi()->getRequestBodyDecoder()->decode( + $currentRequest->getMethod(), + $requestContentPrepared + ); } /** -- content and request @@ -153,7 +199,7 @@ function () use ($currentRequest, $config) { * we could merge it, that would be multipart support however this is jsonapi so we only do jsonapi stuff (atm :)) */ - // create new reqest that will replace original one + // create new request that will replace original one // empty duplicated to get copy of current request with allowed preserved properties $transformedRequest = $currentRequest->duplicate(); // we favour initialize for setup because we can give content of request here, what we cannot do in duplicate @@ -221,10 +267,14 @@ private function decodeFilterParams($sourceFilterParams) if (true === \is_array($sourceFilterParams)) { foreach ($sourceFilterParams as $field => $filters) { - if (false !== strpos($filters, ',')) { - $decodedParams[$field] = explode(',', $filters); + if (true === \is_array($filters)) { + $decodedParams[$field] = $this->decodeFilterParams($filters); } else { - $decodedParams[$field] = $filters; + if (false !== strpos($filters, ',')) { + $decodedParams[$field] = explode(',', $filters); + } else { + $decodedParams[$field] = $filters; + } } } } diff --git a/src/Services/RequestDecoder/SymfonyValidatorAdapter.php b/src/Services/RequestDecoder/SymfonyValidatorAdapter.php new file mode 100644 index 0000000..6fc4787 --- /dev/null +++ b/src/Services/RequestDecoder/SymfonyValidatorAdapter.php @@ -0,0 +1,111 @@ +validator = $validator; + } + + public function validate(array $body) + { + /* + * Symfony validator doesn't have support for multiple type validation, i.e. validating that "data" is either + * a collection or null, so we have to do this explicit check up front. + * This got implemented in 4.4, @see https://github.com/symfony/symfony/pull/31351 + */ + if (\array_key_exists('data', $body) && null === $body['data']) { + return new ConstraintViolationList(); + } + + return $this->validator->validate($body, new Collection([ + 'data' => new Required([ + new Collection([ + 'type' => [ + new Required($this->createMemberNameConstraints()), + ], + // optional because for instance on PATCH it's required, but on POST it's not + 'id' => new Optional($this->createMemberNameConstraints()), + 'attributes' => new Optional(), + 'relationships' => new Optional([ + new All([ + new Collection([ + 'data' => [ + new Required(), + ], + ]), + new Callback(function ($value, ExecutionContextInterface $context, $payload) { + $value = $value['data']; + if (null === $value) { + return; + } + + $message = 'Not a valid resource identifier'; + if ($this->isAssociativeArray($value)) { + if (!$this->isValidResourceIdentifier($value)) { + $context->addViolation($message); + } + + return; + } + + if (\is_array($value)) { + foreach ($value as $index => $item) { + if (!$this->isValidResourceIdentifier($item)) { + $context->buildViolation($message) + ->atPath($index) + ->addViolation(); + } + } + } + }), + ]), + ]), + ]), + ]), + ])); + } + + /** + * @todo this isn't quite right, could be improved, but will do for now + * + * @see https://jsonapi.org/format/#document-member-names-allowed-characters + */ + private function createMemberNameConstraints() + { + return [ + /* + * @see https://gitlab.trikoder.net/trikoder/jsonapibundle/merge_requests/101#note_244436 + * new Type('string'), + */ + new NotBlank(), + ]; + } + + private function isValidResourceIdentifier(array $input) + { + return !empty($input['id']) && !empty($input['type']); + } + + private function isAssociativeArray(array $input) + { + return \count(array_filter(array_keys($input), 'is_string')) > 0; + } +} diff --git a/src/Services/ResponseFactoryService.php b/src/Services/ResponseFactoryService.php index 52ba261..bba3402 100644 --- a/src/Services/ResponseFactoryService.php +++ b/src/Services/ResponseFactoryService.php @@ -19,14 +19,19 @@ class ResponseFactoryService implements ResponseFactoryInterface * @var ErrorFactoryInterface */ private $errorFactory; + /** + * @var ResponseLinterInterface + */ + private $responseLinter; /** * ResponseFactoryService constructor. */ - public function __construct(EncoderService $encoderService, ErrorFactoryInterface $errorFactory) + public function __construct(EncoderService $encoderService, ErrorFactoryInterface $errorFactory, ResponseLinterInterface $responseLinter) { $this->encoderService = $encoderService; $this->errorFactory = $errorFactory; + $this->responseLinter = $responseLinter; } /** @@ -42,8 +47,7 @@ public function createResponse(string $data, Response $response = null): Respons $response->setContent($data); // set proper headers - // TODO - this should be MediaTypeInterface::JSON_API_MEDIA_TYPE but nobody understands this :( - $response->headers->set('Content-type', 'application/json'); + $response = $this->responseLinter->lint($response); return $response; } diff --git a/src/Services/ResponseLinterInterface.php b/src/Services/ResponseLinterInterface.php new file mode 100644 index 0000000..d44b415 --- /dev/null +++ b/src/Services/ResponseLinterInterface.php @@ -0,0 +1,10 @@ +request( 'POST', '/api/generic/', - [], - [], - [], - json_encode([ + [ 'data' => [ 'type' => 'generic-model', 'attributes' => [ @@ -30,7 +27,7 @@ public function testGenericCreateAction() 'isActive' => true, ], ], - ]) + ] ); $response = $client->getResponse(); diff --git a/tests/Functional/Controller/CreateActionTest.php b/tests/Functional/Controller/CreateActionTest.php index 6b5fa4f..1b0bd1d 100644 --- a/tests/Functional/Controller/CreateActionTest.php +++ b/tests/Functional/Controller/CreateActionTest.php @@ -4,6 +4,7 @@ use Symfony\Component\HttpFoundation\Response; use Trikoder\JsonApiBundle\Tests\Functional\JsonapiWebTestCase; +use Trikoder\JsonApiBundle\Tests\Resources\Entity\User; class CreateActionTest extends JsonapiWebTestCase { @@ -271,4 +272,137 @@ public function testUserCreateActionUsingAlternativePost() // TODO - verify the database has the same data } + + public function testCreateWithRelationshipReturnsBadRequestIfInvalidInputIsProvided() + { + $client = static::createClient(); + + $client->request( + 'POST', + '/api/posts', + [ + 'data' => [ + 'type' => 'post', + 'attributes' => [ + ], + 'relationships' => [ + 'author' => [ + 'data' => [ + 'type' => 'user', + // purposefully left out id + ], + ], + ], + ], + ] + ); + + $response = $client->getResponse(); + + $this->assertIsJsonapiResponse($response); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + } + + /** + * test versioned create + */ + public function testVersionedUserCreateAction() + { + $client = static::createClient(); + + $email = sprintf('mytest%s@domain.com', time()); + + $client->request( + 'POST', + '/api/v2/user/', + [], + [], + [], + json_encode([ + 'data' => [ + 'type' => 'user', + 'attributes' => [ + 'email' => $email, + 'active' => true, + ], + ], + ]) + ); + + $response = $client->getResponse(); + + $this->assertIsJsonapiResponse($response); + + $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode()); + + $data = $this->getResponseContentJson($response); + + $this->assertEquals([ + 'type' => 'user', + 'id' => $data['data']['id'], // get take id from response - if none, this will cause error + 'attributes' => [ + 'email' => $email, + 'active' => true, + ], + 'links' => [ + 'self' => '/user/' . $data['data']['id'], + ], + ], $data['data']); + + // TODO - verify the database has the same data + } + + public function testSpecifiedIncludesGetIncludedOnCreateRequests() + { + $client = static::createClient(); + + /** @var User $user */ + $user = $client->getContainer()->get('doctrine.orm.entity_manager')->getRepository(User::class)->find(1); + + $client->request( + 'POST', + '/api/posts?include=author', + [], + [], + [], + json_encode([ + 'data' => [ + 'type' => 'post', + 'attributes' => [ + 'title' => bin2hex(random_bytes(16)), + 'active' => true, + ], + 'relationships' => [ + 'author' => [ + 'data' => [ + 'type' => 'user', + 'id' => (string) $user->getId(), + ], + ], + ], + ], + ]) + ); + + $response = $client->getResponse(); + + $this->assertIsJsonapiResponse($response); + + $this->assertSame(Response::HTTP_CREATED, $response->getStatusCode()); + + $data = $this->getResponseContentJson($response); + + $this->assertArrayHasKey('included', $data); + + $this->assertSame([ + [ + 'type' => 'user', + 'id' => (string) $user->getId(), + 'attributes' => [ + 'email' => $user->getEmail(), + 'active' => $user->isActive(), + ], + ], + ], $data['included']); + } } diff --git a/tests/Functional/Controller/DeleteActionTest.php b/tests/Functional/Controller/DeleteActionTest.php index 7da5934..805370f 100644 --- a/tests/Functional/Controller/DeleteActionTest.php +++ b/tests/Functional/Controller/DeleteActionTest.php @@ -43,13 +43,14 @@ public function testUserDeleteAction() $this->assertNull($user); } - public function testActionWithTrailingSlash() + public function testUserDeleteActionWithTrailingSlash() { $client = static::createClient(); $user = new User(); $user->setEmail('testActionWithTrailingSlash@test.com'); $client->getContainer()->get('doctrine.orm.entity_manager')->persist($user); + $client->getContainer()->get('doctrine.orm.entity_manager')->flush(); $client->request( 'DELETE', @@ -60,7 +61,7 @@ public function testActionWithTrailingSlash() '' ); $response = $client->getResponse(); - $this->assertNotEquals(Response::HTTP_MOVED_PERMANENTLY, $response->getStatusCode()); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); } public function testNotFound() @@ -78,4 +79,21 @@ public function testNotFound() $response = $client->getResponse(); $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); } + + public function testUserDeleteVersioned() + { + $client = static::createClient(); + + $user = new User(); + $user->setEmail('testVersioned@test.com'); + $client->getContainer()->get('doctrine.orm.entity_manager')->persist($user); + $client->getContainer()->get('doctrine.orm.entity_manager')->flush(); + + $client->request( + 'DELETE', + '/api/v2/user/' . $user->getId() + ); + $response = $client->getResponse(); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + } } diff --git a/tests/Functional/Controller/Demo/CustomMetaResponseActionTest.php b/tests/Functional/Controller/Demo/CustomMetaResponseActionTest.php index 2f9505d..db0c09b 100644 --- a/tests/Functional/Controller/Demo/CustomMetaResponseActionTest.php +++ b/tests/Functional/Controller/Demo/CustomMetaResponseActionTest.php @@ -23,8 +23,8 @@ public function testIndexAction() $this->assertEquals([ 'customInfo' => 'valid', - // TODO refactor this to count from database ++ - 'total' => 7, + // TODO refactor this to count from database +++ + 'total' => 9, ], $data['meta']); $this->assertNotEmpty($data['data']); diff --git a/tests/Functional/Controller/Demo/SimpleFileUploadControllerTest.php b/tests/Functional/Controller/Demo/SimpleFileUploadControllerTest.php index ce95d6a..07ce5ff 100644 --- a/tests/Functional/Controller/Demo/SimpleFileUploadControllerTest.php +++ b/tests/Functional/Controller/Demo/SimpleFileUploadControllerTest.php @@ -98,8 +98,7 @@ private function getTestFile() return new UploadedFile( 'README.md', 'README.md', - 'application/octe', - 123 + 'application/octe' ); } } diff --git a/tests/Functional/Controller/IndexActionTest.php b/tests/Functional/Controller/IndexActionTest.php index 6ba89b8..5d3559b 100644 --- a/tests/Functional/Controller/IndexActionTest.php +++ b/tests/Functional/Controller/IndexActionTest.php @@ -138,8 +138,8 @@ public function testSorting() $response = $client->getResponse(); $this->assertIsJsonapiResponse($response); $content = $this->getResponseContentJson($response); - // TODO refactor total of 8 to count from database ++ - $this->assertEquals(8, $content['data'][0]['id']); + // TODO refactor total of 8 to count from database +++ + $this->assertEquals(10, $content['data'][0]['id']); } /** @@ -202,7 +202,7 @@ public function testIAmAllowedToFilterOnlyByFieldsConfiguredInAllowedFilteringPa $client->request( 'GET', - '/api/user-config-restrictions/', + '/api/user-config-restrictions', [ 'filter' => [ 'i_should_not_be_able_to_filter_by_this_field' => 'my', @@ -221,7 +221,7 @@ public function testIAmAllowedToSortOnlyByFieldsConfiguredInAllowedSortFields() $client->request( 'GET', - '/api/user-config-restrictions/', + '/api/user-config-restrictions', [ 'sort' => 'i_should_not_be_able_to_sort_by_this_field', ] @@ -245,4 +245,18 @@ public function testIAmAllowedToFetchOnlyFieldsConfiguredInAllowedFields() $this->assertIsJsonapiResponse($response); $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); } + + /** + * test simple listing + */ + public function testVersionedUserIndexAction() + { + $client = static::createClient(); + + $client->request('GET', '/api/v2/user/'); + + $response = $client->getResponse(); + + $this->assertIsJsonapiResponse($response); + } } diff --git a/tests/Functional/Controller/PaginatedIndexActionTest.php b/tests/Functional/Controller/PaginatedIndexActionTest.php index c68ea87..d2d2c63 100644 --- a/tests/Functional/Controller/PaginatedIndexActionTest.php +++ b/tests/Functional/Controller/PaginatedIndexActionTest.php @@ -28,8 +28,8 @@ public function testUserIndexAction() $this->assertArrayHasKey('meta', $content); $this->assertEquals([ - // TODO refactor this to count from database - 'total' => 7, + // TODO refactor this to count from database ++ + 'total' => 9, ], $content['meta']); $this->assertArrayHasKey('links', $content); @@ -40,9 +40,18 @@ public function testUserIndexAction() $this->assertArrayHasKey('last', $content['links']); // check if urls are correct, eg. /api/user-paginated/?page%5Blimit%5D=2&page%5Boffset%5D=0 - $this->assertStringEndsWith('/api/user-paginated/?page%5Blimit%5D=2&page%5Boffset%5D=0', $content['links']['self']); - $this->assertStringEndsWith('/api/user-paginated/?page%5Blimit%5D=2&page%5Boffset%5D=2', $content['links']['next']); - $this->assertStringEndsWith('/api/user-paginated/?page%5Blimit%5D=2&page%5Boffset%5D=0', $content['links']['first']); + $this->assertStringEndsWith( + '/api/user-paginated/?page[limit]=2&page[offset]=0', + urldecode($content['links']['self']) + ); + $this->assertStringEndsWith( + '/api/user-paginated/?page[limit]=2&page[offset]=2', + urldecode($content['links']['next']) + ); + $this->assertStringEndsWith( + '/api/user-paginated/?page[limit]=2&page[offset]=0', + urldecode($content['links']['first']) + ); $this->assertEmpty($content['links']['prev']); } @@ -58,9 +67,18 @@ public function testFiltersPaginatedLink() $content = $this->getResponseContentJson($response); // check if urls are correct, eg. /api/user-paginated/?page%5Blimit%5D=2&page%5Boffset%5D=0 - $this->assertStringEndsWith('/api/user-paginated/?filter%5Bid%5D=1%2C2%2C3%2C4%2C99&page%5Blimit%5D=2&page%5Boffset%5D=0', $content['links']['self']); - $this->assertStringEndsWith('/api/user-paginated/?filter%5Bid%5D=1%2C2%2C3%2C4%2C99&page%5Blimit%5D=2&page%5Boffset%5D=2', $content['links']['next']); - $this->assertStringEndsWith('/api/user-paginated/?filter%5Bid%5D=1%2C2%2C3%2C4%2C99&page%5Blimit%5D=2&page%5Boffset%5D=0', $content['links']['first']); + $this->assertStringEndsWith( + '/api/user-paginated/?filter[id]=1,2,3,4,99&page[limit]=2&page[offset]=0', + urldecode($content['links']['self']) + ); + $this->assertStringEndsWith( + '/api/user-paginated/?filter[id]=1,2,3,4,99&page[limit]=2&page[offset]=2', + urldecode($content['links']['next']) + ); + $this->assertStringEndsWith( + '/api/user-paginated/?filter[id]=1,2,3,4,99&page[limit]=2&page[offset]=0', + urldecode($content['links']['first']) + ); $this->assertEmpty($content['links']['prev']); } @@ -76,9 +94,18 @@ public function testFieldsPaginatedLink() $content = $this->getResponseContentJson($response); // check if urls are correct, eg. /api/user-paginated/?page%5Blimit%5D=2&page%5Boffset%5D=0 - $this->assertStringEndsWith('/api/user-paginated/?fields%5Buser%5D=email%2Cid%2Cactive%2Cinvalid&page%5Blimit%5D=2&page%5Boffset%5D=0', $content['links']['self']); - $this->assertStringEndsWith('/api/user-paginated/?fields%5Buser%5D=email%2Cid%2Cactive%2Cinvalid&page%5Blimit%5D=2&page%5Boffset%5D=2', $content['links']['next']); - $this->assertStringEndsWith('/api/user-paginated/?fields%5Buser%5D=email%2Cid%2Cactive%2Cinvalid&page%5Blimit%5D=2&page%5Boffset%5D=0', $content['links']['first']); + $this->assertStringEndsWith( + '/api/user-paginated/?fields[user]=email,id,active,invalid&page[limit]=2&page[offset]=0', + urldecode($content['links']['self']) + ); + $this->assertStringEndsWith( + '/api/user-paginated/?fields[user]=email,id,active,invalid&page[limit]=2&page[offset]=2', + urldecode($content['links']['next']) + ); + $this->assertStringEndsWith( + '/api/user-paginated/?fields[user]=email,id,active,invalid&page[limit]=2&page[offset]=0', + urldecode($content['links']['first']) + ); $this->assertEmpty($content['links']['prev']); } @@ -94,9 +121,18 @@ public function testIncludePaginatedLink() $content = $this->getResponseContentJson($response); // check if urls are correct, eg. /api/user-paginated/?page%5Blimit%5D=2&page%5Boffset%5D=0 - $this->assertStringEndsWith('/api/user-paginated/?include=user%2Cpost&page%5Blimit%5D=2&page%5Boffset%5D=0', $content['links']['self']); - $this->assertStringEndsWith('/api/user-paginated/?include=user%2Cpost&page%5Blimit%5D=2&page%5Boffset%5D=2', $content['links']['next']); - $this->assertStringEndsWith('/api/user-paginated/?include=user%2Cpost&page%5Blimit%5D=2&page%5Boffset%5D=0', $content['links']['first']); + $this->assertStringEndsWith( + '/api/user-paginated/?include=user,post&page[limit]=2&page[offset]=0', + urldecode($content['links']['self']) + ); + $this->assertStringEndsWith( + '/api/user-paginated/?include=user,post&page[limit]=2&page[offset]=2', + urldecode($content['links']['next']) + ); + $this->assertStringEndsWith( + '/api/user-paginated/?include=user,post&page[limit]=2&page[offset]=0', + urldecode($content['links']['first']) + ); $this->assertEmpty($content['links']['prev']); } diff --git a/tests/Functional/Controller/ShowActionTest.php b/tests/Functional/Controller/ShowActionTest.php index 81099d0..c298ad4 100644 --- a/tests/Functional/Controller/ShowActionTest.php +++ b/tests/Functional/Controller/ShowActionTest.php @@ -2,6 +2,7 @@ namespace Trikoder\JsonApiBundle\Tests\Functional\Controller; +use DateTime; use Symfony\Component\HttpFoundation\Response; use Trikoder\JsonApiBundle\Tests\Functional\JsonapiWebTestCase; use Trikoder\JsonApiBundle\Tests\Resources\Entity\Post; @@ -61,6 +62,10 @@ public function testCustomerShowAction() // load user /** @var User $user */ $user = $client->getContainer()->get('doctrine.orm.entity_manager')->getRepository(User::class)->find(4); + $user->setCustomer(true); + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + $em->persist($user); + $em->flush(); $client->request('GET', '/api/customer/4'); @@ -184,7 +189,7 @@ public function testRelationshipsDefault() 'author' => [ 'data' => [ 'type' => 'user', - 'id' => 3, + 'id' => '4', ], ], ], @@ -223,7 +228,7 @@ public function testRelationshipsDefaultRequested() 'author' => [ 'data' => [ 'type' => 'user', - 'id' => 3, + 'id' => '4', ], ], ], @@ -235,7 +240,7 @@ public function testRelationshipsDefaultRequested() $this->assertEquals([ [ 'type' => 'user', - 'id' => '3', + 'id' => '4', 'attributes' => [ 'email' => $post->getAuthor()->getEmail(), 'active' => $post->getAuthor()->isActive(), @@ -246,7 +251,7 @@ public function testRelationshipsDefaultRequested() $this->assertArrayHasKey('included', $data); $this->assertNotEmpty($data['included']); $this->assertEquals('user', $data['included'][0]['type']); - $this->assertEquals(3, $data['included'][0]['id']); + $this->assertEquals(4, $data['included'][0]['id']); } public function testIAmAllowedToFetchOnlyFieldsConfiguredInAllowedFields() @@ -263,5 +268,88 @@ public function testIAmAllowedToFetchOnlyFieldsConfiguredInAllowedFields() $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); } + /** + * Test a bit more complex show action + */ + public function testGenericSchemaShowAction() + { + $client = static::createClient(); + + // load post + /** @var Post post */ + $post = $client->getContainer()->get('doctrine.orm.entity_manager')->getRepository(Post::class)->find(1); + + $client->request('GET', '/api/generic-schema/1'); + + $response = $client->getResponse(); + + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + $this->assertIsJsonapiResponse($response); + + $data = $this->getResponseContentJson($response); + + $this->assertEquals([ + 'type' => 'post', + 'id' => '1', + 'attributes' => [ + 'title' => 'Post 1', + 'active' => false, + 'publishedAt' => $post->getPublishedAt()->format(DateTime::ATOM), + ], + 'relationships' => [ + 'author' => [ + 'data' => [ + 'type' => 'user', + 'id' => '4', + ], + ], + ], + 'links' => [ + 'self' => '/post/1', + ], + ], $data['data']); + + $this->assertEquals([ + [ + 'type' => 'user', + 'id' => '4', + 'attributes' => [ + 'email' => $post->getAuthor()->getEmail(), + 'active' => $post->getAuthor()->isActive(), + 'customer' => $post->getAuthor()->isCustomer(), + ], + 'relationships' => [ + 'carts' => [ + 'data' => [ + ], + ], + 'tags' => [ + 'data' => [ + ], + ], + ], + ], + ], $data['included']); + } + + /** + * Test simple show action + */ + public function testVersionedUserShowAction() + { + $client = static::createClient(); + + // load user + /** @var User $user */ + $user = $client->getContainer()->get('doctrine.orm.entity_manager')->getRepository(User::class)->find(1); + + $client->request('GET', '/api/v2/user/1'); + + $response = $client->getResponse(); + + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + $this->assertIsJsonapiResponse($response); + } + // TODO - If a server is unable to identify a relationship path or does not support inclusion of resources from a path, it MUST respond with 400 Bad Request. } diff --git a/tests/Functional/Controller/UpdateActionTest.php b/tests/Functional/Controller/UpdateActionTest.php index 49349be..922310c 100644 --- a/tests/Functional/Controller/UpdateActionTest.php +++ b/tests/Functional/Controller/UpdateActionTest.php @@ -23,19 +23,16 @@ public function testUserUpdateAction() $client->request( 'PUT', '/api/user/3', - [], - [], - [], - json_encode([ + [ 'data' => [ 'type' => 'user', - 'id' => 3, + 'id' => '3', 'attributes' => [ 'email' => 'myupdatetest@domain.com', 'active' => true, ], ], - ]) + ] ); $response = $client->getResponse(); @@ -48,7 +45,7 @@ public function testUserUpdateAction() $this->assertEquals([ 'type' => 'user', - 'id' => 3, + 'id' => '3', 'attributes' => [ 'email' => 'myupdatetest@domain.com', 'active' => true, @@ -75,18 +72,15 @@ public function testUserUpdateInvalid() $client->request( 'PUT', '/api/user/3', - [], - [], - [], - json_encode([ + [ 'data' => [ 'type' => 'user', - 'id' => 3, + 'id' => '3', 'attributes' => [ 'email' => 'invalid', ], ], - ]) + ] ); $response = $client->getResponse(); @@ -115,18 +109,15 @@ public function testNotFound() $client->request( 'PUT', '/api/user/999', - [], - [], - [], - json_encode([ + [ 'data' => [ 'type' => 'user', - 'id' => 3, + 'id' => '3', 'attributes' => [ 'email' => 'invalid', ], ], - ]) + ] ); $response = $client->getResponse(); $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); @@ -178,22 +169,51 @@ public function testIAmAllowedToUpdateOnlyFieldsConfiguredInAllowedFields() $client->request( 'POST', '/api/user-config-restrictions/3', - [], - [], - [], - json_encode([ + [ 'data' => [ 'type' => 'user', - 'id' => 3, + 'id' => '3', 'attributes' => [ 'i_should_not_be_able_to_update_this_field' => 'some malicious value', ], ], - ]) + ] ); $response = $client->getResponse(); $this->assertIsJsonapiResponse($response); $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode(), $response->getContent()); } + + /** + * test simple update + */ + public function testVersionedUserUpdateAction() + { + $client = static::createClient(); + + // load user + /** @var User $user */ + $user = $client->getContainer()->get('doctrine.orm.entity_manager')->getRepository(User::class)->find(3); + + $client->request( + 'PUT', + '/api/v2/user/3', + [ + 'data' => [ + 'type' => 'user', + 'id' => '3', + 'attributes' => [ + 'active' => true, + ], + ], + ] + ); + + $response = $client->getResponse(); + + $this->assertIsJsonapiResponse($response); + + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + } } diff --git a/tests/Functional/Controller/UpdateRelationshipActionTest.php b/tests/Functional/Controller/UpdateRelationshipActionTest.php new file mode 100644 index 0000000..ec0f568 --- /dev/null +++ b/tests/Functional/Controller/UpdateRelationshipActionTest.php @@ -0,0 +1,399 @@ +client = static::createClient(); + } + + protected function tearDown() + { + $this->client = null; + $this->user = null; + parent::tearDown(); + } + + public function testConflictIsReturnedWhenReferencedResourceDoesNotExist() + { + $user = $this->getUser(); + [$tagOne, $tagTwo, $tagThree] = $this->getTags(); + + $this->assertEquals(1, $user->getTags()->count()); + + $this->client->request( + 'POST', + sprintf('/api/user/%d/relationships/tags', $user->getId()), + [ + 'data' => [ + [ + 'type' => 'tag', + 'id' => '666', + ], + ], + ] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + $this->assertEquals(Response::HTTP_CONFLICT, $response->getStatusCode()); + $this->assertEquals(1, $user->getTags()->count()); + } + + public function testPostAddsResourceToRelationship() + { + $user = $this->getUser(); + [$tagOne, $tagTwo, $tagThree] = $this->getTags(); + + $missingTags = [ + $tagOne->getId() => $tagOne, + $tagTwo->getId() => $tagTwo, + $tagThree->getId() => $tagThree, + ]; + + foreach ($this->getUser()->getTags() as $tag) { + unset($missingTags[$tag->getId()]); + } + + $this->assertEquals(2, \count($missingTags)); + $this->assertEquals(1, $user->getTags()->count()); + + [$tagOne, $tagTwo] = array_values($missingTags); + + $this->client->request( + 'POST', + sprintf('/api/user/%d/relationships/tags', $user->getId()), + [ + 'data' => [ + [ + 'type' => 'tag', + 'id' => (string) $tagOne->getId(), + ], + [ + 'type' => 'tag', + 'id' => (string) $tagTwo->getId(), + ], + ], + ] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode(), $response->getContent()); + + $this->assertEquals(3, $user->getTags()->count()); + } + + /** + * @depends testPostAddsResourceToRelationship + */ + public function testPostReturnsNoContentWhenResourcesAreAlreadyAdded() + { + $user = $this->getUser(); + [$tagOne, $tagTwo, $tagThree] = $this->getTags(); + + $this->client->request( + 'POST', + sprintf('/api/user/%d/relationships/tags', $user->getId()), + [ + 'data' => [ + [ + 'type' => 'tag', + 'id' => (string) $tagOne->getId(), + ], + [ + 'type' => 'tag', + 'id' => (string) $tagTwo->getId(), + ], + ], + ] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode(), $response->getContent()); + + $this->assertEquals(3, $user->getTags()->count()); + } + + /** + * @depends testPostAddsResourceToRelationship + */ + public function testDeleteRemovesResourceFromRelationship() + { + $user = $this->getUser(); + [$tagOne, $tagTwo, $tagThree] = $this->getTags(); + + $this->assertEquals(3, $user->getTags()->count()); + + $this->client->request( + 'DELETE', + sprintf('/api/user/%d/relationships/tags', $user->getId()), + [ + 'data' => [ + [ + 'type' => 'tag', + 'id' => (string) $tagOne->getId(), + ], + [ + 'type' => 'tag', + 'id' => (string) $tagTwo->getId(), + ], + ], + ] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + + $this->assertEquals(1, $user->getTags()->count()); + } + + /** + * @depends testDeleteRemovesResourceFromRelationship + */ + public function testDeleteReturnsNoContentWhenResourcesAreAlreadyRemoved() + { + $user = $this->getUser(); + [$tagOne, $tagTwo, $tagThree] = $this->getTags(); + + $this->assertEquals(1, $user->getTags()->count()); + + $this->client->request( + 'DELETE', + sprintf('/api/user/%d/relationships/tags', $user->getId()), + [ + 'data' => [ + [ + 'type' => 'tag', + 'id' => (string) $tagOne->getId(), + ], + [ + 'type' => 'tag', + 'id' => (string) $tagTwo->getId(), + ], + ], + ] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode(), $response->getContent()); + + $this->assertEquals(1, $user->getTags()->count()); + } + + public function testPostReturnsForbiddenForForbiddenRelationshipAction() + { + $this->client->request( + 'POST', + sprintf('/api/user/%d/relationships/carts', $this->getUser()->getId()), + [ + 'data' => [ + [ + 'type' => 'cart', + 'id' => '1', + ], + ], + ] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + $this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + } + + public function testPostReturnsForbiddenForAllowedButNonExistentRelationshipAction() + { + $this->client->request( + 'POST', + sprintf('/api/user/%d/relationships/relationshipWhichDoesNotExistOnResource', $this->getUser()->getId()), + [ + 'data' => [ + [ + 'type' => 'relationshipWhichDoesNotExistOnResource', + 'id' => '1', + ], + ], + ] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + $this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode(), $response->getContent()); + } + + public function testPostReturnsBadRequestForEmptyRequestBody() + { + $this->client->request( + 'POST', + sprintf('/api/user/%d/relationships/tags', $this->getUser()->getId()), + [ + 'data' => [], + ] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + } + + public function getInvalidData(): array + { + return [ + [ + [ + 'type' => 'tag', + ], + ], + [ + [ + 'id' => '1', + ], + ], + ]; + } + + /** + * @dataProvider getInvalidData + */ + public function testPostReturnsBadRequestForInvalidJsonRequestBody(array $data) + { + $this->client->request( + 'POST', + sprintf('/api/user/%d/relationships/tags', $this->getUser()->getId()), + ['data' => [$data]] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + } + + public function testDeleteReturnsForbiddenForForbiddenRelationshipAction() + { + $this->client->request( + 'DELETE', + sprintf('/api/user/%d/relationships/carts', $this->getUser()->getId()), + [ + 'data' => [ + [ + 'type' => 'cart', + 'id' => '1', + ], + ], + ] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + $this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + } + + public function testDeleteReturnsForbiddenForAllowedButNonExistentRelationshipAction() + { + $this->client->request( + 'DELETE', + sprintf('/api/user/%d/relationships/relationshipWhichDoesNotExistOnResource', $this->getUser()->getId()), + [ + 'data' => [ + [ + 'type' => 'relationshipWhichDoesNotExistOnResource', + 'id' => '1', + ], + ], + ] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + $this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode(), $response->getContent()); + } + + public function testDeleteReturnsBadRequestForEmptyRequestBody() + { + $this->client->request( + 'DELETE', + sprintf('/api/user/%d/relationships/tags', $this->getUser()->getId()), + [ + 'data' => [], + ] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + } + + /** + * @dataProvider getInvalidData + */ + public function testDeleteReturnsBadRequestForInvalidJsonRequestBody(array $data) + { + $this->client->request( + 'DELETE', + sprintf('/api/user/%d/relationships/tags', $this->getUser()->getId()), + ['data' => [$data]] + ); + + $response = $this->client->getResponse(); + + $this->assertIsJsonapiResponse($response); + + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + } + + private function getUser(): User + { + if (null !== $this->user) { + return $this->user; + } + + $this->user = $this->client->getContainer()->get('doctrine.orm.entity_manager')->getRepository(User::class)->findOneBy( + ['email' => 'user-with-tag@ghosap.com'] + ); + + return $this->getUser(); + } + + private function getTags(): array + { + $tagOne = $this->client->getContainer()->get('doctrine.orm.entity_manager')->getRepository(Tag::class)->find(1); + $this->assertNotNull($tagOne); + $tagTwo = $this->client->getContainer()->get('doctrine.orm.entity_manager')->getRepository(Tag::class)->find(2); + $this->assertNotNull($tagTwo); + $tagThree = $this->client->getContainer()->get('doctrine.orm.entity_manager')->getRepository(Tag::class)->find(3); + $this->assertNotNull($tagThree); + + return [$tagOne, $tagTwo, $tagThree]; + } +} diff --git a/tests/Integration/ActionTraits/CreateTraitTest.php b/tests/Integration/ActionTraits/CreateTraitTest.php deleted file mode 100644 index 0efe218..0000000 --- a/tests/Integration/ActionTraits/CreateTraitTest.php +++ /dev/null @@ -1,48 +0,0 @@ -getMockForTrait(CreateTrait::class); - - $mock->method('getRouter') - ->will(); - } - - private function getConfig() - { - - } - - private function getRouter() - { - return $this->getMockBuilder(RouterInterface::class)->getMock(); - } -} diff --git a/tests/Integration/ExtensionTest.php b/tests/Integration/ExtensionTest.php new file mode 100644 index 0000000..e7aa0db --- /dev/null +++ b/tests/Integration/ExtensionTest.php @@ -0,0 +1,64 @@ +getContainer(); + + /** @var EventDispatcherInterface $eventDispatcher */ + $eventDispatcher = $container->get('event_dispatcher'); + + $listener = $container->get('test.trikoder.jsonapi.request_listener'); + + $this->assertSame( + 16, + $eventDispatcher->getListenerPriority( + KernelEvents::CONTROLLER, + [$listener, 'onKernelController'] + ) + ); + + $this->assertSame( + -10, + $eventDispatcher->getListenerPriority( + KernelEvents::CONTROLLER_ARGUMENTS, + [$listener, 'onKernelControllerarguments'] + ) + ); + + $this->assertSame( + 0, + $eventDispatcher->getListenerPriority( + KernelEvents::VIEW, + [$listener, 'onKernelView'] + ) + ); + + $this->assertSame( + 0, + $eventDispatcher->getListenerPriority( + KernelEvents::RESPONSE, + [$listener, 'onKernelResponse'] + ) + ); + + $this->assertSame( + 0, + $eventDispatcher->getListenerPriority( + KernelEvents::EXCEPTION, + [$listener, 'onKernelException'] + ) + ); + } +} diff --git a/tests/Integration/GenericFormInputHandlerTest.php b/tests/Integration/GenericFormInputHandlerTest.php new file mode 100644 index 0000000..360c748 --- /dev/null +++ b/tests/Integration/GenericFormInputHandlerTest.php @@ -0,0 +1,62 @@ +getContainer()->get('test.trikoder.jsonapi.model_meta_data_factory'); + + $entity = new User(); + $meta = $metaDataFactory->getMetaDataForModel(User::class); + + /** @var GenericFormModelInputHandler $inputHandler */ + $inputHandler = $kernel->getContainer()->get(ModelToolsFactory::class)->createInputHandler($entity); + $form = $inputHandler->getForm(); + + $this->assertInstanceOf(IntegerType::class, $form->get('id')->getConfig()->getType()->getInnerType()); + $this->assertInstanceOf(EmailType::class, $form->get('email')->getConfig()->getType()->getInnerType()); + $this->assertInstanceOf(CheckboxType::class, $form->get('active')->getConfig()->getType()->getInnerType()); + } + + public function testGenericModelMetaData() + { + $kernel = static::bootKernel(); + + /** @var ModelMetaDataFactory $metaDataFactory */ + $metaDataFactory = $kernel->getContainer()->get('test.trikoder.jsonapi.model_meta_data_factory'); + + $entity = new GenericModel(); + $meta = $metaDataFactory->getMetaDataForModel(GenericModel::class); + + /** @var GenericFormModelInputHandler $inputHandler */ + $inputHandler = $kernel->getContainer()->get(ModelToolsFactory::class)->createInputHandler($entity); + $form = $inputHandler->getForm(); + + $this->assertInstanceOf(TextType::class, $form->get('id')->getConfig()->getType()->getInnerType()); + $this->assertInstanceOf(TextType::class, $form->get('title')->getConfig()->getType()->getInnerType()); + $this->assertInstanceOf(CheckboxType::class, $form->get('isActive')->getConfig()->getType()->getInnerType()); + $this->assertInstanceOf(CheckboxType::class, $form->get('approved')->getConfig()->getType()->getInnerType()); + $this->assertInstanceOf(TextType::class, $form->get('description')->getConfig()->getType()->getInnerType()); + $this->assertInstanceOf(CheckboxType::class, $form->get('canPost')->getConfig()->getType()->getInnerType()); + $this->assertInstanceOf(DateTimeType::class, $form->get('date')->getConfig()->getType()->getInnerType()); + $this->assertInstanceOf(CollectionType::class, $form->get('dependentArray')->getConfig()->getType()->getInnerType()); + } +} diff --git a/tests/Integration/RequestDecoderTest.php b/tests/Integration/RequestDecoderTest.php index aedfeed..7d9ff5d 100644 --- a/tests/Integration/RequestDecoderTest.php +++ b/tests/Integration/RequestDecoderTest.php @@ -6,19 +6,23 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Validator\Validation; use Trikoder\JsonApiBundle\Config\ApiConfig; use Trikoder\JsonApiBundle\Config\Config; use Trikoder\JsonApiBundle\Config\CreateConfig; use Trikoder\JsonApiBundle\Config\DeleteConfig; use Trikoder\JsonApiBundle\Config\IndexConfig; use Trikoder\JsonApiBundle\Config\UpdateConfig; -use Trikoder\JsonApiBundle\Contracts\RequestBodyValidatorInterface; +use Trikoder\JsonApiBundle\Config\UpdateRelationshipConfig; use Trikoder\JsonApiBundle\Controller\JsonApiEnabledInterface; use Trikoder\JsonApiBundle\Model\ModelFactoryInterface; use Trikoder\JsonApiBundle\Services\Neomerx\FactoryService; use Trikoder\JsonApiBundle\Services\Neomerx\ServiceContainer; +use Trikoder\JsonApiBundle\Services\RequestDecoder\RelationshipRequestBodyDecoder; +use Trikoder\JsonApiBundle\Services\RequestDecoder\RelationshipValidatorAdapter; use Trikoder\JsonApiBundle\Services\RequestDecoder\RequestBodyDecoderService; use Trikoder\JsonApiBundle\Services\RequestDecoder\RequestDecoder; +use Trikoder\JsonApiBundle\Services\RequestDecoder\SymfonyValidatorAdapter; class RequestDecoderTest extends KernelTestCase { @@ -34,6 +38,7 @@ public function testJsonBodyPayload() ], ], ])); + $request->setMethod(Request::METHOD_POST); $result = $requestDecoder->decode($request); $this->assertEquals([ 'name' => 'value', @@ -52,12 +57,106 @@ public function testJsonPostPayload() ], ]), ]); + $request->setMethod(Request::METHOD_POST); + $result = $requestDecoder->decode($request); $this->assertEquals([ 'name' => 'value', ], $result->request->all()); } + public function testJsonPostPayloadWithDefaultsAttributeAndInvalidStructure() + { + $this->expectException(BadRequestHttpException::class); + $requestDecoder = $this->getRequestDecoder(null); + + $request = new Request([], [ + 'data' => [ + 'type' => 'test', + 'attributes' => [ + 'name' => 'value', + ], + ], + ]); + + $request->setMethod(Request::METHOD_POST); + $request->attributes->set('_jsonapibundle_relationship_endpoint', true); + + $requestDecoder->decode($request); + } + + public function testJsonPostPayloadWithDefaultsAttributeAndValidStructure() + { + $requestDecoder = $this->getRequestDecoder(null); + + $request = new Request([], [ + 'data' => [ + [ + 'type' => 'some type', + 'id' => '2222', + ], + [ + 'type' => 'some type 3', + 'id' => '2222', + ], + [ + 'type' => 'some x type', + 'id' => '2222', + ], + ], + ]); + $request->setMethod(Request::METHOD_POST); + $request->attributes->set('_jsonapibundle_relationship_endpoint', true); + + $result = $requestDecoder->decode($request); + + $this->assertEquals( + [ + [ + 'type' => 'some type', + 'id' => '2222', + ], + [ + 'type' => 'some type 3', + 'id' => '2222', + ], + [ + 'type' => 'some x type', + 'id' => '2222', + ], + ], + $result->request->all()); + } + + public function testJsonPostPayloadWithDefaultsAttributeAndValidStructureButOnlyOneInvalidElement() + { + $this->expectException(BadRequestHttpException::class); + $requestDecoder = $this->getRequestDecoder(null); + + $request = new Request([], [ + 'data' => [ + [ + 'type' => 'some type', + 'id' => '2222', + ], + [ + 'type' => 'some type 3', + 'id' => '2222', + ], + [ + 'invalid_here' => 'some x type', + 'id' => '2222', + ], + ], + ]); + $request->setMethod(Request::METHOD_POST); + $request->attributes->set('_jsonapibundle_relationship_endpoint', true); + + $result = $requestDecoder->decode($request); + + $this->assertEquals([], $result->request->all()); + } + public function testPlainPostPayload() { $requestDecoder = $this->getRequestDecoder(); @@ -70,6 +169,8 @@ public function testPlainPostPayload() ], ], ]); + $request->setMethod(Request::METHOD_POST); + $result = $requestDecoder->decode($request); $this->assertEquals([ 'name' => 'value', @@ -95,17 +196,57 @@ public function testInvalidPostDataPayload() $result = $requestDecoder->decode($request); } + /** + * @dataProvider provideHttpVerbsThatShouldNotContainBody + */ + public function testReturnsEmptyArrayForHttpVerbsThatShouldNotContainBody($method) + { + $requestDecoder = $this->getRequestDecoder(); + + $request = new Request([], [ + 'data' => [ + 'type' => 'test', + 'attributes' => [ + 'name' => 'value', + ], + ], + ]); + $request->setMethod($method); + + $result = $requestDecoder->decode($request); + $this->assertSame([], $result->request->all()); + } + + public function provideHttpVerbsThatShouldNotContainBody() + { + return [ + [Request::METHOD_GET], + [Request::METHOD_DELETE], + ]; + } + private function getRequestDecoder($controller = null) { if (null === $controller) { // prepare mocked config and controller $controller = $this->getMockBuilder(JsonApiEnabledInterface::class)->disableOriginalConstructor()->getMock(); $apiConfig = new Config( - new ApiConfig("\stdClass", null, null, null, $this->getRequestBodyDecoder(), false), + new ApiConfig( + "\stdClass", + null, + null, + null, + $this->getRequestBodyDecoder(), + false, + $this->getRequestBodyValidator(), + $this->getRelationshipsRequestBodyValidator(), + $this->getRelationshipRequestBodyDecoder() + ), new CreateConfig($this->getModelFactoryMock()), new IndexConfig(), new UpdateConfig(), - new DeleteConfig() + new DeleteConfig(), + new UpdateRelationshipConfig() ); $controller->method('getJsonApiConfig')->willReturn($apiConfig); } @@ -115,9 +256,22 @@ private function getRequestDecoder($controller = null) private function getRequestBodyDecoder() { - $validator = $this->getMockBuilder(RequestBodyValidatorInterface::class)->disableOriginalConstructor()->getMock(); + return new RequestBodyDecoderService(); + } + + private function getRelationshipRequestBodyDecoder() + { + return new RelationshipRequestBodyDecoder(); + } - return new RequestBodyDecoderService($validator); + private function getRequestBodyValidator() + { + return new SymfonyValidatorAdapter(Validation::createValidator()); + } + + private function getRelationshipsRequestBodyValidator() + { + return new RelationshipValidatorAdapter(Validation::createValidator()); } /** @@ -136,7 +290,7 @@ private function getFactoryServiceMock() $logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(); $container = $this->getMockBuilder(ServiceContainer::class)->disableOriginalConstructor()->getMock(); $container->method('has')->willReturn(true); - $container->method('get')->will($this->returnCallback(function (...$args) { + $container->method('get')->willReturnCallback(function (...$args) { switch ($args[0]) { case 'logger': return $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(); @@ -144,7 +298,7 @@ private function getFactoryServiceMock() } return null; - })); + }); $factory = new FactoryService($container, $logger); return $factory; diff --git a/tests/Integration/ResponseBodyDecoderTest.php b/tests/Integration/ResponseBodyDecoderTest.php new file mode 100644 index 0000000..e8eb86e --- /dev/null +++ b/tests/Integration/ResponseBodyDecoderTest.php @@ -0,0 +1,338 @@ + [ + 'type' => 'post', + 'id' => '1', + 'attributes' => [ + 'title' => 'Post 1', + 'active' => true, + ], + 'relationships' => [ + 'author' => [ + 'data' => [ + 'type' => 'user', + 'id' => 3, + ], + ], + ], + 'links' => [ + 'self' => '/post/1', + ], + ], + 'included' => [ + [ + 'type' => 'user', + 'id' => '3', + 'attributes' => [ + 'email' => 'author@example.com', + 'active' => true, + ], + ], + ], + ]); + + /** @var Post $post */ + $post = $this->getResponseBodyDecoder()->decode($payload); + + $this->assertInstanceOf(Post::class, $post); + $this->assertEquals(1, $post->getId()); + $this->assertEquals('Post 1', $post->getTitle()); + $this->assertTrue($post->isActive()); + + $this->assertInstanceOf(User::class, $post->getAuthor()); + $this->assertEquals(3, $post->getAuthor()->getId()); + $this->assertEquals('author@example.com', $post->getAuthor()->getEmail()); + $this->assertTrue($post->getAuthor()->isActive()); + $this->assertFalse($post->getAuthor()->isCustomer()); + } + + public function testCartJsonPayload() + { + $kernel = static::bootKernel(); + + $payload = json_encode([ + 'data' => [ + 'type' => 'cart', + 'id' => '1306', + 'relationships' => [ + 'user' => [ + 'data' => [ + 'type' => 'user', + 'id' => 1, + ], + ], + 'products' => [ + 'data' => [ + [ + 'type' => 'product', + 'id' => 1, + ], + [ + 'type' => 'product', + 'id' => 56, + ], + [ + 'type' => 'product', + 'id' => 7, + ], + ], + ], + ], + 'links' => [ + 'self' => '/cart/1', + ], + ], + 'included' => [ + [ + 'type' => 'user', + 'id' => '3', + 'attributes' => [ + 'email' => 'author3@example.com', + 'active' => true, + ], + ], + [ + 'type' => 'user', + 'id' => '1', + 'attributes' => [ + 'email' => 'author1@example.com', + 'active' => false, + ], + ], + [ + 'type' => 'product', + 'id' => '1', + 'attributes' => [ + 'title' => 'Product 1', + ], + ], + [ + 'type' => 'product', + 'id' => '2', + 'attributes' => [ + 'title' => 'Product 2', + ], + ], + [ + 'type' => 'product', + 'id' => '56', + 'attributes' => [ + 'title' => 'Product 56', + ], + ], + ], + ]); + + /** @var Cart $cart */ + $cart = $this->getResponseBodyDecoder()->decode($payload); + + $this->assertInstanceOf(Cart::class, $cart); + $this->assertEquals(1306, $cart->getId()); + + $this->assertInstanceOf(User::class, $cart->getUser()); + $this->assertEquals(1, $cart->getUser()->getId()); + $this->assertEquals('author1@example.com', $cart->getUser()->getEmail()); + $this->assertFalse($cart->getUser()->isActive()); + $this->assertFalse($cart->getUser()->isCustomer()); + + $products = $cart->getProducts(); + $this->assertCount(3, $products); + + /** @var Product $product */ + $product = $products[0]; + $this->assertInstanceOf(Product::class, $product); + $this->assertEquals('Product 1', $product->getTitle()); + + /** @var Product $product */ + $product = $products[1]; + $this->assertInstanceOf(Product::class, $product); + $this->assertEquals('Product 56', $product->getTitle()); + + /** @var Product $product */ + $product = $products[2]; + $this->assertInstanceOf(Product::class, $product); + $this->assertEmpty($product->getTitle()); + } + + public function testMultipleResourceJsonPayload() + { + $kernel = static::bootKernel(); + + $payload = json_encode([ + 'data' => [ + [ + 'type' => 'post', + 'id' => '1', + 'attributes' => [ + 'title' => 'Post 1', + 'active' => true, + ], + 'relationships' => [ + 'author' => [ + 'data' => [ + 'type' => 'user', + 'id' => 3, + ], + ], + ], + ], + [ + 'type' => 'post', + 'id' => '6', + 'attributes' => [ + 'title' => 'Post 6', + ], + ], + [ + 'type' => 'cart', + 'id' => '1306', + 'relationships' => [ + 'user' => [ + 'data' => [ + 'type' => 'user', + 'id' => 1, + ], + ], + 'products' => [ + 'data' => [ + [ + 'type' => 'product', + 'id' => 1, + ], + [ + 'type' => 'product', + 'id' => 56, + ], + [ + 'type' => 'product', + 'id' => 7, + ], + ], + ], + ], + ], + ], + 'included' => [ + [ + 'type' => 'user', + 'id' => '3', + 'attributes' => [ + 'email' => 'author@example.com', + 'active' => true, + ], + ], + ], + ]); + + $result = $this->getResponseBodyDecoder()->decode($payload); + + /** @var Post $post */ + $post = $result[0]; + $this->assertInstanceOf(Post::class, $post); + $this->assertEquals(1, $post->getId()); + $this->assertEquals('Post 1', $post->getTitle()); + $this->assertTrue($post->isActive()); + $this->assertInstanceOf(User::class, $post->getAuthor()); + $this->assertEquals(3, $post->getAuthor()->getId()); + $this->assertEquals('author@example.com', $post->getAuthor()->getEmail()); + $this->assertTrue($post->getAuthor()->isActive()); + $this->assertFalse($post->getAuthor()->isCustomer()); + + /** @var Post $post */ + $post = $result[1]; + $this->assertInstanceOf(Post::class, $post); + $this->assertEquals(6, $post->getId()); + $this->assertEquals('Post 6', $post->getTitle()); + $this->assertFalse($post->isActive()); + $this->assertNull($post->getAuthor()); + + /** @var Cart $cart */ + $cart = $result[2]; + + $this->assertInstanceOf(Cart::class, $cart); + $this->assertEquals(1306, $cart->getId()); + + $this->assertInstanceOf(User::class, $cart->getUser()); + $this->assertEquals(1, $cart->getUser()->getId()); + + $products = $cart->getProducts(); + $this->assertCount(3, $products); + + /** @var Product $product */ + $product = $products[0]; + $this->assertInstanceOf(Product::class, $product); + + /** @var Product $product */ + $product = $products[1]; + $this->assertInstanceOf(Product::class, $product); + + /** @var Product $product */ + $product = $products[2]; + $this->assertInstanceOf(Product::class, $product); + } + + public function testJsonPayloadWithEmptyData() + { + $kernel = static::bootKernel(); + + $payload = json_encode([ + 'data' => [], + ]); + + $resources = $this->getResponseBodyDecoder()->decode($payload); + + $this->assertInternalType('array', $resources); + $this->assertEmpty($resources); + } + + public function testJsonPayloadWithNullData() + { + $kernel = static::bootKernel(); + + $payload = json_encode([ + 'data' => null, + ]); + + $resource = $this->getResponseBodyDecoder()->decode($payload); + + $this->assertNull($resource); + } + + private function getResponseBodyDecoder(): ResponseBodyDecoder + { + return new ResponseBodyDecoder(static::$kernel->getContainer()->get('trikoder.jsonapi.factory'), $this->getSchemaClassMap()); + } + + private function getSchemaClassMap() + { + $classMap = new SchemaClassMapService(); + + $classMap->add(User::class, UserSchema::class); + $classMap->add(Post::class, PostSchema::class); + $classMap->add(Product::class, ProductSchema::class); + $classMap->add(Cart::class, CartSchema::class); + + return $classMap; + } +} diff --git a/tests/Integration/Services/ModelInput/ConstraintViolationToErrorTransformerTraitIntegrationTest.php b/tests/Integration/Services/ModelInput/ConstraintViolationToErrorTransformerTraitIntegrationTest.php new file mode 100644 index 0000000..25ac220 --- /dev/null +++ b/tests/Integration/Services/ModelInput/ConstraintViolationToErrorTransformerTraitIntegrationTest.php @@ -0,0 +1,114 @@ +setAreaCode('12'); + $phoneNumber->setNumber('345678'); + $contactInfo = new ContactInfoModel(); + $contactInfo->setPhoneNumber($phoneNumber); + $contactInfo->setLabel('too long for this'); + + /** @var ValidatorInterface $validator */ + $validator = $kernel->getContainer()->get('test.symfony.validator'); + + /** @var ConstraintViolationListInterface $validation */ + $validation = $validator->validate($contactInfo); + + $this->assertCount(1, $validation); + + $trait = new ConstraintViolationToErrorTransformerTraitClass(); + $error = $trait->publicConvertViolationToError($validation->get(0)); + + $this->assertEquals(409, $error->getStatus()); + // $this->assertNull($error->getCode()); // TODO - check if correct code is sent + $this->assertEquals('This value is too long. It should have 10 characters or less.', $error->getTitle()); + $this->assertEquals('/data/attributes/label', $error->getSource()['pointer']); + $this->assertEquals('too long for this', $error->getSource()['parameter']); + } + + public function testDeepAttributeErrorFromForm() + { + $kernel = self::bootKernel(); + + // prepare model + $phoneNumber = new PhoneNumberModel(); + $phoneNumber->setAreaCode('1'); + $phoneNumber->setNumber('345678'); + $contactInfo = new ContactInfoModel(); + $contactInfo->setPhoneNumber($phoneNumber); + $contactInfo->setLabel('valid'); + + /** @var ValidatorInterface $validator */ + $validator = $kernel->getContainer()->get('test.symfony.validator'); + + /** @var ConstraintViolationListInterface $validation */ + $validation = $validator->validate($contactInfo); + + $this->assertCount(1, $validation); + + $trait = new ConstraintViolationToErrorTransformerTraitClass(); + $error = $trait->publicConvertViolationToError($validation->get(0)); + + $this->assertEquals(409, $error->getStatus()); + // $this->assertNull($error->getCode()); // TODO - check if correct code is sent + $this->assertEquals('This value is too short. It should have 2 characters or more.', $error->getTitle()); + $this->assertEquals('/data/attributes/phoneNumber/areaCode', $error->getSource()['pointer']); + $this->assertEquals('1', $error->getSource()['parameter']); + } + + public function testDeepAttributeErrorFromFormForGroup() + { + $kernel = self::bootKernel(); + + // prepare model + $phoneNumber = new PhoneNumberModel(); + $phoneNumber->setAreaCode('01'); + $phoneNumber->setNumber('345678'); + $contactInfo = new ContactInfoModel(); + $contactInfo->setPhoneNumber($phoneNumber); + $contactInfo->setLabel('valid'); + + /** @var ValidatorInterface $validator */ + $validator = $kernel->getContainer()->get('test.symfony.validator'); + + /** @var ConstraintViolationListInterface $validation */ + $validation = $validator->validate($contactInfo, null, 'alwaysInvalidPhoneNumber'); + + $this->assertCount(1, $validation); + + $trait = new ConstraintViolationToErrorTransformerTraitClass(); + $error = $trait->publicConvertViolationToError($validation->get(0)); + + $this->assertEquals(409, $error->getStatus()); + // $this->assertNull($error->getCode()); // TODO - check if correct code is sent + $this->assertEquals('This is invalid', $error->getTitle()); + $this->assertEquals('/data/attributes/phoneNumber/number', $error->getSource()['pointer']); + $this->assertEquals('345678', $error->getSource()['parameter']); + } +} + +class ConstraintViolationToErrorTransformerTraitClass +{ + use ConstraintViolationToErrorTransformer; + + public function publicConvertViolationToError(ConstraintViolationInterface $violation) + { + return $this->convertViolationToError($violation); + } +} diff --git a/tests/Integration/Services/ModelInput/FormErrorToErrorTransformerTraitIntegrationTest.php b/tests/Integration/Services/ModelInput/FormErrorToErrorTransformerTraitIntegrationTest.php new file mode 100644 index 0000000..23b2182 --- /dev/null +++ b/tests/Integration/Services/ModelInput/FormErrorToErrorTransformerTraitIntegrationTest.php @@ -0,0 +1,189 @@ +getContainer()->get('test.symfony.form_factory')->create(ContactInfoType::class); + + $form->submit([ + 'label' => 'too long for this', + 'phoneNumber' => [ + 'areaCode' => '12', + 'number' => '345678', + 'intNumber' => 34343, + 'numberWithValidationOnlyOnForm' => '123', + ], + ]); + + $this->assertFalse($form->isValid()); + + $formErrors = $form->getErrors(true); + + $this->assertEquals(1, $formErrors->count()); + + $trait = new FormErrorToErrorTransformerTraitClass(); + $error = $trait->publicFormErrorToError($formErrors->current()); + + $this->assertEquals(409, $error->getStatus()); + $this->assertNull($error->getCode()); + $this->assertEquals('This value is too long. It should have 10 characters or less.', $error->getTitle()); + $this->assertEquals('/data/attributes/label', $error->getSource()['pointer']); + $this->assertEquals('too long for this', $error->getSource()['parameter']); + } + + public function testDeepAttributeErrorFromForm() + { + $kernel = self::bootKernel(); + + /** @var FormInterface $form */ + $form = $kernel->getContainer()->get('test.symfony.form_factory')->create(ContactInfoType::class); + + $form->submit([ + 'label' => 'valid', + 'phoneNumber' => [ + 'areaCode' => '1', + 'number' => '345678', + 'intNumber' => 34343, + 'numberWithValidationOnlyOnForm' => '123', + ], + ]); + + $this->assertFalse($form->isValid()); + + $formErrors = $form->getErrors(true); + + $this->assertEquals(1, $formErrors->count()); + + $trait = new FormErrorToErrorTransformerTraitClass(); + $error = $trait->publicFormErrorToError($formErrors->current()); + + $this->assertEquals(409, $error->getStatus()); + $this->assertNull($error->getCode()); + $this->assertEquals('This value is too short. It should have 2 characters or more.', $error->getTitle()); + $this->assertEquals('/data/attributes/phoneNumber/areaCode', $error->getSource()['pointer']); + $this->assertEquals('1', $error->getSource()['parameter']); + } + + public function testDeepAttributeErrorFromFormForGroup() + { + $kernel = self::bootKernel(); + + /** @var FormInterface $form */ + $form = $kernel->getContainer()->get('test.symfony.form_factory')->create(ContactInfoTypeWithGroup::class); + + $form->submit([ + 'label' => 'valid', + 'phoneNumber' => [ + 'areaCode' => '1', + 'number' => '345678', + 'intNumber' => 34343, + 'numberWithValidationOnlyOnForm' => '123', + ], + ]); + + $this->assertFalse($form->isValid()); + + $formErrors = $form->getErrors(true); + + $this->assertEquals(1, $formErrors->count()); + + $trait = new FormErrorToErrorTransformerTraitClass(); + $error = $trait->publicFormErrorToError($formErrors->current()); + + $this->assertEquals(409, $error->getStatus()); + $this->assertNull($error->getCode()); + $this->assertEquals('This is invalid', $error->getTitle()); + $this->assertEquals('/data/attributes/phoneNumber/number', $error->getSource()['pointer']); + $this->assertEquals('345678', $error->getSource()['parameter']); + } + + public function testFormTypeValidationCalculatingCorrectPath() + { + $kernel = self::bootKernel(); + + /** @var FormInterface $form */ + $form = $kernel->getContainer()->get('test.symfony.form_factory')->create(ContactInfoType::class); + + $form->submit([ + 'label' => 'valid', + 'phoneNumber' => [ + 'areaCode' => '12', + 'number' => '345678', + 'intNumber' => 'abcdef', // this is IntegerType in @see PhoneNumberType, so this will trigger error + 'numberWithValidationOnlyOnForm' => '123', + ], + ]); + + $this->assertFalse($form->isValid()); + + $formErrors = $form->getErrors(true); + + $this->assertEquals(1, $formErrors->count()); + + $trait = new FormErrorToErrorTransformerTraitClass(); + $error = $trait->publicFormErrorToError($formErrors->current()); + + $this->assertEquals(409, $error->getStatus()); + $this->assertNull($error->getCode()); + $this->assertEquals('This value is not valid.', $error->getTitle()); + $this->assertEquals('/data/attributes/phoneNumber/intNumber', $error->getSource()['pointer']); + $this->assertEquals('abcdef', $error->getSource()['parameter']); + } + + public function testSymfonyFormTypeErrorPathBeingCorrectlyCalculatedForConstraintDefinedOnForm() + { + $kernel = self::bootKernel(); + + /** @var FormInterface $form */ + $form = $kernel->getContainer()->get('test.symfony.form_factory')->create(ContactInfoType::class); + + $form->submit([ + 'label' => 'valid', + 'phoneNumber' => [ + 'areaCode' => '12', + 'number' => '345678', + 'intNumber' => 123, + 'numberWithValidationOnlyOnForm' => '1', // this has constraints defined on @see PhoneNumberType + ], + ]); + + $this->assertFalse($form->isValid()); + + $formErrors = $form->getErrors(true); + + $this->assertEquals(1, $formErrors->count()); + + $trait = new FormErrorToErrorTransformerTraitClass(); + $error = $trait->publicFormErrorToError($formErrors->current()); + + $this->assertEquals(409, $error->getStatus()); + $this->assertNull($error->getCode()); + $this->assertEquals('This value is too short. It should have 3 characters or more.', $error->getTitle()); + $this->assertEquals('/data/attributes/phoneNumber/numberWithValidationOnlyOnForm', $error->getSource()['pointer']); + $this->assertEquals('1', $error->getSource()['parameter']); + } +} + +class FormErrorToErrorTransformerTraitClass +{ + use FormErrorToErrorTransformer; + + public function publicFormErrorToError($violation) + { + return $this->convertFormErrorToError($violation); + } +} diff --git a/tests/Integration/Services/RequestDecoder/RelationshipValidatorAdapterTest.php b/tests/Integration/Services/RequestDecoder/RelationshipValidatorAdapterTest.php new file mode 100644 index 0000000..8f84d43 --- /dev/null +++ b/tests/Integration/Services/RequestDecoder/RelationshipValidatorAdapterTest.php @@ -0,0 +1,108 @@ + [ + [ + 'type' => 'author', + 'id' => '123', + ], + ], + ], + 0, + ], + [ + [ + 'data' => [ + [ + 'type' => 'author', + 'id' => '123', + ], + [ + 'type' => 'author x', + 'id' => '456', + ], + ], + ], + 0, + ], + [ + [ + 'data' => [ + [ + 'type' => 'author', + 'id x' => '123', + ], + ], + ], + 2, + ], + [ + [ + 'data' => [ + [ + 'type' => 'author', + 'id' => '123', + ], + [ + 'type_invalid' => 'author x', + 'id' => '456', + ], + ], + ], + 2, + ], + [ + [ + [ + [ + 'type' => 'author', + 'id' => '123', + ], + [ + 'type' => 'author x', + 'id' => '456', + ], + ], + ], + 2, + ], + [ + [ + 'data' => [ + [ + 'type' => 'idIsNotString', + 'id' => 123, + ], + ], + ], + 1, + ], + ]; + } + + /** + * @dataProvider relationshipsProvider + */ + public function testRelationships(array $relationshipData, $expectedViolationsCount) + { + $kernel = static::bootKernel(); + $validator = $kernel->getContainer()->get('trikoder.jsonapi.relationship_request_body_validator'); + + $violationList = $validator->validate($relationshipData); + + $this->assertSame($expectedViolationsCount, $violationList->count()); + } +} diff --git a/tests/Integration/Services/RequestDecoder/SymfonyValidatorAdapterTest.php b/tests/Integration/Services/RequestDecoder/SymfonyValidatorAdapterTest.php new file mode 100644 index 0000000..459b811 --- /dev/null +++ b/tests/Integration/Services/RequestDecoder/SymfonyValidatorAdapterTest.php @@ -0,0 +1,110 @@ + [ + 'data' => [ + 'type' => 'author', + 'id' => '123', + ], + ], + ], + 0, + ], + [ + [ + 'author' => [ + 'data' => [ + // missing type on purpose + 'id' => '123', + ], + ], + ], + 1, + ], + [ + // null + [ + 'images' => [ + 'data' => null, + ], + ], + 0, + ], + [ + // empty collection + [ + 'images' => [ + 'data' => [], + ], + ], + 0, + ], + [ + // collection of resources + [ + 'comments' => [ + 'data' => [ + [ + 'type' => 'comment', + 'id' => '1', + ], + [ + 'type' => 'comment', + 'id' => '2', + ], + ], + ], + ], + 0, + ], + [ + [ + 'comments' => [ + 'data' => [ + [ + 'type' => 'comment', + // missing id on purpose + ], + [ + 'type' => 'comment', + 'id' => '', + ], + ], + ], + ], + 2, + ], + ]; + } + + /** + * @dataProvider relationshipsProvider + */ + public function testRelationships(array $relationshipData, $expectedViolationsCount) + { + $kernel = static::bootKernel(); + $validator = $kernel->getContainer()->get('trikoder.jsonapi.request_body_validator'); + + $violationList = $validator->validate([ + 'data' => [ + 'type' => 'foobar', + 'relationships' => $relationshipData, + ], + ]); + + $this->assertSame($expectedViolationsCount, $violationList->count()); + } +} diff --git a/tests/Resources/Controller/Api/Test/VersionedUserController.php b/tests/Resources/Controller/Api/Test/VersionedUserController.php new file mode 100644 index 0000000..2b8897f --- /dev/null +++ b/tests/Resources/Controller/Api/Test/VersionedUserController.php @@ -0,0 +1,30 @@ +add(Post::class, GenericSchema::class); + $mapService->add(User::class, GenericSchema::class); + + return $mapService; + } +} diff --git a/tests/Resources/Controller/Api/User/UserController.php b/tests/Resources/Controller/Api/User/UserController.php index a8502ea..18a2eef 100644 --- a/tests/Resources/Controller/Api/User/UserController.php +++ b/tests/Resources/Controller/Api/User/UserController.php @@ -10,6 +10,7 @@ use Trikoder\JsonApiBundle\Controller\Traits\IndexActionTrait; use Trikoder\JsonApiBundle\Controller\Traits\ShowActionTrait; use Trikoder\JsonApiBundle\Controller\Traits\UpdateActionTrait; +use Trikoder\JsonApiBundle\Controller\Traits\UpdateRelationshipActionTrait; /** * @Route("/user") @@ -19,6 +20,9 @@ * index=@JsonApiConfig\IndexConfig( * allowedSortFields={"email", "id"}, * allowedFilteringParameters={"email", "id"} + * ), + * updateRelationship=@JsonApiConfig\UpdateRelationshipConfig( + * allowedRelationships={"tags", "relationshipWhichDoesNotExistOnResource"} * ) * ) */ @@ -29,4 +33,5 @@ class UserController extends JsonApiController use CreateActionTrait; use UpdateActionTrait; use DeleteActionTrait; + use UpdateRelationshipActionTrait; } diff --git a/tests/Resources/DataFixtures/ORM/AbstractBaseFixture.php b/tests/Resources/DataFixtures/ORM/AbstractBaseFixture.php index 7379d7b..548101b 100644 --- a/tests/Resources/DataFixtures/ORM/AbstractBaseFixture.php +++ b/tests/Resources/DataFixtures/ORM/AbstractBaseFixture.php @@ -2,7 +2,7 @@ namespace Trikoder\JsonApiBundle\Tests\Resources\DataFixtures\ORM; -use Doctrine\Common\DataFixtures\AbstractFixture as BaseAbstractFixture; +use Doctrine\Bundle\FixturesBundle\Fixture as BaseAbstractFixture; use Doctrine\Common\Persistence\ObjectManager; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerInterface; diff --git a/tests/Resources/DataFixtures/ORM/Configuration/LoadTagData.php b/tests/Resources/DataFixtures/ORM/Configuration/LoadTagData.php new file mode 100644 index 0000000..7b02721 --- /dev/null +++ b/tests/Resources/DataFixtures/ORM/Configuration/LoadTagData.php @@ -0,0 +1,34 @@ +addReference('tag-1', $tag); + $manager->persist($tag); + + $tag = new Tag(); + $this->addReference('tag-2', $tag); + $manager->persist($tag); + + $tag = new Tag(); + $this->addReference('tag-3', $tag); + $manager->persist($tag); + + $manager->flush(); + } + + public function getOrder() + { + return 13; + } +} diff --git a/tests/Resources/DataFixtures/ORM/Configuration/LoadUserWithTag.php b/tests/Resources/DataFixtures/ORM/Configuration/LoadUserWithTag.php new file mode 100644 index 0000000..58a502c --- /dev/null +++ b/tests/Resources/DataFixtures/ORM/Configuration/LoadUserWithTag.php @@ -0,0 +1,29 @@ +setEmail('user-with-tag@ghosap.com'); + $user->addTag($this->getReference('tag-3')); + + $this->addReference('user-with-tag', $user); + + $manager->persist($user); + $manager->flush(); + } + + public function getOrder() + { + return 15; + } +} diff --git a/tests/Resources/Entity/Cart.php b/tests/Resources/Entity/Cart.php new file mode 100644 index 0000000..93afd73 --- /dev/null +++ b/tests/Resources/Entity/Cart.php @@ -0,0 +1,91 @@ +products = new ArrayCollection(); + } + + /** + */ + public function getId(): int + { + return $this->id; + } + + /** + */ + public function setId(int $id): void + { + $this->id = $id; + } + + /** + */ + public function getUser() + { + return $this->user; + } + + /** + */ + public function setUser($user) + { + $this->user = $user; + } + + public function getProducts(): Collection + { + return $this->products; + } + + public function addProduct(Product $product): self + { + if (!$this->products->contains($product)) { + $this->products[] = $product; + } + + return $this; + } + + public function removeProduct(Product $product): self + { + if ($this->products->contains($product)) { + $this->products->removeElement($product); + } + + return $this; + } +} diff --git a/tests/Resources/Entity/Post.php b/tests/Resources/Entity/Post.php index 9f4a965..dce587e 100644 --- a/tests/Resources/Entity/Post.php +++ b/tests/Resources/Entity/Post.php @@ -2,8 +2,10 @@ namespace Trikoder\JsonApiBundle\Tests\Resources\Entity; +use DateTime; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; +use Trikoder\JsonApiBundle\Schema\Builtin\ResourceInterface; /** * Post @@ -11,7 +13,7 @@ * @ORM\Table(name="post") * @ORM\Entity(repositoryClass="Trikoder\JsonApiBundle\Tests\Resources\Repository\PostRepository") */ -class Post +class Post implements ResourceInterface { /** * @var int @@ -43,6 +45,30 @@ class Post */ private $author; + /** + * @var DateTime + * + * @ORM\Column(type="datetime") + */ + private $publishedAt; + + public function __construct() + { + $this->publishedAt = new DateTime(); + } + + public static function getJsonApiResourceType(): string + { + return 'post'; + } + + /** + */ + public function setId(int $id): void + { + $this->id = $id; + } + /** * Get id * @@ -54,8 +80,9 @@ public function getId() } /** + * @return string|null */ - public function getTitle(): string + public function getTitle() { return $this->title; } @@ -93,7 +120,7 @@ public function setActive($active) /** */ - public function getAuthor() + public function getAuthor(): ?User { return $this->author; } @@ -107,4 +134,18 @@ public function setAuthor($author) return $this; } + + /** + */ + public function getPublishedAt(): DateTime + { + return $this->publishedAt; + } + + /** + */ + public function setPublishedAt(DateTime $publishedAt): void + { + $this->publishedAt = $publishedAt; + } } diff --git a/tests/Resources/Entity/Product.php b/tests/Resources/Entity/Product.php index 0ebdacb..b5bcf46 100644 --- a/tests/Resources/Entity/Product.php +++ b/tests/Resources/Entity/Product.php @@ -33,12 +33,23 @@ class Product /** * @var int * - * * @ORM\Column(name="price", type="integer") * @Assert\Type(type="integer") */ private $price = 0; + /** + * @ORM\ManyToOne(targetEntity="Cart", inversedBy="products") + */ + private $cart; + + /** + */ + public function setId(int $id): void + { + $this->id = $id; + } + /** * Get id * @@ -86,4 +97,14 @@ public function setPrice($price) return $this; } + + public function getCart() + { + return $this->cart; + } + + public function setCart(Cart $cart) + { + $this->cart = $cart; + } } diff --git a/tests/Resources/Entity/Tag.php b/tests/Resources/Entity/Tag.php new file mode 100644 index 0000000..58c8342 --- /dev/null +++ b/tests/Resources/Entity/Tag.php @@ -0,0 +1,48 @@ +users = new ArrayCollection(); + } + + /** + */ + public function getId(): int + { + return $this->id; + } + + /** + */ + public function getUsers(): ArrayCollection + { + return $this->users; + } +} diff --git a/tests/Resources/Entity/User.php b/tests/Resources/Entity/User.php index cabf9aa..6409c11 100644 --- a/tests/Resources/Entity/User.php +++ b/tests/Resources/Entity/User.php @@ -2,8 +2,11 @@ namespace Trikoder\JsonApiBundle\Tests\Resources\Entity; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; +use Trikoder\JsonApiBundle\Schema\Builtin\ResourceInterface; /** * User @@ -11,7 +14,7 @@ * @ORM\Table(name="user") * @ORM\Entity(repositoryClass="Trikoder\JsonApiBundle\Tests\Resources\Repository\UserRepository") */ -class User +class User implements ResourceInterface { /** * @var int @@ -31,6 +34,16 @@ class User */ private $email; + /** + * @ORM\OneToMany(targetEntity="Cart", mappedBy="user") + */ + private $carts; + + /** + * @ORM\ManyToMany(targetEntity="Tag", inversedBy="users") + */ + private $tags; + /** * @var bool * @@ -45,6 +58,24 @@ class User */ private $customer = false; + public function __construct() + { + $this->carts = new ArrayCollection(); + $this->tags = new ArrayCollection(); + } + + public static function getJsonApiResourceType(): string + { + return 'user'; + } + + /** + */ + public function setId(int $id): void + { + $this->id = $id; + } + /** * Get id * @@ -106,4 +137,55 @@ public function setCustomer($customer) return $this; } + + public function getCarts() + { + return $this->carts; + } + + public function addCart(Cart $cart): self + { + if (!$this->carts->contains($cart)) { + $this->carts[] = $cart; + $cart->setUser($this); + } + + return $this; + } + + public function removeCart(Cart $cart): self + { + if ($this->carts->contains($cart)) { + $this->carts->removeElement($cart); + // set the owning side to null (unless already changed) + if ($cart->getUser() === $this) { + $cart->setUser(null); + } + } + + return $this; + } + + public function getTags(): Collection + { + return $this->tags; + } + + public function addTag(Tag $tag): self + { + if (!$this->tags->contains($tag)) { + $this->tags[] = $tag; + } + + return $this; + } + + public function removeTag(Tag $tag): self + { + if ($this->tags->contains($tag)) { + $this->tags->removeElement($tag); + } + + return $this; + } } diff --git a/tests/Resources/Form/ContactInfoType.php b/tests/Resources/Form/ContactInfoType.php new file mode 100644 index 0000000..2a0a27a --- /dev/null +++ b/tests/Resources/Form/ContactInfoType.php @@ -0,0 +1,29 @@ +add('phoneNumber', PhoneNumberType::class); + $builder->add('label', TextType::class); + } + + /** + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => ContactInfoModel::class, + ]); + } +} diff --git a/tests/Resources/Form/ContactInfoTypeWithGroup.php b/tests/Resources/Form/ContactInfoTypeWithGroup.php new file mode 100644 index 0000000..330b173 --- /dev/null +++ b/tests/Resources/Form/ContactInfoTypeWithGroup.php @@ -0,0 +1,30 @@ +add('phoneNumber', PhoneNumberType::class); + $builder->add('label', TextType::class); + } + + /** + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => ContactInfoModel::class, + 'validation_groups' => 'alwaysInvalidPhoneNumber', + ]); + } +} diff --git a/tests/Resources/Form/PhoneNumberType.php b/tests/Resources/Form/PhoneNumberType.php new file mode 100644 index 0000000..e00b2e0 --- /dev/null +++ b/tests/Resources/Form/PhoneNumberType.php @@ -0,0 +1,37 @@ +add('areaCode', TextType::class); + $builder->add('number', TextType::class); + $builder->add('intNumber', IntegerType::class); + $builder->add('numberWithValidationOnlyOnForm', TextType::class, [ + 'constraints' => [ + new Length(['min' => 3]), + ], + ]); + } + + /** + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => PhoneNumberModel::class, + ]); + } +} diff --git a/tests/Resources/JsonApi/Schema/CartSchema.php b/tests/Resources/JsonApi/Schema/CartSchema.php new file mode 100644 index 0000000..082c662 --- /dev/null +++ b/tests/Resources/JsonApi/Schema/CartSchema.php @@ -0,0 +1,62 @@ +getId(); + } + + /** + * Get resource attributes. + * + * @param object $resource + * + * @return array + */ + public function getAttributes($resource) + { + /* @var Cart $resource */ + return []; + } + + /** + * @param object $resource + * @param bool $isPrimary + * + * @return array + */ + public function getRelationships($resource, $isPrimary, array $includeRelationships) + { + /** @var Cart $resource */ + $relationships = []; + + $relationships['author'] = [ + self::DATA => function () use ($resource) { + return $resource->getAuthor(); + }, + ]; + + $relationships['products'] = [ + self::DATA => function () use ($resource) { + return $resource->getProducts(); + }, + ]; + + return $relationships; + } +} diff --git a/tests/Resources/Model/ContactInfoModel.php b/tests/Resources/Model/ContactInfoModel.php new file mode 100644 index 0000000..e6237b5 --- /dev/null +++ b/tests/Resources/Model/ContactInfoModel.php @@ -0,0 +1,68 @@ +buildViolation('This is invalid') + ->atPath('phoneNumber.number') + ->setInvalidValue($this->phoneNumber->getNumber()) + ->addViolation(); + } + + public function __construct() + { + $this->phoneNumber = new PhoneNumberModel(); + } + + /** + */ + public function getLabel(): string + { + return $this->label; + } + + /** + */ + public function setLabel(string $label): void + { + $this->label = $label; + } + + /** + */ + public function getPhoneNumber(): PhoneNumberModel + { + return $this->phoneNumber; + } + + /** + */ + public function setPhoneNumber(PhoneNumberModel $phoneNumber): void + { + $this->phoneNumber = $phoneNumber; + } +} diff --git a/tests/Resources/Model/PhoneNumberModel.php b/tests/Resources/Model/PhoneNumberModel.php new file mode 100644 index 0000000..9d8a0dc --- /dev/null +++ b/tests/Resources/Model/PhoneNumberModel.php @@ -0,0 +1,80 @@ +areaCode; + } + + /** + */ + public function setAreaCode(string $areaCode): void + { + $this->areaCode = $areaCode; + } + + /** + */ + public function getNumber(): string + { + return $this->number; + } + + /** + */ + public function setNumber(string $number): void + { + $this->number = $number; + } + + public function getIntNumber(): int + { + return $this->intNumber; + } + + public function setIntNumber(int $intNumber): void + { + $this->intNumber = $intNumber; + } + + public function getNumberWithValidationOnlyOnForm(): string + { + return $this->numberWithValidationOnlyOnForm; + } + + public function setNumberWithValidationOnlyOnForm(string $numberWithValidationOnlyOnForm) + { + $this->numberWithValidationOnlyOnForm = $numberWithValidationOnlyOnForm; + } +} diff --git a/tests/Resources/app/config/jsonapi.yml b/tests/Resources/app/config/jsonapi.yml index 535389b..fe5b6db 100644 --- a/tests/Resources/app/config/jsonapi.yml +++ b/tests/Resources/app/config/jsonapi.yml @@ -2,6 +2,7 @@ trikoder_json_api: model_class: '\stdClass' repository: "trikoder.jsonapi.doctrine_repository_factory" request_body_decoder: "trikoder.jsonapi.request_body_decoder" + relationship_request_body_decoder: "trikoder.jsonapi.relationship_request_body_decoder" fixed_filtering: {} allowed_include_paths: null allow_extra_params: false @@ -21,4 +22,6 @@ trikoder_json_api: required_roles: null delete: required_roles: null - schema_automap_scan_patterns: [] \ No newline at end of file + schema_automap_scan_patterns: [] + kernel_listener_on_kernel_view_priority: 0 + kernel_listener_on_kernel_exception_priority: 0 diff --git a/tests/Resources/app/config/services.yml b/tests/Resources/app/config/services.yml index 299e524..d66ac67 100644 --- a/tests/Resources/app/config/services.yml +++ b/tests/Resources/app/config/services.yml @@ -40,3 +40,23 @@ services: Trikoder\JsonApiBundle\Tests\Resources\Security\AuthenticatedUserProvider: ~ + + test.trikoder.jsonapi.request_listener: + alias: trikoder.jsonapi.request_listener + public: true + + test.trikoder.jsonapi.model_meta_data_factory: + alias: trikoder.jsonapi.model_meta_data_factory + public: true + + + test.symfony.form_factory: + alias: Symfony\Component\Form\FormFactoryInterface + public: true + + test.symfony.validator: + alias: Symfony\Component\Validator\Validator\ValidatorInterface + public: true + + Trikoder\JsonApiBundle\Tests\Resources\DataFixtures\ORM\: + resource: '../../DataFixtures/ORM/*' diff --git a/tests/Resources/docker/bin/setup_fixtures.sh b/tests/Resources/docker/bin/setup_fixtures.sh index 7dd9090..39e0ac9 100755 --- a/tests/Resources/docker/bin/setup_fixtures.sh +++ b/tests/Resources/docker/bin/setup_fixtures.sh @@ -2,4 +2,4 @@ docker-compose run --no-deps --rm php php tests/Resources/app/bin/console doctrine:database:create --if-not-exists docker-compose run --no-deps --rm php php tests/Resources/app/bin/console doctrine:schema:drop --force docker-compose run --no-deps --rm php php tests/Resources/app/bin/console doctrine:schema:update --no-interaction --force -docker-compose run --no-deps --rm php php tests/Resources/app/bin/console doctrine:fixtures:load --no-interaction --fixtures=./tests/Resources/DataFixtures/ORM/ +docker-compose run --no-deps --rm php php tests/Resources/app/bin/console doctrine:fixtures:load --no-interaction diff --git a/tests/Unit/CompilerPass/SchemaAutoMapCompilerPassTest.php b/tests/Unit/CompilerPass/SchemaAutoMapCompilerPassTest.php index 95a2244..ae62ade 100644 --- a/tests/Unit/CompilerPass/SchemaAutoMapCompilerPassTest.php +++ b/tests/Unit/CompilerPass/SchemaAutoMapCompilerPassTest.php @@ -2,7 +2,7 @@ namespace Trikoder\JsonApiBundle\Tests\Unit\CompilerPass; -use Symfony\Bundle\FrameworkBundle\Tests\TestCase; +use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Trikoder\JsonApiBundle\CompilerPass\SchemaAutoMapCompilerPass; diff --git a/tests/Unit/Config/ConfigBuilderTest.php b/tests/Unit/Config/ConfigBuilderTest.php index b928b76..7afebfe 100644 --- a/tests/Unit/Config/ConfigBuilderTest.php +++ b/tests/Unit/Config/ConfigBuilderTest.php @@ -8,8 +8,10 @@ use Trikoder\JsonApiBundle\Config\Annotation\DeleteConfig; use Trikoder\JsonApiBundle\Config\Annotation\IndexConfig; use Trikoder\JsonApiBundle\Config\Annotation\UpdateConfig; +use Trikoder\JsonApiBundle\Config\Annotation\UpdateRelationshipConfig; use Trikoder\JsonApiBundle\Contracts\RepositoryInterface; use Trikoder\JsonApiBundle\Contracts\RequestBodyDecoderInterface; +use Trikoder\JsonApiBundle\Contracts\RequestBodyValidatorInterface; use Trikoder\JsonApiBundle\Model\ModelFactoryInterface; use Trikoder\JsonApiBundle\Model\ModelFactoryResolver; use Trikoder\JsonApiBundle\Model\ModelFactoryResolverInterface; @@ -30,18 +32,21 @@ protected function getContainerMock() { $mocker = $this->getMockBuilder(ContainerInterface::class)->disableOriginalConstructor()->getMock(); $mocker->method('has')->willReturn(true); - $mocker->method('get')->will($this->returnCallback(function (...$args) { + $mocker->method('get')->willReturnCallback(function (...$args) { switch ($args[0]) { case 'request_body_decoder': + case 'relationship_request_body_decoder': return $this->getRequestBodyDecoderMock(); break; + case 'request_body_validator': + return $this->getRequestBodyValidatorMock(); case 'repository': return $this->getRepositoryMock(); break; } return null; - })); + }); return $mocker; } @@ -54,6 +59,11 @@ protected function getRequestBodyDecoderMock() return $this->getMockBuilder(RequestBodyDecoderInterface::class)->disableOriginalConstructor()->getMock(); } + protected function getRequestBodyValidatorMock() + { + return $this->getMockBuilder(RequestBodyValidatorInterface::class)->disableOriginalConstructor()->getMock(); + } + /** * @return RepositoryInterface */ @@ -220,13 +230,16 @@ protected function getAnnotationsConfig() $annotationConfig->create = new CreateConfig(); $annotationConfig->update = new UpdateConfig(); $annotationConfig->delete = new DeleteConfig(); + $annotationConfig->updateRelationship = new UpdateRelationshipConfig(); $annotationConfig->modelClass = 'modelClass'; $annotationConfig->repository = 'repository'; $annotationConfig->requestBodyDecoder = 'request_body_decoder'; + $annotationConfig->requestBodyValidator = 'request_body_validator'; $annotationConfig->fixedFiltering = ['fixed_filtering']; $annotationConfig->allowedIncludePaths = ['allowed_include_paths']; $annotationConfig->allowExtraParams = true; + $annotationConfig->relationshipRequestBodyDecoder = 'relationship_request_body_decoder'; $annotationConfig->index->allowedSortFields = ['allowed_sort_fields']; $annotationConfig->index->allowedFilteringParameters = ['allowed_filtering_parameters']; @@ -256,6 +269,7 @@ protected function getArrayConfig() 'model_class' => '', 'repository' => '', 'request_body_decoder' => '', + 'request_body_validator' => '', 'fixed_filtering' => '', 'allowed_include_paths' => '', 'allow_extra_params' => '', @@ -279,6 +293,8 @@ protected function getArrayConfig() 'delete' => [ 'required_roles' => '', ], + 'relationship_request_body_decoder' => '', + 'relationship_request_body_validator' => '', ]; } } diff --git a/tests/Unit/Config/ConfigurationTest.php b/tests/Unit/Config/ConfigurationTest.php index 7b0ff3c..9e3b409 100644 --- a/tests/Unit/Config/ConfigurationTest.php +++ b/tests/Unit/Config/ConfigurationTest.php @@ -22,6 +22,7 @@ protected function getExpectedConfig() 'model_class' => '\stdClass', 'repository' => 'trikoder.jsonapi.doctrine_repository_factory', 'request_body_decoder' => 'trikoder.jsonapi.request_body_decoder', + 'relationship_request_body_decoder' => 'trikoder.jsonapi.relationship_request_body_decoder', 'fixed_filtering' => [], 'allowed_include_paths' => null, 'allow_extra_params' => false, @@ -46,6 +47,10 @@ protected function getExpectedConfig() 'required_roles' => null, ], 'schema_automap_scan_patterns' => [], + 'kernel_listener_on_kernel_view_priority' => 0, + 'kernel_listener_on_kernel_exception_priority' => 0, + 'request_body_validator' => 'trikoder.jsonapi.request_body_validator', + 'relationship_request_body_validator' => 'trikoder.jsonapi.relationship_request_body_validator', ]; } } diff --git a/tests/Unit/Controller/Traits/IndexTraitTest.php b/tests/Unit/Controller/Traits/IndexTraitTest.php index 6217c33..3613c9e 100644 --- a/tests/Unit/Controller/Traits/IndexTraitTest.php +++ b/tests/Unit/Controller/Traits/IndexTraitTest.php @@ -2,7 +2,7 @@ namespace Trikoder\JsonApiBundle\Tests\Unit\Services\ModelInput; -use Symfony\Bundle\FrameworkBundle\Tests\TestCase; +use PHPUnit\Framework\TestCase; use Trikoder\JsonApiBundle\Controller\Traits\Actions\IndexTrait; class IndexTraitTest extends TestCase diff --git a/tests/Unit/Listener/JsonApiEnabledControllerDetectorTraitTest.php b/tests/Unit/Listener/JsonApiEnabledControllerDetectorTraitTest.php index 1d1e83e..30eb0a5 100644 --- a/tests/Unit/Listener/JsonApiEnabledControllerDetectorTraitTest.php +++ b/tests/Unit/Listener/JsonApiEnabledControllerDetectorTraitTest.php @@ -33,6 +33,36 @@ public function testIsJsonApiEnabledInterface() // test some class $this->assertFalse($trait->isJsonApiEnabledControllerTest($this)); + + // test class with invoke that's not JSON API enabled + $invokableClass = new class() { + public function __invoke() + { + } + }; + + $this->assertFalse($trait->isJsonApiEnabledControllerTest($invokableClass)); + + // test class with invoke that's'JSON API enabled + $invokableClass = new class() implements JsonApiEnabledInterface { + public function __invoke() + { + } + + public function getSchemaClassMapProvider() + { + } + + public function getJsonApiConfig(): ConfigInterface + { + } + + public function setJsonApiConfig(ConfigInterface $config) + { + } + }; + + $this->assertTrue($trait->isJsonApiEnabledControllerTest($invokableClass)); } public function testResolveControllerFromEventController() @@ -51,6 +81,15 @@ public function testResolveControllerFromEventController() $this->assertNull($trait->resolveControllerFromEventControllerTest(function () use ($controller) { return $controller; })); + + // test class with invoke + $invokableClass = new class() { + public function __invoke() + { + } + }; + + $this->assertEquals($invokableClass, $trait->resolveControllerFromEventControllerTest($invokableClass)); } } diff --git a/tests/Unit/Response/CreatedResponseTest.php b/tests/Unit/Response/CreatedResponseTest.php new file mode 100644 index 0000000..4531798 --- /dev/null +++ b/tests/Unit/Response/CreatedResponseTest.php @@ -0,0 +1,49 @@ +assertInstanceOf(CreatedResponse::class, $response); + } + + public function testCreatingCreatedResponseWithLocation() + { + $location = 'foobar'; + + $response = new CreatedResponse(new stdClass(), [], [], $location); + + $headers = $response->getHeaders(); + + $this->assertCount(1, $headers); + + $this->assertSame($response->getStatusCode(), Response::HTTP_CREATED); + $this->assertSame('Location', $headers[0]->getKey()); + $this->assertSame($location, $headers[0]->getValue()); + } + + public function testCreatingCreatedResponseWithHeader() + { + $response = new CreatedResponse(new stdClass(), [], [], null, [new Header('x-test', 'bar')]); + + $headers = $response->getHeaders(); + + $this->assertCount(1, $headers); + + $this->assertSame($response->getStatusCode(), Response::HTTP_CREATED); + $this->assertSame('x-test', $headers[0]->getKey()); + $this->assertSame('bar', $headers[0]->getValue()); + } +} diff --git a/tests/Unit/ResponseFactoryServiceTest.php b/tests/Unit/ResponseFactoryServiceTest.php index ef7171b..82d5c63 100644 --- a/tests/Unit/ResponseFactoryServiceTest.php +++ b/tests/Unit/ResponseFactoryServiceTest.php @@ -4,13 +4,14 @@ use Exception; use Neomerx\JsonApi\Contracts\Document\ErrorInterface; -use PHPUnit_Framework_MockObject_MockObject; use PHPUnit_Framework_TestCase; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Trikoder\JsonApiBundle\Contracts\ErrorFactoryInterface; +use Trikoder\JsonApiBundle\Services\JsonApiResponseLinter; use Trikoder\JsonApiBundle\Services\Neomerx\EncoderService; use Trikoder\JsonApiBundle\Services\ResponseFactoryService; +use Trikoder\JsonApiBundle\Services\ResponseLinterInterface; /** */ @@ -23,7 +24,8 @@ public function testcreateErrorFromException() { $responseFactoryService = new ResponseFactoryService( $this->createEncoderServiceMock(), - $this->createErrorFactoryMock() + $this->createErrorFactoryMock(), + $this->createResponseLinterMock() ); $response = $responseFactoryService->createErrorFromException( @@ -32,7 +34,7 @@ public function testcreateErrorFromException() $this->assertInstanceOf(Response::class, $response); $this->assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); - $this->assertSame('application/json', $response->headers->get('Content-type')); + $this->assertSame(JsonApiResponseLinter::CONTENT_TYPE, $response->headers->get('Content-type')); $this->assertSame('Normal exception', $response->getContent()); } @@ -43,7 +45,8 @@ public function testcreateErrorFromExceptionCustomResponse() { $responseFactoryService = new ResponseFactoryService( $this->createEncoderServiceMock(), - $this->createErrorFactoryMock() + $this->createErrorFactoryMock(), + $this->createResponseLinterMock() ); $customResponse = new Response(); @@ -57,7 +60,7 @@ public function testcreateErrorFromExceptionCustomResponse() $this->assertSame($customResponse, $response); $this->assertInstanceOf(Response::class, $customResponse); $this->assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $customResponse->getStatusCode()); - $this->assertSame('application/json', $customResponse->headers->get('Content-type')); + $this->assertSame(JsonApiResponseLinter::CONTENT_TYPE, $customResponse->headers->get('Content-type')); $this->assertSame('Normal exception', $customResponse->getContent()); $this->assertSame('Bar', $customResponse->headers->get('X-Foo')); } @@ -69,7 +72,8 @@ public function testcreateErrorFromHttpException() { $responseFactoryService = new ResponseFactoryService( $this->createEncoderServiceMock(), - $this->createErrorFactoryMock() + $this->createErrorFactoryMock(), + $this->createResponseLinterMock() ); $response = $responseFactoryService->createErrorFromException( @@ -78,7 +82,7 @@ public function testcreateErrorFromHttpException() $this->assertInstanceOf(Response::class, $response); $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); - $this->assertSame('application/json', $response->headers->get('Content-type')); + $this->assertSame(JsonApiResponseLinter::CONTENT_TYPE, $response->headers->get('Content-type')); $this->assertSame('Access denied', $response->getContent()); $this->assertSame('Bearer', $response->headers->get('WWW-Authenticate')); } @@ -90,7 +94,8 @@ public function testcreateErrorFromHttpExceptionCustomResponse() { $responseFactoryService = new ResponseFactoryService( $this->createEncoderServiceMock(), - $this->createErrorFactoryMock() + $this->createErrorFactoryMock(), + $this->createResponseLinterMock() ); $customResponse = new Response(); @@ -104,14 +109,14 @@ public function testcreateErrorFromHttpExceptionCustomResponse() $this->assertSame($customResponse, $response); $this->assertInstanceOf(Response::class, $customResponse); $this->assertSame(Response::HTTP_UNAUTHORIZED, $customResponse->getStatusCode()); - $this->assertSame('application/json', $customResponse->headers->get('Content-type')); + $this->assertSame(JsonApiResponseLinter::CONTENT_TYPE, $customResponse->headers->get('Content-type')); $this->assertSame('Access denied', $customResponse->getContent()); $this->assertSame('Bearer', $customResponse->headers->get('WWW-Authenticate')); $this->assertSame('Bar', $customResponse->headers->get('X-Foo')); } /** - * @return PHPUnit_Framework_MockObject_MockObject + * @return EncoderService */ private function createEncoderServiceMock() { @@ -127,17 +132,17 @@ private function createEncoderServiceMock() return 'error' === reset($errors)->getId(); }) ) - ->will( - $this->returnCallback(function (array $errors) { + ->willReturnCallback( + function (array $errors) { return reset($errors)->getTitle(); - }) + } ); return $encoderService; } /** - * @return PHPUnit_Framework_MockObject_MockObject + * @return ErrorFactoryInterface */ private function createErrorFactoryMock() { @@ -150,8 +155,8 @@ private function createErrorFactoryMock() return $exception instanceof Exception; }) ) - ->will( - $this->returnCallback(function (Exception $exception) { + ->willReturnCallback( + function (Exception $exception) { $error = $this->createMock(ErrorInterface::class); $error @@ -163,12 +168,22 @@ private function createErrorFactoryMock() ->willReturn($exception->getMessage()); return $error; - }) + } ); return $errorFactory; } + /** + * @return ResponseLinterInterface + */ + private function createResponseLinterMock() + { + $service = new JsonApiResponseLinter(); + + return $service; + } + /** * */ @@ -176,7 +191,8 @@ public function testCreateCreated() { $responseFactoryService = new ResponseFactoryService( $this->createEncoderServiceMock(), - $this->createErrorFactoryMock() + $this->createErrorFactoryMock(), + $this->createResponseLinterMock() ); $response = $responseFactoryService->createCreated( @@ -186,7 +202,7 @@ public function testCreateCreated() $this->assertInstanceOf(Response::class, $response); $this->assertSame(Response::HTTP_CREATED, $response->getStatusCode()); - $this->assertSame('application/json', $response->headers->get('Content-type')); + $this->assertSame(JsonApiResponseLinter::CONTENT_TYPE, $response->headers->get('Content-type')); $this->assertSame('custom.url/test', $response->headers->get('location')); } } diff --git a/tests/Unit/Services/ModelInput/CustomFormModelInputHandlerTest.php b/tests/Unit/Services/ModelInput/CustomFormModelInputHandlerTest.php index 6c18f8a..88620cf 100644 --- a/tests/Unit/Services/ModelInput/CustomFormModelInputHandlerTest.php +++ b/tests/Unit/Services/ModelInput/CustomFormModelInputHandlerTest.php @@ -2,7 +2,7 @@ namespace Trikoder\JsonApiBundle\Tests\Unit\Services\ModelInput; -use Symfony\Bundle\FrameworkBundle\Tests\TestCase; +use PHPUnit\Framework\TestCase; use Symfony\Component\Form\FormInterface; use Trikoder\JsonApiBundle\Services\ModelInput\CustomFormModelInputHandler; diff --git a/tests/Unit/Services/ModelInput/Traits/FormErrorToErrorTransformerTraitTest.php b/tests/Unit/Services/ModelInput/Traits/FormErrorToErrorTransformerTraitTest.php index efe57b4..4339e63 100644 --- a/tests/Unit/Services/ModelInput/Traits/FormErrorToErrorTransformerTraitTest.php +++ b/tests/Unit/Services/ModelInput/Traits/FormErrorToErrorTransformerTraitTest.php @@ -3,10 +3,14 @@ namespace Trikoder\JsonApiBundle\Tests\Unit\Services\ModelInput\Traits; use Neomerx\JsonApi\Document\Error; +use PHPUnit\Framework\TestCase; use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintViolation; use Trikoder\JsonApiBundle\Services\ModelInput\Traits\FormErrorToErrorTransformer; -class FormErrorToErrorTransformerTraitTest extends \PHPUnit_Framework_TestCase +class FormErrorToErrorTransformerTraitTest extends TestCase { public function testFormErrorToError() { @@ -16,12 +20,91 @@ public function testFormErrorToError() $this->assertEquals(Error::class, \get_class($error)); $this->assertEquals('Form error for test', $error->getTitle()); $this->assertEquals('Form error "Form error for test"', $error->getDetail()); + + $this->assertEquals([ + 'pointer' => '/data/attributes/test', + 'parameter' => 'invalid', + ], $error->getSource()); + } + + public function testFormErrorToErrorWithCompositeObjectOrigin() + { + $trait = new FormErrorToErrorTransformerTraitClass(); + $error = $trait->publicFormErrorToError($this->getTestErrorWithCompositeOrigin('Form error for test')); + + $this->assertEquals(Error::class, \get_class($error)); + $this->assertEquals('Form error for test', $error->getTitle()); + $this->assertEquals('Form error "Form error for test"', $error->getDetail()); + + $this->assertEquals([ + 'pointer' => '/data/attributes/rootTest/test', + 'parameter' => 'invalid', + ], $error->getSource()); + } + + public function testGetCodeFromViolation() + { + $trait = new FormErrorToErrorTransformerTraitClass(); + + $nullCodeFromEmptyPayload = $trait->publicCodeFromViolation($this->getTestError('Form error for test', '')); + $nullCodeFromPayloadWithoutCodeKey = $trait->publicCodeFromViolation($this->getTestError('Form error for test', ['key' => 'test'])); + $code = $trait->publicCodeFromViolation($this->getTestError('Form error for test', ['code' => '1000'])); + + $this->assertNull($nullCodeFromEmptyPayload); + $this->assertNull($nullCodeFromPayloadWithoutCodeKey); + $this->assertEquals('1000', $code); + } + + private function getTestError($message, $payload = null) + { + $constraintMock = $this->getMockBuilder(Constraint::class)->disableOriginalConstructor()->getMock(); + $constraintMock->payload = $payload; + + $formElementMock = $this->getMockBuilder(FormInterface::class)->getMock(); + $formElementMock->method('getName')->willReturn('test'); + $formElementMock->method('isRoot')->willReturn(true); + + $constraintValidationMock = $this->getMockBuilder(ConstraintViolation::class)->disableOriginalConstructor()->getMock(); + $constraintValidationMock->method('getConstraint')->willReturn($constraintMock); + $constraintValidationMock->method('getPropertyPath')->willReturn('data.test'); + $constraintValidationMock->method('getInvalidValue')->willReturn('invalid'); + + $violationMock = $this->getMockBuilder(FormError::class)->disableOriginalConstructor()->getMock(); + $violationMock->method('getMessage')->willReturn($message); + $violationMock->method('getOrigin')->willReturn($formElementMock); + $violationMock->method('getCause')->willReturn($constraintValidationMock); + + return $violationMock; } - private function getTestError($message) + private function getTestErrorWithCompositeOrigin($message, $payload = null) { + $constraintMock = $this->getMockBuilder(Constraint::class)->disableOriginalConstructor()->getMock(); + $constraintMock->payload = $payload; + + $resourceFormElementMock = $this->getMockBuilder(FormInterface::class)->getMock(); + $resourceFormElementMock->method('getName')->willReturn('resourceTest'); + $resourceFormElementMock->method('isRoot')->willReturn(true); + + $parentFormElementMock = $this->getMockBuilder(FormInterface::class)->getMock(); + $parentFormElementMock->method('getName')->willReturn('rootTest'); + $parentFormElementMock->method('getParent')->willReturn($resourceFormElementMock); + $parentFormElementMock->method('isRoot')->willReturn(false); + + $formElementMock = $this->getMockBuilder(FormInterface::class)->getMock(); + $formElementMock->method('getName')->willReturn('test'); + $formElementMock->method('isRoot')->willReturn(false); + $formElementMock->method('getParent')->willReturn($parentFormElementMock); + + $constraintValidationMock = $this->getMockBuilder(ConstraintViolation::class)->disableOriginalConstructor()->getMock(); + $constraintValidationMock->method('getConstraint')->willReturn($constraintMock); + $constraintValidationMock->method('getPropertyPath')->willReturn('data.test'); + $constraintValidationMock->method('getInvalidValue')->willReturn('invalid'); + $violationMock = $this->getMockBuilder(FormError::class)->disableOriginalConstructor()->getMock(); $violationMock->method('getMessage')->willReturn($message); + $violationMock->method('getOrigin')->willReturn($formElementMock); + $violationMock->method('getCause')->willReturn($constraintValidationMock); return $violationMock; } @@ -35,4 +118,9 @@ public function publicFormErrorToError($violation) { return $this->convertFormErrorToError($violation); } + + public function publicCodeFromViolation($violation) + { + return $this->getCodeFromViolation($violation); + } } diff --git a/tests/Unit/Services/Neomerx/ContainerAutowiringTest.php b/tests/Unit/Services/Neomerx/ContainerAutowiringTest.php index 6b2a51f..7e131a0 100644 --- a/tests/Unit/Services/Neomerx/ContainerAutowiringTest.php +++ b/tests/Unit/Services/Neomerx/ContainerAutowiringTest.php @@ -18,20 +18,20 @@ class ContainerAutowiringTest extends \PHPUnit_Framework_TestCase private function getServiceContainer() { $serviceContainer = $this->getMockBuilder(ContainerInterface::class)->disableOriginalConstructor()->getMock(); - $serviceContainer->method('has')->will($this->returnCallback(function ($service) { + $serviceContainer->method('has')->willReturnCallback(function ($service) { switch ($service) { case RouterInterface::class: return true; break; } - })); - $serviceContainer->method('get')->will($this->returnCallback(function ($service) { + }); + $serviceContainer->method('get')->willReturnCallback(function ($service) { switch ($service) { case RouterInterface::class: return $this->createMock(RouterInterface::class); break; } - })); + }); return $serviceContainer; } diff --git a/tests/Unit/Services/RequestBodyDecoderServiceTest.php b/tests/Unit/Services/RequestBodyDecoderServiceTest.php index a29eef5..48f45eb 100644 --- a/tests/Unit/Services/RequestBodyDecoderServiceTest.php +++ b/tests/Unit/Services/RequestBodyDecoderServiceTest.php @@ -3,13 +3,12 @@ namespace Trikoder\JsonApiBundle\Tests\Unit\Services; use Trikoder\JsonApiBundle\Services\RequestDecoder\RequestBodyDecoderService; -use Trikoder\JsonApiBundle\Services\RequestDecoder\RequestBodyValidator; class RequestBodyDecoderServiceTest extends \PHPUnit_Framework_TestCase { protected function createService() { - return new RequestBodyDecoderService(new RequestBodyValidator()); + return new RequestBodyDecoderService(); } /** diff --git a/tests/Unit/Services/RequestBodyValidatorTest.php b/tests/Unit/Services/RequestBodyValidatorTest.php index 6ad3689..49b6526 100644 --- a/tests/Unit/Services/RequestBodyValidatorTest.php +++ b/tests/Unit/Services/RequestBodyValidatorTest.php @@ -4,55 +4,47 @@ namespace Trikoder\JsonApiBundle\Tests\Unit\Services; -use Trikoder\JsonApiBundle\Services\RequestDecoder\Exception\InvalidBodyForMethodException; -use Trikoder\JsonApiBundle\Services\RequestDecoder\RequestBodyValidator; +use Prophecy\Argument; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Validator\ValidatorInterface; +use Trikoder\JsonApiBundle\Services\RequestDecoder\SymfonyValidatorAdapter; final class RequestBodyValidatorTest extends \PHPUnit_Framework_TestCase { - public function testExceptionForEmptyPostBody() + public function testReturnsEmptyViolationListWithoutDoingValidationIfPrimaryDataIsNull() { - $this->expectException(InvalidBodyForMethodException::class); - $this->expectExceptionMessage('Passed body is not valid for request method POST'); - $this->createService()->validate('POST', []); - } - - protected function createService() - { - return new RequestBodyValidator(); - } - - public function testNullDataBody() - { - $testData = [ - 'data' => null, - ]; + $symfonyValidator = $this->prophesize(ValidatorInterface::class); + $validator = $this->createService($symfonyValidator->reveal()); - $result = $this->createService()->validate('POST', $testData); + $result = $validator->validate(['data' => null]); - $this->assertNull($result); + $this->assertCount(0, $result); + $symfonyValidator->validate()->shouldNotHaveBeenCalled(); } - public function testExceptionForDataWithoutTypePostBody() + public function testValidationIsBeingPerformedWhenValidInputIsProvided() { $testData = [ 'data' => [ - 'id' => 1, + 'id' => 'foo', + 'type' => 'bar', ], ]; - $this->expectException(InvalidBodyForMethodException::class); - $this->expectExceptionMessage('Passed body is not valid for request method POST'); - $this->createService()->validate('POST', $testData); + $symfonyValidator = $this->prophesize(ValidatorInterface::class); + + $validator = $this->createService($symfonyValidator->reveal()); + + $validator->validate($testData); + + $symfonyValidator->validate( + Argument::type('array'), + Argument::type(Constraint::class) + )->shouldHaveBeenCalledOnce(); } - public function testValidPostBody() + protected function createService(ValidatorInterface $validator) { - $testData = [ - 'data' => [ - 'type' => 'ok', - ], - ]; - - $this->assertNull($this->createService()->validate('POST', $testData)); + return new SymfonyValidatorAdapter($validator); } } diff --git a/tests/Unit/Services/RequestDecoder/RelationshipRequestBodyDecoderTest.php b/tests/Unit/Services/RequestDecoder/RelationshipRequestBodyDecoderTest.php new file mode 100644 index 0000000..cd82ff6 --- /dev/null +++ b/tests/Unit/Services/RequestDecoder/RelationshipRequestBodyDecoderTest.php @@ -0,0 +1,56 @@ + null, + ], + [], + ], + [ + [ + 'data' => [ + [ + 'type' => 'author', + 'id' => '123', + ], + ], + ], + [ + [ + 'type' => 'author', + 'id' => '123', + ], + ], + ], + ]; + } + + /** + * @dataProvider dataProvider + */ + public function testRelationships(array $body, $expected) + { + $bodyDecoder = new RelationshipRequestBodyDecoder(); + + $this->assertEquals($expected, $bodyDecoder->decode('', $body)); + } +} diff --git a/tests/Unit/Services/RequestDecoderTest.php b/tests/Unit/Services/RequestDecoderTest.php index f84ac60..4af4979 100644 --- a/tests/Unit/Services/RequestDecoderTest.php +++ b/tests/Unit/Services/RequestDecoderTest.php @@ -2,16 +2,21 @@ namespace Trikoder\JsonApiBundle\Tests\Unit\Services; +use Exception; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Validator\ConstraintViolationList; use Trikoder\JsonApiBundle\Config\ApiConfig; use Trikoder\JsonApiBundle\Config\Config; use Trikoder\JsonApiBundle\Config\CreateConfig; use Trikoder\JsonApiBundle\Config\DeleteConfig; use Trikoder\JsonApiBundle\Config\IndexConfig; use Trikoder\JsonApiBundle\Config\UpdateConfig; +use Trikoder\JsonApiBundle\Config\UpdateRelationshipConfig; use Trikoder\JsonApiBundle\Contracts\RequestBodyDecoderInterface; +use Trikoder\JsonApiBundle\Contracts\RequestBodyValidatorInterface; use Trikoder\JsonApiBundle\Controller\JsonApiEnabledInterface; use Trikoder\JsonApiBundle\Model\ModelFactoryInterface; use Trikoder\JsonApiBundle\Services\Neomerx\FactoryService; @@ -27,12 +32,26 @@ public function testMultipleValueFilter() { // prepare mocked config and controller $controller = $this->getMockBuilder(JsonApiEnabledInterface::class)->disableOriginalConstructor()->getMock(); + $requestBodyValidator = $this->getRequestBodyValidatorMock(); + $requestBodyValidator->method('validate')->willReturn(new ConstraintViolationList()); + $relationshipBodyValidator = $this->getRelationshipRequestBodyValidatorMock(); + $relationshipBodyDecoder = $this->getRelationshipRequestBodyDecoderMock(); $apiConfig = new Config( - new ApiConfig("\stdClass", null, null, null, $this->getRequestBodyDecoderMock(), false), + new ApiConfig( + "\stdClass", + null, + null, + null, $this->getRequestBodyDecoderMock(), + false, + $requestBodyValidator, + $relationshipBodyValidator->reveal(), + $relationshipBodyDecoder->reveal() + ), new CreateConfig($this->getModelFactoryMock()), new IndexConfig(), new UpdateConfig(), - new DeleteConfig() + new DeleteConfig(), + new UpdateRelationshipConfig() ); $controller->method('getJsonApiConfig')->willReturn($apiConfig); @@ -60,12 +79,27 @@ public function testMultipleValueFields() { // prepare mocked config and controller $controller = $this->getMockBuilder(JsonApiEnabledInterface::class)->disableOriginalConstructor()->getMock(); + $requestBodyValidator = $this->getRequestBodyValidatorMock(); + $requestBodyValidator->method('validate')->willReturn(new ConstraintViolationList()); + $relationshipBodyValidator = $this->getRelationshipRequestBodyValidatorMock(); + $relationshipBodyDecoder = $this->getRelationshipRequestBodyDecoderMock(); + $apiConfig = new Config( - new ApiConfig("\stdClass", null, null, null, $this->getRequestBodyDecoderMock(), false), + new ApiConfig( + "\stdClass", + null, + null, + null, $this->getRequestBodyDecoderMock(), + false, + $requestBodyValidator, + $relationshipBodyValidator->reveal(), + $relationshipBodyDecoder->reveal() + ), new CreateConfig($this->getModelFactoryMock()), new IndexConfig(), new UpdateConfig(), - new DeleteConfig() + new DeleteConfig(), + new UpdateRelationshipConfig() ); $controller->method('getJsonApiConfig')->willReturn($apiConfig); @@ -89,16 +123,184 @@ public function testMultipleValueFields() ], $result->query->get('fields')); } + public function testLimitedFields() + { + // prepare mocked config and controller + $controller = $this->getMockBuilder(JsonApiEnabledInterface::class)->disableOriginalConstructor()->getMock(); + $requestBodyValidator = $this->getRequestBodyValidatorMock(); + $requestBodyValidator->method('validate')->willReturn(new ConstraintViolationList()); + $relationshipBodyValidator = $this->getRelationshipRequestBodyValidatorMock(); + $relationshipBodyDecoder = $this->getRelationshipRequestBodyDecoderMock(); + + $apiConfig = new Config( + new ApiConfig( + "\stdClass", + null, + null, + null, $this->getRequestBodyDecoderMock(), + false, + $requestBodyValidator, + $relationshipBodyValidator->reveal(), + $relationshipBodyDecoder->reveal() + ), + new CreateConfig($this->getModelFactoryMock()), + new IndexConfig(null, ['allowed']), + new UpdateConfig(), + new DeleteConfig(), + new UpdateRelationshipConfig() + ); + $controller->method('getJsonApiConfig')->willReturn($apiConfig); + + $requestDecoder = $this->getRequestDecoder($controller); + + // test single value + $requestParameters = []; + parse_str('filter[allowed]=yes', $requestParameters); + $request = new Request($requestParameters); + $result = $requestDecoder->decode($request); + $this->assertEquals([ + 'allowed' => 'yes', + ], $result->query->get('filter')); + + // test invalid because there is limitation to fields + $requestParameters = []; + parse_str('filter[forbidden]=no', $requestParameters); + $request = new Request($requestParameters); + // TODO expect exception + $this->expectException(BadRequestHttpException::class); + $requestDecoder->decode($request); + } + + public function testMultipleLevelFilterWithEmptyConfig() + { + // prepare mocked config and controller + $controller = $this->getMockBuilder(JsonApiEnabledInterface::class)->disableOriginalConstructor()->getMock(); + $requestBodyValidator = $this->getRequestBodyValidatorMock(); + $requestBodyValidator->method('validate')->willReturn(new ConstraintViolationList()); + $relationshipBodyValidator = $this->getRelationshipRequestBodyValidatorMock(); + $relationshipBodyDecoder = $this->getRelationshipRequestBodyDecoderMock(); + + $apiConfig = new Config( + new ApiConfig( + "\stdClass", + null, + null, + null, $this->getRequestBodyDecoderMock(), + false, + $requestBodyValidator, + $relationshipBodyValidator->reveal(), + $relationshipBodyDecoder->reveal() + ), + new CreateConfig($this->getModelFactoryMock()), + new IndexConfig(), + new UpdateConfig(), + new DeleteConfig(), + new UpdateRelationshipConfig() + ); + $controller->method('getJsonApiConfig')->willReturn($apiConfig); + + $requestDecoder = $this->getRequestDecoder($controller); + + // test single value + $requestParameters = []; + parse_str('filter[image][variation]=12', $requestParameters); + $request = new Request($requestParameters); + $result = $requestDecoder->decode($request); + $this->assertEquals([ + 'image' => [ + 'variation' => 12, + ], + ], $result->query->get('filter')); + + // test simple case + $requestParameters = []; + parse_str('filter[image][variation]=12,13&filter[image][gallery]=13&filter[active]=1', $requestParameters); + $request = new Request($requestParameters); + $result = $requestDecoder->decode($request); + $this->assertEquals([ + 'image' => [ + 'variation' => [12, 13], + 'gallery' => 13, + ], + 'active' => 1, + ], $result->query->get('filter')); + } + + public function testMultipleLevelFilterWithLimitedFields() + { + // prepare mocked config and controller + $controller = $this->getMockBuilder(JsonApiEnabledInterface::class)->disableOriginalConstructor()->getMock(); + $requestBodyValidator = $this->getRequestBodyValidatorMock(); + $requestBodyValidator->method('validate')->willReturn(new ConstraintViolationList()); + $relationshipBodyValidator = $this->getRelationshipRequestBodyValidatorMock(); + $relationshipBodyDecoder = $this->getRelationshipRequestBodyDecoderMock(); + + $apiConfig = new Config( + new ApiConfig( + "\stdClass", + null, + null, + null, $this->getRequestBodyDecoderMock(), + false, + $requestBodyValidator, + $relationshipBodyValidator->reveal(), + $relationshipBodyDecoder->reveal() + ), + new CreateConfig($this->getModelFactoryMock()), + /* TODO limited fields options is currently limited to root level, should add support for sub level i.e. image.gallery */ + new IndexConfig(null, ['image', 'active']), + new UpdateConfig(), + new DeleteConfig(), + new UpdateRelationshipConfig() + ); + $controller->method('getJsonApiConfig')->willReturn($apiConfig); + + $requestDecoder = $this->getRequestDecoder($controller); + + // test single value + $requestParameters = []; + parse_str('filter[image][gallery]=12', $requestParameters); + $request = new Request($requestParameters); + $result = $requestDecoder->decode($request); + $this->assertEquals([ + 'image' => [ + 'gallery' => 12, + ], + ], $result->query->get('filter')); + + // test invalid because there is limitation to fields + $requestParameters = []; + parse_str('filter[image][variation]=12,13&filter[photo][gallery]=13&filter[active]=1', $requestParameters); + $request = new Request($requestParameters); + // TODO expect exception + $this->expectException(BadRequestHttpException::class); + $requestDecoder->decode($request); + } + public function testPerservationOfProperties() { // prepare mocked config and controller $controller = $this->getMockBuilder(JsonApiEnabledInterface::class)->disableOriginalConstructor()->getMock(); + $requestBodyValidator = $this->getRequestBodyValidatorMock(); + $requestBodyValidator->method('validate')->willReturn(new ConstraintViolationList()); + $relationshipBodyValidator = $this->getRelationshipRequestBodyValidatorMock(); + $relationshipBodyDecoder = $this->getRelationshipRequestBodyDecoderMock(); $apiConfig = new Config( - new ApiConfig("\stdClass", null, null, null, $this->getRequestBodyDecoderMock(), false), + new ApiConfig( + "\stdClass", + null, + null, + null, $this->getRequestBodyDecoderMock(), + false, + $requestBodyValidator, + $relationshipBodyValidator->reveal(), + $relationshipBodyDecoder->reveal() + ), new CreateConfig($this->getModelFactoryMock()), new IndexConfig(), new UpdateConfig(), - new DeleteConfig() + new DeleteConfig(), + new UpdateRelationshipConfig() ); $controller->method('getJsonApiConfig')->willReturn($apiConfig); @@ -127,7 +329,7 @@ private function getFactoryServiceMock() $logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(); $container = $this->getMockBuilder(ServiceContainer::class)->disableOriginalConstructor()->getMock(); $container->method('has')->willReturn(true); - $container->method('get')->will($this->returnCallback(function (...$args) { + $container->method('get')->willReturnCallback(function (...$args) { switch ($args[0]) { case 'logger': return $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(); @@ -135,7 +337,7 @@ private function getFactoryServiceMock() } return null; - })); + }); $factory = new FactoryService($container, $logger); return $factory; @@ -157,6 +359,23 @@ private function getRequestBodyDecoderMock() return $this->getMockBuilder(RequestBodyDecoderInterface::class)->disableOriginalConstructor()->getMock(); } + private function getRequestBodyValidatorMock() + { + return $this->getMockBuilder(RequestBodyValidatorInterface::class) + ->disableOriginalConstructor() + ->getMock(); + } + + private function getRelationshipRequestBodyValidatorMock() + { + return $this->prophesize(RequestBodyValidatorInterface::class); + } + + private function getRelationshipRequestBodyDecoderMock() + { + return $this->prophesize(RequestBodyDecoderInterface::class); + } + /** * @return RequestDecoder */