├── .editorconfig ├── .env ├── .env.test ├── .github └── workflows │ ├── ci.yml │ └── specs.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE ├── Makefile ├── README.md ├── bin └── console ├── composer.json ├── composer.lock ├── config ├── bundles.php ├── jwt │ └── test │ │ ├── private.pem │ │ └── public.pem ├── packages │ ├── cache.yaml │ ├── dev │ │ ├── debug.yaml │ │ ├── fidry_alice_data_fixtures.yaml │ │ ├── monolog.yaml │ │ ├── nelmio_alice.yaml │ │ └── web_profiler.yaml │ ├── doctrine.yaml │ ├── doctrine_migrations.yaml │ ├── fos_rest.yaml │ ├── framework.yaml │ ├── lexik_jwt_authentication.yaml │ ├── nelmio_cors.yaml │ ├── prod │ │ ├── deprecations.yaml │ │ ├── doctrine.yaml │ │ └── monolog.yaml │ ├── routing.yaml │ ├── security.yaml │ ├── sensio_framework_extra.yaml │ ├── stof_doctrine_extensions.yaml │ ├── test │ │ ├── dama_doctrine_test_bundle.yaml │ │ ├── doctrine.yaml │ │ ├── fidry_alice_data_fixtures.yaml │ │ ├── monolog.yaml │ │ ├── nelmio_alice.yaml │ │ ├── validator.yaml │ │ └── web_profiler.yaml │ ├── translation.yaml │ ├── twig.yaml │ └── validator.yaml ├── preload.php ├── routes.yaml ├── routes │ ├── annotations.yaml │ ├── dev │ │ └── web_profiler.yaml │ └── framework.yaml ├── services.yaml ├── services_dev.yaml └── services_test.yaml ├── docker-compose.override.yml.dist ├── docker-compose.yml ├── docker ├── nginx │ ├── Dockerfile │ └── conf │ │ └── project.conf ├── node │ └── Dockerfile └── php │ ├── Dockerfile │ └── conf │ ├── php-fpm.conf │ └── php.ini ├── fixtures └── data.yaml ├── logo.png ├── migrations └── Version20180326200407.php ├── phpcs.xml.dist ├── phpmd.xml.dist ├── phpstan.neon.dist ├── phpunit.xml.dist ├── psalm.xml.dist ├── public └── index.php ├── rector.php ├── spec ├── api-spec-test-runner.sh └── conduit.postman_collection.json ├── src ├── Controller │ ├── Article │ │ ├── CreateArticleController.php │ │ ├── DeleteArticleController.php │ │ ├── FavoriteArticleController.php │ │ ├── GetArticlesFeedController.php │ │ ├── GetArticlesListController.php │ │ ├── GetOneArticleController.php │ │ ├── UnfavoriteArticleController.php │ │ └── UpdateArticleController.php │ ├── Comment │ │ ├── CreateCommentController.php │ │ ├── DeleteCommentController.php │ │ └── GetCommentsListController.php │ ├── Profile │ │ ├── FollowProfileController.php │ │ ├── GetProfileController.php │ │ └── UnfollowProfileController.php │ ├── Registration │ │ └── RegisterController.php │ ├── Security │ │ └── LoginController.php │ ├── Tag │ │ └── GetTagsListController.php │ └── User │ │ ├── GetUserController.php │ │ └── UpdateUserController.php ├── DataFixtures │ ├── AppFixtures.php │ ├── Processor │ │ └── UserProcessor.php │ └── Provider │ │ └── CollectionProvider.php ├── Entity │ ├── Article.php │ ├── Comment.php │ ├── Tag.php │ └── User.php ├── EventListener │ └── JWTAuthenticationSubscriber.php ├── Exception │ └── NoCurrentUserException.php ├── Form │ ├── ArticleType.php │ ├── CommentType.php │ ├── DataTransformer │ │ └── TagArrayToStringTransformer.php │ ├── Type │ │ └── TagsInputType.php │ └── UserType.php ├── Kernel.php ├── Repository │ ├── ArticleRepository.php │ ├── CommentRepository.php │ ├── TagRepository.php │ └── UserRepository.php ├── Security │ ├── UserResolver.php │ └── Voter │ │ └── AuthorVoter.php └── Serializer │ └── Normalizer │ ├── ArticleNormalizer.php │ ├── CommentNormalizer.php │ ├── DateTimeNormalizer.php │ ├── FormErrorNormalizer.php │ ├── TagNormalizer.php │ └── UserNormalizer.php ├── symfony.lock ├── templates └── .gitignore ├── tests ├── Controller │ ├── Article │ │ ├── CreateArticleControllerTest.php │ │ ├── DeleteArticleControllerTest.php │ │ ├── FavoriteArticleControllerTest.php │ │ ├── GetArticlesFeedControllerTest.php │ │ ├── GetArticlesListControllerTest.php │ │ ├── GetOneArticleControllerTest.php │ │ ├── UnfavoriteArticleControllerTest.php │ │ └── UpdateArticleControllerTest.php │ ├── Comment │ │ ├── CreateCommentControllerTest.php │ │ ├── DeleteCommentControllerTest.php │ │ └── GetCommentsListControllerTest.php │ ├── Profile │ │ ├── FollowProfileControllerTest.php │ │ ├── GetProfileControllerTest.php │ │ └── UnfollowProfileControllerTest.php │ ├── Registration │ │ └── RegisterControllerTest.php │ ├── Security │ │ └── LoginControllerTest.php │ ├── Tag │ │ └── TagsListControllerTest.php │ └── User │ │ ├── GetUserControllerTest.php │ │ └── UpdateUserControllerTest.php ├── DataFixtures │ ├── Processor │ │ └── UserProcessorTest.php │ └── Provider │ │ └── CollectionProviderTest.php ├── Security │ └── UserResolverTest.php ├── TestCase │ └── WebTestCase.php └── phpunit.bootstrap.php └── translations └── validators.en.yaml /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 4 11 | trim_trailing_whitespace = true 12 | 13 | [Makefile] 14 | indent_style = tab 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # define your env variables for the test env here 2 | KERNEL_CLASS='App\Kernel' 3 | APP_SECRET='s$cretf0rt3st' 4 | JWT_PRIVATE_KEY_PATH=config/jwt/test/private.pem 5 | JWT_PUBLIC_KEY_PATH=config/jwt/test/public.pem 6 | JWT_PASSPHRASE=passphrase 7 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](logo.png) 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 | [![Code Coverage](https://scrutinizer-ci.com/g/slashfan/symfony-realworld-example-app/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/slashfan/symfony-realworld-example-app/?branch=master) 6 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/slashfan/symfony-realworld-example-app/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/slashfan/symfony-realworld-example-app/?branch=master) 7 | [![Build Status](https://scrutinizer-ci.com/g/slashfan/symfony-realworld-example-app/badges/build.png?b=master)](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 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | ['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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/dev/debug.yaml: -------------------------------------------------------------------------------- 1 | debug: 2 | # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. 3 | # See the "server:dump" command to start a new server. 4 | dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" 5 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /config/packages/doctrine_migrations.yaml: -------------------------------------------------------------------------------- 1 | doctrine_migrations: 2 | migrations_paths: 3 | 'DoctrineMigrations': '%kernel.project_dir%/migrations' 4 | storage: 5 | table_storage: 6 | table_name: 'migration_versions' 7 | enable_profiler: '%kernel.debug%' 8 | -------------------------------------------------------------------------------- /config/packages/fos_rest.yaml: -------------------------------------------------------------------------------- 1 | fos_rest: 2 | body_listener: true 3 | param_fetcher_listener: true 4 | view: 5 | view_response_listener: 'force' 6 | empty_content: 204 7 | failed_validation: 422 8 | formats: 9 | json : true 10 | format_listener: 11 | rules: 12 | - { path: '^/api', priorities: ['json'], fallback_format: json, prefer_extension: false } 13 | - { path: '^/', stop: true } 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/packages/lexik_jwt_authentication.yaml: -------------------------------------------------------------------------------- 1 | lexik_jwt_authentication: 2 | secret_key: '%kernel.project_dir%/%env(JWT_PRIVATE_KEY_PATH)%' 3 | public_key: '%kernel.project_dir%/%env(JWT_PUBLIC_KEY_PATH)%' 4 | pass_phrase: '%env(JWT_PASSPHRASE)%' 5 | user_identity_field: 'email' 6 | token_ttl: 86400 7 | token_extractors: 8 | authorization_header: 9 | enabled: true 10 | prefix: Token 11 | name: Authorization 12 | -------------------------------------------------------------------------------- /config/packages/nelmio_cors.yaml: -------------------------------------------------------------------------------- 1 | nelmio_cors: 2 | defaults: 3 | origin_regex: true 4 | allow_origin: ['*'] 5 | allow_methods: ['HEAD', 'GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] 6 | allow_headers: ['*'] 7 | max_age: 3600 8 | paths: 9 | '^/api': ~ 10 | -------------------------------------------------------------------------------- /config/packages/prod/deprecations.yaml: -------------------------------------------------------------------------------- 1 | # As of Symfony 5.1, deprecations are logged in the dedicated "deprecation" channel when it exists 2 | #monolog: 3 | # channels: [deprecation] 4 | # handlers: 5 | # deprecation: 6 | # type: stream 7 | # channels: [deprecation] 8 | # path: php://stderr 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | 5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 7 | #default_uri: http://localhost 8 | 9 | when@prod: 10 | framework: 11 | router: 12 | strict_requirements: null 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/packages/sensio_framework_extra.yaml: -------------------------------------------------------------------------------- 1 | sensio_framework_extra: 2 | router: 3 | annotations: false 4 | -------------------------------------------------------------------------------- /config/packages/stof_doctrine_extensions.yaml: -------------------------------------------------------------------------------- 1 | stof_doctrine_extensions: 2 | orm: 3 | default: 4 | timestampable: true 5 | sluggable: true 6 | -------------------------------------------------------------------------------- /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/test/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: 'sqlite:///%kernel.project_dir%/var/test.db' 4 | # "TEST_TOKEN" is typically set by ParaTest 5 | # dbname_suffix: '_test%env(default::TEST_TOKEN)%' 6 | -------------------------------------------------------------------------------- /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/packages/test/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_http_codes: [404, 405] 8 | channels: ["!event"] 9 | nested: 10 | type: stream 11 | path: "%kernel.logs_dir%/%kernel.environment%.log" 12 | level: debug 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/packages/test/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | not_compromised_password: false 4 | -------------------------------------------------------------------------------- /config/packages/test/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: false 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { collect: false } 7 | -------------------------------------------------------------------------------- /config/packages/translation.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | default_locale: en 3 | translator: 4 | default_path: '%kernel.project_dir%/translations' 5 | fallbacks: 6 | - en 7 | # providers: 8 | # crowdin: 9 | # dsn: '%env(CROWDIN_DSN)%' 10 | # loco: 11 | # dsn: '%env(LOCO_DSN)%' 12 | # lokalise: 13 | # dsn: '%env(LOKALISE_DSN)%' 14 | -------------------------------------------------------------------------------- /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/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | email_validation_mode: html5 4 | 5 | # Enables validator auto-mapping support. 6 | # For instance, basic validation constraints will be inferred from Doctrine's metadata. 7 | # auto_mapping: 8 | # App\Entity\: [] 9 | -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | ' 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 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slashfan/symfony-realworld-example-app/5ad39ded1416f043d6307bcc7a76f9507b8ff116/logo.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /psalm.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /public/index.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 | -------------------------------------------------------------------------------- /spec/api-spec-test-runner.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | 4 | APIURL=${APIURL:-http://nginx/api} 5 | USERNAME=${USERNAME:-u$(date +%s)} 6 | EMAIL=${EMAIL:-$USERNAME@mail.com} 7 | PASSWORD=${PASSWORD:-password} 8 | 9 | npm_config_yes=true npx newman run spec/conduit.postman_collection.json \ 10 | --delay-request 50 \ 11 | --global-var "APIURL=$APIURL" \ 12 | --global-var "USERNAME=$USERNAME" \ 13 | --global-var "EMAIL=$EMAIL" \ 14 | --global-var "PASSWORD=$PASSWORD" 15 | -------------------------------------------------------------------------------- /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/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/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/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/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/Article/GetOneArticleController.php: -------------------------------------------------------------------------------- 1 | $article]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/GetProfileController.php: -------------------------------------------------------------------------------- 1 | $profile]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /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/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/Security/LoginController.php: -------------------------------------------------------------------------------- 1 | tagRepository = $repository; 21 | } 22 | 23 | public function __invoke(): array 24 | { 25 | return ['tags' => $this->tagRepository->findAll()]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/DataFixtures/Provider/CollectionProvider.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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/Exception/NoCurrentUserException.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/Kernel.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/Repository/CommentRepository.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/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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /templates/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slashfan/symfony-realworld-example-app/5ad39ded1416f043d6307bcc7a76f9507b8ff116/templates/.gitignore -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------