├── 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 |
--------------------------------------------------------------------------------