From 6923c692928201c693dc9235e207f5d1ff418f87 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 14 Dec 2023 15:06:49 +0100 Subject: [PATCH] first setup wip --- .env | 4 + README.md | 64 +- composer.json | 11 +- composer.lock | 1176 ++++++++++++++++++-- config/bundles.php | 5 + config/packages/api_platform.yaml | 21 + config/packages/nelmio_cors.yaml | 10 + config/packages/security.yaml | 12 +- config/packages/zenstruck_foundry.yaml | 7 + config/routes.yaml | 1 + config/routes/api_platform.yaml | 5 + migrations/Version20231214140601.php | 35 + phpunit.xml.dist | 1 + src/ApiResource/.gitignore | 0 src/ApiResource/PostingApi.php | 60 + src/ApiResource/UserApi.php | 82 ++ src/Controller/SecurityController.php | 33 + src/DataFixtures/AppFixtures.php | 41 + src/Entity/Posting.php | 65 ++ src/Entity/User.php | 196 ++++ src/Factory/PostingFactory.php | 69 ++ src/Factory/UserFactory.php | 94 ++ src/Mapper/PostingApiToEntityMapper.php | 61 + src/Mapper/PostingEntityToApiMapper.php | 51 + src/Mapper/UserApiToEntityMapper.php | 65 ++ src/Mapper/UserEntityToApiMapper.php | 51 + src/Repository/PostingRepository.php | 48 + src/Repository/UserRepository.php | 67 ++ src/State/EntityClassDtoStateProcessor.php | 49 + src/State/EntityToDtoStateProvider.php | 59 + src/Validator/IsValidOwner.php | 20 + src/Validator/IsValidOwnerValidator.php | 39 + src/Voter/PostingApiVoter.php | 49 + src/Voter/UserApiVoter.php | 50 + symfony.lock | 53 + tests/Functional/UserResourceTest.php | 114 ++ 36 files changed, 2661 insertions(+), 107 deletions(-) create mode 100644 config/packages/api_platform.yaml create mode 100644 config/packages/nelmio_cors.yaml create mode 100644 config/packages/zenstruck_foundry.yaml create mode 100644 config/routes/api_platform.yaml create mode 100644 migrations/Version20231214140601.php create mode 100644 src/ApiResource/.gitignore create mode 100644 src/ApiResource/PostingApi.php create mode 100644 src/ApiResource/UserApi.php create mode 100644 src/Controller/SecurityController.php create mode 100644 src/DataFixtures/AppFixtures.php create mode 100644 src/Entity/Posting.php create mode 100644 src/Entity/User.php create mode 100644 src/Factory/PostingFactory.php create mode 100644 src/Factory/UserFactory.php create mode 100644 src/Mapper/PostingApiToEntityMapper.php create mode 100644 src/Mapper/PostingEntityToApiMapper.php create mode 100644 src/Mapper/UserApiToEntityMapper.php create mode 100644 src/Mapper/UserEntityToApiMapper.php create mode 100644 src/Repository/PostingRepository.php create mode 100644 src/Repository/UserRepository.php create mode 100644 src/State/EntityClassDtoStateProcessor.php create mode 100644 src/State/EntityToDtoStateProvider.php create mode 100644 src/Validator/IsValidOwner.php create mode 100644 src/Validator/IsValidOwnerValidator.php create mode 100644 src/Voter/PostingApiVoter.php create mode 100644 src/Voter/UserApiVoter.php create mode 100644 tests/Functional/UserResourceTest.php diff --git a/.env b/.env index d17fcc5..3c8c7c0 100644 --- a/.env +++ b/.env @@ -39,3 +39,7 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###> symfony/mailer ### # MAILER_DSN=null://null ###< symfony/mailer ### + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' +###< nelmio/cors-bundle ### diff --git a/README.md b/README.md index 870cbb9..f125209 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # matsen-tool-be -Installation: +# Installation: gehe ins root Verzeichnis des Projekts und für aus: ddev config @@ -10,24 +10,78 @@ Installation: projekt installieren: ddev composer install -Troubleshooting: +# Troubleshooting: Unable to listen on required ports, port 443 is already in use -> setze andere Ports in .ddev/config.yaml z.B. router_http_port: 8080 router_https_port: 8443 -PHPMyAdmin installieren: + - https://stackoverflow.com/questions/76507208/symfony-6-3-migration-causes-problems-with-stateless-authenticators-forcing-requ + +# PHPMyAdmin installieren: ddev get ddev/ddev-phpmyadmin ddev restart -Symfony Konsolenbefehle mit Ddev ausführen, z.B.: +# Symfony Konsolenbefehle mit Ddev ausführen, z.B.: ddev exec php bin/console make:migration -Ddev Commands: +# Ddev Commands: ddev describe - zeigt Urls und installierte Komponenten +------------------------- +# Symfony: + +# User with Maker Bundle: + - https://symfonycasts.com/screencast/api-platform/user-entity + ddev composer require maker-bundle --dev + ddev exec bin/console make:user -> erstellt user entity und schreibt in die security.yaml + +# Entity erzeugen oder erweitern: + ddev exec bin/console make:entity + +# Foundry fixtures: + ddev exec composer require foundry orm-fixtures --dev + ddev exec bin/console make:factory + ddev exec bin/console doctrine:fixtures:load + +# Doctrine: + ddev exec bin/console doctrine:database:drop --force + ddev exec bin/console doctrine:database:create + ddev exec bin/console make:migration + ddev exec bin/console doctrine:migration:migrate + +# Profiler + ddev composer require debug + +# Php Unit + - https://symfony.com/doc/current/testing.html#configuring-a-database-for-tests + Setup: + # .env.test.local -> "mysql://root:root@db:3306/db?serverVersion=10.4.30-MariaDB-1:10.4.30+maria~ubu2004-log - mariadb.org binary distribution" + -> this creates a db named db_test (it takes the name of the main database "db" and adds "_test" to its name) + # Create db and create schema + php bin/console --env=test doctrine:database:create + php bin/console --env=test doctrine:schema:create + + - https://symfonycasts.com/screencast/api-platform-security/test-setup + ddev composer require test -> testpack incl. phpunit + ddev composer require zenstruck/browser --dev -> browser test package to imporve testing + -> add extension to phpunit.xml.dist + + + + ddev exec php bin/phpunit --filter=testPostToCreateNewUserPost + + ddev composer require --dev mtdowling/jmespath.php + + +# Api Platform + - https://api-platform.com/docs/core/dto/ + - https://api-platform.com/docs/distribution/ + - https://api-platform.com/docs/core/extending/ + ddev exec bin/console api:openapi:export --yaml + -> export OpenApi spec \ No newline at end of file diff --git a/composer.json b/composer.json index 6e98ce7..fa47b67 100644 --- a/composer.json +++ b/composer.json @@ -7,9 +7,11 @@ "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*", + "api-platform/core": "^3.2", "doctrine/doctrine-bundle": "^2.11", "doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/orm": "^2.17", + "nelmio/cors-bundle": "^2.4", "phpdocumentor/reflection-docblock": "^5.3", "phpstan/phpdoc-parser": "^1.24", "symfony/asset": "7.0.*", @@ -38,6 +40,7 @@ "symfony/validator": "7.0.*", "symfony/web-link": "7.0.*", "symfony/yaml": "7.0.*", + "symfonycasts/micro-mapper": "^0.1.4", "twig/extra-bundle": "^2.12|^3.0", "twig/twig": "^2.12|^3.0" }, @@ -91,13 +94,17 @@ } }, "require-dev": { + "doctrine/doctrine-fixtures-bundle": "^3.5", + "mtdowling/jmespath.php": "^2.7", "phpunit/phpunit": "^9.5", "symfony/browser-kit": "7.0.*", "symfony/css-selector": "7.0.*", "symfony/debug-bundle": "7.0.*", - "symfony/maker-bundle": "^1.0", + "symfony/maker-bundle": "^1.52", "symfony/phpunit-bridge": "^7.0", "symfony/stopwatch": "7.0.*", - "symfony/web-profiler-bundle": "7.0.*" + "symfony/web-profiler-bundle": "7.0.*", + "zenstruck/browser": "^1.6", + "zenstruck/foundry": "^1.36" } } diff --git a/composer.lock b/composer.lock index a4d13dd..1b33cc4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,171 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3bc207441cd949595a265e045a50c919", + "content-hash": "e29fe980d3968cb83139b26407957151", "packages": [ + { + "name": "api-platform/core", + "version": "v3.2.7", + "source": { + "type": "git", + "url": "https://github.com/api-platform/core.git", + "reference": "f297d2192652a3acd2a644707740de8cb5069221" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/core/zipball/f297d2192652a3acd2a644707740de8cb5069221", + "reference": "f297d2192652a3acd2a644707740de8cb5069221", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^1.0 || ^2.0", + "php": ">=8.1", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^3.1", + "symfony/http-foundation": "^6.1 || ^7.0", + "symfony/http-kernel": "^6.1 || ^7.0", + "symfony/property-access": "^6.1 || ^7.0", + "symfony/property-info": "^6.1 || ^7.0", + "symfony/serializer": "^6.1 || ^7.0", + "symfony/translation-contracts": "^3.3", + "symfony/web-link": "^6.1 || ^7.0", + "willdurand/negotiation": "^3.0" + }, + "conflict": { + "doctrine/common": "<3.2.2", + "doctrine/dbal": "<2.10", + "doctrine/mongodb-odm": "<2.4", + "doctrine/orm": "<2.14.0", + "doctrine/persistence": "<1.3", + "elasticsearch/elasticsearch": ">=8.0,<8.4", + "phpspec/prophecy": "<1.15", + "phpunit/phpunit": "<9.5", + "symfony/var-exporter": "<6.1.1" + }, + "require-dev": { + "behat/behat": "^3.11", + "behat/mink": "^1.9", + "doctrine/cache": "^1.11 || ^2.1", + "doctrine/common": "^3.2.2", + "doctrine/dbal": "^3.4.0", + "doctrine/doctrine-bundle": "^1.12 || ^2.0", + "doctrine/mongodb-odm": "^2.2", + "doctrine/orm": "^2.14", + "elasticsearch/elasticsearch": "^7.11 || ^8.4", + "friends-of-behat/mink-browserkit-driver": "^1.3.1", + "friends-of-behat/mink-extension": "^2.2", + "friends-of-behat/symfony-extension": "^2.1", + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "jangregor/phpstan-prophecy": "^1.0", + "justinrainbow/json-schema": "^5.2.1", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpdoc-parser": "^1.13", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-doctrine": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-symfony": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^3.9.7 || ^4.0", + "ramsey/uuid-doctrine": "^1.4 || ^2.0", + "soyuka/contexts": "v3.3.9", + "soyuka/stubs-mongodb": "^1.0", + "symfony/asset": "^6.1 || ^7.0", + "symfony/browser-kit": "^6.1 || ^7.0", + "symfony/cache": "^6.1 || ^7.0", + "symfony/config": "^6.1 || ^7.0", + "symfony/console": "^6.1 || ^7.0", + "symfony/css-selector": "^6.1 || ^7.0", + "symfony/dependency-injection": "^6.1 || ^7.0.12", + "symfony/doctrine-bridge": "^6.1 || ^7.0", + "symfony/dom-crawler": "^6.1 || ^7.0", + "symfony/error-handler": "^6.1 || ^7.0", + "symfony/event-dispatcher": "^6.1 || ^7.0", + "symfony/expression-language": "^6.1 || ^7.0", + "symfony/finder": "^6.1 || ^7.0", + "symfony/form": "^6.1 || ^7.0", + "symfony/framework-bundle": "^6.1 || ^7.0", + "symfony/http-client": "^6.1 || ^7.0", + "symfony/intl": "^6.1 || ^7.0", + "symfony/maker-bundle": "^1.24", + "symfony/mercure-bundle": "*", + "symfony/messenger": "^6.1 || ^7.0", + "symfony/phpunit-bridge": "^6.1 || ^7.0", + "symfony/routing": "^6.1 || ^7.0", + "symfony/security-bundle": "^6.1 || ^7.0", + "symfony/security-core": "^6.1 || ^7.0", + "symfony/stopwatch": "^6.1 || ^7.0", + "symfony/twig-bundle": "^6.1 || ^7.0", + "symfony/uid": "^6.1 || ^7.0", + "symfony/validator": "^6.1 || ^7.0", + "symfony/web-profiler-bundle": "^6.1 || ^7.0", + "symfony/yaml": "^6.1 || ^7.0", + "twig/twig": "^1.42.3 || ^2.12 || ^3.0", + "webonyx/graphql-php": "^14.0 || ^15.0" + }, + "suggest": { + "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", + "elasticsearch/elasticsearch": "To support Elasticsearch.", + "ocramius/package-versions": "To display the API Platform's version in the debug bar.", + "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", + "psr/cache-implementation": "To use metadata caching.", + "ramsey/uuid": "To support Ramsey's UUID identifiers.", + "symfony/cache": "To have metadata caching when using Symfony integration.", + "symfony/config": "To load XML configuration files.", + "symfony/expression-language": "To use authorization features.", + "symfony/http-client": "To use the HTTP cache invalidation system.", + "symfony/messenger": "To support messenger integration.", + "symfony/security": "To use authorization features.", + "symfony/twig-bundle": "To use the Swagger UI integration.", + "symfony/uid": "To support Symfony UUID/ULID identifiers.", + "symfony/web-profiler-bundle": "To use the data collector.", + "webonyx/graphql-php": "To support GraphQL." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.3.x-dev" + }, + "symfony": { + "require": "^6.1 || ^7.0" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + } + ], + "description": "Build a fully-featured hypermedia or GraphQL API in minutes!", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "hal", + "jsonapi", + "openapi", + "rest", + "swagger" + ], + "support": { + "issues": "https://github.com/api-platform/core/issues", + "source": "https://github.com/api-platform/core/tree/v3.2.7" + }, + "time": "2023-11-30T13:51:25+00:00" + }, { "name": "doctrine/cache", "version": "2.2.0", @@ -1497,6 +1660,68 @@ ], "time": "2023-10-27T15:32:31+00:00" }, + { + "name": "nelmio/cors-bundle", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioCorsBundle.git", + "reference": "78fcdb91f76b080a1008133def9c7f613833933d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/78fcdb91f76b080a1008133def9c7f613833933d", + "reference": "78fcdb91f76b080a1008133def9c7f613833933d", + "shasum": "" + }, + "require": { + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.6", + "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\CorsBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nelmio", + "homepage": "http://nelm.io" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors" + } + ], + "description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony application", + "keywords": [ + "api", + "cors", + "crossdomain" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioCorsBundle/issues", + "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.4.0" + }, + "time": "2023-11-30T16:41:19+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -7019,6 +7244,55 @@ ], "time": "2023-11-07T10:26:03+00:00" }, + { + "name": "symfonycasts/micro-mapper", + "version": "v0.1.4", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/micro-mapper.git", + "reference": "81190a2c94359213afc3053c5dd85c9fd2c3d7bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/micro-mapper/zipball/81190a2c94359213afc3053c5dd85c9fd2c3d7bb", + "reference": "81190a2c94359213afc3053c5dd85c9fd2c3d7bb", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpstan/phpstan": "^1.10.39", + "symfony/filesystem": "^6.3", + "symfony/framework-bundle": "^6.3", + "symfony/phpunit-bridge": "^6.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfonycasts\\MicroMapper\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Weaver", + "homepage": "https://symfonycasts.com" + } + ], + "description": "A tiny, underwhelming data mapper to map one object to another!", + "keywords": [ + "data-mapper" + ], + "support": { + "issues": "https://github.com/SymfonyCasts/micro-mapper/issues", + "source": "https://github.com/SymfonyCasts/micro-mapper/tree/v0.1.4" + }, + "time": "2023-10-18T15:25:09+00:00" + }, { "name": "twig/extra-bundle", "version": "v3.8.0", @@ -7222,39 +7496,36 @@ "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, "time": "2022-06-03T18:03:27+00:00" - } - ], - "packages-dev": [ + }, { - "name": "masterminds/html5", - "version": "2.8.1", + "name": "willdurand/negotiation", + "version": "3.1.0", "source": { "type": "git", - "url": "https://github.com/Masterminds/html5-php.git", - "reference": "f47dcf3c70c584de14f21143c55d9939631bc6cf" + "url": "https://github.com/willdurand/Negotiation.git", + "reference": "68e9ea0553ef6e2ee8db5c1d98829f111e623ec2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f47dcf3c70c584de14f21143c55d9939631bc6cf", - "reference": "f47dcf3c70c584de14f21143c55d9939631bc6cf", + "url": "https://api.github.com/repos/willdurand/Negotiation/zipball/68e9ea0553ef6e2ee8db5c1d98829f111e623ec2", + "reference": "68e9ea0553ef6e2ee8db5c1d98829f111e623ec2", "shasum": "" }, "require": { - "ext-dom": "*", - "php": ">=5.3.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8" + "symfony/phpunit-bridge": "^5.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.7-dev" + "dev-master": "3.0-dev" } }, "autoload": { "psr-4": { - "Masterminds\\": "src" + "Negotiation\\": "src/Negotiation" } }, "notification-url": "https://packagist.org/downloads/", @@ -7263,149 +7534,579 @@ ], "authors": [ { - "name": "Matt Butcher", - "email": "technosophos@gmail.com" - }, - { - "name": "Matt Farina", - "email": "matt@mattfarina.com" - }, - { - "name": "Asmir Mustafic", - "email": "goetas@gmail.com" + "name": "William Durand", + "email": "will+git@drnd.me" } ], - "description": "An HTML5 parser and serializer.", - "homepage": "http://masterminds.github.io/html5-php", + "description": "Content Negotiation tools for PHP provided as a standalone library.", + "homepage": "http://williamdurand.fr/Negotiation/", "keywords": [ - "HTML5", - "dom", - "html", - "parser", - "querypath", - "serializer", - "xml" + "accept", + "content", + "format", + "header", + "negotiation" ], "support": { - "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.8.1" + "issues": "https://github.com/willdurand/Negotiation/issues", + "source": "https://github.com/willdurand/Negotiation/tree/3.1.0" }, - "time": "2023-05-10T11:58:31+00:00" - }, + "time": "2022-01-30T20:08:53+00:00" + } + ], + "packages-dev": [ { - "name": "myclabs/deep-copy", - "version": "1.11.1", + "name": "behat/mink", + "version": "v1.11.0", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "url": "https://github.com/minkphp/Mink.git", + "reference": "d8527fdf8785aad38455fb426af457ab9937aece" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/minkphp/Mink/zipball/d8527fdf8785aad38455fb426af457ab9937aece", + "reference": "d8527fdf8785aad38455fb426af457ab9937aece", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "php": ">=7.2", + "symfony/css-selector": "^4.4 || ^5.0 || ^6.0 || ^7.0" }, "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^8.5.22 || ^9.5.11", + "symfony/error-handler": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0" + }, + "suggest": { + "behat/mink-browserkit-driver": "fast headless driver for any app without JS emulation", + "behat/mink-selenium2-driver": "slow, but JS-enabled driver for any app (requires Selenium2)", + "behat/mink-zombie-driver": "fast and JS-enabled headless driver for any app (requires node.js)", + "dmore/chrome-mink-driver": "fast and JS-enabled driver for any app (requires chromium or google chrome)" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" + "Behat\\Mink\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Browser controller/emulator abstraction for PHP", + "homepage": "https://mink.behat.org/", "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "browser", + "testing", + "web" ], "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + "issues": "https://github.com/minkphp/Mink/issues", + "source": "https://github.com/minkphp/Mink/tree/v1.11.0" }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" - } - ], - "time": "2023-03-08T13:26:56+00:00" + "time": "2023-12-09T11:23:23+00:00" }, { - "name": "nikic/php-parser", - "version": "v4.17.1", + "name": "doctrine/data-fixtures", + "version": "1.7.0", "source": { "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" + "url": "https://github.com/doctrine/data-fixtures.git", + "reference": "bbcb74f2ac6dbe81a14b3c3687d7623490a0448f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/bbcb74f2ac6dbe81a14b3c3687d7623490a0448f", + "reference": "bbcb74f2ac6dbe81a14b3c3687d7623490a0448f", "shasum": "" }, "require": { - "ext-tokenizer": "*", - "php": ">=7.0" + "doctrine/deprecations": "^0.5.3 || ^1.0", + "doctrine/persistence": "^2.0|^3.0", + "php": "^7.4 || ^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.5 || >=5", + "doctrine/orm": "<2.14 || >=4", + "doctrine/phpcr-odm": "<1.3.0" }, "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "doctrine/annotations": "^1.12 || ^2", + "doctrine/coding-standard": "^12", + "doctrine/dbal": "^3.5 || ^4", + "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", + "doctrine/orm": "^2.14 || ^3", + "ext-sqlite3": "*", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.6.13 || ^10.4.2", + "symfony/cache": "^5.4 || ^6.3 || ^7", + "symfony/var-exporter": "^5.4 || ^6.3 || ^7", + "vimeo/psalm": "^5.9" }, - "bin": [ - "bin/php-parse" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.9-dev" - } + "suggest": { + "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)", + "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures", + "doctrine/orm": "For loading ORM fixtures", + "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures" }, + "type": "library", "autoload": { "psr-4": { - "PhpParser\\": "lib/PhpParser" + "Doctrine\\Common\\DataFixtures\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Nikita Popov" + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" } ], - "description": "A PHP parser written in PHP", + "description": "Data Fixtures for all Doctrine Object Managers", + "homepage": "https://www.doctrine-project.org", "keywords": [ - "parser", - "php" + "database" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" + "issues": "https://github.com/doctrine/data-fixtures/issues", + "source": "https://github.com/doctrine/data-fixtures/tree/1.7.0" }, - "time": "2023-08-13T19:53:39+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures", + "type": "tidelift" + } + ], + "time": "2023-11-24T11:18:31+00:00" + }, + { + "name": "doctrine/doctrine-fixtures-bundle", + "version": "3.5.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", + "reference": "c808a0c85c38c8ee265cc8405b456c1d2b38567d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/c808a0c85c38c8ee265cc8405b456c1d2b38567d", + "reference": "c808a0c85c38c8ee265cc8405b456c1d2b38567d", + "shasum": "" + }, + "require": { + "doctrine/data-fixtures": "^1.3", + "doctrine/doctrine-bundle": "^2.2", + "doctrine/orm": "^2.14.0 || ^3.0", + "doctrine/persistence": "^2.4|^3.0", + "php": "^7.4 || ^8.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/doctrine-bridge": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0" + }, + "conflict": { + "doctrine/dbal": "< 3" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10.39", + "phpunit/phpunit": "^9.6.13", + "symfony/phpunit-bridge": "^6.3.6", + "vimeo/psalm": "^5.15" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\FixturesBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DoctrineFixturesBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "Fixture", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", + "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/3.5.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle", + "type": "tidelift" + } + ], + "time": "2023-11-19T12:48:54+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.23.0", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e3daa170d00fde61ea7719ef47bb09bb8f1d9b01" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e3daa170d00fde61ea7719ef47bb09bb8f1d9b01", + "reference": "e3daa170d00fde61ea7719ef47bb09bb8f1d9b01", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "v1.21-dev" + } + }, + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.23.0" + }, + "time": "2023-06-12T08:44:38+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.8.1", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "f47dcf3c70c584de14f21143c55d9939631bc6cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f47dcf3c70c584de14f21143c55d9939631bc6cf", + "reference": "f47dcf3c70c584de14f21143c55d9939631bc6cf", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.8.1" + }, + "time": "2023-05-10T11:58:31+00:00" + }, + { + "name": "mtdowling/jmespath.php", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/bbb69a935c2cbb0c03d7f481a238027430f6440b", + "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.7.0" + }, + "time": "2023-08-25T10:54:48+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.11.1", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3,<3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2023-03-08T13:26:56+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v4.17.1", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=7.0" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" + }, + "time": "2023-08-13T19:53:39+00:00" }, { "name": "phar-io/manifest", @@ -9481,6 +10182,285 @@ } ], "time": "2023-11-20T00:12:19+00:00" + }, + { + "name": "zenstruck/assert", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/zenstruck/assert.git", + "reference": "60956bb6584a51c6c2ab9fa8707b7c013d770163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zenstruck/assert/zipball/60956bb6584a51c6c2ab9fa8707b7c013d770163", + "reference": "60956bb6584a51c6c2ab9fa8707b7c013d770163", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5.4|^6.0|^7.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9.5", + "symfony/phpunit-bridge": "^6.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Zenstruck\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + } + ], + "description": "Standalone, lightweight, framework agnostic, test assertion library.", + "homepage": "https://github.com/zenstruck/assert", + "keywords": [ + "assertion", + "phpunit", + "test" + ], + "support": { + "issues": "https://github.com/zenstruck/assert/issues", + "source": "https://github.com/zenstruck/assert/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://github.com/kbond", + "type": "github" + } + ], + "time": "2023-12-02T09:08:04+00:00" + }, + { + "name": "zenstruck/browser", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/zenstruck/browser.git", + "reference": "9e931f5d5a62007f6642bec47992f54574ebc5f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zenstruck/browser/zipball/9e931f5d5a62007f6642bec47992f54574ebc5f1", + "reference": "9e931f5d5a62007f6642bec47992f54574ebc5f1", + "shasum": "" + }, + "require": { + "behat/mink": "^1.8", + "php": ">=8.0", + "symfony/browser-kit": "^5.4|^6.0|^7.0", + "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0", + "zenstruck/assert": "^1.1", + "zenstruck/callback": "^1.4.2" + }, + "require-dev": { + "dbrekelmans/bdi": "^1.0", + "justinrainbow/json-schema": "^5.2", + "mtdowling/jmespath.php": "^2.6", + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9.5", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/panther": "^1.1|^2.0.1", + "symfony/phpunit-bridge": "^6.0|^7.0", + "symfony/security-bundle": "^5.4|^6.0|^7.0", + "zenstruck/foundry": "^1.30" + }, + "suggest": { + "justinrainbow/json-schema": "Json schema validator. Needed to use Json::assertMatchesSchema().", + "mtdowling/jmespath.php": "PHP implementation for JMESPath. Needed to use Json assertions." + }, + "type": "library", + "autoload": { + "psr-4": { + "Zenstruck\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + } + ], + "description": "A fluent interface for your Symfony functional tests.", + "homepage": "https://github.com/zenstruck/browser", + "keywords": [ + "symfony", + "test" + ], + "support": { + "issues": "https://github.com/zenstruck/browser/issues", + "source": "https://github.com/zenstruck/browser/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://github.com/kbond", + "type": "github" + } + ], + "time": "2023-10-31T17:14:32+00:00" + }, + { + "name": "zenstruck/callback", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/zenstruck/callback.git", + "reference": "eed9a532fd8974368e60c4a2550ed65eab7e5432" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zenstruck/callback/zipball/eed9a532fd8974368e60c4a2550ed65eab7e5432", + "reference": "eed9a532fd8974368e60c4a2550ed65eab7e5432", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.14" + }, + "require-dev": { + "symfony/phpunit-bridge": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Zenstruck\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + } + ], + "description": "Callable wrapper to validate and inject arguments.", + "homepage": "https://github.com/zenstruck/callback", + "keywords": [ + "callable", + "callback", + "utility" + ], + "support": { + "issues": "https://github.com/zenstruck/callback/issues", + "source": "https://github.com/zenstruck/callback/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://github.com/kbond", + "type": "github" + } + ], + "time": "2022-08-31T14:56:15+00:00" + }, + { + "name": "zenstruck/foundry", + "version": "v1.36.0", + "source": { + "type": "git", + "url": "https://github.com/zenstruck/foundry.git", + "reference": "1aefc394059a315ef8567de9745efd5ba6aacfd0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zenstruck/foundry/zipball/1aefc394059a315ef8567de9745efd5ba6aacfd0", + "reference": "1aefc394059a315ef8567de9745efd5ba6aacfd0", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^1.3.3|^2.0|^3.0", + "fakerphp/faker": "^1.10", + "php": ">=8.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/string": "^5.4|^6.0|^7.0", + "zenstruck/assert": "^1.0", + "zenstruck/callback": "^1.1" + }, + "conflict": { + "doctrine/mongodb-odm": "2.5.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4", + "dama/doctrine-test-bundle": "^7.0", + "doctrine/doctrine-bundle": "^2.5", + "doctrine/doctrine-migrations-bundle": "^2.2|^3.0", + "doctrine/mongodb-odm": "^2.0", + "doctrine/mongodb-odm-bundle": "^4.4.0", + "doctrine/orm": "^2.9", + "matthiasnoback/symfony-dependency-injection-test": "^4.1", + "symfony/framework-bundle": "^5.4|^6.0|^7.0", + "symfony/maker-bundle": "^1.49", + "symfony/phpunit-bridge": "^5.4|^6.0|^7.0", + "symfony/translation-contracts": "^2.5|^3.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "target-directory": "bin/tools", + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Zenstruck\\Foundry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + } + ], + "description": "A model factory library for creating expressive, auto-completable, on-demand dev/test fixtures with Symfony and Doctrine.", + "homepage": "https://github.com/zenstruck/foundry", + "keywords": [ + "Fixture", + "dev", + "doctrine", + "factory", + "faker", + "symfony", + "test" + ], + "support": { + "issues": "https://github.com/zenstruck/foundry/issues", + "source": "https://github.com/zenstruck/foundry/tree/v1.36.0" + }, + "funding": [ + { + "url": "https://github.com/kbond", + "type": "github" + } + ], + "time": "2023-10-13T18:44:57+00:00" } ], "aliases": [], diff --git a/config/bundles.php b/config/bundles.php index 770ad3c..eaa8354 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -11,4 +11,9 @@ return [ Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], + Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], + ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], + Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true], + Symfonycasts\MicroMapper\SymfonycastsMicroMapperBundle::class => ['all' => true], ]; diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml new file mode 100644 index 0000000..47fc8f5 --- /dev/null +++ b/config/packages/api_platform.yaml @@ -0,0 +1,21 @@ +api_platform: + title: Matsen API Platform + version: 1.0.0 + formats: + jsonld: [ 'application/ld+json' ] + json: [ 'application/json' ] + html: [ 'text/html' ] + jsonhal: [ 'application/hal+json' ] + docs_formats: + jsonld: ['application/ld+json'] + jsonopenapi: ['application/vnd.openapi+json'] + html: ['text/html'] + defaults: + stateless: true + cache_headers: + vary: ['Content-Type', 'Authorization', 'Origin'] + extra_properties: + standard_put: true + rfc_7807_compliant_errors: true + event_listeners_backward_compatibility_layer: false + keep_legacy_inflector: false diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml new file mode 100644 index 0000000..c766508 --- /dev/null +++ b/config/packages/nelmio_cors.yaml @@ -0,0 +1,10 @@ +nelmio_cors: + defaults: + origin_regex: true + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization'] + expose_headers: ['Link'] + max_age: 3600 + paths: + '^/': null diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 367af25..dcb8fc1 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -4,14 +4,22 @@ security: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - users_in_memory: { memory: null } + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + entity: + class: App\Entity\User + property: email firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true - provider: users_in_memory + provider: app_user_provider + json_login: + check_path: app_login + username_path: email + password_path: password # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall diff --git a/config/packages/zenstruck_foundry.yaml b/config/packages/zenstruck_foundry.yaml new file mode 100644 index 0000000..0657d2c --- /dev/null +++ b/config/packages/zenstruck_foundry.yaml @@ -0,0 +1,7 @@ +when@dev: &dev + # See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration + zenstruck_foundry: + # Whether to auto-refresh proxies by default (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#auto-refresh) + auto_refresh_proxies: true + +when@test: *dev diff --git a/config/routes.yaml b/config/routes.yaml index 41ef814..7c25751 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -3,3 +3,4 @@ controllers: path: ../src/Controller/ namespace: App\Controller type: attribute + stateless: false \ No newline at end of file diff --git a/config/routes/api_platform.yaml b/config/routes/api_platform.yaml new file mode 100644 index 0000000..35cafe3 --- /dev/null +++ b/config/routes/api_platform.yaml @@ -0,0 +1,5 @@ +api_platform: + resource: . + type: api_platform + prefix: /api + stateless: false diff --git a/migrations/Version20231214140601.php b/migrations/Version20231214140601.php new file mode 100644 index 0000000..581efd6 --- /dev/null +++ b/migrations/Version20231214140601.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE posting (id INT AUTO_INCREMENT NOT NULL, owner_id INT NOT NULL, message LONGTEXT NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_BD275D737E3C61F9 (owner_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE `user` (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, roles JSON NOT NULL COMMENT \'(DC2Type:json)\', password VARCHAR(255) NOT NULL, active TINYINT(1) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_8D93D649E7927C74 (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE posting ADD CONSTRAINT FK_BD275D737E3C61F9 FOREIGN KEY (owner_id) REFERENCES `user` (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE posting DROP FOREIGN KEY FK_BD275D737E3C61F9'); + $this->addSql('DROP TABLE posting'); + $this->addSql('DROP TABLE `user`'); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6c4bfed..1063dcf 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -34,5 +34,6 @@ + diff --git a/src/ApiResource/.gitignore b/src/ApiResource/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/ApiResource/PostingApi.php b/src/ApiResource/PostingApi.php new file mode 100644 index 0000000..e6ac88f --- /dev/null +++ b/src/ApiResource/PostingApi.php @@ -0,0 +1,60 @@ + + * @date 12.12.23 + */ + + +namespace App\ApiResource; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use App\Entity\Posting; +use App\State\EntityClassDtoStateProcessor; +use App\State\EntityToDtoStateProvider; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Validator\IsValidOwner; +use Symfony\Component\Validator\Constraints\NotBlank; + +#[ApiResource( + shortName: 'Post', + operations: [ + new Get( + security: 'is_granted("ROLE_USER")' + ), + new GetCollection(), + new Post( + security: 'is_granted("ROLE_USER")', + ), + new Patch( + security: 'is_granted("EDIT", object)', + ), + new Delete( + security: 'is_granted("ROLE_ADMIN")', + ) + ], + paginationItemsPerPage: 10, + security: 'is_granted("ROLE_USER")', + provider: EntityToDtoStateProvider::class, + processor: EntityClassDtoStateProcessor::class, + stateOptions: new Options(entityClass: Posting::class), +)] +class PostingApi +{ + #[ApiProperty(readable: false, writable: false, identifier: true)] + public ?int $id = null; + + #[NotBlank] + public ?string $message = null; + + #[IsValidOwner] + public ?UserApi $owner = null; + + #[ApiProperty(writable: false)] + public ?\DateTimeImmutable $createdAt = null; +} \ No newline at end of file diff --git a/src/ApiResource/UserApi.php b/src/ApiResource/UserApi.php new file mode 100644 index 0000000..774ca98 --- /dev/null +++ b/src/ApiResource/UserApi.php @@ -0,0 +1,82 @@ + + * @date 12.12.23 + */ + + +namespace App\ApiResource; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Entity\User; +use App\State\EntityClassDtoStateProcessor; +use App\State\EntityToDtoStateProvider; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + shortName: 'User', + operations: [ + new Get( + security: 'is_granted("ROLE_USER")' + ), + new GetCollection( + security: 'is_granted("ROLE_USER")' + ), + new Post( + security: 'is_granted("PUBLIC_ACCESS")', + validationContext: ['groups' => ['Default', 'postValidation']], + ), + new Patch( + security: 'is_granted("ROLE_USER")' + ), + ], + paginationItemsPerPage: 10, + security: 'is_granted("ROLE_USER")', + provider: EntityToDtoStateProvider::class, + processor: EntityClassDtoStateProcessor::class, + stateOptions: new Options(entityClass: User::class), + +)] +class UserApi +{ + #[ApiProperty(readable: false, writable: false, identifier: true)] + public ?int $id = null; + + #[Assert\NotBlank] + #[Assert\Email] + public ?string $email = null; + + #[Assert\NotBlank] + public ?string $firstName = null; + + #[Assert\NotBlank] + public ?string $lastName = null; + + /** + * The plaintext password when being set or changed. + */ + #[ApiProperty(readable: false)] + #[Assert\NotBlank(groups: ['postValidation'])] + public ?string $password = null; + + // Object is null ONLY during deserialization: so this allows isPublished + // to be writable in ALL cases (which is ok because the operations are secured). + // During serialization, object will always be a DragonTreasureApi, so our + // voter is called. + #[ApiProperty(security: 'object === null or is_granted("EDIT", object)')] + public bool $active; + + #[ApiProperty(writable: false)] + public ?\DateTimeImmutable $createdAt = null; + + /** + * @var array + */ + public array $userPosts = []; +} \ No newline at end of file diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000..c3d0934 --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,33 @@ +json([ + 'error' => 'Invalid login request: check that the Content-Type header is "application/json".', + ], 401); + } + + return new Response(null, 204, [ + 'Location' => $iriConverter->getIriFromResource($user), + ]); + } + + #[Route('/logout', name: 'app_logout')] + public function logout(): void + { + throw new \Exception('This should never be reached!'); + } +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php new file mode 100644 index 0000000..f2bf156 --- /dev/null +++ b/src/DataFixtures/AppFixtures.php @@ -0,0 +1,41 @@ + 'd.knudsen@spawntree.de', + 'firstName' => 'Daniel', + 'lastName' => 'Knudsen', + 'password' => 'test', + ] + ); + $adminD->setRoles(['ROLE_ADMIN']); + + $adminF = UserFactory::createOne( + [ + 'email' => 'f.eisenmenger@spawntree.de', + 'firstName' => 'Florian', + 'lastName' => 'Eisenmenger', + 'password' => 'test', + ] + ); + $adminF->setRoles(['ROLE_ADMIN']); + + UserFactory::createMany(10); + PostingFactory::createMany(50, function() { + return [ + 'owner' => UserFactory::random() + ]; + }); + } +} diff --git a/src/Entity/Posting.php b/src/Entity/Posting.php new file mode 100644 index 0000000..29d27e4 --- /dev/null +++ b/src/Entity/Posting.php @@ -0,0 +1,65 @@ +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setMessage(string $message): static + { + $this->message = $message; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function getOwner(): ?User + { + return $this->owner; + } + + public function setOwner(?User $owner): static + { + $this->owner = $owner; + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..a24f35c --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,196 @@ +createdAt = new \DateTimeImmutable(); + $this->userPosts = new ArrayCollection(); + $this->active = true; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): static + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function eraseCredentials(): void + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + /** + * @return Collection + */ + public function getUserPosts(): Collection + { + return $this->userPosts; + } + + public function addUserPost(Posting $post): static + { + if (!$this->userPosts->contains($post)) { + $this->userPosts->add($post); + $post->setOwner($this); + } + + return $this; + } + + public function removeUserPost(Posting $post): static + { + if ($this->userPosts->removeElement($post)) { + // set the owning side to null (unless already changed) + if ($post->getOwner() === $this) { + $post->setOwner(null); + } + } + + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): static + { + $this->active = $active; + + return $this; + } +} diff --git a/src/Factory/PostingFactory.php b/src/Factory/PostingFactory.php new file mode 100644 index 0000000..c73838e --- /dev/null +++ b/src/Factory/PostingFactory.php @@ -0,0 +1,69 @@ + + * + * @method Posting|Proxy create(array|callable $attributes = []) + * @method static Posting|Proxy createOne(array $attributes = []) + * @method static Posting|Proxy find(object|array|mixed $criteria) + * @method static Posting|Proxy findOrCreate(array $attributes) + * @method static Posting|Proxy first(string $sortedField = 'id') + * @method static Posting|Proxy last(string $sortedField = 'id') + * @method static Posting|Proxy random(array $attributes = []) + * @method static Posting|Proxy randomOrCreate(array $attributes = []) + * @method static PostingRepository|RepositoryProxy repository() + * @method static Posting[]|Proxy[] all() + * @method static Posting[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static Posting[]|Proxy[] createSequence(iterable|callable $sequence) + * @method static Posting[]|Proxy[] findBy(array $attributes) + * @method static Posting[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static Posting[]|Proxy[] randomSet(int $number, array $attributes = []) + */ +final class PostingFactory extends ModelFactory +{ + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services + * + * @todo inject services if required + */ + public function __construct() + { + parent::__construct(); + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories + * + * @todo add your default values here + */ + protected function getDefaults(): array + { + return [ + 'message' => self::faker()->text(), + 'owner' => UserFactory::new(), + ]; + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization + */ + protected function initialize(): self + { + return $this + // ->afterInstantiate(function(Post $post): void {}) + ; + } + + protected static function getClass(): string + { + return Posting::class; + } +} diff --git a/src/Factory/UserFactory.php b/src/Factory/UserFactory.php new file mode 100644 index 0000000..77aea1e --- /dev/null +++ b/src/Factory/UserFactory.php @@ -0,0 +1,94 @@ + + * + * @method User|Proxy create(array|callable $attributes = []) + * @method static User|Proxy createOne(array $attributes = []) + * @method static User|Proxy find(object|array|mixed $criteria) + * @method static User|Proxy findOrCreate(array $attributes) + * @method static User|Proxy first(string $sortedField = 'id') + * @method static User|Proxy last(string $sortedField = 'id') + * @method static User|Proxy random(array $attributes = []) + * @method static User|Proxy randomOrCreate(array $attributes = []) + * @method static UserRepository|RepositoryProxy repository() + * @method static User[]|Proxy[] all() + * @method static User[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static User[]|Proxy[] createSequence(iterable|callable $sequence) + * @method static User[]|Proxy[] findBy(array $attributes) + * @method static User[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static User[]|Proxy[] randomSet(int $number, array $attributes = []) + */ +final class UserFactory extends ModelFactory +{ + const FIRST_NAMES = [ + 'Alice', 'Bob', 'Charlie', 'David', 'Emma', + 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack', + 'Kate', 'Liam', 'Mia', 'Noah', 'Olivia', + 'Paul', 'Quinn', 'Ryan', 'Sophia', 'Thomas', + 'Ursula', 'Victor', 'Wendy', 'Xander', 'Yvonne' + ]; + + const LAST_NAMES = [ + 'Smith', 'Johnson', 'Williams', 'Jones', 'Brown', + 'Davis', 'Miller', 'Wilson', 'Moore', 'Taylor', + 'Anderson', 'Thomas', 'Jackson', 'White', 'Harris', + 'Martin', 'Thompson', 'Garcia', 'Martinez', 'Robinson', + 'Clark', 'Lewis', 'Lee', 'Walker', 'Hall' + ]; + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services + * + * @todo inject services if required + */ + public function __construct(private UserPasswordHasherInterface $passwordHasher) + { + parent::__construct(); + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories + * + * @todo add your default values here + */ + protected function getDefaults(): array + { + return [ + 'email' => self::faker()->email(), + 'firstName' => self::faker()->randomElement(self::FIRST_NAMES), + 'lastName' => self::faker()->randomElement(self::LAST_NAMES), + 'password' => "test", + 'roles' => [], + ]; + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization + */ + protected function initialize(): self + { + return $this + ->afterInstantiate(function(User $user): void { + $user->setPassword($this->passwordHasher->hashPassword( + $user, + $user->getPassword() + )); + }) + ; + } + + protected static function getClass(): string + { + return User::class; + } +} diff --git a/src/Mapper/PostingApiToEntityMapper.php b/src/Mapper/PostingApiToEntityMapper.php new file mode 100644 index 0000000..a7f4cf0 --- /dev/null +++ b/src/Mapper/PostingApiToEntityMapper.php @@ -0,0 +1,61 @@ +id ? $this->repository->find($dto->id) : new Posting(); + if (!$entity) { + throw new \Exception('DragonTreasure not found'); + } + + return $entity; + } + + public function populate(object $from, object $to, array $context): object + { + $dto = $from; + $entity = $to; + assert($dto instanceof PostingApi); + assert($entity instanceof Posting); + + if ($dto->owner) { + $entity->setOwner($this->microMapper->map($dto->owner, User::class, [ + MicroMapperInterface::MAX_DEPTH => 0, + ])); + } else { + $entity->setOwner($this->security->getUser()); + } + + $entity->setMessage($dto->message); + + return $entity; + } +} diff --git a/src/Mapper/PostingEntityToApiMapper.php b/src/Mapper/PostingEntityToApiMapper.php new file mode 100644 index 0000000..52fcb41 --- /dev/null +++ b/src/Mapper/PostingEntityToApiMapper.php @@ -0,0 +1,51 @@ +id = $entity->getId(); + + return $dto; + } + + public function populate(object $from, object $to, array $context): object + { + $entity = $from; + $dto = $to; + assert($entity instanceof Posting); + assert($dto instanceof PostingApi); + + $dto->message = $entity->getMessage(); + $dto->owner = $this->microMapper->map($entity->getOwner(), UserApi::class, [ + MicroMapperInterface::MAX_DEPTH => 0, + ]); + $dto->createdAt = $entity->getCreatedAt(); + + return $dto; + } +} diff --git a/src/Mapper/UserApiToEntityMapper.php b/src/Mapper/UserApiToEntityMapper.php new file mode 100644 index 0000000..d218d94 --- /dev/null +++ b/src/Mapper/UserApiToEntityMapper.php @@ -0,0 +1,65 @@ +id ? $this->userRepository->find($dto->id) : new User(); + if (!$userEntity) { + throw new \Exception('User not found'); + } + + return $userEntity; + } + + public function populate(object $from, object $to, array $context): object + { + $dto = $from; + assert($dto instanceof UserApi); + $entity = $to; + assert($entity instanceof User); + + $entity->setEmail($dto->email); + $entity->setFirstName($dto->firstName); + $entity->setLastName($dto->lastName); + if ($dto->password) { + $entity->setPassword($this->userPasswordHasher->hashPassword($entity, $dto->password)); + } + + $userPostsEntities = []; + foreach ($dto->userPosts as $userPostApi) { + $userPostsEntities[] = $this->microMapper->map($userPostApi, Posting::class, [ + MicroMapperInterface::MAX_DEPTH => 0, + ]); + } + $this->propertyAccessor->setValue($entity, 'userPosts', $userPostsEntities); + + return $entity; + } +} diff --git a/src/Mapper/UserEntityToApiMapper.php b/src/Mapper/UserEntityToApiMapper.php new file mode 100644 index 0000000..ccf7086 --- /dev/null +++ b/src/Mapper/UserEntityToApiMapper.php @@ -0,0 +1,51 @@ +id = $entity->getId(); + + return $dto; + } + + public function populate(object $from, object $to, array $context): object + { + $entity = $from; + $dto = $to; + assert($entity instanceof User); + assert($dto instanceof UserApi); + + $dto->email = $entity->getEmail(); + $dto->firstName = $entity->getFirstName(); + $dto->lastName = $entity->getLastName(); + $dto->userPosts = array_map(function(Posting $userPost) { + return $this->microMapper->map($userPost, PostingApi::class, [ + MicroMapperInterface::MAX_DEPTH => 0, + ]); + }, $entity->getUserPosts()->getValues()); + + return $dto; + } +} diff --git a/src/Repository/PostingRepository.php b/src/Repository/PostingRepository.php new file mode 100644 index 0000000..a56b69f --- /dev/null +++ b/src/Repository/PostingRepository.php @@ -0,0 +1,48 @@ + + * + * @method Posting|null find($id, $lockMode = null, $lockVersion = null) + * @method Posting|null findOneBy(array $criteria, array $orderBy = null) + * @method Posting[] findAll() + * @method Posting[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class PostingRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Posting::class); + } + +// /** +// * @return Post[] Returns an array of Post objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('p') +// ->andWhere('p.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('p.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?Post +// { +// return $this->createQueryBuilder('p') +// ->andWhere('p.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..c788f46 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,67 @@ + + * + * @implements PasswordUpgraderInterface + * + * @method User|null find($id, $lockMode = null, $lockVersion = null) + * @method User|null findOneBy(array $criteria, array $orderBy = null) + * @method User[] findAll() + * @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class)); + } + + $user->setPassword($newHashedPassword); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } + +// /** +// * @return User[] Returns an array of User objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('u') +// ->andWhere('u.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('u.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?User +// { +// return $this->createQueryBuilder('u') +// ->andWhere('u.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/src/State/EntityClassDtoStateProcessor.php b/src/State/EntityClassDtoStateProcessor.php new file mode 100644 index 0000000..b53846f --- /dev/null +++ b/src/State/EntityClassDtoStateProcessor.php @@ -0,0 +1,49 @@ +getStateOptions(); + assert($stateOptions instanceof Options); + $entityClass = $stateOptions->getEntityClass(); + + $entity = $this->mapDtoToEntity($data, $entityClass); + + if ($operation instanceof DeleteOperationInterface) { + $this->removeProcessor->process($entity, $operation, $uriVariables, $context); + + return null; + } + + $this->persistProcessor->process($entity, $operation, $uriVariables, $context); + $data->id = $entity->getId(); + + return $data; + } + + private function mapDtoToEntity(object $dto, string $entityClass): object + { + return $this->microMapper->map($dto, $entityClass); + } +} diff --git a/src/State/EntityToDtoStateProvider.php b/src/State/EntityToDtoStateProvider.php new file mode 100644 index 0000000..de489de --- /dev/null +++ b/src/State/EntityToDtoStateProvider.php @@ -0,0 +1,59 @@ +getClass(); + if ($operation instanceof CollectionOperationInterface) { + $entities = $this->collectionProvider->provide($operation, $uriVariables, $context); + assert($entities instanceof Paginator); + + $dtos = []; + foreach ($entities as $entity) { + $dtos[] = $this->mapEntityToDto($entity, $resourceClass); + } + + return new TraversablePaginator( + new \ArrayIterator($dtos), + $entities->getCurrentPage(), + $entities->getItemsPerPage(), + $entities->getTotalItems() + ); + } + + $entity = $this->itemProvider->provide($operation, $uriVariables, $context); + + if (!$entity) { + return null; + } + + return $this->mapEntityToDto($entity, $resourceClass); + } + + private function mapEntityToDto(object $entity, string $resourceClass): object + { + return $this->microMapper->map($entity, $resourceClass); + } +} diff --git a/src/Validator/IsValidOwner.php b/src/Validator/IsValidOwner.php new file mode 100644 index 0000000..034ceda --- /dev/null +++ b/src/Validator/IsValidOwner.php @@ -0,0 +1,20 @@ +security->getUser(); + if (!$user) { + throw new \LogicException('IsOwnerValidator should only be used when a user is logged in.'); + } + assert($user instanceof User); + + if ($value->id !== $user->getId()) { + $this->context->buildViolation($constraint->message) + ->addViolation(); + } + } +} diff --git a/src/Voter/PostingApiVoter.php b/src/Voter/PostingApiVoter.php new file mode 100644 index 0000000..00a880d --- /dev/null +++ b/src/Voter/PostingApiVoter.php @@ -0,0 +1,49 @@ +getUser(); + // if the user is anonymous, do not grant access + if (!$user instanceof User) { + return false; + } + + if ($this->security->isGranted('ROLE_ADMIN')) { + return true; + } + + assert($subject instanceof PostingApi); + + // ... (check conditions and return true to grant permission) ... + switch ($attribute) { + case self::EDIT: + if ($subject->owner?->id === $user->getId()) { + return true; + } + break; + } + + return false; + } +} diff --git a/src/Voter/UserApiVoter.php b/src/Voter/UserApiVoter.php new file mode 100644 index 0000000..7325600 --- /dev/null +++ b/src/Voter/UserApiVoter.php @@ -0,0 +1,50 @@ +getUser(); + // if the user is anonymous, do not grant access + if (!$user instanceof User) { + return false; + } + + if ($this->security->isGranted('ROLE_ADMIN')) { + return true; + } + + assert($subject instanceof UserApi); + + // ... (check conditions and return true to grant permission) ... + switch ($attribute) { + case self::EDIT: + if ($subject->id === $user->getId()) { + return true; + } + break; + } + + return false; + } +} diff --git a/symfony.lock b/symfony.lock index 48250b2..0c12af2 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,4 +1,18 @@ { + "api-platform/core": { + "version": "3.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.2", + "ref": "696d44adc3c0d4f5d25a2f1c4f3700dd8a5c6db9" + }, + "files": [ + "config/packages/api_platform.yaml", + "config/routes/api_platform.yaml", + "src/ApiResource/.gitignore" + ] + }, "doctrine/doctrine-bundle": { "version": "2.11", "recipe": { @@ -13,6 +27,18 @@ "src/Repository/.gitignore" ] }, + "doctrine/doctrine-fixtures-bundle": { + "version": "3.5", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.0", + "ref": "1f5514cfa15b947298df4d771e694e578d4c204d" + }, + "files": [ + "src/DataFixtures/AppFixtures.php" + ] + }, "doctrine/doctrine-migrations-bundle": { "version": "3.3", "recipe": { @@ -26,6 +52,18 @@ "migrations/.gitignore" ] }, + "nelmio/cors-bundle": { + "version": "2.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.5", + "ref": "6bea22e6c564fba3a1391615cada1437d0bde39c" + }, + "files": [ + "config/packages/nelmio_cors.yaml" + ] + }, "phpunit/phpunit": { "version": "9.6", "recipe": { @@ -244,7 +282,22 @@ "config/routes/web_profiler.yaml" ] }, + "symfonycasts/micro-mapper": { + "version": "v0.1.4" + }, "twig/extra-bundle": { "version": "v3.8.0" + }, + "zenstruck/foundry": { + "version": "1.36", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.10", + "ref": "37c2f894cc098ab4c08874b80cccc8e2f8de7976" + }, + "files": [ + "config/packages/zenstruck_foundry.yaml" + ] } } diff --git a/tests/Functional/UserResourceTest.php b/tests/Functional/UserResourceTest.php new file mode 100644 index 0000000..d65e387 --- /dev/null +++ b/tests/Functional/UserResourceTest.php @@ -0,0 +1,114 @@ + + * @date 12.12.23 + */ + + +namespace App\Tests\Functional; + +use App\Factory\UserFactory; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Browser\Json; +use Zenstruck\Browser\Test\HasBrowser; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; + +class UserResourceTest extends KernelTestCase +{ + use HasBrowser; + use ResetDatabase; + use Factories; + + public function testPostToCreateUser(): void + { + $this->browser() + ->post('/api/users', [ + 'json' => [ + 'email' => 'draggin_in_the_morning@coffee.com', + 'firstName' => 'Danny', + 'lastName' => 'Boy', + 'password' => 'password', + ] + ]) + ->assertStatus(201) + ->use(function (Json $json) { + $json->assertMissing('password'); + $json->assertMissing('id'); + }) + ->post('/login', [ + 'json' => [ + 'email' => 'draggin_in_the_morning@coffee.com', + 'password' => 'password', + ] + ]) + ->assertSuccessful() + ; + } + + public function testGetUsersWithoutAuthentication() + { + UserFactory::createMany(2); + $this->browser() + ->get('/api/users') + ->assertStatus(401) + ; + } + + public function testGetOneUserWithoutAuthentication() + { + UserFactory::createOne(); + $this->browser() + ->get('/api/users/1') + ->assertStatus(401) + ; + } + + public function testPatchUserAsSameUser() + { + $user = UserFactory::createOne( + [ + 'firstName' => 'John', + 'lastName' => 'Doe' + ] + ); + + $this->browser() + ->actingAs($user) + ->patch('/api/users/' . $user->getId(), [ + 'json' => [ + 'firstName' => 'Joe', + 'lastName' => 'Black' + ], + 'headers' => ['Content-Type' => 'application/merge-patch+json'] + ]) + ->assertStatus(200) + ->get('/api/users/' . $user->getId()) + ->assertStatus(200) + ->assertJsonMatches('firstName', 'Joe') + ->assertJsonMatches('lastName', 'Black') + ; + } + + public function testPatchUserInactiveAsSameUser() + { + $user = UserFactory::createOne( + [ + 'firstName' => 'John' + ] + ); + + $this->browser() + ->actingAs($user) + ->patch('/api/users/' . $user->getId(), [ + 'json' => [ + 'firstName' => 'Joe' + ], + 'headers' => ['Content-Type' => 'application/merge-patch+json'] + ]) + ->assertStatus(200) + ->get('/api/users/' . $user->getId()) + ->assertJsonMatches('firstName', 'A shiny thing') + ; + } +} \ No newline at end of file