From 5b6a2c391f12d74a214f1208b731935b22a53217 Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Wed, 24 Apr 2024 09:36:24 +0200 Subject: [PATCH 1/7] Practice 1: Create your first endpoint --- src/Controller/API/Hello.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/Controller/API/Hello.php diff --git a/src/Controller/API/Hello.php b/src/Controller/API/Hello.php new file mode 100644 index 0000000..04f4809 --- /dev/null +++ b/src/Controller/API/Hello.php @@ -0,0 +1,21 @@ + 'Hello world!' + ]); + } +} From 42338475800eafcc97e6db46c15f136f1d606e49 Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Wed, 24 Apr 2024 09:38:15 +0200 Subject: [PATCH 2/7] Practice 2: Dinotopia --- src/Controller/API/Dinosaurs/Create.php | 61 +++++++++++++++++++++++++ src/Controller/API/Dinosaurs/Delete.php | 35 ++++++++++++++ src/Controller/API/Dinosaurs/GetAll.php | 34 ++++++++++++++ src/Controller/API/Dinosaurs/GetOne.php | 38 +++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 src/Controller/API/Dinosaurs/Create.php create mode 100644 src/Controller/API/Dinosaurs/Delete.php create mode 100644 src/Controller/API/Dinosaurs/GetAll.php create mode 100644 src/Controller/API/Dinosaurs/GetOne.php diff --git a/src/Controller/API/Dinosaurs/Create.php b/src/Controller/API/Dinosaurs/Create.php new file mode 100644 index 0000000..f9c7a47 --- /dev/null +++ b/src/Controller/API/Dinosaurs/Create.php @@ -0,0 +1,61 @@ +getContent(), true); + + $species = $manager + ->getRepository(Species::class) + ->find($dinosaurData['speciesId']); + + if (!$species instanceof Species) { + return new JsonResponse([ + 'message' => sprintf('Species with id %s not found', $dinosaurData['speciesId']), + Response::HTTP_NOT_FOUND + ]); + } + + try { + $dinosaur = new Dinosaur( + $dinosaurData['name'], + $dinosaurData['gender'], + $species, + $dinosaurData['age'], + $dinosaurData['eyesColor'], + ); + + $em = $manager->getManager(); + $em->persist($dinosaur); + $em->flush(); + + return new JsonResponse([ + 'id' => $dinosaur->getId(), + 'name' => $dinosaur->getName(), + 'gender' => $dinosaur->getGender(), + 'speciesId' => $dinosaur->getSpecies()->getId(), + 'age' => $dinosaur->getAge(), + 'eyesColor' => $dinosaur->getEyesColor(), + ], Response::HTTP_CREATED); + } catch (\Exception) { + return new JsonResponse([ + 'message' => 'Something went wrong' + ], Response::HTTP_BAD_REQUEST); + } + } +} diff --git a/src/Controller/API/Dinosaurs/Delete.php b/src/Controller/API/Dinosaurs/Delete.php new file mode 100644 index 0000000..abf3508 --- /dev/null +++ b/src/Controller/API/Dinosaurs/Delete.php @@ -0,0 +1,35 @@ +getRepository(Dinosaur::class) + ->find($id); + + if (!$dinosaur instanceof Dinosaur) { + return new JsonResponse([ + 'message' => sprintf('Dinosaur with id %s not found', $id) + ], Response::HTTP_NOT_FOUND); + } + + $em = $manager->getManager(); + $em->remove($dinosaur); + $em->flush(); + + return new Response(status: Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Controller/API/Dinosaurs/GetAll.php b/src/Controller/API/Dinosaurs/GetAll.php new file mode 100644 index 0000000..ab7c04c --- /dev/null +++ b/src/Controller/API/Dinosaurs/GetAll.php @@ -0,0 +1,34 @@ +getRepository(Dinosaur::class) + ->findAll(); + + $dinosaurs = array_map(fn (Dinosaur $dinosaur) => [ + 'id' => $dinosaur->getId(), + 'name' => $dinosaur->getName(), + 'gender' => $dinosaur->getGender(), + 'speciesId' => $dinosaur->getSpecies()->getId(), + 'age' => $dinosaur->getAge(), + 'eyesColor' => $dinosaur->getEyesColor(), + ], $dinosaurs); + + return new JsonResponse($dinosaurs); + } +} diff --git a/src/Controller/API/Dinosaurs/GetOne.php b/src/Controller/API/Dinosaurs/GetOne.php new file mode 100644 index 0000000..d86af3b --- /dev/null +++ b/src/Controller/API/Dinosaurs/GetOne.php @@ -0,0 +1,38 @@ +getRepository(Dinosaur::class) + ->find($id); + + if (!$dinosaur instanceof Dinosaur) { + return new JsonResponse([ + 'message' => sprintf('Dinosaur with id %s not found.', $id) + ], Response::HTTP_NOT_FOUND); + } + + return new JsonResponse([ + 'id' => $dinosaur->getId(), + 'name' => $dinosaur->getName(), + 'gender' => $dinosaur->getGender(), + 'speciesId' => $dinosaur->getSpecies()->getId(), + 'age' => $dinosaur->getAge(), + 'eyesColor' => $dinosaur->getEyesColor(), + ]); + } +} From 36204f6642d5226be03cb70a6d1e93af6bd0adc3 Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Thu, 25 Apr 2024 10:10:29 +0200 Subject: [PATCH 3/7] Practice 3: Serializer --- config/serializer/dinosaur.yaml | 14 +++++++++++ config/serializer/species.yaml | 4 +++ src/Controller/API/Dinosaurs/Create.php | 33 ++++++++++++++++--------- src/Controller/API/Dinosaurs/GetAll.php | 22 ++++++++--------- src/Controller/API/Dinosaurs/GetOne.php | 23 +++++++++-------- 5 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 config/serializer/dinosaur.yaml create mode 100644 config/serializer/species.yaml diff --git a/config/serializer/dinosaur.yaml b/config/serializer/dinosaur.yaml new file mode 100644 index 0000000..09f7ef3 --- /dev/null +++ b/config/serializer/dinosaur.yaml @@ -0,0 +1,14 @@ +App\Entity\Dinosaur: + attributes: + id: + groups: ['dinosaur', 'dinosaurs'] + name: + groups: ['dinosaur', 'dinosaurs'] + gender: + groups: ['dinosaur'] + age: + groups: ['dinosaur'] + eyesColor: + groups: ['dinosaur'] + species: + groups: ['dinosaur'] diff --git a/config/serializer/species.yaml b/config/serializer/species.yaml new file mode 100644 index 0000000..62a6558 --- /dev/null +++ b/config/serializer/species.yaml @@ -0,0 +1,4 @@ +App\Entity\Species: + attributes: + id: + groups: ['dinosaur'] diff --git a/src/Controller/API/Dinosaurs/Create.php b/src/Controller/API/Dinosaurs/Create.php index f9c7a47..e0652cf 100644 --- a/src/Controller/API/Dinosaurs/Create.php +++ b/src/Controller/API/Dinosaurs/Create.php @@ -12,12 +12,16 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Serializer\SerializerInterface; final class Create extends AbstractController { #[Route('/api/dinosaurs', methods: 'POST')] - public function __invoke(ManagerRegistry $manager, Request $request): Response - { + public function __invoke( + ManagerRegistry $manager, + Request $request, + SerializerInterface $serializer + ): Response { $dinosaurData = json_decode($request->getContent(), true); $species = $manager @@ -44,18 +48,23 @@ public function __invoke(ManagerRegistry $manager, Request $request): Response $em->persist($dinosaur); $em->flush(); + $content = $serializer->serialize( + $dinosaur, + 'json', + ['groups' => ['dinosaur']] + ); + + return new JsonResponse( + $content, + Response::HTTP_CREATED, + json: true + ); + } catch (\Exception $e) { return new JsonResponse([ - 'id' => $dinosaur->getId(), - 'name' => $dinosaur->getName(), - 'gender' => $dinosaur->getGender(), - 'speciesId' => $dinosaur->getSpecies()->getId(), - 'age' => $dinosaur->getAge(), - 'eyesColor' => $dinosaur->getEyesColor(), + 'message' => 'Something went wrong', + 'error' => $e->getMessage(), + 'stack' => $e->getTraceAsString(), ], Response::HTTP_CREATED); - } catch (\Exception) { - return new JsonResponse([ - 'message' => 'Something went wrong' - ], Response::HTTP_BAD_REQUEST); } } } diff --git a/src/Controller/API/Dinosaurs/GetAll.php b/src/Controller/API/Dinosaurs/GetAll.php index ab7c04c..9b63ad9 100644 --- a/src/Controller/API/Dinosaurs/GetAll.php +++ b/src/Controller/API/Dinosaurs/GetAll.php @@ -10,25 +10,25 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Serializer\SerializerInterface; final class GetAll extends AbstractController { #[Route('/api/dinosaurs', methods: 'GET')] - public function __invoke(ManagerRegistry $manager): Response - { + public function __invoke( + ManagerRegistry $manager, + SerializerInterface $serializer + ): Response { $dinosaurs = $manager ->getRepository(Dinosaur::class) ->findAll(); - $dinosaurs = array_map(fn (Dinosaur $dinosaur) => [ - 'id' => $dinosaur->getId(), - 'name' => $dinosaur->getName(), - 'gender' => $dinosaur->getGender(), - 'speciesId' => $dinosaur->getSpecies()->getId(), - 'age' => $dinosaur->getAge(), - 'eyesColor' => $dinosaur->getEyesColor(), - ], $dinosaurs); + $content = $serializer->serialize( + $dinosaurs, + 'json', + ['groups' => ['dinosaurs']] + ); - return new JsonResponse($dinosaurs); + return new JsonResponse($content, json: true); } } diff --git a/src/Controller/API/Dinosaurs/GetOne.php b/src/Controller/API/Dinosaurs/GetOne.php index d86af3b..c49d05b 100644 --- a/src/Controller/API/Dinosaurs/GetOne.php +++ b/src/Controller/API/Dinosaurs/GetOne.php @@ -10,12 +10,16 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Serializer\SerializerInterface; final class GetOne extends AbstractController { #[Route('/api/dinosaurs/{id}', methods: 'GET')] - public function __invoke(ManagerRegistry $manager, string $id): Response - { + public function __invoke( + ManagerRegistry $manager, + SerializerInterface $serializer, + string $id + ): Response { $dinosaur = $manager ->getRepository(Dinosaur::class) ->find($id); @@ -26,13 +30,12 @@ public function __invoke(ManagerRegistry $manager, string $id): Response ], Response::HTTP_NOT_FOUND); } - return new JsonResponse([ - 'id' => $dinosaur->getId(), - 'name' => $dinosaur->getName(), - 'gender' => $dinosaur->getGender(), - 'speciesId' => $dinosaur->getSpecies()->getId(), - 'age' => $dinosaur->getAge(), - 'eyesColor' => $dinosaur->getEyesColor(), - ]); + $content = $serializer->serialize( + $dinosaur, + 'json', + ['groups' => ['dinosaur']] + ); + + return new JsonResponse($content, json: true); } } From f6737c6841032248087bb87259ff3f13a650535d Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Thu, 25 Apr 2024 10:25:42 +0200 Subject: [PATCH 4/7] Practice 4: JSON Schema Validation --- composer.json | 1 + composer.lock | 192 +++++++++++++++++++++++- jsonSchema/dinosaur/create.json | 41 +++++ src/Controller/API/Dinosaurs/Create.php | 25 ++- 4 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 jsonSchema/dinosaur/create.json diff --git a/composer.json b/composer.json index f760f5c..41947b4 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "doctrine/doctrine-bundle": "^2.12", "doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/orm": "^3.1", + "opis/json-schema": "^2.3", "phpdocumentor/reflection-docblock": "^5.4", "symfony/asset": "^7.0", "symfony/console": "^7.0", diff --git a/composer.lock b/composer.lock index a3c72ad..5accf8b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e1d68fda857d3a6a99a9c9d4e0c1c4dd", + "content-hash": "15539a85e57277b17ba1f864a4f4d0ee", "packages": [ { "name": "composer/package-versions-deprecated", @@ -1606,6 +1606,196 @@ ], "time": "2024-04-12T21:02:21+00:00" }, + { + "name": "opis/json-schema", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/c48df6d7089a45f01e1c82432348f2d5976f9bfb", + "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.0", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.3.0" + }, + "time": "2022-01-08T20:38:03+00:00" + }, + { + "name": "opis/string", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "9ebf1a1f873f502f6859d11210b25a4bf5d141e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/9ebf1a1f873f502f6859d11210b25a4bf5d141e7", + "reference": "9ebf1a1f873f502f6859d11210b25a4bf5d141e7", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.0.1" + }, + "time": "2022-01-14T15:42:23+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", diff --git a/jsonSchema/dinosaur/create.json b/jsonSchema/dinosaur/create.json new file mode 100644 index 0000000..aec39ee --- /dev/null +++ b/jsonSchema/dinosaur/create.json @@ -0,0 +1,41 @@ +{ + "type": "object", + "title": "Dinausor create", + "description": "Input data for creating a new dinausor", + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the dinausor", + "minLength": 1, + "maxLength": 255 + }, + "gender": { + "type": "string", + "title": "Gender", + "description": "Gender of the dinausor", + "enum": [ + "Male", + "Female" + ] + }, + "speciesId": { + "type": "integer", + "title": "Species ID", + "description": "Species of the dinausor" + }, + "age": { + "type": "integer", + "title": "Age", + "description": "Age of the dinausor", + "exclusiveMinimum": 0 + }, + "eyesColor": { + "type": "string", + "title": "Eye color", + "description": "Eye color of the dinausor", + "minLength": 1, + "maxLength": 255 + } + } +} diff --git a/src/Controller/API/Dinosaurs/Create.php b/src/Controller/API/Dinosaurs/Create.php index e0652cf..d9f9a43 100644 --- a/src/Controller/API/Dinosaurs/Create.php +++ b/src/Controller/API/Dinosaurs/Create.php @@ -8,21 +8,42 @@ use App\Entity\Species; use Doctrine\Persistence\ManagerRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Serializer\SerializerInterface; +use Opis\JsonSchema\Validator as JsonSchemaValidator; +use Opis\JsonSchema\Errors\ErrorFormatter; final class Create extends AbstractController { + public function __construct( + #[Autowire('%kernel.project_dir%/jsonSchema')] + private readonly string $jsonSchemaDir + ) { + } + #[Route('/api/dinosaurs', methods: 'POST')] public function __invoke( ManagerRegistry $manager, Request $request, SerializerInterface $serializer ): Response { - $dinosaurData = json_decode($request->getContent(), true); + $dinosaurData = json_decode($request->getContent()); + + $validator = new JsonSchemaValidator(); + $validator->resolver()->registerPrefix('http://localhost/api', $this->jsonSchemaDir); + + $result = $validator->validate($dinosaurData, 'http://localhost/api/dinosaur/create.json'); + + if ($result->isValid() === false) { + return new JsonResponse([ + 'message' => 'Dinosaur creation failed', + 'errors' => (new ErrorFormatter())->format($result->error()) + ], Response::HTTP_BAD_REQUEST); + } $species = $manager ->getRepository(Species::class) @@ -35,6 +56,8 @@ public function __invoke( ]); } + $dinosaurData = json_decode($request->getContent(), true); + try { $dinosaur = new Dinosaur( $dinosaurData['name'], From cdd0a317bce2cf57c7c7e0f60de296e03afee321 Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Thu, 25 Apr 2024 10:48:46 +0200 Subject: [PATCH 5/7] Practice 4: JSON Schema Validation (improve with a dedicated service) --- src/Controller/API/Dinosaurs/Create.php | 36 ++++------ src/Validator/JsonSchema/Error.php | 24 +++++++ .../JsonSchema/ValidationException.php | 25 +++++++ src/Validator/JsonSchema/Validator.php | 72 +++++++++++++++++++ 4 files changed, 135 insertions(+), 22 deletions(-) create mode 100644 src/Validator/JsonSchema/Error.php create mode 100644 src/Validator/JsonSchema/ValidationException.php create mode 100644 src/Validator/JsonSchema/Validator.php diff --git a/src/Controller/API/Dinosaurs/Create.php b/src/Controller/API/Dinosaurs/Create.php index d9f9a43..dedd3fd 100644 --- a/src/Controller/API/Dinosaurs/Create.php +++ b/src/Controller/API/Dinosaurs/Create.php @@ -8,43 +8,35 @@ use App\Entity\Species; use Doctrine\Persistence\ManagerRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Serializer\SerializerInterface; -use Opis\JsonSchema\Validator as JsonSchemaValidator; -use Opis\JsonSchema\Errors\ErrorFormatter; +use App\Validator\JsonSchema\Validator as JsonSchemaValidator; +use App\Validator\JsonSchema\ValidationException as JsonSchemaValidationException; final class Create extends AbstractController { - public function __construct( - #[Autowire('%kernel.project_dir%/jsonSchema')] - private readonly string $jsonSchemaDir - ) { - } - #[Route('/api/dinosaurs', methods: 'POST')] public function __invoke( ManagerRegistry $manager, Request $request, - SerializerInterface $serializer + SerializerInterface $serializer, + JsonSchemaValidator $jsonSchemaValidator ): Response { - $dinosaurData = json_decode($request->getContent()); - - $validator = new JsonSchemaValidator(); - $validator->resolver()->registerPrefix('http://localhost/api', $this->jsonSchemaDir); - - $result = $validator->validate($dinosaurData, 'http://localhost/api/dinosaur/create.json'); - - if ($result->isValid() === false) { - return new JsonResponse([ - 'message' => 'Dinosaur creation failed', - 'errors' => (new ErrorFormatter())->format($result->error()) - ], Response::HTTP_BAD_REQUEST); + try { + $jsonSchemaValidator->validate($request, '/dinosaur/create.json'); + } catch (JsonSchemaValidationException $e) { + return new JsonResponse( + $e->getMessage(), + Response::HTTP_BAD_REQUEST, + json: true + ); } + $dinosaurData = json_decode($request->getContent(), true); + $species = $manager ->getRepository(Species::class) ->find($dinosaurData['speciesId']); diff --git a/src/Validator/JsonSchema/Error.php b/src/Validator/JsonSchema/Error.php new file mode 100644 index 0000000..b297252 --- /dev/null +++ b/src/Validator/JsonSchema/Error.php @@ -0,0 +1,24 @@ +path; + } + + public function getMessage(): string + { + return $this->message; + } +} diff --git a/src/Validator/JsonSchema/ValidationException.php b/src/Validator/JsonSchema/ValidationException.php new file mode 100644 index 0000000..761fd05 --- /dev/null +++ b/src/Validator/JsonSchema/ValidationException.php @@ -0,0 +1,25 @@ +getPath()] = $error->getMessage(); + } + + parent::__construct(json_encode($data, JSON_THROW_ON_ERROR)); + } +} diff --git a/src/Validator/JsonSchema/Validator.php b/src/Validator/JsonSchema/Validator.php new file mode 100644 index 0000000..e24c8c5 --- /dev/null +++ b/src/Validator/JsonSchema/Validator.php @@ -0,0 +1,72 @@ +validator = new JsonSchemaValidator(); + + $this->validator->resolver()->registerPrefix( + self::SCHEMA_PREFIX, + $this->schemaDirectoryPath + ); + + $this->validator->setMaxErrors(10); + } + + public function validate(Request $request, string $schemaPath): void + { + $data = json_decode( + $request->getContent(), + flags: JSON_THROW_ON_ERROR + ); + + $result = $this->validator->validate( + $data, + self::SCHEMA_PREFIX . $schemaPath + ); + + if ($result->isValid()) { + return; + } + + throw new ValidationException([...$this->yieldErrors($result->error())]); + } + + /** + * @return iterable + */ + private function yieldErrors(ValidationError $error): iterable + { + $formatter = new ErrorFormatter(); + + $errors = $formatter->format($error); + + foreach ($errors as $field => $message) { + $formatted = sizeof($message) === 1 + ? $message[0] + : json_encode($message); + + yield new Error( + $field, + $formatted, + ); + } + } +} From 4970d1b1a8c16a1131ed91c9046539903a23ae05 Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Thu, 25 Apr 2024 16:26:41 +0200 Subject: [PATCH 6/7] Practice 5: Open API documentation --- composer.json | 1 + composer.lock | 198 +++++++++++++++++++++++- config/bundles.php | 1 + config/packages/nelmio_api_doc.yaml | 9 ++ config/routes/nelmio_api_doc.yaml | 4 + src/Controller/API/Dinosaurs/Create.php | 65 +++++++- src/Controller/API/Dinosaurs/Delete.php | 12 +- src/Controller/API/Dinosaurs/GetAll.php | 16 ++ src/Controller/API/Dinosaurs/GetOne.php | 12 ++ symfony.lock | 13 ++ 10 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 config/packages/nelmio_api_doc.yaml create mode 100644 config/routes/nelmio_api_doc.yaml diff --git a/composer.json b/composer.json index 41947b4..74f760d 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "doctrine/doctrine-bundle": "^2.12", "doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/orm": "^3.1", + "nelmio/api-doc-bundle": "^4.26", "opis/json-schema": "^2.3", "phpdocumentor/reflection-docblock": "^5.4", "symfony/asset": "^7.0", diff --git a/composer.lock b/composer.lock index 5accf8b..d4e93ae 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "15539a85e57277b17ba1f864a4f4d0ee", + "content-hash": "ac5bfbb2074a0b964d41a08684710631", "packages": [ { "name": "composer/package-versions-deprecated", @@ -1606,6 +1606,121 @@ ], "time": "2024-04-12T21:02:21+00:00" }, + { + "name": "nelmio/api-doc-bundle", + "version": "v4.26.1", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioApiDocBundle.git", + "reference": "2af8c5d55d48c488ef1a650b9ef7133e3c583623" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/2af8c5d55d48c488ef1a650b9ef7133e3c583623", + "reference": "2af8c5d55d48c488ef1a650b9ef7133e3c583623", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.4", + "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0", + "phpdocumentor/type-resolver": "^1.8.2", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/container": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/config": "^5.4 || ^6.0 || ^7.0", + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/framework-bundle": "^5.4.24 || ^6.0 || ^7.0", + "symfony/http-foundation": "^5.4 || ^6.0 || ^7.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", + "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0", + "symfony/property-info": "^5.4.10 || ^6.0 || ^7.0", + "symfony/routing": "^5.4 || ^6.0 || ^7.0", + "zircote/swagger-php": "^4.6.1" + }, + "conflict": { + "zircote/swagger-php": "4.8.7" + }, + "require-dev": { + "api-platform/core": "^2.7.0 || ^3", + "composer/package-versions-deprecated": "1.11.99.1", + "doctrine/annotations": "^2.0", + "friendsofphp/php-cs-fixer": "^3.52", + "friendsofsymfony/rest-bundle": "^2.8 || ^3.0", + "jms/serializer": "^1.14 || ^3.0", + "jms/serializer-bundle": "^2.3 || ^3.0 || ^4.0 || ^5.0", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-phpunit": "^1.3", + "phpstan/phpstan-strict-rules": "^1.5", + "phpstan/phpstan-symfony": "^1.3", + "phpunit/phpunit": "^9.6 || ^10.5", + "symfony/asset": "^5.4 || ^6.0 || ^7.0", + "symfony/browser-kit": "^5.4 || ^6.0 || ^7.0", + "symfony/cache": "^5.4 || ^6.0 || ^7.0", + "symfony/dom-crawler": "^5.4 || ^6.0 || ^7.0", + "symfony/expression-language": "^5.4 || ^6.0 || ^7.0", + "symfony/form": "^5.4 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^6.4", + "symfony/property-access": "^5.4 || ^6.0 || ^7.0", + "symfony/security-csrf": "^5.4 || ^6.0 || ^7.0", + "symfony/serializer": "^5.4 || ^6.0 || ^7.0", + "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0", + "symfony/templating": "^5.4 || ^6.0 || ^7.0", + "symfony/twig-bundle": "^5.4 || ^6.0 || ^7.0", + "symfony/validator": "^5.4 || ^6.0 || ^7.0", + "willdurand/hateoas-bundle": "^1.0 || ^2.0" + }, + "suggest": { + "api-platform/core": "For using an API oriented framework.", + "doctrine/annotations": "For using doctrine annotations", + "friendsofsymfony/rest-bundle": "For using the parameters annotations.", + "jms/serializer-bundle": "For describing your models.", + "symfony/asset": "For using the Swagger UI.", + "symfony/cache": "For using a PSR-6 compatible cache implementation with the API doc generator.", + "symfony/form": "For describing your form type models.", + "symfony/monolog-bundle": "For using a PSR-3 compatible logger implementation with the API PHP describer.", + "symfony/security-csrf": "For using csrf protection tokens in forms.", + "symfony/serializer": "For describing your models.", + "symfony/twig-bundle": "For using the Swagger UI.", + "symfony/validator": "For describing the validation constraints in your models.", + "willdurand/hateoas-bundle": "For extracting HATEOAS metadata." + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\ApiDocBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioApiDocBundle/contributors" + } + ], + "description": "Generates documentation for your REST API from annotations and attributes", + "keywords": [ + "api", + "doc", + "documentation", + "rest" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioApiDocBundle/issues", + "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.26.1" + }, + "time": "2024-04-20T11:19:38+00:00" + }, { "name": "opis/json-schema", "version": "2.3.0", @@ -7503,6 +7618,87 @@ "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, "time": "2022-06-03T18:03:27+00:00" + }, + { + "name": "zircote/swagger-php", + "version": "4.9.0", + "source": { + "type": "git", + "url": "https://github.com/zircote/swagger-php.git", + "reference": "b46a36d006f4db4d761995a5add1e7ab0386ed1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/b46a36d006f4db4d761995a5add1e7ab0386ed1d", + "reference": "b46a36d006f4db4d761995a5add1e7ab0386ed1d", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.2", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2 || ^3", + "symfony/finder": ">=2.2", + "symfony/yaml": ">=3.3" + }, + "require-dev": { + "composer/package-versions-deprecated": "^1.11", + "doctrine/annotations": "^1.7 || ^2.0", + "friendsofphp/php-cs-fixer": "^2.17 || ^3.47.1", + "phpstan/phpstan": "^1.6", + "phpunit/phpunit": ">=8", + "vimeo/psalm": "^4.23" + }, + "suggest": { + "doctrine/annotations": "^1.7 || ^2.0" + }, + "bin": [ + "bin/openapi" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenApi\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Robert Allen", + "email": "zircote@gmail.com" + }, + { + "name": "Bob Fanger", + "email": "bfanger@gmail.com", + "homepage": "https://bfanger.nl" + }, + { + "name": "Martin Rademacher", + "email": "mano@radebatz.net", + "homepage": "https://radebatz.net" + } + ], + "description": "swagger-php - Generate interactive documentation for your RESTful API using phpdoc annotations", + "homepage": "https://github.com/zircote/swagger-php/", + "keywords": [ + "api", + "json", + "rest", + "service discovery" + ], + "support": { + "issues": "https://github.com/zircote/swagger-php/issues", + "source": "https://github.com/zircote/swagger-php/tree/4.9.0" + }, + "time": "2024-04-18T22:32:11+00:00" } ], "packages-dev": [ diff --git a/config/bundles.php b/config/bundles.php index 3106eee..b74b823 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -12,4 +12,5 @@ Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true], ]; diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml new file mode 100644 index 0000000..a8b842a --- /dev/null +++ b/config/packages/nelmio_api_doc.yaml @@ -0,0 +1,9 @@ +nelmio_api_doc: + documentation: + info: + title: Dinotopia API + description: Dinotopia API, a RESTful API for a dinosaur park + version: 1.0.0 + areas: # to filter documented areas + path_patterns: + - ^/api(?!/doc$) # Accepts routes under /api except /api/doc diff --git a/config/routes/nelmio_api_doc.yaml b/config/routes/nelmio_api_doc.yaml new file mode 100644 index 0000000..98dfb48 --- /dev/null +++ b/config/routes/nelmio_api_doc.yaml @@ -0,0 +1,4 @@ +app.swagger_ui: + path: /api/doc + methods: GET + defaults: { _controller: nelmio_api_doc.controller.swagger_ui } diff --git a/src/Controller/API/Dinosaurs/Create.php b/src/Controller/API/Dinosaurs/Create.php index dedd3fd..8b1ebb4 100644 --- a/src/Controller/API/Dinosaurs/Create.php +++ b/src/Controller/API/Dinosaurs/Create.php @@ -15,10 +15,73 @@ use Symfony\Component\Serializer\SerializerInterface; use App\Validator\JsonSchema\Validator as JsonSchemaValidator; use App\Validator\JsonSchema\ValidationException as JsonSchemaValidationException; +use Nelmio\ApiDocBundle\Annotation\Model; +use OpenApi\Attributes as OA; final class Create extends AbstractController { #[Route('/api/dinosaurs', methods: 'POST')] + #[OA\Tag('dinosaur')] + #[OA\RequestBody( + required: true, + content: new OA\JsonContent( + schema: "DinosaurCreate", + title: "Dinosaur create", + description: "Input data for creating a new dinosaur", + required: ["name", "gender", "speciesId", "age", "eyesColor"], + properties: [ + new OA\Property( + property: "name", + type: "string", + description: "Name of the dinosaur", + minLength: 1, + maxLength: 255, + example: 'Rex' + ), + new OA\Property( + property: "gender", + type: "string", + enum: ["Male", "Female"], + description: "Gender of the dinosaur", + example: "Male" + ), + new OA\Property( + property: "speciesId", + type: "integer", + description: "ID of the species", + example: 1 + ), + new OA\Property( + property: "age", + type: "integer", + description: "Age of the dinosaur", + exclusiveMinimum: 0, + example: 10 + ), + new OA\Property( + property: "eyesColor", + type: "string", + description: "Color of the eyes", + minLength: 1, + maxLength: 255, + example: 'red' + ) + ] + ) + )] + #[OA\Response( + response: Response::HTTP_CREATED, + description: 'Create and return a dinosaur', + content: new Model(type: Dinosaur::class, groups: ['dinosaur']) + )] + #[OA\Response( + response: Response::HTTP_BAD_REQUEST, + description: 'Bad request' + )] + #[OA\Response( + response: Response::HTTP_UNPROCESSABLE_ENTITY, + description: 'The species ID does not exists' + )] public function __invoke( ManagerRegistry $manager, Request $request, @@ -44,7 +107,7 @@ public function __invoke( if (!$species instanceof Species) { return new JsonResponse([ 'message' => sprintf('Species with id %s not found', $dinosaurData['speciesId']), - Response::HTTP_NOT_FOUND + Response::HTTP_UNPROCESSABLE_ENTITY ]); } diff --git a/src/Controller/API/Dinosaurs/Delete.php b/src/Controller/API/Dinosaurs/Delete.php index abf3508..3251e2a 100644 --- a/src/Controller/API/Dinosaurs/Delete.php +++ b/src/Controller/API/Dinosaurs/Delete.php @@ -10,10 +10,20 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use OpenApi\Attributes as OA; final class Delete extends AbstractController { #[Route('/api/dinosaurs/{id}', methods: 'DELETE')] + #[OA\Tag('dinosaur')] + #[OA\Response( + response: Response::HTTP_UNPROCESSABLE_ENTITY, + description: 'Dinosaur with given ID not found' + )] + #[OA\Response( + response: Response::HTTP_NO_CONTENT, + description: 'Dinosaur successfully deleted' + )] public function __invoke(ManagerRegistry $manager, string $id): Response { $dinosaur = $manager @@ -23,7 +33,7 @@ public function __invoke(ManagerRegistry $manager, string $id): Response if (!$dinosaur instanceof Dinosaur) { return new JsonResponse([ 'message' => sprintf('Dinosaur with id %s not found', $id) - ], Response::HTTP_NOT_FOUND); + ], Response::HTTP_UNPROCESSABLE_ENTITY); } $em = $manager->getManager(); diff --git a/src/Controller/API/Dinosaurs/GetAll.php b/src/Controller/API/Dinosaurs/GetAll.php index 9b63ad9..ee7acf2 100644 --- a/src/Controller/API/Dinosaurs/GetAll.php +++ b/src/Controller/API/Dinosaurs/GetAll.php @@ -11,10 +11,26 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Serializer\SerializerInterface; +use Nelmio\ApiDocBundle\Annotation\Model; +use OpenApi\Attributes as OA; final class GetAll extends AbstractController { #[Route('/api/dinosaurs', methods: 'GET')] + #[OA\Tag('dinosaur')] + #[OA\Response( + response: Response::HTTP_OK, + description: 'List all the dinosaurs', + content: new OA\JsonContent( + type: 'array', + items: new OA\Items( + ref: new Model( + type: Dinosaur::class, + groups: ['dinosaurs'] + ) + ) + ) + )] public function __invoke( ManagerRegistry $manager, SerializerInterface $serializer diff --git a/src/Controller/API/Dinosaurs/GetOne.php b/src/Controller/API/Dinosaurs/GetOne.php index c49d05b..940284e 100644 --- a/src/Controller/API/Dinosaurs/GetOne.php +++ b/src/Controller/API/Dinosaurs/GetOne.php @@ -11,10 +11,22 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Serializer\SerializerInterface; +use Nelmio\ApiDocBundle\Annotation\Model; +use OpenApi\Attributes as OA; final class GetOne extends AbstractController { #[Route('/api/dinosaurs/{id}', methods: 'GET')] + #[OA\Tag('dinosaur')] + #[OA\Response( + response: Response::HTTP_UNPROCESSABLE_ENTITY, + description: 'Dinosaur with given ID not found' + )] + #[OA\Response( + response: Response::HTTP_OK, + description: 'Specified dinosaur', + content: new Model(type: Dinosaur::class, groups: ['dinosaur']) + )] public function __invoke( ManagerRegistry $manager, SerializerInterface $serializer, diff --git a/symfony.lock b/symfony.lock index e355d56..2395012 100644 --- a/symfony.lock +++ b/symfony.lock @@ -95,6 +95,19 @@ "myclabs/deep-copy": { "version": "1.10.2" }, + "nelmio/api-doc-bundle": { + "version": "4.26", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "3.0", + "ref": "c8e0c38e1a280ab9e37587a8fa32b251d5bc1c94" + }, + "files": [ + "config/packages/nelmio_api_doc.yaml", + "config/routes/nelmio_api_doc.yaml" + ] + }, "nikic/php-parser": { "version": "v4.13.0" }, From fb91fc2030005f46f8ac7ff19394ad9536e6467e Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Fri, 26 Apr 2024 11:56:44 +0200 Subject: [PATCH 7/7] Practice 6: filters, sorts and pagination --- src/Controller/API/Dinosaurs/Create.php | 15 +++-- src/Controller/API/Dinosaurs/Delete.php | 13 ++--- src/Controller/API/Dinosaurs/GetAll.php | 68 ++++++++++++++++++++-- src/Controller/API/Dinosaurs/GetOne.php | 7 +-- src/Repository/DinosaurRepository.php | 77 +++++++++++++++++++++---- 5 files changed, 146 insertions(+), 34 deletions(-) diff --git a/src/Controller/API/Dinosaurs/Create.php b/src/Controller/API/Dinosaurs/Create.php index 8b1ebb4..f987b40 100644 --- a/src/Controller/API/Dinosaurs/Create.php +++ b/src/Controller/API/Dinosaurs/Create.php @@ -6,7 +6,8 @@ use App\Entity\Dinosaur; use App\Entity\Species; -use Doctrine\Persistence\ManagerRegistry; +use App\Repository\DinosaurRepository; +use App\Repository\SpeciesRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -83,7 +84,8 @@ enum: ["Male", "Female"], description: 'The species ID does not exists' )] public function __invoke( - ManagerRegistry $manager, + DinosaurRepository $dinosaurRepository, + SpeciesRepository $speciesDinosaur, Request $request, SerializerInterface $serializer, JsonSchemaValidator $jsonSchemaValidator @@ -100,9 +102,7 @@ public function __invoke( $dinosaurData = json_decode($request->getContent(), true); - $species = $manager - ->getRepository(Species::class) - ->find($dinosaurData['speciesId']); + $species = $speciesDinosaur->find($dinosaurData['speciesId']); if (!$species instanceof Species) { return new JsonResponse([ @@ -122,9 +122,8 @@ public function __invoke( $dinosaurData['eyesColor'], ); - $em = $manager->getManager(); - $em->persist($dinosaur); - $em->flush(); + $dinosaurRepository->persist($dinosaur); + $dinosaurRepository->flush(); $content = $serializer->serialize( $dinosaur, diff --git a/src/Controller/API/Dinosaurs/Delete.php b/src/Controller/API/Dinosaurs/Delete.php index 3251e2a..a7506d7 100644 --- a/src/Controller/API/Dinosaurs/Delete.php +++ b/src/Controller/API/Dinosaurs/Delete.php @@ -5,7 +5,7 @@ namespace App\Controller\API\Dinosaurs; use App\Entity\Dinosaur; -use Doctrine\Persistence\ManagerRegistry; +use App\Repository\DinosaurRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -24,11 +24,9 @@ final class Delete extends AbstractController response: Response::HTTP_NO_CONTENT, description: 'Dinosaur successfully deleted' )] - public function __invoke(ManagerRegistry $manager, string $id): Response + public function __invoke(DinosaurRepository $dinosaurRepository, string $id): Response { - $dinosaur = $manager - ->getRepository(Dinosaur::class) - ->find($id); + $dinosaur = $dinosaurRepository->find($id); if (!$dinosaur instanceof Dinosaur) { return new JsonResponse([ @@ -36,9 +34,8 @@ public function __invoke(ManagerRegistry $manager, string $id): Response ], Response::HTTP_UNPROCESSABLE_ENTITY); } - $em = $manager->getManager(); - $em->remove($dinosaur); - $em->flush(); + $dinosaurRepository->remove($dinosaur); + $dinosaurRepository->flush(); return new Response(status: Response::HTTP_NO_CONTENT); } diff --git a/src/Controller/API/Dinosaurs/GetAll.php b/src/Controller/API/Dinosaurs/GetAll.php index ee7acf2..d403b11 100644 --- a/src/Controller/API/Dinosaurs/GetAll.php +++ b/src/Controller/API/Dinosaurs/GetAll.php @@ -11,6 +11,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\HttpFoundation\Request; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; @@ -18,6 +19,51 @@ final class GetAll extends AbstractController { #[Route('/api/dinosaurs', methods: 'GET')] #[OA\Tag('dinosaur')] + #[OA\Parameter( + parameter: 'page', + name: 'page', + in: 'query', + schema: new OA\Schema( + type: 'integer', + default: 1, + minimum: 1 + ), + )] + #[OA\Parameter( + parameter: 'limit', + name: 'limit', + in: 'query', + schema: new OA\Schema( + type: 'integer', + default: 25, + minimum: 1 + ), + )] + #[OA\Parameter( + parameter: 'search', + name: 'search', + in: 'query', + schema: new OA\Schema(type: 'string'), + example: 'Dino' + )] + #[OA\Parameter( + parameter: 'filters', + name: 'filters', + in: 'query', + schema: new OA\Schema(type: 'object'), + style: 'deepObject', + explode: true, + example: '{"name": "rex", "gender": "male"}', + )] + #[OA\Parameter( + parameter: 'sorts', + name: 'sorts', + in: 'query', + schema: new OA\Schema(type: 'object'), + style: 'deepObject', + explode: true, + example: '{"age": "ASC"}' + )] #[OA\Response( response: Response::HTTP_OK, description: 'List all the dinosaurs', @@ -32,12 +78,26 @@ final class GetAll extends AbstractController ) )] public function __invoke( - ManagerRegistry $manager, - SerializerInterface $serializer + Request $request, + SerializerInterface $serializer, + ManagerRegistry $managerRegistry, ): Response { - $dinosaurs = $manager + $filters = $request->query->all('filters') ?? []; + $sorts = $request->query->all('sorts') ?? []; + $search = $request->query->get('search'); + $page = $request->query->getInt('page', 1); + $limit = $request->query->getInt('limit', 5); + + $dinosaurs = $managerRegistry ->getRepository(Dinosaur::class) - ->findAll(); + ->search($search) + ->filter($filters) + ->search($search) + ->sort($sorts) + ->paginate( + $page, + $limit + ); $content = $serializer->serialize( $dinosaurs, diff --git a/src/Controller/API/Dinosaurs/GetOne.php b/src/Controller/API/Dinosaurs/GetOne.php index 940284e..d7c2aa7 100644 --- a/src/Controller/API/Dinosaurs/GetOne.php +++ b/src/Controller/API/Dinosaurs/GetOne.php @@ -5,6 +5,7 @@ namespace App\Controller\API\Dinosaurs; use App\Entity\Dinosaur; +use App\Repository\DinosaurRepository; use Doctrine\Persistence\ManagerRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -28,13 +29,11 @@ final class GetOne extends AbstractController content: new Model(type: Dinosaur::class, groups: ['dinosaur']) )] public function __invoke( - ManagerRegistry $manager, + DinosaurRepository $dinosaurRepository, SerializerInterface $serializer, string $id ): Response { - $dinosaur = $manager - ->getRepository(Dinosaur::class) - ->find($id); + $dinosaur = $dinosaurRepository->find($id); if (!$dinosaur instanceof Dinosaur) { return new JsonResponse([ diff --git a/src/Repository/DinosaurRepository.php b/src/Repository/DinosaurRepository.php index 1016b70..d809cee 100644 --- a/src/Repository/DinosaurRepository.php +++ b/src/Repository/DinosaurRepository.php @@ -4,25 +4,82 @@ use App\Entity\Dinosaur; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; class DinosaurRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry) - { + private const ALIAS = 'dinosaur'; + + private QueryBuilder $queryBuilder; + private ManagerRegistry $registry; + + public function __construct( + ManagerRegistry $registry, + ?QueryBuilder $queryBuilder + ) { parent::__construct($registry, Dinosaur::class); + + $this->registry = $registry; + $this->queryBuilder = $queryBuilder ?: $this->createQueryBuilder(self::ALIAS); } - public function search(?string $q): array + + public function search(?string $name = null): self { - if (null === $q) { - return $this->findAll(); + $queryBuilder = $this->cloneQueryBuilder(); + + if ($name !== null) { + $queryBuilder + ->andWhere(sprintf('%s.name LIKE :name', self::ALIAS)) + ->setParameter('name', '%' . $name . '%'); } - return $this->createQueryBuilder('d') - ->where('d.name = :q') - ->setParameter('q', $q) - ->getQuery() - ->getResult(); + return $this->duplicate($queryBuilder); + } + + public function sort(array $sorts): self + { + $queryBuilder = $this->cloneQueryBuilder(); + + foreach ($sorts as $field => $order) { + $queryBuilder->addOrderBy(sprintf('%s.%s', self::ALIAS, $field), $order); + } + + return $this->duplicate($queryBuilder); + } + + public function filter(array $filters): self + { + $queryBuilder = $this->cloneQueryBuilder(); + + foreach ($filters as $field => $value) { + $queryBuilder + ->andWhere(sprintf('%s.%s = :%s', self::ALIAS, $field, $field)) + ->setParameter($field, $value); + } + + return $this->duplicate($queryBuilder); + } + + public function paginate(int $page, int $limit): array + { + $queryBuilder = $this->cloneQueryBuilder(); + + $queryBuilder + ->setFirstResult(($page - 1) * $limit) + ->setMaxResults($limit); + + return $queryBuilder->getQuery()->getResult(); + } + + private function cloneQueryBuilder(): QueryBuilder + { + return clone $this->queryBuilder; + } + + private function duplicate(QueryBuilder $queryBuilder): self + { + return new self($this->registry, $queryBuilder); } }