├── templates
└── .gitignore
├── logo.png
├── config
├── routes.yaml
├── packages
│ ├── sensio_framework_extra.yaml
│ ├── test
│ │ ├── validator.yaml
│ │ ├── fidry_alice_data_fixtures.yaml
│ │ ├── web_profiler.yaml
│ │ ├── dama_doctrine_test_bundle.yaml
│ │ ├── nelmio_alice.yaml
│ │ ├── doctrine.yaml
│ │ └── monolog.yaml
│ ├── twig.yaml
│ ├── dev
│ │ ├── fidry_alice_data_fixtures.yaml
│ │ ├── web_profiler.yaml
│ │ ├── nelmio_alice.yaml
│ │ ├── debug.yaml
│ │ └── monolog.yaml
│ ├── stof_doctrine_extensions.yaml
│ ├── doctrine_migrations.yaml
│ ├── nelmio_cors.yaml
│ ├── validator.yaml
│ ├── prod
│ │ ├── deprecations.yaml
│ │ ├── monolog.yaml
│ │ └── doctrine.yaml
│ ├── routing.yaml
│ ├── translation.yaml
│ ├── fos_rest.yaml
│ ├── lexik_jwt_authentication.yaml
│ ├── doctrine.yaml
│ ├── cache.yaml
│ ├── framework.yaml
│ └── security.yaml
├── routes
│ ├── framework.yaml
│ ├── annotations.yaml
│ └── dev
│ │ └── web_profiler.yaml
├── preload.php
├── services_dev.yaml
├── services_test.yaml
├── jwt
│ └── test
│ │ ├── public.pem
│ │ └── private.pem
├── services.yaml
└── bundles.php
├── docker
├── nginx
│ ├── Dockerfile
│ └── conf
│ │ └── project.conf
├── node
│ └── Dockerfile
└── php
│ ├── conf
│ ├── php.ini
│ └── php-fpm.conf
│ └── Dockerfile
├── docker-compose.override.yml.dist
├── .env.test
├── public
└── index.php
├── src
├── Kernel.php
├── DataFixtures
│ ├── Provider
│ │ └── CollectionProvider.php
│ ├── AppFixtures.php
│ └── Processor
│ │ └── UserProcessor.php
├── Exception
│ └── NoCurrentUserException.php
├── Repository
│ ├── TagRepository.php
│ ├── UserRepository.php
│ ├── CommentRepository.php
│ └── ArticleRepository.php
├── Controller
│ ├── Security
│ │ └── LoginController.php
│ ├── Profile
│ │ ├── GetProfileController.php
│ │ ├── FollowProfileController.php
│ │ └── UnfollowProfileController.php
│ ├── Article
│ │ ├── GetOneArticleController.php
│ │ ├── DeleteArticleController.php
│ │ ├── FavoriteArticleController.php
│ │ ├── UnfavoriteArticleController.php
│ │ ├── UpdateArticleController.php
│ │ ├── GetArticlesFeedController.php
│ │ ├── GetArticlesListController.php
│ │ └── CreateArticleController.php
│ ├── Tag
│ │ └── GetTagsListController.php
│ ├── Comment
│ │ ├── GetCommentsListController.php
│ │ ├── DeleteCommentController.php
│ │ └── CreateCommentController.php
│ ├── User
│ │ ├── GetUserController.php
│ │ └── UpdateUserController.php
│ └── Registration
│ │ └── RegisterController.php
├── Form
│ ├── CommentType.php
│ ├── UserType.php
│ ├── ArticleType.php
│ ├── DataTransformer
│ │ └── TagArrayToStringTransformer.php
│ └── Type
│ │ └── TagsInputType.php
├── Security
│ ├── UserResolver.php
│ └── Voter
│ │ └── AuthorVoter.php
├── Serializer
│ └── Normalizer
│ │ ├── DateTimeNormalizer.php
│ │ ├── TagNormalizer.php
│ │ ├── CommentNormalizer.php
│ │ ├── FormErrorNormalizer.php
│ │ ├── UserNormalizer.php
│ │ └── ArticleNormalizer.php
├── Entity
│ ├── Tag.php
│ ├── Comment.php
│ ├── Article.php
│ └── User.php
└── EventListener
│ └── JWTAuthenticationSubscriber.php
├── .editorconfig
├── spec
└── api-spec-test-runner.sh
├── bin
└── console
├── tests
├── DataFixtures
│ ├── Provider
│ │ └── CollectionProviderTest.php
│ └── Processor
│ │ └── UserProcessorTest.php
├── Controller
│ ├── Profile
│ │ ├── GetProfileControllerTest.php
│ │ ├── FollowProfileControllerTest.php
│ │ └── UnfollowProfileControllerTest.php
│ ├── Article
│ │ ├── GetOneArticleControllerTest.php
│ │ ├── FavoriteArticleControllerTest.php
│ │ ├── UnfavoriteArticleControllerTest.php
│ │ ├── DeleteArticleControllerTest.php
│ │ ├── GetArticlesListControllerTest.php
│ │ ├── GetArticlesFeedControllerTest.php
│ │ ├── UpdateArticleControllerTest.php
│ │ └── CreateArticleControllerTest.php
│ ├── Tag
│ │ └── TagsListControllerTest.php
│ ├── Comment
│ │ ├── GetCommentsListControllerTest.php
│ │ ├── DeleteCommentControllerTest.php
│ │ └── CreateCommentControllerTest.php
│ ├── User
│ │ ├── GetUserControllerTest.php
│ │ └── UpdateUserControllerTest.php
│ ├── Security
│ │ └── LoginControllerTest.php
│ └── Registration
│ │ └── RegisterControllerTest.php
├── phpunit.bootstrap.php
├── TestCase
│ └── WebTestCase.php
└── Security
│ └── UserResolverTest.php
├── psalm.xml.dist
├── rector.php
├── translations
└── validators.en.yaml
├── .gitignore
├── docker-compose.yml
├── phpcs.xml.dist
├── LICENSE
├── phpmd.xml.dist
├── phpunit.xml.dist
├── fixtures
└── data.yaml
├── README.md
├── phpstan.neon.dist
├── .env
├── .github
└── workflows
│ ├── specs.yml
│ └── ci.yml
├── composer.json
├── .php-cs-fixer.dist.php
├── migrations
└── Version20180326200407.php
├── Makefile
└── symfony.lock
/templates/.gitignore:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/spider-yamet/symfony-realworld-app/master/logo.png
--------------------------------------------------------------------------------
/config/routes.yaml:
--------------------------------------------------------------------------------
1 | #index:
2 | # path: /
3 | # controller: App\Controller\DefaultController::index
4 |
--------------------------------------------------------------------------------
/docker/nginx/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginx:1.18
2 |
3 | COPY conf/project.conf /etc/nginx/conf.d/default.conf
4 |
--------------------------------------------------------------------------------
/config/packages/sensio_framework_extra.yaml:
--------------------------------------------------------------------------------
1 | sensio_framework_extra:
2 | router:
3 | annotations: false
4 |
--------------------------------------------------------------------------------
/config/packages/test/validator.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | validation:
3 | not_compromised_password: false
4 |
--------------------------------------------------------------------------------
/config/packages/twig.yaml:
--------------------------------------------------------------------------------
1 | twig:
2 | default_path: '%kernel.project_dir%/templates'
3 |
4 | when@test:
5 | twig:
6 | strict_variables: true
7 |
--------------------------------------------------------------------------------
/config/packages/dev/fidry_alice_data_fixtures.yaml:
--------------------------------------------------------------------------------
1 | fidry_alice_data_fixtures:
2 | default_purge_mode: delete
3 | db_drivers:
4 | doctrine_orm: true
5 |
--------------------------------------------------------------------------------
/config/packages/test/fidry_alice_data_fixtures.yaml:
--------------------------------------------------------------------------------
1 | fidry_alice_data_fixtures:
2 | default_purge_mode: delete
3 | db_drivers:
4 | doctrine_orm: true
5 |
--------------------------------------------------------------------------------
/config/routes/framework.yaml:
--------------------------------------------------------------------------------
1 | when@dev:
2 | _errors:
3 | resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
4 | prefix: /_error
5 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/config/packages/stof_doctrine_extensions.yaml:
--------------------------------------------------------------------------------
1 | stof_doctrine_extensions:
2 | orm:
3 | default:
4 | timestampable: true
5 | sluggable: true
6 |
--------------------------------------------------------------------------------
/config/routes/annotations.yaml:
--------------------------------------------------------------------------------
1 | controllers:
2 | resource: ../../src/Controller/
3 | type: annotation
4 |
5 | kernel:
6 | resource: ../../src/Kernel.php
7 | type: annotation
8 |
--------------------------------------------------------------------------------
/config/packages/test/dama_doctrine_test_bundle.yaml:
--------------------------------------------------------------------------------
1 | dama_doctrine_test:
2 | enable_static_connection: true
3 | enable_static_meta_data_cache: true
4 | enable_static_query_cache: true
5 |
--------------------------------------------------------------------------------
/config/packages/dev/nelmio_alice.yaml:
--------------------------------------------------------------------------------
1 | nelmio_alice:
2 | locale: 'fr_FR'
3 | seed: 1234
4 | functions_blacklist:
5 | - 'current'
6 | loading_limit: 5
7 | max_unique_values_retry: 150
8 |
--------------------------------------------------------------------------------
/config/packages/test/nelmio_alice.yaml:
--------------------------------------------------------------------------------
1 | nelmio_alice:
2 | locale: 'fr_FR'
3 | seed: 1234
4 | functions_blacklist:
5 | - 'current'
6 | loading_limit: 5
7 | max_unique_values_retry: 150
8 |
--------------------------------------------------------------------------------
/docker-compose.override.yml.dist:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 |
5 | nginx:
6 | ports:
7 | - "80:80"
8 |
9 | mysql:
10 | ports:
11 | - "3306:3306"
12 |
--------------------------------------------------------------------------------
/config/preload.php:
--------------------------------------------------------------------------------
1 | $profile];
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/docker/php/conf/php-fpm.conf:
--------------------------------------------------------------------------------
1 | [global]
2 |
3 | error_log = /proc/stderr
4 | daemonize = no
5 |
6 | [www]
7 |
8 | ; if we send this to /proc/self/fd/1, it never appears
9 | access.log = /proc/stdout
10 |
11 | ; this does the trick for changing the user
12 | user = project
13 | group = project
14 |
15 | listen = [::]:9000
16 |
17 | pm = dynamic
18 | pm.max_children = 5
19 | pm.start_servers = 2
20 | pm.min_spare_servers = 1
21 | pm.max_spare_servers = 3
22 |
23 | clear_env = no
24 |
25 | ; Ensure worker stdout and stderr are sent to the main error log.
26 | catch_workers_output = yes
27 |
--------------------------------------------------------------------------------
/src/Controller/Article/GetOneArticleController.php:
--------------------------------------------------------------------------------
1 | $article];
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/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: php://stderr
12 | level: debug
13 | formatter: monolog.formatter.json
14 | console:
15 | type: console
16 | process_psr_3_messages: false
17 | channels: ["!event", "!doctrine"]
18 |
--------------------------------------------------------------------------------
/src/DataFixtures/AppFixtures.php:
--------------------------------------------------------------------------------
1 | loader = $loader;
18 | }
19 |
20 | public function load(ObjectManager $manager): void
21 | {
22 | $this->loader->load([__DIR__ . '/../../fixtures/data.yaml']);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/tests/DataFixtures/Provider/CollectionProviderTest.php:
--------------------------------------------------------------------------------
1 | assertSame([], CollectionProvider::collection([])->toArray());
15 | $this->assertSame(['a'], CollectionProvider::collection(['a'])->toArray());
16 | $this->assertSame(['a', 'b'], CollectionProvider::collection(['a', 'b'])->toArray());
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/psalm.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/config/packages/prod/doctrine.yaml:
--------------------------------------------------------------------------------
1 | doctrine:
2 | orm:
3 | auto_generate_proxy_classes: false
4 | metadata_cache_driver:
5 | type: pool
6 | pool: doctrine.system_cache_pool
7 | query_cache_driver:
8 | type: pool
9 | pool: doctrine.system_cache_pool
10 | result_cache_driver:
11 | type: pool
12 | pool: doctrine.result_cache_pool
13 |
14 | framework:
15 | cache:
16 | pools:
17 | doctrine.result_cache_pool:
18 | adapter: cache.app
19 | doctrine.system_cache_pool:
20 | adapter: cache.system
21 |
--------------------------------------------------------------------------------
/docker/nginx/conf/project.conf:
--------------------------------------------------------------------------------
1 | # see https://symfony.com/doc/current/setup/web_server_configuration.html
2 |
3 | server {
4 |
5 | server_name localhost;
6 | root /project/public;
7 |
8 | location / {
9 | try_files $uri /index.php$is_args$args;
10 | }
11 |
12 | location ~ \.php$ {
13 | fastcgi_pass php:9000;
14 | fastcgi_split_path_info ^(.+\.php)(/.*)$;
15 | include fastcgi_params;
16 | fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
17 | fastcgi_param DOCUMENT_ROOT $realpath_root;
18 | }
19 |
20 | error_log /var/log/nginx/error.log;
21 | access_log /var/log/nginx/access.log;
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 |
--------------------------------------------------------------------------------
/config/packages/doctrine.yaml:
--------------------------------------------------------------------------------
1 | doctrine:
2 | dbal:
3 | driver: 'pdo_mysql'
4 | server_version: '8.0'
5 | charset: utf8mb4
6 | default_table_options:
7 | charset: utf8mb4
8 | collate: utf8mb4_unicode_ci
9 | url: '%env(resolve:DATABASE_URL)%'
10 | orm:
11 | auto_generate_proxy_classes: true
12 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
13 | auto_mapping: true
14 | mappings:
15 | App:
16 | is_bundle: false
17 | type: annotation
18 | dir: '%kernel.project_dir%/src/Entity'
19 | prefix: 'App\Entity'
20 | alias: App
21 |
--------------------------------------------------------------------------------
/rector.php:
--------------------------------------------------------------------------------
1 | parameters();
11 | $parameters->set(Option::CACHE_DIR, 'var/rector');
12 | $parameters->set(Option::PATHS, [__DIR__ . '/src', __DIR__ . '/tests']);
13 | $parameters->set(Option::SYMFONY_CONTAINER_XML_PATH_PARAMETER, __DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml');
14 | $containerConfigurator->import(SymfonySetList::SYMFONY_CODE_QUALITY);
15 | };
16 |
--------------------------------------------------------------------------------
/translations/validators.en.yaml:
--------------------------------------------------------------------------------
1 | article.title.not_blank: can't be blank
2 | article.description.not_blank: can't be blank
3 | article.body.not_blank: can't be blank
4 | comment.body.not_blank: can't be blank
5 | user.email.not_blank: can't be blank
6 | user.email.email: invalid
7 | user.email.unique: has already been taken
8 | user.password.not_blank: can't be blank
9 | user.password.length.min: is too short (minimum is {{ limit }} characters)
10 | user.username.not_blank: can't be blank
11 | user.username.length.min: is too short (minimum is {{ limit }} character)
12 | user.username.length.max: is too long (maximum is {{ limit }} characters)
13 | user.username.unique: has already been taken
14 | user.image.url: is not a valid url
15 |
--------------------------------------------------------------------------------
/tests/Controller/Profile/GetProfileControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('GET', '/api/profiles/user1');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
19 |
20 | $data = json_decode($response->getContent(), true);
21 | $this->assertArrayHasKey('profile', $data);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /docker-compose.override.yml
2 |
3 | ###> symfony/framework-bundle ###
4 | /.env.local
5 | /.env.local.php
6 | /.env.*.local
7 | /config/secrets/prod/prod.decrypt.private.php
8 | /public/bundles/
9 | /var/
10 | /vendor/
11 | ###< symfony/framework-bundle ###
12 |
13 | ###> symfony/phpunit-bridge ###
14 | .phpunit
15 | .phpunit.result.cache
16 | /phpunit.xml
17 | ###< symfony/phpunit-bridge ###
18 |
19 | ###> friendsofphp/php-cs-fixer ###
20 | /.php_cs
21 | ###< friendsofphp/php-cs-fixer ###
22 |
23 | ###> lexik/jwt-authentication-bundle ###
24 | !/config/jwt/test
25 | /config/jwt/*.pem
26 | ###< lexik/jwt-authentication-bundle ###
27 |
28 | ###> squizlabs/php_codesniffer ###
29 | /.phpcs-cache
30 | /phpcs.xml
31 | ###< squizlabs/php_codesniffer ###
32 |
--------------------------------------------------------------------------------
/tests/Controller/Article/GetOneArticleControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('GET', '/api/articles/article-1');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
19 |
20 | $data = json_decode($response->getContent(), true);
21 | $this->assertArrayHasKey('article', $data);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Controller/Tag/GetTagsListController.php:
--------------------------------------------------------------------------------
1 | tagRepository = $repository;
21 | }
22 |
23 | public function __invoke(): array
24 | {
25 | return ['tags' => $this->tagRepository->findAll()];
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/tests/Controller/Tag/TagsListControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('GET', '/api/tags');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
19 |
20 | $data = json_decode($response->getContent(), true);
21 | $this->assertArrayHasKey('tags', $data);
22 | $this->assertCount(2, $data['tags']);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/config/packages/framework.yaml:
--------------------------------------------------------------------------------
1 | # see https://symfony.com/doc/current/reference/configuration/framework.html
2 | framework:
3 | secret: '%env(APP_SECRET)%'
4 | #csrf_protection: true
5 | http_method_override: false
6 |
7 | # Enables session support. Note that the session will ONLY be started if you read or write from it.
8 | # Remove or comment this section to explicitly disable session support.
9 | session:
10 | handler_id: null
11 | cookie_secure: auto
12 | cookie_samesite: lax
13 | storage_factory_id: session.storage.factory.native
14 |
15 | #esi: true
16 | #fragments: true
17 | php_errors:
18 | log: true
19 |
20 | when@test:
21 | framework:
22 | test: true
23 | session:
24 | storage_factory_id: session.storage.factory.mock_file
25 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 |
5 | php:
6 | build: docker/php
7 | volumes:
8 | - "./:/project:cached"
9 | depends_on:
10 | - mysql
11 |
12 | nginx:
13 | build: docker/nginx
14 | volumes:
15 | - "./:/project:cached"
16 | depends_on:
17 | - php
18 |
19 | mysql:
20 | image: mysql
21 | command: --default-authentication-plugin=mysql_native_password
22 | environment:
23 | - "MYSQL_ROOT_PASSWORD=root"
24 | - "MYSQL_USER=project"
25 | - "MYSQL_PASSWORD=project"
26 | - "MYSQL_DATABASE=project"
27 |
28 | node:
29 | build: docker/node
30 | volumes:
31 | - "./:/project"
32 | depends_on:
33 | - nginx
34 |
--------------------------------------------------------------------------------
/tests/Controller/Comment/GetCommentsListControllerTest.php:
--------------------------------------------------------------------------------
1 | createAuthenticatedApiClient();
15 | $client->request('GET', '/api/articles/article-1/comments');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
19 |
20 | $data = json_decode($response->getContent(), true);
21 | $this->assertArrayHasKey('comments', $data);
22 | $this->assertCount(1, $data['comments']);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/config/jwt/test/public.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN PUBLIC KEY-----
2 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAowKaX+wvGpdNQBituIZP
3 | oQiYKZofUg/BR1en9H8lwtESv9m2HgVAX4/nOfzeCaQwiNeI3csOKxdBXGXYQAEU
4 | 80K3UHNJZp33AuWNYfjPER/fRBNLGpGovX/k8xtoQcNaG1fRi2JLDVTSj2nVJ5lN
5 | wRRT28o2J/sarHnogzC4YaIB/TzwGjAmn72ZcyYv9tka1vMld0xkoE9WsJo1OW6c
6 | XwTMWfQ35oqQPIfjBMKi3vVgJZ0SftBjD0d90z27+oBfHJR7tCyZbwiMbgbMohrJ
7 | wyjlaGLhNowOzplFSjVRwZJU82MDyvxiwe05MhjoW+sUYV7k4zpUbEiUYVJ4da65
8 | NzHHHmaXPTpj6WMew1QaxQSQCNsrmChJSTNWJ9kT21h83yPcJRuveVCkhW6cGQM/
9 | mE0rhVLieVmwAyUJZDBuoghTtZz/G/y+mHT+pn/RcmFg5qpBBpT2UeN/4+qlOrs1
10 | cBQGOfAMZ9Udk1PJrqQ9mih+YYam9yGB4mdz8YCuX0tZTwcgYCWAqpQVslJcWdMB
11 | yhS0Mvx0+Bs+Xr3mJF3GMeZ086dwyS9okiPi2EUO0rZxEUVzo7ZZTrHl92EIwCuF
12 | i6J3uCNwWeGzQ0XsGyHqlVxRjxIH2mdkFskiOT1PDha6hB0LHSCu8Xi/BSEXD/XL
13 | 3MHnGPeSCdo2X0uIFvBliEsCAwEAAQ==
14 | -----END PUBLIC KEY-----
15 |
--------------------------------------------------------------------------------
/src/Form/CommentType.php:
--------------------------------------------------------------------------------
1 | add('body', TextareaType::class);
21 | }
22 |
23 | /**
24 | * {@inheritdoc}
25 | */
26 | public function configureOptions(OptionsResolver $resolver): void
27 | {
28 | $resolver->setDefaults([
29 | 'data_class' => Comment::class,
30 | 'csrf_protection' => false,
31 | ]);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Security/UserResolver.php:
--------------------------------------------------------------------------------
1 | tokenStorage = $tokenStorage;
18 | }
19 |
20 | /**
21 | * @throws NoCurrentUserException
22 | */
23 | public function getCurrentUser(): User
24 | {
25 | $token = $this->tokenStorage->getToken();
26 | $user = $token !== null ? $token->getUser() : null;
27 |
28 | if (!($user instanceof User)) {
29 | throw new NoCurrentUserException();
30 | }
31 |
32 | return $user;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Controller/Comment/GetCommentsListController.php:
--------------------------------------------------------------------------------
1 | commentRepository = $repository;
22 | }
23 |
24 | public function __invoke(Article $article): array
25 | {
26 | return [
27 | 'comments' => $this->commentRepository->findBy(['article' => $article]),
28 | ];
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Form/UserType.php:
--------------------------------------------------------------------------------
1 | add('username')
21 | ->add('email')
22 | ->add('password')
23 | ->add('image')
24 | ->add('bio')
25 | ;
26 | }
27 |
28 | public function configureOptions(OptionsResolver $resolver): void
29 | {
30 | $resolver->setDefaults([
31 | 'data_class' => User::class,
32 | 'csrf_protection' => false,
33 | ]);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Serializer/Normalizer/DateTimeNormalizer.php:
--------------------------------------------------------------------------------
1 | toISOString();
19 | }
20 |
21 | /**
22 | * {@inheritdoc}
23 | */
24 | public function supportsNormalization($data, string $format = null): bool
25 | {
26 | return $data instanceof \DateTime;
27 | }
28 |
29 | public function hasCacheableSupportsMethod(): bool
30 | {
31 | return true;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Serializer/Normalizer/TagNormalizer.php:
--------------------------------------------------------------------------------
1 | getName();
20 | }
21 |
22 | /**
23 | * {@inheritdoc}
24 | */
25 | public function supportsNormalization($data, string $format = null): bool
26 | {
27 | return $data instanceof Tag;
28 | }
29 |
30 | public function hasCacheableSupportsMethod(): bool
31 | {
32 | return true;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Controller/User/GetUserController.php:
--------------------------------------------------------------------------------
1 | userResolver = $userResolver;
27 | }
28 |
29 | public function __invoke(): array
30 | {
31 | return ['user' => $this->userResolver->getCurrentUser()];
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Controller/Article/DeleteArticleController.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
25 | }
26 |
27 | public function __invoke(Article $article): void
28 | {
29 | $this->entityManager->remove($article);
30 | $this->entityManager->flush();
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Entity/Tag.php:
--------------------------------------------------------------------------------
1 | name;
33 | }
34 |
35 | public function getId(): ?int
36 | {
37 | return $this->id;
38 | }
39 |
40 | public function getName(): ?string
41 | {
42 | return $this->name;
43 | }
44 |
45 | public function setName(string $name): void
46 | {
47 | $this->name = $name;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Security/Voter/AuthorVoter.php:
--------------------------------------------------------------------------------
1 | getUser();
30 |
31 | if (!$user instanceof User) {
32 | return false;
33 | }
34 |
35 | return $subject->getAuthor()->getId() === $user->getId();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Form/ArticleType.php:
--------------------------------------------------------------------------------
1 | add('title')
22 | ->add('description')
23 | ->add('body')
24 | ->add('tagList', TagsInputType::class, [
25 | 'property_path' => 'tags',
26 | ])
27 | ;
28 | }
29 |
30 | /**
31 | * {@inheritdoc}
32 | */
33 | public function configureOptions(OptionsResolver $resolver): void
34 | {
35 | $resolver->setDefaults([
36 | 'data_class' => Article::class,
37 | 'csrf_protection' => false,
38 | ]);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/phpcs.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 | src/
10 | tests/
11 |
12 |
13 |
14 |
15 |
16 |
17 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/DataFixtures/Processor/UserProcessor.php:
--------------------------------------------------------------------------------
1 | userPasswordHasher = $userPasswordHasher;
18 | }
19 |
20 | /**
21 | * {@inheritdoc}
22 | */
23 | public function preProcess(string $id, object $object): void
24 | {
25 | if (!$object instanceof User) {
26 | return;
27 | }
28 |
29 | $object->setPassword($this->userPasswordHasher->hashPassword($object, $object->getPassword()));
30 | }
31 |
32 | /**
33 | * {@inheritdoc}
34 | */
35 | public function postProcess(string $id, object $object): void
36 | {
37 | // nothing to do
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/Controller/Profile/FollowProfileControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('POST', '/api/profiles/user2/follow');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | public function testAsAuthenticated(): void
22 | {
23 | $client = $this->createAuthenticatedApiClient();
24 | $client->request('POST', '/api/profiles/user2/follow');
25 |
26 | $response = $client->getResponse();
27 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
28 |
29 | $data = json_decode($response->getContent(), true);
30 | $this->assertArrayHasKey('profile', $data);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/tests/Controller/Profile/UnfollowProfileControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('DELETE', '/api/profiles/user2/follow');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | public function testAsAuthenticated(): void
22 | {
23 | $client = $this->createAuthenticatedApiClient();
24 | $client->request('DELETE', '/api/profiles/user2/follow');
25 |
26 | $response = $client->getResponse();
27 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
28 |
29 | $data = json_decode($response->getContent(), true);
30 | $this->assertArrayHasKey('profile', $data);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/tests/Controller/Article/FavoriteArticleControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('POST', '/api/articles/article-2/favorite');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | public function testAsAuthenticated(): void
22 | {
23 | $client = $this->createAuthenticatedApiClient();
24 | $client->request('POST', '/api/articles/article-2/favorite');
25 |
26 | $response = $client->getResponse();
27 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
28 |
29 | $data = json_decode($response->getContent(), true);
30 | $this->assertArrayHasKey('article', $data);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Nicolas CABOT
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/Controller/Article/UnfavoriteArticleControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('DELETE', '/api/articles/article-2/favorite');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | public function testAsAuthenticated(): void
22 | {
23 | $client = $this->createAuthenticatedApiClient();
24 | $client->request('DELETE', '/api/articles/article-2/favorite');
25 |
26 | $response = $client->getResponse();
27 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
28 |
29 | $data = json_decode($response->getContent(), true);
30 | $this->assertArrayHasKey('article', $data);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Controller/Comment/DeleteCommentController.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
28 | }
29 |
30 | public function __invoke(Comment $comment): void
31 | {
32 | $this->entityManager->remove($comment);
33 | $this->entityManager->flush();
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/tests/phpunit.bootstrap.php:
--------------------------------------------------------------------------------
1 | bootEnv(dirname(__DIR__) . '/.env');
13 | }
14 |
15 | $kernel = new App\Kernel('test', true);
16 | $kernel->boot();
17 |
18 | $application = new Symfony\Bundle\FrameworkBundle\Console\Application($kernel);
19 | $application->setAutoExit(false);
20 |
21 | $application->run(new Symfony\Component\Console\Input\ArrayInput([
22 | 'command' => 'doctrine:database:drop',
23 | '--force' => '1',
24 | ]));
25 |
26 | $application->run(new Symfony\Component\Console\Input\ArrayInput([
27 | 'command' => 'doctrine:database:create',
28 | ]));
29 |
30 | $application->run(new Symfony\Component\Console\Input\ArrayInput([
31 | 'command' => 'doctrine:schema:create',
32 | ]));
33 |
34 | $application->run(new Symfony\Component\Console\Input\ArrayInput([
35 | 'command' => 'doctrine:fixtures:load',
36 | '--no-interaction' => '1',
37 | ]));
38 |
39 | $kernel->shutdown();
40 |
--------------------------------------------------------------------------------
/tests/Controller/User/GetUserControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('GET', '/api/user');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | public function testAsAuthenticated(): void
22 | {
23 | $client = $this->createAuthenticatedApiClient();
24 | $client->request('GET', '/api/user');
25 |
26 | $response = $client->getResponse();
27 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
28 |
29 | $data = json_decode($response->getContent(), true);
30 | $this->assertArrayHasKey('user', $data);
31 | $this->assertArrayHasKey('email', $data['user']);
32 | $this->assertSame('user1@conduit.tld', $data['user']['email']);
33 | $this->assertArrayHasKey('token', $data['user']);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Controller/Profile/FollowProfileController.php:
--------------------------------------------------------------------------------
1 | userResolver = $userResolver;
27 | $this->entityManager = $entityManager;
28 | }
29 |
30 | public function __invoke(User $profile): array
31 | {
32 | $user = $this->userResolver->getCurrentUser();
33 | $user->follow($profile);
34 |
35 | $this->entityManager->flush();
36 |
37 | return ['profile' => $profile];
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Controller/Profile/UnfollowProfileController.php:
--------------------------------------------------------------------------------
1 | userResolver = $userResolver;
27 | $this->entityManager = $entityManager;
28 | }
29 |
30 | public function __invoke(User $profile): array
31 | {
32 | $user = $this->userResolver->getCurrentUser();
33 | $user->unfollow($profile);
34 |
35 | $this->entityManager->flush();
36 |
37 | return ['profile' => $profile];
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Controller/Article/FavoriteArticleController.php:
--------------------------------------------------------------------------------
1 | userResolver = $userResolver;
27 | $this->entityManager = $entityManager;
28 | }
29 |
30 | public function __invoke(Article $article): array
31 | {
32 | $user = $this->userResolver->getCurrentUser();
33 | $user->addToFavorites($article);
34 |
35 | $this->entityManager->flush();
36 |
37 | return ['article' => $article];
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Form/DataTransformer/TagArrayToStringTransformer.php:
--------------------------------------------------------------------------------
1 | tagRepository = $tags;
18 | }
19 |
20 | /**
21 | * {@inheritdoc}
22 | */
23 | public function transform($value): string
24 | {
25 | return '';
26 | }
27 |
28 | /**
29 | * {@inheritdoc}
30 | */
31 | public function reverseTransform($value): array
32 | {
33 | if (!\is_array($value)) {
34 | return [];
35 | }
36 |
37 | $names = array_filter(array_unique(array_map('trim', $value)));
38 | $tags = $this->tagRepository->findBy(['name' => $names]);
39 | $newNames = array_diff($names, $tags);
40 |
41 | foreach ($newNames as $name) {
42 | $tag = new Tag();
43 | $tag->setName($name);
44 | $tags[] = $tag;
45 | }
46 |
47 | return $tags;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/tests/Controller/Article/DeleteArticleControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('DELETE', '/api/articles/article-1');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | public function testAsNotOwner(): void
22 | {
23 | $client = $this->createAuthenticatedApiClient();
24 | $client->request('DELETE', '/api/articles/article-2');
25 |
26 | $response = $client->getResponse();
27 | $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode());
28 | }
29 |
30 | public function testAsOwner(): void
31 | {
32 | $client = $this->createAuthenticatedApiClient();
33 | $client->request('DELETE', '/api/articles/article-1');
34 |
35 | $response = $client->getResponse();
36 | $this->assertSame(Response::HTTP_NO_CONTENT, $response->getStatusCode());
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/tests/Controller/Article/GetArticlesListControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
18 | $client->request('GET', '/api/articles' . $query);
19 |
20 | $response = $client->getResponse();
21 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
22 |
23 | $data = json_decode($response->getContent(), true);
24 | $this->assertArrayHasKey('articles', $data);
25 | $this->assertArrayHasKey('articlesCount', $data);
26 | $this->assertSame($expectedCount, $data['articlesCount']);
27 | }
28 |
29 | public function provideResponseCases(): iterable
30 | {
31 | yield ['', 25];
32 | yield ['?tag=lorem', 1];
33 | yield ['?tag=ipsum', 24];
34 | yield ['?author=user2', 1];
35 | yield ['?favorited=user1', 1];
36 | yield ['?tag=lorem&author=user2&favorited=user1', 0];
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/tests/TestCase/WebTestCase.php:
--------------------------------------------------------------------------------
1 | 'application/json',
19 | ]);
20 | }
21 |
22 | protected function createAuthenticatedApiClient(string $user = 'user1@conduit.tld'): KernelBrowser
23 | {
24 | $user = static::getContainer()->get(UserRepository::class)->findOneBy(['email' => $user]);
25 |
26 | if (!$user instanceof User) {
27 | throw new \InvalidArgumentException('User not found.');
28 | }
29 |
30 | $token = static::getContainer()->get(JWTTokenManagerInterface::class)->create($user);
31 | static::ensureKernelShutdown();
32 |
33 | return static::createClient([], [
34 | 'CONTENT_TYPE' => 'application/json',
35 | 'HTTP_AUTHORIZATION' => 'Token ' . $token,
36 | ]);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/tests/Controller/Comment/DeleteCommentControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('DELETE', '/api/articles/article-2/comments/1');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | public function testAsNotOwner(): void
22 | {
23 | $client = $this->createAuthenticatedApiClient();
24 | $client->request('DELETE', '/api/articles/article-2/comments/1');
25 |
26 | $response = $client->getResponse();
27 | $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode());
28 | }
29 |
30 | public function testAsOwner(): void
31 | {
32 | $client = $this->createAuthenticatedApiClient();
33 | $client->request('DELETE', '/api/articles/article-1/comments/2');
34 |
35 | $response = $client->getResponse();
36 | $this->assertSame(Response::HTTP_NO_CONTENT, $response->getStatusCode());
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Form/Type/TagsInputType.php:
--------------------------------------------------------------------------------
1 | tagRepository = $tags;
22 | }
23 |
24 | /**
25 | * {@inheritdoc}
26 | */
27 | public function buildForm(FormBuilderInterface $builder, array $options): void
28 | {
29 | $builder
30 | ->addModelTransformer(new CollectionToArrayTransformer(), true)
31 | ->addModelTransformer(new TagArrayToStringTransformer($this->tagRepository), true)
32 | ;
33 | }
34 |
35 | public function configureOptions(OptionsResolver $resolver): void
36 | {
37 | $resolver->setDefault('multiple', true);
38 | }
39 |
40 | public function getParent(): string
41 | {
42 | return TextType::class;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Controller/Article/UnfavoriteArticleController.php:
--------------------------------------------------------------------------------
1 | userResolver = $userResolver;
30 | $this->entityManager = $entityManager;
31 | }
32 |
33 | public function __invoke(Article $article): array
34 | {
35 | $user = $this->userResolver->getCurrentUser();
36 | $user->removeFromFavorites($article);
37 |
38 | $this->entityManager->flush();
39 |
40 | return ['article' => $article];
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/config/packages/security.yaml:
--------------------------------------------------------------------------------
1 | security:
2 |
3 | enable_authenticator_manager: true
4 |
5 | password_hashers:
6 | App\Entity\User:
7 | algorithm: auto
8 |
9 | providers:
10 | db_users:
11 | entity:
12 | class: App\Entity\User
13 | property: email
14 |
15 | firewalls:
16 | dev:
17 | pattern: ^/(_(profiler|wdt)|css|images|js)/
18 | security: false
19 | api_login:
20 | pattern: ^/api/users/login
21 | stateless: true
22 | json_login:
23 | check_path: api_users_login
24 | username_path: user.email
25 | password_path: user.password
26 | success_handler: Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler
27 | failure_handler: Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationFailureHandler
28 | api:
29 | pattern: ^/api
30 | stateless: true
31 | jwt: ~
32 |
33 | when@test:
34 | security:
35 | password_hashers:
36 | App\Entity\User:
37 | algorithm: auto
38 | cost: 4 # Lowest possible value for bcrypt
39 | time_cost: 3 # Lowest possible value for argon
40 | memory_cost: 10 # Lowest possible value for argon
41 |
--------------------------------------------------------------------------------
/tests/DataFixtures/Processor/UserProcessorTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(UserPasswordHasher::class)->disableOriginalConstructor()->getMock();
18 | $userPasswordHasher->expects($this->never())->method('hashPassword');
19 |
20 | $processor = new UserProcessor($userPasswordHasher);
21 | $processor->preProcess('user', new Article());
22 | }
23 |
24 | public function testPreProcessWithUserObject(): void
25 | {
26 | $userPasswordHasher = $this->getMockBuilder(UserPasswordHasher::class)->disableOriginalConstructor()->getMock();
27 | $userPasswordHasher->expects($this->once())->method('hashPassword')->willReturn('hashed_password');
28 |
29 | $user = new User();
30 | $user->setPassword('password');
31 |
32 | $processor = new UserProcessor($userPasswordHasher);
33 | $processor->preProcess('user', $user);
34 |
35 | $this->assertSame('hashed_password', $user->getPassword());
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/tests/Controller/Security/LoginControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('POST', '/api/users/login');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
19 | }
20 |
21 | public function testResponse(): void
22 | {
23 | $client = $this->createAnonymousApiClient();
24 | $client->request('POST', '/api/users/login', [], [], [], json_encode([
25 | 'user' => [
26 | 'email' => 'user1@conduit.tld',
27 | 'password' => 'password',
28 | ],
29 | ]));
30 |
31 | $response = $client->getResponse();
32 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
33 |
34 | $data = json_decode($response->getContent(), true);
35 | $this->assertArrayHasKey('user', $data);
36 | $this->assertArrayHasKey('email', $data['user']);
37 | $this->assertSame('user1@conduit.tld', $data['user']['email']);
38 | $this->assertArrayHasKey('token', $data['user']);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/phpmd.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | src/DataFixtures/*
41 |
42 |
--------------------------------------------------------------------------------
/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/DataFixtures/'
20 | - '../src/Entity/'
21 | - '../src/Exception/'
22 | - '../src/Kernel.php'
23 |
24 | # controllers are imported separately to make sure services can be injected
25 | # as action arguments even if you don't extend any base controller class
26 | App\Controller\:
27 | resource: '../src/Controller'
28 | tags: ['controller.service_arguments']
29 |
30 | # add more service definitions when explicit configuration is needed
31 | # please note that last definitions always *replace* previous ones
32 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 | ./src/
13 |
14 |
15 | ./src/Migrations
16 | ./src/Kernel.php
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | tests/
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/src/Serializer/Normalizer/CommentNormalizer.php:
--------------------------------------------------------------------------------
1 | $object->getId(),
26 | 'createdAt' => $this->normalizer->normalize($object->getCreatedAt()),
27 | 'updatedAt' => $this->normalizer->normalize($object->getCreatedAt()),
28 | 'body' => $object->getBody(),
29 | 'author' => $this->normalizer->normalize($object->getAuthor()),
30 | ];
31 | }
32 |
33 | /**
34 | * {@inheritdoc}
35 | */
36 | public function supportsNormalization($data, string $format = null): bool
37 | {
38 | return $data instanceof Comment;
39 | }
40 |
41 | public function hasCacheableSupportsMethod(): bool
42 | {
43 | return true;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/EventListener/JWTAuthenticationSubscriber.php:
--------------------------------------------------------------------------------
1 | normalizer = $normalizer;
21 | }
22 |
23 | public static function getSubscribedEvents(): array
24 | {
25 | return [
26 | JWTEvents::AUTHENTICATION_SUCCESS => 'onAuthenticationSuccess',
27 | ];
28 | }
29 |
30 | public function onAuthenticationSuccess(AuthenticationSuccessEvent $event): void
31 | {
32 | $user = $event->getUser();
33 |
34 | if (!$user instanceof User) {
35 | return;
36 | }
37 |
38 | try {
39 | $userData = $this->normalizer->normalize($user, null, ['groups' => ['me']]);
40 | $eventData = $event->getData();
41 | } catch (ExceptionInterface $exception) {
42 | return;
43 | }
44 |
45 | $event->setData(['user' => array_merge($userData, $eventData)]);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/docker/php/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM php:7.4-fpm
2 |
3 | # Set TIMEZONE
4 | RUN rm /etc/localtime \
5 | && ln -s /usr/share/zoneinfo/Europe/Paris /etc/localtime \
6 | && "date"
7 |
8 | # Install TOOLS
9 | RUN apt-get update \
10 | && apt-get install -y git curl wget unzip \
11 | && rm -rf /var/lib/apt/lists/*
12 |
13 | # Install OPCACHE extension
14 | RUN docker-php-ext-install opcache
15 |
16 | # Install APCU extension
17 | RUN pecl install apcu \
18 | && docker-php-ext-enable apcu
19 |
20 | # Install INTL extension
21 | RUN apt-get update \
22 | && apt-get install -y libicu-dev \
23 | && docker-php-ext-configure intl \
24 | && docker-php-ext-install intl \
25 | && rm -rf /var/lib/apt/lists/*
26 |
27 | # Install ZIP extension
28 | RUN apt-get update \
29 | && apt-get install -y libzip-dev zip \
30 | && docker-php-ext-install zip \
31 | && rm -rf /var/lib/apt/lists/*
32 |
33 | # Install PDO MYSQL extension
34 | RUN docker-php-ext-install pdo_mysql
35 |
36 | # Install PCOV extension
37 | RUN pecl install pcov \
38 | && docker-php-ext-enable pcov
39 |
40 | # Install COMPOSER
41 | COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
42 |
43 | # Install SYMFONY BINARY
44 | RUN wget https://get.symfony.com/cli/installer -O - | bash \
45 | && mv ~/.symfony/bin/symfony /usr/local/bin/symfony
46 |
47 | # Set PROJECT USER
48 | RUN useradd -ms /bin/bash project
49 | USER project
50 | WORKDIR /project
51 |
52 | COPY conf/php-fpm.conf /etc/php-fpm.conf
53 | COPY conf/php.ini /usr/local/etc/php/conf.d/100-php.ini
54 |
--------------------------------------------------------------------------------
/tests/Controller/Article/GetArticlesFeedControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('GET', '/api/articles/feed');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | /**
22 | * @dataProvider provideAsAuthenticatedCases
23 | */
24 | public function testAsAuthenticated(string $user, int $expectedCount): void
25 | {
26 | $client = $this->createAuthenticatedApiClient($user);
27 | $client->request('GET', '/api/articles/feed');
28 |
29 | $response = $client->getResponse();
30 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
31 |
32 | $data = json_decode($response->getContent(), true);
33 | $this->assertArrayHasKey('articles', $data);
34 | $this->assertArrayHasKey('articlesCount', $data);
35 | $this->assertSame($expectedCount, $data['articlesCount']);
36 | }
37 |
38 | public function provideAsAuthenticatedCases(): iterable
39 | {
40 | yield ['user1@conduit.tld', 23];
41 | yield ['user2@conduit.tld', 1];
42 | yield ['user3@conduit.tld', 0];
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Controller/Article/UpdateArticleController.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
29 | $this->formFactory = $formFactory;
30 | }
31 |
32 | public function __invoke(Request $request, Article $article): array
33 | {
34 | $form = $this->formFactory->createNamed('article', ArticleType::class, $article);
35 | $form->submit($request->request->get('article'), false);
36 |
37 | if ($form->isValid()) {
38 | $this->entityManager->flush();
39 |
40 | return ['article' => $article];
41 | }
42 |
43 | return ['form' => $form];
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/config/bundles.php:
--------------------------------------------------------------------------------
1 | ['all' => true],
5 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
6 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
7 | Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true],
8 | Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
9 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
10 | Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
11 | FOS\RestBundle\FOSRestBundle::class => ['all' => true],
12 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
13 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
14 | Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
15 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
16 | DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
17 | Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
18 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true],
19 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
20 | Nelmio\Alice\Bridge\Symfony\NelmioAliceBundle::class => ['dev' => true, 'test' => true],
21 | Fidry\AliceDataFixtures\Bridge\Symfony\FidryAliceDataFixturesBundle::class => ['dev' => true, 'test' => true],
22 | ];
23 |
--------------------------------------------------------------------------------
/fixtures/data.yaml:
--------------------------------------------------------------------------------
1 | App\Entity\Tag:
2 | tag_1:
3 | name: 'lorem'
4 | tag_2:
5 | name: 'ipsum'
6 |
7 | App\Entity\User:
8 | user_1:
9 | username: 'user1'
10 | email: 'user1@conduit.tld'
11 | password: 'password'
12 | image: 'https://randomuser.me/api/portraits/men/41.jpg'
13 | bio: ''
14 | followers: ''
15 | favorites: ''
16 | user_2:
17 | username: 'user2'
18 | email: 'user2@conduit.tld'
19 | password: 'password'
20 | user_3:
21 | username: 'user3'
22 | email: 'user3@conduit.tld'
23 | password: 'password'
24 | followers: ''
25 |
26 | App\Entity\Article:
27 | article_1:
28 | title: 'Article #1'
29 | description: 'Description #1'
30 | body: 'Body #1'
31 | author: '@user_1'
32 | tags: ''
33 | article_2:
34 | title: 'Article #2'
35 | description: 'Description #2'
36 | body: 'Body #2'
37 | author: '@user_2'
38 | article_{3..25}:
39 | title: 'Article #'
40 | description: 'Description #'
41 | body: 'Body #'
42 | author: '@user_3'
43 | tags: ''
44 |
45 | App\Entity\Comment:
46 | comment_1:
47 | body: 'Comment #1'
48 | article: '@article_1'
49 | author: '@user_2'
50 | comment_2:
51 | body: 'Comment #2'
52 | article: '@article_2'
53 | author: '@user_1'
54 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 
2 |
3 | > ### Symfony codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API.
4 |
5 | [](https://scrutinizer-ci.com/g/slashfan/symfony-realworld-example-app/?branch=master)
6 | [](https://scrutinizer-ci.com/g/slashfan/symfony-realworld-example-app/?branch=master)
7 | [](https://scrutinizer-ci.com/g/slashfan/symfony-realworld-example-app/build-status/master)
8 |
9 | This codebase was created to demonstrate a fully fledged fullstack application built with **Symfony** including CRUD operations, authentication, routing, pagination, and more.
10 |
11 | We've gone to great lengths to adhere to the **Symfony** community styleguides & best practices.
12 |
13 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo.
14 |
15 | # Getting started
16 |
17 | ```bash
18 | $ git clone https://github.com/slashfan/symfony-realworld-example-app
19 | $ cd symfony-realworld-example-app
20 | ```
21 |
22 | # Run project (with docker)
23 |
24 | On first run :
25 |
26 | ```bash
27 | $ make install
28 | ```
29 |
30 | On next runs :
31 |
32 | ```bash
33 | $ make start
34 | ```
35 |
36 | # Run phpunit tests + api spec compliance tests + qa tools (with docker)
37 |
38 | ```bash
39 | $ make ci
40 | $ make specs
41 | ```
42 |
--------------------------------------------------------------------------------
/src/Controller/Article/GetArticlesFeedController.php:
--------------------------------------------------------------------------------
1 | userResolver = $userResolver;
31 | $this->articleRepository = $repository;
32 | }
33 |
34 | public function __invoke(ParamFetcher $paramFetcher): array
35 | {
36 | $user = $this->userResolver->getCurrentUser();
37 |
38 | $offset = (int) $paramFetcher->get('offset');
39 | $limit = (int) $paramFetcher->get('limit');
40 |
41 | $articlesCount = $this->articleRepository->getArticlesFeedCount($user);
42 | $articles = $this->articleRepository->getArticlesFeed($user, $offset, $limit);
43 |
44 | return [
45 | 'articlesCount' => $articlesCount,
46 | 'articles' => $articles,
47 | ];
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Controller/User/UpdateUserController.php:
--------------------------------------------------------------------------------
1 | userResolver = $userResolver;
36 | $this->formFactory = $formFactory;
37 | $this->entityManager = $entityManager;
38 | }
39 |
40 | public function __invoke(Request $request): array
41 | {
42 | $user = $this->userResolver->getCurrentUser();
43 |
44 | $form = $this->formFactory->createNamed('user', UserType::class, $user);
45 | $form->submit($request->request->get('user'), false);
46 |
47 | if ($form->isValid()) {
48 | $this->entityManager->flush();
49 |
50 | return ['user' => $user];
51 | }
52 |
53 | return ['form' => $form];
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/phpstan.neon.dist:
--------------------------------------------------------------------------------
1 | includes:
2 | - vendor/phpstan/phpstan-deprecation-rules/rules.neon
3 | - vendor/phpstan/phpstan-doctrine/extension.neon
4 | - vendor/phpstan/phpstan-symfony/extension.neon
5 | - vendor/phpstan/phpstan-phpunit/extension.neon
6 | - vendor/phpstan/phpstan-phpunit/rules.neon
7 | - vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon
8 | - vendor/slam/phpstan-extensions/conf/slam-rules.neon
9 | - vendor/symplify/phpstan-rules/config/services/services.neon
10 | - vendor/symplify/phpstan-rules/config/symfony-rules.neon
11 | - vendor/symplify/phpstan-rules/config/test-rules.neon
12 |
13 | parameters:
14 | level: 8
15 | paths:
16 | - src
17 | - tests
18 | excludes_analyse:
19 | - '%rootDir%/../../../src/Kernel.php'
20 | symfony:
21 | container_xml_path: '%currentWorkingDirectory%/var/cache/dev/App_KernelDevDebugContainer.xml'
22 | ignoreErrors:
23 | - '/Parameter #1 \$json of function json_decode expects string, string\|false given\./'
24 | - '/Parameter #6 \$content of method Symfony\\Component\\BrowserKit\\AbstractBrowser::request\(\) expects string\|null, string\|false given\./'
25 | - '/Cannot call method getId\(\) on App\\Entity\\User\|null\./'
26 | - '/Call to an undefined method Symfony\\Component\\Form\\FormError\|Symfony\\Component\\Form\\FormErrorIterator::getMessage\(\)./'
27 | - '/Parameter #1 \$submittedData of method Symfony\\Component\\Form\\FormInterface::submit\(\) expects array\|string\|null, bool\|float\|int\|string\|null given\./'
28 | - '/Parameter #2 \$plainPassword of method Symfony\\Component\\PasswordHasher\\Hasher\\UserPasswordHasherInterface::hashPassword\(\) expects string, string\|null given\./'
29 | reportUnmatchedIgnoredErrors: true
30 | checkMissingIterableValueType: false
31 | checkGenericClassInNonGenericObjectType: false
32 | tmpDir: var
33 |
--------------------------------------------------------------------------------
/.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=dev
18 | APP_SECRET=71896918a3dffccb9e15a8307b6a9652
19 | ###< symfony/framework-bundle ###
20 |
21 | ###> doctrine/doctrine-bundle ###
22 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
23 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
24 | #
25 | # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
26 | # DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7"
27 | # DATABASE_URL="postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=13&charset=utf8"
28 | DATABASE_URL=mysql://project:project@mysql:3306/project
29 | ###< doctrine/doctrine-bundle ###
30 |
31 | ###> lexik/jwt-authentication-bundle ###
32 | # Key paths should be relative to the project directory
33 | JWT_PRIVATE_KEY_PATH=config/jwt/private.pem
34 | JWT_PUBLIC_KEY_PATH=config/jwt/public.pem
35 | JWT_PASSPHRASE=passphrase
36 | ###< lexik/jwt-authentication-bundle ###
37 |
--------------------------------------------------------------------------------
/src/Entity/Comment.php:
--------------------------------------------------------------------------------
1 | body);
45 | }
46 |
47 | public function getId(): ?int
48 | {
49 | return $this->id;
50 | }
51 |
52 | public function getBody(): ?string
53 | {
54 | return $this->body;
55 | }
56 |
57 | public function setBody(?string $body): void
58 | {
59 | $this->body = $body;
60 | }
61 |
62 | public function getAuthor(): ?User
63 | {
64 | return $this->author;
65 | }
66 |
67 | public function setAuthor(?User $author): void
68 | {
69 | $this->author = $author;
70 | }
71 |
72 | public function getArticle(): ?Article
73 | {
74 | return $this->article;
75 | }
76 |
77 | public function setArticle(?Article $article): void
78 | {
79 | $this->article = $article;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/Controller/Article/GetArticlesListController.php:
--------------------------------------------------------------------------------
1 | articleRepository = $repository;
29 | }
30 |
31 | public function __invoke(ParamFetcher $paramFetcher): array
32 | {
33 | $articlesCount = $this->articleRepository->getArticlesListCount(
34 | $paramFetcher->get('tag'),
35 | $paramFetcher->get('author'),
36 | $paramFetcher->get('favorited')
37 | );
38 |
39 | $articles = $this->articleRepository->getArticlesList(
40 | (int) $paramFetcher->get('offset'),
41 | (int) $paramFetcher->get('limit'),
42 | $paramFetcher->get('tag'),
43 | $paramFetcher->get('author'),
44 | $paramFetcher->get('favorited')
45 | );
46 |
47 | return [
48 | 'articlesCount' => $articlesCount,
49 | 'articles' => $articles,
50 | ];
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Controller/Registration/RegisterController.php:
--------------------------------------------------------------------------------
1 | formFactory = $formFactory;
34 | $this->userPasswordHasher = $userPasswordHasher;
35 | $this->entityManager = $entityManager;
36 | }
37 |
38 | public function __invoke(Request $request): array
39 | {
40 | $user = new User();
41 |
42 | $form = $this->formFactory->createNamed('user', UserType::class, $user);
43 | $form->submit($request->request->get('user'));
44 |
45 | if ($form->isValid()) {
46 | $user->setPassword($this->userPasswordHasher->hashPassword($user, $user->getPassword()));
47 | $this->entityManager->persist($user);
48 | $this->entityManager->flush();
49 |
50 | return ['user' => $user];
51 | }
52 |
53 | return ['form' => $form];
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Controller/Article/CreateArticleController.php:
--------------------------------------------------------------------------------
1 | userResolver = $userResolver;
37 | $this->formFactory = $formFactory;
38 | $this->entityManager = $entityManager;
39 | }
40 |
41 | public function __invoke(Request $request): array
42 | {
43 | $user = $this->userResolver->getCurrentUser();
44 |
45 | $article = new Article();
46 | $article->setAuthor($user);
47 |
48 | $form = $this->formFactory->createNamed('article', ArticleType::class, $article);
49 | $form->submit($request->request->get('article'));
50 |
51 | if ($form->isValid()) {
52 | $this->entityManager->persist($article);
53 | $this->entityManager->flush();
54 |
55 | return ['article' => $article];
56 | }
57 |
58 | return ['form' => $form];
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Controller/Comment/CreateCommentController.php:
--------------------------------------------------------------------------------
1 | userResolver = $userResolver;
38 | $this->formFactory = $formFactory;
39 | $this->entityManager = $entityManager;
40 | }
41 |
42 | public function __invoke(Request $request, Article $article): array
43 | {
44 | $user = $this->userResolver->getCurrentUser();
45 |
46 | $comment = new Comment();
47 | $comment->setAuthor($user);
48 | $comment->setArticle($article);
49 |
50 | $form = $this->formFactory->createNamed('comment', CommentType::class, $comment);
51 | $form->submit($request->request->get('comment'));
52 |
53 | if ($form->isValid()) {
54 | $this->entityManager->persist($comment);
55 | $this->entityManager->flush();
56 |
57 | return ['comment' => $comment];
58 | }
59 |
60 | return ['form' => $form];
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Serializer/Normalizer/FormErrorNormalizer.php:
--------------------------------------------------------------------------------
1 | $context['status_code'] ?? null,
22 | 'message' => 'Validation Failed',
23 | 'errors' => $this->convertFormToArray($object),
24 | ];
25 |
26 | if (!\is_array($data)) {
27 | throw new \RuntimeException('Normalized form data should be of type array.');
28 | }
29 |
30 | /** @var array $data */
31 | $data = $data['errors']['children'];
32 | $data = array_filter($data, fn (array $child) => isset($child['errors']) && \count($child['errors']) > 0);
33 |
34 | return array_map(fn (array $child) => $child['errors'] ?? [], $data);
35 | }
36 |
37 | /**
38 | * {@inheritdoc}
39 | */
40 | public function supportsNormalization($data, string $format = null): bool
41 | {
42 | return $data instanceof FormInterface && $data->isSubmitted() && !$data->isValid();
43 | }
44 |
45 | private function convertFormToArray(FormInterface $data): array
46 | {
47 | $errors = [];
48 | $form = [];
49 |
50 | foreach ($data->getErrors() as $error) {
51 | $errors[] = $error->getMessage();
52 | }
53 |
54 | if ($errors !== []) {
55 | $form['errors'] = $errors;
56 | }
57 |
58 | $children = [];
59 | foreach ($data->all() as $child) {
60 | if ($child instanceof FormInterface) {
61 | $children[$child->getName()] = $this->convertFormToArray($child);
62 | }
63 | }
64 |
65 | if ($children !== []) {
66 | $form['children'] = $children;
67 | }
68 |
69 | return $form;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/tests/Security/UserResolverTest.php:
--------------------------------------------------------------------------------
1 | expectException(\Exception::class);
22 |
23 | $storage = $this->getMockBuilder(TokenStorageInterface::class)->getMock();
24 | $storage->method('getToken')->willReturn(null);
25 |
26 | /** @var TokenStorageInterface $storage */
27 | $resolver = new UserResolver($storage);
28 | $resolver->getCurrentUser();
29 | }
30 |
31 | /**
32 | * @throws \Exception
33 | */
34 | public function testGetCurrentUserWithNonAppUser(): void
35 | {
36 | $this->expectException(\Exception::class);
37 |
38 | $user = $this->getMockBuilder(UserInterface::class)->getMock();
39 |
40 | $token = $this->getMockBuilder(TokenInterface::class)->getMock();
41 | $token->method('getUser')->willReturn($user);
42 |
43 | $storage = $this->getMockBuilder(TokenStorageInterface::class)->getMock();
44 | $storage->method('getToken')->willReturn($token);
45 |
46 | /** @var TokenStorageInterface $storage */
47 | $resolver = new UserResolver($storage);
48 | $resolver->getCurrentUser();
49 | }
50 |
51 | /**
52 | * @throws \Exception
53 | */
54 | public function testGetCurrentUserWithAppUser(): void
55 | {
56 | $user = $this->getMockBuilder(User::class)->getMock();
57 |
58 | $token = $this->getMockBuilder(TokenInterface::class)->getMock();
59 | $token->method('getUser')->willReturn($user);
60 |
61 | $storage = $this->getMockBuilder(TokenStorageInterface::class)->getMock();
62 | $storage->method('getToken')->willReturn($token);
63 |
64 | /** @var TokenStorageInterface $storage */
65 | $resolver = new UserResolver($storage);
66 | $this->assertSame($user, $resolver->getCurrentUser());
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/Serializer/Normalizer/UserNormalizer.php:
--------------------------------------------------------------------------------
1 | userResolver = $userResolver;
27 | $this->jwtManager = $jwtManager;
28 | }
29 |
30 | /**
31 | * {@inheritdoc}
32 | */
33 | public function normalize($object, string $format = null, array $context = []): array
34 | {
35 | /* @var User $object */
36 |
37 | $data = [
38 | 'username' => $object->getUsername(),
39 | 'image' => $object->getImage() ?: 'https://static.productionready.io/images/smiley-cyrus.jpg',
40 | 'bio' => $object->getBio(),
41 | ];
42 |
43 | if (isset($context['groups']) && \in_array('me', $context['groups'], true)) {
44 | $data['email'] = $object->getEmail();
45 | $data['token'] = $this->jwtManager->create($object);
46 | } else {
47 | try {
48 | $user = $this->userResolver->getCurrentUser();
49 | $data['following'] = $user->follows($object);
50 | } catch (NoCurrentUserException $exception) {
51 | $data['following'] = false;
52 | }
53 | }
54 |
55 | return $data;
56 | }
57 |
58 | /**
59 | * {@inheritdoc}
60 | */
61 | public function supportsNormalization($data, string $format = null): bool
62 | {
63 | return $data instanceof User;
64 | }
65 |
66 | public function hasCacheableSupportsMethod(): bool
67 | {
68 | return true;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Serializer/Normalizer/ArticleNormalizer.php:
--------------------------------------------------------------------------------
1 | tokenStorage = $tokenStorage;
25 | }
26 |
27 | /**
28 | * {@inheritdoc}
29 | */
30 | public function normalize($object, string $format = null, array $context = []): array
31 | {
32 | /* @var Article $object */
33 |
34 | $data = [
35 | 'slug' => $object->getSlug(),
36 | 'title' => $object->getTitle(),
37 | 'description' => $object->getDescription(),
38 | 'body' => $object->getBody(),
39 | 'tagList' => array_map(fn (Tag $tag) => $this->normalizer->normalize($tag), $object->getTags()->toArray()),
40 | 'createdAt' => $this->normalizer->normalize($object->getCreatedAt()),
41 | 'updatedAt' => $this->normalizer->normalize($object->getCreatedAt()),
42 | 'favorited' => false,
43 | 'favoritesCount' => $object->getFavoritedByCount(),
44 | 'author' => $this->normalizer->normalize($object->getAuthor()),
45 | ];
46 |
47 | $token = $this->tokenStorage->getToken();
48 | $user = $token !== null ? $token->getUser() : null;
49 |
50 | if ($user instanceof User) {
51 | $data['favorited'] = $user->hasFavorite($object);
52 | }
53 |
54 | return $data;
55 | }
56 |
57 | /**
58 | * {@inheritdoc}
59 | */
60 | public function supportsNormalization($data, string $format = null): bool
61 | {
62 | return $data instanceof Article;
63 | }
64 |
65 | public function hasCacheableSupportsMethod(): bool
66 | {
67 | return true;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tests/Controller/User/UpdateUserControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('PUT', '/api/user');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | public function testEmptyRequestAsAuthenticated(): void
22 | {
23 | $client = $this->createAuthenticatedApiClient();
24 | $client->request('PUT', '/api/user');
25 |
26 | $response = $client->getResponse();
27 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
28 | }
29 |
30 | public function testBadRequestAsAuthenticated(): void
31 | {
32 | $client = $this->createAuthenticatedApiClient();
33 | $client->request('PUT', '/api/user', [], [], [], json_encode([
34 | 'user' => [
35 | 'email' => '',
36 | ],
37 | ]));
38 |
39 | $response = $client->getResponse();
40 | $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
41 | $this->assertSame(
42 | [
43 | 'email' => ['can\'t be blank'],
44 | ],
45 | json_decode($response->getContent(), true)
46 | );
47 | }
48 |
49 | public function testAsAuthenticated(): void
50 | {
51 | $client = $this->createAuthenticatedApiClient();
52 | $client->request('PUT', '/api/user', [], [], [], json_encode([
53 | 'user' => [
54 | 'email' => 'user1001@conduit.tld',
55 | 'username' => 'user1001',
56 | 'password' => 'password',
57 | 'image' => 'http://user1001.tld',
58 | 'bio' => 'Bio #1001',
59 | ],
60 | ]));
61 |
62 | $response = $client->getResponse();
63 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
64 |
65 | $data = json_decode($response->getContent(), true);
66 | $this->assertArrayHasKey('user', $data);
67 | $this->assertSame('user1001@conduit.tld', $data['user']['email']);
68 | $this->assertSame('user1001', $data['user']['username']);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/tests/Controller/Registration/RegisterControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 |
16 | $client->request('POST', '/api/users');
17 | $response = $client->getResponse();
18 |
19 | $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
20 | $this->assertSame(
21 | [
22 | 'username' => ['can\'t be blank'],
23 | 'email' => ['can\'t be blank'],
24 | 'password' => ['can\'t be blank'],
25 | ],
26 | json_decode($response->getContent(), true)
27 | );
28 |
29 | $client->request('POST', '/api/users', [], [], [], json_encode([
30 | 'user' => [
31 | 'username' => 'user1',
32 | 'email' => 'user1@conduit.tld',
33 | 'password' => 'pass',
34 | ],
35 | ]));
36 | $response = $client->getResponse();
37 |
38 | $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
39 | $this->assertSame(
40 | [
41 | 'username' => ['has already been taken'],
42 | 'email' => ['has already been taken'],
43 | 'password' => ['is too short (minimum is 8 characters)'],
44 | ],
45 | json_decode($response->getContent(), true)
46 | );
47 | }
48 |
49 | public function testResponse(): void
50 | {
51 | $client = $this->createAnonymousApiClient();
52 | $client->request('POST', '/api/users', [], [], [], json_encode([
53 | 'user' => [
54 | 'username' => 'user1000',
55 | 'email' => 'user1000@conduit.tld',
56 | 'password' => 'password',
57 | ],
58 | ]));
59 |
60 | $response = $client->getResponse();
61 | $this->assertSame(Response::HTTP_CREATED, $response->getStatusCode());
62 |
63 | $data = json_decode($response->getContent(), true);
64 | $this->assertArrayHasKey('user', $data);
65 | $this->assertArrayHasKey('email', $data['user']);
66 | $this->assertSame('user1000@conduit.tld', $data['user']['email']);
67 | $this->assertArrayHasKey('token', $data['user']);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tests/Controller/Article/UpdateArticleControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('PUT', '/api/articles/article-2');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | public function testAsNotOwner(): void
22 | {
23 | $client = $this->createAuthenticatedApiClient();
24 | $client->request('PUT', '/api/articles/article-2');
25 |
26 | $response = $client->getResponse();
27 | $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode());
28 | }
29 |
30 | public function testBadRequestAsOwner(): void
31 | {
32 | $client = $this->createAuthenticatedApiClient();
33 | $client->request('PUT', '/api/articles/article-1', [], [], [], json_encode([
34 | 'article' => [
35 | 'title' => '',
36 | ],
37 | ]));
38 |
39 | $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $client->getResponse()->getStatusCode());
40 | $this->assertSame(
41 | [
42 | 'title' => ['can\'t be blank'],
43 | ],
44 | json_decode($client->getResponse()->getContent(), true)
45 | );
46 | }
47 |
48 | public function testAsOwner(): void
49 | {
50 | $client = $this->createAuthenticatedApiClient();
51 | $client->request('PUT', '/api/articles/article-1', [], [], [], json_encode([
52 | 'article' => [
53 | 'title' => 'Article #1B',
54 | 'description' => 'Description #1B',
55 | ],
56 | ]));
57 |
58 | $response = $client->getResponse();
59 | $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
60 |
61 | $data = json_decode($response->getContent(), true);
62 | $this->assertArrayHasKey('article', $data);
63 | $this->assertArrayHasKey('title', $data['article']);
64 | $this->assertSame('Article #1B', $data['article']['title']);
65 | $this->assertArrayHasKey('description', $data['article']);
66 | $this->assertSame('Description #1B', $data['article']['description']);
67 | $this->assertArrayHasKey('body', $data['article']);
68 | $this->assertSame('Body #1', $data['article']['body']);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/tests/Controller/Comment/CreateCommentControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('POST', '/api/articles/article-2/comments');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | public function testEmptyRequestAsAuthenticated(): void
22 | {
23 | $client = $this->createAuthenticatedApiClient();
24 | $client->request('POST', '/api/articles/article-2/comments');
25 |
26 | $response = $client->getResponse();
27 | $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
28 | $this->assertSame(
29 | [
30 | 'body' => ['can\'t be blank'],
31 | ],
32 | json_decode($response->getContent(), true)
33 | );
34 | }
35 |
36 | public function testInvalidRequestAsAuthenticated(): void
37 | {
38 | $client = $this->createAuthenticatedApiClient();
39 | $client->request('POST', '/api/articles/article-2/comments', [], [], [], json_encode([
40 | 'comment' => [
41 | 'body' => '',
42 | ],
43 | ]));
44 |
45 | $response = $client->getResponse();
46 | $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
47 | $this->assertSame(
48 | [
49 | 'body' => ['can\'t be blank'],
50 | ],
51 | json_decode($response->getContent(), true)
52 | );
53 | }
54 |
55 | public function testValidRequestAsAuthenticated(): void
56 | {
57 | $client = $this->createAuthenticatedApiClient();
58 | $client->request('POST', '/api/articles/article-2/comments', [], [], [], json_encode([
59 | 'comment' => [
60 | 'body' => 'Comment #3 on article #2 by user #1',
61 | ],
62 | ]));
63 |
64 | $response = $client->getResponse();
65 | $this->assertSame(Response::HTTP_CREATED, $response->getStatusCode());
66 |
67 | $data = json_decode($response->getContent(), true);
68 | $this->assertArrayHasKey('comment', $data);
69 | $this->assertArrayHasKey('body', $data['comment']);
70 | $this->assertSame('Comment #3 on article #2 by user #1', $data['comment']['body']);
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/tests/Controller/Article/CreateArticleControllerTest.php:
--------------------------------------------------------------------------------
1 | createAnonymousApiClient();
15 | $client->request('POST', '/api/articles');
16 |
17 | $response = $client->getResponse();
18 | $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
19 | }
20 |
21 | public function testBadRequestAsAuthenticated(): void
22 | {
23 | $data = [
24 | 'article' => [
25 | 'title' => '',
26 | 'description' => '',
27 | ],
28 | ];
29 |
30 | $client = $this->createAuthenticatedApiClient();
31 | $client->request('POST', '/api/articles', [], [], [], json_encode($data));
32 |
33 | $response = $client->getResponse();
34 | $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
35 | $this->assertSame(
36 | [
37 | 'title' => ['can\'t be blank'],
38 | 'description' => ['can\'t be blank'],
39 | 'body' => ['can\'t be blank'],
40 | ],
41 | json_decode($client->getResponse()->getContent(), true)
42 | );
43 | }
44 |
45 | public function testAsAuthenticated(): void
46 | {
47 | $client = $this->createAuthenticatedApiClient();
48 | $client->request('POST', '/api/articles', [], [], [], json_encode([
49 | 'article' => [
50 | 'title' => 'Article #3',
51 | 'description' => 'Description #3',
52 | 'body' => 'Body #3',
53 | 'tagList' => [
54 | ' lorem',
55 | 'ipsum',
56 | 'dolor',
57 | ],
58 | ],
59 | ]));
60 |
61 | $response = $client->getResponse();
62 | $this->assertSame(Response::HTTP_CREATED, $response->getStatusCode());
63 |
64 | $data = json_decode($response->getContent(), true);
65 | $this->assertArrayHasKey('article', $data);
66 | $this->assertArrayHasKey('title', $data['article']);
67 | $this->assertArrayHasKey('description', $data['article']);
68 | $this->assertArrayHasKey('body', $data['article']);
69 | $this->assertArrayHasKey('tagList', $data['article']);
70 | $this->assertSame('Article #3', $data['article']['title']);
71 | $this->assertSame('Description #3', $data['article']['description']);
72 | $this->assertSame('Body #3', $data['article']['body']);
73 | $this->assertCount(3, $data['article']['tagList']);
74 | $this->assertContains('lorem', $data['article']['tagList']);
75 | $this->assertContains('ipsum', $data['article']['tagList']);
76 | $this->assertContains('dolor', $data['article']['tagList']);
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/config/jwt/test/private.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN RSA PRIVATE KEY-----
2 | Proc-Type: 4,ENCRYPTED
3 | DEK-Info: AES-256-CBC,145DB8BED38507BF94A2A3067977FD75
4 |
5 | uN2D9RMSS1k3OuHx2t1vOr7DpOQexKmybWpYltN3CFwpGshderGdiAw6gDK0WbOb
6 | NDL89I60KWl6jXn5P4STv0phOxVflPxRqtg4AFodZ3jSxmWJm+oBpdC/4th4vcnW
7 | XZfNhHO2p7ULnXyUv3FGkLZoyTse7v41y0SP9JocQPrWa4dOAoHIRox5mgZdGRat
8 | nizLuznkAoMWBfRlD8V+cFdE78uZANcF5O3H5Vm6IFuFPuegpUhqiWTy918D7ezR
9 | 7FkY8k6FSvplafeE3QmCxceIm8ilyzyhwBcuXCDBx1sKnowi0QSq8on1rrYdOuY/
10 | aJewUqPoqYbtOASWHZ9DO0StFjdiGZREL26Z1Bz7E4MGoLsECCZsIzt/f2APfexv
11 | XtOtwwyveiyw2Cjw1unuyPlfb7a9rD+qolNrk2+Xn+7JcrrzRSy/BSvAuZlH2Hzt
12 | 72rhMoyf5bUbrYpu6l8nadKVWIcgQx2CKFSYW8RCC2K0uqCRfALEZL6Gg0jPvlkx
13 | zsEGBK12PMjrkSf0HIDbupQ9QkAZOAzwJTVhlVj/kgjazOlxK0jbMDqhazzlhFDX
14 | Qy+JwbsNoNx3WLiimyuuOybxd8gdDHubO+B3KTUlLKh5ABFGJ38x+7Zbj8MHsDmf
15 | zpofqT97FAXs6In6exWYOccu0AJSTUXeX2cw14FTG7k3m2iEtyzJwlch9haSimru
16 | syEBBsqYfy2VoOKVadUpSWuKtp+mt4djrA9BUARviedkZGQDwH+BVFUHUxnhMpKn
17 | NYNgiSMV/DdqVf6plpMrykLOjTShZFJ4rFVklK2u0QD22BoZ3crEt6DUEzttiHez
18 | DawPJMTtKVq+7x7I3Ryi+mFW/am5U+wHHs2uvWO00tcXwS9sdCaiXzUUng/WwbgV
19 | Nq2xAjQH6AD2k/mUViYYEnRdzdt2FGVlN3RpWPibFaLUfjKMOO8POTaVGdS5JXJA
20 | rrfTRc//7tcREa0MTwgxR0rE/lu6J+8VTxwolwZZoCIsAbSk+soOWL1C+odxAGQm
21 | 99fCQETiyWXIHMZIo4UH2O9NCxwgRAyXhq+sh8aRa8RMA0nDtSjUYA72PwICzxcY
22 | SktTt3PK+3wR4i/v5zdSduKH+9VSjS48dLOPQ7Hhc02SrhQQDf/McbzDK88/iW1m
23 | 9xGFE52K2FFvV+KUVbazt72QYDxsDKElJSLRGDPD8kmv7ngHyY6yN9GeajMs/3Su
24 | M3nF5+MhgVcNC4HGuxYP0eDv/qgJzZ6ROYcmcS5kaYRLYrjeSaOBPXsYeljEPtgf
25 | 4sAbfqg5QjLNYAn/wlE7gC+l9nmt/9rbxzeGzLWb+xdQTztup+dHg5KRZY9uBvxT
26 | Ol8gVnUgknzS1Z/qqO+jHUUhVE/A7IBgjiI6R6QLMQv3P5T9Y9S9M0QcCxl35aZC
27 | u8GH/7/EQpRlXIDuaq6x5zkRLHytb4Ks4LSbU6X7vJUmknROUT+4NDXyRlcAunDw
28 | JewrmEEquLTpyrm9LTVcD1iUbolNKlX0Z8hcpbmkvAzasYWW6XIwf4Nk47CyWCQd
29 | KjNzb2r/oHFl3DItKracd7104WRHt8DJbkB5EG5dQXNx3XcVTXCfgFGtsWNV73+9
30 | nRuJwg95SnKakyhYoC4w/aid7pLvcp9+szM4zMRzU6fEqyWC0NmPJz9VaUJGuXMn
31 | UtEt3ZPR0etKA1HBiO2m8v6wDvwvOsWwzbCmh6s2EldCh63fHWthZrje/EsDJllP
32 | mN2qfJmQ9ylb5z/dhh4pNQEjrp9TpzSCgrZ+WnujYktR2Hwd7Jvicw4pGV87Jm3F
33 | zk/mJYzVq1B1cQYkjqulkQLNRAn562w49HsoAie5UYq4Ti6b2TyAj70u8/XApFiq
34 | tcUMA0/kC2gwEjBRDx37jNMtHSXhN0beaspFPBBsAxwHnulWf84i+uTX+tHZK1fe
35 | q7p24BOT40buKrM4GEMCPtqaxMO0z3NkFecU70SfghJOuxs/VZP1N/xrCoCyIB9L
36 | +KW91M4s7eaKde5flyRarjv6Z9SzKMRCX++vb9QwzKfHNknwQe8rfhQq99C8x4/h
37 | B5bNVzjF1x3j208gVGe/CL/xRYohPkZ7XTkVbV9tz6++JOq6ucvMsz2wR5csH4iV
38 | x4zMQXW3R8qGmiyrRtKsmaqTf5vifUMtP1rJQxc/Nj5DiTDrzmgDreg0XBkXtH+C
39 | W6s87sbSaJRekBqnenfHlCLuOOhtF5K+6O65Z1suQYU7ogjoUMjB368gQRNA7Kpx
40 | BYlS+Fr4/l37nuQUFKRF8QxViZB5bqsROAZ9uSITGlPF3e1/FshAISIdsVOEOV6k
41 | r5+hY0QLtvQti5sFJlOyalI/Dzx4S4exb2+8lXjLtNorIhs9Yrt6CpLs7kaoIcTm
42 | t5DPGiFTHBSwCG4ZtORITiirPX8fyf4360GYhiniygDjM5L+c4k6ZL5ul/ZVqXT5
43 | ndMsZSL6ApVpQ19AzYUdclwLBMElDE4EoGmxb+e7PUET0AUvcR1QsldGAai/TvzP
44 | 40W9o2zuteZxAKNFMjlo+GrKEf+Sp4YtiiwSf56nbfyv5JubqroQmI9axdKzfQab
45 | BGIvqlLaU+VRi2mk0sMUQ5zvBfjaAaAuZww28rXuBhW/CguoYX8JtcI/ZGwOE8ux
46 | Rt9+WfNbcn9PntdfH9WXJLUmrR0dLbPrzxOYJN28r2ZiXoqhVmW2RKSht2H2SRDe
47 | rq5y50Id+1YIiB1QMsUPiT/scPCPjn+1frP3G+pGR2jGZ3qFL7DmxPGI2L9qmFiA
48 | +aaamClJqvUpZ9g0DwExvaVwy+vM6uaDHDRr6LtB1hBiGwj+BLCYtkf0esyApmvM
49 | vjMM8AMieChl17hi1PqJD+tvEhaNaY95hscV4EWygaM1VkGpGUUmL0eEpAsG91ZY
50 | WwUziKsLOOr/t2XPu3zkb7dC2fBTfkF6dmV/+0q9cMoJ+utTRiRoVZKo7tP7lcYX
51 | T0wPv5J/kmyojW/J8hpZ6Rfvf6oqmMaFe0PFF9YsWqvui0I0aFDgS3py5tSWu8QC
52 | Xep5eF4tT6h9ZIxAJdEgVt7ogVPgBw8zCXwh8YJqKVVABalzGg6RpV8WTjz8nZCf
53 | bsuJKIS7wiS7j4GEY6j5MKWkK+yt5Z+guJsSPgThM9j5jjO3iWDYAFumqkQFsZ8O
54 | -----END RSA PRIVATE KEY-----
55 |
--------------------------------------------------------------------------------
/.github/workflows/specs.yml:
--------------------------------------------------------------------------------
1 | name: SPECS
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | env:
10 | SYMFONY_ENV: 'dev'
11 | DATABASE_URL: 'sqlite:///%kernel.project_dir%/var/specs.db'
12 |
13 | jobs:
14 |
15 | build:
16 |
17 | runs-on: ubuntu-latest
18 |
19 | steps:
20 |
21 | # —— Setup GitHub actions —————————————————————————————————————————————
22 |
23 | # https://github.com/actions/checkout (official)
24 | -
25 | name: Checkout
26 | uses: actions/checkout@v2
27 |
28 | # —— Setup Environment —————————————————————————————————————————————
29 |
30 | # https://github.com/shivammathur/setup-php (community)
31 | -
32 | name: Setup PHP, extensions and composer with shivammathur/setup-php
33 | uses: shivammathur/setup-php@v2
34 | with:
35 | php-version: '7.4'
36 | extensions: mbstring, intl, sqlite, json, simplexml
37 | tools: composer:v2, symfony
38 | ini-values: date.timezone=Europe/Paris
39 | coverage: none
40 | env:
41 | update: true
42 |
43 | # https://github.com/actions/setup-node (community)
44 |
45 | -
46 | name: Setup Node.js with actions/setup-node
47 | uses: actions/setup-node@v2
48 | with:
49 | node-version: 16
50 |
51 | # —— Composer —————————————————————————————————————————————————————————
52 |
53 | -
54 | name: Get composer cache directory
55 | id: composer-cache
56 | run: echo "::set-output name=dir::$(composer config cache-files-dir)"
57 |
58 | -
59 | name: Cache composer dependencies
60 | uses: actions/cache@v2
61 | with:
62 | path: ${{ steps.composer-cache.outputs.dir }}
63 | key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
64 | restore-keys: ${{ runner.os }}-composer-
65 |
66 | -
67 | name: Install Composer dependencies
68 | run: composer install --no-progress --ansi --prefer-dist --optimize-autoloader --no-interaction
69 |
70 | ## —— Prepare Environment ——————————————————————————————————————————————
71 |
72 | -
73 | name: Generate RSA keys needed for authentication
74 | run: |
75 | openssl genrsa -out config/jwt/private.pem -aes256 -passout pass:passphrase 4096
76 | openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem -passin pass:passphrase
77 |
78 | -
79 | name: Prepare database
80 | run: |
81 | bin/console doctrine:database:create
82 | bin/console doctrine:schema:create
83 |
84 | -
85 | name: Symfony warmup
86 | run: |
87 | bin/console cache:clear --env=dev
88 |
89 | ## —— Run SPECS ——————————————————————————————————————————————
90 |
91 | -
92 | name: Launch PHP server
93 | run: symfony local:server:start --no-tls -d
94 |
95 | -
96 | name: Run specs tests
97 | run: APIURL=http://127.0.0.1:8000/api ./spec/api-spec-test-runner.sh
98 |
99 | -
100 | name: Stop PHP server
101 | run: symfony local:server:stop
102 |
--------------------------------------------------------------------------------
/src/Repository/ArticleRepository.php:
--------------------------------------------------------------------------------
1 | getArticlesListQueryBuilder($tag, $authorUsername, $favoritedByUsername)
27 | ->select('count(a.id) as total')
28 | ->getQuery()
29 | ->getSingleScalarResult()
30 | ;
31 | } catch (NonUniqueResultException | NoResultException $exception) {
32 | return 0;
33 | }
34 | }
35 |
36 | /**
37 | * @return Article[]
38 | */
39 | public function getArticlesList(
40 | int $offset,
41 | int $limit,
42 | ?string $tag,
43 | ?string $authorUsername,
44 | ?string $favoritedByUsername
45 | ): array {
46 | return $this
47 | ->getArticlesListQueryBuilder($tag, $authorUsername, $favoritedByUsername)
48 | ->setFirstResult($offset)
49 | ->setMaxResults($limit)
50 | ->getQuery()
51 | ->getResult()
52 | ;
53 | }
54 |
55 | public function getArticlesFeedCount(User $user): int
56 | {
57 | try {
58 | return (int) $this
59 | ->getArticlesFeedQueryBuilder($user)
60 | ->select('count(a.id) as total')
61 | ->getQuery()
62 | ->getSingleScalarResult()
63 | ;
64 | } catch (NonUniqueResultException | NoResultException $exception) {
65 | return 0;
66 | }
67 | }
68 |
69 | /**
70 | * @return Article[]
71 | */
72 | public function getArticlesFeed(User $user, int $offset, int $limit): array
73 | {
74 | return $this
75 | ->getArticlesFeedQueryBuilder($user)
76 | ->setFirstResult($offset)
77 | ->setMaxResults($limit)
78 | ->getQuery()
79 | ->getResult()
80 | ;
81 | }
82 |
83 | private function getArticlesListQueryBuilder(
84 | ?string $tag,
85 | ?string $authorUsername,
86 | ?string $favoritedByUsername
87 | ): QueryBuilder {
88 | $queryBuilder = $this
89 | ->createQueryBuilder('a')
90 | ->innerJoin('a.author', 'author')
91 | ->orderBy('a.id', 'desc')
92 | ;
93 |
94 | if ($tag) {
95 | $queryBuilder->innerJoin('a.tags', 't');
96 | $queryBuilder->andWhere('t.name = :tag');
97 | $queryBuilder->setParameter('tag', $tag);
98 | }
99 |
100 | if ($authorUsername) {
101 | $queryBuilder->andWhere('author.username = :author_username');
102 | $queryBuilder->setParameter('author_username', $authorUsername);
103 | }
104 |
105 | if ($favoritedByUsername) {
106 | $queryBuilder->innerJoin('a.favoritedBy', 'favoritedBy');
107 | $queryBuilder->andWhere('favoritedBy.username = :favoritedby_username');
108 | $queryBuilder->setParameter('favoritedby_username', $favoritedByUsername);
109 | }
110 |
111 | return $queryBuilder;
112 | }
113 |
114 | private function getArticlesFeedQueryBuilder(User $user): QueryBuilder
115 | {
116 | return $this
117 | ->createQueryBuilder('a')
118 | ->innerJoin('a.author', 'author')
119 | ->andWhere('author IN (:authors_ids)')
120 | ->setParameter('authors_ids', $user->getFolloweds())
121 | ->orderBy('a.id', 'desc')
122 | ;
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/Entity/Article.php:
--------------------------------------------------------------------------------
1 | tags = new ArrayCollection();
79 | $this->favoritedBy = new ArrayCollection();
80 | }
81 |
82 | public function __toString(): string
83 | {
84 | return sprintf('%s', $this->title);
85 | }
86 |
87 | public function getId(): ?int
88 | {
89 | return $this->id;
90 | }
91 |
92 | public function getTitle(): ?string
93 | {
94 | return $this->title;
95 | }
96 |
97 | public function setTitle(?string $title): void
98 | {
99 | $this->title = $title;
100 | }
101 |
102 | public function getSlug(): ?string
103 | {
104 | return $this->slug;
105 | }
106 |
107 | public function setSlug(?string $slug): void
108 | {
109 | $this->slug = $slug;
110 | }
111 |
112 | public function getDescription(): ?string
113 | {
114 | return $this->description;
115 | }
116 |
117 | public function setDescription(?string $description): void
118 | {
119 | $this->description = $description;
120 | }
121 |
122 | public function getBody(): ?string
123 | {
124 | return $this->body;
125 | }
126 |
127 | public function setBody(?string $body): void
128 | {
129 | $this->body = $body;
130 | }
131 |
132 | public function getAuthor(): ?User
133 | {
134 | return $this->author;
135 | }
136 |
137 | public function setAuthor(?User $author): void
138 | {
139 | $this->author = $author;
140 | }
141 |
142 | /**
143 | * @return Collection|Tag[]
144 | */
145 | public function getTags(): Collection
146 | {
147 | return $this->tags;
148 | }
149 |
150 | /**
151 | * @param Collection|Tag[] $tags
152 | */
153 | public function setTags(Collection $tags): void
154 | {
155 | $this->tags = $tags;
156 | }
157 |
158 | /**
159 | * @return Collection|User[]
160 | */
161 | public function getFavoritedBy(): Collection
162 | {
163 | return $this->favoritedBy;
164 | }
165 |
166 | public function getFavoritedByCount(): int
167 | {
168 | return $this->favoritedBy->count();
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "slashfan/symfony-realworld-example-app",
3 | "description": "Exemplary real world application built with Symfony",
4 | "license": "MIT",
5 | "type": "project",
6 | "require": {
7 | "php": "^7.4",
8 | "ext-ctype": "*",
9 | "ext-iconv": "*",
10 | "ext-json": "*",
11 | "composer/package-versions-deprecated": "1.11.99.2",
12 | "doctrine/annotations": "^1.0",
13 | "doctrine/doctrine-bundle": "^2.4",
14 | "doctrine/doctrine-migrations-bundle": "^3.1",
15 | "doctrine/orm": "^2.9",
16 | "friendsofsymfony/rest-bundle": "3.1.*",
17 | "lexik/jwt-authentication-bundle": "^2.12",
18 | "nelmio/cors-bundle": "^2.1",
19 | "nesbot/carbon": "^2.50",
20 | "sensio/framework-extra-bundle": "^6.2",
21 | "stof/doctrine-extensions-bundle": "^1.6",
22 | "symfony/cache": "5.4.*",
23 | "symfony/console": "5.4.*",
24 | "symfony/dotenv": "5.4.*",
25 | "symfony/expression-language": "5.4.*",
26 | "symfony/flex": "^1.13",
27 | "symfony/form": "5.4.*",
28 | "symfony/framework-bundle": "5.4.*",
29 | "symfony/monolog-bundle": "^3.7",
30 | "symfony/property-access": "5.4.*",
31 | "symfony/property-info": "5.4.*",
32 | "symfony/runtime": "5.4.*",
33 | "symfony/security-bundle": "5.4.*",
34 | "symfony/serializer": "5.4.*",
35 | "symfony/translation": "5.4.*",
36 | "symfony/twig-bundle": "5.4.*",
37 | "symfony/validator": "5.4.*",
38 | "symfony/yaml": "5.4.*"
39 | },
40 | "require-dev": {
41 | "dama/doctrine-test-bundle": "^6.6",
42 | "doctrine/doctrine-fixtures-bundle": "^3.4",
43 | "ergebnis/composer-normalize": "^2.15",
44 | "fakerphp/faker": "^1.15",
45 | "friendsofphp/php-cs-fixer": "^3.0",
46 | "kubawerlos/php-cs-fixer-custom-fixers": "^3.7",
47 | "nelmio/alice": "^3.8",
48 | "phpmd/phpmd": "2.6.*",
49 | "phpstan/phpstan": "^0.12",
50 | "phpstan/phpstan-deprecation-rules": "^0.12",
51 | "phpstan/phpstan-doctrine": "^0.12",
52 | "phpstan/phpstan-phpunit": "^0.12",
53 | "phpstan/phpstan-symfony": "^0.12",
54 | "phpunit/phpunit": "^9.5",
55 | "psalm/plugin-symfony": "^2.4",
56 | "pyrech/composer-changelogs": "^1.7",
57 | "rector/rector": "^0.11",
58 | "slam/phpstan-extensions": "^5.1",
59 | "squizlabs/php_codesniffer": "^3.6",
60 | "symfony/browser-kit": "5.4.*",
61 | "symfony/css-selector": "5.4.*",
62 | "symfony/debug-bundle": "5.4.*",
63 | "symfony/maker-bundle": "^1.33",
64 | "symfony/phpunit-bridge": "5.4.*",
65 | "symfony/stopwatch": "5.4.*",
66 | "symfony/var-dumper": "5.4.*",
67 | "symfony/web-profiler-bundle": "5.4.*",
68 | "symplify/phpstan-rules": "^9.3",
69 | "thecodingmachine/phpstan-strict-rules": "^0.12",
70 | "theofidry/alice-data-fixtures": "^1.4",
71 | "vimeo/psalm": "^4.9"
72 | },
73 | "replace": {
74 | "paragonie/random_compat": "2.*",
75 | "symfony/polyfill-ctype": "*",
76 | "symfony/polyfill-iconv": "*",
77 | "symfony/polyfill-php56": "*",
78 | "symfony/polyfill-php70": "*",
79 | "symfony/polyfill-php71": "*",
80 | "symfony/polyfill-php72": "*",
81 | "symfony/polyfill-php73": "*"
82 | },
83 | "autoload": {
84 | "psr-4": {
85 | "App\\": "src/"
86 | }
87 | },
88 | "autoload-dev": {
89 | "psr-4": {
90 | "App\\Tests\\": "tests/"
91 | }
92 | },
93 | "config": {
94 | "allow-plugins": {
95 | "composer/package-versions-deprecated": true,
96 | "ergebnis/composer-normalize": true,
97 | "pyrech/composer-changelogs": true,
98 | "symfony/flex": true,
99 | "symfony/runtime": true
100 | },
101 | "optimize-autoloader": true,
102 | "preferred-install": {
103 | "*": "dist"
104 | },
105 | "sort-packages": true
106 | },
107 | "extra": {
108 | "symfony": {
109 | "allow-contrib": true,
110 | "require": "5.4.*"
111 | }
112 | },
113 | "scripts": {
114 | "post-install-cmd": [
115 | "@auto-scripts"
116 | ],
117 | "post-update-cmd": [
118 | "@auto-scripts"
119 | ],
120 | "auto-scripts": {
121 | "cache:clear": "symfony-cmd",
122 | "assets:install %PUBLIC_DIR%": "symfony-cmd"
123 | }
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 | in(__DIR__)
7 | ->exclude('bin')
8 | ->exclude('config')
9 | ->exclude('docker')
10 | ->exclude('fixtures')
11 | ->exclude('templates')
12 | ->exclude('translations')
13 | ->exclude('var')
14 | ->notPath('public/index.php')
15 | ;
16 |
17 | return (new PhpCsFixer\Config())
18 | ->setRiskyAllowed(true)
19 | ->registerCustomFixers(new PhpCsFixerCustomFixers\Fixers())
20 | ->setRules([
21 | '@Symfony' => true,
22 | '@Symfony:risky' => true,
23 | 'doctrine_annotation_array_assignment' => ['operator' => '='],
24 | 'doctrine_annotation_braces' => ['syntax' => 'with_braces'],
25 | 'align_multiline_comment' => true,
26 | 'array_indentation' => true,
27 | 'array_syntax' => ['syntax' => 'short'],
28 | 'class_definition' => ['single_line' => false],
29 | 'class_reference_name_casing' => true,
30 | 'combine_consecutive_issets' => true,
31 | 'combine_consecutive_unsets' => true,
32 | 'compact_nullable_typehint' => true,
33 | 'concat_space' => ['spacing' => 'one'],
34 | 'declare_strict_types' => true,
35 | 'echo_tag_syntax' => ['format' => 'long'],
36 | 'general_phpdoc_annotation_remove' => ['annotations' => ['author']],
37 | 'linebreak_after_opening_tag' => true,
38 | 'mb_str_functions' => true,
39 | 'multiline_comment_opening_closing' => true,
40 | 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'],
41 | 'no_alternative_syntax' => true,
42 | 'no_null_property_initialization' => true,
43 | 'no_php4_constructor' => true,
44 | 'no_superfluous_elseif' => true,
45 | 'no_superfluous_phpdoc_tags' => true,
46 | 'no_unreachable_default_argument_value' => true,
47 | 'no_unset_on_property' => true,
48 | 'no_useless_else' => true,
49 | 'no_useless_return' => true,
50 | 'no_useless_sprintf' => true,
51 | 'ordered_class_elements' => true,
52 | 'ordered_imports' => true,
53 | 'php_unit_dedicate_assert' => true,
54 | 'php_unit_strict' => true,
55 | 'php_unit_set_up_tear_down_visibility' => true,
56 | 'phpdoc_add_missing_param_annotation' => false,
57 | 'phpdoc_order' => true,
58 | 'phpdoc_trim_consecutive_blank_line_separation' => true,
59 | 'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'],
60 | 'pow_to_exponentiation' => true,
61 | 'random_api_migration' => true,
62 | 'return_assignment' => true,
63 | 'self_accessor' => false,
64 | 'semicolon_after_instruction' => true,
65 | 'simplified_null_return' => true,
66 | 'single_line_throw' => true,
67 | 'strict_comparison' => true,
68 | 'strict_param' => true,
69 | 'string_line_ending' => true,
70 | 'ternary_to_null_coalescing' => true,
71 | 'types_spaces' => false,
72 | 'void_return' => true,
73 | 'yoda_style' => [
74 | 'equal' => false,
75 | 'identical' => false,
76 | 'less_and_greater' => false,
77 | ],
78 | PhpCsFixerCustomFixers\Fixer\CommentSurroundedBySpacesFixer::name() => true,
79 | PhpCsFixerCustomFixers\Fixer\DataProviderNameFixer::name() => true,
80 | PhpCsFixerCustomFixers\Fixer\DataProviderReturnTypeFixer::name() => true,
81 | PhpCsFixerCustomFixers\Fixer\NoDuplicatedImportsFixer::name() => true,
82 | PhpCsFixerCustomFixers\Fixer\MultilineCommentOpeningClosingAloneFixer::name() => true,
83 | PhpCsFixerCustomFixers\Fixer\NoCommentedOutCodeFixer::name() => true,
84 | PhpCsFixerCustomFixers\Fixer\NoDoctrineMigrationsGeneratedCommentFixer::name() => true,
85 | PhpCsFixerCustomFixers\Fixer\NoImportFromGlobalNamespaceFixer::name() => true,
86 | PhpCsFixerCustomFixers\Fixer\NoLeadingSlashInGlobalNamespaceFixer::name() => true,
87 | PhpCsFixerCustomFixers\Fixer\NoPhpStormGeneratedCommentFixer::name() => true,
88 | PhpCsFixerCustomFixers\Fixer\NoReferenceInFunctionDefinitionFixer::name() => true,
89 | PhpCsFixerCustomFixers\Fixer\NoSuperfluousConcatenationFixer::name() => true,
90 | PhpCsFixerCustomFixers\Fixer\NoUselessCommentFixer::name() => true,
91 | PhpCsFixerCustomFixers\Fixer\NoUselessDoctrineRepositoryCommentFixer::name() => true,
92 | PhpCsFixerCustomFixers\Fixer\PhpdocNoSuperfluousParamFixer::name() => true,
93 | PhpCsFixerCustomFixers\Fixer\PhpdocParamOrderFixer::name() => true,
94 | PhpCsFixerCustomFixers\Fixer\PhpdocParamTypeFixer::name() => true,
95 | PhpCsFixerCustomFixers\Fixer\PhpUnitNoUselessReturnFixer::name() => true,
96 | PhpCsFixerCustomFixers\Fixer\SingleSpaceAfterStatementFixer::name() => true,
97 | PhpCsFixerCustomFixers\Fixer\SingleSpaceBeforeStatementFixer::name() => true,
98 | ])
99 | ->setFinder($finder)
100 | ->setCacheFile(__DIR__.'/var/.php_cs.cache')
101 | ;
102 |
--------------------------------------------------------------------------------
/migrations/Version20180326200407.php:
--------------------------------------------------------------------------------
1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
15 |
16 | $this->addSql('CREATE TABLE rw_comment (id INT AUTO_INCREMENT NOT NULL, author_id INT DEFAULT NULL, article_id INT DEFAULT NULL, body LONGTEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, INDEX IDX_EAFFB7F675F31B (author_id), INDEX IDX_EAFFB77294869C (article_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
17 | $this->addSql('CREATE TABLE rw_article (id INT AUTO_INCREMENT NOT NULL, author_id INT DEFAULT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, description LONGTEXT NOT NULL, body LONGTEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_96A4A3BD989D9B62 (slug), INDEX IDX_96A4A3BDF675F31B (author_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
18 | $this->addSql('CREATE TABLE rw_article_tag (article_id INT NOT NULL, tag_id INT NOT NULL, INDEX IDX_A9C3949B7294869C (article_id), INDEX IDX_A9C3949BBAD26311 (tag_id), PRIMARY KEY(article_id, tag_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
19 | $this->addSql('CREATE TABLE rw_user (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, username VARCHAR(255) NOT NULL, bio LONGTEXT DEFAULT NULL, image LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_E628724AE7927C74 (email), UNIQUE INDEX UNIQ_E628724AF85E0677 (username), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
20 | $this->addSql('CREATE TABLE rw_user_follower (user_id INT NOT NULL, follower_id INT NOT NULL, INDEX IDX_E90DC36A76ED395 (user_id), INDEX IDX_E90DC36AC24F853 (follower_id), PRIMARY KEY(user_id, follower_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
21 | $this->addSql('CREATE TABLE rw_user_favorite (user_id INT NOT NULL, article_id INT NOT NULL, INDEX IDX_DF835BA9A76ED395 (user_id), INDEX IDX_DF835BA97294869C (article_id), PRIMARY KEY(user_id, article_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
22 | $this->addSql('CREATE TABLE rw_tag (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_D37C40D35E237E06 (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
23 | $this->addSql('ALTER TABLE rw_comment ADD CONSTRAINT FK_EAFFB7F675F31B FOREIGN KEY (author_id) REFERENCES rw_user (id)');
24 | $this->addSql('ALTER TABLE rw_comment ADD CONSTRAINT FK_EAFFB77294869C FOREIGN KEY (article_id) REFERENCES rw_article (id)');
25 | $this->addSql('ALTER TABLE rw_article ADD CONSTRAINT FK_96A4A3BDF675F31B FOREIGN KEY (author_id) REFERENCES rw_user (id)');
26 | $this->addSql('ALTER TABLE rw_article_tag ADD CONSTRAINT FK_A9C3949B7294869C FOREIGN KEY (article_id) REFERENCES rw_article (id) ON DELETE CASCADE');
27 | $this->addSql('ALTER TABLE rw_article_tag ADD CONSTRAINT FK_A9C3949BBAD26311 FOREIGN KEY (tag_id) REFERENCES rw_tag (id) ON DELETE CASCADE');
28 | $this->addSql('ALTER TABLE rw_user_follower ADD CONSTRAINT FK_E90DC36A76ED395 FOREIGN KEY (user_id) REFERENCES rw_user (id)');
29 | $this->addSql('ALTER TABLE rw_user_follower ADD CONSTRAINT FK_E90DC36AC24F853 FOREIGN KEY (follower_id) REFERENCES rw_user (id)');
30 | $this->addSql('ALTER TABLE rw_user_favorite ADD CONSTRAINT FK_DF835BA9A76ED395 FOREIGN KEY (user_id) REFERENCES rw_user (id) ON DELETE CASCADE');
31 | $this->addSql('ALTER TABLE rw_user_favorite ADD CONSTRAINT FK_DF835BA97294869C FOREIGN KEY (article_id) REFERENCES rw_article (id) ON DELETE CASCADE');
32 | }
33 |
34 | public function down(Schema $schema): void
35 | {
36 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
37 |
38 | $this->addSql('ALTER TABLE rw_comment DROP FOREIGN KEY FK_EAFFB77294869C');
39 | $this->addSql('ALTER TABLE rw_article_tag DROP FOREIGN KEY FK_A9C3949B7294869C');
40 | $this->addSql('ALTER TABLE rw_user_favorite DROP FOREIGN KEY FK_DF835BA97294869C');
41 | $this->addSql('ALTER TABLE rw_comment DROP FOREIGN KEY FK_EAFFB7F675F31B');
42 | $this->addSql('ALTER TABLE rw_article DROP FOREIGN KEY FK_96A4A3BDF675F31B');
43 | $this->addSql('ALTER TABLE rw_user_follower DROP FOREIGN KEY FK_E90DC36A76ED395');
44 | $this->addSql('ALTER TABLE rw_user_follower DROP FOREIGN KEY FK_E90DC36AC24F853');
45 | $this->addSql('ALTER TABLE rw_user_favorite DROP FOREIGN KEY FK_DF835BA9A76ED395');
46 | $this->addSql('ALTER TABLE rw_article_tag DROP FOREIGN KEY FK_A9C3949BBAD26311');
47 | $this->addSql('DROP TABLE rw_comment');
48 | $this->addSql('DROP TABLE rw_article');
49 | $this->addSql('DROP TABLE rw_article_tag');
50 | $this->addSql('DROP TABLE rw_user');
51 | $this->addSql('DROP TABLE rw_user_follower');
52 | $this->addSql('DROP TABLE rw_user_favorite');
53 | $this->addSql('DROP TABLE rw_tag');
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | env:
10 | SYMFONY_ENV: 'test'
11 |
12 | jobs:
13 |
14 | build:
15 |
16 | runs-on: ubuntu-latest
17 |
18 | steps:
19 |
20 | # —— Setup GitHub actions —————————————————————————————————————————————
21 |
22 | # https://github.com/actions/checkout (official)
23 | -
24 | name: Checkout
25 | uses: actions/checkout@v2
26 |
27 | # —— Setup Environment —————————————————————————————————————————————
28 |
29 | # https://github.com/shivammathur/setup-php (community)
30 | -
31 | name: Setup PHP, extensions and composer with shivammathur/setup-php
32 | uses: shivammathur/setup-php@v2
33 | with:
34 | php-version: '7.4'
35 | extensions: mbstring, intl, sqlite, json, simplexml
36 | tools: composer:v2, symfony
37 | ini-values: date.timezone=Europe/Paris
38 | coverage: none
39 | env:
40 | update: true
41 |
42 | # —— Composer —————————————————————————————————————————————————————————
43 |
44 | -
45 | name: Get composer cache directory
46 | id: composer-cache
47 | run: echo "::set-output name=dir::$(composer config cache-files-dir)"
48 |
49 | -
50 | name: Cache composer dependencies
51 | uses: actions/cache@v2
52 | with:
53 | path: ${{ steps.composer-cache.outputs.dir }}
54 | key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
55 | restore-keys: ${{ runner.os }}-composer-
56 |
57 | -
58 | name: Install Composer dependencies
59 | run: composer install --no-progress --ansi --prefer-dist --optimize-autoloader --no-interaction
60 |
61 | ## —— Security checks ————————————————————————————————————————————————
62 |
63 | -
64 | name: Security checks
65 | run: |
66 | symfony security:check --no-interaction
67 |
68 | ## —— Composer checks ————————————————————————————————————————————————
69 |
70 | -
71 | name: Composer checks
72 | run: |
73 | composer validate
74 | composer normalize --dry-run
75 |
76 | # —— Symfony checks ——————————————————————————————————————————————————————————
77 |
78 | -
79 | name: Symfony warmup
80 | run: |
81 | bin/console cache:clear --env=test
82 | bin/console cache:clear --env=dev
83 | bin/console cache:clear --env=prod
84 |
85 | -
86 | name: Symfony checks
87 | run: |
88 | bin/console lint:yaml config/ --parse-tags --env=dev
89 | bin/console lint:yaml fixtures/ --env=dev
90 | bin/console lint:yaml translations/ --env=dev
91 | bin/console lint:container --env=dev
92 | bin/console lint:container --env=prod
93 |
94 | -
95 | name: Doctrine checks
96 | run: |
97 | bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction --env=dev
98 |
99 | ## —— Static Code Analysis checks ————————————————————————————————————————————————
100 |
101 | -
102 | name: Cache PHP-CS-FIXER
103 | uses: actions/cache@v2
104 | env:
105 | cache-name: cache-php-cs-fixer
106 | with:
107 | path: var/.php_cs.cache
108 | key: ${{ env.cache-name }}
109 |
110 | -
111 | name: Cache PHP_CodeSniffer
112 | uses: actions/cache@v2
113 | env:
114 | cache-name: cache-php-cs
115 | with:
116 | path: var/.phpcs-cache
117 | key: ${{ env.cache-name }}
118 |
119 | -
120 | name: Cache PHPStan
121 | uses: actions/cache@v2
122 | env:
123 | cache-name: cache-phpstan
124 | with:
125 | path: var/resultCache.php
126 | key: ${{ env.cache-name }}
127 |
128 | -
129 | name: Cache Rector
130 | uses: actions/cache@v2
131 | env:
132 | cache-name: cache-rector
133 | with:
134 | path: var/rector
135 | key: ${{ env.cache-name }}
136 |
137 | -
138 | name: Cache Psalm
139 | uses: actions/cache@v2
140 | env:
141 | cache-name: cache-psalm
142 | with:
143 | path: var/psalm
144 | key: ${{ env.cache-name }}
145 |
146 | -
147 | name: Run PHP-CS-FIXER (dry-run)
148 | run: |
149 | vendor/bin/php-cs-fixer fix --verbose --dry-run
150 |
151 | -
152 | name: Run PHP_CodeSniffer
153 | run: |
154 | vendor/bin/phpcs -p
155 |
156 | -
157 | name: Run PHPStan
158 | run: |
159 | vendor/bin/phpstan analyse
160 |
161 | -
162 | name: Run Psalm
163 | run: |
164 | vendor/bin/psalm
165 |
166 | -
167 | name: Run PHPMD
168 | run: |
169 | vendor/bin/phpmd src/,tests/ text phpmd.xml.dist
170 |
171 | -
172 | name: Run Rector (dry-run)
173 | run: |
174 | vendor/bin/rector process --dry-run
175 |
176 | ## —— Tests ———————————————————————————————————————————————————————————
177 |
178 | -
179 | name: Run functionnal and unit tests
180 | run: |
181 | vendor/bin/phpunit
182 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | DOCKER_COMPOSE = docker-compose
2 | EXEC = $(DOCKER_COMPOSE) exec
3 | EXEC_PHP = $(DOCKER_COMPOSE) exec php
4 | EXEC_NODE = $(DOCKER_COMPOSE) exec node
5 | SYMFONY = $(EXEC_PHP) bin/console
6 | COMPOSER = $(EXEC_PHP) composer
7 |
8 | ##
9 | ## Project
10 | ## -------
11 | ##
12 |
13 | build:
14 | @$(DOCKER_COMPOSE) pull --parallel --quiet --ignore-pull-failures 2> /dev/null
15 | $(DOCKER_COMPOSE) build --pull
16 |
17 | kill:
18 | $(DOCKER_COMPOSE) kill
19 | $(DOCKER_COMPOSE) down --volumes --remove-orphans
20 |
21 | install: ## Install and start the project
22 | install: docker-compose.override.yml build start vendor rsa-keys db
23 |
24 | reset: ## Stop and start a fresh install of the project
25 | reset: kill install
26 |
27 | start: ## Start the project
28 | $(DOCKER_COMPOSE) up -d --remove-orphans --no-recreate
29 |
30 | stop: ## Stop the project
31 | $(DOCKER_COMPOSE) stop
32 |
33 | clean: ## Stop the project and remove generated files
34 | clean: kill
35 | rm -rf docker-compose.override.yml config/jwt/*.pem vendor
36 |
37 | no-docker:
38 | $(eval DOCKER_COMPOSE := \#)
39 | $(eval EXEC_PHP := )
40 | $(eval EXEC_JS := )
41 |
42 | .PHONY: build kill install reset start stop clean no-docker
43 |
44 | ##
45 | ## Utils
46 | ## -----
47 | ##
48 |
49 | wait-for-db:
50 | $(EXEC_PHP) php -r "set_time_limit(60);for(;;){if(@fsockopen('mysql',3306)){break;}echo \"Waiting for MySQL\n\";sleep(1);}"
51 |
52 | db: ## Reset the database and load fixtures
53 | db: vendor wait-for-db
54 | $(SYMFONY) doctrine:database:drop --if-exists --force
55 | $(SYMFONY) doctrine:database:create --if-not-exists
56 | $(SYMFONY) doctrine:migrations:migrate --no-interaction --allow-no-migration
57 | $(SYMFONY) doctrine:fixtures:load --no-interaction
58 |
59 | migration: ## Generate a new doctrine migration
60 | migration: vendor
61 | $(SYMFONY) doctrine:migrations:diff
62 |
63 | db-validate-schema: ## Validate the doctrine ORM mapping
64 | db-validate-schema: vendor
65 | $(SYMFONY) doctrine:schema:validate
66 |
67 | .PHONY: db migration watch
68 |
69 | # rules based on files
70 | #composer.lock: composer.json
71 | # $(COMPOSER) update --lock --no-scripts --no-interaction
72 |
73 | vendor: composer.lock
74 | $(COMPOSER) install
75 |
76 | docker-compose.override.yml: docker-compose.override.yml.dist
77 | @if [ -f docker-compose.override.yml ]; \
78 | then\
79 | echo '\033[1;41m/!\ The docker-compose.override.yml.dist file has changed. Please check your docker-compose.override.yml file (this message will not be displayed again).\033[0m';\
80 | touch docker-compose.override.yml;\
81 | exit 1;\
82 | else\
83 | echo cp docker-compose.override.yml.dist docker-compose.override.yml;\
84 | cp docker-compose.override.yml.dist docker-compose.override.yml;\
85 | fi
86 |
87 | rsa-keys: ## Generate RSA keys needed for JWT encoding / decoding
88 | rsa-keys:
89 | @if [ -f config/jwt/private.pem ]; \
90 | then\
91 | rm config/jwt/private.pem;\
92 | fi
93 | @if [ -f config/jwt/public.pem ]; \
94 | then\
95 | rm config/jwt/public.pem;\
96 | fi
97 | $(EXEC_PHP) openssl genrsa -out config/jwt/private.pem -aes256 -passout pass:passphrase 4096
98 | $(EXEC_PHP) openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem -passin pass:passphrase
99 |
100 | ##
101 | ## Quality assurance
102 | ## -----------------
103 | ##
104 |
105 | ci: ## Run all quality insurance checks (tests, code styles, linting, security, static analysis...)
106 | #ci: php-cs-fixer phpcs phpmd phpmnd phpstan psalm lint validate-composer validate-mapping security test test-coverage
107 | ci: php-cs-fixer phpcs phpmd phpstan rector.dry psalm lint validate-composer validate-mapping security test test-coverage
108 |
109 | ci.local: ## Run quality insurance checks from inside the php container
110 | ci.local: no-docker ci
111 |
112 | lint: ## Run lint check
113 | lint:
114 | $(SYMFONY) lint:yaml config/ --parse-tags
115 | $(SYMFONY) lint:yaml fixtures/
116 | $(SYMFONY) lint:yaml translations/
117 | $(SYMFONY) lint:container
118 |
119 | phpcs: ## Run phpcode_sniffer
120 | phpcs:
121 | $(EXEC_PHP) vendor/bin/phpcs
122 |
123 | php-cs-fixer: ## Run PHP-CS-FIXER
124 | php-cs-fixer:
125 | $(EXEC_PHP) vendor/bin/php-cs-fixer fix --verbose
126 |
127 | php-cs-fixer.dry-run: ## Run php-cs-fixer in dry-run mode
128 | php-cs-fixer.dry-run:
129 | $(EXEC_PHP) vendor/bin/php-cs-fixer fix --verbose --diff --dry-run
130 |
131 | phpmd: ## Run PHPMD
132 | phpmd:
133 | $(EXEC_PHP) vendor/bin/phpmd src/,tests/ text phpmd.xml.dist
134 |
135 | #phpmnd: ## Run PHPMND
136 | #phpmnd:
137 | # $(EXEC_PHP) vendor/bin/phpmnd src --extensions=default_parameter
138 |
139 | phpstan: ## Run PHPSTAN
140 | phpstan:
141 | $(EXEC_PHP) vendor/bin/phpstan analyse
142 |
143 | rector.dry: ## Dry-run rector
144 | rector.dry:
145 | $(EXEC_PHP) vendor/bin/rector process --dry-run
146 |
147 | rector: ## Run RECTOR
148 | rector:
149 | $(EXEC_PHP) vendor/bin/rector process
150 |
151 | psalm: ## Run PSALM
152 | psalm:
153 | $(EXEC_PHP) vendor/bin/psalm
154 |
155 | security: ## Run security-checker
156 | security:
157 | $(EXEC_PHP) symfony security:check --no-interaction
158 |
159 | test: ## Run phpunit tests
160 | test:
161 | $(EXEC_PHP) vendor/bin/phpunit
162 |
163 | test-coverage: ## Run phpunit tests with code coverage (phpdbg)
164 | test-coverage: test-coverage-pcov
165 |
166 | test-coverage-phpdbg: ## Run phpunit tests with code coverage (phpdbg)
167 | test-coverage-phpdbg:
168 | $(EXEC_PHP) phpdbg -qrr ./vendor/bin/phpunit --coverage-html=var/coverage
169 |
170 | test-coverage-pcov: ## Run phpunit tests with code coverage (pcov - uncomment extension in dockerfile)
171 | test-coverage-pcov:
172 | $(EXEC_PHP) vendor/bin/phpunit --coverage-html=var/coverage
173 |
174 | test-coverage-xdebug: ## Run phpunit tests with code coverage (xdebug - uncomment extension in dockerfile)
175 | test-coverage-xdebug:
176 | $(EXEC_PHP) vendor/bin/phpunit --coverage-html=var/coverage
177 |
178 | test-coverage-xdebug-filter: ## Run phpunit tests with code coverage (xdebug with filter - uncomment extension in dockerfile)
179 | test-coverage-xdebug-filter:
180 | $(EXEC_PHP) vendor/bin/phpunit --dump-xdebug-filter var/xdebug-filter.php
181 | $(EXEC_PHP) vendor/bin/phpunit --prepend var/xdebug-filter.php --coverage-html=var/coverage
182 |
183 | test-deprecations-log: ## Run phpunit default test suite with deprecations logging to external file
184 | test-deprecations-log:
185 | $(EXEC) php rm -f var/deprecations.log
186 | $(EXEC) --env SYMFONY_DEPRECATIONS_HELPER='logFile=var/deprecations.log' php vendor/bin/phpunit
187 |
188 | specs: ## Run postman collection tests
189 | specs:
190 | $(EXEC_NODE) ./spec/api-spec-test-runner.sh
191 |
192 | specs.local: ## Run postman collection tests
193 | specs.local:
194 | symfony local:server:start --no-tls -d
195 | APIURL=http://127.0.0.1:8000/api ./spec/api-spec-test-runner.sh
196 | symfony local:server:stop
197 |
198 | validate-composer: ## Validate composer.json and composer.lock
199 | validate-composer:
200 | $(EXEC_PHP) composer validate
201 | $(EXEC_PHP) composer normalize --dry-run
202 |
203 | validate-mapping: ## Validate doctrine mapping
204 | validate-mapping:
205 | $(SYMFONY) doctrine:schema:validate --skip-sync -vvv --no-interaction
206 |
207 | .DEFAULT_GOAL := help
208 | help:
209 | @grep -E '(^[a-zA-Z_-]+:.*?##.*$$)|(^##)' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}' | sed -e 's/\[32m##/[33m/'
210 | .PHONY: help
211 |
--------------------------------------------------------------------------------
/src/Entity/User.php:
--------------------------------------------------------------------------------
1 | followed = new ArrayCollection();
105 | $this->followers = new ArrayCollection();
106 | $this->favorites = new ArrayCollection();
107 | }
108 |
109 | public function __toString(): string
110 | {
111 | return sprintf('%s', $this->email);
112 | }
113 |
114 | public function getId(): ?int
115 | {
116 | return $this->id;
117 | }
118 |
119 | public function getEmail(): ?string
120 | {
121 | return $this->email;
122 | }
123 |
124 | public function setEmail(?string $email): void
125 | {
126 | $this->email = $email;
127 | }
128 |
129 | public function getPassword(): ?string
130 | {
131 | return $this->password;
132 | }
133 |
134 | public function setPassword(?string $password): void
135 | {
136 | $this->password = $password;
137 | }
138 |
139 | public function getUserIdentifier(): string
140 | {
141 | return $this->username ?? '';
142 | }
143 |
144 | public function getUsername(): string
145 | {
146 | return $this->getUserIdentifier();
147 | }
148 |
149 | public function setUsername(?string $username): void
150 | {
151 | $this->username = $username;
152 | }
153 |
154 | public function getBio(): ?string
155 | {
156 | return $this->bio;
157 | }
158 |
159 | public function setBio(?string $bio): void
160 | {
161 | $this->bio = $bio;
162 | }
163 |
164 | public function getImage(): ?string
165 | {
166 | return $this->image;
167 | }
168 |
169 | public function setImage(?string $image): void
170 | {
171 | $this->image = $image;
172 | }
173 |
174 | /**
175 | * @return string[]
176 | */
177 | public function getRoles(): array
178 | {
179 | return ['ROLE_USER'];
180 | }
181 |
182 | public function getSalt(): ?string
183 | {
184 | return null;
185 | }
186 |
187 | public function eraseCredentials(): void
188 | {
189 | }
190 |
191 | public function follows(User $user): bool
192 | {
193 | return $this->followed->contains($user);
194 | }
195 |
196 | public function follow(User $user): void
197 | {
198 | if ($user->getFollowers()->contains($this)) {
199 | return;
200 | }
201 |
202 | $user->getFollowers()->add($this);
203 | }
204 |
205 | public function unfollow(User $user): void
206 | {
207 | if (!$user->getFollowers()->contains($this)) {
208 | return;
209 | }
210 |
211 | $user->getFollowers()->removeElement($this);
212 | }
213 |
214 | /**
215 | * @return Collection|User[]
216 | */
217 | public function getFollowers(): Collection
218 | {
219 | return $this->followers;
220 | }
221 |
222 | /**
223 | * @param Collection|User[] $followers
224 | */
225 | public function setFollowers(Collection $followers): void
226 | {
227 | $this->followers = $followers;
228 | }
229 |
230 | /**
231 | * @return Collection|User[]
232 | */
233 | public function getFolloweds(): Collection
234 | {
235 | return $this->followed;
236 | }
237 |
238 | /**
239 | * @return Collection|Article[]
240 | */
241 | public function getFavorites(): Collection
242 | {
243 | return $this->favorites;
244 | }
245 |
246 | /**
247 | * @param Collection|Article[] $favorites
248 | */
249 | public function setFavorites(Collection $favorites): void
250 | {
251 | $this->favorites = $favorites;
252 | }
253 |
254 | public function hasFavorite(Article $article): bool
255 | {
256 | return $this->favorites->contains($article);
257 | }
258 |
259 | public function addToFavorites(Article $article): void
260 | {
261 | if ($this->favorites->contains($article)) {
262 | return;
263 | }
264 |
265 | $this->favorites->add($article);
266 | }
267 |
268 | public function removeFromFavorites(Article $article): void
269 | {
270 | if (!$this->favorites->contains($article)) {
271 | return;
272 | }
273 |
274 | $this->favorites->removeElement($article);
275 | }
276 | }
277 |
--------------------------------------------------------------------------------
/symfony.lock:
--------------------------------------------------------------------------------
1 | {
2 | "amphp/amp": {
3 | "version": "v2.4.1"
4 | },
5 | "amphp/byte-stream": {
6 | "version": "v1.7.2"
7 | },
8 | "behat/transliterator": {
9 | "version": "v1.2.0"
10 | },
11 | "composer/package-versions-deprecated": {
12 | "version": "1.11.99.2"
13 | },
14 | "composer/pcre": {
15 | "version": "1.0.0"
16 | },
17 | "composer/semver": {
18 | "version": "1.4.2"
19 | },
20 | "composer/xdebug-handler": {
21 | "version": "1.3.0"
22 | },
23 | "dama/doctrine-test-bundle": {
24 | "version": "4.0",
25 | "recipe": {
26 | "repo": "github.com/symfony/recipes-contrib",
27 | "branch": "master",
28 | "version": "4.0",
29 | "ref": "56eaa387b5e48ebcc7c95a893b47dfa1ad51449c"
30 | }
31 | },
32 | "danielstjules/stringy": {
33 | "version": "3.1.0"
34 | },
35 | "dnoegel/php-xdg-base-dir": {
36 | "version": "v0.1.1"
37 | },
38 | "doctrine/annotations": {
39 | "version": "1.0",
40 | "recipe": {
41 | "repo": "github.com/symfony/recipes",
42 | "branch": "master",
43 | "version": "1.0",
44 | "ref": "cb4152ebcadbe620ea2261da1a1c5a9b8cea7672"
45 | }
46 | },
47 | "doctrine/cache": {
48 | "version": "v1.7.1"
49 | },
50 | "doctrine/collections": {
51 | "version": "v1.5.0"
52 | },
53 | "doctrine/common": {
54 | "version": "v2.8.1"
55 | },
56 | "doctrine/data-fixtures": {
57 | "version": "v1.3.0"
58 | },
59 | "doctrine/dbal": {
60 | "version": "v2.6.3"
61 | },
62 | "doctrine/deprecations": {
63 | "version": "v0.5.3"
64 | },
65 | "doctrine/doctrine-bundle": {
66 | "version": "1.6",
67 | "recipe": {
68 | "repo": "github.com/symfony/recipes",
69 | "branch": "master",
70 | "version": "1.6",
71 | "ref": "c745b67e4dec2771d4d57a60efd224faf445c929"
72 | }
73 | },
74 | "doctrine/doctrine-fixtures-bundle": {
75 | "version": "3.0",
76 | "recipe": {
77 | "repo": "github.com/symfony/recipes",
78 | "branch": "master",
79 | "version": "3.0",
80 | "ref": "2ea6070ecf365f9a801ccaed4b31d4a3b7af5693"
81 | }
82 | },
83 | "doctrine/doctrine-migrations-bundle": {
84 | "version": "1.2",
85 | "recipe": {
86 | "repo": "github.com/symfony/recipes",
87 | "branch": "master",
88 | "version": "1.2",
89 | "ref": "c1431086fec31f17fbcfe6d6d7e92059458facc1"
90 | }
91 | },
92 | "doctrine/event-manager": {
93 | "version": "v1.0.0"
94 | },
95 | "doctrine/inflector": {
96 | "version": "v1.3.0"
97 | },
98 | "doctrine/instantiator": {
99 | "version": "1.1.0"
100 | },
101 | "doctrine/lexer": {
102 | "version": "v1.0.1"
103 | },
104 | "doctrine/migrations": {
105 | "version": "v1.6.2"
106 | },
107 | "doctrine/orm": {
108 | "version": "v2.6.1"
109 | },
110 | "doctrine/persistence": {
111 | "version": "v1.1.0"
112 | },
113 | "doctrine/sql-formatter": {
114 | "version": "1.0.1"
115 | },
116 | "ergebnis/composer-normalize": {
117 | "version": "2.5.1"
118 | },
119 | "ergebnis/json-normalizer": {
120 | "version": "0.12.0"
121 | },
122 | "ergebnis/json-printer": {
123 | "version": "3.0.2"
124 | },
125 | "ergebnis/json-schema-validator": {
126 | "version": "2.0.0"
127 | },
128 | "fakerphp/faker": {
129 | "version": "v1.14.1"
130 | },
131 | "felixfbecker/advanced-json-rpc": {
132 | "version": "v3.1.1"
133 | },
134 | "felixfbecker/language-server-protocol": {
135 | "version": "v1.4.0"
136 | },
137 | "friendsofphp/php-cs-fixer": {
138 | "version": "2.2",
139 | "recipe": {
140 | "repo": "github.com/symfony/recipes",
141 | "branch": "master",
142 | "version": "2.2",
143 | "ref": "81dee417d2cc60cd1c9d6208dff2ec22a1103e93"
144 | }
145 | },
146 | "friendsofphp/proxy-manager-lts": {
147 | "version": "v1.0.1"
148 | },
149 | "friendsofsymfony/rest-bundle": {
150 | "version": "2.2",
151 | "recipe": {
152 | "repo": "github.com/symfony/recipes-contrib",
153 | "branch": "master",
154 | "version": "2.2",
155 | "ref": "258300d52be6ad59b32a888d5ddafbf9638540ff"
156 | }
157 | },
158 | "gedmo/doctrine-extensions": {
159 | "version": "v2.4.33"
160 | },
161 | "justinrainbow/json-schema": {
162 | "version": "5.2.8"
163 | },
164 | "kubawerlos/php-cs-fixer-custom-fixers": {
165 | "version": "v1.13.0"
166 | },
167 | "laminas/laminas-code": {
168 | "version": "3.4.1"
169 | },
170 | "lcobucci/clock": {
171 | "version": "2.0.0"
172 | },
173 | "lcobucci/jwt": {
174 | "version": "3.2.4"
175 | },
176 | "lexik/jwt-authentication-bundle": {
177 | "version": "2.3",
178 | "recipe": {
179 | "repo": "github.com/symfony/recipes",
180 | "branch": "master",
181 | "version": "2.3",
182 | "ref": "a66e8a7b75a1825cf2414d5dd53c7ed38c8654d1"
183 | }
184 | },
185 | "localheinz/diff": {
186 | "version": "1.0.0"
187 | },
188 | "monolog/monolog": {
189 | "version": "1.23.0"
190 | },
191 | "myclabs/deep-copy": {
192 | "version": "1.7.0"
193 | },
194 | "namshi/jose": {
195 | "version": "7.2.3"
196 | },
197 | "nelmio/alice": {
198 | "version": "v3.2.2"
199 | },
200 | "nelmio/cors-bundle": {
201 | "version": "1.5",
202 | "recipe": {
203 | "repo": "github.com/symfony/recipes",
204 | "branch": "master",
205 | "version": "1.5",
206 | "ref": "7b6cbc842f8cd3d550815247d12294f6f304a8c4"
207 | }
208 | },
209 | "nesbot/carbon": {
210 | "version": "2.8.0"
211 | },
212 | "netresearch/jsonmapper": {
213 | "version": "v1.6.0"
214 | },
215 | "nette/neon": {
216 | "version": "v3.2.2"
217 | },
218 | "nette/utils": {
219 | "version": "v3.2.5"
220 | },
221 | "nikic/php-parser": {
222 | "version": "v4.3.0"
223 | },
224 | "openlss/lib-array2xml": {
225 | "version": "1.0.0"
226 | },
227 | "pdepend/pdepend": {
228 | "version": "2.5.2"
229 | },
230 | "phar-io/manifest": {
231 | "version": "1.0.1"
232 | },
233 | "phar-io/version": {
234 | "version": "1.0.1"
235 | },
236 | "php-cs-fixer/diff": {
237 | "version": "v1.3.0"
238 | },
239 | "phpdocumentor/reflection-common": {
240 | "version": "1.0.1"
241 | },
242 | "phpdocumentor/reflection-docblock": {
243 | "version": "4.3.0"
244 | },
245 | "phpdocumentor/type-resolver": {
246 | "version": "0.4.0"
247 | },
248 | "phpmd/phpmd": {
249 | "version": "2.6.0"
250 | },
251 | "phpspec/prophecy": {
252 | "version": "1.7.5"
253 | },
254 | "phpstan/phpdoc-parser": {
255 | "version": "0.5.7"
256 | },
257 | "phpstan/phpstan": {
258 | "version": "0.12.4"
259 | },
260 | "phpstan/phpstan-deprecation-rules": {
261 | "version": "0.11"
262 | },
263 | "phpstan/phpstan-doctrine": {
264 | "version": "0.11.1"
265 | },
266 | "phpstan/phpstan-phpunit": {
267 | "version": "0.11"
268 | },
269 | "phpstan/phpstan-symfony": {
270 | "version": "0.11"
271 | },
272 | "phpunit/php-code-coverage": {
273 | "version": "6.0.3"
274 | },
275 | "phpunit/php-file-iterator": {
276 | "version": "1.4.5"
277 | },
278 | "phpunit/php-invoker": {
279 | "version": "3.0.0"
280 | },
281 | "phpunit/php-text-template": {
282 | "version": "1.2.1"
283 | },
284 | "phpunit/php-timer": {
285 | "version": "2.0.0"
286 | },
287 | "phpunit/phpunit": {
288 | "version": "4.7",
289 | "recipe": {
290 | "repo": "github.com/symfony/recipes",
291 | "branch": "master",
292 | "version": "4.7",
293 | "ref": "c276fa48d4713de91eb410289b3b1834acb7e403"
294 | }
295 | },
296 | "psalm/plugin-symfony": {
297 | "version": "v1.1.3"
298 | },
299 | "psr/cache": {
300 | "version": "1.0.1"
301 | },
302 | "psr/container": {
303 | "version": "1.0.0"
304 | },
305 | "psr/event-dispatcher": {
306 | "version": "1.0.0"
307 | },
308 | "psr/log": {
309 | "version": "1.0.2"
310 | },
311 | "pyrech/composer-changelogs": {
312 | "version": "v1.6.0"
313 | },
314 | "rector/rector": {
315 | "version": "0.11.43"
316 | },
317 | "sebastian/cli-parser": {
318 | "version": "1.0.0"
319 | },
320 | "sebastian/code-unit": {
321 | "version": "1.0.2"
322 | },
323 | "sebastian/code-unit-reverse-lookup": {
324 | "version": "1.0.1"
325 | },
326 | "sebastian/comparator": {
327 | "version": "2.1.3"
328 | },
329 | "sebastian/complexity": {
330 | "version": "2.0.0"
331 | },
332 | "sebastian/diff": {
333 | "version": "3.0.0"
334 | },
335 | "sebastian/environment": {
336 | "version": "3.1.0"
337 | },
338 | "sebastian/exporter": {
339 | "version": "3.1.0"
340 | },
341 | "sebastian/global-state": {
342 | "version": "2.0.0"
343 | },
344 | "sebastian/lines-of-code": {
345 | "version": "1.0.0"
346 | },
347 | "sebastian/object-enumerator": {
348 | "version": "3.0.3"
349 | },
350 | "sebastian/object-reflector": {
351 | "version": "1.1.1"
352 | },
353 | "sebastian/recursion-context": {
354 | "version": "3.0.0"
355 | },
356 | "sebastian/resource-operations": {
357 | "version": "1.0.0"
358 | },
359 | "sebastian/type": {
360 | "version": "1.1.2"
361 | },
362 | "sebastian/version": {
363 | "version": "2.0.1"
364 | },
365 | "sensio/framework-extra-bundle": {
366 | "version": "4.0",
367 | "recipe": {
368 | "repo": "github.com/symfony/recipes",
369 | "branch": "master",
370 | "version": "4.0",
371 | "ref": "aaddfdf43cdecd4cf91f992052d76c2cadc04543"
372 | }
373 | },
374 | "slam/phpstan-extensions": {
375 | "version": "v3.0.0"
376 | },
377 | "squizlabs/php_codesniffer": {
378 | "version": "3.6",
379 | "recipe": {
380 | "repo": "github.com/symfony/recipes-contrib",
381 | "branch": "master",
382 | "version": "3.6",
383 | "ref": "1019e5c08d4821cb9b77f4891f8e9c31ff20ac6f"
384 | },
385 | "files": [
386 | "phpcs.xml.dist"
387 | ]
388 | },
389 | "stof/doctrine-extensions-bundle": {
390 | "version": "1.2",
391 | "recipe": {
392 | "repo": "github.com/symfony/recipes-contrib",
393 | "branch": "master",
394 | "version": "1.2",
395 | "ref": "6c1ceb662f8997085f739cd089bfbef67f245983"
396 | }
397 | },
398 | "symfony/browser-kit": {
399 | "version": "v4.0.6"
400 | },
401 | "symfony/cache": {
402 | "version": "v4.0.6"
403 | },
404 | "symfony/cache-contracts": {
405 | "version": "v1.1.1"
406 | },
407 | "symfony/config": {
408 | "version": "v4.0.6"
409 | },
410 | "symfony/console": {
411 | "version": "3.3",
412 | "recipe": {
413 | "repo": "github.com/symfony/recipes",
414 | "branch": "master",
415 | "version": "3.3",
416 | "ref": "e3868d2f4a5104f19f844fe551099a00c6562527"
417 | }
418 | },
419 | "symfony/css-selector": {
420 | "version": "v4.0.6"
421 | },
422 | "symfony/debug-bundle": {
423 | "version": "3.3",
424 | "recipe": {
425 | "repo": "github.com/symfony/recipes",
426 | "branch": "master",
427 | "version": "3.3",
428 | "ref": "71d29aaf710fd59cd3abff2b1ade907ed73103c6"
429 | }
430 | },
431 | "symfony/dependency-injection": {
432 | "version": "v4.0.6"
433 | },
434 | "symfony/deprecation-contracts": {
435 | "version": "v2.1.2"
436 | },
437 | "symfony/doctrine-bridge": {
438 | "version": "v4.0.6"
439 | },
440 | "symfony/dom-crawler": {
441 | "version": "v4.0.6"
442 | },
443 | "symfony/dotenv": {
444 | "version": "v4.0.6"
445 | },
446 | "symfony/error-handler": {
447 | "version": "v4.4.0"
448 | },
449 | "symfony/event-dispatcher": {
450 | "version": "v4.0.6"
451 | },
452 | "symfony/event-dispatcher-contracts": {
453 | "version": "v1.1.1"
454 | },
455 | "symfony/expression-language": {
456 | "version": "v4.0.6"
457 | },
458 | "symfony/filesystem": {
459 | "version": "v4.0.6"
460 | },
461 | "symfony/finder": {
462 | "version": "v4.0.6"
463 | },
464 | "symfony/flex": {
465 | "version": "1.0",
466 | "recipe": {
467 | "repo": "github.com/symfony/recipes",
468 | "branch": "master",
469 | "version": "1.0",
470 | "ref": "cc1afd81841db36fbef982fe56b48ade6716fac4"
471 | }
472 | },
473 | "symfony/form": {
474 | "version": "v4.0.6"
475 | },
476 | "symfony/framework-bundle": {
477 | "version": "3.3",
478 | "recipe": {
479 | "repo": "github.com/symfony/recipes",
480 | "branch": "master",
481 | "version": "3.3",
482 | "ref": "8a2f7fa50a528f0aad1d7a87ae3730c981b367ce"
483 | }
484 | },
485 | "symfony/http-foundation": {
486 | "version": "v4.0.6"
487 | },
488 | "symfony/http-kernel": {
489 | "version": "v4.0.6"
490 | },
491 | "symfony/maker-bundle": {
492 | "version": "1.0",
493 | "recipe": {
494 | "repo": "github.com/symfony/recipes",
495 | "branch": "master",
496 | "version": "1.0",
497 | "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
498 | }
499 | },
500 | "symfony/monolog-bridge": {
501 | "version": "v4.0.6"
502 | },
503 | "symfony/monolog-bundle": {
504 | "version": "3.1",
505 | "recipe": {
506 | "repo": "github.com/symfony/recipes",
507 | "branch": "master",
508 | "version": "3.1",
509 | "ref": "371d1a2b69984710646b09a1182ef1d4308c904f"
510 | }
511 | },
512 | "symfony/options-resolver": {
513 | "version": "v4.0.6"
514 | },
515 | "symfony/password-hasher": {
516 | "version": "v5.4.3"
517 | },
518 | "symfony/phpunit-bridge": {
519 | "version": "3.3",
520 | "recipe": {
521 | "repo": "github.com/symfony/recipes",
522 | "branch": "master",
523 | "version": "3.3",
524 | "ref": "179470cb6492db92dffee208cfdb436f175c93b4"
525 | }
526 | },
527 | "symfony/polyfill-intl-grapheme": {
528 | "version": "v1.17.0"
529 | },
530 | "symfony/polyfill-intl-icu": {
531 | "version": "v1.7.0"
532 | },
533 | "symfony/polyfill-intl-normalizer": {
534 | "version": "v1.17.0"
535 | },
536 | "symfony/polyfill-mbstring": {
537 | "version": "v1.7.0"
538 | },
539 | "symfony/polyfill-php80": {
540 | "version": "v1.17.0"
541 | },
542 | "symfony/polyfill-php81": {
543 | "version": "v1.23.0"
544 | },
545 | "symfony/process": {
546 | "version": "v4.0.6"
547 | },
548 | "symfony/property-access": {
549 | "version": "v4.0.6"
550 | },
551 | "symfony/property-info": {
552 | "version": "v4.0.6"
553 | },
554 | "symfony/routing": {
555 | "version": "4.0",
556 | "recipe": {
557 | "repo": "github.com/symfony/recipes",
558 | "branch": "master",
559 | "version": "4.0",
560 | "ref": "cda8b550123383d25827705d05a42acf6819fe4e"
561 | }
562 | },
563 | "symfony/runtime": {
564 | "version": "v5.3.3"
565 | },
566 | "symfony/security-bundle": {
567 | "version": "3.3",
568 | "recipe": {
569 | "repo": "github.com/symfony/recipes",
570 | "branch": "master",
571 | "version": "3.3",
572 | "ref": "f8a63faa0d9521526499c0a8f403c9964ecb0527"
573 | }
574 | },
575 | "symfony/security-core": {
576 | "version": "v5.1.0"
577 | },
578 | "symfony/security-csrf": {
579 | "version": "v5.1.0"
580 | },
581 | "symfony/security-guard": {
582 | "version": "v5.1.0"
583 | },
584 | "symfony/security-http": {
585 | "version": "v5.1.0"
586 | },
587 | "symfony/serializer": {
588 | "version": "v4.0.6"
589 | },
590 | "symfony/service-contracts": {
591 | "version": "v1.1.2"
592 | },
593 | "symfony/stopwatch": {
594 | "version": "v4.0.6"
595 | },
596 | "symfony/string": {
597 | "version": "v5.1.0"
598 | },
599 | "symfony/translation": {
600 | "version": "3.3",
601 | "recipe": {
602 | "repo": "github.com/symfony/recipes",
603 | "branch": "master",
604 | "version": "3.3",
605 | "ref": "6bcd6c570c017ea6ae5a7a6a027c929fd3542cd8"
606 | }
607 | },
608 | "symfony/translation-contracts": {
609 | "version": "v1.1.2"
610 | },
611 | "symfony/twig-bridge": {
612 | "version": "v4.0.6"
613 | },
614 | "symfony/twig-bundle": {
615 | "version": "3.3",
616 | "recipe": {
617 | "repo": "github.com/symfony/recipes",
618 | "branch": "master",
619 | "version": "3.3",
620 | "ref": "f75ac166398e107796ca94cc57fa1edaa06ec47f"
621 | }
622 | },
623 | "symfony/validator": {
624 | "version": "v4.0.6"
625 | },
626 | "symfony/var-dumper": {
627 | "version": "v4.0.6"
628 | },
629 | "symfony/var-exporter": {
630 | "version": "v4.2.0"
631 | },
632 | "symfony/web-profiler-bundle": {
633 | "version": "3.3",
634 | "recipe": {
635 | "repo": "github.com/symfony/recipes",
636 | "branch": "master",
637 | "version": "3.3",
638 | "ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6"
639 | }
640 | },
641 | "symfony/yaml": {
642 | "version": "v4.0.6"
643 | },
644 | "symplify/astral": {
645 | "version": "v9.3.0"
646 | },
647 | "symplify/autowire-array-parameter": {
648 | "version": "v9.3.26"
649 | },
650 | "symplify/composer-json-manipulator": {
651 | "version": "v9.3.26"
652 | },
653 | "symplify/console-package-builder": {
654 | "version": "v9.3.26"
655 | },
656 | "symplify/easy-testing": {
657 | "version": "v9.3.26"
658 | },
659 | "symplify/package-builder": {
660 | "version": "v9.3.26"
661 | },
662 | "symplify/phpstan-rules": {
663 | "version": "v9.3.0"
664 | },
665 | "symplify/rule-doc-generator-contracts": {
666 | "version": "v9.3.26"
667 | },
668 | "symplify/smart-file-system": {
669 | "version": "v9.3.26"
670 | },
671 | "symplify/symplify-kernel": {
672 | "version": "v9.3.26"
673 | },
674 | "thecodingmachine/phpstan-strict-rules": {
675 | "version": "v0.11.0"
676 | },
677 | "theofidry/alice-data-fixtures": {
678 | "version": "v1.0.1"
679 | },
680 | "theseer/tokenizer": {
681 | "version": "1.1.0"
682 | },
683 | "twig/twig": {
684 | "version": "v2.4.7"
685 | },
686 | "vimeo/psalm": {
687 | "version": "3.10.1"
688 | },
689 | "webmozart/assert": {
690 | "version": "1.3.0"
691 | },
692 | "webmozart/path-util": {
693 | "version": "2.3.0"
694 | },
695 | "willdurand/jsonp-callback-validator": {
696 | "version": "v1.1.0"
697 | },
698 | "willdurand/negotiation": {
699 | "version": "v2.3.1"
700 | }
701 | }
702 |
--------------------------------------------------------------------------------