| @@ -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 ### | |||
| @@ -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 | |||
| <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", | |||
| "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" | |||
| } | |||
| } | |||
| @@ -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], | |||
| ]; | |||
| @@ -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' | |||
| # 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 | |||
| @@ -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/ | |||
| namespace: App\Controller | |||
| 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> | |||
| <extensions> | |||
| <extension class="Zenstruck\Browser\Test\BrowserExtension" /> | |||
| </extensions> | |||
| </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": { | |||
| "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" | |||
| ] | |||
| } | |||
| } | |||
| @@ -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') | |||
| ; | |||
| } | |||
| } | |||