mtarld / apip-ddd Goto Github PK
View Code? Open in Web Editor NEWAn example of hexagonal API Platform 3 implementation
License: MIT License
An example of hexagonal API Platform 3 implementation
License: MIT License
Hi! First off, thank you for this demo repo! This really did help me get my head around ApiPlattform in a non-vanilla "Symfony architecture" 😀👍
Second, I noticed in POST api/books/anonymize
, that each SQL update statement is executed in its own transactions, instead of the entire update being done in a single transaction:
Bonjour à tous,
Je viens de voir votre conférence sur Youtube, laquelle ma beaucoup apporté.
Par contre je ne comprends pas pourquoi vous avez mis Doctrine dans le Domaine, alors que dans la conférence vous dite qu'il ne faut pas faire ça.
Pouvez-vous m'expliquer votre changement de position ?
Merci pour le partage !
The processor and provider interfaces changed can you adapt the corresponding php classes please I try to do it on my own but there is no mush documentation for the 2.7/3 version for now ...
https://github.com/api-platform/core/blob/main/CHANGELOG.md#270-alpha2
Thanks for your help.
In some cases (no paginator and queries with joins, etc.), the count
method of the DoctrineRepository
seems to return wrong results :
apip-ddd/src/Shared/Infrastructure/Doctrine/DoctrineRepository.php
Lines 49 to 56 in a7608c6
I had better results relying on the doctrine paginator's logic :
if (null !== $paginator = $this->paginator()) {
return count($paginator);
}
return (new Paginator(clone $this->queryBuilder))->count();
Please change "license": "proprietary",
to "license": "MIT",
in line 3 of composer.json
to be consistent with the LICENSE
file.
Sorry to be so picky about that stuff...
Do you want me to make an PR?
apip-ddd/config/services/shared.php
Line 15 in 741d51c
If I'm right, the Kernel path is src/Shared/Infrastructure/Symfony/Kernel.php
.
I was a bit curious though... What I understood by debugging with the debug:container
is that this typo has no effect on the services list in the container in the end since Symfony seems to override the definition for the kernel class anyway (with the "kernel" alias).
To me, it looks like this exclude (present in the default config of a strandard Symfony project) just improves performance a bit.
I just saw your conference on Youtube, which helped me a lot.
On the other hand, I don't understand why you put Doctrine in the Domain, when in the conference you said that you shouldn't do that.
Can you explain your change of position to me?
Thank you for sharing !
Hi, I am using this repository as base for my new project. However this repo does not implement async commands and as a consequence it does not show how to consider async commands inside a command bus implementation.
I rewritten the MessengerCommandBus implementation however this does not satisfy PHPStan as returning null for async commands results in error
Method MessengerCommandBus::dispatch() should return T but returns null.
💡 Type null is not always the same as T. It breaks the contract for some argument types, typically subtypes.
final class MessengerCommandBus implements CommandBusInterface
{
public function __construct(MessageBusInterface $commandBus)
{
$this->messageBus = $commandBus;
}
/**
* @template T
*
* @param CommandInterface<T> $command
*
* @return T
*/
public function dispatch(CommandInterface $command): mixed
{
try {
$envelope = $this->messageBus->dispatch($message);
/** @var HandledStamp[] $handledStamps */
$handledStamps = $envelope->all(HandledStamp::class);
if (!$handledStamps) {
// async command
return null;
}
if (\count($handledStamps) > 1) {
$handlers = implode(', ', array_map(fn (HandledStamp $stamp): string => sprintf('"%s"', $stamp->getHandlerName()), $handledStamps));
throw new LogicException(sprintf('Message of type "%s" was handled multiple times. Only one handler is expected when using "%s::%s()", got %d: %s.', get_debug_type($envelope->getMessage()), static::class, __FUNCTION__, \count($handledStamps), $handlers));
}
return $handledStamps[0]->getResult();
} catch (HandlerFailedException $e) {
if ($exception = current($e->getWrappedExceptions())) {
throw $exception;
}
throw $e;
}
}
}
In the docs for openApicontext, in the schema section it appears Book.AnonymizeBooksCommand.jsonld and Book.DiscountBookPayload.jsonld which are the inputs for some of custom methods for the Book resource. But in hydra context, there's no definition or mention in the docs.jsonld
I have added some hydraContext for the custom methods, and for discount and anonymize end point, in the expects section of the hydraContext I have DiscountBookCommand::class or AnonymizeBooksCommand::class which will result in docs.jsonld in "expects": "App\Application\Library\Command\AnonymizeBooksCommand" or "expects": "App\Application\Library\Command\DiscountBookCommand". If Book.AnonymizeBooksCommand.jsonld and Book.DiscountBookPayload.jsonld were to exists in the docs.jsonld as class definition then it should reference the expects filed.
The documentation is both unfinished and outdated, we must update it.
I have two entities in a one-to-many relation.
To refer to your example of the BookStore imagine that a Book has a Category in a one-to-many relation. A Category is in relation with many Book entities.
I have created a sort of "copy" of the book store for the folders and files structure for both my two entities, with create commands, other commands, item and collection provider, repositories, resources, etc etc. Same as the book store.
My problem is in the creation of a Book by BookResource and the various commands and command handlers when I have to save the related Category at the same time. Let me explain with the code.
The BookResource has the POST operation defined like this:
new Post(
validationContext: ['groups' => ['create']],
processor: CreateBookProcessor::class,
),
Your BookResource class is the seguent:
final class BookResource
{
public function __construct(
#[ApiProperty(identifier: true, readable: false, writable: false)]
public ?AbstractUid $id = null,
#[Assert\NotNull(groups: ['create'])]
#[Assert\Length(min: 1, max: 255, groups: ['create', 'Default'])]
public ?string $name = null,
#[Assert\NotNull(groups: ['create'])]
#[Assert\Length(min: 1, max: 1023, groups: ['create', 'Default'])]
public ?string $description = null,
#[Assert\NotNull(groups: ['create'])]
#[Assert\Length(min: 1, max: 255, groups: ['create', 'Default'])]
public ?string $author = null,
#[Assert\NotNull(groups: ['create'])]
#[Assert\Length(min: 1, max: 65535, groups: ['create', 'Default'])]
public ?string $content = null,
#[Assert\NotNull(groups: ['create'])]
#[Assert\PositiveOrZero(groups: ['create', 'Default'])]
public ?int $price = null,
) {
}
public static function fromModel(Book $book): static
{
return new self(
$book->id->value,
$book->name->value,
$book->description->value,
$book->author->value,
$book->content->value,
$book->price->amount,
);
}
}
Now, how can I define a new property to associate the category to the book? Which is the type that I must assign to the category property?
My question is about what I see in the swagger and in which way I define and use the property in the command and command handler.
If I define category only as the category id, for example a string for uuid, the swagger show me a thing like this in the request body:
"category": "string",
If I accept the category as a string, for example the uuid, then I receive the uuid in the $data in your CreateBookProcessor.
final class CreateBookProcessor implements ProcessorInterface
{
public function __construct(
private CommandBusInterface $commandBus,
) {
}
/**
* @param mixed $data
*
* @return BookResource
*/
public function process($data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
Assert::isInstanceOf($data, BookResource::class);
Assert::notNull($data->name);
Assert::notNull($data->description);
Assert::notNull($data->author);
Assert::notNull($data->content);
Assert::notNull($data->price);
Assert::notNull($data->category); // The category received
$command = new CreateBookCommand(
new BookName($data->name),
new BookDescription($data->description),
new Author($data->author),
new BookContent($data->content),
new Price($data->price),
);
/** @var Book $model */
$model = $this->commandBus->dispatch($command);
return BookResource::fromModel($model);
}
}
The processor make a new CreateBookCommand to dispatch to the command bus. The command is defined like this.
final class CreateBookCommand implements CommandInterface
{
public function __construct(
public readonly BookName $name,
public readonly BookDescription $description,
public readonly Author $author,
public readonly BookContent $content,
public readonly Price $price,
) {
}
}
The CreateBookCommandHandler is defined like this:
final class CreateBookCommandHandler implements CommandHandlerInterface
{
public function __construct(private BookRepositoryInterface $bookRepository)
{
}
public function __invoke(CreateBookCommand $command): Book
{
$book = new Book(
$command->name,
$command->description,
$command->author,
$command->content,
$command->price,
);
$this->bookRepository->add($book);
return $book;
}
}
To use the category id received in the processor I think that I have to add in the create command a $category property of type CategoryId that is the category id value object like this:
final class CreateBookCommand implements CommandInterface
{
public function __construct(
public readonly BookName $name,
public readonly BookDescription $description,
public readonly Author $author,
public readonly BookContent $content,
public readonly Price $price,
public readonly CategoryId $category, // The category property
) {
}
}
If I add the category like this, I have to initialize the command like that in the processor.
$command = new CreateBookCommand(
new BookName($data->name),
new BookDescription($data->description),
new Author($data->author),
new BookContent($data->content),
new Price($data->price),
new CategoryId(Uuid::fromString($data->category)) // The category
);
The command handler is now a big problem because I don't know in which way I can save the category to the new book because the Book entity has the Category public property and not a string like the uuid received in the handler.
My actual solution is to find the category with the provided id by the category reporitory and set the found object in the Book entity. The use the book repository to save the new book.
final class CreateBookCommandHandler implements CommandHandlerInterface
{
public function __construct(private BookRepositoryInterface $bookRepository)
{
}
public function __invoke(CreateBookCommand $command): Book
{
$book = new Book(
$command->name,
$command->description,
$command->author,
$command->content,
$command->price,
);
$foundCategory = $this->courseRepository->ofId($command->categoryId);
if (is_null($foundCategory)) {
throw new NotFoundHttpException("Category not found by id " . $command->categoryId->value->toRfc4122());
}
$book->category = $foundCategory;
$this->bookRepository->add($book);
return $book;
}
}
With this code I can save correctly the category to the book but with the category defined like this in all the classes I have my big problem when getting the data. Like the processor, the state provider uses the fromModel
method of the BookResource. The method return a new self object and this is the problem because I defined the category as a string and in the swagger result I only can send a string and nothing else, like the Category object for example.
How can I accept a string in the category property in the book resource and send back the IRI of the associated Category when I retrieve a book item or books collection?
If I define the category property in the book resource as a Category object, and then in all the other classes, I see all the Category properties in the POST request body and I don't want to create a new category when I create a new Book. I only want to create the new book and associate the category to it! This last solution send in output the whole category in the book single item but I don't want this solution because it's not the IRI at the same way of the first solution!
If it's not clear ask me!
This is not really an issue, but more an information request. I really like the way repositories are implemented in this project. A problem I usually have with repositories is their size when implementing all the queries needed for the model.
From what I see here I understand that the basic idea is to treat them like immutable collections where methods are basically filters used to reduce the size of the collection.
Can you point me to a book, blog post or another resource that explore the subject in detail?
There's only a little inconsistency though between the doctrine book repository and the in memory one:
Except this the idea is excellent.
Thanks for the example and the presentation, but as this should be an example project for best practices(?), allow me to ask some questions:
But remove the complete book and then add it again.
$this->bookRepository->remove($book);
$this->bookRepository->add($book);
Why would you do that?
And despite telling me in the Chat on the API platform conference that they are readonly, they are not.
You even use this fact in the above-mentioned command handler:
$book->name = $command->name ?? $book->name;
$book->description = $command->description ?? $book->description;
$book->author = $command->author ?? $book->author;
$book->content = $command->content ?? $book->content;
which contradicts ideas DDD stands for. You don't want to modify single (public) properties and expose the object to the risk of entering an invalid state!
The correct way to do this update is through a single public method that updates all (private/protected) properties at once (and checks some rules).
Docker/Compose Versions:
$ docker-compose --version
docker-compose version 1.29.2, build unknown
docker --version
Docker version 20.10.19+dfsg1, build d85ef84
Docker Compose Config:
# docker-compose config | yq
services:
caddy:
build:
context: /home/qualeo/Downloads/apip-ddd
target: symfony_caddy
depends_on:
php:
condition: service_started
environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeMe!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeMe!'
SERVER_NAME: localhost, caddy:80
ports:
- protocol: tcp
published: 80
target: 80
- protocol: tcp
published: 443
target: 443
- protocol: udp
published: 443
target: 443
restart: unless-stopped
volumes:
- caddy_config:/config:rw
- caddy_data:/data:rw
- /home/qualeo/Downloads/apip-ddd/docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- /home/qualeo/Downloads/apip-ddd/public:/srv/app/public:ro
- php_socket:/var/run/php:rw
database:
environment:
POSTGRES_DB: app
POSTGRES_PASSWORD: ChangeMe
POSTGRES_USER: symfony
image: postgres:13-alpine
ports:
- target: 5432
volumes:
- db-data:/var/lib/postgresql/data:rw
php:
build:
args:
SKELETON: symfony/skeleton
STABILITY: stable
SYMFONY_VERSION: ''
context: /home/qualeo/Downloads/apip-ddd
target: symfony_php
environment:
APP_ENV: dev
MERCURE_JWT_SECRET: '!ChangeMe!'
MERCURE_PUBLIC_URL: https://localhost/.well-known/mercure
MERCURE_URL: http://caddy/.well-known/mercure
healthcheck:
interval: 10s
retries: 3
start_period: 30s
timeout: 3s
restart: unless-stopped
volumes:
- /home/qualeo/Downloads/apip-ddd:/srv/app:rw,cached
- /srv/app/var
- /home/qualeo/Downloads/apip-ddd/docker/php/conf.d/symfony.dev.ini:/usr/local/etc/php/conf.d/symfony.ini:rw
- php_socket:/var/run/php:rw
version: '3.4'
volumes:
caddy_config: {}
caddy_data: {}
db-data: {}
php_socket: {}
Steps to Reproduce:
git clone https://github.com/mtarld/apip-ddd.git
cd apip-ddd
make install
# ...
- Downloading dnoegel/php-xdg-base-dir (v0.1.1)
- Downloading amphp/amp (v2.6.2)
- Downloading amphp/byte-stream (v1.8.1)
- Downloading vimeo/psalm (4.27.0)
8/76 [==>-------------------------] 10%make[1]: *** [Makefile:52: vendor] Error 137
make: *** [Makefile:47: install] Error 2
make install
# ...
Executing script cache:clear [OK]
Executing script assets:install public [OK]
Dropped database "app" for connection named default
Created database "app" for connection named default
Updating database schema...
4 queries were executed
[OK] Database schema updated successfully!
make stop
# ...
make start
Creating apip-ddd_php_1 ... done
Creating apip-ddd_database_1 ... done
Creating apip-ddd_caddy_1 ... done
docker-compose logs -f caddy
Attaching to apip-ddd_caddy_1
# ...
caddy_1 | Error: adapting config using caddyfile: parsing caddyfile tokens for 'servers': /etc/caddy/Caddyfile:7 - Error during parsing: unrecognized protocol option 'experimental_http3'
caddy_1 | {"level":"info","ts":1667602742.0740893,"msg":"using provided configuration","config_file":"/etc/caddy/Caddyfile","config_adapter":"caddyfile"}
caddy_1 | {"level":"warn","ts":1667602742.074148,"logger":"caddyfile","msg":"DEPRECATED: protocol sub-option will be removed soon"}
caddy_1 | Error: adapting config using caddyfile: parsing caddyfile tokens for 'servers': /etc/caddy/Caddyfile:7 - Error during parsing: unrecognized protocol option 'experimental_http3'
$ docker-compose ps caddy
Name Command State Ports
----------------------------------------------------------------------
apip-ddd_caddy_1 caddy run --config /etc/ca ... Restarting
make stop
Hey,
I followed the instructions in the readme to start the project as I tried the endpoints on the /api
page I received the following error:
Commit 635d064
Url: https://localhost/api/books?page=1
Not sure how to fix that issue. readonly
properties are also new to me, especially in inheritance.
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.