Skip to content

Commit 2040ecc

Browse files
feat: introduce BookRepository as abstraction of OpenLibrary (#471)
1 parent 7e4f8f5 commit 2040ecc

File tree

22 files changed

+332
-147
lines changed

22 files changed

+332
-147
lines changed

.github/workflows/ci.yml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,6 @@ jobs:
209209
run: |
210210
docker compose ps
211211
docker compose logs
212-
-
213-
name: Debug Services
214-
if: failure()
215-
run: |
216-
docker compose ps
217-
docker compose logs
218212
-
219213
uses: actions/upload-artifact@v4
220214
if: failure()

api/config/packages/framework.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ framework:
2929
# use scoped client to ease mock on functional tests
3030
security.authorization.client:
3131
base_uri: '%env(OIDC_SERVER_URL_INTERNAL)%/'
32+
open_library.client:
33+
base_uri: 'https://openlibrary.org/'
34+
gutendex.client:
35+
base_uri: 'https://gutendex.com/'
3236

3337
when@test:
3438
framework:
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\BookRepository;
6+
7+
use App\Entity\Book;
8+
9+
interface BookRepositoryInterface
10+
{
11+
public function find(string $url): ?Book;
12+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\BookRepository;
6+
7+
use App\Entity\Book;
8+
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
9+
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
10+
11+
#[AsAlias]
12+
final readonly class ChainBookRepository implements BookRepositoryInterface
13+
{
14+
/** @param iterable<RestrictedBookRepositoryInterface> $repositories */
15+
public function __construct(
16+
#[AutowireIterator(tag: RestrictedBookRepositoryInterface::TAG)]
17+
private iterable $repositories,
18+
) {
19+
}
20+
21+
public function find(string $url): ?Book
22+
{
23+
foreach ($this->repositories as $repository) {
24+
if ($repository->supports($url)) {
25+
return $repository->find($url);
26+
}
27+
}
28+
29+
return null;
30+
}
31+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\BookRepository;
6+
7+
use App\Entity\Book;
8+
use Symfony\Component\Serializer\Encoder\DecoderInterface;
9+
use Symfony\Contracts\HttpClient\HttpClientInterface;
10+
11+
final readonly class GutendexBookRepository implements RestrictedBookRepositoryInterface
12+
{
13+
public function __construct(
14+
private HttpClientInterface $gutendexClient,
15+
private DecoderInterface $decoder,
16+
) {
17+
}
18+
19+
public function supports(string $url): bool
20+
{
21+
return str_starts_with($url, 'https://gutendex.com');
22+
}
23+
24+
public function find(string $url): ?Book
25+
{
26+
$options = ['headers' => ['Accept' => 'application/json']];
27+
$response = $this->gutendexClient->request('GET', $url, $options);
28+
if (200 !== $response->getStatusCode()) {
29+
return null;
30+
}
31+
32+
$book = new Book();
33+
34+
$data = $this->decoder->decode($response->getContent(), 'json');
35+
$book->title = $data['title'];
36+
$book->author = $data['authors'][0]['name'] ?? null;
37+
38+
return $book;
39+
}
40+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\BookRepository;
6+
7+
use App\Entity\Book;
8+
use Symfony\Component\Serializer\Encoder\DecoderInterface;
9+
use Symfony\Contracts\HttpClient\HttpClientInterface;
10+
11+
final readonly class OpenLibraryBookRepository implements RestrictedBookRepositoryInterface
12+
{
13+
public function __construct(
14+
private HttpClientInterface $openLibraryClient,
15+
private DecoderInterface $decoder,
16+
) {
17+
}
18+
19+
public function supports(string $url): bool
20+
{
21+
return str_starts_with($url, 'https://openlibrary.org');
22+
}
23+
24+
public function find(string $url): ?Book
25+
{
26+
$options = ['headers' => ['Accept' => 'application/json']];
27+
$response = $this->openLibraryClient->request('GET', $url, $options);
28+
if (200 !== $response->getStatusCode()) {
29+
return null;
30+
}
31+
32+
$book = new Book();
33+
34+
$data = $this->decoder->decode($response->getContent(), 'json');
35+
$book->title = $data['title'];
36+
37+
$book->author = null;
38+
if (isset($data['authors'][0]['key'])) {
39+
$author = $this->openLibraryClient->request('GET', $data['authors'][0]['key'] . '.json', $options);
40+
if (isset($author['name'])) {
41+
$book->author = $author['name'];
42+
}
43+
}
44+
45+
return $book;
46+
}
47+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\BookRepository;
6+
7+
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
8+
9+
#[AutoconfigureTag(name: RestrictedBookRepositoryInterface::TAG)]
10+
interface RestrictedBookRepositoryInterface extends BookRepositoryInterface
11+
{
12+
public const TAG = 'book.repository';
13+
14+
public function supports(string $url): bool;
15+
}

api/src/Entity/Book.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use App\Repository\BookRepository;
2121
use App\State\Processor\BookPersistProcessor;
2222
use App\State\Processor\BookRemoveProcessor;
23+
use App\Validator\BookUrl;
2324
use Doctrine\Common\Collections\ArrayCollection;
2425
use Doctrine\Common\Collections\Collection;
2526
use Doctrine\DBAL\Types\Types;
@@ -121,7 +122,7 @@ class Book
121122
)]
122123
#[Assert\NotBlank(allowNull: false)]
123124
#[Assert\Url(protocols: ['https'], requireTld: true)]
124-
#[Assert\Regex(pattern: '/^https:\/\/openlibrary.org\/books\/OL\d+[A-Z]{1}\.json$/')]
125+
#[BookUrl]
125126
#[Groups(groups: ['Book:read', 'Book:read:admin', 'Bookmark:read', 'Book:write'])]
126127
#[ORM\Column(unique: true)]
127128
public ?string $book = null;

api/src/State/Processor/BookPersistProcessor.php

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@
77
use ApiPlatform\Doctrine\Common\State\PersistProcessor;
88
use ApiPlatform\Metadata\Operation;
99
use ApiPlatform\State\ProcessorInterface;
10+
use App\BookRepository\BookRepositoryInterface;
1011
use App\Entity\Book;
1112
use Symfony\Component\DependencyInjection\Attribute\Autowire;
12-
use Symfony\Component\HttpFoundation\Request;
13-
use Symfony\Component\Serializer\Encoder\DecoderInterface;
14-
use Symfony\Contracts\HttpClient\HttpClientInterface;
13+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
1514

1615
/**
1716
* @implements ProcessorInterface<Book, Book>
@@ -24,8 +23,7 @@
2423
public function __construct(
2524
#[Autowire(service: PersistProcessor::class)]
2625
private ProcessorInterface $persistProcessor,
27-
private HttpClientInterface $client,
28-
private DecoderInterface $decoder,
26+
private BookRepositoryInterface $bookRepository,
2927
) {
3028
}
3129

@@ -34,27 +32,17 @@ public function __construct(
3432
*/
3533
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Book
3634
{
37-
$book = $this->getData($data->book);
38-
$data->title = $book['title'];
39-
40-
$data->author = null;
41-
if (isset($book['authors'][0]['key'])) {
42-
$author = $this->getData('https://openlibrary.org' . $book['authors'][0]['key'] . '.json');
43-
if (isset($author['name'])) {
44-
$data->author = $author['name'];
45-
}
35+
$book = $this->bookRepository->find($data->book);
36+
37+
// this should never happen
38+
if (!$book instanceof Book) {
39+
throw new NotFoundHttpException();
4640
}
4741

42+
$data->title = $book->title;
43+
$data->author = $book->author;
44+
4845
// save entity
4946
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
5047
}
51-
52-
private function getData(string $uri): array
53-
{
54-
return $this->decoder->decode($this->client->request(Request::METHOD_GET, $uri, [
55-
'headers' => [
56-
'Accept' => 'application/json',
57-
],
58-
])->getContent(), 'json');
59-
}
6048
}

api/src/Validator/BookUrl.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Validator;
6+
7+
use Symfony\Component\Validator\Constraint;
8+
9+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
10+
final class BookUrl extends Constraint
11+
{
12+
public string $message = 'This book URL is not valid.';
13+
14+
public function __construct(?array $options = null, ?string $message = null, ?array $groups = null, mixed $payload = null)
15+
{
16+
parent::__construct($options ?? [], $groups, $payload);
17+
18+
$this->message = $message ?? $this->message;
19+
}
20+
21+
public function getTargets(): string
22+
{
23+
return self::PROPERTY_CONSTRAINT;
24+
}
25+
}

0 commit comments

Comments
 (0)