| @@ -39,3 +39,7 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 | |||||
| ###> symfony/mailer ### | ###> symfony/mailer ### | ||||
| # MAILER_DSN=null://null | # MAILER_DSN=null://null | ||||
| ###< symfony/mailer ### | ###< symfony/mailer ### | ||||
| ###> nelmio/cors-bundle ### | |||||
| CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' | |||||
| ###< nelmio/cors-bundle ### | |||||
| @@ -1,6 +1,6 @@ | |||||
| # matsen-tool-be | # matsen-tool-be | ||||
| Installation: | |||||
| # Installation: | |||||
| gehe ins root Verzeichnis des Projekts und für aus: ddev config | gehe ins root Verzeichnis des Projekts und für aus: ddev config | ||||
| @@ -10,24 +10,78 @@ Installation: | |||||
| projekt installieren: ddev composer install | projekt installieren: ddev composer install | ||||
| Troubleshooting: | |||||
| # Troubleshooting: | |||||
| Unable to listen on required ports, port 443 is already in use | Unable to listen on required ports, port 443 is already in use | ||||
| -> setze andere Ports in .ddev/config.yaml z.B. | -> setze andere Ports in .ddev/config.yaml z.B. | ||||
| router_http_port: 8080 | router_http_port: 8080 | ||||
| router_https_port: 8443 | 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 get ddev/ddev-phpmyadmin | ||||
| ddev restart | 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 exec php bin/console make:migration | ||||
| Ddev Commands: | |||||
| # Ddev Commands: | |||||
| ddev describe - zeigt Urls und installierte Komponenten | 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 | |||||
| <extensions> | |||||
| <extension class="Zenstruck\Browser\Test\BrowserExtension" /> | |||||
| </extensions> | |||||
| 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 | |||||
| @@ -7,9 +7,11 @@ | |||||
| "php": ">=8.2", | "php": ">=8.2", | ||||
| "ext-ctype": "*", | "ext-ctype": "*", | ||||
| "ext-iconv": "*", | "ext-iconv": "*", | ||||
| "api-platform/core": "^3.2", | |||||
| "doctrine/doctrine-bundle": "^2.11", | "doctrine/doctrine-bundle": "^2.11", | ||||
| "doctrine/doctrine-migrations-bundle": "^3.3", | "doctrine/doctrine-migrations-bundle": "^3.3", | ||||
| "doctrine/orm": "^2.17", | "doctrine/orm": "^2.17", | ||||
| "nelmio/cors-bundle": "^2.4", | |||||
| "phpdocumentor/reflection-docblock": "^5.3", | "phpdocumentor/reflection-docblock": "^5.3", | ||||
| "phpstan/phpdoc-parser": "^1.24", | "phpstan/phpdoc-parser": "^1.24", | ||||
| "symfony/asset": "7.0.*", | "symfony/asset": "7.0.*", | ||||
| @@ -38,6 +40,7 @@ | |||||
| "symfony/validator": "7.0.*", | "symfony/validator": "7.0.*", | ||||
| "symfony/web-link": "7.0.*", | "symfony/web-link": "7.0.*", | ||||
| "symfony/yaml": "7.0.*", | "symfony/yaml": "7.0.*", | ||||
| "symfonycasts/micro-mapper": "^0.1.4", | |||||
| "twig/extra-bundle": "^2.12|^3.0", | "twig/extra-bundle": "^2.12|^3.0", | ||||
| "twig/twig": "^2.12|^3.0" | "twig/twig": "^2.12|^3.0" | ||||
| }, | }, | ||||
| @@ -91,13 +94,17 @@ | |||||
| } | } | ||||
| }, | }, | ||||
| "require-dev": { | "require-dev": { | ||||
| "doctrine/doctrine-fixtures-bundle": "^3.5", | |||||
| "mtdowling/jmespath.php": "^2.7", | |||||
| "phpunit/phpunit": "^9.5", | "phpunit/phpunit": "^9.5", | ||||
| "symfony/browser-kit": "7.0.*", | "symfony/browser-kit": "7.0.*", | ||||
| "symfony/css-selector": "7.0.*", | "symfony/css-selector": "7.0.*", | ||||
| "symfony/debug-bundle": "7.0.*", | "symfony/debug-bundle": "7.0.*", | ||||
| "symfony/maker-bundle": "^1.0", | |||||
| "symfony/maker-bundle": "^1.52", | |||||
| "symfony/phpunit-bridge": "^7.0", | "symfony/phpunit-bridge": "^7.0", | ||||
| "symfony/stopwatch": "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" | |||||
| } | } | ||||
| } | } | ||||
| @@ -11,4 +11,9 @@ return [ | |||||
| Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], | ||||
| Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], | ||||
| Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => 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], | |||||
| ]; | ]; | ||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -4,14 +4,22 @@ security: | |||||
| Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' | ||||
| # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider | # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider | ||||
| providers: | 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: | firewalls: | ||||
| dev: | dev: | ||||
| pattern: ^/(_(profiler|wdt)|css|images|js)/ | pattern: ^/(_(profiler|wdt)|css|images|js)/ | ||||
| security: false | security: false | ||||
| main: | main: | ||||
| lazy: true | 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 | # activate different ways to authenticate | ||||
| # https://symfony.com/doc/current/security.html#the-firewall | # https://symfony.com/doc/current/security.html#the-firewall | ||||
| @@ -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 | |||||
| @@ -3,3 +3,4 @@ controllers: | |||||
| path: ../src/Controller/ | path: ../src/Controller/ | ||||
| namespace: App\Controller | namespace: App\Controller | ||||
| type: attribute | type: attribute | ||||
| stateless: false | |||||
| @@ -0,0 +1,5 @@ | |||||
| api_platform: | |||||
| resource: . | |||||
| type: api_platform | |||||
| prefix: /api | |||||
| stateless: false | |||||
| @@ -0,0 +1,35 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace DoctrineMigrations; | |||||
| use Doctrine\DBAL\Schema\Schema; | |||||
| use Doctrine\Migrations\AbstractMigration; | |||||
| /** | |||||
| * Auto-generated Migration: Please modify to your needs! | |||||
| */ | |||||
| final class Version20231214140601 extends AbstractMigration | |||||
| { | |||||
| public function getDescription(): string | |||||
| { | |||||
| return ''; | |||||
| } | |||||
| public function up(Schema $schema): void | |||||
| { | |||||
| // this up() migration is auto-generated, please modify it to your needs | |||||
| $this->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`'); | |||||
| } | |||||
| } | |||||
| @@ -34,5 +34,6 @@ | |||||
| </listeners> | </listeners> | ||||
| <extensions> | <extensions> | ||||
| <extension class="Zenstruck\Browser\Test\BrowserExtension" /> | |||||
| </extensions> | </extensions> | ||||
| </phpunit> | </phpunit> | ||||
| @@ -0,0 +1,60 @@ | |||||
| <?php | |||||
| /** | |||||
| * @author Daniel Knudsen <d.knudsen@spawntree.de> | |||||
| * @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; | |||||
| } | |||||
| @@ -0,0 +1,82 @@ | |||||
| <?php | |||||
| /** | |||||
| * @author Daniel Knudsen <d.knudsen@spawntree.de> | |||||
| * @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<int, PostingApi> | |||||
| */ | |||||
| public array $userPosts = []; | |||||
| } | |||||
| @@ -0,0 +1,33 @@ | |||||
| <?php | |||||
| namespace App\Controller; | |||||
| use ApiPlatform\Api\IriConverterInterface; | |||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |||||
| use Symfony\Component\HttpFoundation\Response; | |||||
| use Symfony\Component\Routing\Annotation\Route; | |||||
| use Symfony\Component\Security\Http\Attribute\CurrentUser; | |||||
| class SecurityController extends AbstractController | |||||
| { | |||||
| #[Route('/login', name: 'app_login', methods: ['POST'])] | |||||
| #[Api] | |||||
| public function login(IriConverterInterface $iriConverter, #[CurrentUser] $user = null): Response | |||||
| { | |||||
| if (!$user) { | |||||
| return $this->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!'); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,41 @@ | |||||
| <?php | |||||
| namespace App\DataFixtures; | |||||
| use App\Factory\PostingFactory; | |||||
| use App\Factory\UserFactory; | |||||
| use Doctrine\Bundle\FixturesBundle\Fixture; | |||||
| use Doctrine\Persistence\ObjectManager; | |||||
| class AppFixtures extends Fixture | |||||
| { | |||||
| public function load(ObjectManager $manager): void | |||||
| { | |||||
| $adminD = UserFactory::createOne( | |||||
| [ | |||||
| 'email' => '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() | |||||
| ]; | |||||
| }); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,65 @@ | |||||
| <?php | |||||
| namespace App\Entity; | |||||
| use App\Repository\PostingRepository; | |||||
| use Doctrine\DBAL\Types\Types; | |||||
| use Doctrine\ORM\Mapping as ORM; | |||||
| #[ORM\Entity(repositoryClass: PostingRepository::class)] | |||||
| class Posting | |||||
| { | |||||
| #[ORM\Id] | |||||
| #[ORM\GeneratedValue] | |||||
| #[ORM\Column] | |||||
| private ?int $id = null; | |||||
| #[ORM\Column(type: Types::TEXT)] | |||||
| private ?string $message = null; | |||||
| #[ORM\Column] | |||||
| private ?\DateTimeImmutable $createdAt = null; | |||||
| #[ORM\ManyToOne(inversedBy: 'userPosts')] | |||||
| #[ORM\JoinColumn(nullable: false)] | |||||
| private ?User $owner = null; | |||||
| public function __construct() | |||||
| { | |||||
| $this->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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,196 @@ | |||||
| <?php | |||||
| namespace App\Entity; | |||||
| use App\Repository\UserRepository; | |||||
| use Doctrine\Common\Collections\ArrayCollection; | |||||
| use Doctrine\Common\Collections\Collection; | |||||
| use Doctrine\ORM\Mapping as ORM; | |||||
| use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; | |||||
| use Symfony\Component\Security\Core\User\UserInterface; | |||||
| #[ORM\Entity(repositoryClass: UserRepository::class)] | |||||
| #[ORM\Table(name: '`user`')] | |||||
| class User implements UserInterface, PasswordAuthenticatedUserInterface | |||||
| { | |||||
| #[ORM\Id] | |||||
| #[ORM\GeneratedValue] | |||||
| #[ORM\Column] | |||||
| private ?int $id = null; | |||||
| #[ORM\Column(length: 180, unique: true)] | |||||
| private ?string $email = null; | |||||
| #[ORM\Column(length: 255)] | |||||
| private ?string $firstName = null; | |||||
| #[ORM\Column(length: 255)] | |||||
| private ?string $lastName = null; | |||||
| #[ORM\Column] | |||||
| private array $roles = []; | |||||
| /** | |||||
| * @var string The hashed password | |||||
| */ | |||||
| #[ORM\Column] | |||||
| private ?string $password = null; | |||||
| #[ORM\Column] | |||||
| private bool $active; | |||||
| #[ORM\Column] | |||||
| private ?\DateTimeImmutable $createdAt = null; | |||||
| #[ORM\OneToMany(mappedBy: 'owner', targetEntity: Posting::class, orphanRemoval: true)] | |||||
| private Collection $userPosts; | |||||
| public function __construct() | |||||
| { | |||||
| $this->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<int, Posting> | |||||
| */ | |||||
| 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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,69 @@ | |||||
| <?php | |||||
| namespace App\Factory; | |||||
| use App\Entity\Posting; | |||||
| use App\Repository\PostingRepository; | |||||
| use Zenstruck\Foundry\ModelFactory; | |||||
| use Zenstruck\Foundry\Proxy; | |||||
| use Zenstruck\Foundry\RepositoryProxy; | |||||
| /** | |||||
| * @extends ModelFactory<Posting> | |||||
| * | |||||
| * @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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,94 @@ | |||||
| <?php | |||||
| namespace App\Factory; | |||||
| use App\Entity\User; | |||||
| use App\Repository\UserRepository; | |||||
| use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; | |||||
| use Zenstruck\Foundry\ModelFactory; | |||||
| use Zenstruck\Foundry\Proxy; | |||||
| use Zenstruck\Foundry\RepositoryProxy; | |||||
| /** | |||||
| * @extends ModelFactory<User> | |||||
| * | |||||
| * @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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,61 @@ | |||||
| <?php | |||||
| namespace App\Mapper; | |||||
| use App\ApiResource\DragonTreasureApi; | |||||
| use App\ApiResource\PostingApi; | |||||
| use App\Entity\DragonTreasure; | |||||
| use App\Entity\User; | |||||
| use App\Entity\Posting; | |||||
| use App\Repository\DragonTreasureRepository; | |||||
| use App\Repository\PostingRepository; | |||||
| use Symfony\Bundle\SecurityBundle\Security; | |||||
| use Symfonycasts\MicroMapper\AsMapper; | |||||
| use Symfonycasts\MicroMapper\MapperInterface; | |||||
| use Symfonycasts\MicroMapper\MicroMapperInterface; | |||||
| #[AsMapper(from: PostingApi::class, to: Posting::class)] | |||||
| class PostingApiToEntityMapper implements MapperInterface | |||||
| { | |||||
| public function __construct( | |||||
| private PostingRepository $repository, | |||||
| private Security $security, | |||||
| private MicroMapperInterface $microMapper, | |||||
| ) | |||||
| { | |||||
| } | |||||
| public function load(object $from, string $toClass, array $context): object | |||||
| { | |||||
| $dto = $from; | |||||
| assert($dto instanceof PostingApi); | |||||
| $entity = $dto->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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,51 @@ | |||||
| <?php | |||||
| namespace App\Mapper; | |||||
| use App\ApiResource\DragonTreasureApi; | |||||
| use App\ApiResource\UserApi; | |||||
| use App\ApiResource\PostingApi; | |||||
| use App\Entity\DragonTreasure; | |||||
| use App\Entity\Posting; | |||||
| use Symfony\Bundle\SecurityBundle\Security; | |||||
| use Symfonycasts\MicroMapper\AsMapper; | |||||
| use Symfonycasts\MicroMapper\MapperInterface; | |||||
| use Symfonycasts\MicroMapper\MicroMapperInterface; | |||||
| #[AsMapper(from: Posting::class, to: PostingApi::class)] | |||||
| class PostingEntityToApiMapper implements MapperInterface | |||||
| { | |||||
| public function __construct( | |||||
| private MicroMapperInterface $microMapper, | |||||
| private Security $security, | |||||
| ) | |||||
| { | |||||
| } | |||||
| public function load(object $from, string $toClass, array $context): object | |||||
| { | |||||
| $entity = $from; | |||||
| assert($entity instanceof Posting); | |||||
| $dto = new PostingApi(); | |||||
| $dto->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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,65 @@ | |||||
| <?php | |||||
| namespace App\Mapper; | |||||
| use App\ApiResource\UserApi; | |||||
| use App\Entity\DragonTreasure; | |||||
| use App\Entity\User; | |||||
| use App\Entity\Posting; | |||||
| use App\Repository\UserRepository; | |||||
| use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; | |||||
| use Symfony\Component\PropertyAccess\PropertyAccessorInterface; | |||||
| use Symfonycasts\MicroMapper\AsMapper; | |||||
| use Symfonycasts\MicroMapper\MapperInterface; | |||||
| use Symfonycasts\MicroMapper\MicroMapperInterface; | |||||
| #[AsMapper(from: UserApi::class, to: User::class)] | |||||
| class UserApiToEntityMapper implements MapperInterface | |||||
| { | |||||
| public function __construct( | |||||
| private UserRepository $userRepository, | |||||
| private UserPasswordHasherInterface $userPasswordHasher, | |||||
| private MicroMapperInterface $microMapper, | |||||
| private PropertyAccessorInterface $propertyAccessor, | |||||
| ) | |||||
| { | |||||
| } | |||||
| public function load(object $from, string $toClass, array $context): object | |||||
| { | |||||
| $dto = $from; | |||||
| assert($dto instanceof UserApi); | |||||
| $userEntity = $dto->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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,51 @@ | |||||
| <?php | |||||
| namespace App\Mapper; | |||||
| use App\ApiResource\UserApi; | |||||
| use App\ApiResource\PostingApi; | |||||
| use App\Entity\User; | |||||
| use App\Entity\Posting; | |||||
| use Symfonycasts\MicroMapper\AsMapper; | |||||
| use Symfonycasts\MicroMapper\MapperInterface; | |||||
| use Symfonycasts\MicroMapper\MicroMapperInterface; | |||||
| #[AsMapper(from: User::class, to: UserApi::class)] | |||||
| class UserEntityToApiMapper implements MapperInterface | |||||
| { | |||||
| public function __construct( | |||||
| private MicroMapperInterface $microMapper, | |||||
| ) | |||||
| { | |||||
| } | |||||
| public function load(object $from, string $toClass, array $context): object | |||||
| { | |||||
| $entity = $from; | |||||
| assert($entity instanceof User); | |||||
| $dto = new UserApi(); | |||||
| $dto->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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,48 @@ | |||||
| <?php | |||||
| namespace App\Repository; | |||||
| use App\Entity\Posting; | |||||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | |||||
| use Doctrine\Persistence\ManagerRegistry; | |||||
| /** | |||||
| * @extends ServiceEntityRepository<Posting> | |||||
| * | |||||
| * @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() | |||||
| // ; | |||||
| // } | |||||
| } | |||||
| @@ -0,0 +1,67 @@ | |||||
| <?php | |||||
| namespace App\Repository; | |||||
| use App\Entity\User; | |||||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | |||||
| use Doctrine\Persistence\ManagerRegistry; | |||||
| use Symfony\Component\Security\Core\Exception\UnsupportedUserException; | |||||
| use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; | |||||
| use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; | |||||
| /** | |||||
| * @extends ServiceEntityRepository<User> | |||||
| * | |||||
| * @implements PasswordUpgraderInterface<User> | |||||
| * | |||||
| * @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() | |||||
| // ; | |||||
| // } | |||||
| } | |||||
| @@ -0,0 +1,49 @@ | |||||
| <?php | |||||
| namespace App\State; | |||||
| use ApiPlatform\Doctrine\Common\State\PersistProcessor; | |||||
| use ApiPlatform\Doctrine\Common\State\RemoveProcessor; | |||||
| use ApiPlatform\Doctrine\Orm\State\Options; | |||||
| use ApiPlatform\Metadata\DeleteOperationInterface; | |||||
| use ApiPlatform\Metadata\Operation; | |||||
| use ApiPlatform\State\ProcessorInterface; | |||||
| use Symfony\Component\DependencyInjection\Attribute\Autowire; | |||||
| use Symfonycasts\MicroMapper\MicroMapperInterface; | |||||
| class EntityClassDtoStateProcessor implements ProcessorInterface | |||||
| { | |||||
| public function __construct( | |||||
| #[Autowire(service: PersistProcessor::class)] private ProcessorInterface $persistProcessor, | |||||
| #[Autowire(service: RemoveProcessor::class)] private ProcessorInterface $removeProcessor, | |||||
| private MicroMapperInterface $microMapper | |||||
| ) | |||||
| { | |||||
| } | |||||
| public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) | |||||
| { | |||||
| $stateOptions = $operation->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); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,59 @@ | |||||
| <?php | |||||
| namespace App\State; | |||||
| use ApiPlatform\Doctrine\Orm\State\ItemProvider; | |||||
| use ApiPlatform\Metadata\CollectionOperationInterface; | |||||
| use ApiPlatform\Doctrine\Orm\Paginator; | |||||
| use ApiPlatform\Doctrine\Orm\State\CollectionProvider; | |||||
| use ApiPlatform\Metadata\Operation; | |||||
| use ApiPlatform\State\Pagination\TraversablePaginator; | |||||
| use ApiPlatform\State\ProviderInterface; | |||||
| use Symfony\Component\DependencyInjection\Attribute\Autowire; | |||||
| use Symfonycasts\MicroMapper\MicroMapperInterface; | |||||
| class EntityToDtoStateProvider implements ProviderInterface | |||||
| { | |||||
| public function __construct( | |||||
| #[Autowire(service: CollectionProvider::class)] private ProviderInterface $collectionProvider, | |||||
| #[Autowire(service: ItemProvider::class)] private ProviderInterface $itemProvider, | |||||
| private MicroMapperInterface $microMapper | |||||
| ) | |||||
| { | |||||
| } | |||||
| public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |||||
| { | |||||
| $resourceClass = $operation->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); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| <?php | |||||
| namespace App\Validator; | |||||
| use Symfony\Component\Validator\Constraint; | |||||
| /** | |||||
| * @Annotation | |||||
| * | |||||
| * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) | |||||
| */ | |||||
| #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] | |||||
| class IsValidOwner extends Constraint | |||||
| { | |||||
| /* | |||||
| * Any public properties become valid options for the annotation. | |||||
| * Then, use these in your validator class. | |||||
| */ | |||||
| public string $message = 'You are not allowed to set the owner to this value.'; | |||||
| } | |||||
| @@ -0,0 +1,39 @@ | |||||
| <?php | |||||
| namespace App\Validator; | |||||
| use App\ApiResource\UserApi; | |||||
| use App\Entity\User; | |||||
| use Symfony\Bundle\SecurityBundle\Security; | |||||
| use Symfony\Component\Validator\Constraint; | |||||
| use Symfony\Component\Validator\ConstraintValidator; | |||||
| class IsValidOwnerValidator extends ConstraintValidator | |||||
| { | |||||
| public function __construct(private Security $security) | |||||
| { | |||||
| } | |||||
| public function validate($value, Constraint $constraint): void | |||||
| { | |||||
| assert($constraint instanceof IsValidOwner); | |||||
| if (null === $value || '' === $value) { | |||||
| return; | |||||
| } | |||||
| // constraint is only meant to be used above a User property | |||||
| assert($value instanceof UserApi); | |||||
| $user = $this->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(); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,49 @@ | |||||
| <?php | |||||
| namespace App\Voter; | |||||
| use App\ApiResource\PostingApi; | |||||
| use App\Entity\User; | |||||
| use Symfony\Bundle\SecurityBundle\Security; | |||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | |||||
| use Symfony\Component\Security\Core\Authorization\Voter\Voter; | |||||
| class PostingApiVoter extends Voter | |||||
| { | |||||
| public const EDIT = 'EDIT'; | |||||
| public function __construct(private Security $security) | |||||
| { | |||||
| } | |||||
| protected function supports(string $attribute, mixed $subject): bool | |||||
| { | |||||
| return in_array($attribute, [self::EDIT]) && $subject instanceof PostingApi; | |||||
| } | |||||
| protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool | |||||
| { | |||||
| $user = $token->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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,50 @@ | |||||
| <?php | |||||
| namespace App\Voter; | |||||
| use App\ApiResource\UserApi; | |||||
| use App\Entity\User; | |||||
| use Symfony\Bundle\SecurityBundle\Security; | |||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | |||||
| use Symfony\Component\Security\Core\Authorization\Voter\Voter; | |||||
| class UserApiVoter extends Voter | |||||
| { | |||||
| public const EDIT = 'EDIT'; | |||||
| public function __construct(private Security $security) | |||||
| { | |||||
| } | |||||
| protected function supports(string $attribute, mixed $subject): bool | |||||
| { | |||||
| return in_array($attribute, [self::EDIT]) && $subject instanceof UserApi; | |||||
| } | |||||
| protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool | |||||
| { | |||||
| //dd($subject); | |||||
| $user = $token->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; | |||||
| } | |||||
| } | |||||
| @@ -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": { | "doctrine/doctrine-bundle": { | ||||
| "version": "2.11", | "version": "2.11", | ||||
| "recipe": { | "recipe": { | ||||
| @@ -13,6 +27,18 @@ | |||||
| "src/Repository/.gitignore" | "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": { | "doctrine/doctrine-migrations-bundle": { | ||||
| "version": "3.3", | "version": "3.3", | ||||
| "recipe": { | "recipe": { | ||||
| @@ -26,6 +52,18 @@ | |||||
| "migrations/.gitignore" | "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": { | "phpunit/phpunit": { | ||||
| "version": "9.6", | "version": "9.6", | ||||
| "recipe": { | "recipe": { | ||||
| @@ -244,7 +282,22 @@ | |||||
| "config/routes/web_profiler.yaml" | "config/routes/web_profiler.yaml" | ||||
| ] | ] | ||||
| }, | }, | ||||
| "symfonycasts/micro-mapper": { | |||||
| "version": "v0.1.4" | |||||
| }, | |||||
| "twig/extra-bundle": { | "twig/extra-bundle": { | ||||
| "version": "v3.8.0" | "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" | |||||
| ] | |||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,114 @@ | |||||
| <?php | |||||
| /** | |||||
| * @author Daniel Knudsen <d.knudsen@spawntree.de> | |||||
| * @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') | |||||
| ; | |||||
| } | |||||
| } | |||||