Skip to content

Commit

Permalink
feat: introduce BookRepository as abstraction of OpenLibrary (#471)
Browse files Browse the repository at this point in the history
  • Loading branch information
vincentchalamon authored Oct 23, 2024
1 parent 7e4f8f5 commit 2040ecc
Show file tree
Hide file tree
Showing 22 changed files with 332 additions and 147 deletions.
6 changes: 0 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,6 @@ jobs:
run: |
docker compose ps
docker compose logs
-
name: Debug Services
if: failure()
run: |
docker compose ps
docker compose logs
-
uses: actions/upload-artifact@v4
if: failure()
Expand Down
4 changes: 4 additions & 0 deletions api/config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ framework:
# use scoped client to ease mock on functional tests
security.authorization.client:
base_uri: '%env(OIDC_SERVER_URL_INTERNAL)%/'
open_library.client:
base_uri: 'https://openlibrary.org/'
gutendex.client:
base_uri: 'https://gutendex.com/'

when@test:
framework:
Expand Down
12 changes: 12 additions & 0 deletions api/src/BookRepository/BookRepositoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace App\BookRepository;

use App\Entity\Book;

interface BookRepositoryInterface
{
public function find(string $url): ?Book;
}
31 changes: 31 additions & 0 deletions api/src/BookRepository/ChainBookRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\BookRepository;

use App\Entity\Book;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

#[AsAlias]
final readonly class ChainBookRepository implements BookRepositoryInterface
{
/** @param iterable<RestrictedBookRepositoryInterface> $repositories */
public function __construct(
#[AutowireIterator(tag: RestrictedBookRepositoryInterface::TAG)]
private iterable $repositories,
) {
}

public function find(string $url): ?Book
{
foreach ($this->repositories as $repository) {
if ($repository->supports($url)) {
return $repository->find($url);
}
}

return null;
}
}
40 changes: 40 additions & 0 deletions api/src/BookRepository/GutendexBookRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace App\BookRepository;

use App\Entity\Book;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class GutendexBookRepository implements RestrictedBookRepositoryInterface
{
public function __construct(
private HttpClientInterface $gutendexClient,
private DecoderInterface $decoder,
) {
}

public function supports(string $url): bool
{
return str_starts_with($url, 'https://gutendex.com');
}

public function find(string $url): ?Book
{
$options = ['headers' => ['Accept' => 'application/json']];
$response = $this->gutendexClient->request('GET', $url, $options);
if (200 !== $response->getStatusCode()) {
return null;
}

$book = new Book();

$data = $this->decoder->decode($response->getContent(), 'json');
$book->title = $data['title'];
$book->author = $data['authors'][0]['name'] ?? null;

return $book;
}
}
47 changes: 47 additions & 0 deletions api/src/BookRepository/OpenLibraryBookRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace App\BookRepository;

use App\Entity\Book;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class OpenLibraryBookRepository implements RestrictedBookRepositoryInterface
{
public function __construct(
private HttpClientInterface $openLibraryClient,
private DecoderInterface $decoder,
) {
}

public function supports(string $url): bool
{
return str_starts_with($url, 'https://openlibrary.org');
}

public function find(string $url): ?Book
{
$options = ['headers' => ['Accept' => 'application/json']];
$response = $this->openLibraryClient->request('GET', $url, $options);
if (200 !== $response->getStatusCode()) {
return null;
}

$book = new Book();

$data = $this->decoder->decode($response->getContent(), 'json');
$book->title = $data['title'];

$book->author = null;
if (isset($data['authors'][0]['key'])) {
$author = $this->openLibraryClient->request('GET', $data['authors'][0]['key'] . '.json', $options);
if (isset($author['name'])) {
$book->author = $author['name'];
}
}

return $book;
}
}
15 changes: 15 additions & 0 deletions api/src/BookRepository/RestrictedBookRepositoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\BookRepository;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag(name: RestrictedBookRepositoryInterface::TAG)]
interface RestrictedBookRepositoryInterface extends BookRepositoryInterface
{
public const TAG = 'book.repository';

public function supports(string $url): bool;
}
3 changes: 2 additions & 1 deletion api/src/Entity/Book.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use App\Repository\BookRepository;
use App\State\Processor\BookPersistProcessor;
use App\State\Processor\BookRemoveProcessor;
use App\Validator\BookUrl;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
Expand Down Expand Up @@ -121,7 +122,7 @@ class Book
)]
#[Assert\NotBlank(allowNull: false)]
#[Assert\Url(protocols: ['https'], requireTld: true)]
#[Assert\Regex(pattern: '/^https:\/\/openlibrary.org\/books\/OL\d+[A-Z]{1}\.json$/')]
#[BookUrl]
#[Groups(groups: ['Book:read', 'Book:read:admin', 'Bookmark:read', 'Book:write'])]
#[ORM\Column(unique: true)]
public ?string $book = null;
Expand Down
34 changes: 11 additions & 23 deletions api/src/State/Processor/BookPersistProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@
use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\BookRepository\BookRepositoryInterface;
use App\Entity\Book;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
* @implements ProcessorInterface<Book, Book>
Expand All @@ -24,8 +23,7 @@
public function __construct(
#[Autowire(service: PersistProcessor::class)]
private ProcessorInterface $persistProcessor,
private HttpClientInterface $client,
private DecoderInterface $decoder,
private BookRepositoryInterface $bookRepository,
) {
}

Expand All @@ -34,27 +32,17 @@ public function __construct(
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Book
{
$book = $this->getData($data->book);
$data->title = $book['title'];

$data->author = null;
if (isset($book['authors'][0]['key'])) {
$author = $this->getData('https://openlibrary.org' . $book['authors'][0]['key'] . '.json');
if (isset($author['name'])) {
$data->author = $author['name'];
}
$book = $this->bookRepository->find($data->book);

// this should never happen
if (!$book instanceof Book) {
throw new NotFoundHttpException();
}

$data->title = $book->title;
$data->author = $book->author;

// save entity
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}

private function getData(string $uri): array
{
return $this->decoder->decode($this->client->request(Request::METHOD_GET, $uri, [
'headers' => [
'Accept' => 'application/json',
],
])->getContent(), 'json');
}
}
25 changes: 25 additions & 0 deletions api/src/Validator/BookUrl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class BookUrl extends Constraint
{
public string $message = 'This book URL is not valid.';

public function __construct(?array $options = null, ?string $message = null, ?array $groups = null, mixed $payload = null)
{
parent::__construct($options ?? [], $groups, $payload);

$this->message = $message ?? $this->message;
}

public function getTargets(): string
{
return self::PROPERTY_CONSTRAINT;
}
}
36 changes: 36 additions & 0 deletions api/src/Validator/BookUrlValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\Validator;

use App\BookRepository\BookRepositoryInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

final class BookUrlValidator extends ConstraintValidator
{
public function __construct(
private readonly BookRepositoryInterface $bookRepository,
) {
}

/**
* @param string|null $value
*/
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof BookUrl) {
throw new UnexpectedTypeException($constraint, BookUrl::class);
}

if (null === $value || '' === $value) {
return;
}

if (!$this->bookRepository->find($value)) {
$this->context->buildViolation($constraint->message)->addViolation();
}
}
}
24 changes: 12 additions & 12 deletions api/tests/Api/Admin/BookTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ public function asNonAdminUserICannotCreateABook(int $expectedCode, string $hydr

$this->client->request('POST', '/admin/books', $options + [
'json' => [
'book' => 'https://openlibrary.org/books/OL28346544M.json',
'book' => 'https://gutendex.com/books/31547.json',
'condition' => BookCondition::NewCondition->value,
],
'headers' => [
Expand Down Expand Up @@ -328,7 +328,7 @@ public static function getInvalidData(): iterable
];
yield 'invalid condition' => [
[
'book' => 'https://openlibrary.org/books/OL28346544M.json',
'book' => 'https://gutendex.com/books/31547.json',
'condition' => 'invalid condition',
],
Response::HTTP_UNPROCESSABLE_ENTITY,
Expand Down Expand Up @@ -377,7 +377,7 @@ public function asAdminUserICanCreateABook(): void
$response = $this->client->request('POST', '/admin/books', [
'auth_bearer' => $token,
'json' => [
'book' => 'https://openlibrary.org/books/OL28346544M.json',
'book' => 'https://gutendex.com/books/31547.json',
'condition' => BookCondition::NewCondition->value,
],
'headers' => [
Expand All @@ -390,10 +390,10 @@ public function asAdminUserICanCreateABook(): void
self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
self::assertEquals('<https://localhost/.well-known/mercure>; rel="mercure"', $response->getHeaders(false)['link'][1]);
self::assertJsonContains([
'book' => 'https://openlibrary.org/books/OL28346544M.json',
'book' => 'https://gutendex.com/books/31547.json',
'condition' => BookCondition::NewCondition->value,
'title' => 'Foundation',
'author' => 'Isaac Asimov',
'title' => 'Youth',
'author' => 'Asimov, Isaac',
]);
self::assertMatchesJsonSchema(file_get_contents(__DIR__ . '/schemas/Book/item.json'));
$id = preg_replace('/^.*\/(.+)$/', '$1', $response->toArray()['@id']);
Expand Down Expand Up @@ -429,7 +429,7 @@ public function asNonAdminUserICannotUpdateBook(int $expectedCode, string $hydra

$this->client->request('PUT', '/admin/books/' . $book->getId(), $options + [
'json' => [
'book' => 'https://openlibrary.org/books/OL28346544M.json',
'book' => 'https://gutendex.com/books/31547.json',
'condition' => BookCondition::NewCondition->value,
],
'headers' => [
Expand Down Expand Up @@ -504,7 +504,7 @@ public function asAdminUserICannotUpdateABookWithInvalidData(array $data, int $s
public function asAdminUserICanUpdateABook(): void
{
$book = BookFactory::createOne([
'book' => 'https://openlibrary.org/books/OL28346544M.json',
'book' => 'https://gutendex.com/books/31547.json',
]);
self::getMercureHub()->reset();

Expand All @@ -517,7 +517,7 @@ public function asAdminUserICanUpdateABook(): void
'json' => [
'@id' => '/books/' . $book->getId(),
// Must set all data because of standard PUT
'book' => 'https://openlibrary.org/books/OL28346544M.json',
'book' => 'https://gutendex.com/books/31547.json',
'condition' => BookCondition::DamagedCondition->value,
],
'headers' => [
Expand All @@ -530,10 +530,10 @@ public function asAdminUserICanUpdateABook(): void
self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
self::assertEquals('<https://localhost/.well-known/mercure>; rel="mercure"', $response->getHeaders(false)['link'][1]);
self::assertJsonContains([
'book' => 'https://openlibrary.org/books/OL28346544M.json',
'book' => 'https://gutendex.com/books/31547.json',
'condition' => BookCondition::DamagedCondition->value,
'title' => 'Foundation',
'author' => 'Isaac Asimov',
'title' => 'Youth',
'author' => 'Asimov, Isaac',
]);
self::assertMatchesJsonSchema(file_get_contents(__DIR__ . '/schemas/Book/item.json'));
self::assertCount(1, self::getMercureMessages());
Expand Down
Loading

0 comments on commit 2040ecc

Please sign in to comment.