diff --git a/README.md b/README.md index 0499290..04c6e35 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,24 @@ And to stop all services just run This code can also be run using Symfony Local Webserver. +### Fixtures + + make database + +#### Adding Authors + + http --json --verify false POST https://127.0.0.1:8000/chapter7/author author_id="a64a52cc-3ee9-4a15-918b-099e18b43119" username="bob" email="bob@bob.com" + http --json --verify false POST https://127.0.0.1:8000/chapter7/author author_id="1fd7d739-2ad7-41a8-8c18-565603e3733f" username="alice" email="alice@alice.com" + http --json --verify false POST https://127.0.0.1:8000/chapter7/author author_id="1da1366f-b066-4514-9b29-7346df41e371" username="charlie" email="charlie@charlie.com" + +#### Posting Cheeps + + http --json --verify false POST https://127.0.0.1:8000/chapter7/cheep cheep_id="28bc90bd-2dfb-4b71-962f-81f02b0b3149" author_id="a64a52cc-3ee9-4a15-918b-099e18b43119" message="Hello world! This is Bob!" + http --json --verify false POST https://127.0.0.1:8000/chapter7/cheep cheep_id="04efc3af-59a3-4695-803f-d37166c3af56" author_id="1fd7d739-2ad7-41a8-8c18-565603e3733f" message="Hello world! This is Alice!" + http --json --verify false POST https://127.0.0.1:8000/chapter7/cheep cheep_id="8a5539e6-3be2-4fa7-906e-179efcfca46b" author_id="1da1366f-b066-4514-9b29-7346df41e371" message="Hello world! This is Charlie!" + +#### Following other Authors + + http --json --verify false POST https://127.0.0.1:8000/chapter7/follow follow_id="8cc71bf2-f827-4c92-95a5-43bb1bc622ad" from_author_id="a64a52cc-3ee9-4a15-918b-099e18b43119" to_author_id="1fd7d739-2ad7-41a8-8c18-565603e3733f" + http --json --verify false POST https://127.0.0.1:8000/chapter7/follow follow_id="f3088920-841e-4577-a3c2-efdc80f0dea5" from_author_id="a64a52cc-3ee9-4a15-918b-099e18b43119" to_author_id="1da1366f-b066-4514-9b29-7346df41e371" + http --json --verify false POST https://127.0.0.1:8000/chapter7/follow follow_id="45ea9e38-d821-4c8c-8619-362bf57f4c56" from_author_id="1fd7d739-2ad7-41a8-8c18-565603e3733f" to_author_id="a64a52cc-3ee9-4a15-918b-099e18b43119" diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 7f18c59..1d5cd1a 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -23,3 +23,9 @@ doctrine: dir: '%kernel.project_dir%/src/Cheeper/Infrastructure/Persistence/doctrine-mappings' prefix: 'Cheeper\DomainModel' alias: CheeperDomainModel + CheeperChapter7DomainModel: + is_bundle: false + type: xml + dir: '%kernel.project_dir%/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineMappings' + prefix: 'Cheeper\Chapter7\DomainModel' + alias: CheeperChapter7DomainModel diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index ed4b427..4129df9 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -37,9 +37,10 @@ framework: Cheeper\Application\Command\AsyncCommand: async_commands Cheeper\Application\Command\SyncCommand: sync_commands - # Cheeper\Chapter7\Application\Command\Author\SignUp: chapter7_sync_commands - Cheeper\Chapter7\Application\Command\Author\SignUp: chapter7_async_commands - Cheeper\Chapter7\Application\Command\Author\Follow: chapter7_async_commands + # Cheeper\Chapter7\Application\Command\Author\SignUp: chapter7_async_commands + Cheeper\Chapter7\Application\Command\Author\SignUp: chapter7_sync_commands + Cheeper\Chapter7\Application\Command\Author\Follow: chapter7_sync_commands + Cheeper\Chapter7\Application\Command\Cheep\PostCheep: chapter7_sync_commands # Domain Events are sent to asynchronous # transport to be processed later. diff --git a/config/services.yaml b/config/services.yaml index 69a560e..584b58c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -17,6 +17,9 @@ services: # ignore Cheeper\DomainModel\Cheep\Cheeps $cheeps: '@Cheeper\Infrastructure\Persistence\DoctrineOrmCheeps' Cheeper\DomainModel\Author\Authors $authors: '@Cheeper\Infrastructure\Persistence\DoctrineOrmAuthors' + Cheeper\Chapter7\DomainModel\Author\Authors $authors: '@Cheeper\Chapter7\Infrastructure\Persistence\DoctrineOrmAuthors' + Cheeper\Chapter7\DomainModel\Cheep\Cheeps $cheeps: '@Cheeper\Chapter7\Infrastructure\Persistence\DoctrineOrmCheeps' + Cheeper\Chapter7\DomainModel\Follow\Follows $follows: '@Cheeper\Chapter7\Infrastructure\Persistence\DoctrineOrmFollows' Cheeper\DomainModel\Author\Authors $authorsRepository: '@Cheeper\Infrastructure\Persistence\DoctrineOrmAuthors' Cheeper\DomainModel\Follow\Follows $follows: '@Cheeper\Infrastructure\Persistence\DoctrineOrmFollows' #end-ignore @@ -99,11 +102,25 @@ services: - { name: messenger.message_handler, bus: event.bus } class: 'Cheeper\Chapter6\Infrastructure\Application\Projector\Author\SymfonyAuthorFollowedHandler' + # CHAPTER 7 + Cheeper\Chapter7\Infrastructure\: + resource: '../src/Cheeper/Chapter7/Infrastructure/**/*.php' + Cheeper\Chapter7\Application\Command\Author\SignUpHandler: tags: - { name: messenger.message_handler, bus: command.bus } class: 'Cheeper\Chapter7\Application\Command\Author\SignUpHandler' + Cheeper\Chapter7\Application\Command\Cheep\PostCheepHandler: + tags: + - { name: messenger.message_handler, bus: command.bus } + class: 'Cheeper\Chapter7\Application\Command\Cheep\PostCheepHandler' + + Cheeper\Chapter7\Application\Command\Author\FollowHandler: + tags: + - { name: messenger.message_handler, bus: command.bus } + class: 'Cheeper\Chapter7\Application\Command\Author\FollowHandler' + App\EventListener\UserCreatedListener: tags: - name: "doctrine.orm.entity_listener" diff --git a/src/App/Controller/Chapter7/DefaultController.php b/src/App/Controller/Chapter7/DefaultController.php deleted file mode 100644 index 42af9c7..0000000 --- a/src/App/Controller/Chapter7/DefaultController.php +++ /dev/null @@ -1,37 +0,0 @@ -commandBus->handle( - Follow::fromAuthorToAuthor( - FollowId::nextIdentity(), - AuthorId::nextIdentity(), - AuthorId::nextIdentity(), - ) - ); - - return $this->json("hello"); - } -} diff --git a/src/App/Controller/Chapter7/FollowAuthorController.php b/src/App/Controller/Chapter7/FollowAuthorController.php new file mode 100644 index 0000000..b1441c9 --- /dev/null +++ b/src/App/Controller/Chapter7/FollowAuthorController.php @@ -0,0 +1,60 @@ +getRequestContentInJson($request) + ); + + $commandBus->handle($command); + + $httpContent = [ + 'message_id' => $command->messageId()?->toString() + ]; + } catch ( + AuthorDoesNotExist + |InvalidArgumentException $exception + ) { + $httpCode = Response::HTTP_INTERNAL_SERVER_ERROR; + $httpContent = ['message' => $exception->getMessage()]; + } + + return $this->buildJsonResponse($httpContent, $httpCode); + } + + private function getRequestContentInJson(Request $request): mixed + { + return \Safe\json_decode( + $request->getContent(), + true + ); + } + + private function buildJsonResponse(array $httpContent, int $httpCode): JsonResponse + { + return $this->json( + data: $httpContent, + status: $httpCode, + ); + } +} diff --git a/src/App/Controller/Chapter7/PostCheepController.php b/src/App/Controller/Chapter7/PostCheepController.php new file mode 100644 index 0000000..227e64f --- /dev/null +++ b/src/App/Controller/Chapter7/PostCheepController.php @@ -0,0 +1,59 @@ +getRequestContentInJson($request) + ); + + $commandBus->handle($command); + $httpContent = [ + 'message_id' => $command->messageId()?->toString(), + 'cheep_id' => $command->authorId(), + ]; + } catch ( + AuthorDoesNotExist + |InvalidArgumentException $exception + ) { + $httpCode = Response::HTTP_INTERNAL_SERVER_ERROR; + $httpContent = ['message' => $exception->getMessage()]; + } + + return $this->buildJsonResponse($httpContent, $httpCode); + } + + private function getRequestContentInJson(Request $request): mixed + { + return \Safe\json_decode( + $request->getContent(), + true + ); + } + + private function buildJsonResponse(array $httpContent, int $httpCode): JsonResponse + { + return $this->json( + data: $httpContent, + status: $httpCode, + ); + } +} diff --git a/src/App/Controller/Chapter7/SignUpAuthorController.php b/src/App/Controller/Chapter7/SignUpAuthorController.php index d2067ca..6f4a4e4 100644 --- a/src/App/Controller/Chapter7/SignUpAuthorController.php +++ b/src/App/Controller/Chapter7/SignUpAuthorController.php @@ -6,7 +6,10 @@ namespace App\Controller\Chapter7; use App\Messenger\CommandBus; use Cheeper\Chapter7\Application\Command\Author\SignUp; +use Cheeper\DomainModel\Author\AuthorAlreadyExists; +use InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -14,21 +17,28 @@ use Symfony\Component\Routing\Annotation\Route; final class SignUpAuthorController extends AbstractController { #[Route("/chapter7/author", methods: ["POST"])] - public function follow(Request $request, CommandBus $commandBus): Response + public function __invoke(Request $request, CommandBus $commandBus): Response { - $command = SignUp::fromArray( - $this->getRequestContentInJson($request) - ); + $httpCode = Response::HTTP_ACCEPTED; + try { + $command = SignUp::fromArray( + $this->getRequestContentInJson($request) + ); - $commandBus->handle($command); - - return $this->json( - data: [ - 'message_id' => $command->messageId()->toString(), + $commandBus->handle($command); + $httpContent = [ + 'message_id' => $command->messageId()?->toString(), 'author_id' => $command->authorId(), - ], - status: Response::HTTP_CREATED, - ); + ]; + } catch ( + AuthorAlreadyExists + |InvalidArgumentException $exception + ) { + $httpCode = Response::HTTP_INTERNAL_SERVER_ERROR; + $httpContent = ['message' => $exception->getMessage()]; + } + + return $this->buildJsonResponse($httpContent, $httpCode); } private function getRequestContentInJson(Request $request): mixed @@ -38,5 +48,13 @@ final class SignUpAuthorController extends AbstractController true ); } + + private function buildJsonResponse(array $httpContent, int $httpCode): JsonResponse + { + return $this->json( + data: $httpContent, + status: $httpCode, + ); + } } diff --git a/src/App/Messenger/FromScratch.php b/src/App/Messenger/FromScratch.php index 1927599..e254e8a 100644 --- a/src/App/Messenger/FromScratch.php +++ b/src/App/Messenger/FromScratch.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace App\Messenger; + use Cheeper\Application\Command\Cheep\PostCheep; use Cheeper\Application\Command\Cheep\PostCheepHandler; use Cheeper\Chapter6\Infrastructure\Application\Event\InMemoryEventBus; diff --git a/src/Cheeper/Chapter7/Application/Command/Author/Follow.php b/src/Cheeper/Chapter7/Application/Command/Author/Follow.php index 8ce7797..4be235a 100644 --- a/src/Cheeper/Chapter7/Application/Command/Author/Follow.php +++ b/src/Cheeper/Chapter7/Application/Command/Author/Follow.php @@ -5,15 +5,18 @@ declare(strict_types=1); namespace Cheeper\Chapter7\Application\Command\Author; use Cheeper\Chapter7\Application\MessageTrait; +use Cheeper\DomainModel\Follow\FollowId; final class Follow { use MessageTrait; private function __construct( + private string $followId, private string $fromAuthorId, private string $toAuthorId ) { + $this->stampAsNewMessage(); } public function fromAuthorId(): string @@ -26,8 +29,22 @@ final class Follow return $this->toAuthorId; } + public function followId(): string + { + return $this->followId; + } + public static function fromAuthorIdToAuthorId(string $from, string $to): self { - return new self($from, $to); + return new self($from, $to, FollowId::nextIdentity()->toString()); } -} + + public static function fromArray(array $array): self + { + return new self( + $array['follow_id'] ?? '', + $array['from_author_id'] ?? '', + $array['to_author_id'] ?? '', + ); + } +} \ No newline at end of file diff --git a/src/Cheeper/Chapter7/Application/Command/Author/FollowHandler.php b/src/Cheeper/Chapter7/Application/Command/Author/FollowHandler.php index 0663826..f3d3f6f 100644 --- a/src/Cheeper/Chapter7/Application/Command/Author/FollowHandler.php +++ b/src/Cheeper/Chapter7/Application/Command/Author/FollowHandler.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace Cheeper\Chapter7\Application\Command\Author; use Cheeper\Chapter6\Application\Event\EventBus; -use Cheeper\DomainModel\Author\Author; +use Cheeper\Chapter7\DomainModel\Author\Author; +use Cheeper\Chapter7\DomainModel\Author\Authors; +use Cheeper\Chapter7\DomainModel\Follow\Follows; use Cheeper\DomainModel\Author\AuthorDoesNotExist; use Cheeper\DomainModel\Author\AuthorId; -use Cheeper\DomainModel\Author\Authors; -use Cheeper\DomainModel\Follow\Follows; final class FollowHandler { diff --git a/src/Cheeper/Chapter7/Application/Command/Author/SignUp.php b/src/Cheeper/Chapter7/Application/Command/Author/SignUp.php index c5c8cc4..f920e01 100644 --- a/src/Cheeper/Chapter7/Application/Command/Author/SignUp.php +++ b/src/Cheeper/Chapter7/Application/Command/Author/SignUp.php @@ -23,6 +23,7 @@ final class SignUp private ?string $website = null, private ?string $birthDate = null, ) { + $this->stampAsNewMessage(); } //ignore @@ -68,11 +69,11 @@ final class SignUp public static function fromArray(array $array): self { - return (new self( + return new self( $array['author_id'] ?? '', $array['username'] ?? '', $array['email'] ?? '', - ))->stampAsNewMessage(); + ); } //end-ignore } diff --git a/src/Cheeper/Chapter7/Application/Command/Author/SignUpHandler.php b/src/Cheeper/Chapter7/Application/Command/Author/SignUpHandler.php index b37ec5b..b236b66 100644 --- a/src/Cheeper/Chapter7/Application/Command/Author/SignUpHandler.php +++ b/src/Cheeper/Chapter7/Application/Command/Author/SignUpHandler.php @@ -5,10 +5,10 @@ declare(strict_types=1); namespace Cheeper\Chapter7\Application\Command\Author; use Cheeper\Chapter6\Application\Event\EventBus; -use Cheeper\DomainModel\Author\Author; +use Cheeper\Chapter7\DomainModel\Author\Author; +use Cheeper\Chapter7\DomainModel\Author\Authors; use Cheeper\DomainModel\Author\AuthorAlreadyExists; use Cheeper\DomainModel\Author\AuthorId; -use Cheeper\DomainModel\Author\Authors; use Cheeper\DomainModel\Author\BirthDate; use Cheeper\DomainModel\Author\EmailAddress; use Cheeper\DomainModel\Author\UserName; diff --git a/src/Cheeper/Chapter7/Application/Command/Cheep/PostCheep.php b/src/Cheeper/Chapter7/Application/Command/Cheep/PostCheep.php new file mode 100644 index 0000000..11b5ec9 --- /dev/null +++ b/src/Cheeper/Chapter7/Application/Command/Cheep/PostCheep.php @@ -0,0 +1,46 @@ +stampAsNewMessage(); + } + + public static function fromArray(array $array): self + { + return new self( + $array['cheep_id'] ?? '', + $array['author_id'] ?? '', + $array['message'] ?? '', + ); + } + + public function cheepId(): string + { + return $this->cheepId; + } + + public function authorId(): string + { + return $this->authorId; + } + + public function message(): string + { + return $this->message; + } +} +//end-snippet diff --git a/src/Cheeper/Chapter7/Application/Command/Cheep/PostCheepHandler.php b/src/Cheeper/Chapter7/Application/Command/Cheep/PostCheepHandler.php new file mode 100644 index 0000000..b78ac7a --- /dev/null +++ b/src/Cheeper/Chapter7/Application/Command/Cheep/PostCheepHandler.php @@ -0,0 +1,49 @@ +authorId()); + $cheepId = CheepId::fromString($command->cheepId()); + $message = CheepMessage::write($command->message()); + + $author = $this->authors->ofId($authorId); + $this->checkAuthorExists($author, $authorId); + + $cheep = Cheep::compose($authorId, $cheepId, $message); + $this->cheeps->add($cheep); + + $this->eventBus->notifyAll($cheep->domainEvents()); + } + + private function checkAuthorExists(?Author $author, AuthorId $authorId): void + { + if (null === $author) { + throw AuthorDoesNotExist::withAuthorIdOf($authorId); + } + } +} +//end-snippet diff --git a/src/Cheeper/Chapter7/DomainModel/Author/Author.php b/src/Cheeper/Chapter7/DomainModel/Author/Author.php index abfb442..9c0b092 100644 --- a/src/Cheeper/Chapter7/DomainModel/Author/Author.php +++ b/src/Cheeper/Chapter7/DomainModel/Author/Author.php @@ -5,32 +5,126 @@ declare(strict_types=1); namespace Cheeper\Chapter7\DomainModel\Author; use Cheeper\Chapter7\DomainModel\Follow\Follow; -use Cheeper\DomainModel\Author\Author as AuthorChapter6; use Cheeper\DomainModel\Author\AuthorId; +use Cheeper\DomainModel\Author\BirthDate; +use Cheeper\DomainModel\Author\EmailAddress; +use Cheeper\DomainModel\Author\UserName; +use Cheeper\DomainModel\Author\Website; use Cheeper\DomainModel\Follow\FollowId; +use Cheeper\DomainModel\TriggerEventsTrait; use DateTimeImmutable; +use InvalidArgumentException; -class Author extends AuthorChapter6 +final class Author { - protected function __construct( - protected string $authorId, - protected string $userName, - protected string $email, - protected ?string $name = null, - protected ?string $biography = null, - protected ?string $location = null, - protected ?string $website = null, - protected ?DateTimeImmutable $birthDate = null, + use TriggerEventsTrait; + + private function __construct( + private string $authorId, + private string $userName, + private string $email, + private ?string $name = null, + private ?string $biography = null, + private ?string $location = null, + private ?string $website = null, + private ?DateTimeImmutable $birthDate = null, ) { $this->setName($name); $this->setBiography($biography); $this->setLocation($location); $this->notifyDomainEvent( - NewAuthorSigned::fromAuthor($this) + $this->buildNewAuthorSignedDomainEvent() ); } + protected function buildNewAuthorSignedDomainEvent(): NewAuthorSigned + { + return NewAuthorSigned::fromAuthor($this); + } + + public static function signUp( + AuthorId $authorId, + UserName $userName, + EmailAddress $email, + ?string $name = null, + ?string $biography = null, + ?string $location = null, + ?Website $website = null, + ?BirthDate $birthDate = null + ): static { + return new static( + $authorId->toString(), + $userName->userName(), + $email->value(), + $name, + $biography, + $location, + $website?->toString(), + $birthDate?->date() + ); + } + + protected function setName(?string $name): void + { + $this->name = $this->checkIsNotNull($name, 'Name cannot be empty'); + } + + protected function setBiography(?string $biography): void + { + $this->biography = $this->checkIsNotNull($biography, 'Biography cannot be empty'); + } + + protected function setLocation(?string $location): void + { + $this->location = $this->checkIsNotNull($location, 'Location cannot be empty'); + } + + public function authorId(): AuthorId + { + return AuthorId::fromString($this->authorId); + } + + public function userId(): AuthorId + { + return AuthorId::fromString($this->authorId); + } + + public function userName(): UserName + { + return UserName::pick($this->userName); + } + + public function email(): EmailAddress + { + return EmailAddress::from($this->email); + } + + public function name(): ?string + { + return $this->name; + } + + public function biography(): ?string + { + return $this->biography; + } + + public function location(): ?string + { + return $this->location; + } + + public function website(): ?Website + { + return $this->website !== null ? Website::fromString($this->website) : null; + } + + public function birthDate(): ?BirthDate + { + return $this->birthDate !== null ? BirthDate::fromString($this->birthDate->format('Y-m-d')) : null; + } + public function followAuthorId(AuthorId $toFollow): Follow { return Follow::fromAuthorToAuthor( @@ -39,4 +133,13 @@ class Author extends AuthorChapter6 toAuthorId: $toFollow ); } -} + + private function checkIsNotNull(?string $value, string $errorMessage): ?string + { + if ('' === $value) { + throw new InvalidArgumentException($errorMessage); + } + + return $value; + } +} \ No newline at end of file diff --git a/src/Cheeper/Chapter7/DomainModel/Author/Authors.php b/src/Cheeper/Chapter7/DomainModel/Author/Authors.php new file mode 100644 index 0000000..2d8b219 --- /dev/null +++ b/src/Cheeper/Chapter7/DomainModel/Author/Authors.php @@ -0,0 +1,17 @@ +authorId()->toString(), + new DateTimeImmutable( + timezone: new DateTimeZone("UTC") + ) + ); + } + + public function authorId(): string + { + return $this->authorId; + } + + public function occurredOn(): DateTimeImmutable + { + return $this->occurredOn; + } } +// end-snippet diff --git a/src/Cheeper/Chapter7/DomainModel/Cheep/Cheep.php b/src/Cheeper/Chapter7/DomainModel/Cheep/Cheep.php new file mode 100644 index 0000000..891a125 --- /dev/null +++ b/src/Cheeper/Chapter7/DomainModel/Cheep/Cheep.php @@ -0,0 +1,66 @@ +notifyDomainEvent( + CheepPosted::fromCheep($this) + ); + } + + public static function compose(AuthorId $authorId, CheepId $cheepId, CheepMessage $cheepMessage): self + { + $cheepDate = new CheepDate( + (new \DateTimeImmutable(timezone: new \DateTimeZone('UTC')))->format('Y-m-d H:i:s') + ); + + return new self( + $authorId, + $cheepId, + $cheepMessage, + $cheepDate + ); + } + + final public function authorId(): AuthorId + { + return $this->authorId; + } + + final public function cheepId(): CheepId + { + return $this->cheepId; + } + + final public function cheepMessage(): CheepMessage + { + return $this->cheepMessage; + } + + final public function cheepDate(): CheepDate + { + return $this->cheepDate; + } + + final public function recomposeWith(CheepMessage $cheepMessage): void + { + $this->cheepMessage = $cheepMessage; + } +} diff --git a/src/Cheeper/Chapter7/DomainModel/Cheep/CheepPosted.php b/src/Cheeper/Chapter7/DomainModel/Cheep/CheepPosted.php new file mode 100644 index 0000000..8e2cfd5 --- /dev/null +++ b/src/Cheeper/Chapter7/DomainModel/Cheep/CheepPosted.php @@ -0,0 +1,65 @@ +stampAsNewMessage(); + } + + public static function fromCheep(Cheep $cheep): self + { + return new self( + $cheep->cheepId()->toString(), + $cheep->authorId()->toString(), + $cheep->cheepMessage()->message(), + $cheep->cheepDate()->date(), + new DateTimeImmutable( + timezone: new DateTimeZone("UTC") + ) + ); + } + + public function cheepId(): string + { + return $this->cheepId; + } + + public function authorId(): string + { + return $this->authorId; + } + + public function cheepMessage(): string + { + return $this->cheepMessage; + } + + public function cheepDate(): string + { + return $this->cheepDate; + } + + public function occurredOn(): DateTimeImmutable + { + return $this->occurredOn; + } +} +// end-snippet diff --git a/src/Cheeper/Chapter7/DomainModel/Cheep/Cheeps.php b/src/Cheeper/Chapter7/DomainModel/Cheep/Cheeps.php new file mode 100644 index 0000000..e664620 --- /dev/null +++ b/src/Cheeper/Chapter7/DomainModel/Cheep/Cheeps.php @@ -0,0 +1,15 @@ +stampAsNewMessage(); } public static function fromFollow(Follow $follow): self diff --git a/src/Cheeper/Chapter7/DomainModel/Follow/Follow.php b/src/Cheeper/Chapter7/DomainModel/Follow/Follow.php index 1bdb617..e57e102 100644 --- a/src/Cheeper/Chapter7/DomainModel/Follow/Follow.php +++ b/src/Cheeper/Chapter7/DomainModel/Follow/Follow.php @@ -4,23 +4,68 @@ declare(strict_types=1); namespace Cheeper\Chapter7\DomainModel\Follow; -use Cheeper\DomainModel\Follow\Follow as FollowChapter6; use Cheeper\DomainModel\Author\AuthorId; use Cheeper\DomainModel\Follow\FollowId; use Cheeper\DomainModel\TriggerEventsTrait; -class Follow extends FollowChapter6 +// snippet follow-entity-with-events +final class Follow { use TriggerEventsTrait; protected function __construct( - protected FollowId $followId, - protected AuthorId $fromAuthorId, - protected AuthorId $toAuthorId, + private string $followId, + private string $fromAuthorId, + private string $toAuthorId, ) { $this->notifyDomainEvent( AuthorFollowed::fromFollow($this) - ->stampAsNewMessage() + ); + + /** + * As an alternative, we can use a Singleton + * implementing an Observer pattern with + * Subscribers that will publish the triggered + * Domain Events into a queue system like + * Rabbit. It's useful for Legacy projects + * because you can trigger any Domain Event + * from any place in your code, not only + * Entities. + * + * DomainEventPublisher::getInstance() + * ->notifyDomainEvent( + * AuthorFollowed::fromFollow($this) + * ) + * ); + */ + } + + public static function fromAuthorToAuthor( + FollowId $followId, + AuthorId $fromAuthorId, + AuthorId $toAuthorId, + ): static + { + return new static( + followId: $followId->toString(), + fromAuthorId: $fromAuthorId->toString(), + toAuthorId: $toAuthorId->toString() ); } + + public function fromAuthorId(): AuthorId + { + return AuthorId::fromString($this->fromAuthorId); + } + + public function toAuthorId(): AuthorId + { + return AuthorId::fromString($this->toAuthorId); + } + + public function followId(): FollowId + { + return FollowId::fromString($this->followId); + } } +// end-snippet \ No newline at end of file diff --git a/src/Cheeper/Chapter7/DomainModel/Follow/Follows.php b/src/Cheeper/Chapter7/DomainModel/Follow/Follows.php new file mode 100644 index 0000000..ece2349 --- /dev/null +++ b/src/Cheeper/Chapter7/DomainModel/Follow/Follows.php @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineMappings/Cheep.Cheep.orm.xml b/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineMappings/Cheep.Cheep.orm.xml new file mode 100644 index 0000000..a823bff --- /dev/null +++ b/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineMappings/Cheep.Cheep.orm.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineMappings/Follow.Follow.orm.xml b/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineMappings/Follow.Follow.orm.xml new file mode 100644 index 0000000..9000288 --- /dev/null +++ b/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineMappings/Follow.Follow.orm.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineOrmAuthors.php b/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineOrmAuthors.php new file mode 100644 index 0000000..ee8bb8f --- /dev/null +++ b/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineOrmAuthors.php @@ -0,0 +1,42 @@ +em + ->getRepository(Author::class) + ->findOneBy([ + 'authorId' => $authorId->id(), + ]); + } + + public function ofUserName(UserName $userName): ?Author + { + return $this->em + ->getRepository(Author::class) + ->findOneBy(['userName' => $userName->userName()]); + } + + public function add(Author $author): void + { + $this->em->persist($author); + } +} +//end-snippet diff --git a/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineOrmCheeps.php b/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineOrmCheeps.php new file mode 100644 index 0000000..d5de767 --- /dev/null +++ b/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineOrmCheeps.php @@ -0,0 +1,33 @@ +em->persist($cheep); + } + //end-ignore + + public function ofId(CheepId $cheepId): ?Cheep + { + return $this->em->find(Cheep::class, Uuid::fromString($cheepId->id())); + } +} +//end-snippet diff --git a/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineOrmFollows.php b/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineOrmFollows.php new file mode 100644 index 0000000..c03b2ec --- /dev/null +++ b/src/Cheeper/Chapter7/Infrastructure/Persistence/DoctrineOrmFollows.php @@ -0,0 +1,49 @@ +em->persist($follow); + } + + public function ofFromAuthorIdAndToAuthorId(AuthorId $fromAuthorId, AuthorId $toAuthorId): ?Follow + { + $repository = $this->em->getRepository(Follow::class); + + return $repository->findOneBy([ + 'fromAuthorId' => $fromAuthorId, + 'toAuthorId' => $toAuthorId, + ]); + } + + public function toAuthorId(AuthorId $authorId): array + { + $repository = $this->em->getRepository(Follow::class); + + return $repository->findBy(['toAuthorId' => $authorId]); + } +} +//end-snippet diff --git a/src/Cheeper/Chapter7/Infrastructure/Persistence/InMemoryAuthors.php b/src/Cheeper/Chapter7/Infrastructure/Persistence/InMemoryAuthors.php new file mode 100644 index 0000000..dc97c54 --- /dev/null +++ b/src/Cheeper/Chapter7/Infrastructure/Persistence/InMemoryAuthors.php @@ -0,0 +1,59 @@ +authors = []; + } + + public function ofId(AuthorId $authorId): ?Author + { + $candidate = head( + select($this->authors, fn (Author $u): bool => $u->authorId()->equals($authorId)) + ); + + if (null === $candidate) { + return $candidate; + } + + return $candidate; + } + + public function ofUserName(UserName $userName): ?Author + { + $candidate = head( + select($this->authors, fn (Author $u): bool => $u->userName()->equalsTo($userName)) + ); + + if (null === $candidate) { + return null; + } + + return $candidate; + } + + public function add(Author $author): void + { + $candidate = head( + select($this->authors, fn (Author $u): bool => $u->authorId()->equals($author->authorId())) + ); + + if ((null !== $candidate && $candidate !== $author) || null === $candidate) { + $this->authors[$author->authorId()->toString()] = $author; + } + } +} diff --git a/src/Cheeper/Chapter7/Infrastructure/Persistence/InMemoryCheeps.php b/src/Cheeper/Chapter7/Infrastructure/Persistence/InMemoryCheeps.php new file mode 100644 index 0000000..042062e --- /dev/null +++ b/src/Cheeper/Chapter7/Infrastructure/Persistence/InMemoryCheeps.php @@ -0,0 +1,27 @@ +items[$cheep->cheepId()->toString()] = $cheep; + } + + public function ofId(CheepId $cheepId): ?Cheep + { + return $this->items[$cheepId->toString()] ?? null; + } +} +//end-snippet diff --git a/src/Cheeper/Chapter7/Infrastructure/Persistence/InMemoryFollows.php b/src/Cheeper/Chapter7/Infrastructure/Persistence/InMemoryFollows.php new file mode 100644 index 0000000..b621da8 --- /dev/null +++ b/src/Cheeper/Chapter7/Infrastructure/Persistence/InMemoryFollows.php @@ -0,0 +1,68 @@ + */ + public array $collection = []; + + public function ofId(FollowId $followId): ?Follow + { + $candidate = head( + select($this->collection, fn (Follow $u): bool => $u->followId()->equals($followId)) + ); + + if (null === $candidate) { + return null; + } + + return $candidate; + } + + public function add(Follow $follow): void + { + $candidate = head( + select($this->collection, fn (Follow $u): bool => $u->fromAuthorId()->equals($follow->fromAuthorId()) && $u->toAuthorId()->equals($follow->toAuthorId())) + ); + + if ((null !== $candidate && $candidate != $follow) || null === $candidate) { + $this->collection[$follow->followId()->toString()] = $follow; + } + } + + public function numberOfFollowersFor(AuthorId $authorId): int + { + return reduce_left( + $this->collection, + function(Follow $f, string $key, array $collection, int $initial) use($authorId): int { + return $initial + ($f->fromAuthorId()->equals($authorId) ? 1 : 0); + }, + 0 + ); + } + + public function ofFromAuthorIdAndToAuthorId(AuthorId $fromAuthorId, AuthorId $toAuthorId): ?Follow + { + $candidate = head( + select($this->collection, fn (Follow $u): bool => $u->fromAuthorId()->equals($fromAuthorId) && $u->toAuthorId()->equals($toAuthorId)) + ); + + return $candidate ?? null; + } + + public function toAuthorId(AuthorId $authorId): array + { + return select($this->collection, fn (Follow $f): bool => $f->toAuthorId()->equals($authorId)); + } +} diff --git a/src/Cheeper/DomainModel/Author/Author.php b/src/Cheeper/DomainModel/Author/Author.php index 29531e3..db619ea 100644 --- a/src/Cheeper/DomainModel/Author/Author.php +++ b/src/Cheeper/DomainModel/Author/Author.php @@ -8,8 +8,9 @@ use Cheeper\DomainModel\Follow\Follow; use Cheeper\DomainModel\Follow\FollowId; use Cheeper\DomainModel\TriggerEventsTrait; use DateTimeImmutable; +use InvalidArgumentException; -class Author +final class Author { use TriggerEventsTrait; @@ -28,10 +29,15 @@ class Author $this->setLocation($location); $this->notifyDomainEvent( - NewAuthorSigned::fromAuthor($this) + $this->buildNewAuthorSignedDomainEvent() ); } + protected function buildNewAuthorSignedDomainEvent(): NewAuthorSigned + { + return NewAuthorSigned::fromAuthor($this); + } + public static function signUp( AuthorId $authorId, UserName $userName, @@ -126,7 +132,7 @@ class Author private function checkIsNotNull(?string $value, string $errorMessage): ?string { if ('' === $value) { - throw new \InvalidArgumentException($errorMessage); + throw new InvalidArgumentException($errorMessage); } return $value; diff --git a/src/Cheeper/DomainModel/Author/NewAuthorSigned.php b/src/Cheeper/DomainModel/Author/NewAuthorSigned.php index 800f32f..93a9443 100644 --- a/src/Cheeper/DomainModel/Author/NewAuthorSigned.php +++ b/src/Cheeper/DomainModel/Author/NewAuthorSigned.php @@ -17,9 +17,9 @@ class NewAuthorSigned implements DomainEvent ) { } - public static function fromAuthor(Author $author): self + public static function fromAuthor(Author $author): static { - return new self( + return new static( $author->authorId()->toString(), new DateTimeImmutable( timezone: new DateTimeZone("UTC") diff --git a/tests/Cheeper/Tests/Chapter7/Application/Command/Author/FollowHandlerTest.php b/tests/Cheeper/Tests/Chapter7/Application/Command/Author/FollowHandlerTest.php index aa50f3d..1d6e831 100644 --- a/tests/Cheeper/Tests/Chapter7/Application/Command/Author/FollowHandlerTest.php +++ b/tests/Cheeper/Tests/Chapter7/Application/Command/Author/FollowHandlerTest.php @@ -4,11 +4,15 @@ declare(strict_types=1); namespace Cheeper\Tests\Chapter7\Application\Command\Author; +use Cheeper\Chapter6\Infrastructure\Application\Event\InMemoryEventBus; use Cheeper\Chapter7\Application\Command\Author\FollowHandler; use Cheeper\Chapter7\Application\Command\Author\Follow; use Cheeper\Chapter7\DomainModel\Author\Author; +use Cheeper\Chapter7\DomainModel\Follow\Follow as FollowRelation; use Cheeper\Chapter7\DomainModel\Follow\AuthorFollowed; -use Cheeper\DomainModel\Follow\Follow as FollowRelation; +use Cheeper\Chapter7\Infrastructure\Persistence\InMemoryAuthors; +use Cheeper\Chapter7\Infrastructure\Persistence\InMemoryFollows; + use Cheeper\DomainModel\Author\AuthorDoesNotExist; use Cheeper\DomainModel\Author\AuthorId; use Cheeper\DomainModel\Follow\FollowId; @@ -19,8 +23,7 @@ use PHPUnit\Framework\TestCase; final class FollowHandlerTest extends TestCase { - use SendsCommands; - + private const FOLLOW_ID = '337df284-d475-4cbd-89af-12d7451f73f1'; private const AUTHOR_ID_FROM = '400ea77d-0c8c-44f2-abe8-db05d0852966'; private const AUTHOR_ID_TO = '52d8f0b5-544f-46e0-84dc-f8b513391a0e'; @@ -29,12 +32,23 @@ final class FollowHandlerTest extends TestCase private const EMAIL_KEYVAN = 'keyvan.akbary@gmail.com'; private const EMAIL_CARLOS = 'carlos.buenosvinos@gmail.com'; + protected function setUp(): void + { + $this->authors = new InMemoryAuthors(); + $this->follows = new InMemoryFollows(); + $this->eventBus = new InMemoryEventBus(); + } + /** @test */ public function givenTwoNonExistingAuthorsWhenFollowingOneToAnotherOneNonExistingAuthorExceptionShouldBeThrown(): void { $this->expectException(AuthorDoesNotExist::class); - $this->runHandler(self::AUTHOR_ID_FROM, self::AUTHOR_ID_TO); + $this->runHandler( + self::FOLLOW_ID, + self::AUTHOR_ID_FROM, + self::AUTHOR_ID_TO + ); } /** @test */ @@ -50,7 +64,11 @@ final class FollowHandlerTest extends TestCase ) ); - $this->runHandler(self::AUTHOR_ID_FROM, self::AUTHOR_ID_TO); + $this->runHandler( + self::FOLLOW_ID, + self::AUTHOR_ID_FROM, + self::AUTHOR_ID_TO + ); } /** @test */ @@ -66,7 +84,11 @@ final class FollowHandlerTest extends TestCase ) ); - $this->runHandler(self::AUTHOR_ID_FROM, self::AUTHOR_ID_TO); + $this->runHandler( + self::FOLLOW_ID, + self::AUTHOR_ID_FROM, + self::AUTHOR_ID_TO + ); } /** @test */ @@ -74,7 +96,6 @@ final class FollowHandlerTest extends TestCase { $fromAuthorId = self::AUTHOR_ID_FROM; $toAuthorId = self::AUTHOR_ID_TO; - $followId = '51d8ffff-123f-78e1-48fc-f8b513391a0e'; $fromAuthor = Author::signUp( AuthorId::fromString($fromAuthorId), @@ -90,15 +111,20 @@ final class FollowHandlerTest extends TestCase $this->authors->add($fromAuthor); $this->authors->add($toAuthor); + $this->follows->add( FollowRelation::fromAuthorToAuthor( - FollowId::fromString($followId), + FollowId::fromString(self::FOLLOW_ID), $fromAuthor->authorId(), $toAuthor->authorId(), ) ); - $this->runHandler($fromAuthorId, $toAuthorId); + $this->runHandler( + self::FOLLOW_ID, + self::AUTHOR_ID_FROM, + self::AUTHOR_ID_TO + ); $this->assertCount( 1, @@ -130,7 +156,11 @@ final class FollowHandlerTest extends TestCase $this->authors->add($fromAuthor); $this->authors->add($toAuthor); - $command = $this->runHandler($fromAuthorId, $toAuthorId); + $command = $this->runHandler( + self::FOLLOW_ID, + $fromAuthorId, + $toAuthorId + ); $this->assertCount( 1, @@ -155,14 +185,19 @@ final class FollowHandlerTest extends TestCase ); } - private function runHandler(string $fromAuthorId, string $toAuthorId): Follow + private function runHandler( + string $followId, + string $fromAuthorId, + string $toAuthorId + ): Follow { $this->eventBus->reset(); - $command = (Follow::fromAuthorIdToAuthorId( - $fromAuthorId, - $toAuthorId - ))->stampAsNewMessage(); + $command = Follow::fromArray([ + 'follow_id' => $followId, + 'from_author_id' => $fromAuthorId, + 'to_author_id' => $toAuthorId + ]); (new FollowHandler( $this->authors,