diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c12f4184..c8e48c00a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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() diff --git a/api/config/packages/framework.yaml b/api/config/packages/framework.yaml index b57ce99e2..8b53393bb 100644 --- a/api/config/packages/framework.yaml +++ b/api/config/packages/framework.yaml @@ -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: diff --git a/api/src/BookRepository/ChainBookRepository.php b/api/src/BookRepository/ChainBookRepository.php index 7e8865947..678b04891 100644 --- a/api/src/BookRepository/ChainBookRepository.php +++ b/api/src/BookRepository/ChainBookRepository.php @@ -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; @@ -27,6 +26,6 @@ public function find(string $url): ?Book } } - throw new UnsupportedBookException(); + return null; } } diff --git a/api/src/BookRepository/Exception/UnsupportedBookException.php b/api/src/BookRepository/Exception/UnsupportedBookException.php deleted file mode 100644 index 292376b98..000000000 --- a/api/src/BookRepository/Exception/UnsupportedBookException.php +++ /dev/null @@ -1,13 +0,0 @@ - ['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; + } +} diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php index 6b5651dd6..85a560551 100644 --- a/api/src/Entity/Book.php +++ b/api/src/Entity/Book.php @@ -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; @@ -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; diff --git a/api/src/Validator/BookUrl.php b/api/src/Validator/BookUrl.php new file mode 100644 index 000000000..a6b3f20eb --- /dev/null +++ b/api/src/Validator/BookUrl.php @@ -0,0 +1,25 @@ +message = $message ?? $this->message; + } + + public function getTargets(): string + { + return self::PROPERTY_CONSTRAINT; + } +} diff --git a/api/src/Validator/BookUrlValidator.php b/api/src/Validator/BookUrlValidator.php new file mode 100644 index 000000000..6a8a7bccb --- /dev/null +++ b/api/src/Validator/BookUrlValidator.php @@ -0,0 +1,36 @@ +bookRepository->find($value)) { + $this->context->buildViolation($constraint->message)->addViolation(); + } + } +} diff --git a/api/tests/Api/Admin/BookTest.php b/api/tests/Api/Admin/BookTest.php index bbfa1cdf1..6a1de90bf 100644 --- a/api/tests/Api/Admin/BookTest.php +++ b/api/tests/Api/Admin/BookTest.php @@ -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' => [ @@ -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, @@ -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' => [ @@ -390,10 +390,10 @@ public function asAdminUserICanCreateABook(): void self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); self::assertEquals('; 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']); @@ -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' => [ @@ -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(); @@ -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' => [ @@ -530,10 +530,10 @@ public function asAdminUserICanUpdateABook(): void self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); self::assertEquals('; 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()); diff --git a/pwa/components/admin/book/BookInput.tsx b/pwa/components/admin/book/BookInput.tsx index 1340bd848..57589f9ee 100644 --- a/pwa/components/admin/book/BookInput.tsx +++ b/pwa/components/admin/book/BookInput.tsx @@ -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; @@ -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" && @@ -68,6 +70,50 @@ const fetchOpenLibrarySearch = async ( } }; +const fetchGutendexSearch = async ( + query: string, + signal?: AbortSignal | undefined +): Promise> => { + 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 }, @@ -89,7 +135,7 @@ export const BookInput = (props: BookInputProps) => { } controller.current = new AbortController(); - return await fetchOpenLibrarySearch( + return await fetchGutendexSearch( searchQuery, controller.current.signal ); @@ -132,7 +178,7 @@ export const BookInput = (props: BookInputProps) => { {...field} {...props} source="book" - label="Open Library Book" + label="Book Reference" /> )} /> diff --git a/pwa/next-env.d.ts b/pwa/next-env.d.ts index 4f11a03dc..40c3d6809 100644 --- a/pwa/next-env.d.ts +++ b/pwa/next-env.d.ts @@ -2,4 +2,4 @@ /// // 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. diff --git a/pwa/tests/admin/BookCreate.spec.ts b/pwa/tests/admin/BookCreate.spec.ts index e98960a1a..44465a802 100644 --- a/pwa/tests/admin/BookCreate.spec.ts +++ b/pwa/tests/admin/BookCreate.spec.ts @@ -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(); @@ -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(); diff --git a/pwa/tests/admin/BookEdit.spec.ts b/pwa/tests/admin/BookEdit.spec.ts index 519ced0e6..5ead784bb 100644 --- a/pwa/tests/admin/BookEdit.spec.ts +++ b/pwa/tests/admin/BookEdit.spec.ts @@ -7,12 +7,12 @@ test.describe("Edit a book @admin", () => { }); test("I can edit a book @write", async ({ page }) => { - // fill in Open Library Book - await page.getByLabel("Open Library Book").fill("Eon - Greg Bear"); - await page.getByRole("listbox").getByText("Eon - Greg Bear", { exact: true }).waitFor({ state: "visible" }); - await page.getByRole("listbox").getByText("Eon - Greg Bear", { exact: true }).click(); + // fill in Book Reference + await page.getByLabel("Book Reference").fill("Asimov"); + await page.getByRole("listbox").getByText("The Genetic Effects of Radiation - Asimov, Isaac", { exact: true }).waitFor({ state: "visible" }); + await page.getByRole("listbox").getByText("The Genetic Effects of Radiation - Asimov, Isaac", { exact: true }).click(); await expect(page.getByRole("listbox")).not.toBeAttached(); - await expect(page.getByLabel("Open Library Book")).toHaveValue("Eon - Greg Bear"); + await expect(page.getByLabel("Book Reference")).toHaveValue("The Genetic Effects of Radiation - Asimov, Isaac"); // fill in condition await page.getByLabel("Condition").click(); @@ -23,7 +23,7 @@ test.describe("Edit 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 updated")).toBeVisible(); }); @@ -33,7 +33,7 @@ test.describe("Edit a book @admin", () => { await expect(page.getByRole("button", { name: "Confirm" })).toBeVisible(); await page.getByRole("button", { name: "Confirm" }).click(); await page.getByRole("button", { name: "Confirm" }).waitFor({ state: "detached" }); - await expect(page.getByLabel("Open Library Book")).not.toBeAttached(); + await expect(page.getByLabel("Book Reference")).not.toBeAttached(); await expect(page.getByText("Element deleted")).toBeVisible(); }); }); diff --git a/pwa/types/Gutendex/Search.ts b/pwa/types/Gutendex/Search.ts new file mode 100644 index 000000000..b77ef5f69 --- /dev/null +++ b/pwa/types/Gutendex/Search.ts @@ -0,0 +1,8 @@ +import { SearchDoc } from "./SearchDoc"; + +export class Search { + constructor( + public results: Array, + ) { + } +} diff --git a/pwa/types/Gutendex/SearchDoc.ts b/pwa/types/Gutendex/SearchDoc.ts new file mode 100644 index 000000000..8e85da4f8 --- /dev/null +++ b/pwa/types/Gutendex/SearchDoc.ts @@ -0,0 +1,8 @@ +export class SearchDoc { + constructor( + public id?: number, + public title?: string, + public authors?: Array, + ) { + } +} diff --git a/pwa/types/OpenLibrary/Book.ts b/pwa/types/OpenLibrary/Book.ts index 9cb6c481e..d9fe5ef82 100644 --- a/pwa/types/OpenLibrary/Book.ts +++ b/pwa/types/OpenLibrary/Book.ts @@ -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( diff --git a/pwa/types/OpenLibrary/Search.ts b/pwa/types/OpenLibrary/Search.ts index a5b0ffe92..5ff5bfd16 100644 --- a/pwa/types/OpenLibrary/Search.ts +++ b/pwa/types/OpenLibrary/Search.ts @@ -1,4 +1,4 @@ -import { SearchDoc } from "../../types/OpenLibrary/SearchDoc"; +import { SearchDoc } from "./SearchDoc"; export class Search { constructor( diff --git a/pwa/types/OpenLibrary/Work.ts b/pwa/types/OpenLibrary/Work.ts index 42fcffeb7..eb8e5632d 100644 --- a/pwa/types/OpenLibrary/Work.ts +++ b/pwa/types/OpenLibrary/Work.ts @@ -1,4 +1,4 @@ -import { type Description } from "../../types/OpenLibrary/Description"; +import { type Description } from "./Description"; export class Work { constructor(