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 fc939a9
Show file tree
Hide file tree
Showing 18 changed files with 207 additions and 61 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
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
58 changes: 52 additions & 6 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,50 @@ 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.replace(
/ - /,
" "
)}`,
{
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 +135,7 @@ export const BookInput = (props: BookInputProps) => {
}
controller.current = new AbortController();

return await fetchOpenLibrarySearch(
return await fetchGutendexSearch(
searchQuery,
controller.current.signal
);
Expand Down Expand Up @@ -132,7 +178,7 @@ export const BookInput = (props: BookInputProps) => {
{...field}
{...props}
source="book"
label="Open Library Book"
label="Book Reference"
/>
)}
/>
Expand Down
2 changes: 1 addition & 1 deletion pwa/next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
18 changes: 9 additions & 9 deletions pwa/tests/admin/BookCreate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ test.describe("Create a book @admin", () => {
});

test("I can create a book @write", async ({ bookPage, page }) => {
// fill in Open Library Book
await page.getByLabel("Open Library Book").fill("Foundation - Isaac Asimov");
await page.getByRole("listbox").getByText("Foundation - Isaac Asimov", { exact: true }).waitFor({ state: "visible" });
await page.getByRole("listbox").getByText("Foundation - Isaac Asimov", { exact: true }).click();
// fill in Book Reference
await page.getByLabel("Book Reference").fill("Asimov");
await page.getByRole("listbox").getByText("Let's Get Together - Asimov, Isaac", { exact: true }).waitFor({ state: "visible" });
await page.getByRole("listbox").getByText("Let's Get Together - Asimov, Isaac", { exact: true }).click();
await expect(page.getByRole("listbox")).not.toBeAttached();
await expect(page.getByLabel("Open Library Book")).toHaveValue("Foundation - Isaac Asimov");
await expect(page.getByLabel("Book Reference")).toHaveValue("Let's Get Together - Asimov, Isaac");

// fill in condition
await page.getByLabel("Condition").click();
Expand All @@ -23,18 +23,18 @@ test.describe("Create a book @admin", () => {

// submit form
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByLabel("Open Library Book")).not.toBeAttached();
await expect(page.getByLabel("Book Reference")).not.toBeAttached();
await expect(page.getByText("Element created")).toBeVisible();
});

// todo need work in api-platform/core about error handling
// test("I cannot create a book with an already used Open Library value @read", async ({ bookPage, page }) => {
// // fill in Open Library Book
// await page.getByLabel("Open Library Book").fill("Hyperion - Dan Simmons");
// // fill in Book Reference
// await page.getByLabel("Book Reference").fill("Hyperion - Dan Simmons");
// await page.getByRole("listbox").getByText("Hyperion - Dan Simmons", { exact: true }).waitFor({ state: "visible" });
// await page.getByRole("listbox").getByText("Hyperion - Dan Simmons", { exact: true }).click();
// await expect(page.getByRole("listbox")).not.toBeAttached();
// await expect(page.getByLabel("Open Library Book")).toHaveValue("Hyperion - Dan Simmons");
// await expect(page.getByLabel("Book Reference")).toHaveValue("Hyperion - Dan Simmons");
//
// // fill in condition
// await page.getByLabel("Condition").click();
Expand Down
Loading

0 comments on commit fc939a9

Please sign in to comment.