diff --git a/docs/book/v4/introduction/getting-started.md b/docs/book/v4/introduction/getting-started.md index db745aa..fc13998 100644 --- a/docs/book/v4/introduction/getting-started.md +++ b/docs/book/v4/introduction/getting-started.md @@ -2,4 +2,6 @@ Using your terminal, navigate inside the directory you want to download the project files into. Make sure that the directory is empty before proceeding to the download process. Once there, run the following command: - git clone https://github.com/dotkernel/api.git . +```shell +git clone https://github.com/dotkernel/api.git . +``` diff --git a/docs/book/v4/introduction/installation.md b/docs/book/v4/introduction/installation.md index 2e11b81..3e6430e 100644 --- a/docs/book/v4/introduction/installation.md +++ b/docs/book/v4/introduction/installation.md @@ -2,21 +2,29 @@ ## Install dependencies - composer install +```shell +composer install +``` ## Development mode If you're installing the project for development, make sure you have development mode enabled, by running: - composer development-enable +```shell +composer development-enable +``` You can disable development mode by running: - composer development-disable +```shell +composer development-disable +``` You can check if you have development mode enabled by running: - composer development-status +```shell +composer development-status +``` ## Prepare config files @@ -34,11 +42,13 @@ Make sure you fill out the database credentials in `config/autoload/local.php` u * create a new MySQL database - set collation to `utf8mb4_general_ci` * run the database migrations by using the following command: - php vendor/bin/doctrine-migrations migrate +```shell +php vendor/bin/doctrine-migrations migrate +``` This command will prompt you to confirm that you want to run it. - WARNING! You are about to execute a migration in database "..." that could result in schema changes and data loss. Are you sure you wish to continue? (yes/no) [yes]: +> WARNING! You are about to execute a migration in database "..." that could result in schema changes and data loss. Are you sure you wish to continue? (yes/no) [yes]: Hit `Enter` to confirm the operation. @@ -48,40 +58,52 @@ Hit `Enter` to confirm the operation. To list all the fixtures, run: - php bin/doctrine fixtures:list +```shell +php bin/doctrine fixtures:list +``` This will output all the fixtures in the order of execution. To execute all fixtures, run: - php bin/doctrine fixtures:execute +```shell +php bin/doctrine fixtures:execute +``` To execute a specific fixture, run: - php bin/doctrine fixtures:execute --class=FixtureClassName +```shell +php bin/doctrine fixtures:execute --class=FixtureClassName +``` More details on how fixtures work can be found here: https://github.com/dotkernel/dot-data-fixtures#creating-fixtures ## Test the installation - php -S 0.0.0.0:8080 -t public +```shell +php -S 0.0.0.0:8080 -t public +``` Sending a GET request to the [home page](http://0.0.0.0:8080/) should output the following message: - { - "message": "Welcome to DotKernel API!" - } +> {"message": "Welcome to DotKernel API!"} ## Running tests The project has 2 types of tests : functional and unit tests, you can run both types at the same type by executing this command: - php vendor/bin/phpunit +```shell +php vendor/bin/phpunit +``` ## Running unit tests - vendor/bin/phpunit --testsuite=UnitTests --testdox --colors=always +```shell +vendor/bin/phpunit --testsuite=UnitTests --testdox --colors=always +``` ## Running functional tests - vendor/bin/phpunit --testsuite=FunctionalTests --testdox --colors=always +```shell +vendor/bin/phpunit --testsuite=FunctionalTests --testdox --colors=always +``` diff --git a/docs/book/v4/introduction/introduction.md b/docs/book/v4/introduction/introduction.md index 9bba1b7..9bf0271 100644 --- a/docs/book/v4/introduction/introduction.md +++ b/docs/book/v4/introduction/introduction.md @@ -35,8 +35,8 @@ The benefit of Doctrine for the programmer is the ability to focus on the object Our documentation is Postman based. We use the following files in which we store information about every available endpoint ready to be tested: - documentation/DotKernel_API.postman_collection.json - documentation/DotKernel_API.postman_environment.json +* documentation/DotKernel_API.postman_collection.json +* documentation/DotKernel_API.postman_environment.json ## Hypertext Application Language @@ -94,12 +94,18 @@ One of the best ways to ensure the quality of your product is to create and run We have 2 types of tests: functional and unit tests, you can run both types at the same type by executing this command: - php vendor/bin/phpunit +```shell +php vendor/bin/phpunit +``` ## Running unit tests - vendor/bin/phpunit --testsuite=UnitTests --testdox --colors=always +```shell +vendor/bin/phpunit --testsuite=UnitTests --testdox --colors=always +``` ## Running functional tests - vendor/bin/phpunit --testsuite=FunctionalTests --testdox --colors=always +```shell +vendor/bin/phpunit --testsuite=FunctionalTests --testdox --colors=always +``` diff --git a/docs/book/v4/tutorials/create-book-module.md b/docs/book/v4/tutorials/create-book-module.md index eb1fac9..32b753f 100644 --- a/docs/book/v4/tutorials/create-book-module.md +++ b/docs/book/v4/tutorials/create-book-module.md @@ -4,27 +4,31 @@ The below file structure is just an example, you can have multiple components such as event listeners, wrappers, etc. - /src/ - /Book/ - /src/ - /Collection/ - /BookCollection.php - /Entity/ - /Book.php - /Handler/ - /BookHandler.php - /Repository/ - /BookRepository.php - /Service/ - /BookService.php - /InputFilter/ - /Input/ - /NameInput.php - /AuthorInput.php - /ReleaseDateInput.php - /BookInputFilter.php - ConfigProvider.php - RoutesDelegator.php +```markdown +. +└── src/ +└── Book/ +└── src/ +├── Collection/ +│ └── BookCollection.php +├── Entity/ +│ └── Book.php +├── Handler/ +│ └── BookHandler.php +├── InputFilter/ +│ ├── Input/ +│ │ ├── AuthorInput.php +│ │ ├── NameInput.php +│ │ └── ReleaseDateInput.php +│ └── BookInputFilter.php +├── Repository/ +│ └── BookRepository.php +├── Service/ +│ ├── BookService.php +│ └── BookServiceInterface.php +├── ConfigProvider.php +└── RoutesDelegator.php +``` * `src/Book/src/Collection/BookCollection.php` - a collection refers to a container for a group of related objects, typically used to manage sets of related entities fetched from a database * `src/Book/src/Entity/Book.php` - an entity refers to a PHP class that represents a persistent object or data structure @@ -40,465 +44,489 @@ The below file structure is just an example, you can have multiple components su * `src/Book/src/Collection/BookCollection.php` - setName($name); - $this->setAuthor($author); - $this->setReleaseDate($releaseDate); - } - - public function getName(): string - { - return $this->name; - } - - public function setName(string $name): self - { - $this->name = $name; - - return $this; - } - - public function getAuthor(): string - { - return $this->author; - } - - public function setAuthor(string $author): self - { - $this->author = $author; - - return $this; - } - - public function getReleaseDate(): DateTimeImmutable - { - return $this->releaseDate; - } - - public function setReleaseDate(DateTimeImmutable $releaseDate): self - { - $this->releaseDate = $releaseDate; - - return $this; - } - - public function getArrayCopy(): array - { - return [ - 'uuid' => $this->getUuid()->toString(), - 'name' => $this->getName(), - 'author' => $this->getAuthor(), - 'releaseDate' => $this->getReleaseDate(), - ]; - } + parent::__construct(); + + $this->setName($name); + $this->setAuthor($author); + $this->setReleaseDate($releaseDate); + } + + public function getName(): string + { + return $this->name; } + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getAuthor(): string + { + return $this->author; + } + + public function setAuthor(string $author): self + { + $this->author = $author; + + return $this; + } + + public function getReleaseDate(): DateTimeImmutable + { + return $this->releaseDate; + } + + public function setReleaseDate(DateTimeImmutable $releaseDate): self + { + $this->releaseDate = $releaseDate; + + return $this; + } + + public function getArrayCopy(): array + { + return [ + 'uuid' => $this->getUuid()->toString(), + 'name' => $this->getName(), + 'author' => $this->getAuthor(), + 'releaseDate' => $this->getReleaseDate(), + ]; + } +} +``` + * `src/Book/src/Repository/BookRepository.php` - - */ - class BookRepository extends EntityRepository - { - public function saveBook(Book $book): Book - { - $this->getEntityManager()->persist($book); - $this->getEntityManager()->flush(); +```php +getEntityManager() - ->createQueryBuilder() - ->select('book') - ->from(Book::class, 'book') - ->orderBy($filters['order'] ?? 'book.created', $filters['dir'] ?? 'desc') - ->setFirstResult($page['offset']) - ->setMaxResults($page['limit']); - - $qb->getQuery()->useQueryCache(true); - - return new BookCollection($qb, false); - } - } +declare(strict_types=1); + +namespace Api\Book\Repository; + +use Api\App\Helper\PaginationHelper; +use Api\Book\Collection\BookCollection; +use Api\Book\Entity\Book; +use Doctrine\ORM\EntityRepository; +use Dot\AnnotatedServices\Annotation\Entity; + +/** + * @Entity(name="Api\Book\Entity\Book") + * @extends EntityRepository + */ +class BookRepository extends EntityRepository +{ + public function saveBook(Book $book): Book + { + $this->getEntityManager()->persist($book); + $this->getEntityManager()->flush(); + + return $book; + } + + public function getBooks(array $filters = []): BookCollection + { + $page = PaginationHelper::getOffsetAndLimit($filters); + + $qb = $this + ->getEntityManager() + ->createQueryBuilder() + ->select('book') + ->from(Book::class, 'book') + ->orderBy($filters['order'] ?? 'book.created', $filters['dir'] ?? 'desc') + ->setFirstResult($page['offset']) + ->setMaxResults($page['limit']); + + $qb->getQuery()->useQueryCache(true); + + return new BookCollection($qb, false); + } +} +``` * `src/Book/src/Service/BookService.php` - bookRepository->saveBook($book); - } - - public function getBooks(array $filters = []) - { - return $this->bookRepository->getBooks($filters); - } - } +declare(strict_types=1); + +namespace Api\Book\Service; + +use Api\Book\Entity\Book; +use Api\Book\Repository\BookRepository; +use Dot\AnnotatedServices\Annotation\Inject; +use DateTimeImmutable; + +class BookService implements BookServiceInterface +{ + /** + * @Inject({ + * BookRepository::class, + * }) + */ + public function __construct(protected BookRepository $bookRepository) + { + } + + public function createBook(array $data): Book + { + $book = new Book( + $data['name'], + $data['author'], + new DateTimeImmutable($data['releaseDate']) + ); + + return $this->bookRepository->saveBook($book); + } + + public function getBooks(array $filters = []) + { + return $this->bookRepository->getBooks($filters); + } +} +``` * `src/Book/src/Service/BookServiceInterface.php` - $this->getDependencies(), - MetadataMap::class => $this->getHalConfig(), - ]; - } - - public function getDependencies(): array - { - return [ - 'factories' => [ - BookHandler::class => AnnotatedServiceFactory::class, - BookService::class => AnnotatedServiceFactory::class, - BookRepository::class => AnnotatedRepositoryFactory::class, - ], - 'aliases' => [ - BookServiceInterface::class => BookService::class, - ], - ]; - } - - public function getHalConfig(): array - { - return [ - AppConfigProvider::getCollection(BookCollection::class, 'books.list', 'books'), - AppConfigProvider::getResource(Book::class, 'book.create'), - ]; - } - } +declare(strict_types=1); + +namespace Api\Book; + +use Api\Book\Collection\BookCollection; +use Api\Book\Entity\Book; +use Api\Book\Handler\BookHandler; +use Api\Book\Repository\BookRepository; +use Api\Book\Service\BookService; +use Api\Book\Service\BookServiceInterface; +use Dot\AnnotatedServices\Factory\AnnotatedRepositoryFactory; +use Dot\AnnotatedServices\Factory\AnnotatedServiceFactory; +use Mezzio\Hal\Metadata\MetadataMap; +use Api\App\ConfigProvider as AppConfigProvider; + +class ConfigProvider +{ + public function __invoke(): array + { + return [ + 'dependencies' => $this->getDependencies(), + MetadataMap::class => $this->getHalConfig(), + ]; + } + + public function getDependencies(): array + { + return [ + 'factories' => [ + BookHandler::class => AnnotatedServiceFactory::class, + BookService::class => AnnotatedServiceFactory::class, + BookRepository::class => AnnotatedRepositoryFactory::class, + ], + 'aliases' => [ + BookServiceInterface::class => BookService::class, + ], + ]; + } + + public function getHalConfig(): array + { + return [ + AppConfigProvider::getCollection(BookCollection::class, 'books.list', 'books'), + AppConfigProvider::getResource(Book::class, 'book.create'), + ]; + } +} +``` * `src/Book/src/RoutesDelegator.php` - get( - '/books', - BookHandler::class, - 'books.list' - ); - - $app->post( - '/book', - BookHandler::class, - 'book.create' - ); - - return $app; - } - } +namespace Api\Book; + +use Api\Book\Handler\BookHandler; +use Mezzio\Application; +use Psr\Container\ContainerInterface; + +class RoutesDelegator +{ + public function __invoke(ContainerInterface $container, string $serviceName, callable $callback): Application + { + /** @var Application $app */ + $app = $callback(); + + $app->get( + '/books', + BookHandler::class, + 'books.list' + ); + + $app->post( + '/book', + BookHandler::class, + 'book.create' + ); + + return $app; + } +} +``` * `src/Book/src/InputFilter/BookInputFilter.php` - add(new NameInput('name')); - $this->add(new AuthorInput('author')); - $this->add(new ReleaseDateInput('releaseDate')); - } - } +declare(strict_types=1); + +namespace Api\Book\InputFilter; + +use Api\Book\InputFilter\Input\AuthorInput; +use Api\Book\InputFilter\Input\NameInput; +use Api\Book\InputFilter\Input\ReleaseDateInput; +use Laminas\InputFilter\InputFilter; + +class BookInputFilter extends InputFilter +{ + public function __construct() + { + $this->add(new NameInput('name')); + $this->add(new AuthorInput('author')); + $this->add(new ReleaseDateInput('releaseDate')); + } +} +``` * `src/Book/src/InputFilter/Input/AuthorInput.php` - setRequired($isRequired); - - $this->getFilterChain() - ->attachByName(StringTrim::class) - ->attachByName(StripTags::class); - - $this->getValidatorChain() - ->attachByName(NotEmpty::class, [ - 'message' => sprintf(Message::VALIDATOR_REQUIRED_FIELD_BY_NAME, 'author'), - ], true); - } - } +declare(strict_types=1); + +namespace Api\Book\InputFilter\Input; + +use Api\App\Message; +use Laminas\Filter\StringTrim; +use Laminas\Filter\StripTags; +use Laminas\InputFilter\Input; +use Laminas\Validator\NotEmpty; + +class AuthorInput extends Input +{ + public function __construct(?string $name = null, bool $isRequired = true) + { + parent::__construct($name); + + $this->setRequired($isRequired); + + $this->getFilterChain() + ->attachByName(StringTrim::class) + ->attachByName(StripTags::class); + + $this->getValidatorChain() + ->attachByName(NotEmpty::class, [ + 'message' => sprintf(Message::VALIDATOR_REQUIRED_FIELD_BY_NAME, 'author'), + ], true); + } +} +``` * `src/Book/src/InputFilter/Input/NameInput.php` - setRequired($isRequired); - - $this->getFilterChain() - ->attachByName(StringTrim::class) - ->attachByName(StripTags::class); - - $this->getValidatorChain() - ->attachByName(NotEmpty::class, [ - 'message' => sprintf(Message::VALIDATOR_REQUIRED_FIELD_BY_NAME, 'name'), - ], true); - } - } +declare(strict_types=1); + +namespace Api\Book\InputFilter\Input; + +use Api\App\Message; +use Laminas\Filter\StringTrim; +use Laminas\Filter\StripTags; +use Laminas\InputFilter\Input; +use Laminas\Validator\NotEmpty; + +class NameInput extends Input +{ + public function __construct(?string $name = null, bool $isRequired = true) + { + parent::__construct($name); + + $this->setRequired($isRequired); + + $this->getFilterChain() + ->attachByName(StringTrim::class) + ->attachByName(StripTags::class); + + $this->getValidatorChain() + ->attachByName(NotEmpty::class, [ + 'message' => sprintf(Message::VALIDATOR_REQUIRED_FIELD_BY_NAME, 'name'), + ], true); + } +} +``` * `src/Book/src/InputFilter/Input/ReleaseDateInput.php` - setRequired($isRequired); - - $this->getFilterChain() - ->attachByName(StringTrim::class) - ->attachByName(StripTags::class); +```php +getValidatorChain() - ->attachByName(Date::class, [ - 'message' => sprintf(Message::INVALID_VALUE, 'releaseDate'), - ], true); - } - } +declare(strict_types=1); + +namespace Api\Book\InputFilter\Input; + +use Api\App\Message; +use Laminas\Filter\StringTrim; +use Laminas\Filter\StripTags; +use Laminas\InputFilter\Input; +use Laminas\Validator\Date; +use Laminas\Validator\NotEmpty; + +class ReleaseDateInput extends Input +{ + public function __construct(?string $name = null, bool $isRequired = true) + { + parent::__construct($name); + + $this->setRequired($isRequired); + + $this->getFilterChain() + ->attachByName(StringTrim::class) + ->attachByName(StripTags::class); + + $this->getValidatorChain() + ->attachByName(Date::class, [ + 'message' => sprintf(Message::INVALID_VALUE, 'releaseDate'), + ], true); + } +} +``` * `src/Book/src/Handler/BookHandler.php` - bookService->getBooks($request->getQueryParams()); - - return $this->createResponse($request, $books); - } - - public function post(ServerRequestInterface $request): ResponseInterface - { - $inputFilter = (new BookInputFilter())->setData($request->getParsedBody()); - if (! $inputFilter->isValid()) { - return $this->errorResponse($inputFilter->getMessages()); - } - - $book = $this->bookService->createBook($inputFilter->getValues()); - - return $this->createResponse($request, $book); - } - } +```php +bookService->getBooks($request->getQueryParams()); + + return $this->createResponse($request, $books); + } + + public function post(ServerRequestInterface $request): ResponseInterface + { + $inputFilter = (new BookInputFilter())->setData($request->getParsedBody()); + if (! $inputFilter->isValid()) { + return $this->errorResponse($inputFilter->getMessages()); + } + + $book = $this->bookService->createBook($inputFilter->getValues()); + + return $this->createResponse($request, $book); + } +} +``` ## Configuring and registering the new module @@ -510,71 +538,77 @@ Once you set up all the files as in the example above, you will need to do a few It should look like this: - public function getDependencies(): array - { - return [ - 'delegators' => [ - Application::class => [ - RoutesDelegator::class, - \Api\Admin\RoutesDelegator::class, - \Api\User\RoutesDelegator::class, - \Api\Book\RoutesDelegator::class, - ], - ], - 'factories' => [ - ... - ] - ... +```php +public function getDependencies(): array +{ + return [ + 'delegators' => [ + Application::class => [ + RoutesDelegator::class, + \Api\Admin\RoutesDelegator::class, + \Api\User\RoutesDelegator::class, + \Api\Book\RoutesDelegator::class, + ], + ], + 'factories' => [ + ... + ] + ... +``` * In `src/config/autoload/doctrine.global.php` add this under the `doctrine.driver` key: - 'BookEntities' => [ - 'class' => AttributeDriver::class, - 'cache' => 'array', - 'paths' => __DIR__ . '/../../src/Book/src/Entity', - ], +```php +'BookEntities' => [ + 'class' => AttributeDriver::class, + 'cache' => 'array', + 'paths' => __DIR__ . '/../../src/Book/src/Entity', +], +``` * `Api\\Book\Entity' => 'BookEntities',` add this under the `doctrine.driver.drivers` key Example: - [ - ... - 'driver' => [ - 'orm_default' => [ - 'class' => MappingDriverChain::class, - 'drivers' => [ - 'Api\\App\Entity' => 'AppEntities', - 'Api\\Admin\\Entity' => 'AdminEntities', - 'Api\\User\\Entity' => 'UserEntities', - 'Api\\Book\Entity' => 'BookEntities', - ], - ], - 'AdminEntities' => [ - 'class' => AttributeDriver::class, - 'cache' => 'array', - 'paths' => __DIR__ . '/../../src/Admin/src/Entity', - ], - 'UserEntities' => [ - 'class' => AttributeDriver::class, - 'cache' => 'array', - 'paths' => __DIR__ . '/../../src/User/src/Entity', - ], - 'AppEntities' => [ - 'class' => AttributeDriver::class, - 'cache' => 'array', - 'paths' => __DIR__ . '/../../src/App/src/Entity', - ], - 'BookEntities' => [ - 'class' => AttributeDriver::class, - 'cache' => 'array', - 'paths' => __DIR__ . '/../../src/Book/src/Entity', +```php + [ + ... + 'driver' => [ + 'orm_default' => [ + 'class' => MappingDriverChain::class, + 'drivers' => [ + 'Api\\App\Entity' => 'AppEntities', + 'Api\\Admin\\Entity' => 'AdminEntities', + 'Api\\User\\Entity' => 'UserEntities', + 'Api\\Book\Entity' => 'BookEntities', ], ], - ... + 'AdminEntities' => [ + 'class' => AttributeDriver::class, + 'cache' => 'array', + 'paths' => __DIR__ . '/../../src/Admin/src/Entity', + ], + 'UserEntities' => [ + 'class' => AttributeDriver::class, + 'cache' => 'array', + 'paths' => __DIR__ . '/../../src/User/src/Entity', + ], + 'AppEntities' => [ + 'class' => AttributeDriver::class, + 'cache' => 'array', + 'paths' => __DIR__ . '/../../src/App/src/Entity', + ], + 'BookEntities' => [ + 'class' => AttributeDriver::class, + 'cache' => 'array', + 'paths' => __DIR__ . '/../../src/Book/src/Entity', + ], + ], + ... +``` Next we need to configure access to the newly created endpoints, add `books.list` and `book.create` to the authorization rbac array, under the `UserRole::ROLE_GUEST` key. > Make sure you read and understand the rbac documentation. @@ -585,22 +619,30 @@ We created the `Book` entity, but we didn't create the associated table for it. Doctrine can handle the table creation, run the following command: - vendor/bin/doctrine-migrations diff --filter-expression='/^(?!oauth_)/' +```shell +vendor/bin/doctrine-migrations diff --filter-expression='/^(?!oauth_)/' +``` This will check for differences between your entities and database structure and create migration files if necessary, in `data/doctrine/migrations`. To execute the migrations run: - vendor/bin/doctrine-migrations migrate +```shell +vendor/bin/doctrine-migrations migrate +``` ## Checking endpoints If we did everything as planned we can call the `http://0.0.0.0:8080/book` endpoint and create a new book: - curl -X POST http://0.0.0.0:8080/book - -H "Content-Type: application/json" - -d '{"name": "test", "author": "author name", "releaseDate": "2023-03-03"}' +```shell +curl -X POST http://0.0.0.0:8080/book + -H "Content-Type: application/json" + -d '{"name": "test", "author": "author name", "releaseDate": "2023-03-03"}' +``` To list the books use : - curl http://0.0.0.0:8080/books +```shell +curl http://0.0.0.0:8080/books +```