Skip to content

Commit

Permalink
feat: introduce GutendexBookRepository
Browse files Browse the repository at this point in the history
  • Loading branch information
vincentchalamon committed Oct 23, 2024
1 parent 07b6dcf commit 86e9930
Show file tree
Hide file tree
Showing 14 changed files with 186 additions and 37 deletions.
2 changes: 2 additions & 0 deletions api/config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ framework:
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
3 changes: 1 addition & 2 deletions api/src/BookRepository/ChainBookRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace App\BookRepository;

use App\BookRepository\Exception\UnsupportedBookException;
use App\Entity\Book;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
Expand All @@ -27,6 +26,6 @@ public function find(string $url): ?Book
}
}

throw new UnsupportedBookException();
return null;
}
}
13 changes: 0 additions & 13 deletions api/src/BookRepository/Exception/UnsupportedBookException.php

This file was deleted.

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;
}
}
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
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
53 changes: 48 additions & 5 deletions pwa/components/admin/book/BookInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { TextInput, type TextInputProps, useInput } from "react-admin";
import { useQuery } from "@tanstack/react-query";
import { useWatch } from "react-hook-form";

import { Search } from "../../../types/OpenLibrary/Search";
import { SearchDoc } from "../../../types/OpenLibrary/SearchDoc";
import { Search as OpenLibrarySearch } from "../../../types/OpenLibrary/Search";
import { SearchDoc as OpenLibrarySearchDoc } from "../../../types/OpenLibrary/SearchDoc";
import { Search as GutendexSearch } from "../../../types/Gutendex/Search";
import { SearchDoc as GutendexSearchDoc } from "../../../types/Gutendex/SearchDoc";

interface Result {
title: string;
Expand Down Expand Up @@ -35,10 +37,10 @@ const fetchOpenLibrarySearch = async (
next: { revalidate: 3600 },
}
);
const results: Search = await response.json();
const results: OpenLibrarySearch = await response.json();

return results.docs
.filter((result: SearchDoc) => {
.filter((result: OpenLibrarySearchDoc) => {
return (
typeof result.title !== "undefined" &&
typeof result.author_name !== "undefined" &&
Expand Down Expand Up @@ -68,6 +70,47 @@ const fetchOpenLibrarySearch = async (
}
};

const fetchGutendexSearch = async (
query: string,
signal?: AbortSignal | undefined
): Promise<Array<Result>> => {
try {
const response = await fetch(
`https://gutendex.com/books?search=${query}`,
{
signal,
method: "GET",
next: { revalidate: 3600 },
}
);
const results: GutendexSearch = await response.json();

return results.results
.filter((result: GutendexSearchDoc) => {
return (
typeof result.id !== "undefined" &&
typeof result.title !== "undefined" &&
typeof result.authors !== "undefined" &&
result.authors.length > 0
);
})
.map(({ id, title, authors }): Result => {
return {
// @ts-ignore
title,
// @ts-ignore
author: authors[0].name,
// @ts-ignore
value: `https://gutendex.com/books/${id}.json`,
};
});
} catch (error) {
console.error(error);

return Promise.resolve([]);
}
};

export const BookInput = (props: BookInputProps) => {
const {
field: { ref, ...field },
Expand All @@ -89,7 +132,7 @@ export const BookInput = (props: BookInputProps) => {
}
controller.current = new AbortController();

return await fetchOpenLibrarySearch(
return await fetchGutendexSearch(
searchQuery,
controller.current.signal
);
Expand Down
8 changes: 8 additions & 0 deletions pwa/types/Gutendex/Search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { SearchDoc } from "./SearchDoc";

export class Search {
constructor(
public results: Array<SearchDoc>,
) {
}
}
8 changes: 8 additions & 0 deletions pwa/types/Gutendex/SearchDoc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class SearchDoc {
constructor(
public id?: number,
public title?: string,
public authors?: Array<string>,
) {
}
}
4 changes: 2 additions & 2 deletions pwa/types/OpenLibrary/Book.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type Description } from "../../types/OpenLibrary/Description";
import { type Item } from "../../types/OpenLibrary/Item";
import { type Description } from "./Description";
import { type Item } from "./Item";

export class Book {
constructor(
Expand Down
2 changes: 1 addition & 1 deletion pwa/types/OpenLibrary/Search.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SearchDoc } from "../../types/OpenLibrary/SearchDoc";
import { SearchDoc } from "./SearchDoc";

export class Search {
constructor(
Expand Down
2 changes: 1 addition & 1 deletion pwa/types/OpenLibrary/Work.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Description } from "../../types/OpenLibrary/Description";
import { type Description } from "./Description";

export class Work {
constructor(
Expand Down

0 comments on commit 86e9930

Please sign in to comment.