├── var ├── cache │ └── .gitkeep ├── logs │ └── .gitkeep └── sessions │ └── .gitkeep ├── web ├── favicon.ico ├── apple-touch-icon.png ├── robots.txt ├── app.php ├── app_dev.php ├── app_acceptance.php └── .htaccess ├── features ├── Dummy │ └── Image │ │ ├── pk140.jpg │ │ └── phit200x100.png └── bootstrap │ └── FeatureContext.php ├── app ├── AppCache.php ├── serializer │ └── FOSUB │ │ └── Model.User.yml ├── .htaccess ├── config │ ├── routing.yml │ ├── config_test.yml │ ├── routing_dev.yml │ ├── routing_api.yml │ ├── config_acceptance.yml │ ├── config_prod.yml │ ├── config_dev.yml │ ├── parameters.yml.dist │ ├── security.yml │ ├── config.yml │ └── services.yml ├── autoload.php ├── Resources │ └── views │ │ ├── base.html.twig │ │ └── default │ │ └── index.html.twig └── AppKernel.php ├── src ├── .htaccess └── AppBundle │ ├── AppBundle.php │ ├── DTO │ ├── SymfonyFormDTO.php │ ├── DTOInterface.php │ ├── DTOConvertableInterface.php │ ├── DTOUpdatableInterface.php │ ├── AccountDTO.php │ └── FileDTO.php │ ├── Form │ ├── Type │ │ ├── UserType.php │ │ ├── FileType.php │ │ └── AccountType.php │ ├── Handler │ │ ├── FormHandlerInterface.php │ │ └── FormHandler.php │ └── Transformer │ │ ├── ManyEntityToIdObjectTransformer.php │ │ └── EntityToIdObjectTransformer.php │ ├── Util │ ├── FilesystemInterface.php │ └── UploadFilesystem.php │ ├── Repository │ ├── RepositoryInterface.php │ ├── UserRepositoryInterface.php │ ├── Restricted │ │ ├── RestrictedRepository.php │ │ ├── RestrictedUserRepository.php │ │ ├── RestrictedAccountRepository.php │ │ └── RestrictedFileRepository.php │ ├── AccountRepositoryInterface.php │ ├── FileRepositoryInterface.php │ └── Doctrine │ │ ├── CommonDoctrineRepository.php │ │ ├── DoctrineUserRepository.php │ │ ├── DoctrineAccountRepository.php │ │ └── DoctrineFileRepository.php │ ├── Security │ └── Authorization │ │ ├── UserOwnableInterface.php │ │ └── Voter │ │ ├── UserVoter.php │ │ ├── FileVoter.php │ │ └── AccountVoter.php │ ├── Exception │ ├── HttpContentTypeException.php │ └── InvalidFormException.php │ ├── Controller │ ├── RestLoginController.php │ ├── UsersController.php │ ├── AccountsController.php │ └── FilesController.php │ ├── Factory │ ├── AccountFactoryInterface.php │ ├── FileFactoryInterface.php │ ├── AccountFactory.php │ └── FileFactory.php │ ├── Entity │ ├── Repository │ │ ├── UserEntityRepository.php │ │ ├── AccountEntityRepository.php │ │ └── FileEntityRepository.php │ ├── User.php │ ├── Account.php │ └── File.php │ ├── Handler │ ├── FileHandlerInterface.php │ ├── HandlerInterface.php │ ├── UserHandler.php │ ├── AccountHandler.php │ └── FileHandler.php │ ├── Model │ ├── UploadedFileInterface.php │ ├── UserInterface.php │ ├── FileInterface.php │ ├── AccountInterface.php │ └── UploadedFile.php │ ├── DataTransformer │ ├── FileDataTransformer.php │ └── AccountDataTransformer.php │ ├── Features │ ├── developer_experience.feature │ ├── Context │ │ ├── FileStorageContext.php │ │ ├── MysqlDatabaseContext.php │ │ ├── UserSetupContext.php │ │ ├── FileSetupContext.php │ │ └── AccountSetupContext.php │ ├── user.feature │ └── file.feature │ └── Event │ └── Listener │ └── ContentTypeListener.php ├── .gitignore ├── spec └── AppBundle │ ├── Entity │ └── UserSpec.php │ ├── Form │ ├── Type │ │ └── UserTypeSpec.php │ └── Handler │ │ └── FormHandlerSpec.php │ ├── Exception │ ├── InvalidFormExceptionSpec.php │ └── HttpContentTypeExceptionSpec.php │ ├── Repository │ ├── Doctrine │ │ └── UserRepositorySpec.php │ └── Restricted │ │ └── UserRepositorySpec.php │ ├── Security │ └── Authorisation │ │ └── Voter │ │ └── UserVoterSpec.php │ ├── Handler │ └── UserHandlerSpec.php │ └── Event │ └── Listener │ └── ContentTypeListenerSpec.php ├── tests └── AppBundle │ └── Controller │ └── DefaultControllerTest.php ├── bin ├── console └── symfony_requirements ├── phpunit.xml.dist ├── behat.yml ├── composer.json └── README.md /var/cache/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /var/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /var/sessions/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codereviewvideos/symfony-3-rest-api-example/HEAD/web/favicon.ico -------------------------------------------------------------------------------- /web/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codereviewvideos/symfony-3-rest-api-example/HEAD/web/apple-touch-icon.png -------------------------------------------------------------------------------- /web/robots.txt: -------------------------------------------------------------------------------- 1 | # www.robotstxt.org/ 2 | # www.google.com/support/webmasters/bin/answer.py?hl=en&answer=156449 3 | 4 | User-agent: * 5 | -------------------------------------------------------------------------------- /features/Dummy/Image/pk140.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codereviewvideos/symfony-3-rest-api-example/HEAD/features/Dummy/Image/pk140.jpg -------------------------------------------------------------------------------- /app/AppCache.php: -------------------------------------------------------------------------------- 1 | 2 | Require all denied 3 | 4 | 5 | Order deny,allow 6 | Deny from all 7 | 8 | -------------------------------------------------------------------------------- /src/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | Require all denied 3 | 4 | 5 | Order deny,allow 6 | Deny from all 7 | 8 | -------------------------------------------------------------------------------- /src/AppBundle/AppBundle.php: -------------------------------------------------------------------------------- 1 | shouldHaveType('AppBundle\Entity\User'); 13 | $this->shouldImplement('AppBundle\Model\UserInterface'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/AppBundle/Repository/RepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Welcome!{% endblock %} 6 | {% block stylesheets %}{% endblock %} 7 | 8 | 9 | 10 | {% block body %}{% endblock %} 11 | {% block javascripts %}{% endblock %} 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/AppBundle/Exception/HttpContentTypeException.php: -------------------------------------------------------------------------------- 1 | beConstructedWith(['Some\Form\Class']); 13 | } 14 | 15 | function it_is_initializable() 16 | { 17 | $this->shouldHaveType('AppBundle\Form\Type\UserType'); 18 | $this->shouldHaveType('FOS\UserBundle\Form\Type\ProfileFormType'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/AppBundle/Entity/Repository/UserEntityRepository.php: -------------------------------------------------------------------------------- 1 | true]) 14 | { 15 | $this->_em->persist($user); 16 | 17 | if ($options['flush'] === true) { 18 | $this->_em->flush(); 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/AppBundle/Handler/FileHandlerInterface.php: -------------------------------------------------------------------------------- 1 | request('GET', '/'); 14 | 15 | $this->assertEquals(200, $client->getResponse()->getStatusCode()); 16 | $this->assertContains('Welcome to Symfony', $crawler->filter('#container h1')->text()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/AppBundle/Repository/UserRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | true]); 24 | } -------------------------------------------------------------------------------- /app/config/config_acceptance.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: config_test.yml } 3 | 4 | framework: 5 | profiler: 6 | only_exceptions: false 7 | collect: true 8 | 9 | web_profiler: 10 | toolbar: true 11 | 12 | doctrine: 13 | dbal: 14 | dbname: "%database_name%_acceptance" 15 | 16 | csa_guzzle: 17 | logger: true 18 | clients: 19 | local_test_api: 20 | config: 21 | base_url: http://symfony-rest-example.dev/app_acceptance.php/ 22 | 23 | # Oneup Flysystem 24 | oneup_flysystem: 25 | adapters: 26 | local_adapter: 27 | local: 28 | directory: %kernel.root_dir%/../features/uploadTemp -------------------------------------------------------------------------------- /src/AppBundle/Exception/InvalidFormException.php: -------------------------------------------------------------------------------- 1 | form = $form; 20 | } 21 | 22 | /** 23 | * @return array|null 24 | */ 25 | public function getForm() 26 | { 27 | return $this->form; 28 | } 29 | } -------------------------------------------------------------------------------- /src/AppBundle/Model/UploadedFileInterface.php: -------------------------------------------------------------------------------- 1 | shouldHaveType('AppBundle\Exception\InvalidFormException'); 14 | } 15 | 16 | function it_should_have_a_sensible_default_error_message() 17 | { 18 | $this->getMessage()->shouldReturn(InvalidFormException::DEFAULT_ERROR_MESSAGE); 19 | } 20 | 21 | function it_should_be_able_to_return_the_underlying_form() 22 | { 23 | $this->beConstructedWith(['a']); 24 | 25 | $this->getForm()->shouldReturn(['a']); 26 | } 27 | } -------------------------------------------------------------------------------- /spec/AppBundle/Exception/HttpContentTypeExceptionSpec.php: -------------------------------------------------------------------------------- 1 | shouldHaveType('AppBundle\Exception\HttpContentTypeException'); 14 | $this->shouldHaveType('Symfony\Component\HttpKernel\Exception\HttpException'); 15 | } 16 | 17 | function it_should_have_a_sensible_default_error_message() 18 | { 19 | $this->getMessage()->shouldReturn(HttpContentTypeException::ERROR_MESSAGE); 20 | } 21 | 22 | function it_should_have_the_correct_error_code() 23 | { 24 | $this->getStatusCode()->shouldReturn(HttpContentTypeException::ERROR_CODE); 25 | } 26 | } -------------------------------------------------------------------------------- /src/AppBundle/Entity/Repository/AccountEntityRepository.php: -------------------------------------------------------------------------------- 1 | getEntityManager() 18 | ->createQueryBuilder() 19 | ->select('a') 20 | ->from('AppBundle\Entity\Account', 'a') 21 | ->join('a.users', 'u') 22 | ->where('u.id = :userId') 23 | ->setParameter('userId', $user->getId()) 24 | ->getQuery(); 25 | 26 | return $query->getResult(); 27 | } 28 | } -------------------------------------------------------------------------------- /src/AppBundle/Factory/AccountFactory.php: -------------------------------------------------------------------------------- 1 | getName()); 26 | 27 | foreach ($accountDTO->getUsers() as $user) { /** @var $user \AppBundle\Model\UserInterface */ 28 | $user->addAccount($account); 29 | } 30 | 31 | return $account; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/AppBundle/DataTransformer/FileDataTransformer.php: -------------------------------------------------------------------------------- 1 | setName($file->getDisplayedFileName()); 19 | 20 | return $dto; 21 | } 22 | 23 | /** 24 | * @param FileInterface $file 25 | * @param FileDTO $dto 26 | * @return FileInterface 27 | */ 28 | public function updateFromDTO(FileInterface $file, FileDTO $dto) 29 | { 30 | if ($file->getDisplayedFileName() !== $dto->getName()) { 31 | $file->changeDisplayedFileName($dto->getName()); 32 | } 33 | 34 | return $file; 35 | } 36 | } -------------------------------------------------------------------------------- /src/AppBundle/Model/UserInterface.php: -------------------------------------------------------------------------------- 1 | setName($account->getName()); 16 | $dto->setUsers($account->getUsers()); 17 | 18 | return $dto; 19 | } 20 | 21 | public function updateFromDTO(AccountInterface $account, AccountDTO $dto) 22 | { 23 | if ($account->getName() !== $dto->getName()) { 24 | $account->changeName($dto->getName()); 25 | } 26 | 27 | $account->removeAllUsers(); 28 | 29 | foreach ($dto->getUsers() as $user) { /** @var UserInterface $user */ 30 | $user->addAccount($account); 31 | } 32 | 33 | return $account; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], getenv('SYMFONY_ENV') ?: 'dev'); 21 | $debug = getenv('SYMFONY_DEBUG') !== '0' && !$input->hasParameterOption(['--no-debug', '']) && $env !== 'prod'; 22 | 23 | if ($debug) { 24 | Debug::enable(); 25 | } 26 | 27 | $kernel = new AppKernel($env, $debug); 28 | $application = new Application($kernel); 29 | $application->run($input); 30 | -------------------------------------------------------------------------------- /web/app.php: -------------------------------------------------------------------------------- 1 | unregister(); 18 | $apcLoader->register(true); 19 | */ 20 | 21 | $kernel = new AppKernel('prod', false); 22 | $kernel->loadClassCache(); 23 | //$kernel = new AppCache($kernel); 24 | 25 | // When using the HttpCache, you need to call the method in your front controller instead of relying on the configuration parameter 26 | //Request::enableHttpMethodParameterOverride(); 27 | $request = Request::createFromGlobals(); 28 | $response = $kernel->handle($request); 29 | $response->send(); 30 | $kernel->terminate($request, $response); 31 | -------------------------------------------------------------------------------- /src/AppBundle/Features/developer_experience.feature: -------------------------------------------------------------------------------- 1 | Feature: To improve the developer experience of our API 2 | 3 | In order to offer an API user a better experience 4 | As an API developer 5 | I need to return useful information when situations may be otherwise confusing 6 | 7 | 8 | Background: 9 | Given there are Users with the following details: 10 | | uid | username | email | password | 11 | | u1 | peter | peter@test.com | testpass | 12 | | u2 | john | john@test.org | johnpass | 13 | And I am successfully logged in with username: "peter", and password: "testpass" 14 | And when consuming the endpoint I use the "headers/content-type" of "application/json" 15 | 16 | 17 | Scenario: User must have the right Content-type 18 | When I have forgotten to set the "headers/content-type" 19 | And I send a "PATCH" request to "/users/u1" 20 | Then the response code should be 415 21 | And the response should contain json: 22 | """ 23 | { 24 | "code": 415, 25 | "message": "Invalid or missing Content-type header" 26 | } 27 | """ -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | tests 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | src 27 | 28 | src/*Bundle/Resources 29 | src/*/*Bundle/Resources 30 | src/*/Bundle/*Bundle/Resources 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/AppBundle/Repository/Restricted/RestrictedRepository.php: -------------------------------------------------------------------------------- 1 | authorizationChecker->isGranted($attributes, $object)) { 28 | throw new AccessDeniedHttpException($message); 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/AppBundle/Repository/AccountRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 25 | } 26 | 27 | /** 28 | * @Then the file with internal name: :internalName should have been deleted 29 | */ 30 | public function theFileWithInternalNameShouldHaveBeenDeleted($internalName) 31 | { 32 | $this->filesystem->assertAbsent($internalName); 33 | } 34 | 35 | /** 36 | * @Then the file with internal name: :internalName should not have been deleted 37 | */ 38 | public function theFileWithInternalNameShouldNotHaveBeenDeleted($internalName) 39 | { 40 | $this->filesystem->assertPresent($internalName); 41 | } 42 | } -------------------------------------------------------------------------------- /src/AppBundle/Form/Type/FileType.php: -------------------------------------------------------------------------------- 1 | add('name', TextType::class, [ 21 | 'required' => false, 22 | ]) 23 | ; 24 | 25 | if ($options['has_file']) { 26 | $builder->add('uploadedFile', CoreFileType::class, [ 27 | 'multiple' => false, 28 | ]); 29 | } 30 | } 31 | 32 | public function configureOptions(OptionsResolver $resolver) 33 | { 34 | $resolver->setDefaults([ 35 | 'data_class' => 'AppBundle\DTO\FileDTO', 36 | 'has_file' => true, 37 | ]); 38 | } 39 | 40 | public function getName() 41 | { 42 | return 'file'; 43 | } 44 | } -------------------------------------------------------------------------------- /src/AppBundle/Factory/FileFactory.php: -------------------------------------------------------------------------------- 1 | create( 36 | $uploadedFile->getOriginalFileName(), 37 | $internalFileName, 38 | $uploadedFile->getFileExtension(), 39 | $uploadedFile->getFileSize() 40 | ); 41 | } 42 | } -------------------------------------------------------------------------------- /features/bootstrap/FeatureContext.php: -------------------------------------------------------------------------------- 1 | doctrine = $doctrine; 28 | $this->manager = $doctrine->getManager(); 29 | 30 | $this->schemaTool = new \Doctrine\ORM\Tools\SchemaTool($this->manager); 31 | 32 | $this->classes = $this->manager->getMetadataFactory()->getAllMetadata(); 33 | } 34 | 35 | /** 36 | * @BeforeScenario 37 | */ 38 | public function createSchema() 39 | { 40 | $this->schemaTool->dropSchema($this->classes); 41 | $this->schemaTool->createSchema($this->classes); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /web/app_dev.php: -------------------------------------------------------------------------------- 1 | loadClassCache(); 29 | $request = Request::createFromGlobals(); 30 | $response = $kernel->handle($request); 31 | $response->send(); 32 | $kernel->terminate($request, $response); 33 | -------------------------------------------------------------------------------- /web/app_acceptance.php: -------------------------------------------------------------------------------- 1 | loadClassCache(); 29 | $request = Request::createFromGlobals(); 30 | $response = $kernel->handle($request); 31 | $response->send(); 32 | $kernel->terminate($request, $response); 33 | -------------------------------------------------------------------------------- /src/AppBundle/Handler/HandlerInterface.php: -------------------------------------------------------------------------------- 1 | getRequest(); 17 | 18 | if ($request->headers->contains('Content-type', self::MIME_TYPE_APPLICATION_JSON)) { 19 | return true; 20 | } 21 | 22 | if ($request->getMethod() === Request::METHOD_GET) { 23 | return true; 24 | } 25 | 26 | if ($this->isMultipartFilePost($request)) { 27 | return true; 28 | } 29 | 30 | throw new HttpContentTypeException(); 31 | } 32 | 33 | private function isMultipartFilePost(Request $request) 34 | { 35 | $contentType = $request->headers->get('Content-type'); 36 | 37 | $isMultipart = (strpos($contentType, self::MIME_TYPE_MULTIPART_FORM_DATA) !== false ? true : false); 38 | 39 | if ($isMultipart && $request->get('_route') === 'post_accounts_files') { 40 | return true; 41 | } 42 | 43 | return false; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /spec/AppBundle/Repository/Doctrine/UserRepositorySpec.php: -------------------------------------------------------------------------------- 1 | entityRepository = $entityRepository; 17 | 18 | $this->beConstructedWith($entityRepository); 19 | } 20 | 21 | function it_is_initializable() 22 | { 23 | $this->shouldHaveType('AppBundle\Repository\Doctrine\UserRepository'); 24 | $this->shouldImplement('AppBundle\Repository\UserRepositoryInterface'); 25 | } 26 | 27 | function it_can_find() 28 | { 29 | $id = 24234; 30 | $this->find($id); 31 | $this->entityRepository->find($id)->shouldHaveBeenCalled(); 32 | } 33 | 34 | function it_can_save_a_user_to_the_repository(UserInterface $user) 35 | { 36 | $this->save($user); 37 | $this->entityRepository->save($user, ['flush'=>true])->shouldHaveBeenCalled(); 38 | } 39 | 40 | function it_can_save_without_flushing(UserInterface $user) 41 | { 42 | $this->save($user, ['flush'=>false]); 43 | $this->entityRepository->save($user, ['flush'=>false])->shouldHaveBeenCalled(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /behat.yml: -------------------------------------------------------------------------------- 1 | default: 2 | 3 | suites: 4 | default: 5 | type: symfony_bundle 6 | bundle: AppBundle 7 | contexts: 8 | - FeatureContext: 9 | doctrine: "@doctrine" 10 | - AppBundle\Features\Context\RestApiContext: 11 | client: "@csa_guzzle.client.local_test_api" 12 | dummyDataPath: "%paths.base%/features/Dummy/" 13 | - AppBundle\Features\Context\AccountSetupContext: 14 | userManager: "@fos_user.user_manager" 15 | accountFactory: "@crv.factory.account_factory" 16 | em: "@doctrine.orm.entity_manager" 17 | - AppBundle\Features\Context\FileSetupContext: 18 | userManager: "@fos_user.user_manager" 19 | fileFactory: "@crv.factory.file_factory" 20 | em: "@doctrine.orm.entity_manager" 21 | filesystem: "@oneup_flysystem.local_filesystem" 22 | dummyDataPath: "%paths.base%/features/Dummy/" 23 | - AppBundle\Features\Context\FileStorageContext: 24 | filesystem: "@oneup_flysystem.local_filesystem" 25 | - AppBundle\Features\Context\MysqlDatabaseContext: 26 | em: "@doctrine.orm.entity_manager" 27 | - AppBundle\Features\Context\UserSetupContext: 28 | userManager: "@fos_user.user_manager" 29 | em: "@doctrine.orm.entity_manager" 30 | 31 | 32 | extensions: 33 | Behat\Symfony2Extension: 34 | kernel: 35 | env: "acceptance" 36 | debug: "true" 37 | 38 | -------------------------------------------------------------------------------- /src/AppBundle/Repository/Restricted/RestrictedUserRepository.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 23 | $this->authorizationChecker = $authorizationChecker; 24 | } 25 | 26 | public function save(UserInterface $user, array $arguments = []) 27 | { 28 | $this->authorizationChecker->isGranted('view', $user); 29 | 30 | $this->repository->save($user); 31 | } 32 | 33 | public function delete(UserInterface $user, array $arguments = []) 34 | { 35 | $this->authorizationChecker->isGranted('view', $user); 36 | 37 | $this->repository->delete($user); 38 | } 39 | 40 | public function findOneById($id) 41 | { 42 | $user = $this->repository->findOneById($id); 43 | 44 | $this->denyAccessUnlessGranted('view', $user); 45 | 46 | return $user; 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /app/config/parameters.yml.dist: -------------------------------------------------------------------------------- 1 | # This file is a "template" of what your parameters.yml file should look like 2 | # Set parameters here that may be different on each deployment target of the app, e.g. development, staging, production. 3 | # http://symfony.com/doc/current/best_practices/configuration.html#infrastructure-related-configuration 4 | parameters: 5 | database_host: 127.0.0.1 6 | database_port: ~ 7 | database_name: symfony 8 | database_user: root 9 | database_password: ~ 10 | # You should uncomment this if you want use pdo_sqlite 11 | # database_path: "%kernel.root_dir%/data.db3" 12 | 13 | mailer_transport: smtp 14 | mailer_host: 127.0.0.1 15 | mailer_user: ~ 16 | mailer_password: ~ 17 | 18 | # A secret key that's used to generate certain security-related tokens 19 | secret: ThisTokenIsNotSoSecretChangeIt 20 | 21 | # fos user bundle 22 | fos_user.from_email.address: "noreply@example.com" 23 | fos_user.from_email.sender_name: "Demo App" 24 | 25 | # nelmio cors 26 | cors_allow_origin: 'http://localhost' 27 | 28 | # nelmio api docs 29 | api_name: 'Your API name' 30 | api_description: 'The full description of your API' 31 | 32 | # lexik jwt 33 | jwt_private_key_path: %kernel.root_dir%/../var/jwt/private.pem # ssh private key path 34 | jwt_public_key_path: %kernel.root_dir%/../var/jwt/public.pem # ssh public key path 35 | jwt_key_pass_phrase: '1234' # ssh key pass phrase 36 | jwt_token_ttl: 86400 -------------------------------------------------------------------------------- /src/AppBundle/Entity/Repository/FileEntityRepository.php: -------------------------------------------------------------------------------- 1 | getEntityManager() 19 | ->createQueryBuilder() 20 | ->select('f') 21 | ->from('AppBundle\Entity\File', 'f') 22 | ->join('f.accounts', 'a') 23 | ->where('a.id = :accountId') 24 | ->setParameter('accountId', $account->getId()) 25 | ->getQuery(); 26 | 27 | return new ArrayCollection( 28 | $query->getResult() 29 | ); 30 | } 31 | 32 | /** 33 | * @param UserInterface $user 34 | * @return array 35 | */ 36 | public function findAllForUser(UserInterface $user) 37 | { 38 | $query = $this->getEntityManager() 39 | ->createQueryBuilder() 40 | ->select('f') 41 | ->from('AppBundle\Entity\File', 'f') 42 | ->join('f.accounts', 'a') 43 | ->join('a.users', 'u') 44 | ->where('u.id = :userId') 45 | ->setParameter('userId', $user->getId()) 46 | ->getQuery(); 47 | 48 | return new ArrayCollection( 49 | $query->getResult() 50 | ); 51 | } 52 | } -------------------------------------------------------------------------------- /src/AppBundle/Model/FileInterface.php: -------------------------------------------------------------------------------- 1 | em = $em; 25 | } 26 | 27 | /** 28 | * @return EntityManagerInterface 29 | */ 30 | public function getEntityManager() 31 | { 32 | return $this->em; 33 | } 34 | 35 | /** 36 | * @param mixed $object 37 | */ 38 | public function refresh($object) 39 | { 40 | $this->em->refresh($object); 41 | } 42 | 43 | /** 44 | * @param mixed $object 45 | * @param array $arguments 46 | */ 47 | public function save($object, array $arguments = ['flush'=>true]) 48 | { 49 | $this->em->persist($object); 50 | 51 | if ($arguments['flush'] === true) { 52 | $this->em->flush(); 53 | } 54 | } 55 | 56 | /** 57 | * @param mixed $object 58 | * @param array $arguments 59 | */ 60 | public function delete($object, array $arguments = ['flush'=>true]) 61 | { 62 | $this->em->remove($object); 63 | 64 | if ($arguments['flush'] === true) { 65 | $this->em->flush(); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/AppBundle/Form/Handler/FormHandler.php: -------------------------------------------------------------------------------- 1 | formFactory = $formFactory; 32 | $this->formType = $formType; 33 | } 34 | 35 | /** 36 | * @param mixed $object 37 | * @param array $parameters 38 | * @param string $method 39 | * @param array $options 40 | * @return mixed 41 | * @throws InvalidFormException 42 | */ 43 | public function handle($object, array $parameters, $method, array $options = []) 44 | { 45 | $options = array_replace_recursive([ 46 | 'method' => $method, 47 | 'csrf_protection' => false, 48 | ], $options); 49 | 50 | $form = $this->formFactory->create(get_class($this->formType), $object, $options); 51 | 52 | $form->submit($parameters, 'PATCH' !== $method); 53 | 54 | if (!$form->isValid()) { 55 | throw new InvalidFormException($form); 56 | } 57 | 58 | return $form->getData(); 59 | } 60 | } -------------------------------------------------------------------------------- /src/AppBundle/Security/Authorization/Voter/UserVoter.php: -------------------------------------------------------------------------------- 1 | supportsClass(get_class($requestedUser))) { 30 | return VoterInterface::ACCESS_ABSTAIN; 31 | } 32 | 33 | // set the attribute to check against 34 | $attribute = $attributes[0]; 35 | 36 | // check if the given attribute is covered by this voter 37 | if (!$this->supportsAttribute($attribute)) { 38 | return VoterInterface::ACCESS_ABSTAIN; 39 | } 40 | 41 | // get current logged in user 42 | $loggedInUser = $token->getUser(); 43 | 44 | // make sure there is a user object (i.e. that the user is logged in) 45 | if ($loggedInUser === $requestedUser) { 46 | return VoterInterface::ACCESS_GRANTED; 47 | } 48 | 49 | return VoterInterface::ACCESS_DENIED; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /spec/AppBundle/Form/Handler/FormHandlerSpec.php: -------------------------------------------------------------------------------- 1 | form = $form; 25 | 26 | $formFactory->create(Argument::any(), Argument::any(), Argument::any())->willReturn($this->form); 27 | 28 | $this->beConstructedWith($formFactory, $formType); 29 | } 30 | 31 | function it_is_initializable() 32 | { 33 | $this->shouldHaveType('AppBundle\Form\Handler\FormHandler'); 34 | $this->shouldImplement('AppBundle\Form\Handler\FormHandlerInterface'); 35 | } 36 | 37 | function it_should_throw_if_form_is_invalid() 38 | { 39 | $this->form->submit(Argument::any(), Argument::any())->willReturn(false); 40 | $this->form->isValid()->willReturn(false); 41 | 42 | $this 43 | ->shouldThrow('AppBundle\Exception\InvalidFormException') 44 | ->during('handle', [new \StdClass(), [1,2,3], 'BAD', []]) 45 | ; 46 | } 47 | 48 | function it_can_handle_a_form() 49 | { 50 | $this->form->submit(Argument::any(), Argument::any())->shouldBeCalled(); 51 | $this->form->isValid()->willReturn(true); 52 | $this->form->getData()->shouldBeCalled(); 53 | 54 | $this->handle(new \StdClass(), [1,2,3], 'FAKE', []); 55 | } 56 | } -------------------------------------------------------------------------------- /src/AppBundle/Form/Transformer/ManyEntityToIdObjectTransformer.php: -------------------------------------------------------------------------------- 1 | entityToIdObjectTransformer = $entityToIdObjectTransformer; 21 | } 22 | 23 | /** 24 | * Do nothing 25 | * 26 | * @param array $array 27 | * @return array 28 | */ 29 | public function transform($array) 30 | { 31 | $transformed = []; 32 | 33 | if (empty($array) || null === $array) { 34 | return $transformed; 35 | } 36 | 37 | foreach ($array as $k => $v) { 38 | $transformed[] = $this->entityToIdObjectTransformer->transform($v); 39 | } 40 | 41 | return $transformed; 42 | } 43 | 44 | /** 45 | * Transforms an array of arrays including an identifier to an object. 46 | * 47 | * @param array $array 48 | * 49 | * @return array 50 | */ 51 | public function reverseTransform($array) 52 | { 53 | if (!is_array($array)) { 54 | $array = [$array]; 55 | } 56 | 57 | $reverseTransformed = []; 58 | 59 | foreach ($array as $k => $v) { 60 | $reverseTransformed[] = $this->entityToIdObjectTransformer->reverseTransform($v); 61 | } 62 | 63 | return $reverseTransformed; 64 | } 65 | } -------------------------------------------------------------------------------- /src/AppBundle/Repository/Doctrine/DoctrineUserRepository.php: -------------------------------------------------------------------------------- 1 | commonRepository = $commonRepository; 26 | $this->em = $em; 27 | } 28 | 29 | /** 30 | * @param UserInterface $user 31 | */ 32 | public function refresh(UserInterface $user) 33 | { 34 | $this->commonRepository->refresh($user); 35 | } 36 | 37 | /** 38 | * @param UserInterface $user 39 | * @param array $arguments 40 | */ 41 | public function save(UserInterface $user, array $arguments = ['flush'=>true]) 42 | { 43 | $this->commonRepository->save($user, $arguments); 44 | } 45 | 46 | /** 47 | * @param UserInterface $user 48 | * @param array $arguments 49 | */ 50 | public function delete(UserInterface $user, array $arguments = ['flush'=>true]) 51 | { 52 | $this->commonRepository->delete($user, $arguments); 53 | } 54 | 55 | public function findOneById($id) 56 | { 57 | return $this->em->getRepository('AppBundle:User')->find($id); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/AppBundle/Features/Context/MysqlDatabaseContext.php: -------------------------------------------------------------------------------- 1 | em = $em; 26 | } 27 | 28 | /** 29 | * @Then the :entity with id: :id should have been deleted 30 | */ 31 | public function theWithIdXShouldHaveBeenDeleted($entity, $id) 32 | { 33 | $className = $this->getClassNameFor($entity); 34 | 35 | Assertions::isNull( 36 | $this->em->getRepository($className)->find($id) 37 | ); 38 | } 39 | 40 | /** 41 | * @param $entity 42 | * @return string 43 | */ 44 | private function getClassNameFor($entity) 45 | { 46 | switch ($entity) { 47 | 48 | case 'Account': 49 | $entityPath = 'AppBundle:Account'; 50 | break; 51 | 52 | case 'File': 53 | $entityPath = 'AppBundle:File'; 54 | break; 55 | 56 | default: 57 | throw new \InvalidArgumentException( 58 | sprintf('Unrecognised Entity: %s, did you forget to add a new class name to the switch statement?', 59 | $entity 60 | ) 61 | ); 62 | } 63 | 64 | return $entityPath; 65 | } 66 | } -------------------------------------------------------------------------------- /app/config/security.yml: -------------------------------------------------------------------------------- 1 | # To get started with security, check out the documentation: 2 | # http://symfony.com/doc/current/book/security.html 3 | security: 4 | 5 | encoders: 6 | FOS\UserBundle\Model\UserInterface: bcrypt 7 | 8 | role_hierarchy: 9 | ROLE_ADMIN: ROLE_USER 10 | ROLE_SUPER_ADMIN: ROLE_ADMIN 11 | 12 | providers: 13 | fos_userbundle: 14 | id: fos_user.user_provider.username_email 15 | 16 | firewalls: 17 | dev: 18 | pattern: ^/(_(profiler|wdt|error)|css|images|js)/ 19 | security: false 20 | 21 | # activate different ways to authenticate 22 | 23 | # http_basic: ~ 24 | # http://symfony.com/doc/current/book/security.html#a-configuring-how-your-users-will-authenticate 25 | 26 | # form_login: ~ 27 | # http://symfony.com/doc/current/cookbook/security/form_login_setup.html 28 | 29 | api_docs: 30 | pattern: ^/doc 31 | anonymous: true 32 | 33 | api_login: 34 | pattern: ^/login 35 | stateless: true 36 | anonymous: true 37 | form_login: 38 | check_path: /login 39 | require_previous_session: false 40 | username_parameter: username 41 | password_parameter: password 42 | success_handler: lexik_jwt_authentication.handler.authentication_success 43 | failure_handler: lexik_jwt_authentication.handler.authentication_failure 44 | 45 | api: 46 | pattern: ^/ 47 | stateless: true 48 | lexik_jwt: ~ 49 | 50 | 51 | access_control: 52 | - { path: ^/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY } 53 | - { path: ^/doc, roles: IS_AUTHENTICATED_ANONYMOUSLY } 54 | - { path: ^/, roles: IS_AUTHENTICATED_FULLY } -------------------------------------------------------------------------------- /src/AppBundle/Model/AccountInterface.php: -------------------------------------------------------------------------------- 1 | userManager = $userManager; 26 | $this->em = $em; 27 | } 28 | 29 | /** 30 | * @Given there are Users with the following details: 31 | */ 32 | public function thereAreUsersWithTheFollowingDetails(TableNode $users) 33 | { 34 | foreach ($users->getColumnsHash() as $key => $val) { 35 | 36 | $user = $this->userManager->createUser(); 37 | 38 | $user->setEnabled(true); 39 | $user->setUsername($val['username']); 40 | $user->setEmail($val['email']); 41 | $user->setPlainPassword($val['password']); 42 | 43 | $this->userManager->updateUser($user); 44 | 45 | 46 | $qb = $this->em->createQueryBuilder(); 47 | 48 | $query = $qb->update('AppBundle:User', 'u') 49 | ->set('u.id', $qb->expr()->literal($val['uid'])) 50 | ->where('u.username = :username') 51 | ->andWhere('u.email = :email') 52 | ->setParameters([ 53 | 'username' => $val['username'], 54 | 'email' => $val['email'] 55 | ]) 56 | ->getQuery(); 57 | 58 | $query->execute(); 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /src/AppBundle/Form/Transformer/EntityToIdObjectTransformer.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 27 | } 28 | 29 | /** 30 | * Do nothing. 31 | * 32 | * @param object|null $object 33 | * 34 | * @return string 35 | */ 36 | public function transform($object) 37 | { 38 | return ''; 39 | } 40 | 41 | /** 42 | * Transforms an array including an identifier to an object. 43 | * 44 | * @param array $idObject 45 | * 46 | * @return object|null 47 | * 48 | * @throws TransformationFailedException if object is not found. 49 | */ 50 | public function reverseTransform($idObject) 51 | { 52 | if (!is_array($idObject)) { 53 | return false; 54 | } 55 | 56 | if ( ! array_key_exists('id', $idObject)) { 57 | throw new TransformationFailedException('Unable to find an ID key / value pair on passed in $idObject'); 58 | } 59 | 60 | $object = $this->repository->findOneById($idObject['id']); 61 | 62 | if (null === $object) { 63 | throw new TransformationFailedException(sprintf( 64 | 'A "%s" with ID "%s" does not exist!', 65 | get_class($object), 66 | $idObject['id'] 67 | )); 68 | } 69 | 70 | return $object; 71 | } 72 | } -------------------------------------------------------------------------------- /src/AppBundle/Form/Type/AccountType.php: -------------------------------------------------------------------------------- 1 | userRepository = $userRepository; 27 | } 28 | 29 | /** 30 | * @param FormBuilderInterface $builder 31 | * @param array $options 32 | */ 33 | public function buildForm(FormBuilderInterface $builder, array $options) 34 | { 35 | $builder 36 | ->add('name', TextType::class) 37 | ; 38 | 39 | $userTransformer = new EntityToIdObjectTransformer($this->userRepository); 40 | $userCollectionTransformer = new ManyEntityToIdObjectTransformer($userTransformer); 41 | 42 | $builder 43 | ->add( 44 | $builder->create('users', TextType::class)->addModelTransformer($userCollectionTransformer) 45 | ) 46 | ; 47 | 48 | // ->add('files', EntityType::class, [ 49 | // 'class' => 'AppBundle\Entity\File', 50 | // "property" => "id", 51 | // "multiple" => true, 52 | // ]) 53 | ; 54 | } 55 | 56 | public function configureOptions(OptionsResolver $resolver) 57 | { 58 | $resolver->setDefaults([ 59 | 'data_class' => 'AppBundle\DTO\AccountDTO', 60 | ]); 61 | } 62 | 63 | public function getName() 64 | { 65 | return 'account'; 66 | } 67 | } -------------------------------------------------------------------------------- /spec/AppBundle/Security/Authorisation/Voter/UserVoterSpec.php: -------------------------------------------------------------------------------- 1 | shouldHaveType('AppBundle\Security\Authorisation\Voter\UserVoter'); 17 | $this->shouldImplement('Symfony\Component\Security\Core\Authorization\Voter\VoterInterface'); 18 | } 19 | 20 | function it_supports_expected_attributes() 21 | { 22 | $this->supportsAttribute('view')->shouldReturn(true); 23 | } 24 | 25 | function it_doesnt_support_unexpected_attributes() 26 | { 27 | $this->supportsAttribute('1')->shouldReturn(false); 28 | } 29 | 30 | function it_supports_expected_class(User $user) 31 | { 32 | $this->supportsClass($user)->shouldReturn(true); 33 | } 34 | 35 | function it_doesnt_support_unexpected_class() 36 | { 37 | $this->supportsClass(new \StdClass())->shouldReturn(false); 38 | } 39 | 40 | function it_abstains_if_doesnt_support_attribute(TokenInterface $token, User $user) 41 | { 42 | $this->vote($token, $user, ['unsupported'])->shouldReturn(VoterInterface::ACCESS_ABSTAIN); 43 | } 44 | 45 | function it_abstains_if_doesnt_support_this_class(TokenInterface $token) 46 | { 47 | $this->vote($token, new \StdClass(), [UserVoter::VIEW])->shouldReturn(VoterInterface::ACCESS_ABSTAIN); 48 | } 49 | 50 | function it_denies_if_not_matching(TokenInterface $token, User $user1, User $user2) 51 | { 52 | $token->getUser()->willReturn($user2); 53 | 54 | $this->vote($token, $user1, [UserVoter::VIEW])->shouldReturn(VoterInterface::ACCESS_DENIED); 55 | } 56 | 57 | function it_grants_if_matching(TokenInterface $token, User $user) 58 | { 59 | $token->getUser()->willReturn($user); 60 | 61 | $this->vote($token, $user, [UserVoter::VIEW])->shouldReturn(VoterInterface::ACCESS_GRANTED); 62 | } 63 | } -------------------------------------------------------------------------------- /app/AppKernel.php: -------------------------------------------------------------------------------- 1 | getEnvironment(), ['dev', 'test', 'acceptance'], true)) { 32 | $bundles[] = new Symfony\Bundle\DebugBundle\DebugBundle(); 33 | $bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle(); 34 | $bundles[] = new Sensio\Bundle\DistributionBundle\SensioDistributionBundle(); 35 | $bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle(); 36 | } 37 | 38 | return $bundles; 39 | } 40 | 41 | public function getRootDir() 42 | { 43 | return __DIR__; 44 | } 45 | 46 | public function getCacheDir() 47 | { 48 | return dirname(__DIR__).'/var/cache/'.$this->getEnvironment(); 49 | } 50 | 51 | public function getLogDir() 52 | { 53 | return dirname(__DIR__).'/var/logs'; 54 | } 55 | 56 | public function registerContainerConfiguration(LoaderInterface $loader) 57 | { 58 | $loader->load($this->getRootDir().'/config/config_'.$this->getEnvironment().'.yml'); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/AppBundle/Handler/UserHandler.php: -------------------------------------------------------------------------------- 1 | formHandler = $formHandler; 27 | $this->repository = $userRepositoryInterface; 28 | } 29 | 30 | public function get($id) 31 | { 32 | return $this->repository->findOneById($id); 33 | } 34 | 35 | public function all($limit = 10, $offset = 0) 36 | { 37 | throw new \DomainException('UserHandler::all is currently not implemented.'); 38 | } 39 | 40 | public function post(array $parameters, array $options = []) 41 | { 42 | throw new \DomainException('UserHandler::post is currently not implemented.'); 43 | } 44 | 45 | public function put($resource, array $parameters, array $options = []) 46 | { 47 | throw new \DomainException('UserHandler::put is currently not implemented.'); 48 | } 49 | 50 | /** 51 | * @param UserInterface $user 52 | * @param array $parameters 53 | * @param array $options 54 | * @return UserInterface 55 | */ 56 | public function patch($user, array $parameters, array $options = []) 57 | { 58 | if ( ! $user instanceof UserInterface) { 59 | throw new \InvalidArgumentException('Not a valid User'); 60 | } 61 | 62 | $user = $this->formHandler->handle( 63 | $user, 64 | $parameters, 65 | Request::METHOD_PATCH, 66 | $options 67 | ); 68 | 69 | $this->repository->save($user); 70 | 71 | return $user; 72 | } 73 | 74 | public function delete($resource) 75 | { 76 | throw new \DomainException('UserHandler::delete is currently not implemented.'); 77 | } 78 | 79 | } -------------------------------------------------------------------------------- /src/AppBundle/Security/Authorization/Voter/FileVoter.php: -------------------------------------------------------------------------------- 1 | supportsClass(get_class($requestedFile))) { 50 | return VoterInterface::ACCESS_ABSTAIN; 51 | } 52 | 53 | // set the attribute to check against 54 | $attribute = $attributes[0]; 55 | 56 | // check if the given attribute is covered by this voter 57 | if (!$this->supportsAttribute($attribute)) { 58 | return VoterInterface::ACCESS_ABSTAIN; 59 | } 60 | 61 | // get current logged in user 62 | $loggedInUser = $token->getUser(); /** @var $loggedInUser UserInterface */ 63 | 64 | try { 65 | if ($requestedFile->belongsToUser($loggedInUser)) { 66 | 67 | return VoterInterface::ACCESS_GRANTED; 68 | } 69 | } catch (\Exception $e) { 70 | 71 | return VoterInterface::ACCESS_DENIED; 72 | } 73 | 74 | return VoterInterface::ACCESS_DENIED; 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /src/AppBundle/Security/Authorization/Voter/AccountVoter.php: -------------------------------------------------------------------------------- 1 | supportsClass(get_class($requestedAccount))) { 53 | return VoterInterface::ACCESS_ABSTAIN; 54 | } 55 | 56 | // set the attribute to check against 57 | $attribute = $attributes[0]; 58 | 59 | // check if the given attribute is covered by this voter 60 | if (!$this->supportsAttribute($attribute)) { 61 | return VoterInterface::ACCESS_ABSTAIN; 62 | } 63 | 64 | // get current logged in user 65 | $loggedInUser = $token->getUser(); /** @var $loggedInUser UserInterface */ 66 | 67 | // make sure that this User has access to this account 68 | if ($loggedInUser->hasAccount($requestedAccount)) { 69 | return VoterInterface::ACCESS_GRANTED; 70 | } 71 | 72 | return VoterInterface::ACCESS_DENIED; 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /spec/AppBundle/Handler/UserHandlerSpec.php: -------------------------------------------------------------------------------- 1 | formHandler = $formHandler; 23 | $this->repository = $repository; 24 | 25 | $this->beConstructedWith($repository, $formHandler); 26 | } 27 | 28 | function it_is_initializable() 29 | { 30 | $this->shouldHaveType('AppBundle\Handler\UserHandler'); 31 | $this->shouldImplement('AppBundle\Handler\HandlerInterface'); 32 | } 33 | 34 | function it_can_GET() 35 | { 36 | $id = 777; 37 | $this->get($id); 38 | $this->repository->find($id)->shouldHaveBeenCalled(); 39 | } 40 | 41 | function it_cannot_get_ALL() 42 | { 43 | $this->shouldThrow('\DomainException')->during('all', [1,2]); 44 | } 45 | 46 | function it_cannot_POST() 47 | { 48 | $this->shouldThrow('\DomainException')->during('post', [['param1']]); 49 | } 50 | 51 | function it_cannot_PUT() 52 | { 53 | $this->shouldThrow('\DomainException')->during('put', [['param1'], []]); 54 | } 55 | 56 | function it_should_allow_PATCH(User $user) 57 | { 58 | $params = ['1','2','3']; 59 | 60 | $this->formHandler->handle($user, $params, Request::METHOD_PATCH, [])->willReturn($user); 61 | $this->repository->save(Argument::type('AppBundle\Entity\User'))->shouldBeCalled(); 62 | 63 | $this->patch($user, $params)->shouldReturn($user); 64 | } 65 | 66 | function it_should_throw_if_PATCH_not_given_a_valid_user() 67 | { 68 | $this->shouldThrow('\InvalidArgumentException')->during('patch', ['not_user', []]); 69 | $this->shouldThrow('\InvalidArgumentException')->during('patch', [new \StdClass(), [123]]); 70 | } 71 | 72 | function it_cannot_DELETE() 73 | { 74 | $this->shouldThrow('\DomainException')->during('delete', ['something']); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/AppBundle/Model/UploadedFile.php: -------------------------------------------------------------------------------- 1 | filePath = $path; 30 | $this->originalFileName = $originalName; 31 | $this->mimeType = $mimeType; 32 | $this->fileSize = $size; 33 | $this->fileExtension = $extension; 34 | } 35 | 36 | /** 37 | * @param SymfonyUploadedFile $uploadedFile 38 | * @return SymfonyUploadedFile 39 | */ 40 | public static function createFromSymfonyUploadedFile(SymfonyUploadedFile $uploadedFile) 41 | { 42 | return new UploadedFile( 43 | //$uploadedFile->getPath(), 44 | $uploadedFile->getPathname(), 45 | $uploadedFile->getClientOriginalName(), 46 | $uploadedFile->getClientMimeType(), 47 | $uploadedFile->getSize(), 48 | $uploadedFile->getClientOriginalExtension() 49 | ); 50 | } 51 | 52 | /** 53 | * @return string 54 | */ 55 | public function getFilePath() 56 | { 57 | return $this->filePath; 58 | } 59 | 60 | /** 61 | * @return string 62 | */ 63 | public function getOriginalFileName() 64 | { 65 | return $this->originalFileName; 66 | } 67 | 68 | /** 69 | * @return string 70 | */ 71 | public function getFileExtension() 72 | { 73 | return $this->fileExtension; 74 | } 75 | 76 | /** 77 | * @return string 78 | */ 79 | public function getMimeType() 80 | { 81 | return $this->mimeType; 82 | } 83 | 84 | /** 85 | * @return int 86 | */ 87 | public function getFileSize() 88 | { 89 | return $this->fileSize; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /spec/AppBundle/Repository/Restricted/UserRepositorySpec.php: -------------------------------------------------------------------------------- 1 | userRepository = $userRepository; 22 | $this->authorizationChecker = $authorizationChecker; 23 | 24 | $this->beConstructedWith($userRepository, $authorizationChecker); 25 | } 26 | 27 | function it_is_initializable() 28 | { 29 | $this->shouldHaveType('AppBundle\Repository\Restricted\UserRepository'); 30 | $this->shouldImplement('AppBundle\Repository\UserRepositoryInterface'); 31 | } 32 | 33 | function it_can_find_logged_in_user_by_id(UserInterface $user) 34 | { 35 | $id = 1; 36 | 37 | $this->userRepository->find($id)->willReturn($user); 38 | $this->authorizationChecker->isGranted('view', $user)->willReturn(true); 39 | 40 | $this->find($id)->shouldReturn($user); 41 | } 42 | 43 | function it_cannot_find_any_other_user(UserInterface $user) 44 | { 45 | $id = 6; 46 | 47 | $this->userRepository->find($id)->willReturn($user); 48 | $this->authorizationChecker->isGranted('view', $user)->willReturn(false); 49 | 50 | $this->shouldThrow('Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException') 51 | ->during('find', [$id]); 52 | } 53 | 54 | function it_can_save_logged_in_user(UserInterface $user) 55 | { 56 | $this->authorizationChecker->isGranted('view', $user)->willReturn(true); 57 | 58 | $this->save($user); 59 | $this->userRepository->save($user,['flush'=>true])->shouldHaveBeenCalled(); 60 | } 61 | 62 | function it_cannot_save_any_other_user(UserInterface $user) 63 | { 64 | $this->authorizationChecker->isGranted('view', $user)->willReturn(false); 65 | 66 | $this->shouldThrow('Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException') 67 | ->during('save', [$user,['flush'=>true]]); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/AppBundle/DTO/AccountDTO.php: -------------------------------------------------------------------------------- 1 | users = new ArrayCollection(); 30 | // $this->files = new ArrayCollection(); 31 | } 32 | 33 | /** 34 | * @return mixed 35 | */ 36 | public function getDataClass() 37 | { 38 | return self::class; 39 | } 40 | 41 | /** 42 | * @return string 43 | */ 44 | public function jsonSerialize() 45 | { 46 | return [ 47 | 'name' => $this->name, 48 | 'users' => $this->users, 49 | // 'files' => $this->files, 50 | ]; 51 | } 52 | 53 | /** 54 | * @return ArrayCollection 55 | */ 56 | public function getUsers() 57 | { 58 | return $this->users; 59 | } 60 | 61 | /** 62 | * @param Array $users 63 | * @return $this 64 | */ 65 | public function setUsers($users) 66 | { 67 | $this->users = $users; 68 | 69 | return $this; 70 | } 71 | 72 | /** 73 | * @return mixed 74 | */ 75 | public function getName() 76 | { 77 | return $this->name; 78 | } 79 | 80 | /** 81 | * @param mixed $name 82 | * @return $this 83 | */ 84 | public function setName($name) 85 | { 86 | $this->name = $name; 87 | 88 | return $this; 89 | } 90 | 91 | // /** 92 | // * @return array 93 | // */ 94 | // public function getFiles() 95 | // { 96 | // return $this->files; 97 | // } 98 | // 99 | // /** 100 | // * @param array $files 101 | // * @return $this 102 | // */ 103 | // public function setFiles($files) 104 | // { 105 | // $this->files = $files; 106 | // 107 | // return $this; 108 | // } 109 | } 110 | -------------------------------------------------------------------------------- /src/AppBundle/Repository/Doctrine/DoctrineAccountRepository.php: -------------------------------------------------------------------------------- 1 | commonRepository = $commonRepository; 34 | $this->accountEntityRepository = $accountEntityRepository; 35 | } 36 | 37 | /** 38 | * @param AccountInterface $account 39 | */ 40 | public function refresh(AccountInterface $account) 41 | { 42 | $this->commonRepository->refresh($account); 43 | } 44 | 45 | /** 46 | * @param AccountInterface $account 47 | * @param array $arguments 48 | */ 49 | public function save(AccountInterface $account, array $arguments = ['flush'=>true]) 50 | { 51 | $this->commonRepository->save($account, $arguments); 52 | } 53 | 54 | /** 55 | * @param AccountInterface $account 56 | * @param array $arguments 57 | */ 58 | public function delete(AccountInterface $account, array $arguments = ['flush'=>true]) 59 | { 60 | $this->commonRepository->delete($account, $arguments); 61 | } 62 | 63 | /** 64 | * @param $id 65 | * @return mixed 66 | */ 67 | public function findOneById($id) 68 | { 69 | return $this->accountEntityRepository->find($id); 70 | } 71 | 72 | /** 73 | * @param UserInterface $user 74 | * @return array 75 | */ 76 | public function findAllForUser(UserInterface $user) 77 | { 78 | return $this->accountEntityRepository->findAllForUser($user); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/AppBundle/Repository/Doctrine/DoctrineFileRepository.php: -------------------------------------------------------------------------------- 1 | commonRepository = $commonRepository; 36 | $this->fileEntityRepository = $fileEntityRepository; 37 | } 38 | 39 | /** 40 | * @param FileInterface $file 41 | */ 42 | public function refresh(FileInterface $file) 43 | { 44 | $this->commonRepository->refresh($file); 45 | } 46 | 47 | /** 48 | * @param FileInterface $file 49 | * @param array $arguments 50 | */ 51 | public function save(FileInterface $file, array $arguments = ['flush'=>true]) 52 | { 53 | $this->commonRepository->save($file, $arguments); 54 | } 55 | 56 | /** 57 | * @param FileInterface $file 58 | * @param array $arguments 59 | */ 60 | public function delete(FileInterface $file, array $arguments = ['flush'=>true]) 61 | { 62 | $this->commonRepository->delete($file, $arguments); 63 | } 64 | 65 | /** 66 | * @param $id 67 | * @return mixed 68 | */ 69 | public function findOneById($id) 70 | { 71 | return $this->fileEntityRepository->find($id); 72 | } 73 | 74 | /** 75 | * @param AccountInterface $account 76 | * @return array 77 | */ 78 | public function findAllForAccount(AccountInterface $account) 79 | { 80 | return $this->fileEntityRepository->findAllForAccount($account); 81 | } 82 | 83 | /** 84 | * @param UserInterface $user 85 | * @return mixed 86 | */ 87 | public function findAllForUser(UserInterface $user) 88 | { 89 | return $this->fileEntityRepository->findAllForUser($user); 90 | } 91 | } -------------------------------------------------------------------------------- /src/AppBundle/Repository/Restricted/RestrictedAccountRepository.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 33 | $this->authorizationChecker = $authorizationChecker; 34 | } 35 | 36 | /** 37 | * @param AccountInterface $account 38 | * @return mixed 39 | */ 40 | public function refresh(AccountInterface $account) 41 | { 42 | $this->authorizationChecker->isGranted('view', $account); 43 | 44 | $this->repository->refresh($account); 45 | } 46 | 47 | /** 48 | * @param AccountInterface $account 49 | * @param array $arguments 50 | */ 51 | public function save(AccountInterface $account, array $arguments = []) 52 | { 53 | $this->authorizationChecker->isGranted('view', $account); 54 | 55 | $this->repository->save($account); 56 | } 57 | 58 | /** 59 | * @param AccountInterface $account 60 | * @param array $arguments 61 | */ 62 | public function delete(AccountInterface $account, array $arguments = []) 63 | { 64 | $this->authorizationChecker->isGranted('view', $account); 65 | 66 | $this->repository->delete($account); 67 | } 68 | 69 | /** 70 | * @param $id 71 | * @return mixed|null 72 | */ 73 | public function findOneById($id) 74 | { 75 | $account = $this->repository->findOneById($id); 76 | 77 | $this->denyAccessUnlessGranted('view', $account); 78 | 79 | return $account; 80 | } 81 | 82 | /** 83 | * @param UserInterface $user 84 | * @return mixed 85 | */ 86 | public function findAllForUser(UserInterface $user) 87 | { 88 | $accounts = $this->repository->findAllForUser($user); 89 | 90 | foreach ($accounts as $account) { 91 | $this->denyAccessUnlessGranted('view', $account); 92 | } 93 | 94 | return $accounts; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/AppBundle/Repository/Restricted/RestrictedFileRepository.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 29 | $this->authorizationChecker = $authorizationChecker; 30 | } 31 | 32 | /** 33 | * @param FileInterface $file 34 | * @return mixed 35 | */ 36 | public function refresh(FileInterface $file) 37 | { 38 | $this->denyAccessUnlessGranted('view', $file); 39 | 40 | $this->repository->refresh($file); 41 | } 42 | 43 | /** 44 | * @param FileInterface $file 45 | * @param array $arguments 46 | */ 47 | public function save(FileInterface $file, array $arguments = ['flush'=>true]) 48 | { 49 | $this->denyAccessUnlessGranted('view', $file); 50 | 51 | $this->repository->save($file, $arguments); 52 | } 53 | 54 | /** 55 | * @param FileInterface $file 56 | * @param array $arguments 57 | */ 58 | public function delete(FileInterface $file, array $arguments = ['flush'=>true]) 59 | { 60 | $this->denyAccessUnlessGranted('view', $file); 61 | 62 | $this->repository->delete($file, $arguments); 63 | } 64 | 65 | /** 66 | * @param $id 67 | * @return mixed|null 68 | */ 69 | public function findOneById($id) 70 | { 71 | $file = $this->repository->findOneById($id); 72 | 73 | $this->denyAccessUnlessGranted('view', $file); 74 | 75 | return $file; 76 | } 77 | 78 | /** 79 | * @param AccountInterface $account 80 | * @return mixed 81 | */ 82 | public function findAllForAccount(AccountInterface $account) 83 | { 84 | $files = $this->repository->findAllForAccount($account); 85 | 86 | foreach ($files as $file) { 87 | $this->denyAccessUnlessGranted('view', $file); 88 | } 89 | 90 | return $files; 91 | } 92 | 93 | /** 94 | * @param UserInterface $user 95 | * @return mixed 96 | */ 97 | public function findAllForUser(UserInterface $user) 98 | { 99 | $files = $this->repository->findAllForUser($user); 100 | 101 | foreach ($files as $file) { 102 | $this->denyAccessUnlessGranted('view', $file); 103 | } 104 | 105 | return $files; 106 | } 107 | } -------------------------------------------------------------------------------- /spec/AppBundle/Event/Listener/ContentTypeListenerSpec.php: -------------------------------------------------------------------------------- 1 | shouldHaveType('AppBundle\Event\Listener\ContentTypeListener'); 16 | } 17 | 18 | function it_throws_if_not_matching_other_rules(GetResponseEvent $event) 19 | { 20 | $request = new Request(); 21 | $request->headers->set('Content-type', 'blah'); 22 | $request->setMethod(Request::METHOD_PUT); 23 | 24 | $event->getRequest()->willReturn($request); 25 | 26 | $this->shouldThrow('AppBundle\Exception\HttpContentTypeException') 27 | ->during('onKernelRequest', [$event]); 28 | } 29 | 30 | function it_throws_if_no_content_type_and_is_not_GET(GetResponseEvent $event) 31 | { 32 | $request = new Request(); 33 | $request->setMethod(Request::METHOD_POST); 34 | 35 | $event->getRequest()->willReturn($request); 36 | 37 | $this->shouldThrow('AppBundle\Exception\HttpContentTypeException') 38 | ->during('onKernelRequest', [$event]); 39 | } 40 | 41 | function it_expects_a_valid_content_type_header(GetResponseEvent $event) 42 | { 43 | $request = new Request(); 44 | $request->headers->set('Content-type', ContentTypeListener::MIME_TYPE_APPLICATION_JSON); 45 | 46 | $event->getRequest()->willReturn($request); 47 | 48 | $this->onKernelRequest($event)->shouldReturn(true); 49 | } 50 | 51 | function it_should_ignore_method_type_GET(GetResponseEvent $event) 52 | { 53 | $request = new Request(); 54 | $request->headers->set('Content-type', 'fake'); 55 | $request->setMethod(Request::METHOD_GET); 56 | 57 | $event->getRequest()->willReturn($request); 58 | 59 | $this->onKernelRequest($event)->shouldReturn(true); 60 | } 61 | 62 | function it_should_deny_multipart_form_data_for_none_file_route(GetResponseEvent $event) 63 | { 64 | // $request = new Request(); 65 | // $request->request->set('_route', 'some_none_file_route'); 66 | // $request->headers->set('Content-type', ContentTypeListener::MIME_TYPE_MULTIPART_FORM_DATA); 67 | // $request->setMethod(Request::METHOD_POST); 68 | // 69 | // $event->getRequest()->willReturn($request); 70 | // 71 | // $this->shouldThrow('AppBundle\Exception\HttpContentTypeException') 72 | // ->during('onKernelRequest', [$event]); 73 | } 74 | 75 | function it_should_allow_multipart_form_data_for_file_routes(GetResponseEvent $event) 76 | { 77 | // $request = new Request(); 78 | // $request->request->set('_route', 'post_accounts_files'); 79 | // $request->headers->set('Content-type', ContentTypeListener::MIME_TYPE_MULTIPART_FORM_DATA); 80 | // $request->setMethod(Request::METHOD_POST); 81 | // 82 | // $event->getRequest()->willReturn($request); 83 | // 84 | // $this->onKernelRequest($event)->shouldReturn(true); 85 | } 86 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "a6_chris/symfony.rest.example.dev", 3 | "license": "proprietary", 4 | "type": "project", 5 | "autoload": { 6 | "psr-4": { 7 | "": "src/" 8 | }, 9 | "classmap": [ 10 | "app/AppKernel.php", 11 | "app/AppCache.php" 12 | ] 13 | }, 14 | "autoload-dev": { 15 | "psr-4": { 16 | "Tests\\": "tests/" 17 | } 18 | }, 19 | "require": { 20 | "php": ">=5.5.9", 21 | "symfony/symfony": "3.0.*", 22 | "doctrine/orm": "^2.5", 23 | "doctrine/doctrine-bundle": "^1.6", 24 | "doctrine/doctrine-cache-bundle": "^1.2", 25 | "symfony/swiftmailer-bundle": "^2.3", 26 | "symfony/monolog-bundle": "^2.8", 27 | "sensio/distribution-bundle": "^5.0", 28 | "sensio/framework-extra-bundle": "^3.0.2", 29 | "incenteev/composer-parameter-handler": "^2.0", 30 | 31 | "nelmio/cors-bundle": "^1.4", 32 | "nelmio/api-doc-bundle": "^2.11", 33 | "friendsofsymfony/rest-bundle": "^1.7", 34 | "csa/guzzle-bundle": "^1.3", 35 | "jms/serializer-bundle": "^1.1", 36 | "oneup/flysystem-bundle": "^1.2", 37 | "friendsofsymfony/user-bundle": "dev-master", 38 | "lexik/jwt-authentication-bundle": "dev-master" 39 | 40 | }, 41 | "require-dev": { 42 | "sensio/generator-bundle": "^3.0", 43 | "symfony/phpunit-bridge": "^2.7", 44 | 45 | "phpspec/phpspec": "^2.4", 46 | "behat/behat": "dev-master", 47 | 48 | "behat/symfony2-extension": "^2.1", 49 | "phpunit/phpunit": "^5.1" 50 | }, 51 | "minimum-stability": "stable", 52 | "scripts": { 53 | "post-install-cmd": [ 54 | "Incenteev\\ParameterHandler\\ScriptHandler::buildParameters", 55 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::buildBootstrap", 56 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::clearCache", 57 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installAssets", 58 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installRequirementsFile", 59 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::prepareDeploymentTarget" 60 | ], 61 | "post-update-cmd": [ 62 | "Incenteev\\ParameterHandler\\ScriptHandler::buildParameters", 63 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::buildBootstrap", 64 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::clearCache", 65 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installAssets", 66 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installRequirementsFile", 67 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::prepareDeploymentTarget" 68 | ] 69 | }, 70 | "extra": { 71 | "symfony-app-dir": "app", 72 | "symfony-bin-dir": "bin", 73 | "symfony-var-dir": "var", 74 | "symfony-web-dir": "web", 75 | "symfony-tests-dir": "tests", 76 | "symfony-assets-install": "relative", 77 | "incenteev-parameters": { 78 | "file": "app/config/parameters.yml" 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Symfony 3 RESTful API Example 2 | ============================= 3 | 4 | This is the code for the [Symfony 3 REST Tutorial][1] at CodeReviewVideos.com. 5 | 6 | ## About The Course 7 | 8 | In this series we are going to take most of what has been taught on CodeReviewVideos so far, and use it to implement the foundation of a Symfony-based RESTful API. 9 | 10 | This is not a good starting point if you have never used Symfony before. If you are a beginner, please try the [Symfony Tutorial for Beginners][2] before attempting to create a RESTful API. 11 | 12 | Whilst very new, we are going to proceed with Symfony 3 for this project. However, if you are still using Symfony 2, you should have no trouble following along. If anything, your life may be that little bit easier ;) 13 | 14 | By the end of this series you will have created a RESTful API with FOSRESTBundle, guided by tests using Behat 3 and PHPSpec 2. The API itself will exposes Users - via FOSUserBundle - along with Accounts, and File Uploading using Flysystem. 15 | 16 | You will have gained an understanding into how to add further 'modules' to your API, allowing you to create custom end points to meet the needs of your specific application. 17 | 18 | As with many things Symfony, there are multiple ways to achieve the goals in this series. We could switch out FOSRESTBundle for DunglasAPIBundle. We could switch Flysystem for Guafrette. You are entirely free to do so. 19 | 20 | This API is going to be based on FOSRESTBundle. The main reasoning for this is that I have already covered this bundle in another dedicated tutorial series, so if you get stuck, there are plenty of lessons explaining the general setup. 21 | 22 | You may be wondering about the difference between a User and an Account. The idea here is that every User would have their own User Profile, but may belong to one of more Accounts. This is pretty helpful in the real world, and opens up options for your app as it grows. Feel free to rip this part out if you don't need it. 23 | 24 | Log in is going to be a little different here. When a User POST's in a valid username and password combo, they will receive a JSON Web Token / JWT (pronounced Jot), which they will then need to use as part of any future request. Don't worry, we will cover this in full, and you will see it's really not that difficult at all. The reasoning for doing this is that our front end will thank us for it. 25 | 26 | To ensure stability, we will be using VirtualBox with an Ansible build script. This will - hopefully - mean getting from development to production is largely taken care of. 27 | 28 | Lastly, we will cover how to interact with this API using ReactJS. You could switch this out for Angular, Ember, a mobile Application (e.g. Ionic, or a native app), or any other front end framework. That choice is entirely up to you. I am by far and away not the world's best JavaScript guy, so take this section as an example, rather than a defacto standard. 29 | 30 | Hopefully you will find this exercise useful and practical. Please feel free to leave comments, ask questions, or get in touch if you would like to know more about a specific topic. 31 | 32 | [Click here to watch all the videos in this series][1] 33 | 34 | 35 | [1]: https://www.codereviewvideos.com/course/symfony-3-rest-tutorial 36 | [2]: https://www.codereviewvideos.com/course/beginner-friendly-hands-on-symfony-3-tutorial -------------------------------------------------------------------------------- /web/.htaccess: -------------------------------------------------------------------------------- 1 | # Use the front controller as index file. It serves as a fallback solution when 2 | # every other rewrite/redirect fails (e.g. in an aliased environment without 3 | # mod_rewrite). Additionally, this reduces the matching process for the 4 | # start page (path "/") because otherwise Apache will apply the rewriting rules 5 | # to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl). 6 | DirectoryIndex app.php 7 | 8 | # By default, Apache does not evaluate symbolic links if you did not enable this 9 | # feature in your server configuration. Uncomment the following line if you 10 | # install assets as symlinks or if you experience problems related to symlinks 11 | # when compiling LESS/Sass/CoffeScript assets. 12 | # Options FollowSymlinks 13 | 14 | # Disabling MultiViews prevents unwanted negotiation, e.g. "/app" should not resolve 15 | # to the front controller "/app.php" but be rewritten to "/app.php/app". 16 | 17 | Options -MultiViews 18 | 19 | 20 | 21 | RewriteEngine On 22 | 23 | # Determine the RewriteBase automatically and set it as environment variable. 24 | # If you are using Apache aliases to do mass virtual hosting or installed the 25 | # project in a subdirectory, the base path will be prepended to allow proper 26 | # resolution of the app.php file and to redirect to the correct URI. It will 27 | # work in environments without path prefix as well, providing a safe, one-size 28 | # fits all solution. But as you do not need it in this case, you can comment 29 | # the following 2 lines to eliminate the overhead. 30 | RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ 31 | RewriteRule ^(.*) - [E=BASE:%1] 32 | 33 | # Sets the HTTP_AUTHORIZATION header removed by Apache 34 | RewriteCond %{HTTP:Authorization} . 35 | RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 36 | 37 | # Redirect to URI without front controller to prevent duplicate content 38 | # (with and without `/app.php`). Only do this redirect on the initial 39 | # rewrite by Apache and not on subsequent cycles. Otherwise we would get an 40 | # endless redirect loop (request -> rewrite to front controller -> 41 | # redirect -> request -> ...). 42 | # So in case you get a "too many redirects" error or you always get redirected 43 | # to the start page because your Apache does not expose the REDIRECT_STATUS 44 | # environment variable, you have 2 choices: 45 | # - disable this feature by commenting the following 2 lines or 46 | # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the 47 | # following RewriteCond (best solution) 48 | RewriteCond %{ENV:REDIRECT_STATUS} ^$ 49 | RewriteRule ^app\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] 50 | 51 | # If the requested filename exists, simply serve it. 52 | # We only want to let Apache serve files and not directories. 53 | RewriteCond %{REQUEST_FILENAME} -f 54 | RewriteRule ^ - [L] 55 | 56 | # Rewrite all other queries to the front controller. 57 | RewriteRule ^ %{ENV:BASE}/app.php [L] 58 | 59 | 60 | 61 | 62 | # When mod_rewrite is not available, we instruct a temporary redirect of 63 | # the start page to the front controller explicitly so that the website 64 | # and the generated links can still be used. 65 | RedirectMatch 302 ^/$ /app.php/ 66 | # RedirectTemp cannot be used instead 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/AppBundle/Features/Context/FileSetupContext.php: -------------------------------------------------------------------------------- 1 | userManager = $userManager; 55 | $this->fileFactory = $fileFactory; 56 | $this->em = $em; 57 | $this->filesystem = $filesystem; 58 | $this->dummyDataPath = $dummyDataPath; 59 | } 60 | 61 | /** 62 | * @AfterScenario 63 | */ 64 | public static function removeUploadedFiles() 65 | { 66 | foreach (glob(__DIR__ . '/../../../../uploads/*') as $file) { 67 | echo "Removing uploaded file: \n"; 68 | echo basename($file) . "\n"; 69 | unlink($file); 70 | } 71 | } 72 | 73 | /** 74 | * @Given there are files with the following details: 75 | */ 76 | public function thereAreFilesWithTheFollowingDetails(TableNode $files) 77 | { 78 | foreach ($files->getColumnsHash() as $key => $val) { 79 | 80 | $file = $this->fileFactory->create( 81 | $val['originalFileName'], 82 | $val['internalFileName'], 83 | $val['guessedExtension'], 84 | $val['size'] 85 | ); 86 | 87 | $this->em->persist($file); 88 | $this->em->flush(); 89 | 90 | $qb = $this->em->createQueryBuilder(); 91 | 92 | $query = $qb->update('AppBundle:File', 'f') 93 | ->set('f.id', $qb->expr()->literal($val['uid'])) 94 | ->where('f.internalFileName = :internalFileName') 95 | ->setParameters([ 96 | 'internalFileName' => $val['internalFileName'], 97 | ]) 98 | ->getQuery() 99 | ; 100 | 101 | $query->execute(); 102 | 103 | if ( ! empty($val['dummyFile'])) { 104 | $this->filesystem->put( 105 | $val['internalFileName'], 106 | file_get_contents($this->dummyDataPath . $val['dummyFile']) 107 | ); 108 | } 109 | } 110 | 111 | $this->em->flush(); 112 | } 113 | } -------------------------------------------------------------------------------- /src/AppBundle/Controller/UsersController.php: -------------------------------------------------------------------------------- 1 | getUserHandler()->get($id); 46 | 47 | $view = $this->view($user); 48 | 49 | return $view; 50 | } 51 | 52 | /** 53 | * Gets a collection of Users. 54 | * 55 | * @ApiDoc( 56 | * output = "AppBundle\Entity\User", 57 | * statusCodes = { 58 | * 405 = "Method not allowed" 59 | * } 60 | * ) 61 | * 62 | * @throws MethodNotAllowedHttpException 63 | * 64 | * @return View 65 | */ 66 | public function cgetAction() 67 | { 68 | throw new MethodNotAllowedHttpException([], "Method not allowed"); 69 | } 70 | 71 | /** 72 | * Update existing User from the submitted data 73 | * 74 | * @ApiDoc( 75 | * resource = true, 76 | * input = "AppBundle\Form\UserType", 77 | * statusCodes = { 78 | * 204 = "Returned when successful", 79 | * 400 = "Returned when errors", 80 | * 401 = "Returned when provided password is incorrect", 81 | * 404 = "Returned when not found" 82 | * } 83 | * ) 84 | * 85 | * @param Request $request the request object 86 | * @param int $id the user id 87 | * 88 | * @return FormTypeInterface|RouteRedirectView 89 | * 90 | * @throws NotFoundHttpException when does not exist 91 | */ 92 | public function patchAction(Request $request, $id) 93 | { 94 | $requestedUser = $this->get('crv.repository.restricted_user_repository')->findOneById($id); 95 | 96 | try { 97 | 98 | $statusCode = Response::HTTP_NO_CONTENT; 99 | 100 | /** @var $user \AppBundle\Entity\User */ 101 | $user = $this->getUserHandler()->patch( 102 | $requestedUser, 103 | $request->request->all() 104 | ); 105 | 106 | $routeOptions = array( 107 | 'id' => $user->getId(), 108 | '_format' => $request->get('_format') 109 | ); 110 | 111 | return $this->routeRedirectView('get_users', $routeOptions, $statusCode); 112 | 113 | } catch (InvalidFormException $e) { 114 | 115 | return $e->getForm(); 116 | } 117 | } 118 | 119 | /** 120 | * @return UserHandler 121 | */ 122 | private function getUserHandler() 123 | { 124 | return $this->container->get('crv.handler.restricted_user_handler'); 125 | } 126 | } -------------------------------------------------------------------------------- /src/AppBundle/Features/Context/AccountSetupContext.php: -------------------------------------------------------------------------------- 1 | userManager = $userManager; 41 | $this->accountFactory = $accountFactory; 42 | $this->em = $em; 43 | } 44 | 45 | /** 46 | * @Given there are Accounts with the following details: 47 | */ 48 | public function thereAreAccountsWithTheFollowingDetails(TableNode $accounts) 49 | { 50 | foreach ($accounts->getColumnsHash() as $key => $val) { 51 | 52 | $account = $this->accountFactory->create($val['name']); 53 | 54 | $this->em->persist($account); 55 | $this->em->flush(); 56 | 57 | 58 | $this->fixIdForAccountNamed($val['uid'], $val['name']); 59 | 60 | 61 | $account = $this->em->getRepository('AppBundle:Account')->find($val['uid']); 62 | 63 | $this->addUsersToAccount($val['users'], $account); 64 | 65 | if (isset($val['files'])) { 66 | $this->addFilesToAccount($val['files'], $account); 67 | } 68 | } 69 | 70 | $this->em->flush(); 71 | } 72 | 73 | private function fixIdForAccountNamed($id, $accountName) 74 | { 75 | $qb = $this->em->createQueryBuilder(); 76 | 77 | $query = $qb->update('AppBundle:Account', 'a') 78 | ->set('a.id', $qb->expr()->literal($id)) 79 | ->where('a.name = :accountName') 80 | ->setParameters([ 81 | 'accountName' => $accountName, 82 | ]) 83 | ->getQuery() 84 | ; 85 | 86 | $query->execute(); 87 | } 88 | 89 | private function addUsersToAccount($userIds, Account $account) 90 | { 91 | $userIds = explode(',', $userIds); 92 | 93 | if (empty($userIds)) { 94 | return false; 95 | } 96 | 97 | foreach ($userIds as $userId) { 98 | /** @var $user \AppBundle\Entity\User */ 99 | $user = $this->userManager->findUserBy(['id'=>$userId]); 100 | 101 | if (!$user) { 102 | continue; 103 | } 104 | 105 | $user->addAccount($account); 106 | } 107 | 108 | $this->em->flush(); 109 | } 110 | 111 | private function addFilesToAccount($fileIds, Account $account) 112 | { 113 | if (empty($fileIds)) { 114 | return false; 115 | } 116 | 117 | $fileIds = explode(',', $fileIds); 118 | 119 | if (empty($fileIds)) { 120 | return false; 121 | } 122 | 123 | foreach ($fileIds as $fileId) { 124 | /** @var $file \AppBundle\Entity\File */ 125 | $file = $this->em->getRepository('AppBundle:File')->find($fileId); 126 | 127 | if (!$file) { 128 | continue; 129 | } 130 | 131 | $account->addFile($file); 132 | } 133 | 134 | $this->em->flush(); 135 | } 136 | } -------------------------------------------------------------------------------- /src/AppBundle/Features/user.feature: -------------------------------------------------------------------------------- 1 | Feature: Manage Users data via the RESTful API 2 | 3 | In order to offer the User resource via an hypermedia API 4 | As a client software developer 5 | I need to be able to retrieve, create, update, and delete JSON encoded User resources 6 | 7 | 8 | Background: 9 | Given there are Users with the following details: 10 | | uid | username | email | password | 11 | | u1 | peter | peter@test.com | testpass | 12 | | u2 | john | john@test.org | johnpass | 13 | # And there are Accounts with the following details: 14 | # | uid | name | users | 15 | # | a1 | account1 | u1 | 16 | And I am successfully logged in with username: "peter", and password: "testpass" 17 | And when consuming the endpoint I use the "headers/content-type" of "application/json" 18 | 19 | 20 | Scenario: User cannot GET a Collection of User objects 21 | When I send a "GET" request to "/users" 22 | Then the response code should be 405 23 | 24 | 25 | Scenario: User can GET their personal data by their unique ID 26 | When I send a "GET" request to "/users/u1" 27 | Then the response code should be 200 28 | And the response header "Content-Type" should be equal to "application/json; charset=utf-8" 29 | And the response should contain json: 30 | """ 31 | { 32 | "id": "u1", 33 | "email": "peter@test.com", 34 | "username": "peter" 35 | } 36 | """ 37 | 38 | 39 | Scenario: User cannot GET a different User's personal data 40 | When I send a "GET" request to "/users/u2" 41 | Then the response code should be 403 42 | 43 | 44 | Scenario: User cannot determine if another User ID is active 45 | When I send a "GET" request to "/users/u100" 46 | Then the response code should be 403 47 | 48 | 49 | Scenario: User cannot POST to the Users collection 50 | When I send a "POST" request to "/users" 51 | Then the response code should be 405 52 | 53 | 54 | Scenario: User can PATCH to update their personal data 55 | When I send a "PATCH" request to "/users/u1" with body: 56 | """ 57 | { 58 | "email": "peter@something-else.net", 59 | "current_password": "testpass" 60 | } 61 | """ 62 | Then the response code should be 204 63 | And I send a "GET" request to "/users/u1" 64 | And the response should contain json: 65 | """ 66 | { 67 | "id": "u1", 68 | "email": "peter@something-else.net", 69 | "username": "peter" 70 | } 71 | """ 72 | 73 | 74 | Scenario: User cannot PATCH without a valid password 75 | When I send a "PATCH" request to "/users/u1" with body: 76 | """ 77 | { 78 | "email": "peter@something-else.net", 79 | "current_password": "wrong-password" 80 | } 81 | """ 82 | Then the response code should be 400 83 | 84 | 85 | Scenario: User cannot PATCH a different User's personal data 86 | When I send a "PATCH" request to "/users/u2" 87 | Then the response code should be 403 88 | 89 | 90 | Scenario: User cannot PATCH a none existent User 91 | When I send a "PATCH" request to "/users/u100" 92 | Then the response code should be 403 93 | 94 | 95 | Scenario: User cannot PUT to replace their personal data 96 | When I send a "PUT" request to "/users/u1" 97 | Then the response code should be 405 98 | 99 | 100 | Scenario: User cannot PUT a different User's personal data 101 | When I send a "PUT" request to "/users/u2" 102 | Then the response code should be 405 103 | 104 | 105 | Scenario: User cannot PUT a none existent User 106 | When I send a "PUT" request to "/users/u100" 107 | Then the response code should be 405 108 | 109 | 110 | Scenario: User cannot DELETE their personal data 111 | When I send a "DELETE" request to "/users/u1" 112 | Then the response code should be 405 113 | 114 | 115 | Scenario: User cannot DELETE a different User's personal data 116 | When I send a "DELETE" request to "/users/u2" 117 | Then the response code should be 405 118 | 119 | 120 | Scenario: User cannot DELETE a none existent User 121 | When I send a "DELETE" request to "/users/u100" 122 | Then the response code should be 405 -------------------------------------------------------------------------------- /src/AppBundle/Entity/User.php: -------------------------------------------------------------------------------- 1 | accounts = new ArrayCollection(); 89 | } 90 | 91 | /** 92 | * @param AccountInterface $accountInterface 93 | * @return User 94 | */ 95 | public function addAccount(AccountInterface $accountInterface) 96 | { 97 | if ( ! $this->hasAccount($accountInterface)) { 98 | $accountInterface->addUser($this); 99 | $this->accounts->add($accountInterface); 100 | } 101 | 102 | return $this; 103 | } 104 | 105 | /** 106 | * @param AccountInterface $accountInterface 107 | * @return User 108 | */ 109 | public function removeAccount(AccountInterface $accountInterface) 110 | { 111 | if ($this->hasAccount($accountInterface)) { 112 | $accountInterface->removeUser($this); 113 | $this->accounts->removeElement($accountInterface); 114 | } 115 | 116 | return $this; 117 | } 118 | 119 | /** 120 | * @param AccountInterface $accountInterface 121 | * @return bool 122 | */ 123 | public function hasAccount(AccountInterface $accountInterface) 124 | { 125 | return $this->accounts->contains($accountInterface); 126 | } 127 | 128 | /** 129 | * @return Collection 130 | */ 131 | public function getAccounts() 132 | { 133 | return $this->accounts; 134 | } 135 | 136 | /** 137 | * @return string 138 | */ 139 | function jsonSerialize() 140 | { 141 | return [ 142 | 'id' => $this->id, 143 | 'username' => $this->username, 144 | 'accounts' => $this->accounts, 145 | ]; 146 | } 147 | } -------------------------------------------------------------------------------- /bin/symfony_requirements: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getPhpIniConfigPath(); 9 | 10 | echo_title('Symfony Requirements Checker'); 11 | 12 | echo '> PHP is using the following php.ini file:'.PHP_EOL; 13 | if ($iniPath) { 14 | echo_style('green', ' '.$iniPath); 15 | } else { 16 | echo_style('yellow', ' WARNING: No configuration file (php.ini) used by PHP!'); 17 | } 18 | 19 | echo PHP_EOL.PHP_EOL; 20 | 21 | echo '> Checking Symfony requirements:'.PHP_EOL.' '; 22 | 23 | $messages = array(); 24 | foreach ($symfonyRequirements->getRequirements() as $req) { 25 | if ($helpText = get_error_message($req, $lineSize)) { 26 | echo_style('red', 'E'); 27 | $messages['error'][] = $helpText; 28 | } else { 29 | echo_style('green', '.'); 30 | } 31 | } 32 | 33 | $checkPassed = empty($messages['error']); 34 | 35 | foreach ($symfonyRequirements->getRecommendations() as $req) { 36 | if ($helpText = get_error_message($req, $lineSize)) { 37 | echo_style('yellow', 'W'); 38 | $messages['warning'][] = $helpText; 39 | } else { 40 | echo_style('green', '.'); 41 | } 42 | } 43 | 44 | if ($checkPassed) { 45 | echo_block('success', 'OK', 'Your system is ready to run Symfony projects'); 46 | } else { 47 | echo_block('error', 'ERROR', 'Your system is not ready to run Symfony projects'); 48 | 49 | echo_title('Fix the following mandatory requirements', 'red'); 50 | 51 | foreach ($messages['error'] as $helpText) { 52 | echo ' * '.$helpText.PHP_EOL; 53 | } 54 | } 55 | 56 | if (!empty($messages['warning'])) { 57 | echo_title('Optional recommendations to improve your setup', 'yellow'); 58 | 59 | foreach ($messages['warning'] as $helpText) { 60 | echo ' * '.$helpText.PHP_EOL; 61 | } 62 | } 63 | 64 | echo PHP_EOL; 65 | echo_style('title', 'Note'); 66 | echo ' The command console could use a different php.ini file'.PHP_EOL; 67 | echo_style('title', '~~~~'); 68 | echo ' than the one used with your web server. To be on the'.PHP_EOL; 69 | echo ' safe side, please check the requirements from your web'.PHP_EOL; 70 | echo ' server using the '; 71 | echo_style('yellow', 'web/config.php'); 72 | echo ' script.'.PHP_EOL; 73 | echo PHP_EOL; 74 | 75 | exit($checkPassed ? 0 : 1); 76 | 77 | function get_error_message(Requirement $requirement, $lineSize) 78 | { 79 | if ($requirement->isFulfilled()) { 80 | return; 81 | } 82 | 83 | $errorMessage = wordwrap($requirement->getTestMessage(), $lineSize - 3, PHP_EOL.' ').PHP_EOL; 84 | $errorMessage .= ' > '.wordwrap($requirement->getHelpText(), $lineSize - 5, PHP_EOL.' > ').PHP_EOL; 85 | 86 | return $errorMessage; 87 | } 88 | 89 | function echo_title($title, $style = null) 90 | { 91 | $style = $style ?: 'title'; 92 | 93 | echo PHP_EOL; 94 | echo_style($style, $title.PHP_EOL); 95 | echo_style($style, str_repeat('~', strlen($title)).PHP_EOL); 96 | echo PHP_EOL; 97 | } 98 | 99 | function echo_style($style, $message) 100 | { 101 | // ANSI color codes 102 | $styles = array( 103 | 'reset' => "\033[0m", 104 | 'red' => "\033[31m", 105 | 'green' => "\033[32m", 106 | 'yellow' => "\033[33m", 107 | 'error' => "\033[37;41m", 108 | 'success' => "\033[37;42m", 109 | 'title' => "\033[34m", 110 | ); 111 | $supports = has_color_support(); 112 | 113 | echo($supports ? $styles[$style] : '').$message.($supports ? $styles['reset'] : ''); 114 | } 115 | 116 | function echo_block($style, $title, $message) 117 | { 118 | $message = ' '.trim($message).' '; 119 | $width = strlen($message); 120 | 121 | echo PHP_EOL.PHP_EOL; 122 | 123 | echo_style($style, str_repeat(' ', $width)); 124 | echo PHP_EOL; 125 | echo_style($style, str_pad(' ['.$title.']', $width, ' ', STR_PAD_RIGHT)); 126 | echo PHP_EOL; 127 | echo_style($style, $message); 128 | echo PHP_EOL; 129 | echo_style($style, str_repeat(' ', $width)); 130 | echo PHP_EOL; 131 | } 132 | 133 | function has_color_support() 134 | { 135 | static $support; 136 | 137 | if (null === $support) { 138 | if (DIRECTORY_SEPARATOR == '\\') { 139 | $support = false !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI'); 140 | } else { 141 | $support = function_exists('posix_isatty') && @posix_isatty(STDOUT); 142 | } 143 | } 144 | 145 | return $support; 146 | } 147 | -------------------------------------------------------------------------------- /app/Resources/views/default/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body %} 4 |
5 |
6 |
7 |

Welcome to Symfony {{ constant('Symfony\\Component\\HttpKernel\\Kernel::VERSION') }}

8 |
9 | 10 |
11 |

12 | 13 | 14 | Your application is ready to start working on it at: 15 | {{ base_dir }}/ 16 |

17 |
18 | 19 | 44 | 45 |
46 |
47 | {% endblock %} 48 | 49 | {% block stylesheets %} 50 | 76 | {% endblock %} 77 | -------------------------------------------------------------------------------- /src/AppBundle/DTO/FileDTO.php: -------------------------------------------------------------------------------- 1 | $this->name 53 | ]; 54 | } 55 | 56 | /** 57 | * @return string 58 | */ 59 | public function getName() 60 | { 61 | return $this->name; 62 | } 63 | 64 | /** 65 | * @param string $name 66 | * @return $this 67 | */ 68 | public function setName($name) 69 | { 70 | $this->name = $name; 71 | 72 | return $this; 73 | } 74 | 75 | /** 76 | * @return UploadedFileInterface 77 | */ 78 | public function getUploadedFile() 79 | { 80 | if (!$this->uploadedFile instanceof UploadedFile) { 81 | return null; 82 | } 83 | 84 | return \AppBundle\Model\UploadedFile::createFromSymfonyUploadedFile($this->uploadedFile); 85 | } 86 | 87 | /** 88 | * @param UploadedFile $uploadedFile 89 | * @return $this 90 | */ 91 | public function setUploadedFile(UploadedFile $uploadedFile) 92 | { 93 | $this->uploadedFile = $uploadedFile; 94 | 95 | return $this; 96 | } 97 | 98 | 99 | /** 100 | * @return mixed 101 | */ 102 | public function getId() 103 | { 104 | throw new \RuntimeException('Should never be calling this on a File DTO'); 105 | } 106 | 107 | /** 108 | * @return mixed 109 | */ 110 | public function getOriginalFileName() 111 | { 112 | throw new \RuntimeException('Should never be calling this on a File DTO'); 113 | } 114 | 115 | /** 116 | * @return mixed 117 | */ 118 | public function getInternalFileName() 119 | { 120 | throw new \RuntimeException('Should never be calling this on a File DTO'); 121 | } 122 | 123 | /** 124 | * @return mixed 125 | */ 126 | public function getGuessedExtension() 127 | { 128 | throw new \RuntimeException('Should never be calling this on a File DTO'); 129 | } 130 | 131 | /** 132 | * @return mixed 133 | */ 134 | public function getFileSize() 135 | { 136 | throw new \RuntimeException('Should never be calling this on a File DTO'); 137 | } 138 | 139 | /** 140 | * @return mixed 141 | */ 142 | public function getDisplayedFileName() 143 | { 144 | throw new \RuntimeException('Should never be calling this on a File DTO'); 145 | } 146 | 147 | /** 148 | * @param $newName 149 | * @return mixed 150 | */ 151 | public function changeDisplayedFileName($newName) 152 | { 153 | throw new \RuntimeException('Should never be calling this on a File DTO'); 154 | } 155 | 156 | /** 157 | * @param AccountInterface $accountInterface 158 | * @return mixed 159 | */ 160 | public function addAccount(AccountInterface $accountInterface) 161 | { 162 | throw new \RuntimeException('Should never be calling this on a File DTO'); 163 | } 164 | 165 | /** 166 | * @param AccountInterface $accountInterface 167 | * @return mixed 168 | */ 169 | public function removeAccount(AccountInterface $accountInterface) 170 | { 171 | throw new \RuntimeException('Should never be calling this on a File DTO'); 172 | } 173 | 174 | /** 175 | * @param AccountInterface $accountInterface 176 | * @return mixed 177 | */ 178 | public function hasAccount(AccountInterface $accountInterface) 179 | { 180 | throw new \RuntimeException('Should never be calling this on a File DTO'); 181 | } 182 | 183 | /** 184 | * @return mixed 185 | */ 186 | public function getAccounts() 187 | { 188 | throw new \RuntimeException('Should never be calling this on a File DTO'); 189 | } 190 | 191 | /** 192 | * @param UserInterface $user 193 | * @return mixed 194 | */ 195 | public function belongsToUser(UserInterface $user) 196 | { 197 | throw new \RuntimeException('Should never be calling this on a File DTO'); 198 | } 199 | } -------------------------------------------------------------------------------- /app/config/config.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: parameters.yml } 3 | - { resource: security.yml } 4 | - { resource: services.yml } 5 | 6 | # Put parameters here that don't need to change on each machine where the app is deployed 7 | # http://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration 8 | parameters: 9 | locale: en 10 | 11 | framework: 12 | #esi: ~ 13 | #translator: { fallbacks: ["%locale%"] } 14 | secret: "%secret%" 15 | router: 16 | resource: "%kernel.root_dir%/config/routing.yml" 17 | strict_requirements: ~ 18 | form: ~ 19 | csrf_protection: ~ 20 | validation: { enable_annotations: true } 21 | #serializer: { enable_annotations: true } 22 | templating: 23 | engines: ['twig'] 24 | #assets_version: SomeVersionScheme 25 | default_locale: "%locale%" 26 | trusted_hosts: ~ 27 | trusted_proxies: ~ 28 | session: 29 | # handler_id set to null will use default session handler from php.ini 30 | handler_id: ~ 31 | save_path: "%kernel.root_dir%/../var/sessions/%kernel.environment%" 32 | fragments: ~ 33 | http_method_override: true 34 | assets: ~ 35 | 36 | # Twig Configuration 37 | twig: 38 | debug: "%kernel.debug%" 39 | strict_variables: "%kernel.debug%" 40 | 41 | # Doctrine Configuration 42 | doctrine: 43 | dbal: 44 | driver: pdo_mysql 45 | host: "%database_host%" 46 | port: "%database_port%" 47 | dbname: "%database_name%" 48 | user: "%database_user%" 49 | password: "%database_password%" 50 | charset: UTF8 51 | # if using pdo_sqlite as your database driver: 52 | # 1. add the path in parameters.yml 53 | # e.g. database_path: "%kernel.root_dir%/data/data.db3" 54 | # 2. Uncomment database_path in parameters.yml.dist 55 | # 3. Uncomment next line: 56 | # path: "%database_path%" 57 | 58 | orm: 59 | auto_generate_proxy_classes: "%kernel.debug%" 60 | naming_strategy: doctrine.orm.naming_strategy.underscore 61 | auto_mapping: true 62 | 63 | # Swiftmailer Configuration 64 | swiftmailer: 65 | transport: "%mailer_transport%" 66 | host: "%mailer_host%" 67 | username: "%mailer_user%" 68 | password: "%mailer_password%" 69 | spool: { type: memory } 70 | 71 | 72 | # Nelmio CORS 73 | nelmio_cors: 74 | defaults: 75 | allow_origin: ["%cors_allow_origin%"] 76 | allow_methods: ["POST", "PUT", "GET", "DELETE", "OPTIONS"] 77 | allow_headers: ["content-type", "authorization"] 78 | max_age: 3600 79 | paths: 80 | '^/': ~ 81 | 82 | # Nelmio API Doc 83 | nelmio_api_doc: 84 | sandbox: 85 | accept_type: "application/json" 86 | body_format: 87 | formats: [ "json" ] 88 | default_format: "json" 89 | request_format: 90 | formats: 91 | json: "application/json" 92 | 93 | 94 | # FOS User Bundle 95 | fos_user: 96 | db_driver: orm # other valid values are 'mongodb', 'couchdb' and 'propel' 97 | firewall_name: main 98 | user_class: AppBundle\Entity\User 99 | from_email: 100 | address: "%fos_user.from_email.address%" 101 | sender_name: "%fos_user.from_email.sender_name%" 102 | 103 | 104 | # FOS REST Bundle 105 | fos_rest: 106 | body_listener: true 107 | format_listener: true 108 | param_fetcher_listener: true 109 | view: 110 | view_response_listener: 'force' 111 | exception_wrapper_handler: null 112 | formats: 113 | jsonp: true 114 | json: true 115 | xml: false 116 | rss: false 117 | mime_types: 118 | json: ['application/json', 'application/x-json'] 119 | jpg: 'image/jpeg' 120 | png: 'image/png' 121 | jsonp_handler: ~ 122 | routing_loader: 123 | default_format: json 124 | include_format: false 125 | format_listener: 126 | rules: 127 | - { path: ^/, priorities: [ json, jsonp ], fallback_format: ~, prefer_extension: true } 128 | exception: 129 | enabled: true 130 | 131 | 132 | # Lexik JWT Auth 133 | lexik_jwt_authentication: 134 | private_key_path: %jwt_private_key_path% 135 | public_key_path: %jwt_public_key_path% 136 | pass_phrase: %jwt_key_pass_phrase% 137 | token_ttl: %jwt_token_ttl% 138 | 139 | 140 | # CSA Guzzle 141 | csa_guzzle: 142 | profiler: %kernel.debug% 143 | 144 | 145 | # JMS Serializer 146 | jms_serializer: 147 | metadata: 148 | directories: 149 | FOSUB: 150 | namespace_prefix: FOS\UserBundle 151 | path: %kernel.root_dir%/serializer/FOSUB 152 | 153 | 154 | # Oneup Flysystem 155 | oneup_flysystem: 156 | adapters: 157 | local_adapter: 158 | local: 159 | directory: %kernel.root_dir%/../uploads 160 | writeFlags: ~ 161 | linkHandling: ~ 162 | filesystems: 163 | local: 164 | adapter: local_adapter 165 | cache: ~ 166 | alias: ~ 167 | mount: ~ -------------------------------------------------------------------------------- /src/AppBundle/Entity/Account.php: -------------------------------------------------------------------------------- 1 | ") 34 | * @JMSSerializer\MaxDepth(2) 35 | * @JMSSerializer\Groups({"accounts_all"}) 36 | */ 37 | private $users; 38 | 39 | /** 40 | * @ORM\ManyToMany(targetEntity="File", inversedBy="accounts") 41 | * @ORM\JoinTable(name="accounts__files") 42 | * 43 | * @JMSSerializer\Expose 44 | * @JMSSerializer\Type("array") 45 | **/ 46 | private $files; 47 | 48 | /** 49 | * @ORM\Column(type="string", name="name") 50 | * @JMSSerializer\Expose 51 | * @JMSSerializer\Groups({"accounts_all", "accounts_summary"}) 52 | */ 53 | private $name; 54 | 55 | 56 | public function __construct($accountName) 57 | { 58 | $this->name = $accountName; 59 | $this->users = new ArrayCollection(); 60 | $this->files = new ArrayCollection(); 61 | } 62 | 63 | public function getName() 64 | { 65 | return $this->name; 66 | } 67 | 68 | public function changeName($newName) 69 | { 70 | $this->name = (string) $newName; 71 | 72 | return $this; 73 | } 74 | 75 | public function getId() 76 | { 77 | return $this->id; 78 | } 79 | 80 | /** 81 | * @return mixed 82 | */ 83 | public function getUsers() 84 | { 85 | return $this->users; 86 | } 87 | 88 | public function addUsers(array $users) 89 | { 90 | foreach ($users as $user) { 91 | $this->addUser($user); 92 | } 93 | 94 | return $this; 95 | } 96 | 97 | public function addUser(UserInterface $user) 98 | { 99 | if ( ! $this->isManagedBy($user)) { 100 | $this->users->add($user); 101 | } 102 | 103 | return $this; 104 | } 105 | 106 | public function isManagedBy(UserInterface $user) 107 | { 108 | return $this->users->contains($user); 109 | } 110 | 111 | public function removeUser(UserInterface $user) 112 | { 113 | if ($this->isManagedBy($user)) { 114 | $this->users->removeElement($user); 115 | } 116 | 117 | return $this; 118 | } 119 | 120 | public function removeAllUsers() 121 | { 122 | foreach ($this->users as $user) { /** @var UserInterface $user */ 123 | $user->removeAccount($this); 124 | $this->removeUser($user); 125 | } 126 | } 127 | 128 | 129 | /** 130 | * @return ArrayCollection 131 | */ 132 | public function getFiles() 133 | { 134 | return $this->files; 135 | } 136 | 137 | /** 138 | * @param array $files 139 | * @return $this 140 | */ 141 | public function addFiles(array $files) 142 | { 143 | foreach ($files as $file) { 144 | $this->addFile($file); 145 | } 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * @param FileInterface $file 152 | * @return $this 153 | */ 154 | public function addFile(FileInterface $file) 155 | { 156 | if ( ! $this->usesFile($file)) { 157 | $file->addAccount($this); 158 | $this->files->add($file); 159 | } 160 | 161 | return $this; 162 | } 163 | 164 | /** 165 | * @param FileInterface $file 166 | * @return bool 167 | */ 168 | public function usesFile(FileInterface $file) 169 | { 170 | return $this->files->contains($file); 171 | } 172 | 173 | /** 174 | * @param FileInterface $file 175 | * @return $this 176 | */ 177 | public function removeFile(FileInterface $file) 178 | { 179 | if ($this->usesFile($file)) { 180 | $file->removeAccount($this); 181 | $this->files->removeElement($file); 182 | } 183 | 184 | return $this; 185 | } 186 | 187 | /** 188 | * @return $this 189 | */ 190 | public function removeAllFiles() 191 | { 192 | foreach ($this->files as $file) { /** @var FileInterface $file */ 193 | $this->removeFile($file); 194 | } 195 | 196 | return $this; 197 | } 198 | 199 | 200 | 201 | /** 202 | * @return mixed 203 | */ 204 | function jsonSerialize() 205 | { 206 | return [ 207 | 'id' => $this->id, 208 | 'name' => $this->name, 209 | 'users' => $this->users, 210 | 'files' => $this->files, 211 | ]; 212 | } 213 | 214 | } 215 | -------------------------------------------------------------------------------- /src/AppBundle/Handler/AccountHandler.php: -------------------------------------------------------------------------------- 1 | formHandler = $formHandler; 44 | $this->repository = $accountRepository; 45 | $this->factory = $accountFactory; 46 | $this->dataTransformer = $dataTransformer; 47 | } 48 | 49 | /** 50 | * @param int $id 51 | * @return mixed 52 | */ 53 | public function get($id) 54 | { 55 | if ($id === null) { 56 | throw new BadRequestHttpException('An account ID was not specified.'); 57 | } 58 | 59 | return $this->repository->findOneById($id); 60 | } 61 | 62 | /** 63 | * @param int $limit 64 | * @param int $offset 65 | * @return mixed 66 | */ 67 | public function all($limit = 10, $offset = 0) 68 | { 69 | if ($this->user === null || ! $this->user instanceof UserInterface) { 70 | throw new \BadMethodCallException('Unable to find a User, did you remember to set one?'); 71 | } 72 | 73 | return $this->repository->findAllForUser($this->user)->slice($offset, $limit); 74 | } 75 | 76 | /** 77 | * @param array $parameters 78 | * @param array $options 79 | * @return AccountInterface 80 | */ 81 | public function post(array $parameters, array $options = []) 82 | { 83 | $accountDTO = $this->formHandler->handle( 84 | new AccountDTO(), 85 | $parameters, 86 | Request::METHOD_POST, 87 | $options 88 | ); 89 | 90 | $account = $this->factory->createFromDTO($accountDTO); 91 | 92 | $this->repository->save($account); 93 | 94 | return $account; 95 | } 96 | 97 | 98 | /** 99 | * @param AccountInterface $account 100 | * @param array $parameters 101 | * @param array $options 102 | * @return mixed 103 | */ 104 | public function patch($account, array $parameters, array $options = []) 105 | { 106 | $this->guardAccountImplementsInterface($account); 107 | 108 | /** @var AccountInterface $account */ 109 | $accountDTO = $this->dataTransformer->convertToDTO($account); 110 | 111 | $accountDTO = $this->formHandler->handle( 112 | $accountDTO, 113 | $parameters, 114 | Request::METHOD_PATCH, 115 | $options 116 | ); 117 | 118 | $this->repository->refresh($account); 119 | 120 | $account = $this->dataTransformer->updateFromDTO($account, $accountDTO); 121 | 122 | $this->repository->save($account); 123 | 124 | return $account; 125 | } 126 | 127 | 128 | /** 129 | * @param AccountInterface $account 130 | * @param array $parameters 131 | * @param array $options 132 | * @return mixed 133 | */ 134 | public function put($account, array $parameters, array $options = []) 135 | { 136 | $this->guardAccountImplementsInterface($account); 137 | 138 | /** @var AccountInterface $account */ 139 | $accountDTO = $this->dataTransformer->convertToDTO($account); 140 | 141 | $accountDTO = $this->formHandler->handle( 142 | $accountDTO, 143 | $parameters, 144 | Request::METHOD_PUT, 145 | $options 146 | ); 147 | 148 | $this->repository->refresh($account); 149 | 150 | $account = $this->dataTransformer->updateFromDTO($account, $accountDTO); 151 | 152 | $this->repository->save($account); 153 | 154 | return $account; 155 | } 156 | 157 | 158 | /** 159 | * @param mixed $resource 160 | * @return bool 161 | */ 162 | public function delete($resource) 163 | { 164 | $this->guardAccountImplementsInterface($resource); 165 | 166 | $this->repository->delete($resource); 167 | 168 | return true; 169 | } 170 | 171 | /** 172 | * @param UserInterface $user 173 | * @return $this 174 | */ 175 | public function setUser($user) 176 | { 177 | $this->user = $user; 178 | 179 | return $this; 180 | } 181 | 182 | /** 183 | * @param $account 184 | */ 185 | private function guardAccountImplementsInterface($account) 186 | { 187 | if (!$account instanceof AccountInterface) { 188 | throw new \InvalidArgumentException('Expected passed Account to implement AccountInterface'); 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/AppBundle/Entity/File.php: -------------------------------------------------------------------------------- 1 | accounts = new ArrayCollection(); 87 | 88 | $this->originalFileName = $originalFileName; 89 | $this->internalFileName = $internalFileName; 90 | $this->guessedExtension = $guessedExtension; 91 | $this->fileSize = $fileSize; 92 | 93 | $this->displayedFileName = $originalFileName; 94 | } 95 | 96 | /** 97 | * @return mixed 98 | */ 99 | public function getId() 100 | { 101 | return $this->id; 102 | } 103 | 104 | /** 105 | * @return string 106 | */ 107 | public function getOriginalFileName() 108 | { 109 | return $this->originalFileName; 110 | } 111 | 112 | /** 113 | * @return string 114 | */ 115 | public function getInternalFileName() 116 | { 117 | return $this->internalFileName; 118 | } 119 | 120 | /** 121 | * @return string 122 | */ 123 | public function getGuessedExtension() 124 | { 125 | return $this->guessedExtension; 126 | } 127 | 128 | /** 129 | * @return mixed 130 | */ 131 | public function getDisplayedFileName() 132 | { 133 | return $this->displayedFileName; 134 | } 135 | 136 | /** 137 | * @return File 138 | */ 139 | public function changeDisplayedFileName($newName) 140 | { 141 | $this->displayedFileName = $newName; 142 | 143 | return $this; 144 | } 145 | 146 | /** 147 | * @param AccountInterface $account 148 | * @return File 149 | */ 150 | public function addAccount(AccountInterface $account) 151 | { 152 | if ( ! $this->hasAccount($account)) { 153 | $this->accounts->add($account); 154 | } 155 | 156 | return $this; 157 | } 158 | 159 | /** 160 | * @param AccountInterface $account 161 | * @return File 162 | */ 163 | public function removeAccount(AccountInterface $account) 164 | { 165 | if ($this->hasAccount($account)) { 166 | $this->accounts->removeElement($account); 167 | } 168 | 169 | return $this; 170 | } 171 | 172 | /** 173 | * @param AccountInterface $account 174 | * @return bool 175 | */ 176 | public function hasAccount(AccountInterface $account) 177 | { 178 | return $this->accounts->contains($account); 179 | } 180 | 181 | /** 182 | * @return Collection 183 | */ 184 | public function getAccounts() 185 | { 186 | return $this->accounts; 187 | } 188 | 189 | /** 190 | * @return int 191 | */ 192 | public function getFileSize() 193 | { 194 | return $this->fileSize; 195 | } 196 | 197 | /** 198 | * @param UserInterface $user 199 | * @return bool 200 | */ 201 | public function belongsToUser(UserInterface $user) 202 | { 203 | return $this 204 | ->getAccounts() 205 | ->filter(function ($account) use ($user) { /** @var $account AccountInterface */ 206 | return $account->isManagedBy($user); 207 | }) 208 | ->count() > 0 209 | ; 210 | } 211 | 212 | /** 213 | * @return string 214 | */ 215 | function jsonSerialize() 216 | { 217 | return [ 218 | 'id' => $this->id, 219 | 'originalFileName' => $this->originalFileName, 220 | 'internalFileName' => $this->internalFileName, 221 | 'guessedExtension' => $this->guessedExtension, 222 | 'fileSize' => $this->fileSize, 223 | ]; 224 | } 225 | 226 | } -------------------------------------------------------------------------------- /src/AppBundle/Handler/FileHandler.php: -------------------------------------------------------------------------------- 1 | formHandler = $formHandler; 68 | $this->repository = $fileRepository; 69 | $this->factory = $fileFactory; 70 | $this->dataTransformer = $dataTransformer; 71 | $this->filesystem = $filesystem; 72 | $this->uploadFilesystem = $uploadFilesystem; 73 | } 74 | 75 | /** 76 | * @param AccountInterface $account 77 | * @return $this 78 | */ 79 | public function setAccount(AccountInterface $account) 80 | { 81 | $this->account = $account; 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * @return AccountInterface 88 | */ 89 | public function getAccount() 90 | { 91 | if ( empty($this->account) || ! $this->account instanceof AccountInterface) { 92 | throw new \BadMethodCallException('Unable to find a valid Account. Did you forget to set it?'); 93 | } 94 | 95 | return $this->account; 96 | } 97 | 98 | /** 99 | * @param int $id 100 | * @return mixed 101 | */ 102 | public function get($id) 103 | { 104 | return $this->repository->findOneById($id); 105 | } 106 | 107 | /** 108 | * @param int $limit 109 | * @param int $offset 110 | * @return mixed 111 | */ 112 | public function all($limit = 10, $offset = 0) 113 | { 114 | return $this->repository->findAllForAccount($this->getAccount())->slice($offset, $limit); 115 | } 116 | 117 | /** 118 | * @param array $parameters 119 | * @param array $options 120 | * @return FileInterface 121 | */ 122 | public function post(array $parameters, array $options = []) 123 | { 124 | $account = $this->getAccount(); 125 | 126 | $options = array_replace_recursive([ 127 | 'validation_groups' => ['post'], 128 | 'has_file' => true, 129 | ], $options); 130 | 131 | $fileDTO = $this->formHandler->handle( 132 | new FileDTO(), 133 | $parameters, 134 | Request::METHOD_POST, 135 | $options 136 | ); /** @var $fileDTO FileDTO */ 137 | 138 | $file = $this->factory->createFromUploadedFile($fileDTO->getUploadedFile()); 139 | $file->changeDisplayedFileName($fileDTO->getName()); 140 | 141 | $fileContents = $this->uploadFilesystem->getFileContentsFromPath($fileDTO->getUploadedFile()->getFilePath()); 142 | $this->filesystem->put($file->getInternalFileName(), $fileContents); 143 | 144 | $account->addFile($file); 145 | 146 | $this->repository->save($file); 147 | 148 | return $file; 149 | } 150 | 151 | 152 | /** 153 | * @param FileInterface $file 154 | * @param array $parameters 155 | * @param array $options 156 | * @return mixed 157 | */ 158 | public function patch($file, array $parameters, array $options = []) 159 | { 160 | $this->guardFileImplementsInterface($file); 161 | 162 | $options = array_replace_recursive([ 163 | 'validation_groups' => ['patch'], 164 | 'has_file' => false, 165 | ], $options); 166 | 167 | $fileDTO = $this->formHandler->handle( 168 | new FileDTO(), 169 | $parameters, 170 | Request::METHOD_PATCH, 171 | $options 172 | ); /** @var $fileDTO FileDTO */ 173 | 174 | $this->repository->refresh($file); 175 | 176 | $file = $this->dataTransformer->updateFromDTO($file, $fileDTO); 177 | 178 | $this->repository->save($file); 179 | 180 | return $file; 181 | } 182 | 183 | 184 | /** 185 | * @param FileInterface $file 186 | * @param array $parameters 187 | * @param array $options 188 | * @return mixed 189 | */ 190 | public function put($file, array $parameters, array $options = []) 191 | { 192 | $this->guardFileImplementsInterface($file); 193 | 194 | $options = array_replace_recursive([ 195 | 'validation_groups' => ['put'], 196 | 'has_file' => false, 197 | ], $options); 198 | 199 | $fileDTO = $this->formHandler->handle( 200 | new FileDTO(), 201 | $parameters, 202 | Request::METHOD_PUT, 203 | $options 204 | ); /** @var $fileDTO FileDTO */ 205 | 206 | $this->repository->refresh($file); 207 | 208 | $file = $this->dataTransformer->updateFromDTO($file, $fileDTO); 209 | 210 | $this->repository->save($file); 211 | 212 | return $file; 213 | } 214 | 215 | 216 | /** 217 | * @param FileInterface $file 218 | * @return bool 219 | */ 220 | public function delete($file) 221 | { 222 | $this->guardFileImplementsInterface($file); 223 | 224 | $isDeleted = $this->filesystem->delete($file->getInternalFileName()); 225 | 226 | $this->repository->delete($file); 227 | 228 | return $isDeleted; 229 | } 230 | 231 | /** 232 | * This method is in place as class can't use a type hint due to interface 233 | * @param $file 234 | */ 235 | private function guardFileImplementsInterface($file) 236 | { 237 | if (!$file instanceof FileInterface) { 238 | throw new \InvalidArgumentException('Expected passed File to implement FileInterface'); 239 | } 240 | } 241 | } -------------------------------------------------------------------------------- /src/AppBundle/Controller/AccountsController.php: -------------------------------------------------------------------------------- 1 | getAccountRepository()->findOneById($accountId); 48 | } 49 | 50 | /** 51 | * Gets a collection of the given User's Accounts. 52 | * 53 | * @ApiDoc( 54 | * output = "AppBundle\Entity\Account", 55 | * statusCodes = { 56 | * 200 = "Returned when successful", 57 | * 404 = "Returned when not found" 58 | * } 59 | * ) 60 | * 61 | * @throws NotFoundHttpException when does not exist 62 | * 63 | * @Annotations\View(serializerGroups={ 64 | * "accounts_all", 65 | * "users_summary" 66 | * }) 67 | * 68 | * @return View 69 | */ 70 | public function cgetAction() 71 | { 72 | $user = $this->getUser(); 73 | 74 | return $this->getAccountRepository()->findAllForUser($user); 75 | } 76 | 77 | 78 | /** 79 | * Creates a new Account 80 | * 81 | * @ApiDoc( 82 | * input = "AppBundle\Form\Type\AccountFormType", 83 | * output = "AppBundle\Entity\Account", 84 | * statusCodes={ 85 | * 201="Returned when a new Account has been successfully created", 86 | * 400="Returned when the posted data is invalid" 87 | * } 88 | * ) 89 | * 90 | * @param Request $request 91 | * @return View 92 | */ 93 | public function postAction(Request $request) 94 | { 95 | try { 96 | 97 | $account = $this->getHandler()->post($request->request->all()); 98 | 99 | $routeOptions = [ 100 | 'accountId' => $account->getId(), 101 | '_format' => $request->get('_format'), 102 | ]; 103 | 104 | return $this->routeRedirectView('get_accounts', $routeOptions, Response::HTTP_CREATED); 105 | 106 | } catch (InvalidFormException $e) { 107 | 108 | return $e->getForm(); 109 | } 110 | } 111 | 112 | 113 | /** 114 | * Update existing Account from the submitted data 115 | * 116 | * @ApiDoc( 117 | * resource = true, 118 | * input = "AppBundle\Form\AccountType", 119 | * output = "AppBundle\Entity\Account", 120 | * statusCodes = { 121 | * 204 = "Returned when successful", 122 | * 400 = "Returned when errors", 123 | * 404 = "Returned when not found" 124 | * } 125 | * ) 126 | * 127 | * @param Request $request the request object 128 | * @param int $id the account id 129 | * 130 | * @return FormTypeInterface|RouteRedirectView 131 | * 132 | * @throws NotFoundHttpException when does not exist 133 | */ 134 | public function patchAction(Request $request, $id) 135 | { 136 | $requestedAccount = $this->get('crv.repository.restricted_account_repository')->findOneById($id); 137 | 138 | try { 139 | 140 | $account = $this->getHandler()->patch( 141 | $requestedAccount, 142 | $request->request->all() 143 | ); 144 | 145 | $routeOptions = [ 146 | 'accountId' => $account->getId(), 147 | '_format' => $request->get('_format'), 148 | ]; 149 | 150 | return $this->routeRedirectView('get_accounts', $routeOptions, Response::HTTP_NO_CONTENT); 151 | 152 | } catch (InvalidFormException $e) { 153 | 154 | return $e->getForm(); 155 | } 156 | } 157 | 158 | 159 | /** 160 | * Replaces existing Account from the submitted data 161 | * 162 | * @ApiDoc( 163 | * resource = true, 164 | * input = "AppBundle\Form\AccountType", 165 | * output = "AppBundle\Entity\Account", 166 | * statusCodes = { 167 | * 204 = "Returned when successful", 168 | * 400 = "Returned when errors", 169 | * 404 = "Returned when not found" 170 | * } 171 | * ) 172 | * 173 | * @param Request $request the request object 174 | * @param int $id the account id 175 | * 176 | * @return FormTypeInterface|RouteRedirectView 177 | * 178 | * @throws NotFoundHttpException when does not exist 179 | */ 180 | public function putAction(Request $request, $id) 181 | { 182 | $requestedAccount = $this->get('crv.repository.restricted_account_repository')->findOneById($id); 183 | 184 | try { 185 | 186 | $account = $this->getHandler()->put( 187 | $requestedAccount, 188 | $request->request->all() 189 | ); 190 | 191 | $routeOptions = [ 192 | 'accountId' => $account->getId(), 193 | '_format' => $request->get('_format'), 194 | ]; 195 | 196 | return $this->routeRedirectView('get_accounts', $routeOptions, Response::HTTP_NO_CONTENT); 197 | 198 | } catch (InvalidFormException $e) { 199 | 200 | return $e->getForm(); 201 | } 202 | } 203 | 204 | 205 | /** 206 | * Deletes a specific Account by ID 207 | * 208 | * @ApiDoc( 209 | * description="Deletes an existing Account", 210 | * statusCodes={ 211 | * 204="Returned when an existing Account has been successfully deleted", 212 | * 403="Returned when trying to delete a non existent Account" 213 | * } 214 | * ) 215 | * 216 | * @param int $id the account id 217 | * @return View 218 | */ 219 | public function deleteAction($id) 220 | { 221 | $requestedAccount = $this->get('crv.repository.restricted_account_repository')->findOneById($id); 222 | 223 | $this->getHandler()->delete($requestedAccount); 224 | 225 | return new View(null, Response::HTTP_NO_CONTENT); 226 | } 227 | 228 | 229 | /** 230 | * Returns the required handler for this controller 231 | * 232 | * @return \AppBundle\Handler\AccountHandler 233 | */ 234 | private function getHandler() 235 | { 236 | return $this->get('crv.handler.restricted_account_handler'); 237 | } 238 | 239 | /** 240 | * @return \AppBundle\Repository\Restricted\RestrictedAccountRepository 241 | */ 242 | private function getAccountRepository() 243 | { 244 | return $this->get('crv.repository.restricted_account_repository'); 245 | } 246 | } -------------------------------------------------------------------------------- /src/AppBundle/Features/file.feature: -------------------------------------------------------------------------------- 1 | Feature: Manage uploaded Files through API 2 | 3 | In order to upload Files resources via an hypermedia API 4 | As a client software developer 5 | I need to be able to retrieve, create, update and delete Files resources. 6 | 7 | 8 | Background: 9 | Given there are Users with the following details: 10 | | uid | username | email | password | 11 | | u1 | peter | peter@test.com | testpass | 12 | | u2 | jonty | jonty@test.net | somepass | 13 | And there are files with the following details: 14 | | uid | originalFileName | internalFileName | guessedExtension | size | dummyFile | 15 | | f1 | some long file name.jpg | intfile1 | jpg | 100 | Image/pk140.jpg | 16 | | f2 | not_terrible.unk | intfile2 | bin | 20 | Image/phit200x100.png | 17 | | f3 | ok.png | intfile3 | png | 666 | | 18 | And there are Accounts with the following details: 19 | | uid | name | users | files | 20 | | a1 | account1 | u1 | f1,f3 | 21 | | a2 | account2 | u1 | f3 | 22 | | a3 | account3 | u2 | f2 | 23 | And I am successfully logged in with username: "peter", and password: "testpass" 24 | And when consuming the endpoint I use the "headers/content-type" of "application/json" 25 | 26 | 27 | Scenario: User can GET a Collection of their Files objects 28 | When I send a "GET" request to "/accounts/a1/files" 29 | Then the response code should be 200 30 | And the response header "Content-Type" should be equal to "application/json; charset=utf-8" 31 | And the response should contain json: 32 | """ 33 | [ 34 | { 35 | "id": "f1", 36 | "originalFileName": "some long file name.jpg", 37 | "internalFileName": "intfile1", 38 | "guessedExtension": "jpg", 39 | "displayedFileName": "some long file name.jpg", 40 | "fileSize": 100 41 | }, 42 | { 43 | "id": "f3", 44 | "originalFileName": "ok.png", 45 | "internalFileName": "intfile3", 46 | "guessedExtension": "png", 47 | "displayedFileName": "ok.png", 48 | "fileSize": 666 49 | } 50 | ] 51 | """ 52 | 53 | 54 | Scenario: User can GET an individual File by ID 55 | When I send a "GET" request to "/accounts/a1/files/f1" 56 | Then the response code should be 200 57 | And the response header "Content-Type" should be equal to "application/json; charset=utf-8" 58 | And the response should contain json: 59 | """ 60 | { 61 | "id": "f1", 62 | "originalFileName": "some long file name.jpg", 63 | "internalFileName": "intfile1", 64 | "guessedExtension": "jpg", 65 | "displayedFileName": "some long file name.jpg", 66 | "fileSize": 100 67 | } 68 | """ 69 | 70 | 71 | Scenario: User cannot access a valid File with the wrong Account ID 72 | When I send a "GET" request to "/accounts/a3/files/f2" 73 | Then the response code should be 403 74 | 75 | 76 | Scenario: User cannot determine if another File exists 77 | When I send a "GET" request to "/accounts/a3/files/f1000" 78 | Then the response code should be 403 79 | 80 | 81 | Scenario: User can add a new File 82 | When I send a multipart "POST" request to "/accounts/a1/files" with form data: 83 | | name | filePath | 84 | | a new file name | Image/pk140.jpg | 85 | Then the response code should be 201 86 | And the response header "Content-Type" should be equal to "application/json; charset=utf-8" 87 | And the I follow the link in the Location response header 88 | And the response should contain json: 89 | """ 90 | { 91 | "originalFileName": "pk140.jpg", 92 | "guessedExtension": "jpg", 93 | "displayedFileName": "a new file name", 94 | "fileSize": 8053 95 | } 96 | """ 97 | 98 | 99 | Scenario: User cannot add a File to an Account they do not have access too 100 | When I send a multipart "POST" request to "/accounts/a3/files" with form data: 101 | | name | filePath | 102 | | a new file name | Image/pk140.jpg | 103 | Then the response code should be 403 104 | 105 | 106 | Scenario: User can PATCH to update the File metadata 107 | When I send a "PATCH" request to "/accounts/a1/files/f1" with body: 108 | """ 109 | { 110 | "name": "a patched file name" 111 | } 112 | """ 113 | Then the response code should be 204 114 | And I send a "GET" request to "/accounts/a1/files/f1" 115 | And the response should contain json: 116 | """ 117 | { 118 | "id": "f1", 119 | "originalFileName": "some long file name.jpg", 120 | "internalFileName": "intfile1", 121 | "guessedExtension": "jpg", 122 | "displayedFileName": "a patched file name", 123 | "fileSize": 100 124 | } 125 | """ 126 | 127 | 128 | Scenario: User cannot PATCH to update the File content 129 | When I send a "PATCH" request to "/accounts/a1/files/f1" with body: 130 | """ 131 | { 132 | "file": "some new file" 133 | } 134 | """ 135 | Then the response code should be 400 136 | 137 | 138 | Scenario: User cannot PATCH to update a File with which they are not authorised 139 | When I send a "PATCH" request to "/accounts/a3/files/f2" 140 | Then the response code should be 403 141 | 142 | 143 | Scenario: User cannot PATCH to update a none-existent File 144 | When I send a "PATCH" request to "/accounts/a1/files/madeup" 145 | Then the response code should be 403 146 | 147 | 148 | Scenario: User can PUT to replace their File metadata 149 | When I send a "PUT" request to "/accounts/a1/files/f1" with body: 150 | """ 151 | { 152 | "name": "a put file name" 153 | } 154 | """ 155 | Then the response code should be 204 156 | And the I follow the link in the Location response header 157 | And the response should contain json: 158 | """ 159 | { 160 | "id": "f1", 161 | "originalFileName": "some long file name.jpg", 162 | "internalFileName": "intfile1", 163 | "guessedExtension": "jpg", 164 | "displayedFileName": "a put file name", 165 | "fileSize": 100 166 | } 167 | """ 168 | 169 | 170 | Scenario: User cannot PUT to update the File content 171 | When I send a "PUT" request to "/accounts/a1/files/f1" with body: 172 | """ 173 | { 174 | "file": "some new file" 175 | } 176 | """ 177 | Then the response code should be 400 178 | 179 | 180 | Scenario: User cannot PUT to update a File with which they are not authorised 181 | When I send a "PUT" request to "/accounts/a3/files/f2" 182 | Then the response code should be 403 183 | 184 | 185 | Scenario: User cannot PUT to update a File that doesn't exist 186 | When I send a "PUT" request to "/accounts/a1/files/invalid-file-id" 187 | Then the response code should be 403 188 | 189 | @t 190 | Scenario: User can DELETE a File 191 | When I send a "DELETE" request to "/accounts/a1/files/f1" 192 | Then the response code should be 204 193 | And the "File" with id: f1 should have been deleted 194 | And the file with internal name: "intfile1" should have been deleted 195 | 196 | 197 | Scenario: User cannot DELETE a File they do not own 198 | When I send a "DELETE" request to "/accounts/a3/files/f2" 199 | Then the response code should be 403 200 | And the file with internal name: "intfile2" should not have been deleted -------------------------------------------------------------------------------- /app/config/services.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | crv.user.entity: 'AppBundle\Entity\User' 3 | 4 | services: 5 | 6 | # -- DATA TRANSFORMERs -- 7 | crv.data_transformer.account_data_transformer: 8 | class: AppBundle\DataTransformer\AccountDataTransformer 9 | 10 | crv.data_transformer.file_data_transformer: 11 | class: AppBundle\DataTransformer\FileDataTransformer 12 | 13 | 14 | # -- DOCTRINE ENTITY REPOSITORY -- 15 | crv.doctrine_entity_repository.account: 16 | class: Doctrine\ORM\EntityRepository 17 | factory: ["@doctrine", getRepository] 18 | arguments: 19 | - AppBundle\Entity\Account 20 | 21 | crv.doctrine_entity_repository.file: 22 | class: Doctrine\ORM\EntityRepository 23 | factory: ["@doctrine", getRepository] 24 | arguments: 25 | - AppBundle\Entity\File 26 | 27 | crv.doctrine_entity_repository.user: 28 | class: Doctrine\ORM\EntityRepository 29 | factory: ["@doctrine", getRepository] 30 | arguments: 31 | - AppBundle\Entity\User 32 | 33 | 34 | 35 | # -- EVENTS -- 36 | crv.event.listener.content_type_listener: 37 | class: AppBundle\Event\Listener\ContentTypeListener 38 | tags: 39 | - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest } 40 | 41 | 42 | 43 | # -- FACTORY -- 44 | crv.factory.account_factory: 45 | class: AppBundle\Factory\AccountFactory 46 | 47 | crv.factory.file_factory: 48 | class: AppBundle\Factory\FileFactory 49 | 50 | 51 | # -- FORM -- 52 | crv.form.type.account: 53 | class: AppBundle\Form\Type\AccountType 54 | arguments: 55 | - "@crv.repository.doctrine_user_repository" 56 | tags: 57 | - { name: form.type, alias: account_form } 58 | 59 | crv.form.type.file: 60 | class: AppBundle\Form\Type\FileType 61 | tags: 62 | - { name: form.type, alias: file_form } 63 | 64 | crv.form.type.user: 65 | class: AppBundle\Form\Type\UserType 66 | arguments: 67 | - "%crv.user.entity%" 68 | tags: 69 | - { name: form.type, alias: user_form } 70 | 71 | 72 | crv.form.type.restricted_account: 73 | class: AppBundle\Form\Type\AccountType 74 | arguments: 75 | - "@crv.repository.restricted_user_repository" 76 | tags: 77 | - { name: form.type, alias: restricted_account_form } 78 | 79 | crv.form.type.restricted_file: 80 | class: AppBundle\Form\Type\FileType 81 | tags: 82 | - { name: form.type, alias: restricted_file_form } 83 | 84 | crv.form.type.restricted_user: 85 | class: AppBundle\Form\Type\UserType 86 | arguments: 87 | - "%crv.user.entity%" 88 | tags: 89 | - { name: form.type, alias: restricted_user_form } 90 | 91 | 92 | # -- FORM HANDLER -- 93 | crv.form.handler.account_form_handler: 94 | class: AppBundle\Form\Handler\FormHandler 95 | arguments: 96 | - "@form.factory" 97 | - "@crv.form.type.account" 98 | 99 | crv.form.handler.file_form_handler: 100 | class: AppBundle\Form\Handler\FormHandler 101 | arguments: 102 | - "@form.factory" 103 | - "@crv.form.type.file" 104 | 105 | crv.form.handler.user_form_handler: 106 | class: AppBundle\Form\Handler\FormHandler 107 | arguments: 108 | - "@form.factory" 109 | - "@crv.form.type.user" 110 | 111 | 112 | crv.form.handler.restricted_account_form_handler: 113 | class: AppBundle\Form\Handler\FormHandler 114 | arguments: 115 | - "@form.factory" 116 | - "@crv.form.type.restricted_account" 117 | 118 | crv.form.handler.restricted_file_form_handler: 119 | class: AppBundle\Form\Handler\FormHandler 120 | arguments: 121 | - "@form.factory" 122 | - "@crv.form.type.restricted_file" 123 | 124 | crv.form.handler.restricted_user_form_handler: 125 | class: AppBundle\Form\Handler\FormHandler 126 | arguments: 127 | - "@form.factory" 128 | - "@crv.form.type.restricted_user" 129 | 130 | 131 | # -- HANDLER -- 132 | crv.handler.restricted_account_handler: 133 | class: AppBundle\Handler\AccountHandler 134 | arguments: 135 | - "@crv.form.handler.restricted_account_form_handler" 136 | - "@crv.data_transformer.account_data_transformer" 137 | - "@crv.repository.restricted_account_repository" 138 | - "@crv.factory.account_factory" 139 | 140 | crv.handler.restricted_file_handler: 141 | class: AppBundle\Handler\FileHandler 142 | arguments: 143 | - "@crv.form.handler.restricted_file_form_handler" 144 | - "@crv.data_transformer.file_data_transformer" 145 | - "@crv.repository.restricted_file_repository" 146 | - "@crv.factory.file_factory" 147 | - "@oneup_flysystem.local_filesystem" 148 | - "@crv.utility.upload_file_system" 149 | 150 | crv.handler.restricted_user_handler: 151 | class: AppBundle\Handler\UserHandler 152 | arguments: 153 | - "@crv.form.handler.restricted_user_form_handler" 154 | - "@crv.repository.restricted_user_repository" 155 | 156 | 157 | 158 | # -- REPOSITORY -- 159 | crv.repository.common_doctrine_repository: 160 | class: AppBundle\Repository\Doctrine\CommonDoctrineRepository 161 | arguments: 162 | - "@doctrine.orm.entity_manager" 163 | 164 | crv.repository.doctrine_account_repository: 165 | class: AppBundle\Repository\Doctrine\DoctrineAccountRepository 166 | arguments: 167 | - "@crv.repository.common_doctrine_repository" 168 | - "@crv.doctrine_entity_repository.account" 169 | 170 | crv.repository.doctrine_file_repository: 171 | class: AppBundle\Repository\Doctrine\DoctrineFileRepository 172 | arguments: 173 | - "@crv.repository.common_doctrine_repository" 174 | - "@crv.doctrine_entity_repository.file" 175 | 176 | crv.repository.doctrine_user_repository: 177 | class: AppBundle\Repository\Doctrine\DoctrineUserRepository 178 | arguments: 179 | - "@crv.repository.common_doctrine_repository" 180 | - "@doctrine.orm.entity_manager" 181 | 182 | 183 | crv.repository.restricted_account_repository: 184 | class: AppBundle\Repository\Restricted\RestrictedAccountRepository 185 | arguments: 186 | - "@crv.repository.doctrine_account_repository" 187 | - "@security.authorization_checker" 188 | 189 | crv.repository.restricted_file_repository: 190 | class: AppBundle\Repository\Restricted\RestrictedFileRepository 191 | arguments: 192 | - "@crv.repository.doctrine_file_repository" 193 | - "@security.authorization_checker" 194 | 195 | crv.repository.restricted_user_repository: 196 | class: AppBundle\Repository\Restricted\RestrictedUserRepository 197 | arguments: 198 | - "@crv.repository.doctrine_user_repository" 199 | - "@security.authorization_checker" 200 | 201 | 202 | 203 | # # -- MANAGERS -- 204 | # crv.manager.user_manager: 205 | # class: AppBundle\Model\UserManager 206 | # arguments: 207 | # - "@fos_user.user_manager" 208 | 209 | 210 | 211 | # -- SECURITY --- 212 | crv.security.authorization.voter.account_voter: 213 | class: AppBundle\Security\Authorization\Voter\AccountVoter 214 | public: false 215 | tags: 216 | - { name: security.voter } 217 | 218 | crv.security.authorization.voter.file_voter: 219 | class: AppBundle\Security\Authorization\Voter\FileVoter 220 | public: false 221 | tags: 222 | - { name: security.voter } 223 | 224 | crv.security.authorization.voter.user_voter: 225 | class: AppBundle\Security\Authorization\Voter\UserVoter 226 | public: false 227 | tags: 228 | - { name: security.voter } 229 | 230 | 231 | 232 | # -- UTILITY -- 233 | crv.utility.upload_file_system: 234 | class: AppBundle\Util\UploadFilesystem -------------------------------------------------------------------------------- /src/AppBundle/Controller/FilesController.php: -------------------------------------------------------------------------------- 1 | getFileHandler()->get($fileId); 47 | } 48 | 49 | /** 50 | * Gets a collection of the given User's Files. 51 | * 52 | * @ApiDoc( 53 | * output = "AppBundle\Entity\File", 54 | * statusCodes = { 55 | * 200 = "Returned when successful", 56 | * 404 = "Returned when not found" 57 | * } 58 | * ) 59 | * 60 | * @param int $accountId the Account id 61 | * 62 | * @Annotations\View(serializerGroups={ "files_all", "accounts_summary" }) 63 | * 64 | * @throws NotFoundHttpException when does not exist 65 | * 66 | * @return View 67 | */ 68 | public function cgetAction($accountId) 69 | { 70 | $account = $this->getAccountHandler()->get($accountId); 71 | 72 | return $this 73 | ->getFileHandler() 74 | ->setAccount($account) 75 | ->all() 76 | ; 77 | } 78 | 79 | 80 | /** 81 | * Creates a new File 82 | * 83 | * @ApiDoc( 84 | * input = "AppBundle\Form\Type\FileType", 85 | * output = "AppBundle\Entity\File", 86 | * statusCodes={ 87 | * 201="Returned when a new File has been successfully created", 88 | * 400="Returned when the posted data is invalid" 89 | * } 90 | * ) 91 | * 92 | * @param Request $request 93 | * @param int $accountId the account id 94 | * 95 | * @return View 96 | */ 97 | public function postAction(Request $request, $accountId) 98 | { 99 | $this->getFileHandler()->setAccount( 100 | $this->getAccountHandler()->get($accountId) 101 | ); 102 | 103 | $parameters = array_replace_recursive( 104 | $request->request->all(), 105 | $request->files->all() 106 | ); 107 | 108 | try { 109 | $file = $this->getFileHandler()->post($parameters); 110 | } catch (InvalidFormException $e) { 111 | 112 | return $e->getForm(); 113 | } 114 | 115 | $routeOptions = [ 116 | 'accountId' => $accountId, 117 | 'fileId' => $file->getId(), 118 | '_format' => $request->get('_format'), 119 | ]; 120 | 121 | return $this->routeRedirectView('get_accounts_files', $routeOptions, Response::HTTP_CREATED); 122 | } 123 | 124 | 125 | /** 126 | * Update existing File from the submitted data 127 | * 128 | * @ApiDoc( 129 | * resource = true, 130 | * input = "AppBundle\Form\FileType", 131 | * output = "AppBundle\Entity\File", 132 | * statusCodes = { 133 | * 204 = "Returned when successful", 134 | * 400 = "Returned when errors", 135 | * 404 = "Returned when not found" 136 | * } 137 | * ) 138 | * 139 | * @param Request $request the request object 140 | * @param int $accountId the account id 141 | * @param int $fileId the file id 142 | * 143 | * @return FormTypeInterface|RouteRedirectView 144 | * 145 | * @throws NotFoundHttpException when does not exist 146 | */ 147 | public function patchAction(Request $request, $accountId, $fileId) 148 | { 149 | $this->getFileHandler()->setAccount( 150 | $this->getAccountHandler()->get($accountId) 151 | ); 152 | 153 | $file = $this->getFileHandler()->get($fileId); 154 | 155 | $parameters = array_replace_recursive( 156 | $request->request->all(), 157 | $request->files->all() 158 | ); 159 | 160 | try { 161 | $file = $this->getFileHandler()->patch($file, $parameters); 162 | } catch (InvalidFormException $e) { 163 | 164 | return $e->getForm(); 165 | } 166 | 167 | $routeOptions = [ 168 | 'accountId' => $fileId, 169 | 'fileId' => $file->getId(), 170 | '_format' => $request->get('_format'), 171 | ]; 172 | 173 | return $this->routeRedirectView('get_accounts_files', $routeOptions, Response::HTTP_NO_CONTENT); 174 | } 175 | 176 | 177 | /** 178 | * Replaces existing File from the submitted data 179 | * 180 | * @ApiDoc( 181 | * resource = true, 182 | * input = "AppBundle\Form\FileType", 183 | * output = "AppBundle\Entity\File", 184 | * statusCodes = { 185 | * 204 = "Returned when successful", 186 | * 400 = "Returned when errors", 187 | * 404 = "Returned when not found" 188 | * } 189 | * ) 190 | * 191 | * @param Request $request the request object 192 | * @param int $accountId the account id 193 | * @param int $fileId the file id 194 | * 195 | * @return FormTypeInterface|RouteRedirectView 196 | * 197 | * @throws NotFoundHttpException when does not exist 198 | */ 199 | public function putAction(Request $request, $accountId, $fileId) 200 | { 201 | $this->getFileHandler()->setAccount( 202 | $this->getAccountHandler()->get($accountId) 203 | ); 204 | 205 | $file = $this->getFileHandler()->get($fileId); 206 | 207 | $parameters = array_replace_recursive( 208 | $request->request->all(), 209 | $request->files->all() 210 | ); 211 | 212 | try { 213 | $file = $this->getFileHandler()->put($file, $parameters); 214 | } catch (InvalidFormException $e) { 215 | 216 | return $e->getForm(); 217 | } 218 | 219 | $routeOptions = [ 220 | 'accountId' => $fileId, 221 | 'fileId' => $file->getId(), 222 | '_format' => $request->get('_format'), 223 | ]; 224 | 225 | return $this->routeRedirectView('get_accounts_files', $routeOptions, Response::HTTP_NO_CONTENT); 226 | } 227 | 228 | 229 | /** 230 | * Deletes a specific File by ID 231 | * 232 | * @ApiDoc( 233 | * description="Deletes an existing File", 234 | * statusCodes={ 235 | * 204 = "Returned when an existing File has been successfully deleted", 236 | * 403 = "Returned when trying to delete a non existent File" 237 | * } 238 | * ) 239 | * 240 | * @param int $accountId the account id 241 | * @param int $fileId the file id 242 | * 243 | * @return View 244 | * 245 | * @throws NotFoundHttpException when does not exist 246 | */ 247 | public function deleteAction($accountId, $fileId) 248 | { 249 | $file = $this->getFileHandler()->get($fileId); 250 | 251 | $this->getFileHandler()->delete($file); 252 | 253 | return new View(null, Response::HTTP_NO_CONTENT); 254 | } 255 | 256 | 257 | /** 258 | * @return \AppBundle\Handler\FileHandler 259 | */ 260 | private function getFileHandler() 261 | { 262 | return $this->get('crv.handler.restricted_file_handler'); 263 | } 264 | 265 | 266 | /** 267 | * @return \AppBundle\Handler\AccountHandler 268 | */ 269 | private function getAccountHandler() 270 | { 271 | return $this->get('crv.handler.restricted_account_handler'); 272 | } 273 | } --------------------------------------------------------------------------------