├── src ├── Core │ ├── Domain │ │ ├── Service │ │ │ └── .gitkeep │ │ └── Model │ │ │ ├── User │ │ │ ├── UniqueUsernameSpecificationInterface.php │ │ │ ├── UserFetcherInterface.php │ │ │ ├── UserRepositoryInterface.php │ │ │ ├── UserGS.php │ │ │ └── User.php │ │ │ └── Task │ │ │ ├── TaskRepositoryInterface.php │ │ │ ├── TaskDoneEvent.php │ │ │ ├── TaskCreatedEvent.php │ │ │ ├── TaskDeclinedEvent.php │ │ │ ├── Status.php │ │ │ ├── TaskGS.php │ │ │ └── Task.php │ ├── Application │ │ ├── Service │ │ │ └── .gitkeep │ │ ├── Command │ │ │ ├── Task │ │ │ │ ├── CreateTask │ │ │ │ │ ├── CreateTaskCommand.php │ │ │ │ │ └── CreateTaskCommandHandler.php │ │ │ │ ├── DeleteTask │ │ │ │ │ ├── DeleteTaskCommand.php │ │ │ │ │ └── DeleteTaskCommandHandler.php │ │ │ │ ├── MakeTaskDone │ │ │ │ │ ├── MakeTaskDoneCommand.php │ │ │ │ │ └── MakeTaskDoneCommandHandler.php │ │ │ │ ├── MakeTaskDeclined │ │ │ │ │ ├── MakeTaskDeclinedCommand.php │ │ │ │ │ └── MakeTaskDeclinedCommandHandler.php │ │ │ │ ├── UpdateTask │ │ │ │ │ ├── UpdateTaskCommand.php │ │ │ │ │ └── UpdateTaskCommandHandler.php │ │ │ │ └── TaskCommand.php │ │ │ ├── User │ │ │ │ └── CreateUser │ │ │ │ │ ├── CreateUserCommand.php │ │ │ │ │ └── CreateUserCommandHandler.php │ │ │ └── AuthToken │ │ │ │ └── CreateAuthToken │ │ │ │ ├── CreateAuthTokenCommand.php │ │ │ │ └── CreateAuthTokenCommandHandler.php │ │ ├── Query │ │ │ └── Task │ │ │ │ ├── GetTask │ │ │ │ ├── GetTaskQuery.php │ │ │ │ └── GetTaskQueryHandler.php │ │ │ │ ├── GetTasks │ │ │ │ ├── GetTasksQuery.php │ │ │ │ └── GetTasksQueryHandler.php │ │ │ │ └── DTO │ │ │ │ └── TaskDTO.php │ │ └── EventHandler │ │ │ └── Task │ │ │ └── LogTaskLiveCycleChanges │ │ │ ├── TaskDoneEventHandler.php │ │ │ ├── TaskCreatedEventHandler.php │ │ │ └── TaskDeclinedEventHandler.php │ ├── Ports │ │ ├── Rest │ │ │ ├── SerializerMapping │ │ │ │ └── TaskDTO.yaml │ │ │ ├── Task │ │ │ │ ├── DeleteTaskAction.php │ │ │ │ ├── MakeTaskDoneAction.php │ │ │ │ ├── MakeTaskDeclinedAction.php │ │ │ │ ├── GetTaskAction.php │ │ │ │ ├── UpdateTaskAction.php │ │ │ │ ├── CreateTaskAction.php │ │ │ │ └── GetTasksAction.php │ │ │ └── AuthToken │ │ │ │ └── CreateAuthTokenAction.php │ │ └── Cli │ │ │ └── AddUserCommand.php │ └── Infrastructure │ │ ├── Repository │ │ ├── TaskRepository.php │ │ └── UserRepository.php │ │ ├── Specification │ │ └── User │ │ │ └── UniqueUsernameSpecification.php │ │ └── Security │ │ └── UserFetcher.php ├── Shared │ ├── Domain │ │ ├── Model │ │ │ ├── DomainEventInterface.php │ │ │ ├── EntityInterface.php │ │ │ └── Aggregate.php │ │ ├── Exception │ │ │ ├── InvalidInputDataException.php │ │ │ ├── AccessForbiddenException.php │ │ │ ├── ResourceNotFoundException.php │ │ │ └── BusinessLogicViolationException.php │ │ └── Service │ │ │ └── Assert │ │ │ └── Assert.php │ └── Infrastructure │ │ ├── Type │ │ └── DateTimeFormat.php │ │ ├── Http │ │ ├── HttpSpec.php │ │ ├── ApiRequestSubscriber.php │ │ ├── ApiExceptionSubscriber.php │ │ └── ParamFetcher.php │ │ ├── ValueObject │ │ ├── PaginatedData.php │ │ └── Pagination.php │ │ ├── Doctrine │ │ └── DomainEventSubscriber.php │ │ └── Migration │ │ └── Version20200601085854.php └── Kernel.php ├── config ├── routes.yaml ├── packages │ ├── test │ │ ├── twig.yaml │ │ ├── framework.yaml │ │ ├── web_profiler.yaml │ │ └── monolog.yaml │ ├── routing.yaml │ ├── twig.yaml │ ├── prod │ │ ├── routing.yaml │ │ ├── doctrine.yaml │ │ └── monolog.yaml │ ├── dev │ │ ├── web_profiler.yaml │ │ ├── debug.yaml │ │ └── monolog.yaml │ ├── lexik_jwt_authentication.yaml │ ├── security_checker.yaml │ ├── doctrine_migrations.yaml │ ├── messenger.yaml │ ├── doctrine.yaml │ ├── nelmio_api_doc.yaml │ ├── framework.yaml │ ├── cache.yaml │ └── security.yaml ├── routes │ ├── dev │ │ ├── framework.yaml │ │ └── web_profiler.yaml │ ├── annotations.yaml │ └── nelmio_api_doc.yaml ├── bundles.php ├── bootstrap.php └── services.yaml ├── docker ├── .dockerignore ├── .env.dist ├── mysql │ ├── Dockerfile │ └── my.cnf ├── php-fpm │ ├── xdebug.ini │ ├── Dockerfile │ └── xdebug ├── workplace │ ├── xdebug.ini │ └── Dockerfile ├── nginx │ ├── default.conf │ ├── Dockerfile │ └── nginx.conf └── docker-compose.yml ├── .env.test ├── phpstan.neon ├── templates └── base.html.twig ├── tests ├── bootstrap.php └── Unit │ └── Core │ ├── Domain │ └── Model │ │ ├── Task │ │ ├── StatusTest.php │ │ └── TaskTest.php │ │ └── User │ │ └── UserTest.php │ └── Application │ └── Command │ └── Task │ ├── CreateTask │ └── CreateTaskCommandHandlerTest.php │ ├── MakeTaskDone │ └── MakeTaskDoneCommandHandlerTest.php │ ├── MakeTaskDeclined │ └── MakeTaskDeclinedCommandHandlerTest.php │ ├── DeleteTask │ └── DeleteTaskCommandHandlerTest.php │ └── UpdateTask │ └── UpdateTaskCommandHandlerTest.php ├── bin ├── phpunit └── console ├── .gitignore ├── phpunit.xml.dist ├── public └── index.php ├── depfile.yaml ├── setup_env ├── setup_env.sh ├── .php_cs ├── .env ├── Makefile ├── Readme.md ├── composer.json └── symfony.lock /src/Core/Domain/Service/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Core/Application/Service/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/routes.yaml: -------------------------------------------------------------------------------- 1 | api_login_check: 2 | path: /api/login_check -------------------------------------------------------------------------------- /config/packages/test/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | strict_variables: true 3 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | -------------------------------------------------------------------------------- /config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | default_path: '%kernel.project_dir%/templates' 3 | -------------------------------------------------------------------------------- /config/packages/prod/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | strict_requirements: null 4 | -------------------------------------------------------------------------------- /docker/.dockerignore: -------------------------------------------------------------------------------- 1 | ../var/** 2 | ../vendor/** 3 | ../.env.local 4 | ../.env.*.local 5 | ../config/jwt -------------------------------------------------------------------------------- /config/packages/test/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | test: true 3 | session: 4 | storage_id: session.storage.mock_file 5 | -------------------------------------------------------------------------------- /config/routes/dev/framework.yaml: -------------------------------------------------------------------------------- 1 | _errors: 2 | resource: '@FrameworkBundle/Resources/config/routing/errors.xml' 3 | prefix: /_error 4 | -------------------------------------------------------------------------------- /config/packages/test/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: false 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { collect: false } 7 | -------------------------------------------------------------------------------- /config/packages/dev/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: true 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { only_exceptions: false } 7 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # define your env variables for the test env here 2 | KERNEL_CLASS='App\Kernel' 3 | APP_SECRET='$ecretf0rt3st' 4 | SYMFONY_DEPRECATIONS_HELPER=999999 5 | PANTHER_APP_ENV=panther 6 | -------------------------------------------------------------------------------- /config/routes/annotations.yaml: -------------------------------------------------------------------------------- 1 | controllers: 2 | resource: ../../src/Core/Ports/Rest/ 3 | type: annotation 4 | kernel: 5 | resource: ../../src/Kernel.php 6 | type: annotation 7 | -------------------------------------------------------------------------------- /src/Shared/Domain/Model/DomainEventInterface.php: -------------------------------------------------------------------------------- 1 | /etc/timezone && chown -R mysql:root /var/lib/mysql/ 6 | 7 | COPY my.cnf /etc/mysql/conf.d/my.cnf 8 | 9 | RUN chmod 0444 /etc/mysql/conf.d/my.cnf 10 | 11 | CMD ["mysqld"] 12 | 13 | EXPOSE 3306 -------------------------------------------------------------------------------- /src/Core/Domain/Model/Task/TaskRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Welcome!{% endblock %} 6 | {% block stylesheets %}{% endblock %} 7 | 8 | 9 | {% block body %}{% endblock %} 10 | {% block javascripts %}{% endblock %} 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | -------------------------------------------------------------------------------- /docker/mysql/my.cnf: -------------------------------------------------------------------------------- 1 | 2 | # The MySQL Client configuration file. 3 | # 4 | # For explanations see 5 | # http://dev.mysql.com/doc/mysql/en/server-system-variables.html 6 | 7 | [mysql] 8 | 9 | [mysqld] 10 | sql-mode="STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION" 11 | character-set-server=utf8 12 | default-authentication-plugin=mysql_native_password -------------------------------------------------------------------------------- /src/Core/Ports/Rest/SerializerMapping/TaskDTO.yaml: -------------------------------------------------------------------------------- 1 | App\Core\Application\Query\Task\DTO\TaskDTO: 2 | attributes: 3 | id: 4 | groups: ['task_view'] 5 | title: 6 | groups: ['task_view'] 7 | description: 8 | groups: ['task_view'] 9 | status: 10 | groups: ['task_view'] 11 | executionDay: 12 | groups: ['task_view'] 13 | createdAt: 14 | groups: ['task_view'] -------------------------------------------------------------------------------- /config/packages/test/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_http_codes: [404, 405] 8 | channels: ["!event"] 9 | nested: 10 | type: stream 11 | path: "%kernel.logs_dir%/%kernel.environment%.log" 12 | level: debug 13 | -------------------------------------------------------------------------------- /src/Core/Application/Query/Task/GetTask/GetTaskQuery.php: -------------------------------------------------------------------------------- 1 | id = $id; 14 | } 15 | 16 | public function getId(): int 17 | { 18 | return $this->id; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Core/Domain/Model/User/UserRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | id = $id; 14 | } 15 | 16 | public function getId(): int 17 | { 18 | return $this->id; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Core/Application/Command/Task/MakeTaskDone/MakeTaskDoneCommand.php: -------------------------------------------------------------------------------- 1 | id = $id; 14 | } 15 | 16 | public function getId(): int 17 | { 18 | return $this->id; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /config/routes/nelmio_api_doc.yaml: -------------------------------------------------------------------------------- 1 | # Expose your documentation as JSON swagger compliant 2 | app.swagger: 3 | path: /api/doc.json 4 | methods: GET 5 | defaults: { _controller: nelmio_api_doc.controller.swagger } 6 | 7 | ## Requires the Asset component and the Twig bundle 8 | ## $ composer require twig asset 9 | #app.swagger_ui: 10 | # path: /api/doc 11 | # methods: GET 12 | # defaults: { _controller: nelmio_api_doc.controller.swagger_ui } 13 | -------------------------------------------------------------------------------- /src/Core/Application/Command/Task/MakeTaskDeclined/MakeTaskDeclinedCommand.php: -------------------------------------------------------------------------------- 1 | id = $id; 14 | } 15 | 16 | public function getId(): int 17 | { 18 | return $this->id; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | task = $task; 16 | } 17 | 18 | public function getTask(): Task 19 | { 20 | return $this->task; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docker/workplace/xdebug.ini: -------------------------------------------------------------------------------- 1 | xdebug.remote_host="host.docker.internal" 2 | xdebug.remote_connect_back=0 3 | xdebug.remote_port=9000 4 | xdebug.idekey=PHPSTORM 5 | 6 | xdebug.remote_autostart=1 7 | xdebug.remote_enable=1 8 | xdebug.cli_color=1 9 | xdebug.profiler_enable=0 10 | xdebug.profiler_output_dir="~/xdebug/phpstorm/tmp/profiling" 11 | 12 | xdebug.remote_handler=dbgp 13 | xdebug.remote_mode=req 14 | 15 | xdebug.var_display_max_children=-1 16 | xdebug.var_display_max_data=-1 17 | xdebug.var_display_max_depth=-1 -------------------------------------------------------------------------------- /src/Core/Domain/Model/Task/TaskCreatedEvent.php: -------------------------------------------------------------------------------- 1 | task = $task; 16 | } 17 | 18 | public function getTask(): Task 19 | { 20 | return $this->task; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Core/Domain/Model/Task/TaskDeclinedEvent.php: -------------------------------------------------------------------------------- 1 | task = $task; 16 | } 17 | 18 | public function getTask(): Task 19 | { 20 | return $this->task; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | mapping_types: 5 | enum: string 6 | orm: 7 | auto_generate_proxy_classes: true 8 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 9 | auto_mapping: true 10 | mappings: 11 | App: 12 | is_bundle: false 13 | type: annotation 14 | dir: '%kernel.project_dir%/src/Core/Domain/Model' 15 | prefix: 'App\Core\Domain\Model' 16 | alias: App 17 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Http/HttpSpec.php: -------------------------------------------------------------------------------- 1 | id = $id; 17 | } 18 | 19 | public function getId(): int 20 | { 21 | return $this->id; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Core/Application/Command/User/CreateUser/CreateUserCommand.php: -------------------------------------------------------------------------------- 1 | username = $username; 16 | $this->password = $password; 17 | } 18 | 19 | public function getUsername(): string 20 | { 21 | return $this->username; 22 | } 23 | 24 | public function getPassword(): string 25 | { 26 | return $this->password; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Core/Application/EventHandler/Task/LogTaskLiveCycleChanges/TaskDoneEventHandler.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 17 | } 18 | 19 | public function __invoke(TaskDoneEvent $event): void 20 | { 21 | $this->logger->info(sprintf('Task %s was done', $event->getTask()->getId())); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /config/packages/dev/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: stream 5 | path: "%kernel.logs_dir%/%kernel.environment%.log" 6 | level: debug 7 | channels: ["!event"] 8 | # uncomment to get logging in your browser 9 | # you may have to allow bigger header sizes in your Web server configuration 10 | #firephp: 11 | # type: firephp 12 | # level: info 13 | #chromephp: 14 | # type: chromephp 15 | # level: info 16 | console: 17 | type: console 18 | process_psr_3_messages: false 19 | channels: ["!event", "!doctrine", "!console"] 20 | -------------------------------------------------------------------------------- /src/Core/Application/EventHandler/Task/LogTaskLiveCycleChanges/TaskCreatedEventHandler.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 17 | } 18 | 19 | public function __invoke(TaskCreatedEvent $event): void 20 | { 21 | $this->logger->info(sprintf('Task %s was created', $event->getTask()->getId())); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###> symfony/framework-bundle ### 2 | /.env.local 3 | /.env.local.php 4 | /.env.*.local 5 | /config/secrets/prod/prod.decrypt.private.php 6 | /public/bundles/ 7 | /var/ 8 | /vendor/ 9 | ###< symfony/framework-bundle ### 10 | ###> symfony/phpunit-bridge ### 11 | .phpunit 12 | .phpunit.result.cache 13 | /phpunit.xml 14 | ###< symfony/phpunit-bridge ### 15 | 16 | ###> JetBrains ## 17 | # User-specific stuff 18 | .idea/ 19 | ###< JetBrains ### 20 | 21 | .php_cs.cache 22 | docker/.env 23 | 24 | ###> sensiolabs-de/deptrac-shim ### 25 | /.deptrac.cache 26 | ###< sensiolabs-de/deptrac-shim ### 27 | 28 | ###> lexik/jwt-authentication-bundle ### 29 | /config/jwt/*.pem 30 | ###< lexik/jwt-authentication-bundle ### 31 | -------------------------------------------------------------------------------- /src/Core/Application/EventHandler/Task/LogTaskLiveCycleChanges/TaskDeclinedEventHandler.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 17 | } 18 | 19 | public function __invoke(TaskDeclinedEvent $event): void 20 | { 21 | $this->logger->info(sprintf('Task %s was declined', $event->getTask()->getId())); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Shared/Domain/Model/Aggregate.php: -------------------------------------------------------------------------------- 1 | events; 22 | $this->events = []; 23 | 24 | return $events; 25 | } 26 | 27 | protected function raise(DomainEventInterface $event): void 28 | { 29 | $this->events[] = $event; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Core/Application/Command/AuthToken/CreateAuthToken/CreateAuthTokenCommand.php: -------------------------------------------------------------------------------- 1 | username = $username; 16 | $this->password = $password; 17 | } 18 | 19 | public function getUsername(): string 20 | { 21 | return $this->username; 22 | } 23 | 24 | public function getPassword(): string 25 | { 26 | return $this->password; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | secret: '%env(APP_SECRET)%' 3 | #csrf_protection: true 4 | #http_method_override: true 5 | 6 | # Enables session support. Note that the session will ONLY be started if you read or write from it. 7 | # Remove or comment this section to explicitly disable session support. 8 | session: 9 | handler_id: null 10 | cookie_secure: auto 11 | cookie_samesite: lax 12 | 13 | #esi: true 14 | #fragments: true 15 | php_errors: 16 | log: true 17 | 18 | serializer: 19 | name_converter: 'serializer.name_converter.camel_case_to_snake_case' 20 | mapping: 21 | paths: 22 | - '%kernel.project_dir%/src/Core/Ports/Rest/SerializerMapping' 23 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 14 | ./tests/Unit 15 | 16 | 17 | 18 | 19 | 20 | ./src/ 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/ValueObject/PaginatedData.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | private array $data; 13 | 14 | private int $count; 15 | 16 | /** 17 | * @param array $data 18 | * @param int $count 19 | */ 20 | public function __construct(array $data, int $count) 21 | { 22 | $this->data = $data; 23 | $this->count = $count; 24 | } 25 | 26 | /** 27 | * @return array 28 | */ 29 | public function getData(): array 30 | { 31 | return $this->data; 32 | } 33 | 34 | public function getCount(): int 35 | { 36 | return $this->count; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handle($request); 26 | $response->send(); 27 | $kernel->terminate($request, $response); 28 | -------------------------------------------------------------------------------- /tests/Unit/Core/Domain/Model/Task/StatusTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidInputDataException::class); 16 | $this->expectDeprecationMessageMatches('/Status value should be one of/'); 17 | 18 | new Status('some_invalid_status'); 19 | } 20 | 21 | /** 22 | * @doesNotPerformAssertions 23 | */ 24 | public function test_it_ok_when_valid_value_set(): void 25 | { 26 | foreach (Status::VALID_STATUSES as $status) { 27 | new Status($status); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Core/Application/Command/Task/TaskCommand.php: -------------------------------------------------------------------------------- 1 | title = $title; 18 | $this->executionDay = $executionDay; 19 | $this->description = $description; 20 | } 21 | 22 | public function getTitle(): string 23 | { 24 | return $this->title; 25 | } 26 | 27 | public function getDescription(): string 28 | { 29 | return $this->description; 30 | } 31 | 32 | public function getExecutionDay(): \DateTimeImmutable 33 | { 34 | return $this->executionDay; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docker/nginx/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | 3 | listen 80; 4 | listen [::]:80; 5 | 6 | listen 443 ssl; 7 | listen [::]:443 ssl ipv6only=on; 8 | ssl_certificate /etc/nginx/ssl/default.crt; 9 | ssl_certificate_key /etc/nginx/ssl/default.key; 10 | 11 | root /var/www/public; 12 | index index.php index.html index.htm; 13 | 14 | location / { 15 | try_files $uri /index.php$is_args$args; 16 | } 17 | 18 | location ~ ^/index\.php(/|$) { 19 | fastcgi_pass php-upstream; 20 | fastcgi_split_path_info ^(.+\.php)(/.*)$; 21 | include fastcgi_params; 22 | fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; 23 | fastcgi_param DOCUMENT_ROOT $realpath_root; 24 | internal; 25 | } 26 | 27 | location ~ \.php$ { 28 | return 404; 29 | } 30 | 31 | error_log /var/log/nginx/project_error.log; 32 | access_log /var/log/nginx/project_access.log; 33 | } -------------------------------------------------------------------------------- /src/Core/Infrastructure/Repository/TaskRepository.php: -------------------------------------------------------------------------------- 1 | em = $em; 18 | } 19 | 20 | public function find(int $id): ?Task 21 | { 22 | return $this->em->find(Task::class, $id); 23 | } 24 | 25 | public function add(Task $task): void 26 | { 27 | $this->em->persist($task); 28 | $this->em->flush(); 29 | } 30 | 31 | public function remove(Task $task): void 32 | { 33 | $this->em->remove($task); 34 | $this->em->flush(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/packages/prod/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_http_codes: [404, 405] 8 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 9 | nested: 10 | type: stream 11 | path: "%kernel.logs_dir%/%kernel.environment%.log" 12 | level: debug 13 | console: 14 | type: console 15 | process_psr_3_messages: false 16 | channels: ["!event", "!doctrine"] 17 | 18 | # Uncomment to log deprecations 19 | #deprecation: 20 | # type: stream 21 | # path: "%kernel.logs_dir%/%kernel.environment%.deprecations.log" 22 | #deprecation_filter: 23 | # type: filter 24 | # handler: deprecation 25 | # max_level: info 26 | # channels: ["php"] 27 | -------------------------------------------------------------------------------- /src/Core/Infrastructure/Specification/User/UniqueUsernameSpecification.php: -------------------------------------------------------------------------------- 1 | em = $em; 18 | } 19 | 20 | public function isSatisfiedBy(string $username): bool 21 | { 22 | return $this->em->createQueryBuilder() 23 | ->select('u') 24 | ->from(User::class, 'u') 25 | ->where('u.username = :username') 26 | ->setParameters(['username' => $username]) 27 | ->getQuery()->getOneOrNullResult() === null; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 6 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 7 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 8 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 9 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 10 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 11 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 12 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], 13 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 14 | Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], 15 | Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true], 16 | ]; 17 | -------------------------------------------------------------------------------- /src/Core/Application/Query/Task/GetTasks/GetTasksQuery.php: -------------------------------------------------------------------------------- 1 | pagination = $pagination; 20 | $this->executionDate = $executionDate; 21 | $this->searchText = $searchText; 22 | } 23 | 24 | public function getPagination(): Pagination 25 | { 26 | return $this->pagination; 27 | } 28 | 29 | public function getExecutionDate(): ?\DateTimeImmutable 30 | { 31 | return $this->executionDate; 32 | } 33 | 34 | public function getSearchText(): ?string 35 | { 36 | return $this->searchText; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Shared/Domain/Service/Assert/Assert.php: -------------------------------------------------------------------------------- 1 | /etc/nginx/conf.d/upstream.conf 24 | 25 | RUN mkdir "/etc/nginx/ssl" \ 26 | && openssl genrsa -out "/etc/nginx/ssl/default.key" 2048 \ 27 | && openssl req -new -key "/etc/nginx/ssl/default.key" -out "/etc/nginx/ssl/default.csr" -subj "/CN=default/O=default/C=UK" \ 28 | && openssl x509 -req -days 365 -in "/etc/nginx/ssl/default.csr" -signkey "/etc/nginx/ssl/default.key" -out "/etc/nginx/ssl/default.crt" 29 | 30 | EXPOSE 80 443 -------------------------------------------------------------------------------- /src/Core/Application/Command/Task/CreateTask/CreateTaskCommandHandler.php: -------------------------------------------------------------------------------- 1 | taskRepository = $taskRepository; 20 | $this->userFetcher = $userFetcher; 21 | } 22 | 23 | public function __invoke(CreateTaskCommand $command): int 24 | { 25 | $user = $this->userFetcher->fetchRequiredUser(); 26 | 27 | $task = new Task($command->getTitle(), $command->getExecutionDay(), $user, $command->getDescription()); 28 | $this->taskRepository->add($task); 29 | 30 | return $task->getId(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /depfile.yaml: -------------------------------------------------------------------------------- 1 | paths: 2 | - ./src 3 | exclude_files: 4 | - Kernel.php 5 | layers: 6 | - name: CoreApplication 7 | collectors: 8 | - type: className 9 | regex: .*App\\Core\\Application\\(?!Query).* 10 | - name: CoreDomain 11 | collectors: 12 | - type: className 13 | regex: .*App\\Core\\Domain\\.* 14 | - name: CoreInfrastructure 15 | collectors: 16 | - type: className 17 | regex: .*App\\Core\\Infrastructure\\.* 18 | - name: CorePorts 19 | collectors: 20 | - type: className 21 | regex: .*App\\Core\\Ports\\.* 22 | - name: SharedDomain 23 | collectors: 24 | - type: className 25 | regex: .*App\\Shared\\Domain\\.* 26 | - name: SharedInfrastructure 27 | collectors: 28 | - type: className 29 | regex: .*App\\Shared\\Infrastructure\\.* 30 | ruleset: 31 | CoreApplication: 32 | - CoreDomain 33 | - SharedDomain 34 | CoreDomain: 35 | - SharedDomain 36 | CorePorts: 37 | - CoreApplication 38 | - CoreInfrastructure 39 | - SharedInfrastructure 40 | CoreInfrastructure: 41 | - CoreDomain 42 | SharedDomain: ~ 43 | SharedInfrastructure: 44 | - SharedDomain -------------------------------------------------------------------------------- /config/bootstrap.php: -------------------------------------------------------------------------------- 1 | =1.2) 13 | if (is_array($env = @include dirname(__DIR__).'/.env.local.php') && (!isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) { 14 | (new Dotenv(false))->populate($env); 15 | } else { 16 | // load all the .env files 17 | (new Dotenv(false))->loadEnv(dirname(__DIR__).'/.env'); 18 | } 19 | 20 | $_SERVER += $_ENV; 21 | $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev'; 22 | $_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV']; 23 | $_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; 24 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | services: 4 | php-fpm: 5 | build: 6 | context: ./php-fpm 7 | args: 8 | - PUID=${PUID} 9 | - PGID=${PGID} 10 | volumes: 11 | - ..:/var/www/:rw 12 | depends_on: 13 | - db 14 | networks: 15 | - main 16 | 17 | db: 18 | build: 19 | context: ./mysql 20 | volumes: 21 | - db-data:/var/lib/mysql:rw 22 | environment: 23 | - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD} 24 | - MYSQL_DATABASE=${DB_DATABASE} 25 | - MYSQL_USER=${DB_USER} 26 | - MYSQL_PASSWORD=${DB_PASSWORD} 27 | ports: 28 | - "${MYSQL_PORT}:3306" 29 | networks: 30 | - main 31 | 32 | nginx: 33 | build: 34 | context: ./nginx 35 | volumes: 36 | - ..:/var/www:rw 37 | ports: 38 | - "${NGINX_HOST_HTTP_PORT}:80" 39 | - "${NGINX_HOST_HTTPS_PORT}:443" 40 | depends_on: 41 | - php-fpm 42 | networks: 43 | - main 44 | 45 | workplace: 46 | build: 47 | context: ./workplace 48 | args: 49 | - PUID=${PUID} 50 | - PGID=${PGID} 51 | volumes: 52 | - ..:/var/www/:rw 53 | tty: true 54 | depends_on: 55 | - db 56 | networks: 57 | - main 58 | 59 | volumes: 60 | db-data: {} 61 | 62 | networks: 63 | main: -------------------------------------------------------------------------------- /src/Core/Infrastructure/Repository/UserRepository.php: -------------------------------------------------------------------------------- 1 | em = $em; 18 | } 19 | 20 | public function find(int $id): ?User 21 | { 22 | return $this->em->find(User::class, $id); 23 | } 24 | 25 | public function findUserByUserName(string $username): ?User 26 | { 27 | return $this->em->createQueryBuilder() 28 | ->select('u') 29 | ->from(User::class, 'u') 30 | ->where('u.username = :username') 31 | ->setParameters(['username' => $username]) 32 | ->getQuery()->getOneOrNullResult(); 33 | } 34 | 35 | public function add(User $user): void 36 | { 37 | $this->em->persist($user); 38 | $this->em->flush(); 39 | } 40 | 41 | public function remove(User $user): void 42 | { 43 | $this->em->remove($user); 44 | $this->em->flush(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], null, true)) { 23 | putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env); 24 | } 25 | 26 | if ($input->hasParameterOption('--no-debug', true)) { 27 | putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0'); 28 | } 29 | 30 | require dirname(__DIR__).'/config/bootstrap.php'; 31 | 32 | if ($_SERVER['APP_DEBUG']) { 33 | umask(0000); 34 | 35 | if (class_exists(Debug::class)) { 36 | Debug::enable(); 37 | } 38 | } 39 | 40 | $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); 41 | $application = new Application($kernel); 42 | $application->run($input); 43 | -------------------------------------------------------------------------------- /src/Core/Application/Command/Task/DeleteTask/DeleteTaskCommandHandler.php: -------------------------------------------------------------------------------- 1 | taskRepository = $taskRepository; 21 | $this->userFetcher = $userFetcher; 22 | } 23 | 24 | public function __invoke(DeleteTaskCommand $command): void 25 | { 26 | $task = $this->taskRepository->find($command->getId()); 27 | 28 | if ($task === null) { 29 | throw new ResourceNotFoundException(sprintf('Task with id "%s" is not found', $command->getId())); 30 | } 31 | 32 | $user = $this->userFetcher->fetchRequiredUser(); 33 | 34 | if (!$task->getUser()->equals($user)) { 35 | throw new AccessForbiddenException('Access prohibited'); 36 | } 37 | 38 | $this->taskRepository->remove($task); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Core/Application/Query/Task/GetTask/GetTaskQueryHandler.php: -------------------------------------------------------------------------------- 1 | em = $em; 23 | $this->userFetcher = $userFetcher; 24 | } 25 | 26 | public function __invoke(GetTaskQuery $query): TaskDTO 27 | { 28 | $task = $this->em->find(Task::class, $query->getId()); 29 | 30 | if ($task === null) { 31 | throw new ResourceNotFoundException(sprintf('Task with id "%s" is not found', $query->getId())); 32 | } 33 | 34 | $user = $this->userFetcher->fetchRequiredUser(); 35 | 36 | if (!$task->getUser()->equals($user)) { 37 | throw new AccessForbiddenException('Access prohibited'); 38 | } 39 | 40 | return TaskDTO::fromEntity($task); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Core/Infrastructure/Security/UserFetcher.php: -------------------------------------------------------------------------------- 1 | security = $security; 18 | } 19 | 20 | public function fetchRequiredUser(): User 21 | { 22 | $user = $this->security->getUser(); 23 | 24 | if ($user === null) { 25 | throw new \InvalidArgumentException('Current user not found check security access list'); 26 | } 27 | 28 | if (!($user instanceof User)) { 29 | throw new \InvalidArgumentException(sprintf('Invalid user type %s', \get_class($user))); 30 | } 31 | 32 | return $user; 33 | } 34 | 35 | public function fetchNullableUser(): ?user 36 | { 37 | $user = $this->security->getUser(); 38 | 39 | if ($user === null) { 40 | return null; 41 | } 42 | 43 | if (!($user instanceof User)) { 44 | throw new \InvalidArgumentException(sprintf('Invalid user type %s', \get_class($user))); 45 | } 46 | 47 | return $user; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/ValueObject/Pagination.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 28 | $this->offset = $offset; 29 | } 30 | 31 | public static function fromRequest(Request $request): self 32 | { 33 | $limit = $request->get(self::LIMIT_NAME, self::DEFAULT_LIMIT); 34 | Assert::integerish($limit); 35 | 36 | $offset = $request->get(self::OFFSET_NAME, self::DEFAULT_OFFSET); 37 | Assert::integerish($offset); 38 | 39 | return new self((int) $limit, (int) $offset); 40 | } 41 | 42 | public function getLimit(): int 43 | { 44 | return $this->limit; 45 | } 46 | 47 | public function getOffset(): int 48 | { 49 | return $this->offset; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docker/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user www-data; 2 | worker_processes 4; 3 | pid /run/nginx.pid; 4 | 5 | events { 6 | worker_connections 2048; 7 | multi_accept on; 8 | use epoll; 9 | } 10 | 11 | http { 12 | server_tokens off; 13 | sendfile on; 14 | tcp_nopush on; 15 | tcp_nodelay on; 16 | keepalive_timeout 15; 17 | types_hash_max_size 2048; 18 | client_max_body_size 20M; 19 | include /etc/nginx/mime.types; 20 | default_type application/octet-stream; 21 | access_log /dev/stdout; 22 | error_log /dev/stderr; 23 | gzip on; 24 | gzip_disable "msie6"; 25 | 26 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 27 | ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS'; 28 | 29 | include /etc/nginx/conf.d/*.conf; 30 | include /etc/nginx/sites-available/*.conf; 31 | open_file_cache off; # Disabled for issue 619 32 | charset UTF-8; 33 | } -------------------------------------------------------------------------------- /src/Core/Application/Command/Task/MakeTaskDone/MakeTaskDoneCommandHandler.php: -------------------------------------------------------------------------------- 1 | taskRepository = $taskRepository; 21 | $this->userFetcher = $userFetcher; 22 | } 23 | 24 | public function __invoke(MakeTaskDoneCommand $command): void 25 | { 26 | $task = $this->taskRepository->find($command->getId()); 27 | 28 | if ($task === null) { 29 | throw new ResourceNotFoundException(sprintf('Task with id "%s" is not found', $command->getId())); 30 | } 31 | 32 | $user = $this->userFetcher->fetchRequiredUser(); 33 | 34 | if (!$task->getUser()->equals($user)) { 35 | throw new AccessForbiddenException('Access prohibited'); 36 | } 37 | 38 | $task->done(); 39 | 40 | $this->taskRepository->add($task); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Core/Application/Command/Task/MakeTaskDeclined/MakeTaskDeclinedCommandHandler.php: -------------------------------------------------------------------------------- 1 | taskRepository = $taskRepository; 21 | $this->userFetcher = $userFetcher; 22 | } 23 | 24 | public function __invoke(MakeTaskDeclinedCommand $command): void 25 | { 26 | $task = $this->taskRepository->find($command->getId()); 27 | 28 | if ($task === null) { 29 | throw new ResourceNotFoundException(sprintf('Task with id "%s" is not found', $command->getId())); 30 | } 31 | 32 | $user = $this->userFetcher->fetchRequiredUser(); 33 | 34 | if (!$task->getUser()->equals($user)) { 35 | throw new AccessForbiddenException('Access prohibited'); 36 | } 37 | 38 | $task->decline(); 39 | 40 | $this->taskRepository->add($task); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Core/Domain/Model/Task/Status.php: -------------------------------------------------------------------------------- 1 | status = $status; 30 | } 31 | 32 | public function __toString(): string 33 | { 34 | return $this->status; 35 | } 36 | 37 | public function getStatus(): string 38 | { 39 | return $this->status; 40 | } 41 | 42 | public static function NEW(): self 43 | { 44 | return new self(self::NEW); 45 | } 46 | 47 | public static function DECLINED(): self 48 | { 49 | return new self(self::DECLINED); 50 | } 51 | 52 | public static function DONE(): self 53 | { 54 | return new self(self::DONE); 55 | } 56 | 57 | public function is(string $status): bool 58 | { 59 | return $this->status === $status; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /setup_env: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "$1" ] 4 | then 5 | echo 'Enter the environment by first argument [dev, prod, test]' 6 | exit 1 7 | fi 8 | 9 | #docker 10 | PUID=$(id -u) 11 | PGID=$(id -g) 12 | DB_ROOT_PASSWORD=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 6 ; echo '') 13 | DB_PASSWORD=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 6 ; echo '') 14 | 15 | #app 16 | APP_ENV=dev 17 | APP_SECRET=$(xxd -l 16 -p /dev/random) 18 | DATABASE_URL="mysql:\/\/task:${DB_PASSWORD}@db:3306\/task?serverVersion=8.0" 19 | 20 | #docker 21 | cp ./docker/.env.dist ./docker/.env && \ 22 | sed -i "s/^PUID=.*/PUID=${PUID}/g" ./docker/.env && \ 23 | sed -i "s/^PGID=.*/PGID=${PGID}/g" ./docker/.env && \ 24 | sed -i "s/^DB_ROOT_PASSWORD=.*/DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}/g" ./docker/.env && \ 25 | sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=${DB_PASSWORD}/g" ./docker/.env 26 | 27 | #application 28 | cp .env .env.local && \ 29 | sed -i "s/^APP_ENV=.*/APP_ENV=${APP_ENV}/g" .env.local && \ 30 | sed -i "s/^APP_SECRET=.*/APP_SECRET=${APP_SECRET}/g" .env.local && \ 31 | sed -i "s/^DATABASE_URL=.*/DATABASE_URL=${DATABASE_URL}/g" .env.local 32 | 33 | #jwt keys 34 | mkdir -p config/jwt && \ 35 | openssl genpkey -out config/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa_keygen_bits:4096 && \ 36 | openssl pkey -in config/jwt/private.pem -out config/jwt/public.pem -pubout 37 | 38 | echo -n "Enter PEM password to save in .env.local: " 39 | read -s JWT_PASSPHRASE 40 | sed -i "s/^JWT_PASSPHRASE=.*/JWT_PASSPHRASE=${JWT_PASSPHRASE}/g" .env.local 41 | -------------------------------------------------------------------------------- /setup_env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "$1" ] 4 | then 5 | echo 'Enter the environment by first argument [dev, prod, test]' 6 | exit 1 7 | fi 8 | 9 | #docker 10 | PUID=$(id -u) 11 | PGID=$(id -g) 12 | DB_ROOT_PASSWORD=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 6 ; echo '') 13 | DB_PASSWORD=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 6 ; echo '') 14 | 15 | #app 16 | APP_ENV=dev 17 | APP_SECRET=$(xxd -l 16 -p /dev/random) 18 | DATABASE_URL="mysql:\/\/task:${DB_PASSWORD}@db:3306\/task?serverVersion=8.0" 19 | 20 | #docker 21 | cp ./docker/.env.dist ./docker/.env && \ 22 | sed -i "s/^PUID=.*/PUID=${PUID}/g" ./docker/.env && \ 23 | sed -i "s/^PGID=.*/PGID=${PGID}/g" ./docker/.env && \ 24 | sed -i "s/^DB_ROOT_PASSWORD=.*/DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}/g" ./docker/.env && \ 25 | sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=${DB_PASSWORD}/g" ./docker/.env 26 | 27 | #application 28 | cp .env .env.local && \ 29 | sed -i "s/^APP_ENV=.*/APP_ENV=${APP_ENV}/g" .env.local && \ 30 | sed -i "s/^APP_SECRET=.*/APP_SECRET=${APP_SECRET}/g" .env.local && \ 31 | sed -i "s/^DATABASE_URL=.*/DATABASE_URL=${DATABASE_URL}/g" .env.local 32 | 33 | #jwt keys 34 | mkdir -p config/jwt && \ 35 | openssl genpkey -out config/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa_keygen_bits:4096 && \ 36 | openssl pkey -in config/jwt/private.pem -out config/jwt/public.pem -pubout 37 | 38 | echo -n "Enter PEM password to save in .env.local: " 39 | read -s JWT_PASSPHRASE 40 | sed -i "s/^JWT_PASSPHRASE=.*/JWT_PASSPHRASE=${JWT_PASSPHRASE}/g" .env.local 41 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | in(['src']) 5 | ; 6 | return \PhpCsFixer\Config::create() 7 | ->setRules([ 8 | '@Symfony' => true, 9 | '@PHP71Migration' => true, 10 | 'concat_space' => ['spacing' => 'one'], 11 | 'phpdoc_summary' => false, 12 | 'phpdoc_align' => false, 13 | 'no_short_echo_tag' => true, 14 | 'no_useless_else' => true, 15 | 'is_null' => true, 16 | 'multiline_whitespace_before_semicolons' => true, 17 | 'list_syntax' => ['syntax' => 'short'], 18 | 'array_syntax' => ['syntax' => 'short'], 19 | 'php_unit_strict' => false, 20 | 'strict_comparison' => true, 21 | 'strict_param' => true, 22 | 'declare_strict_types' => true, 23 | 'yoda_style' => false, 24 | 'ordered_class_elements' => true, 25 | 'date_time_immutable' => true, 26 | 'no_unused_imports' => true, 27 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 28 | 'native_function_invocation' => [ 29 | 'include' => ['@compiler_optimized'] 30 | ], 31 | 'method_argument_space' => [ 32 | 'on_multiline' => 'ensure_fully_multiline' 33 | ], 34 | 'fully_qualified_strict_types' => true, 35 | 'no_unreachable_default_argument_value' => true, 36 | 'static_lambda' => true, 37 | 'no_superfluous_phpdoc_tags' => false, 38 | 'single_line_throw' => false, 39 | ]) 40 | ->setFinder($finder) 41 | ; 42 | -------------------------------------------------------------------------------- /config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | encoders: 3 | App\Core\Domain\Model\User\User: 4 | algorithm: auto 5 | 6 | providers: 7 | app_user_provider: 8 | entity: 9 | class: App\Core\Domain\Model\User\User 10 | property: username 11 | firewalls: 12 | api_doc: 13 | pattern: ^/api/doc 14 | stateless: true 15 | anonymous: true 16 | create_token: 17 | pattern: ^/api/auth-token 18 | stateless: true 19 | anonymous: true 20 | login: 21 | pattern: ^/api/login 22 | stateless: true 23 | anonymous: true 24 | json_login: 25 | check_path: /api/login_check 26 | success_handler: lexik_jwt_authentication.handler.authentication_success 27 | failure_handler: lexik_jwt_authentication.handler.authentication_failure 28 | 29 | api: 30 | pattern: ^/api 31 | stateless: true 32 | guard: 33 | authenticators: 34 | - lexik_jwt_authentication.jwt_token_authenticator 35 | 36 | access_control: 37 | - { path: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } 38 | - { path: ^/api/auth-token, roles: IS_AUTHENTICATED_ANONYMOUSLY } 39 | - { path: ^/api/doc, roles: IS_AUTHENTICATED_ANONYMOUSLY } 40 | - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } 41 | -------------------------------------------------------------------------------- /src/Core/Application/Command/User/CreateUser/CreateUserCommandHandler.php: -------------------------------------------------------------------------------- 1 | encoderFactory = $encoderFactory; 26 | $this->userRepository = $userRepository; 27 | $this->uniqueUsernameSpecification = $uniqueUsernameSpecification; 28 | } 29 | 30 | public function __invoke(CreateUserCommand $command): void 31 | { 32 | $encoder = $this->encoderFactory->getEncoder(User::class); 33 | $user = new User( 34 | $command->getUsername(), 35 | $encoder->encodePassword($command->getPassword(), null), 36 | $this->uniqueUsernameSpecification 37 | ); 38 | $this->userRepository->add($user); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Core/Application/Command/Task/UpdateTask/UpdateTaskCommandHandler.php: -------------------------------------------------------------------------------- 1 | taskRepository = $taskRepository; 21 | $this->userFetcher = $userFetcher; 22 | } 23 | 24 | public function __invoke(UpdateTaskCommand $command): void 25 | { 26 | $task = $this->taskRepository->find($command->getId()); 27 | 28 | if ($task === null) { 29 | throw new ResourceNotFoundException(sprintf('Task with id "%s" is not found', $command->getId())); 30 | } 31 | 32 | $user = $this->userFetcher->fetchRequiredUser(); 33 | 34 | if (!$task->getUser()->equals($user)) { 35 | throw new AccessForbiddenException('Access prohibited'); 36 | } 37 | 38 | $task->changeTitle($command->getTitle()); 39 | $task->changeDescription($command->getDescription()); 40 | $task->changeExecutionDay($command->getExecutionDay()); 41 | 42 | $this->taskRepository->add($task); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Core/Application/Command/AuthToken/CreateAuthToken/CreateAuthTokenCommandHandler.php: -------------------------------------------------------------------------------- 1 | userPasswordEncoder = $userPasswordEncoder; 26 | $this->userRepository = $userRepository; 27 | $this->JWTTokenManager = $JWTTokenManager; 28 | } 29 | 30 | public function __invoke(CreateAuthTokenCommand $command): string 31 | { 32 | $user = $this->userRepository->findUserByUserName($command->getUsername()); 33 | 34 | if ($user === null) { 35 | throw new InvalidInputDataException('Invalid credentials'); 36 | } 37 | 38 | if (!$this->userPasswordEncoder->isPasswordValid($user, $command->getPassword())) { 39 | throw new InvalidInputDataException('Invalid credentials'); 40 | } 41 | 42 | return $this->JWTTokenManager->create($user); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Core/Ports/Rest/Task/DeleteTaskAction.php: -------------------------------------------------------------------------------- 1 | messageBus = $commandBus; 25 | } 26 | 27 | /** 28 | * @Route("/api/tasks/{id}", methods={"DELETE"}, requirements={"id": "\d+"}) 29 | * 30 | * @param Request $request 31 | * 32 | * @return Response 33 | * 34 | * @OA\Response(response=Response::HTTP_NO_CONTENT, description=HttpSpec::STR_HTTP_NO_CONTENT) 35 | * @OA\Response(response=Response::HTTP_NOT_FOUND, description=HttpSpec::STR_HTTP_NOT_FOUND) 36 | * @OA\Response(response=Response::HTTP_UNAUTHORIZED, description=HttpSpec::STR_HTTP_UNAUTHORIZED) 37 | * 38 | * @OA\Tag(name="Task") 39 | */ 40 | public function __invoke(Request $request): Response 41 | { 42 | $route = ParamFetcher::fromRequestAttributes($request); 43 | 44 | $this->handle(new DeleteTaskCommand($route->getRequiredInt('id'))); 45 | 46 | return new JsonResponse(null, Response::HTTP_NO_CONTENT); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Core/Ports/Rest/Task/MakeTaskDoneAction.php: -------------------------------------------------------------------------------- 1 | messageBus = $commandBus; 25 | } 26 | 27 | /** 28 | * @Route("/api/tasks/{id}/status/done", methods={"PATCH"}) 29 | * 30 | * @OA\Patch(description="Make task done") 31 | * 32 | * @param Request $request 33 | * 34 | * @return Response 35 | * 36 | * @OA\Response(response=Response::HTTP_NO_CONTENT, description=HttpSpec::STR_HTTP_NO_CONTENT) 37 | * @OA\Response(response=Response::HTTP_NOT_FOUND, description=HttpSpec::STR_HTTP_NOT_FOUND) 38 | * @OA\Response(response=Response::HTTP_UNAUTHORIZED, description=HttpSpec::STR_HTTP_UNAUTHORIZED) 39 | * 40 | * @OA\Tag(name="Task") 41 | */ 42 | public function __invoke(Request $request): Response 43 | { 44 | $route = ParamFetcher::fromRequestAttributes($request); 45 | 46 | $this->handle(new MakeTaskDoneCommand($route->getRequiredInt('id'))); 47 | 48 | return new JsonResponse(null, Response::HTTP_NO_CONTENT); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Core/Ports/Rest/Task/MakeTaskDeclinedAction.php: -------------------------------------------------------------------------------- 1 | messageBus = $commandBus; 25 | } 26 | 27 | /** 28 | * @Route("/api/tasks/{id}/status/declined", methods={"PATCH"}) 29 | * 30 | * @OA\Patch(description="Make task declined") 31 | * 32 | * @param Request $request 33 | * 34 | * @return Response 35 | * 36 | * @OA\Response(response=Response::HTTP_NO_CONTENT, description=HttpSpec::STR_HTTP_NO_CONTENT) 37 | * @OA\Response(response=Response::HTTP_NOT_FOUND, description=HttpSpec::STR_HTTP_NOT_FOUND) 38 | * @OA\Response(response=Response::HTTP_UNAUTHORIZED, description=HttpSpec::STR_HTTP_UNAUTHORIZED) 39 | * 40 | * @OA\Tag(name="Task") 41 | */ 42 | public function __invoke(Request $request): Response 43 | { 44 | $route = ParamFetcher::fromRequestAttributes($request); 45 | 46 | $this->handle(new MakeTaskDeclinedCommand($route->getRequiredInt('id'))); 47 | 48 | return new JsonResponse(null, Response::HTTP_NO_CONTENT); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Core/Domain/Model/User/UserGS.php: -------------------------------------------------------------------------------- 1 | id; 14 | } 15 | 16 | public function getUsername(): string 17 | { 18 | return $this->username; 19 | } 20 | 21 | /** 22 | * @return array 23 | */ 24 | public function getRoles(): array 25 | { 26 | return $this->roles; 27 | } 28 | 29 | public function getPassword(): string 30 | { 31 | return $this->password; 32 | } 33 | 34 | public function getSalt(): string 35 | { 36 | return ''; 37 | } 38 | 39 | public function getCreatedAt(): \DateTimeImmutable 40 | { 41 | return $this->createdAt; 42 | } 43 | 44 | // Setters 45 | 46 | private function setPassword(string $password): void 47 | { 48 | Assert::maxLength($password, self::MAX_PASSWORD_LENGTH, 'Password should contain at most %2$s characters. Got: %s'); 49 | $this->password = $password; 50 | } 51 | 52 | private function setUsername(string $username): void 53 | { 54 | Assert::maxLength($username, self::MAX_USER_NAME_LENGTH, 'Username should contain at most %2$s characters. Got: %s'); 55 | $this->username = $username; 56 | } 57 | 58 | private function setCreatedAt(\DateTimeImmutable $createdAt): void 59 | { 60 | $this->createdAt = $createdAt; 61 | } 62 | 63 | /** 64 | * @param array $roles 65 | */ 66 | private function setRoles(array $roles): void 67 | { 68 | if (!\in_array(self::DEFAULT_USER_ROLE, $roles, true)) { 69 | $roles[] = self::DEFAULT_USER_ROLE; 70 | } 71 | 72 | $this->roles = array_unique($roles); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Http/ApiRequestSubscriber.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public static function getSubscribedEvents(): array 21 | { 22 | return [KernelEvents::REQUEST => 'onRequest']; 23 | } 24 | 25 | public function onRequest(RequestEvent $event): void 26 | { 27 | if (!$event->isMasterRequest()) { 28 | return; 29 | } 30 | 31 | $request = $event->getRequest(); 32 | 33 | if (\is_resource($request->getContent()) 34 | || $request->getContent() === '' 35 | || strpos($request->getPathInfo(), '/api/doc') === 0 36 | || strpos($request->getPathInfo(), '/api/') !== 0) { 37 | return; 38 | } 39 | 40 | if ($request->getContentType() !== 'json') { 41 | $event->setResponse(new JsonResponse('Invalid content type', Response::HTTP_BAD_REQUEST)); 42 | 43 | return; 44 | } 45 | 46 | try { 47 | $requestContent = json_decode($request->getContent(), true, self::DEFAULT_JSON_DEPTH, JSON_THROW_ON_ERROR); 48 | } catch (\JsonException $e) { 49 | $event->setResponse(new JsonResponse('Invalid json string', Response::HTTP_BAD_REQUEST)); 50 | 51 | return; 52 | } 53 | 54 | if (\is_array($requestContent)) { 55 | $request->request->replace($requestContent); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Doctrine/DomainEventSubscriber.php: -------------------------------------------------------------------------------- 1 | eventBus = $eventBus; 26 | } 27 | 28 | public function getSubscribedEvents(): array 29 | { 30 | return [ 31 | Events::postPersist, 32 | Events::postUpdate, 33 | Events::postRemove, 34 | Events::postFlush, 35 | ]; 36 | } 37 | 38 | public function postPersist(LifecycleEventArgs $args): void 39 | { 40 | $this->keepAggregateRoots($args); 41 | } 42 | 43 | public function postUpdate(LifecycleEventArgs $args): void 44 | { 45 | $this->keepAggregateRoots($args); 46 | } 47 | 48 | public function postRemove(LifecycleEventArgs $args): void 49 | { 50 | $this->keepAggregateRoots($args); 51 | } 52 | 53 | public function postFlush(PostFlushEventArgs $args): void 54 | { 55 | foreach ($this->entities as $entity) { 56 | foreach ($entity->popEvents() as $event) { 57 | $this->eventBus->dispatch($event); 58 | } 59 | } 60 | } 61 | 62 | private function keepAggregateRoots(LifecycleEventArgs $args): void 63 | { 64 | $entity = $args->getEntity(); 65 | 66 | if (!($entity instanceof Aggregate)) { 67 | return; 68 | } 69 | 70 | $this->entities[] = $entity; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | # This file is the entry point to configure your own services. 2 | # Files in the packages/ subdirectory configure your dependencies. 3 | 4 | # Put parameters here that don't need to change on each machine where the app is deployed 5 | # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration 6 | parameters: 7 | 8 | services: 9 | # default configuration for services in *this* file 10 | _defaults: 11 | autowire: true # Automatically injects dependencies in your services. 12 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 13 | 14 | # makes classes in src/ available to be used as services 15 | # this creates a service per class whose id is the fully-qualified class name 16 | App\: 17 | resource: '../src/*' 18 | exclude: 19 | - '../src/Shared/Infrastructure/Migration' 20 | 21 | App\Core\Ports\Rest\: 22 | resource: '../src/Core/Ports/Rest' 23 | tags: ['controller.service_arguments'] 24 | 25 | command_handlers: 26 | namespace: App\Core\Application\Command\ 27 | resource: '%kernel.project_dir%/src/Core/Application/Command/*/*/*CommandHandler.php' 28 | autoconfigure: false 29 | tags: 30 | - { name: messenger.message_handler, bus: command.bus } 31 | 32 | query_handlers: 33 | namespace: App\Core\Application\Query\ 34 | resource: '%kernel.project_dir%/src/Core/Application/Query/*/*/*QueryHandler.php' 35 | autoconfigure: false 36 | tags: 37 | - { name: messenger.message_handler, bus: query.bus } 38 | 39 | event_handlers: 40 | namespace: App\Core\Application\ 41 | resource: '%kernel.project_dir%/src/Core/Application/**/*EventHandler.php' 42 | autoconfigure: false 43 | tags: 44 | - { name: messenger.message_handler, bus: event.bus } 45 | 46 | App\Shared\Infrastructure\Doctrine\DomainEventSubscriber: 47 | tags: [{name: 'doctrine.event_subscriber'}] 48 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # 13 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 14 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 15 | 16 | ###> symfony/framework-bundle ### 17 | APP_ENV= 18 | APP_SECRET= 19 | #TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 20 | #TRUSTED_HOSTS='^(localhost|example\.com)$' 21 | ###< symfony/framework-bundle ### 22 | 23 | ###> doctrine/doctrine-bundle ### 24 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 25 | # For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db" 26 | # For a PostgreSQL database, use: "postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=11&charset=utf8" 27 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml 28 | DATABASE_URL= 29 | ###< doctrine/doctrine-bundle ### 30 | ###> symfony/messenger ### 31 | # Choose one of the transports below 32 | # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages 33 | # MESSENGER_TRANSPORT_DSN=doctrine://default 34 | # MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages 35 | ###< symfony/messenger ### 36 | 37 | ###> lexik/jwt-authentication-bundle ### 38 | JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem 39 | JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem 40 | JWT_PASSPHRASE=532342fc598e3f18af81096bcbdef713 41 | ###< lexik/jwt-authentication-bundle ### 42 | -------------------------------------------------------------------------------- /src/Core/Ports/Rest/Task/GetTaskAction.php: -------------------------------------------------------------------------------- 1 | messageBus = $queryBus; 30 | $this->normalizer = $normalizer; 31 | } 32 | 33 | /** 34 | * @Route("/api/tasks/{id}", methods={"GET"}, requirements={"id": "\d+"}, name="api_get_task") 35 | * 36 | * @param Request $request 37 | * 38 | * @return Response 39 | * 40 | * @OA\Response( 41 | * response=Response::HTTP_OK, 42 | * description=HttpSpec::STR_HTTP_OK, 43 | * @OA\Schema(ref=@Model(type=TaskDTO::class, groups={"task_view"})) 44 | * ) 45 | * @OA\Response(response=Response::HTTP_NOT_FOUND, description=HttpSpec::STR_HTTP_NOT_FOUND) 46 | * @OA\Response(response=Response::HTTP_UNAUTHORIZED, description=HttpSpec::STR_HTTP_UNAUTHORIZED) 47 | * 48 | * @OA\Tag(name="Task") 49 | */ 50 | public function __invoke(Request $request): Response 51 | { 52 | $route = ParamFetcher::fromRequestAttributes($request); 53 | 54 | $task = $this->handle(new GetTaskQuery($route->getRequiredInt('id'))); 55 | 56 | return new JsonResponse( 57 | $this->normalizer->normalize($task, '', ['groups' => 'task_view']), 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: run 2 | run: 3 | @cd docker && docker-compose run -u dev workplace $(filter-out $@,$(MAKECMDGOALS)) 4 | 5 | .PHONY: dev 6 | dev: 7 | @cd docker && docker-compose exec -u dev workplace bash 8 | 9 | ################## 10 | # Docker compose 11 | ################## 12 | 13 | .PHONY: dc_start 14 | dc_start: 15 | @cd docker && docker-compose start 16 | 17 | .PHONY: dc_stop 18 | dc_stop: 19 | @cd docker && docker-compose stop 20 | 21 | .PHONY: dc_up 22 | dc_up: 23 | @cd docker && docker-compose up -d 24 | 25 | .PHONY: dc_ps 26 | dc_ps: 27 | @cd docker && docker-compose ps 28 | 29 | .PHONY: dc_down 30 | dc_down: 31 | @cd docker && docker-compose down -v --rmi=all --remove-orphans 32 | 33 | ################## 34 | # Setup 35 | ################## 36 | 37 | .PHONY: setup_dev 38 | setup_dev: 39 | cd docker && docker-compose run -u dev workplace composer install 40 | cd docker && docker-compose run -u dev workplace php bin/console doctrine:migrations:migrate --no-interaction 41 | cd docker && docker-compose run -u dev workplace php bin/console cache:clear 42 | 43 | ################## 44 | # CI (workplace container) 45 | ################## 46 | 47 | .PHONY: analyze 48 | analyze: deptrac cs_check phpmnd phpcpd phpstan security_check schema_validate phpunit 49 | 50 | .PHONY: cs_check 51 | cs_check: 52 | php-cs-fixer fix --config=.php_cs -v --allow-risky=yes --dry-run --diff --stop-on-violation 53 | 54 | .PHONY: cs_fix 55 | cs_fix: 56 | php-cs-fixer fix --config=.php_cs -v --allow-risky=yes --diff 57 | 58 | .PHONY: schema_validate 59 | schema_validate: 60 | php bin/console doctrine:cache:clear-metadata 61 | php bin/console doctrine:schema:validate 62 | 63 | .PHONY: phpmnd 64 | phpmnd: 65 | /home/dev/.composer/vendor/bin/phpmnd src -v --progress --extensions=all --non-zero-exit-on-violation 66 | 67 | .PHONY: phpcpd 68 | phpcpd: 69 | ./vendor/bin/phpcpd src --exclude=Entity 70 | 71 | .PHONY: security_check 72 | security_check: 73 | php bin/console security:check 74 | 75 | .PHONY: phpstan 76 | phpstan: 77 | php ./vendor/bin/phpstan analyse src -c phpstan.neon 78 | 79 | .PHONY: deptrac 80 | deptrac: 81 | php ./vendor/bin/deptrac analyze depfile.yaml 82 | 83 | .PHONY: phpunit 84 | phpunit: 85 | php ./vendor/bin/phpunit 86 | 87 | -------------------------------------------------------------------------------- /src/Core/Ports/Cli/AddUserCommand.php: -------------------------------------------------------------------------------- 1 | messageBus = $commandBus; 29 | parent::__construct(); 30 | } 31 | 32 | protected function execute(InputInterface $input, OutputInterface $output): int 33 | { 34 | $helper = $this->getHelper('question'); 35 | 36 | $question = new Question('Please enter the username [admin] : ', 'admin'); 37 | $userName = (string) $helper->ask($input, $output, $question); 38 | 39 | if ($userName === '') { 40 | $output->writeln('User name should be not blank'); 41 | } 42 | 43 | $question = new Question('Please enter the password : '); 44 | $question->setHidden(true); 45 | $password = (string) $helper->ask($input, $output, $question); 46 | 47 | if (\strlen($password) < self::MIN_PASSWORD_LENGTH) { 48 | $output->writeln('Password is to short, need more than 4 symbols (bytes)'); 49 | } 50 | 51 | $question = new Question('Please repeat the password : '); 52 | $question->setHidden(true); 53 | $passwordRepeat = (string) $helper->ask($input, $output, $question); 54 | 55 | if ($password !== $passwordRepeat) { 56 | $output->writeln('Passwords dont match'); 57 | } 58 | 59 | $this->handle(new CreateUserCommand($userName, $password)); 60 | 61 | $output->writeln('User created'); 62 | 63 | return 0; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | getProjectDir() . '/config/bundles.php'; 23 | foreach ($contents as $class => $envs) { 24 | if ($envs[$this->environment] ?? $envs['all'] ?? false) { 25 | yield new $class(); 26 | } 27 | } 28 | } 29 | 30 | public function getProjectDir(): string 31 | { 32 | return \dirname(__DIR__); 33 | } 34 | 35 | protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void 36 | { 37 | $container->addResource(new FileResource($this->getProjectDir() . '/config/bundles.php')); 38 | $container->setParameter('container.dumper.inline_class_loader', \PHP_VERSION_ID < 70400 || $this->debug); 39 | $container->setParameter('container.dumper.inline_factories', true); 40 | $confDir = $this->getProjectDir() . '/config'; 41 | 42 | $loader->load($confDir . '/{packages}/*' . self::CONFIG_EXTS, 'glob'); 43 | $loader->load($confDir . '/{packages}/' . $this->environment . '/*' . self::CONFIG_EXTS, 'glob'); 44 | $loader->load($confDir . '/{services}' . self::CONFIG_EXTS, 'glob'); 45 | $loader->load($confDir . '/{services}_' . $this->environment . self::CONFIG_EXTS, 'glob'); 46 | } 47 | 48 | protected function configureRoutes(RouteCollectionBuilder $routes): void 49 | { 50 | $confDir = $this->getProjectDir() . '/config'; 51 | 52 | $routes->import($confDir . '/{routes}/' . $this->environment . '/*' . self::CONFIG_EXTS, '/', 'glob'); 53 | $routes->import($confDir . '/{routes}/*' . self::CONFIG_EXTS, '/', 'glob'); 54 | $routes->import($confDir . '/{routes}' . self::CONFIG_EXTS, '/', 'glob'); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Migration/Version20200601085854.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); 24 | 25 | $this->addSql('CREATE TABLE user (id INT UNSIGNED AUTO_INCREMENT NOT NULL, username VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_8D93D649F85E0677 (username), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 26 | $this->addSql('CREATE TABLE task (id INT UNSIGNED AUTO_INCREMENT NOT NULL, user_id INT UNSIGNED NOT NULL, title VARCHAR(100) NOT NULL, description VARCHAR(255) NOT NULL, execution_day DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', status VARCHAR(10) NOT NULL CHECK ( status IN (\'new\',\'declined\',\'done\')), INDEX IDX_527EDB25A76ED395 (user_id), INDEX task_status_idx (status), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 27 | $this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB25A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE'); 28 | } 29 | 30 | public function down(Schema $schema): void 31 | { 32 | // this down() migration is auto-generated, please modify it to your needs 33 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); 34 | 35 | $this->addSql('ALTER TABLE task DROP FOREIGN KEY FK_527EDB25A76ED395'); 36 | $this->addSql('DROP TABLE user'); 37 | $this->addSql('DROP TABLE task'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Unit/Core/Application/Command/Task/CreateTask/CreateTaskCommandHandlerTest.php: -------------------------------------------------------------------------------- 1 | setTime(0, 0); 21 | $title = 'Some title'; 22 | $description = 'Some description'; 23 | 24 | $repository = $this->createMock(TaskRepositoryInterface::class); 25 | $repository->expects(self::once()) 26 | ->method('add') 27 | ->with(self::callback( 28 | fn(Task $task): bool => $task->getTitle() === $title 29 | && $task->getDescription() === $description 30 | && $task->getExecutionDay() == $executionDay 31 | )); 32 | 33 | $userFetcher = $this->createMock(UserFetcherInterface::class); 34 | $userFetcher->method('fetchRequiredUser')->willReturn(new User('name', 'pass_hash', $this->getUniqueUsernameSpecification())); 35 | 36 | $command = new CreateTaskCommand($title, $executionDay, $description); 37 | $handler = new CreateTaskCommandHandler($repository, $userFetcher); 38 | 39 | try { 40 | $handler($command); 41 | } catch (\Error $e) { 42 | // php7.4 fix 43 | if (strpos($e->getMessage(), 'id must not be accessed before initialization') === false) { 44 | throw $e; 45 | } 46 | } 47 | } 48 | 49 | private function getUniqueUsernameSpecification(): UniqueUsernameSpecificationInterface 50 | { 51 | $specification = $this->createMock(UniqueUsernameSpecificationInterface::class); 52 | $specification->method('isSatisfiedBy')->willReturn(true); 53 | 54 | return $specification; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Core/Domain/Model/Task/TaskGS.php: -------------------------------------------------------------------------------- 1 | id; 15 | } 16 | 17 | public function getTitle(): string 18 | { 19 | return $this->title; 20 | } 21 | 22 | public function getExecutionDay(): \DateTimeImmutable 23 | { 24 | return $this->executionDay; 25 | } 26 | 27 | public function getUser(): User 28 | { 29 | return $this->user; 30 | } 31 | 32 | public function getDescription(): string 33 | { 34 | return $this->description; 35 | } 36 | 37 | public function getStatus(): Status 38 | { 39 | return $this->status; 40 | } 41 | 42 | public function getCreatedAt(): \DateTimeImmutable 43 | { 44 | return $this->createdAt; 45 | } 46 | 47 | private function setTitle(string $title): void 48 | { 49 | Assert::minLength($title, self::MIN_TITLE_LENGTH, 'Title should contain at least %2$s characters. Got: %s'); 50 | Assert::maxLength($title, self::MAX_TITLE_LENGTH, 'Title should contain at most %2$s characters. Got: %s'); 51 | $this->title = $title; 52 | } 53 | 54 | private function setDescription(string $description): void 55 | { 56 | Assert::maxLength($description, self::MAX_DESCRIPTION_LENGTH, 'Description should contain at most %2$s characters. Got: %s'); 57 | $this->description = $description; 58 | } 59 | 60 | private function setUser(User $user): void 61 | { 62 | $this->user = $user; 63 | } 64 | 65 | private function setStatus(Status $status): void 66 | { 67 | $this->status = $status; 68 | } 69 | 70 | private function setExecutionDay(\DateTimeImmutable $executionDay): void 71 | { 72 | $executionDayNormalized = $executionDay->setTime(0, 0); 73 | $now = (new \DateTimeImmutable())->setTime(0, 0); 74 | 75 | Assert::greaterThanEq($executionDayNormalized, $now, 'Execution day should be not in past'); 76 | 77 | $this->executionDay = $executionDayNormalized; 78 | } 79 | 80 | private function setCreatedAt(\DateTimeImmutable $createdAt): void 81 | { 82 | $this->createdAt = $createdAt; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Core/Domain/Model/User/User.php: -------------------------------------------------------------------------------- 1 | 37 | * 38 | * @ORM\Column(type="json", nullable=false) 39 | */ 40 | private array $roles = []; 41 | 42 | /** 43 | * @ORM\Column(type="string", nullable=false) 44 | */ 45 | private string $password; 46 | 47 | /** 48 | * @ORM\Column(type="datetime_immutable", options={"default"="CURRENT_TIMESTAMP"}, nullable=false) 49 | */ 50 | private \DateTimeImmutable $createdAt; 51 | 52 | /** 53 | * @param string $username 54 | * @param string $password 55 | * @param UniqueUsernameSpecificationInterface $uniqueUsernameSpecification 56 | * @param array|string[] $roles 57 | */ 58 | public function __construct( 59 | string $username, 60 | string $password, 61 | UniqueUsernameSpecificationInterface $uniqueUsernameSpecification, 62 | array $roles = [self::DEFAULT_USER_ROLE] 63 | ) { 64 | if (!$uniqueUsernameSpecification->isSatisfiedBy($username)) { 65 | throw new InvalidInputDataException(sprintf('Username %s already exists', $username)); 66 | } 67 | 68 | $this->setUsername($username); 69 | $this->setPassword($password); 70 | $this->setRoles($roles); 71 | $this->setCreatedAt(new \DateTimeImmutable()); 72 | } 73 | 74 | public function eraseCredentials(): void 75 | { 76 | //dont need 77 | } 78 | 79 | public function equals(User $user): bool 80 | { 81 | return $user->getId() === $this->getId(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Core/Ports/Rest/AuthToken/CreateAuthTokenAction.php: -------------------------------------------------------------------------------- 1 | messageBus = $commandBus; 25 | } 26 | 27 | /** 28 | * @Route("/api/auth-token", methods={"POST"}) 29 | * 30 | * @param Request $request 31 | * 32 | * @return Response 33 | * 34 | * @OA\Parameter( 35 | * name="body", 36 | * in="body", 37 | * description="JSON Payload", 38 | * required=true, 39 | * content="application/json", 40 | * @OA\Schema( 41 | * type="object", 42 | * @OA\Property(property="username", type="string"), 43 | * @OA\Property(property="password", type="string"), 44 | * ) 45 | * ) 46 | * 47 | * @OA\Response( 48 | * response=Response::HTTP_CREATED, 49 | * description=HttpSpec::STR_HTTP_CREATED, 50 | * @OA\Schema(@OA\Property(property="token", type="string")) 51 | * ) 52 | * @OA\Response(response=Response::HTTP_BAD_REQUEST, description=HttpSpec::STR_HTTP_BAD_REQUEST) 53 | * @OA\Response(response=Response::HTTP_UNAUTHORIZED, description=HttpSpec::STR_HTTP_UNAUTHORIZED) 54 | * 55 | * @OA\Tag(name="Auth token") 56 | */ 57 | public function __invoke(Request $request): Response 58 | { 59 | $paramFetcher = ParamFetcher::fromRequestBody($request); 60 | 61 | $token = $this->handle(new CreateAuthTokenCommand( 62 | $paramFetcher->getRequiredString('username'), 63 | $paramFetcher->getRequiredString('password') 64 | )); 65 | 66 | return new JsonResponse(['token' => $token], Response::HTTP_CREATED); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Http/ApiExceptionSubscriber.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public static function getSubscribedEvents(): array 23 | { 24 | return [ 25 | KernelEvents::EXCEPTION => 'onException', 26 | ]; 27 | } 28 | 29 | public function onException(ExceptionEvent $event): void 30 | { 31 | if (!$event->isMasterRequest()) { 32 | return; 33 | } 34 | 35 | $request = $event->getRequest(); 36 | 37 | if (strpos($request->getPathInfo(), '/api/doc') === 0 38 | || strpos($request->getPathInfo(), '/api/') !== 0) { 39 | return; 40 | } 41 | 42 | $throwable = $event->getThrowable(); 43 | 44 | if ($throwable instanceof HandlerFailedException && \count($throwable->getNestedExceptions()) > 0) { 45 | $throwable = $throwable->getNestedExceptions()[0]; 46 | } 47 | 48 | switch (true) { 49 | case $throwable instanceof ResourceNotFoundException: 50 | $event->setResponse( 51 | new JsonResponse($event->getThrowable()->getMessage(), Response::HTTP_NOT_FOUND) 52 | ); 53 | break; 54 | 55 | case $throwable instanceof AccessForbiddenException: 56 | $event->setResponse( 57 | new JsonResponse($event->getThrowable()->getMessage(), Response::HTTP_FORBIDDEN) 58 | ); 59 | break; 60 | 61 | case $throwable instanceof InvalidInputDataException: 62 | $event->setResponse( 63 | new JsonResponse($event->getThrowable()->getMessage(), Response::HTTP_BAD_REQUEST) 64 | ); 65 | break; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Core/Ports/Rest/Task/UpdateTaskAction.php: -------------------------------------------------------------------------------- 1 | messageBus = $commandBus; 25 | } 26 | 27 | /** 28 | * @Route("/api/tasks/{id}", methods={"PUT"}, requirements={"id": "\d+"}) 29 | * 30 | * @param Request $request 31 | * 32 | * @return Response 33 | * 34 | * @OA\Parameter( 35 | * name="body", 36 | * in="body", 37 | * description="JSON Payload", 38 | * required=true, 39 | * content="application/json", 40 | * @OA\Schema( 41 | * type="object", 42 | * @OA\Property(property="title", type="string"), 43 | * @OA\Property(property="execution_day", type="string"), 44 | * @OA\Property(property="description", type="string"), 45 | * ) 46 | * ) 47 | * 48 | * @OA\Response(response=Response::HTTP_NO_CONTENT, description=HttpSpec::STR_HTTP_NO_CONTENT) 49 | * @OA\Response(response=Response::HTTP_NOT_FOUND, description=HttpSpec::STR_HTTP_NOT_FOUND) 50 | * @OA\Response(response=Response::HTTP_BAD_REQUEST, description=HttpSpec::STR_HTTP_BAD_REQUEST) 51 | * @OA\Response(response=Response::HTTP_UNAUTHORIZED, description=HttpSpec::STR_HTTP_UNAUTHORIZED) 52 | * 53 | * @OA\Tag(name="Task") 54 | */ 55 | public function __invoke(Request $request): Response 56 | { 57 | $route = ParamFetcher::fromRequestAttributes($request); 58 | $body = ParamFetcher::fromRequestBody($request); 59 | 60 | $command = new UpdateTaskCommand( 61 | $route->getRequiredInt('id'), 62 | $body->getRequiredString('title'), 63 | $body->getRequiredDate('execution_day'), 64 | $body->getNullableString('description') ?? '', 65 | ); 66 | 67 | $this->handle($command); 68 | 69 | return new JsonResponse(null, Response::HTTP_NO_CONTENT); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /docker/php-fpm/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.4-fpm 2 | 3 | # Set Environment Variables 4 | ENV DEBIAN_FRONTEND noninteractive 5 | 6 | RUN set -eux; \ 7 | apt-get update; \ 8 | apt-get upgrade -y; \ 9 | apt-get install -yqq apt-utils; \ 10 | apt-get install -y --no-install-recommends \ 11 | curl \ 12 | libmemcached-dev \ 13 | libz-dev \ 14 | libpq-dev \ 15 | libjpeg-dev \ 16 | libpng-dev \ 17 | libfreetype6-dev \ 18 | libssl-dev \ 19 | libmcrypt-dev \ 20 | libonig-dev \ 21 | libzip-dev zip unzip; 22 | 23 | RUN set -eux; \ 24 | docker-php-ext-install pdo_mysql; \ 25 | docker-php-ext-configure gd \ 26 | --prefix=/usr \ 27 | --with-jpeg \ 28 | --with-freetype; \ 29 | docker-php-ext-install gd; \ 30 | docker-php-ext-configure zip; \ 31 | docker-php-ext-install zip; \ 32 | php -r 'var_dump(gd_info());' 33 | 34 | 35 | RUN set -xe; \ 36 | pecl channel-update pecl.php.net 37 | 38 | ########################################################################### 39 | # xDebug: 40 | ########################################################################### 41 | 42 | ARG INSTALL_XDEBUG=true 43 | 44 | RUN if [ ${INSTALL_XDEBUG} = true ]; then \ 45 | pecl install xdebug; \ 46 | docker-php-ext-enable xdebug \ 47 | ;fi 48 | 49 | # Copy xdebug configuration for remote debugging 50 | COPY ./xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini 51 | 52 | ########################################################################### 53 | # bcmath: 54 | ########################################################################### 55 | 56 | RUN docker-php-ext-install bcmath 57 | 58 | ########################################################################### 59 | # LDAP: 60 | ########################################################################### 61 | 62 | RUN apt-get install -y libldap2-dev && \ 63 | docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/ && \ 64 | docker-php-ext-install ldap 65 | 66 | 67 | COPY ./php7.4-dev.ini /usr/local/etc/php/php.ini 68 | 69 | USER root 70 | 71 | # Clean up 72 | RUN apt-get clean && \ 73 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \ 74 | rm /var/log/lastlog /var/log/faillog 75 | 76 | # Configure non-root user. 77 | ARG PUID=1000 78 | ENV PUID ${PUID} 79 | ARG PGID=1000 80 | ENV PGID ${PGID} 81 | 82 | RUN groupmod -o -g ${PGID} www-data && \ 83 | usermod -o -u ${PUID} -g www-data www-data 84 | 85 | # Configure locale. 86 | ARG LOCALE=POSIX 87 | ENV LC_ALL ${LOCALE} 88 | 89 | WORKDIR /var/www 90 | 91 | CMD ["php-fpm"] 92 | 93 | EXPOSE 9000 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /src/Core/Ports/Rest/Task/CreateTaskAction.php: -------------------------------------------------------------------------------- 1 | messageBus = $commandBus; 28 | $this->router = $router; 29 | } 30 | 31 | /** 32 | * @Route("/api/tasks", methods={"POST"}) 33 | * 34 | * @param Request $request 35 | * 36 | * @return Response 37 | * 38 | * @OA\Parameter( 39 | * name="body", 40 | * in="body", 41 | * description="JSON Payload", 42 | * required=true, 43 | * content="application/json", 44 | * @OA\Schema( 45 | * type="object", 46 | * @OA\Property(property="title", type="string"), 47 | * @OA\Property(property="execution_day", type="string"), 48 | * @OA\Property(property="description", type="string"), 49 | * ) 50 | * ) 51 | * 52 | * @OA\Response(response=Response::HTTP_CREATED, description=HttpSpec::STR_HTTP_CREATED) 53 | * @OA\Response(response=Response::HTTP_BAD_REQUEST, description=HttpSpec::STR_HTTP_BAD_REQUEST) 54 | * @OA\Response(response=Response::HTTP_UNAUTHORIZED, description=HttpSpec::STR_HTTP_UNAUTHORIZED) 55 | * 56 | * @OA\Tag(name="Task") 57 | */ 58 | public function __invoke(Request $request): Response 59 | { 60 | $paramFetcher = ParamFetcher::fromRequestBody($request); 61 | 62 | $command = new CreateTaskCommand( 63 | $paramFetcher->getRequiredString('title'), 64 | $paramFetcher->getRequiredDate('execution_day'), 65 | $paramFetcher->getNullableString('description') ?? '', 66 | ); 67 | 68 | $id = $this->handle($command); 69 | $resourceUrl = $this->router->generate('api_get_task', ['id' => $id]); 70 | 71 | return new JsonResponse(null, Response::HTTP_CREATED, [HttpSpec::HEADER_LOCATION => $resourceUrl]); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | ### About 2 | This is an example of implementation and my vision of **practical** CQRS, DDD, ADR, hexagonal architecture and directory structure. 3 | Project has entities `Task` and `User`. 4 | All UI is the REST endpoints. 5 | 6 | ### What is done 7 | * Hexagonal Architecture (`Ports` directory for external endpoints) 8 | * CQRS (based on symfony messenger component command/query buses with middlewares) 9 | * DDD: directory structure (used sensiolabs-de/deptrac to control layers dependencies) 10 | * DDD: core bounded context 11 | * DDD: domain events implementation 12 | * DDD: example of specification in User entity that requires a db query 13 | 14 | ### To do 15 | * Add another bounded context 16 | * Add anti-corruption layer for interaction between contexts 17 | 18 | ### My assumptions 19 | * I placed entities public getters and private setters into the traits with *GS suffix to make entities a little bit clear (phpstorm tracks fine all references to entity classes) anyway you can put getters with setters in the same class 20 | * Unfortunately mysql has a poor performance with primary uuids. Of course prefer application generated uuid if database supports them. 21 | 22 | ### How to install the project 23 | * `bash setup_env.sh dev` - to setup .env.local docker/.env 24 | * `make dc_up` - docker-compose up 25 | * `make setup_dev` - composer install, migrations and so on 26 | * `make run php bin/console app:create-user` - create a user 27 | * `http://127.0.0.1:888/api/doc` `https://127.0.0.1:444/api/doc` - api doc 28 | 29 | ### Some words about docker 30 | In project is used workplace container for code manipulations, CI or building. It was created for preventing of pollution 31 | of working containers (php-fpm) of unused in request, building tools like nodejs, composer, dev libs and so on. 32 | Also was created a local user based on host machine user PUID PGID to resolve conflicts with file permissions. 33 | 34 | `make dev` - jump into workplace container 35 | 36 | ### CI 37 | ``` 38 | make dev 39 | //in container execute 40 | make analyze 41 | ``` 42 | 43 | ### Implementation 44 | Used symfony messenger component to create transactional command bus, query bus and event bus. 45 | Query model represented by DTOs. Domain and Command layers are covered by unit tests. 46 | 47 | ``` 48 | ├── Core (Core bounded context) 49 | │   ├── Application 50 | │   │   ├── Command 51 | │   │   │   ├── AuthToken 52 | │   │   │   ├── Task 53 | │   │   │   └── User 54 | │   │   ├── Query 55 | │   │      └── Task 56 | │   ├── Domain 57 | │   │   └── Model 58 | │   │   ├── Task 59 | │   │   └── User 60 | │   ├── Infrastructure 61 | │   │   └── Repository 62 | │   └── Ports 63 | │   ├── Cli 64 | │   └── Rest 65 | └── Shared 66 | ├── Domain 67 | └── Infrastructure 68 | 69 | ``` 70 | 71 | 72 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "license": "proprietary", 4 | "require": { 5 | "php": "7.4.*", 6 | "ext-ctype": "*", 7 | "ext-iconv": "*", 8 | "ext-json": "*", 9 | "ext-pdo": "*", 10 | "lexik/jwt-authentication-bundle": "^2.10", 11 | "nelmio/api-doc-bundle": "^4.1", 12 | "sensiolabs/security-checker": "^6.0", 13 | "symfony/asset": "5.0.*", 14 | "symfony/console": "5.0.*", 15 | "symfony/dotenv": "5.0.*", 16 | "symfony/flex": "^1.3.1", 17 | "symfony/framework-bundle": "5.0.*", 18 | "symfony/messenger": "5.0.*", 19 | "symfony/orm-pack": "^1.0", 20 | "symfony/security-bundle": "5.0.*", 21 | "symfony/serializer-pack": "^1.0", 22 | "symfony/twig-pack": "^1.0", 23 | "symfony/yaml": "5.0.*", 24 | "webmozart/assert": "^1.8" 25 | }, 26 | "require-dev": { 27 | "phpstan/phpstan": "^0.12.25", 28 | "phpstan/phpstan-doctrine": "^0.12.13", 29 | "phpstan/phpstan-phpunit": "^0.12.8", 30 | "phpstan/phpstan-strict-rules": "^0.12.2", 31 | "phpstan/phpstan-symfony": "^0.12.6", 32 | "phpstan/phpstan-webmozart-assert": "^0.12.4", 33 | "phpunit/phpunit": "^9.1", 34 | "sebastian/phpcpd": "^5.0", 35 | "sensiolabs-de/deptrac-shim": "^0.7.1", 36 | "symfony/debug-pack": "^1.0", 37 | "symfony/maker-bundle": "^1.18", 38 | "symfony/test-pack": "^1.0" 39 | }, 40 | "config": { 41 | "preferred-install": { 42 | "*": "dist" 43 | }, 44 | "sort-packages": true 45 | }, 46 | "autoload": { 47 | "psr-4": { 48 | "App\\": "src/" 49 | } 50 | }, 51 | "autoload-dev": { 52 | "psr-4": { 53 | "App\\Tests\\": "tests/" 54 | } 55 | }, 56 | "replace": { 57 | "paragonie/random_compat": "2.*", 58 | "symfony/polyfill-ctype": "*", 59 | "symfony/polyfill-iconv": "*", 60 | "symfony/polyfill-php72": "*", 61 | "symfony/polyfill-php71": "*", 62 | "symfony/polyfill-php70": "*", 63 | "symfony/polyfill-php56": "*" 64 | }, 65 | "scripts": { 66 | "auto-scripts": { 67 | "cache:clear": "symfony-cmd", 68 | "assets:install %PUBLIC_DIR%": "symfony-cmd", 69 | "security-checker security:check": "script" 70 | }, 71 | "post-install-cmd": [ 72 | "@auto-scripts" 73 | ], 74 | "post-update-cmd": [ 75 | "@auto-scripts" 76 | ] 77 | }, 78 | "conflict": { 79 | "symfony/symfony": "*" 80 | }, 81 | "extra": { 82 | "symfony": { 83 | "allow-contrib": false, 84 | "require": "5.0.*" 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /docker/php-fpm/xdebug: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # NOTE: At the moment, this has only been confirmed to work with PHP 7 4 | 5 | 6 | # Grab full name of php-fpm container 7 | PHP_FPM_CONTAINER=$(docker ps | grep php-fpm | awk '{print $1}') 8 | 9 | 10 | # Grab OS type 11 | if [[ "$(uname)" == "Darwin" ]]; then 12 | OS_TYPE="OSX" 13 | else 14 | OS_TYPE=$(expr substr $(uname -s) 1 5) 15 | fi 16 | 17 | 18 | xdebug_status () 19 | { 20 | echo 'xDebug status' 21 | 22 | # If running on Windows, need to prepend with winpty :( 23 | if [[ $OS_TYPE == "MINGW" ]]; then 24 | winpty docker exec -it $PHP_FPM_CONTAINER bash -c 'php -v' 25 | 26 | else 27 | docker exec -it $PHP_FPM_CONTAINER bash -c 'php -v' 28 | fi 29 | 30 | } 31 | 32 | 33 | xdebug_start () 34 | { 35 | echo 'Start xDebug' 36 | 37 | # And uncomment line with xdebug extension, thus enabling it 38 | ON_CMD="sed -i 's/^;zend_extension=/zend_extension=/g' \ 39 | /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini" 40 | 41 | 42 | # If running on Windows, need to prepend with winpty :( 43 | if [[ $OS_TYPE == "MINGW" ]]; then 44 | winpty docker exec -it $PHP_FPM_CONTAINER bash -c "${ON_CMD}" 45 | docker restart $PHP_FPM_CONTAINER 46 | winpty docker exec -it $PHP_FPM_CONTAINER bash -c 'php -v' 47 | 48 | else 49 | docker exec -it $PHP_FPM_CONTAINER bash -c "${ON_CMD}" 50 | docker restart $PHP_FPM_CONTAINER 51 | docker exec -it $PHP_FPM_CONTAINER bash -c 'php -v' 52 | fi 53 | } 54 | 55 | 56 | xdebug_stop () 57 | { 58 | echo 'Stop xDebug' 59 | 60 | # Comment out xdebug extension line 61 | OFF_CMD="sed -i 's/^zend_extension=/;zend_extension=/g' /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini" 62 | 63 | 64 | # If running on Windows, need to prepend with winpty :( 65 | if [[ $OS_TYPE == "MINGW" ]]; then 66 | # This is the equivalent of: 67 | # winpty docker exec -it laradock_php-fpm_1 bash -c 'bla bla bla' 68 | # Thanks to @michaelarnauts at https://github.com/docker/compose/issues/593 69 | winpty docker exec -it $PHP_FPM_CONTAINER bash -c "${OFF_CMD}" 70 | docker restart $PHP_FPM_CONTAINER 71 | #docker-compose restart php-fpm 72 | winpty docker exec -it $PHP_FPM_CONTAINER bash -c 'php -v' 73 | 74 | else 75 | docker exec -it $PHP_FPM_CONTAINER bash -c "${OFF_CMD}" 76 | # docker-compose restart php-fpm 77 | docker restart $PHP_FPM_CONTAINER 78 | docker exec -it $PHP_FPM_CONTAINER bash -c 'php -v' 79 | fi 80 | } 81 | 82 | 83 | case $@ in 84 | stop|STOP) 85 | xdebug_stop 86 | ;; 87 | start|START) 88 | xdebug_start 89 | ;; 90 | status|STATUS) 91 | xdebug_status 92 | ;; 93 | *) 94 | echo "xDebug [Stop | Start | Status] in the ${PHP_FPM_CONTAINER} container." 95 | echo "xDebug must have already been installed." 96 | echo "Usage:" 97 | echo " .php-fpm/xdebug stop|start|status" 98 | 99 | esac 100 | 101 | exit 1 -------------------------------------------------------------------------------- /src/Core/Application/Query/Task/GetTasks/GetTasksQueryHandler.php: -------------------------------------------------------------------------------- 1 | em = $em; 24 | $this->userFetcher = $userFetcher; 25 | } 26 | 27 | public function __invoke(GetTasksQuery $query): PaginatedData 28 | { 29 | $userId = $this->userFetcher->fetchRequiredUser()->getId(); 30 | 31 | $qb = $this->buildQuery($query, $userId); 32 | $tasks = $this->em->getConnection()->executeQuery($qb->getSQL(), $qb->getParameters())->fetchAll(\PDO::FETCH_ASSOC); 33 | 34 | $taskDTOs = []; 35 | 36 | foreach ($tasks as $task) { 37 | $taskDTOs[] = TaskDTO::fromQueryArray($task); 38 | } 39 | 40 | $qb = $this->buildQuery($query, $userId) 41 | ->select('COUNT(*)') 42 | ->setMaxResults(null) 43 | ->setFirstResult(0); 44 | 45 | $count = (int) $this->em->getConnection()->executeQuery($qb->getSQL(), $qb->getParameters())->fetchColumn(); 46 | 47 | return new PaginatedData($taskDTOs, $count); 48 | } 49 | 50 | private function buildQuery(GetTasksQuery $query, int $userId): QueryBuilder 51 | { 52 | $taskTable = $this->em->getClassMetadata(Task::class)->getTableName(); 53 | 54 | $qb = $this->em->getConnection()->createQueryBuilder() 55 | ->select('t.*') 56 | ->from($taskTable, 't') 57 | ->innerJoin('t', 'user', 'u', 'u.id = t.user_id') 58 | ->where('u.id = :userId') 59 | ->orderBy('t.created_at') 60 | ->setFirstResult($query->getPagination()->getOffset()) 61 | ->setMaxResults($query->getPagination()->getLimit()) 62 | ->setParameter('userId', $userId); 63 | 64 | if ($query->getExecutionDate() !== null) { 65 | $executionDay = $query->getExecutionDate()->setTime(0, 0); 66 | $qb->andWhere('t.execution_day >= :fromTime') 67 | ->andWhere('t.execution_day < :toTime') 68 | ->setParameter('fromTime', $executionDay->format(DateTimeFormat::MYSQL_FORMAT)) 69 | ->setParameter('toTime', $executionDay->modify('+1 day')->format(DateTimeFormat::MYSQL_FORMAT)); 70 | } 71 | 72 | if ($query->getSearchText() !== null) { 73 | $qb->andWhere('t.title LIKE :searchText OR t.description LIKE :searchText') 74 | ->setParameter('searchText', "%{$query->getSearchText()}%"); 75 | } 76 | 77 | return $qb; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/Unit/Core/Domain/Model/User/UserTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidInputDataException::class); 17 | $this->expectDeprecationMessageMatches('/Password should contain at most/'); 18 | 19 | new User('admin', str_repeat('x', User::MAX_PASSWORD_LENGTH + 1), $this->getUniqueUsernameSpecification()); 20 | } 21 | 22 | public function test_it_return_false_when_users_are_not_equals(): void 23 | { 24 | $userOne = new User('admin', 'some_hash', $this->getUniqueUsernameSpecification()); 25 | $userTwo = new User('admin', 'some_hash', $this->getUniqueUsernameSpecification()); 26 | $userThree = new User('admin', 'some_hash', $this->getUniqueUsernameSpecification()); 27 | 28 | $this->setUserId($userOne, 1); 29 | $this->setUserId($userTwo, 2); 30 | $this->setUserId($userThree, 1); 31 | 32 | self::assertFalse($userOne->equals($userTwo)); 33 | self::assertTrue($userOne->equals($userThree)); 34 | } 35 | 36 | public function test_it_throws_exception_when_username_to_long(): void 37 | { 38 | $this->expectException(InvalidInputDataException::class); 39 | $this->expectDeprecationMessageMatches('/Username should contain at most/'); 40 | 41 | new User(str_repeat('x', User::MAX_USER_NAME_LENGTH + 1), 'some_hash', $this->getUniqueUsernameSpecification()); 42 | } 43 | 44 | public function test_it_creates_default_role_user(): void 45 | { 46 | $user = new User('admin', 'some_hash', $this->getUniqueUsernameSpecification()); 47 | 48 | self::assertContains(User::DEFAULT_USER_ROLE, $user->getRoles()); 49 | 50 | $user = new User('admin', 'some_hash', $this->getUniqueUsernameSpecification(), ['ROLE_ADMIN']); 51 | 52 | self::assertContains(User::DEFAULT_USER_ROLE, $user->getRoles()); 53 | } 54 | 55 | /** 56 | * @doesNotPerformAssertions 57 | */ 58 | public function test_it_ok_when_valid_values_set(): void 59 | { 60 | new User(str_repeat('x', User::MAX_USER_NAME_LENGTH), str_repeat('x', User::MAX_PASSWORD_LENGTH), $this->getUniqueUsernameSpecification()); 61 | } 62 | 63 | private function setUserId(User $user, int $id): void 64 | { 65 | $reflection = new \ReflectionClass($user); 66 | $property = $reflection->getProperty('id'); 67 | $property->setAccessible(true); 68 | $property->setValue($user, $id); 69 | } 70 | 71 | private function getUniqueUsernameSpecification(): UniqueUsernameSpecificationInterface 72 | { 73 | $specification = $this->createMock(UniqueUsernameSpecificationInterface::class); 74 | $specification->method('isSatisfiedBy')->willReturn(true); 75 | 76 | return $specification; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Core/Application/Query/Task/DTO/TaskDTO.php: -------------------------------------------------------------------------------- 1 | setId($task->getId()); 27 | $dto->setTitle($task->getTitle()); 28 | $dto->setDescription($task->getDescription()); 29 | $dto->setStatus((string) $task->getStatus()); 30 | $dto->setExecutionDay($task->getExecutionDay()); 31 | $dto->setCreatedAt($task->getCreatedAt()); 32 | 33 | return $dto; 34 | } 35 | 36 | /** 37 | * @param array $data 38 | * 39 | * @return TaskDTO 40 | */ 41 | public static function fromQueryArray(array $data): TaskDTO 42 | { 43 | if (!isset($data['id'], $data['title'], $data['description'], $data['status'], $data['execution_day'], $data['created_at'])) { 44 | throw new \InvalidArgumentException(sprintf('Not all keys are set or null %s', var_export($data, true))); 45 | } 46 | 47 | $dto = new static(); 48 | $dto->setId((int) $data['id']); 49 | $dto->setTitle($data['title']); 50 | $dto->setDescription($data['description']); 51 | $dto->setStatus($data['status']); 52 | $dto->setExecutionDay(new \DateTimeImmutable($data['execution_day'])); 53 | $dto->setCreatedAt(new \DateTimeImmutable($data['created_at'])); 54 | 55 | return $dto; 56 | } 57 | 58 | public function getId(): int 59 | { 60 | return $this->id; 61 | } 62 | 63 | public function setId(int $id): void 64 | { 65 | $this->id = $id; 66 | } 67 | 68 | public function getTitle(): string 69 | { 70 | return $this->title; 71 | } 72 | 73 | public function setTitle(string $title): void 74 | { 75 | $this->title = $title; 76 | } 77 | 78 | public function getDescription(): string 79 | { 80 | return $this->description; 81 | } 82 | 83 | public function setDescription(string $description): void 84 | { 85 | $this->description = $description; 86 | } 87 | 88 | public function getStatus(): string 89 | { 90 | return $this->status; 91 | } 92 | 93 | public function setStatus(string $status): void 94 | { 95 | $this->status = $status; 96 | } 97 | 98 | public function getExecutionDay(): \DateTimeImmutable 99 | { 100 | return $this->executionDay; 101 | } 102 | 103 | public function setExecutionDay(\DateTimeImmutable $executionDay): void 104 | { 105 | $this->executionDay = $executionDay; 106 | } 107 | 108 | public function getCreatedAt(): \DateTimeImmutable 109 | { 110 | return $this->createdAt; 111 | } 112 | 113 | public function setCreatedAt(\DateTimeImmutable $createdAt): void 114 | { 115 | $this->createdAt = $createdAt; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/Unit/Core/Application/Command/Task/MakeTaskDone/MakeTaskDoneCommandHandlerTest.php: -------------------------------------------------------------------------------- 1 | expectException(ResourceNotFoundException::class); 24 | 25 | $repository = $this->createMock(TaskRepositoryInterface::class); 26 | $repository->method('find')->willReturn(null); 27 | 28 | $userFetcher = $this->createMock(UserFetcherInterface::class); 29 | $userFetcher->method('fetchRequiredUser')->willReturn($this->getUser()); 30 | 31 | $command = new MakeTaskDoneCommand(1); 32 | $handler = new MakeTaskDoneCommandHandler($repository, $userFetcher); 33 | 34 | $handler($command); 35 | } 36 | 37 | public function test_it_throws_exception_when_task_not_yours(): void 38 | { 39 | $this->expectException(AccessForbiddenException::class); 40 | 41 | $user = $this->createMock(User::class); 42 | $user->method('equals')->willReturn(false); 43 | 44 | $repository = $this->createMock(TaskRepositoryInterface::class); 45 | $repository->method('find')->willReturn(new Task('title', new \DateTimeImmutable(), $user)); 46 | 47 | $userFetcher = $this->createMock(UserFetcherInterface::class); 48 | $userFetcher->method('fetchRequiredUser')->willReturn($user); 49 | 50 | $command = new MakeTaskDoneCommand(1); 51 | $handler = new MakeTaskDoneCommandHandler($repository, $userFetcher); 52 | 53 | $handler($command); 54 | } 55 | 56 | public function test_it_make_task_declined_when_invoked(): void 57 | { 58 | $repository = $this->createMock(TaskRepositoryInterface::class); 59 | $repository->method('find') 60 | ->willReturn(new Task('title', new \DateTimeImmutable(), $this->getUser())); 61 | $repository->expects(self::once()) 62 | ->method('add') 63 | ->with(self::callback(fn(Task $task): bool => $task->getStatus()->is(Status::DONE))); 64 | 65 | $userFetcher = $this->createMock(UserFetcherInterface::class); 66 | $userFetcher->method('fetchRequiredUser')->willReturn($this->getUser()); 67 | 68 | $command = new MakeTaskDoneCommand(1); 69 | $handler = new MakeTaskDoneCommandHandler($repository, $userFetcher); 70 | 71 | $handler($command); 72 | } 73 | 74 | /** 75 | * @return User|MockObject 76 | */ 77 | private function getUser(): MockObject 78 | { 79 | $user = $this->createMock(User::class); 80 | $user->method('equals')->willReturn(true); 81 | 82 | return $user; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Unit/Core/Application/Command/Task/MakeTaskDeclined/MakeTaskDeclinedCommandHandlerTest.php: -------------------------------------------------------------------------------- 1 | expectException(ResourceNotFoundException::class); 24 | 25 | $repository = $this->createMock(TaskRepositoryInterface::class); 26 | $repository->method('find')->willReturn(null); 27 | 28 | $userFetcher = $this->createMock(UserFetcherInterface::class); 29 | $userFetcher->method('fetchRequiredUser')->willReturn($this->getUser()); 30 | 31 | $command = new MakeTaskDeclinedCommand(1); 32 | $handler = new MakeTaskDeclinedCommandHandler($repository, $userFetcher); 33 | 34 | $handler($command); 35 | } 36 | 37 | public function test_it_throws_exception_when_task_not_yours(): void 38 | { 39 | $this->expectException(AccessForbiddenException::class); 40 | 41 | $user = $this->createMock(User::class); 42 | $user->method('equals')->willReturn(false); 43 | 44 | $repository = $this->createMock(TaskRepositoryInterface::class); 45 | $repository->method('find')->willReturn(new Task('title', new \DateTimeImmutable(), $user)); 46 | 47 | $userFetcher = $this->createMock(UserFetcherInterface::class); 48 | $userFetcher->method('fetchRequiredUser')->willReturn($user); 49 | 50 | $command = new MakeTaskDeclinedCommand(1); 51 | $handler = new MakeTaskDeclinedCommandHandler($repository, $userFetcher); 52 | 53 | $handler($command); 54 | } 55 | 56 | public function test_it_make_task_declined_when_invoked(): void 57 | { 58 | $repository = $this->createMock(TaskRepositoryInterface::class); 59 | $repository->method('find') 60 | ->willReturn(new Task('title', new \DateTimeImmutable(), $this->getUser())); 61 | $repository->expects(self::once()) 62 | ->method('add') 63 | ->with(self::callback(fn(Task $task): bool => $task->getStatus()->is(Status::DECLINED))); 64 | 65 | $userFetcher = $this->createMock(UserFetcherInterface::class); 66 | $userFetcher->method('fetchRequiredUser')->willReturn($this->getUser()); 67 | 68 | $command = new MakeTaskDeclinedCommand(1); 69 | $handler = new MakeTaskDeclinedCommandHandler($repository, $userFetcher); 70 | 71 | $handler($command); 72 | } 73 | 74 | /** 75 | * @return User|MockObject 76 | */ 77 | private function getUser(): MockObject 78 | { 79 | $user = $this->createMock(User::class); 80 | $user->method('equals')->willReturn(true); 81 | 82 | return $user; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Core/Ports/Rest/Task/GetTasksAction.php: -------------------------------------------------------------------------------- 1 | messageBus = $queryBus; 32 | $this->normalizer = $normalizer; 33 | } 34 | 35 | /** 36 | * @Route("/api/tasks", methods={"GET"}) 37 | * 38 | * @param Request $request 39 | * 40 | * @return Response 41 | * 42 | * @OA\Parameter( 43 | * name="execution_day", 44 | * in="query", 45 | * description="Search phrase text", 46 | * @OA\Schema(type="string") 47 | * ) 48 | * @OA\Parameter( 49 | * name="search", 50 | * in="query", 51 | * description="Search phrase text", 52 | * @OA\Schema(type="string") 53 | * ) 54 | * @OA\Parameter( 55 | * name="limit", 56 | * in="query", 57 | * description="Number of result items", 58 | * @OA\Schema(type="integer", default=Pagination::DEFAULT_LIMIT) 59 | * ) 60 | * @OA\Parameter( 61 | * name="offset", 62 | * in="query", 63 | * description="First result offset", 64 | * @OA\Schema(type="integer", default=Pagination::DEFAULT_OFFSET) 65 | * ) 66 | * @OA\Response( 67 | * response=Response::HTTP_OK, 68 | * description=HttpSpec::STR_HTTP_OK, 69 | * @OA\Schema(type="array", @OA\Items(ref=@Model(type=TaskDTO::class, groups={"task_view"}))) 70 | * ) 71 | * @OA\Response(response=Response::HTTP_BAD_REQUEST, description=HttpSpec::STR_HTTP_BAD_REQUEST) 72 | * @OA\Response(response=Response::HTTP_UNAUTHORIZED, description=HttpSpec::STR_HTTP_UNAUTHORIZED) 73 | * 74 | * @OA\Tag(name="Task") 75 | */ 76 | public function __invoke(Request $request): Response 77 | { 78 | $query = ParamFetcher::fromRequestQuery($request); 79 | 80 | $query = new GetTasksQuery( 81 | Pagination::fromRequest($request), 82 | $query->getNullableDate('execution_day'), 83 | $query->getNullableString('search') 84 | ); 85 | 86 | /** @var PaginatedData $paginatedData */ 87 | $paginatedData = $this->handle($query); 88 | 89 | return new JsonResponse( 90 | $this->normalizer->normalize($paginatedData->getData(), '', ['groups' => 'task_view']), 91 | Response::HTTP_OK, 92 | [HttpSpec::HEADER_X_ITEMS_COUNT => $paginatedData->getCount()] 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Core/Domain/Model/Task/Task.php: -------------------------------------------------------------------------------- 1 | setTitle($title); 65 | $this->setExecutionDay($executionDay); 66 | $this->setUser($user); 67 | $this->setDescription($description); 68 | $this->setStatus(Status::NEW()); 69 | $this->setCreatedAt(new \DateTimeImmutable()); 70 | 71 | $this->raise(new TaskCreatedEvent($this)); 72 | } 73 | 74 | // API 75 | 76 | public function changeTitle(string $title): void 77 | { 78 | $this->setTitle($title); 79 | } 80 | 81 | public function changeDescription(string $description): void 82 | { 83 | $this->setDescription($description); 84 | } 85 | 86 | public function changeExecutionDay(\DateTimeImmutable $assignedDay): void 87 | { 88 | $this->setExecutionDay($assignedDay); 89 | } 90 | 91 | public function done(): void 92 | { 93 | if ($this->status->is(Status::DONE)) { 94 | return; 95 | } 96 | 97 | if ($this->status->is(Status::DECLINED)) { 98 | throw new BusinessLogicViolationException('Declined task can\'t be done'); 99 | } 100 | 101 | $this->setStatus(Status::DONE()); 102 | $this->raise(new TaskDoneEvent($this)); 103 | } 104 | 105 | public function decline(): void 106 | { 107 | if ($this->status->is(Status::DECLINED)) { 108 | return; 109 | } 110 | 111 | if ($this->status->is(Status::DONE)) { 112 | throw new BusinessLogicViolationException('Done task can\'t be declined'); 113 | } 114 | 115 | $this->setStatus(Status::DECLINED()); 116 | $this->raise(new TaskDeclinedEvent($this)); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/Unit/Core/Application/Command/Task/DeleteTask/DeleteTaskCommandHandlerTest.php: -------------------------------------------------------------------------------- 1 | expectException(ResourceNotFoundException::class); 23 | 24 | $repository = $this->createMock(TaskRepositoryInterface::class); 25 | $repository->method('find')->willReturn(null); 26 | 27 | $userFetcher = $this->createMock(UserFetcherInterface::class); 28 | $userFetcher->method('fetchRequiredUser')->willReturn($this->getUser()); 29 | 30 | $command = new DeleteTaskCommand(1); 31 | $handler = new DeleteTaskCommandHandler($repository, $userFetcher); 32 | 33 | $handler($command); 34 | } 35 | 36 | public function test_it_throws_exception_when_task_not_yours(): void 37 | { 38 | $this->expectException(AccessForbiddenException::class); 39 | 40 | $user = $this->createMock(User::class); 41 | $user->method('equals')->willReturn(false); 42 | 43 | $repository = $this->createMock(TaskRepositoryInterface::class); 44 | $repository->method('find')->willReturn(new Task('title', new \DateTimeImmutable(), $user)); 45 | 46 | $userFetcher = $this->createMock(UserFetcherInterface::class); 47 | $userFetcher->method('fetchRequiredUser')->willReturn($user); 48 | 49 | $command = new DeleteTaskCommand(1); 50 | $handler = new DeleteTaskCommandHandler($repository, $userFetcher); 51 | 52 | $handler($command); 53 | } 54 | 55 | public function test_it_deletes_when_invoked(): void 56 | { 57 | $title = 'Some title'; 58 | $executionDay = (new \DateTimeImmutable())->setTime(0, 0); 59 | $description = 'Some description'; 60 | 61 | $repository = $this->createMock(TaskRepositoryInterface::class); 62 | $repository->method('find')->willReturn(new Task($title, $executionDay, $this->getUser(), $description)); 63 | $repository->expects(self::once()) 64 | ->method('remove') 65 | ->with(self::callback( 66 | fn(Task $task): bool => $task->getTitle() === $title 67 | && $task->getDescription() === $description 68 | && $task->getExecutionDay() == $executionDay 69 | )); 70 | 71 | $userFetcher = $this->createMock(UserFetcherInterface::class); 72 | $userFetcher->method('fetchRequiredUser')->willReturn($this->getUser()); 73 | 74 | $command = new DeleteTaskCommand(1); 75 | $handler = new DeleteTaskCommandHandler($repository, $userFetcher); 76 | $handler($command); 77 | } 78 | 79 | /** 80 | * @return User|MockObject 81 | */ 82 | private function getUser(): MockObject 83 | { 84 | $user = $this->createMock(User::class); 85 | $user->method('equals')->willReturn(true); 86 | 87 | return $user; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/Unit/Core/Application/Command/Task/UpdateTask/UpdateTaskCommandHandlerTest.php: -------------------------------------------------------------------------------- 1 | expectException(ResourceNotFoundException::class); 23 | 24 | $repository = $this->createMock(TaskRepositoryInterface::class); 25 | $repository->method('find')->willReturn(null); 26 | 27 | $userFetcher = $this->createMock(UserFetcherInterface::class); 28 | $userFetcher->method('fetchRequiredUser')->willReturn($this->getUser()); 29 | 30 | $command = new UpdateTaskCommand(1, 'title', new \DateTimeImmutable()); 31 | $handler = new UpdateTaskCommandHandler($repository, $userFetcher); 32 | 33 | $handler($command); 34 | } 35 | 36 | public function test_it_throws_exception_when_task_not_yours(): void 37 | { 38 | $this->expectException(AccessForbiddenException::class); 39 | 40 | $user = $this->createMock(User::class); 41 | $user->method('equals')->willReturn(false); 42 | 43 | $repository = $this->createMock(TaskRepositoryInterface::class); 44 | $repository->method('find')->willReturn(new Task('title', new \DateTimeImmutable(), $user)); 45 | 46 | $userFetcher = $this->createMock(UserFetcherInterface::class); 47 | $userFetcher->method('fetchRequiredUser')->willReturn($user); 48 | 49 | $command = new UpdateTaskCommand(1, 'title', new \DateTimeImmutable()); 50 | $handler = new UpdateTaskCommandHandler($repository, $userFetcher); 51 | 52 | $handler($command); 53 | } 54 | 55 | public function test_it_update_task_when_invoked(): void 56 | { 57 | $newTitle = 'new title'; 58 | $newDescription = 'new description'; 59 | $newExecutionDay = (new \DateTimeImmutable())->setTime(0, 0)->modify('+2 days'); 60 | 61 | $repository = $this->createMock(TaskRepositoryInterface::class); 62 | 63 | $repository->method('find') 64 | ->willReturn(new Task('title', new \DateTimeImmutable(), $this->getUser())); 65 | 66 | $repository->expects(self::once()) 67 | ->method('add') 68 | ->with(self::callback(fn(Task $task): bool => 69 | $task->getTitle() === $newTitle 70 | && $task->getDescription() === $newDescription 71 | && $task->getExecutionDay() == $newExecutionDay 72 | )); 73 | 74 | $userFetcher = $this->createMock(UserFetcherInterface::class); 75 | $userFetcher->method('fetchRequiredUser')->willReturn($this->getUser()); 76 | 77 | $command = new UpdateTaskCommand(1, $newTitle, $newExecutionDay, $newDescription); 78 | $handler = new UpdateTaskCommandHandler($repository, $userFetcher); 79 | 80 | $handler($command); 81 | } 82 | 83 | /** 84 | * @return User|MockObject 85 | */ 86 | private function getUser(): MockObject 87 | { 88 | $user = $this->createMock(User::class); 89 | $user->method('equals')->willReturn(true); 90 | 91 | return $user; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Http/ParamFetcher.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | private array $data; 24 | 25 | private bool $testScalarType; 26 | 27 | /** 28 | * @param array $data 29 | * @param bool $testScalarType 30 | */ 31 | public function __construct(array $data, bool $testScalarType = true) 32 | { 33 | $this->data = $data; 34 | $this->testScalarType = $testScalarType; 35 | } 36 | 37 | public static function fromRequestAttributes(Request $request): self 38 | { 39 | return new self($request->attributes->all(), false); 40 | } 41 | 42 | public static function fromRequestBody(Request $request): self 43 | { 44 | return new self($request->request->all()); 45 | } 46 | 47 | public static function fromRequestQuery(Request $request): self 48 | { 49 | return new self($request->query->all(), false); 50 | } 51 | 52 | public function getRequiredString(string $key): string 53 | { 54 | $this->assertRequired($key); 55 | $this->assertType($key, self::TYPE_STRING); 56 | 57 | return (string) $this->data[$key]; 58 | } 59 | 60 | public function getNullableString(string $key): ?string 61 | { 62 | if (!isset($this->data[$key])) { 63 | return null; 64 | } 65 | $this->assertType($key, self::TYPE_STRING); 66 | 67 | return (string) $this->data[$key]; 68 | } 69 | 70 | public function getRequiredInt(string $key): int 71 | { 72 | $this->assertRequired($key); 73 | $this->assertType($key, self::TYPE_INT); 74 | 75 | return (int) $this->data[$key]; 76 | } 77 | 78 | public function getNullableInt(string $key): ?int 79 | { 80 | if (!isset($this->data[$key])) { 81 | return null; 82 | } 83 | $this->assertType($key, self::TYPE_INT); 84 | 85 | return (int) $this->data[$key]; 86 | } 87 | 88 | public function getRequiredDate(string $key): \DateTimeImmutable 89 | { 90 | $this->assertRequired($key); 91 | $this->assertType($key, self::TYPE_DATE); 92 | 93 | return new \DateTimeImmutable($this->data[$key]); 94 | } 95 | 96 | public function getNullableDate(string $key): ?\DateTimeImmutable 97 | { 98 | if (!isset($this->data[$key])) { 99 | return null; 100 | } 101 | $this->assertType($key, self::TYPE_DATE); 102 | 103 | return new \DateTimeImmutable($this->data[$key]); 104 | } 105 | 106 | // ..... 107 | // TODO: Add additional required methods for every scalar type 108 | // ..... 109 | 110 | private function assertRequired(string $key): void 111 | { 112 | Assert::keyExists($this->data, $key, sprintf('"%s" not found', $key)); 113 | Assert::notNull($this->data[$key], sprintf('"%s" should be not null', $key)); 114 | } 115 | 116 | private function assertType(string $key, string $type): void 117 | { 118 | if (!$this->testScalarType && \in_array($type, self::SCALAR_TYPES, true)) { 119 | return; 120 | } 121 | 122 | switch ($type) { 123 | case self::TYPE_STRING: 124 | Assert::string($this->data[$key], sprintf('"%s" should be a string. Got %%s', $key)); 125 | break; 126 | 127 | case self::TYPE_INT: 128 | Assert::string($this->data[$key], sprintf('"%s" should be an integer. Got %%s', $key)); 129 | break; 130 | 131 | case self::TYPE_DATE: 132 | Assert::dateTimeString($this->data[$key], DateTimeFormat::DATE_FORMAT, sprintf('"%s" should be a valid format "%s" date', $key, DateTimeFormat::DATE_FORMAT)); 133 | break; 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /docker/workplace/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM phusion/baseimage:0.11 2 | 3 | RUN DEBIAN_FRONTEND=noninteractive 4 | RUN locale-gen en_US.UTF-8 5 | 6 | ENV LANGUAGE=en_US.UTF-8 7 | ENV LC_ALL=en_US.UTF-8 8 | ENV LC_CTYPE=en_US.UTF-8 9 | ENV LANG=en_US.UTF-8 10 | ENV TERM xterm 11 | 12 | # Add the "PHP 7" ppa 13 | RUN apt-get install -y software-properties-common && \ 14 | add-apt-repository -y ppa:ondrej/php 15 | 16 | RUN echo 'DPkg::options { "--force-confdef"; };' >> /etc/apt/apt.conf 17 | 18 | # yarn 19 | RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ 20 | echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list 21 | 22 | # Install "PHP Extentions", "libraries", "Software's" 23 | RUN apt-get update && \ 24 | apt-get upgrade -y && \ 25 | apt-get install -y --allow-downgrades --allow-remove-essential \ 26 | --allow-change-held-packages \ 27 | apt-utils \ 28 | libldap2-dev \ 29 | libzip-dev zip unzip \ 30 | php7.4-cli \ 31 | php7.4-common \ 32 | php7.4-curl \ 33 | php7.4-intl \ 34 | php7.4-json \ 35 | php7.4-xml \ 36 | php7.4-mbstring \ 37 | php7.4-mysql \ 38 | php7.4-zip \ 39 | php7.4-bcmath \ 40 | php7.4-memcached \ 41 | php7.4-gd \ 42 | php7.4-dev \ 43 | php7.4-ldap \ 44 | php7.4-zip \ 45 | php7.4-xdebug \ 46 | pkg-config \ 47 | libcurl4-openssl-dev \ 48 | libedit-dev \ 49 | libssl-dev \ 50 | libxml2-dev \ 51 | xz-utils \ 52 | libsqlite3-dev \ 53 | sqlite3 \ 54 | git \ 55 | curl \ 56 | vim \ 57 | nasm \ 58 | nano 59 | 60 | ##################################### 61 | # User 62 | ##################################### 63 | 64 | ARG PUID=1000 65 | ENV PUID ${PUID} 66 | ARG PGID=1000 67 | ENV PGID ${PGID} 68 | 69 | RUN set -xe; \ 70 | groupadd -g ${PGID} dev && \ 71 | useradd -l -u ${PUID} -g dev -m dev -G docker_env && \ 72 | usermod -p "*" dev -s /bin/bash 73 | 74 | RUN pecl channel-update pecl.php.net 75 | 76 | ##################################### 77 | # Composer: 78 | ##################################### 79 | 80 | RUN curl -s http://getcomposer.org/installer | php && \ 81 | echo "export PATH=${PATH}:/var/www/vendor/bin" >> ~/.bashrc && \ 82 | mv composer.phar /usr/local/bin/composer 83 | 84 | RUN . ~/.bashrc 85 | 86 | ##################################### 87 | # xDebug 88 | ##################################### 89 | 90 | RUN sed -i 's/^;//g' /etc/php/7.4/cli/conf.d/20-xdebug.ini 91 | 92 | # ADD for REMOTE debugging 93 | COPY ./xdebug.ini /etc/php/7.4/cli/conf.d/xdebug.ini 94 | 95 | ###################################### 96 | # Node Yarn: 97 | ###################################### 98 | 99 | RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - \ 100 | && apt-get install -y yarn 101 | 102 | ###################################### 103 | # CS Fixer 104 | ###################################### 105 | 106 | RUN curl -L https://cs.symfony.com/download/php-cs-fixer-v2.phar -o php-cs-fixer \ 107 | && chmod a+x php-cs-fixer \ 108 | && mv php-cs-fixer /usr/local/bin/php-cs-fixer 109 | 110 | ###################################### 111 | # Bash updates 112 | ###################################### 113 | 114 | # makefile autocomplete 115 | RUN echo "complete -W \"\`grep -oE '^[a-zA-Z0-9_-]+:([^=]|$)' ?akefile | sed 's/[^a-zA-Z0-9_.-]*$//'\`\" make" >> ~/.bashrc && \ 116 | echo "complete -W \"\`grep -oE '^[a-zA-Z0-9_-]+:([^=]|$)' ?akefile | sed 's/[^a-zA-Z0-9_.-]*$//'\`\" make" >> ~/.bash_profile && \ 117 | echo "complete -W \"\`grep -oE '^[a-zA-Z0-9_-]+:([^=]|$)' ?akefile | sed 's/[^a-zA-Z0-9_.-]*$//'\`\" make" >> /home/dev/.bashrc && \ 118 | echo "complete -W \"\`grep -oE '^[a-zA-Z0-9_-]+:([^=]|$)' ?akefile | sed 's/[^a-zA-Z0-9_.-]*$//'\`\" make" >> /home/dev/bash_profile 119 | 120 | # Clean up 121 | RUN apt-get clean && \ 122 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \ 123 | rm /var/log/lastlog /var/log/faillog 124 | 125 | ###################################### 126 | # PHP MND (conflicts with local repo) 127 | ###################################### 128 | USER dev 129 | 130 | RUN composer global require povils/phpmnd && \ 131 | echo "export PATH=${PATH}:${HOME}/.composer/vendor/bin" >> ~/.bashrc 132 | 133 | USER root 134 | 135 | # Set default work directory 136 | WORKDIR /var/www -------------------------------------------------------------------------------- /tests/Unit/Core/Domain/Model/Task/TaskTest.php: -------------------------------------------------------------------------------- 1 | getShortTitle(), new \DateTimeImmutable(), $this->getUser()); 23 | } 24 | 25 | public function test_it_throws_exception_when_title_changed_and_title_too_short(): void 26 | { 27 | $task = $this->getTask(); 28 | $task->changeTitle($this->getShortTitle()); 29 | } 30 | 31 | public function test_it_throws_exception_when_task_created_and_title_too_long(): void 32 | { 33 | new Task($this->getLongTitle(), new \DateTimeImmutable(), $this->getUser()); 34 | } 35 | 36 | public function test_it_throws_exception_when_title_changed_and_title_too_long(): void 37 | { 38 | $task = $this->getTask(); 39 | $task->changeTitle($this->getLongTitle()); 40 | } 41 | 42 | public function test_it_throws_exception_when_title_changed_and_description_too_long(): void 43 | { 44 | new Task(str_repeat('x', Task::MAX_TITLE_LENGTH), new \DateTimeImmutable(), $this->getUser(), $this->getLongDescription()); 45 | } 46 | 47 | public function test_it_throws_exception_when_description_changed_and_description_too_long(): void 48 | { 49 | $task = $this->getTask(); 50 | $task->changeDescription($this->getLongDescription()); 51 | } 52 | 53 | public function test_it_throws_exception_when_created_and_execution_time_in_past(): void 54 | { 55 | $this->expectException(InvalidInputDataException::class); 56 | $this->expectExceptionMessage('Execution day should be not in past'); 57 | 58 | new Task('title', (new \DateTimeImmutable())->modify('-1 day'), $this->getUser()); 59 | } 60 | 61 | public function test_it_throws_exception_when_changed_and_execution_time_in_past(): void 62 | { 63 | $this->expectException(InvalidInputDataException::class); 64 | $this->expectExceptionMessage('Execution day should be not in past'); 65 | 66 | $task = $this->getTask(); 67 | $task->changeExecutionDay((new \DateTimeImmutable())->modify('-1 day')); 68 | } 69 | 70 | /** 71 | * @doesNotPerformAssertions 72 | */ 73 | public function test_it_ok_when_valid_values_set(): void 74 | { 75 | $task = new Task(str_repeat('x', Task::MAX_TITLE_LENGTH), new \DateTimeImmutable(), $this->getUser(), str_repeat('x', Task::MAX_DESCRIPTION_LENGTH)); 76 | 77 | $task->changeTitle(str_repeat('y', Task::MAX_TITLE_LENGTH)); 78 | $task->changeDescription(str_repeat('y', Task::MAX_DESCRIPTION_LENGTH)); 79 | } 80 | 81 | public function test_it_creates_new_status_when_task_is_created(): void 82 | { 83 | $task = $this->getTask(); 84 | self::assertTrue($task->getStatus()->is(Status::NEW)); 85 | } 86 | 87 | public function test_it_throws_exception_when_done_declined_task(): void 88 | { 89 | $this->expectException(BusinessLogicViolationException::class); 90 | $this->expectExceptionMessage('Declined task can\'t be done'); 91 | 92 | $task = $this->getTask(); 93 | $task->decline(); 94 | self::assertTrue($task->getStatus()->is(Status::DECLINED)); 95 | $task->done(); 96 | } 97 | 98 | public function test_it_throws_exception_when_decline_done_task(): void 99 | { 100 | $this->expectException(BusinessLogicViolationException::class); 101 | $this->expectExceptionMessage('Done task can\'t be declined'); 102 | 103 | $task = $this->getTask(); 104 | $task->done(); 105 | self::assertTrue($task->getStatus()->is(Status::DONE)); 106 | $task->decline(); 107 | } 108 | 109 | public function test_it_raises_event_when_task_created(): void 110 | { 111 | $task = $this->getTask(); 112 | $events = $task->popEvents(); 113 | 114 | self::assertContainsEquals(new TaskCreatedEvent($task), $events); 115 | } 116 | 117 | public function test_it_raises_event_when_task_becomes_done(): void 118 | { 119 | $task = $this->getTask(); 120 | $task->done(); 121 | $events = $task->popEvents(); 122 | 123 | self::assertContainsEquals(new TaskDoneEvent($task), $events); 124 | } 125 | 126 | public function test_it_raises_event_when_task_becomes_declined(): void 127 | { 128 | $task = $this->getTask(); 129 | $task->decline(); 130 | $events = $task->popEvents(); 131 | 132 | self::assertContainsEquals(new TaskDeclinedEvent($task), $events); 133 | } 134 | 135 | private function getTask(): Task 136 | { 137 | return new Task(str_repeat('x', Task::MAX_TITLE_LENGTH), new \DateTimeImmutable(), $this->getUser()); 138 | } 139 | 140 | private function getShortTitle(): string 141 | { 142 | $this->expectException(InvalidInputDataException::class); 143 | $this->expectDeprecationMessageMatches('/Title should contain at least/'); 144 | 145 | return str_repeat('x', Task::MIN_TITLE_LENGTH - 1); 146 | } 147 | 148 | private function getLongTitle(): string 149 | { 150 | $this->expectException(InvalidInputDataException::class); 151 | $this->expectDeprecationMessageMatches('/Title should contain at most/'); 152 | 153 | return str_repeat('x', Task::MAX_TITLE_LENGTH +1); 154 | } 155 | 156 | private function getLongDescription(): string 157 | { 158 | $this->expectException(InvalidInputDataException::class); 159 | $this->expectDeprecationMessageMatches('/Description should contain at most/'); 160 | 161 | return str_repeat('x', Task::MAX_DESCRIPTION_LENGTH + 1); 162 | } 163 | 164 | private function getUser(): User 165 | { 166 | $specification = $this->createMock(UniqueUsernameSpecificationInterface::class); 167 | $specification->method('isSatisfiedBy')->willReturn(true); 168 | 169 | return new User('Test', 'pass_hash', $specification); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "doctrine/annotations": { 3 | "version": "1.0", 4 | "recipe": { 5 | "repo": "github.com/symfony/recipes", 6 | "branch": "master", 7 | "version": "1.0", 8 | "ref": "a2759dd6123694c8d901d0ec80006e044c2e6457" 9 | }, 10 | "files": [ 11 | "config/routes/annotations.yaml" 12 | ] 13 | }, 14 | "doctrine/cache": { 15 | "version": "1.10.0" 16 | }, 17 | "doctrine/collections": { 18 | "version": "1.6.5" 19 | }, 20 | "doctrine/common": { 21 | "version": "2.13.1" 22 | }, 23 | "doctrine/dbal": { 24 | "version": "2.10.2" 25 | }, 26 | "doctrine/doctrine-bundle": { 27 | "version": "2.0", 28 | "recipe": { 29 | "repo": "github.com/symfony/recipes", 30 | "branch": "master", 31 | "version": "2.0", 32 | "ref": "a9f2463b9f73efe74482f831f03a204a41328555" 33 | }, 34 | "files": [ 35 | "config/packages/doctrine.yaml", 36 | "config/packages/prod/doctrine.yaml", 37 | "src/Entity/.gitignore", 38 | "src/Repository/.gitignore" 39 | ] 40 | }, 41 | "doctrine/doctrine-migrations-bundle": { 42 | "version": "1.2", 43 | "recipe": { 44 | "repo": "github.com/symfony/recipes", 45 | "branch": "master", 46 | "version": "1.2", 47 | "ref": "c1431086fec31f17fbcfe6d6d7e92059458facc1" 48 | }, 49 | "files": [ 50 | "config/packages/doctrine_migrations.yaml", 51 | "src/Migrations/.gitignore" 52 | ] 53 | }, 54 | "doctrine/event-manager": { 55 | "version": "1.1.0" 56 | }, 57 | "doctrine/inflector": { 58 | "version": "1.4.2" 59 | }, 60 | "doctrine/instantiator": { 61 | "version": "1.3.0" 62 | }, 63 | "doctrine/lexer": { 64 | "version": "1.2.1" 65 | }, 66 | "doctrine/migrations": { 67 | "version": "2.2.1" 68 | }, 69 | "doctrine/orm": { 70 | "version": "v2.7.2" 71 | }, 72 | "doctrine/persistence": { 73 | "version": "1.3.7" 74 | }, 75 | "doctrine/reflection": { 76 | "version": "1.2.1" 77 | }, 78 | "doctrine/sql-formatter": { 79 | "version": "1.0.1" 80 | }, 81 | "exsyst/swagger": { 82 | "version": "v0.4.1" 83 | }, 84 | "laminas/laminas-code": { 85 | "version": "3.4.1" 86 | }, 87 | "laminas/laminas-eventmanager": { 88 | "version": "3.2.1" 89 | }, 90 | "laminas/laminas-zendframework-bridge": { 91 | "version": "1.0.4" 92 | }, 93 | "lcobucci/clock": { 94 | "version": "2.0.0" 95 | }, 96 | "lcobucci/jwt": { 97 | "version": "4.1.0" 98 | }, 99 | "lexik/jwt-authentication-bundle": { 100 | "version": "2.5", 101 | "recipe": { 102 | "repo": "github.com/symfony/recipes", 103 | "branch": "master", 104 | "version": "2.5", 105 | "ref": "5b2157bcd5778166a5696e42f552ad36529a07a6" 106 | }, 107 | "files": [ 108 | "config/packages/lexik_jwt_authentication.yaml" 109 | ] 110 | }, 111 | "monolog/monolog": { 112 | "version": "2.1.0" 113 | }, 114 | "myclabs/deep-copy": { 115 | "version": "1.9.5" 116 | }, 117 | "namshi/jose": { 118 | "version": "7.2.3" 119 | }, 120 | "nelmio/api-doc-bundle": { 121 | "version": "3.0", 122 | "recipe": { 123 | "repo": "github.com/symfony/recipes-contrib", 124 | "branch": "master", 125 | "version": "3.0", 126 | "ref": "c8e0c38e1a280ab9e37587a8fa32b251d5bc1c94" 127 | }, 128 | "files": [ 129 | "config/packages/nelmio_api_doc.yaml", 130 | "config/routes/nelmio_api_doc.yaml" 131 | ] 132 | }, 133 | "nikic/php-parser": { 134 | "version": "v4.4.0" 135 | }, 136 | "ocramius/package-versions": { 137 | "version": "1.8.0" 138 | }, 139 | "ocramius/proxy-manager": { 140 | "version": "2.8.0" 141 | }, 142 | "phar-io/manifest": { 143 | "version": "1.0.3" 144 | }, 145 | "phar-io/version": { 146 | "version": "2.0.1" 147 | }, 148 | "php": { 149 | "version": "7.4" 150 | }, 151 | "phpdocumentor/reflection-common": { 152 | "version": "2.1.0" 153 | }, 154 | "phpdocumentor/reflection-docblock": { 155 | "version": "5.1.0" 156 | }, 157 | "phpdocumentor/type-resolver": { 158 | "version": "1.1.0" 159 | }, 160 | "phpspec/prophecy": { 161 | "version": "v1.10.3" 162 | }, 163 | "phpstan/phpstan": { 164 | "version": "0.12.25" 165 | }, 166 | "phpstan/phpstan-doctrine": { 167 | "version": "0.12.13" 168 | }, 169 | "phpstan/phpstan-phpunit": { 170 | "version": "0.12.8" 171 | }, 172 | "phpstan/phpstan-strict-rules": { 173 | "version": "0.12.2" 174 | }, 175 | "phpstan/phpstan-symfony": { 176 | "version": "0.12.6" 177 | }, 178 | "phpstan/phpstan-webmozart-assert": { 179 | "version": "0.12.4" 180 | }, 181 | "phpunit/php-code-coverage": { 182 | "version": "8.0.2" 183 | }, 184 | "phpunit/php-file-iterator": { 185 | "version": "3.0.1" 186 | }, 187 | "phpunit/php-invoker": { 188 | "version": "3.0.0" 189 | }, 190 | "phpunit/php-text-template": { 191 | "version": "2.0.0" 192 | }, 193 | "phpunit/php-timer": { 194 | "version": "3.1.4" 195 | }, 196 | "phpunit/php-token-stream": { 197 | "version": "4.0.1" 198 | }, 199 | "phpunit/phpunit": { 200 | "version": "4.7", 201 | "recipe": { 202 | "repo": "github.com/symfony/recipes", 203 | "branch": "master", 204 | "version": "4.7", 205 | "ref": "00fdb38c318774cd39f475a753028a5e8d25d47c" 206 | }, 207 | "files": [ 208 | ".env.test", 209 | "phpunit.xml.dist", 210 | "tests/bootstrap.php" 211 | ] 212 | }, 213 | "psr/cache": { 214 | "version": "1.0.1" 215 | }, 216 | "psr/container": { 217 | "version": "1.0.0" 218 | }, 219 | "psr/event-dispatcher": { 220 | "version": "1.0.0" 221 | }, 222 | "psr/log": { 223 | "version": "1.1.3" 224 | }, 225 | "sebastian/code-unit": { 226 | "version": "1.0.2" 227 | }, 228 | "sebastian/code-unit-reverse-lookup": { 229 | "version": "2.0.0" 230 | }, 231 | "sebastian/comparator": { 232 | "version": "4.0.0" 233 | }, 234 | "sebastian/diff": { 235 | "version": "4.0.1" 236 | }, 237 | "sebastian/environment": { 238 | "version": "5.1.0" 239 | }, 240 | "sebastian/exporter": { 241 | "version": "4.0.0" 242 | }, 243 | "sebastian/finder-facade": { 244 | "version": "2.0.0" 245 | }, 246 | "sebastian/global-state": { 247 | "version": "4.0.0" 248 | }, 249 | "sebastian/object-enumerator": { 250 | "version": "4.0.0" 251 | }, 252 | "sebastian/object-reflector": { 253 | "version": "2.0.0" 254 | }, 255 | "sebastian/phpcpd": { 256 | "version": "5.0.2" 257 | }, 258 | "sebastian/recursion-context": { 259 | "version": "4.0.0" 260 | }, 261 | "sebastian/resource-operations": { 262 | "version": "3.0.0" 263 | }, 264 | "sebastian/type": { 265 | "version": "2.0.0" 266 | }, 267 | "sebastian/version": { 268 | "version": "3.0.0" 269 | }, 270 | "sensiolabs-de/deptrac-shim": { 271 | "version": "0.7", 272 | "recipe": { 273 | "repo": "github.com/symfony/recipes-contrib", 274 | "branch": "master", 275 | "version": "0.7", 276 | "ref": "1f7e6648916732a736466ccf2caa5b4b4e7f4968" 277 | }, 278 | "files": [ 279 | "depfile.yaml" 280 | ] 281 | }, 282 | "sensiolabs/security-checker": { 283 | "version": "4.0", 284 | "recipe": { 285 | "repo": "github.com/symfony/recipes", 286 | "branch": "master", 287 | "version": "4.0", 288 | "ref": "160c9b600564faa1224e8f387d49ef13ceb8b793" 289 | }, 290 | "files": [ 291 | "config/packages/security_checker.yaml" 292 | ] 293 | }, 294 | "symfony/asset": { 295 | "version": "v5.0.8" 296 | }, 297 | "symfony/browser-kit": { 298 | "version": "v5.0.8" 299 | }, 300 | "symfony/cache": { 301 | "version": "v5.0.8" 302 | }, 303 | "symfony/cache-contracts": { 304 | "version": "v2.0.1" 305 | }, 306 | "symfony/config": { 307 | "version": "v5.0.8" 308 | }, 309 | "symfony/console": { 310 | "version": "4.4", 311 | "recipe": { 312 | "repo": "github.com/symfony/recipes", 313 | "branch": "master", 314 | "version": "4.4", 315 | "ref": "ea8c0eda34fda57e7d5cd8cbd889e2a387e3472c" 316 | }, 317 | "files": [ 318 | "bin/console", 319 | "config/bootstrap.php" 320 | ] 321 | }, 322 | "symfony/css-selector": { 323 | "version": "v5.0.8" 324 | }, 325 | "symfony/debug-bundle": { 326 | "version": "4.1", 327 | "recipe": { 328 | "repo": "github.com/symfony/recipes", 329 | "branch": "master", 330 | "version": "4.1", 331 | "ref": "f8863cbad2f2e58c4b65fa1eac892ab189971bea" 332 | }, 333 | "files": [ 334 | "config/packages/dev/debug.yaml" 335 | ] 336 | }, 337 | "symfony/debug-pack": { 338 | "version": "v1.0.8" 339 | }, 340 | "symfony/dependency-injection": { 341 | "version": "v5.0.8" 342 | }, 343 | "symfony/doctrine-bridge": { 344 | "version": "v5.0.8" 345 | }, 346 | "symfony/dom-crawler": { 347 | "version": "v5.0.8" 348 | }, 349 | "symfony/dotenv": { 350 | "version": "v5.0.8" 351 | }, 352 | "symfony/error-handler": { 353 | "version": "v5.0.8" 354 | }, 355 | "symfony/event-dispatcher": { 356 | "version": "v5.0.8" 357 | }, 358 | "symfony/event-dispatcher-contracts": { 359 | "version": "v2.0.1" 360 | }, 361 | "symfony/filesystem": { 362 | "version": "v5.0.8" 363 | }, 364 | "symfony/finder": { 365 | "version": "v5.0.8" 366 | }, 367 | "symfony/flex": { 368 | "version": "1.0", 369 | "recipe": { 370 | "repo": "github.com/symfony/recipes", 371 | "branch": "master", 372 | "version": "1.0", 373 | "ref": "c0eeb50665f0f77226616b6038a9b06c03752d8e" 374 | }, 375 | "files": [ 376 | ".env" 377 | ] 378 | }, 379 | "symfony/framework-bundle": { 380 | "version": "4.4", 381 | "recipe": { 382 | "repo": "github.com/symfony/recipes", 383 | "branch": "master", 384 | "version": "4.4", 385 | "ref": "36d3075b2b8e0c4de0e82356a86e4c4a4eb6681b" 386 | }, 387 | "files": [ 388 | "config/bootstrap.php", 389 | "config/packages/cache.yaml", 390 | "config/packages/framework.yaml", 391 | "config/packages/test/framework.yaml", 392 | "config/routes/dev/framework.yaml", 393 | "config/services.yaml", 394 | "public/index.php", 395 | "src/Controller/.gitignore", 396 | "src/Kernel.php" 397 | ] 398 | }, 399 | "symfony/http-client": { 400 | "version": "v5.0.8" 401 | }, 402 | "symfony/http-client-contracts": { 403 | "version": "v2.1.2" 404 | }, 405 | "symfony/http-foundation": { 406 | "version": "v5.0.8" 407 | }, 408 | "symfony/http-kernel": { 409 | "version": "v5.0.8" 410 | }, 411 | "symfony/inflector": { 412 | "version": "v5.0.8" 413 | }, 414 | "symfony/maker-bundle": { 415 | "version": "1.0", 416 | "recipe": { 417 | "repo": "github.com/symfony/recipes", 418 | "branch": "master", 419 | "version": "1.0", 420 | "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" 421 | } 422 | }, 423 | "symfony/messenger": { 424 | "version": "4.3", 425 | "recipe": { 426 | "repo": "github.com/symfony/recipes", 427 | "branch": "master", 428 | "version": "4.3", 429 | "ref": "8a2675c061737658bed85102e9241c752620e575" 430 | }, 431 | "files": [ 432 | "config/packages/messenger.yaml" 433 | ] 434 | }, 435 | "symfony/mime": { 436 | "version": "v5.0.8" 437 | }, 438 | "symfony/monolog-bridge": { 439 | "version": "v5.0.8" 440 | }, 441 | "symfony/monolog-bundle": { 442 | "version": "3.3", 443 | "recipe": { 444 | "repo": "github.com/symfony/recipes", 445 | "branch": "master", 446 | "version": "3.3", 447 | "ref": "a89f4cd8a232563707418eea6c2da36acd36a917" 448 | }, 449 | "files": [ 450 | "config/packages/dev/monolog.yaml", 451 | "config/packages/prod/monolog.yaml", 452 | "config/packages/test/monolog.yaml" 453 | ] 454 | }, 455 | "symfony/options-resolver": { 456 | "version": "v5.0.11" 457 | }, 458 | "symfony/orm-pack": { 459 | "version": "v1.0.8" 460 | }, 461 | "symfony/phpunit-bridge": { 462 | "version": "4.3", 463 | "recipe": { 464 | "repo": "github.com/symfony/recipes", 465 | "branch": "master", 466 | "version": "4.3", 467 | "ref": "6d0e35f749d5f4bfe1f011762875275cd3f9874f" 468 | }, 469 | "files": [ 470 | ".env.test", 471 | "bin/phpunit", 472 | "phpunit.xml.dist", 473 | "tests/bootstrap.php" 474 | ] 475 | }, 476 | "symfony/polyfill-intl-idn": { 477 | "version": "v1.17.0" 478 | }, 479 | "symfony/polyfill-mbstring": { 480 | "version": "v1.17.0" 481 | }, 482 | "symfony/polyfill-php73": { 483 | "version": "v1.17.0" 484 | }, 485 | "symfony/profiler-pack": { 486 | "version": "v1.0.4" 487 | }, 488 | "symfony/property-access": { 489 | "version": "v5.0.8" 490 | }, 491 | "symfony/property-info": { 492 | "version": "v5.0.8" 493 | }, 494 | "symfony/routing": { 495 | "version": "4.2", 496 | "recipe": { 497 | "repo": "github.com/symfony/recipes", 498 | "branch": "master", 499 | "version": "4.2", 500 | "ref": "683dcb08707ba8d41b7e34adb0344bfd68d248a7" 501 | }, 502 | "files": [ 503 | "config/packages/prod/routing.yaml", 504 | "config/packages/routing.yaml", 505 | "config/routes.yaml" 506 | ] 507 | }, 508 | "symfony/security-bundle": { 509 | "version": "4.4", 510 | "recipe": { 511 | "repo": "github.com/symfony/recipes", 512 | "branch": "master", 513 | "version": "4.4", 514 | "ref": "7b4408dc203049666fe23fabed23cbadc6d8440f" 515 | }, 516 | "files": [ 517 | "config/packages/security.yaml" 518 | ] 519 | }, 520 | "symfony/security-core": { 521 | "version": "v5.0.11" 522 | }, 523 | "symfony/security-csrf": { 524 | "version": "v5.0.11" 525 | }, 526 | "symfony/security-guard": { 527 | "version": "v5.0.11" 528 | }, 529 | "symfony/security-http": { 530 | "version": "v5.0.11" 531 | }, 532 | "symfony/serializer": { 533 | "version": "v5.0.8" 534 | }, 535 | "symfony/serializer-pack": { 536 | "version": "v1.0.3" 537 | }, 538 | "symfony/service-contracts": { 539 | "version": "v2.0.1" 540 | }, 541 | "symfony/stopwatch": { 542 | "version": "v5.0.8" 543 | }, 544 | "symfony/test-pack": { 545 | "version": "v1.0.6" 546 | }, 547 | "symfony/translation-contracts": { 548 | "version": "v2.0.1" 549 | }, 550 | "symfony/twig-bridge": { 551 | "version": "v5.0.8" 552 | }, 553 | "symfony/twig-bundle": { 554 | "version": "5.0", 555 | "recipe": { 556 | "repo": "github.com/symfony/recipes", 557 | "branch": "master", 558 | "version": "5.0", 559 | "ref": "fab9149bbaa4d5eca054ed93f9e1b66cc500895d" 560 | }, 561 | "files": [ 562 | "config/packages/test/twig.yaml", 563 | "config/packages/twig.yaml", 564 | "templates/base.html.twig" 565 | ] 566 | }, 567 | "symfony/twig-pack": { 568 | "version": "v1.0.0" 569 | }, 570 | "symfony/var-dumper": { 571 | "version": "v5.0.8" 572 | }, 573 | "symfony/var-exporter": { 574 | "version": "v5.0.8" 575 | }, 576 | "symfony/web-profiler-bundle": { 577 | "version": "3.3", 578 | "recipe": { 579 | "repo": "github.com/symfony/recipes", 580 | "branch": "master", 581 | "version": "3.3", 582 | "ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6" 583 | }, 584 | "files": [ 585 | "config/packages/dev/web_profiler.yaml", 586 | "config/packages/test/web_profiler.yaml", 587 | "config/routes/dev/web_profiler.yaml" 588 | ] 589 | }, 590 | "symfony/yaml": { 591 | "version": "v5.0.8" 592 | }, 593 | "theseer/fdomdocument": { 594 | "version": "1.6.6" 595 | }, 596 | "theseer/tokenizer": { 597 | "version": "1.1.3" 598 | }, 599 | "twig/extra-bundle": { 600 | "version": "v3.0.3" 601 | }, 602 | "twig/twig": { 603 | "version": "v3.0.3" 604 | }, 605 | "webimpress/safe-writer": { 606 | "version": "2.0.1" 607 | }, 608 | "webmozart/assert": { 609 | "version": "1.8.0" 610 | }, 611 | "zircote/swagger-php": { 612 | "version": "3.1.0" 613 | } 614 | } 615 | --------------------------------------------------------------------------------