Skip to content

Commit

Permalink
code challenge 3 solution
Browse files Browse the repository at this point in the history
  • Loading branch information
jschaedl committed Sep 26, 2023
1 parent 89d9a2b commit 1d5573f
Show file tree
Hide file tree
Showing 22 changed files with 435 additions and 48 deletions.
18 changes: 18 additions & 0 deletions CODING-CHALLENGE-4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# RESTful Webservices in Symfony

## Coding Challenge 4 - HATEOAS

### Tasks

- introduce HATEOAS links for your read and list representations of workshops and attendees
- use the JSON-HAL format

### Solution

- add a `links` property to the `PaginatedCollection` class (annotate the getter with `#[SerializedName('_links')]`)
- add `UrlGeneratorInterface` as dependency of `PaginatedCollectionFactory`
- introduce a `addLink(string $rel, string $href)` method in the `PaginatedCollection` class
- add links to the created `PaginatedCollection` (self, next, prev, first, last)
- adjust the AttendeeNormalizer and WorkshopNormalizer and add
- `$data['_links']['self']['href']` (remember to check for is_array($data))
- `$data['_links']['collection']['href']` (remember to check for is_array($data))
17 changes: 11 additions & 6 deletions src/Controller/Attendee/ListController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

namespace App\Controller\Attendee;

use App\Repository\AttendeeRepository;
use App\Pagination\AttendeeCollectionFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\SerializerInterface;
Expand All @@ -13,18 +14,22 @@
final class ListController
{
public function __construct(
private readonly AttendeeRepository $attendeeRepository,
private AttendeeCollectionFactory $attendeeCollectionFactory,
private readonly SerializerInterface $serializer,
) {
}

public function __invoke(): Response
public function __invoke(Request $request): Response
{
$allAttendees = $this->attendeeRepository->findAll();
// since 6.3 https://symfony.com/doc/current/controller.html#mapping-the-whole-query-string
$attendeeCollection = $this->attendeeCollectionFactory->create(
$request->query->getInt('page', 1),
$request->query->getInt('size', 10)
);

$serializedAttendees = $this->serializer->serialize($allAttendees, 'json');
$serializedAttendeeCollection = $this->serializer->serialize($attendeeCollection, 'json');

return new Response($serializedAttendees, Response::HTTP_OK, [
return new Response($serializedAttendeeCollection, Response::HTTP_OK, [
'Content-Type' => 'application/json',
]);
}
Expand Down
17 changes: 11 additions & 6 deletions src/Controller/Workshop/ListController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

namespace App\Controller\Workshop;

use App\Repository\WorkshopRepository;
use App\Pagination\WorkshopCollectionFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\SerializerInterface;
Expand All @@ -13,18 +14,22 @@
final class ListController
{
public function __construct(
private readonly WorkshopRepository $workshopRepository,
private readonly WorkshopCollectionFactory $workshopCollectionFactory,
private readonly SerializerInterface $serializer
) {
}

public function __invoke(): Response
public function __invoke(Request $request): Response
{
$allWorkshops = $this->workshopRepository->findAll();
// since 6.3 https://symfony.com/doc/current/controller.html#mapping-the-whole-query-string
$workshopCollection = $this->workshopCollectionFactory->create(
$request->query->getInt('page', 1),
$request->query->getInt('size', 10)
);

$serializedWorkshops = $this->serializer->serialize($allWorkshops, 'json');
$serializedWorkshopCollection = $this->serializer->serialize($workshopCollection, 'json');

return new Response($serializedWorkshops, Response::HTTP_OK, [
return new Response($serializedWorkshopCollection, Response::HTTP_OK, [
'Content-Type' => 'application/json',
]);
}
Expand Down
21 changes: 21 additions & 0 deletions src/Pagination/AttendeeCollectionFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Pagination;

use App\Repository\AttendeeRepository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepositoryInterface;

final class AttendeeCollectionFactory extends PaginatedCollectionFactory
{
public function __construct(
private readonly AttendeeRepository $attendeeRepository
) {
}

public function getRepository(): ServiceEntityRepositoryInterface
{
return $this->attendeeRepository;
}
}
19 changes: 19 additions & 0 deletions src/Pagination/PaginatedCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Pagination;

final class PaginatedCollection
{
public readonly array $items;
public readonly int $total;
public readonly int $count;

public function __construct(\Iterator $items, int $total)
{
$this->items = iterator_to_array($items);
$this->total = $total;
$this->count = \count($this->items);
}
}
32 changes: 32 additions & 0 deletions src/Pagination/PaginatedCollectionFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Pagination;

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator;

abstract class PaginatedCollectionFactory
{
abstract public function getRepository(): ServiceEntityRepositoryInterface;

public function create(int $page, int $size): PaginatedCollection
{
$query = $this->getRepository()
->createQueryBuilder('u')
->orderBy('u.id', 'asc')
->getQuery()
;

$paginator = new Paginator($query);
$total = count($paginator);

$paginator
->getQuery()
->setFirstResult($size * ($page - 1))
->setMaxResults($size);

return new PaginatedCollection($paginator->getIterator(), $total);
}
}
12 changes: 12 additions & 0 deletions src/Pagination/PaginationInformation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace App\Pagination;

class PaginationInformation
{
public function __construct(
public readonly ?int $page = 1,
public readonly ?int $size = 10,
) {
}
}
21 changes: 21 additions & 0 deletions src/Pagination/WorkshopCollectionFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Pagination;

use App\Repository\WorkshopRepository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepositoryInterface;

final class WorkshopCollectionFactory extends PaginatedCollectionFactory
{
public function __construct(
private readonly WorkshopRepository $workshopRepository
) {
}

public function getRepository(): ServiceEntityRepositoryInterface
{
return $this->workshopRepository;
}
}
24 changes: 24 additions & 0 deletions tests/Controller/Attendee/ListControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,28 @@ public function test_it_should_list_all_attendees(): void

$this->assertMatchesJsonSnapshot($this->browser->getResponse()->getContent());
}

/**
* @dataProvider paginationQueryParameterValues
*/
public function test_it_should_paginate_attendees(int $page, int $size): void
{
$this->loadFixtures([
__DIR__.'/fixtures/paginate_attendee.yaml',
]);

$this->browser->request('GET', sprintf('/attendees?page=%d&size=%d', $page, $size));

static::assertResponseIsSuccessful();

$this->assertMatchesJsonSnapshot($this->browser->getResponse()->getContent());
}

public static function paginationQueryParameterValues(): \Generator
{
yield 'show 1st page, 3 items each' => [1, 3];
yield 'show 2nd page, 3 items each' => [2, 3];
yield 'show 1st page, 5 items each' => [1, 5];
yield 'show 2nd page, 5 items each' => [2, 5];
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
[
{
"identifier": "803449f4-9a4c-4ecb-8ce4-cebc804fe70a",
"firstname": "Jan",
"lastname": "Sch\u00e4dlich",
"email": "[email protected]",
"workshops": [
{
"identifier": "abba667a-96ae-4f75-9b71-97819b682e8d",
"title": "RESTful Webservices in Symfony",
"workshop_date": "2022-06-14",
"attendees": [
"Jan Sch\u00e4dlich"
]
}
]
}
]
{
"items": [
{
"identifier": "803449f4-9a4c-4ecb-8ce4-cebc804fe70a",
"firstname": "Jan",
"lastname": "Sch\u00e4dlich",
"email": "[email protected]",
"workshops": [
{
"identifier": "abba667a-96ae-4f75-9b71-97819b682e8d",
"title": "RESTful Webservices in Symfony",
"workshop_date": "2022-06-14",
"attendees": [
"Jan Sch\u00e4dlich"
]
}
]
}
],
"total": 1,
"count": 1
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"items": [
{
"identifier": "4878f198-36ab-4fe3-8189-19662a9764fa",
"firstname": "a",
"lastname": "1",
"email": "[email protected]",
"workshops": []
},
{
"identifier": "e942ce16-27c2-494f-9d93-03412da980c5",
"firstname": "b",
"lastname": "2",
"email": "[email protected]",
"workshops": []
},
{
"identifier": "4714fb8a-83d8-49af-abbf-7c68fc6c9656",
"firstname": "c",
"lastname": "3",
"email": "[email protected]",
"workshops": []
}
],
"total": 5,
"count": 3
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"items": [
{
"identifier": "4878f198-36ab-4fe3-8189-19662a9764fa",
"firstname": "a",
"lastname": "1",
"email": "[email protected]",
"workshops": []
},
{
"identifier": "e942ce16-27c2-494f-9d93-03412da980c5",
"firstname": "b",
"lastname": "2",
"email": "[email protected]",
"workshops": []
},
{
"identifier": "4714fb8a-83d8-49af-abbf-7c68fc6c9656",
"firstname": "c",
"lastname": "3",
"email": "[email protected]",
"workshops": []
},
{
"identifier": "65445e8c-a6c6-4955-9eb2-5fb60d6a991e",
"firstname": "d",
"lastname": "4",
"email": "[email protected]",
"workshops": []
},
{
"identifier": "3aacd688-5b81-4aba-a5ea-ac7668ba95b6",
"firstname": "e",
"lastname": "5",
"email": "[email protected]",
"workshops": []
}
],
"total": 5,
"count": 5
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"items": [
{
"identifier": "65445e8c-a6c6-4955-9eb2-5fb60d6a991e",
"firstname": "d",
"lastname": "4",
"email": "[email protected]",
"workshops": []
},
{
"identifier": "3aacd688-5b81-4aba-a5ea-ac7668ba95b6",
"firstname": "e",
"lastname": "5",
"email": "[email protected]",
"workshops": []
}
],
"total": 5,
"count": 2
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"items": [],
"total": 5,
"count": 0
}
11 changes: 11 additions & 0 deletions tests/Controller/Attendee/fixtures/paginate_attendee.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
App\Entity\Attendee:
attendee_1:
__construct: [ '4878f198-36ab-4fe3-8189-19662a9764fa', 'a', '1', '[email protected]' ]
attendee_2:
__construct: [ 'e942ce16-27c2-494f-9d93-03412da980c5', 'b', '2', '[email protected]' ]
attendee_3:
__construct: [ '4714fb8a-83d8-49af-abbf-7c68fc6c9656', 'c', '3', '[email protected]' ]
attendee_4:
__construct: [ '65445e8c-a6c6-4955-9eb2-5fb60d6a991e', 'd', '4', '[email protected]' ]
attendee_5:
__construct: [ '3aacd688-5b81-4aba-a5ea-ac7668ba95b6', 'e', '5', '[email protected]' ]
Loading

0 comments on commit 1d5573f

Please sign in to comment.