From f3437a76a454101cde64c0ff5e18de31122b4491 Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sun, 29 Mar 2026 15:28:24 +0300 Subject: [PATCH] gutendex integration phase 2 --- app/DTOs/GutendexBook.php | 260 +++++++++++++++++++++++ app/Providers/AppServiceProvider.php | 5 +- app/Services/GutendexClient.php | 307 +++++++++++++++++++++++++++ 3 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 app/DTOs/GutendexBook.php create mode 100644 app/Services/GutendexClient.php diff --git a/app/DTOs/GutendexBook.php b/app/DTOs/GutendexBook.php new file mode 100644 index 0000000..daa216c --- /dev/null +++ b/app/DTOs/GutendexBook.php @@ -0,0 +1,260 @@ + $author['name'] ?? 'Unknown', + 'birth_year' => $author['birth_year'] ?? null, + 'death_year' => $author['death_year'] ?? null, + ]; + }, $authors); + } + + /** + * Get the primary author name. + * + * @return string + */ + public function getPrimaryAuthorName(): string + { + if (empty($this->authors)) { + return 'Unknown Author'; + } + + return $this->authors[0]['name'] ?? 'Unknown Author'; + } + + /** + * Get all author names as a string. + * + * @param string $separator Separator between author names + * @return string + */ + public function getAuthorNamesString(string $separator = ', '): string + { + $names = array_map(fn($author) => $author['name'], $this->authors); + return implode($separator, $names) ?: 'Unknown Author'; + } + + /** + * Get the plain text URL if available. + * + * @return string|null + */ + public function getPlainTextUrl(): ?string + { + // Priority order for text formats + $preferredFormats = [ + 'text/plain; charset=utf-8', + 'text/plain; charset=us-ascii', + 'text/plain', + ]; + + foreach ($preferredFormats as $format) { + if (isset($this->formats[$format])) { + return $this->formats[$format]; + } + } + + // Fallback: look for any text/plain format + foreach ($this->formats as $format => $url) { + if (str_contains($format, 'text/plain')) { + return $url; + } + } + + return null; + } + + /** + * Get the HTML URL if available. + * + * @return string|null + */ + public function getHtmlUrl(): ?string + { + return $this->formats['text/html'] ?? null; + } + + /** + * Get the cover image URL. + * + * @return string|null + */ + public function getCoverImageUrl(): ?string + { + return $this->formats['image/jpeg'] ?? null; + } + + /** + * Get the primary language. + * + * @return string + */ + public function getPrimaryLanguage(): string + { + return $this->languages[0] ?? 'en'; + } + + /** + * Determine the most appropriate category from subjects and bookshelves. + * + * @return string|null + */ + public function suggestCategory(): ?string + { + $categoryMapping = config('gutendex.category_mapping', []); + + // Check bookshelves first (usually more specific) + foreach ($this->bookshelves as $bookshelf) { + foreach ($categoryMapping as $key => $slug) { + if (stripos($bookshelf, $key) !== false) { + return $slug; + } + } + } + + // Then check subjects + foreach ($this->subjects as $subject) { + foreach ($categoryMapping as $key => $slug) { + if (stripos($subject, $key) !== false) { + return $slug; + } + } + } + + // Default category based on common patterns + $allText = implode(' ', array_merge($this->subjects, $this->bookshelves)); + $allTextLower = strtolower($allText); + + if (str_contains($allTextLower, 'fiction')) { + return 'fiction'; + } + + if (str_contains($allTextLower, 'history')) { + return 'history'; + } + + if (str_contains($allTextLower, 'science')) { + return 'science'; + } + + if (str_contains($allTextLower, 'philosophy')) { + return 'philosophy'; + } + + return null; + } + + /** + * Get a description from subjects and bookshelves. + * + * @param int $maxLength Maximum length of description + * @return string + */ + public function generateDescription(int $maxLength = 200): string + { + $parts = []; + + if (!empty($this->subjects)) { + $parts[] = 'Subjects: ' . implode(', ', array_slice($this->subjects, 0, 3)); + } + + if (!empty($this->bookshelves)) { + $parts[] = 'Categories: ' . implode(', ', array_slice($this->bookshelves, 0, 2)); + } + + $description = implode('. ', $parts); + + if (strlen($description) > $maxLength) { + $description = substr($description, 0, $maxLength - 3) . '...'; + } + + return $description ?: 'A book from Project Gutenberg.'; + } + + /** + * Check if this book is in the public domain. + * + * @return bool + */ + public function isPublicDomain(): bool + { + return $this->copyright === null || $this->copyright === false; + } + + /** + * Convert the DTO to an array suitable for database insertion. + * + * @return array + */ + public function toBookArray(): array + { + return [ + 'gutenberg_id' => $this->id, + 'title' => $this->title, + 'author' => $this->getPrimaryAuthorName(), + 'cover_image' => $this->getCoverImageUrl(), + 'description' => $this->generateDescription(), + 'language' => $this->getPrimaryLanguage(), + 'full_text_url' => $this->getPlainTextUrl(), + 'source' => 'gutendex', + ]; + } + + /** + * Get all author names for the many-to-many relationship. + * + * @return array Array of author names + */ + public function getAuthorNames(): array + { + return array_map(fn($author) => $author['name'], $this->authors); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..56ad3f9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,7 +11,10 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + // Register GutendexClient as a singleton + $this->app->singleton(\App\Services\GutendexClient::class, function ($app) { + return new \App\Services\GutendexClient(); + }); } /** diff --git a/app/Services/GutendexClient.php b/app/Services/GutendexClient.php new file mode 100644 index 0000000..e21bb10 --- /dev/null +++ b/app/Services/GutendexClient.php @@ -0,0 +1,307 @@ +baseUrl = config('gutendex.api_url', 'https://gutendex.com'); + $this->timeout = config('gutendex.timeout', 30); + $this->retryMaxAttempts = config('gutendex.retry.max_attempts', 3); + $this->retryBackoff = config('gutendex.retry.backoff_multiplier', 2); + $this->cacheEnabled = config('gutendex.cache.enabled', true); + $this->cacheTtl = config('gutendex.cache.ttl', 86400); + $this->rateLimit = config('gutendex.rate_limit_delay', 1000); + } + + /** + * Search for books with optional query and filters. + * + * @param string|null $query Search query + * @param int $page Page number + * @param array $filters Additional filters (language, topic, author, etc.) + * @return array API response with results + */ + public function searchBooks(?string $query = null, int $page = 1, array $filters = []): array + { + $params = array_merge(['page' => $page], $filters); + + if ($query) { + $params['search'] = $query; + } + + $cacheKey = 'gutendex:search:' . md5(json_encode($params)); + + if ($this->cacheEnabled && Cache::has($cacheKey)) { + Log::info('Gutendex: Cache hit for search', ['params' => $params]); + return Cache::get($cacheKey); + } + + $response = $this->makeRequest('GET', '/books', $params); + + if ($this->cacheEnabled && isset($response['results'])) { + Cache::put($cacheKey, $response, $this->cacheTtl); + } + + return $response; + } + + /** + * Get a specific book by its Gutenberg ID. + * + * @param int $gutenbergId The Gutenberg book ID + * @return GutendexBook|null + */ + public function getBook(int $gutenbergId): ?GutendexBook + { + $cacheKey = 'gutendex:book:' . $gutenbergId; + + if ($this->cacheEnabled && Cache::has($cacheKey)) { + Log::info('Gutendex: Cache hit for book', ['id' => $gutenbergId]); + return Cache::get($cacheKey); + } + + try { + $response = $this->makeRequest('GET', "/books/{$gutenbergId}"); + + if (empty($response)) { + return null; + } + + $book = GutendexBook::fromApiResponse($response); + + if ($this->cacheEnabled) { + Cache::put($cacheKey, $book, $this->cacheTtl); + } + + return $book; + } catch (\Exception $e) { + Log::error('Gutendex: Failed to fetch book', [ + 'id' => $gutenbergId, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Get books by language. + * + * @param string $language Language code (e.g., 'en', 'fr') + * @param int $page Page number + * @return array + */ + public function getBooksByLanguage(string $language = 'en', int $page = 1): array + { + return $this->searchBooks(null, $page, ['languages' => $language]); + } + + /** + * Get books filtered by copyright status. + * + * @param string $copyright 'true', 'false', or 'null' + * @param int $page Page number + * @return array + */ + public function getBooksByCopyright(string $copyright = 'false', int $page = 1): array + { + return $this->searchBooks(null, $page, ['copyright' => $copyright]); + } + + /** + * Fetch full text content from a given URL. + * + * @param string $textUrl URL to the full text + * @return string|null The text content or null on failure + */ + public function fetchFullText(string $textUrl): ?string + { + try { + Log::info('Gutendex: Fetching full text', ['url' => $textUrl]); + + $response = Http::timeout($this->timeout * 2) + ->retry($this->retryMaxAttempts, $this->retryBackoff * 1000) + ->get($textUrl); + + if ($response->successful()) { + $text = $response->body(); + Log::info('Gutendex: Successfully fetched full text', [ + 'url' => $textUrl, + 'size' => strlen($text), + ]); + return $text; + } + + Log::warning('Gutendex: Failed to fetch full text', [ + 'url' => $textUrl, + 'status' => $response->status(), + ]); + + return null; + } catch (ConnectionException $e) { + Log::error('Gutendex: Connection error fetching full text', [ + 'url' => $textUrl, + 'error' => $e->getMessage(), + ]); + return null; + } catch (\Exception $e) { + Log::error('Gutendex: Error fetching full text', [ + 'url' => $textUrl, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Get books with pagination info. + * + * @param array $filters Filters to apply + * @param int $page Page number + * @return array Contains 'books' (GutendexBook[]), 'count', 'next', 'previous' + */ + public function getBooksWithPagination(array $filters = [], int $page = 1): array + { + $response = $this->searchBooks(null, $page, $filters); + + $books = array_map( + fn($bookData) => GutendexBook::fromApiResponse($bookData), + $response['results'] ?? [] + ); + + return [ + 'books' => $books, + 'count' => $response['count'] ?? 0, + 'next' => $response['next'] ?? null, + 'previous' => $response['previous'] ?? null, + ]; + } + + /** + * Check if the Gutendex API is accessible. + * + * @return bool + */ + public function checkConnection(): bool + { + try { + $response = Http::timeout(10)->get($this->baseUrl . '/books?page=1'); + return $response->successful(); + } catch (\Exception $e) { + Log::error('Gutendex: Connection check failed', ['error' => $e->getMessage()]); + return false; + } + } + + /** + * Make an HTTP request to the Gutendex API with retry logic. + * + * @param string $method HTTP method + * @param string $endpoint API endpoint + * @param array $params Query parameters + * @return array Response data + * @throws RequestException + */ + protected function makeRequest(string $method, string $endpoint, array $params = []): array + { + $url = $this->baseUrl . $endpoint; + $startTime = microtime(true); + + try { + $response = Http::timeout($this->timeout) + ->retry( + $this->retryMaxAttempts, + function ($exception, $request) { + // Exponential backoff + return $this->retryBackoff * 1000; + }, + function ($exception, $request) { + // Retry on connection errors and 429 rate limit + if ($exception instanceof ConnectionException) { + return true; + } + + if (method_exists($exception, 'response')) { + $response = $exception->response; + return $response && $response->status() === 429; + } + + return false; + } + ) + ->acceptJson() + ->$method($url, $params); + + $duration = microtime(true) - $startTime; + + if ($response->successful()) { + Log::info('Gutendex: API request successful', [ + 'method' => $method, + 'endpoint' => $endpoint, + 'duration' => round($duration, 2) . 's', + ]); + + // Add rate limit delay + if ($this->rateLimit > 0) { + usleep($this->rateLimit * 1000); + } + + return $response->json(); + } + + Log::warning('Gutendex: API request failed', [ + 'method' => $method, + 'endpoint' => $endpoint, + 'status' => $response->status(), + 'duration' => round($duration, 2) . 's', + ]); + + return []; + } catch (ConnectionException $e) { + Log::error('Gutendex: Connection error', [ + 'method' => $method, + 'endpoint' => $endpoint, + 'error' => $e->getMessage(), + ]); + throw $e; + } catch (RequestException $e) { + $duration = microtime(true) - $startTime; + + Log::error('Gutendex: Request exception', [ + 'method' => $method, + 'endpoint' => $endpoint, + 'error' => $e->getMessage(), + 'duration' => round($duration, 2) . 's', + ]); + + throw $e; + } + } + + /** + * Clear all cached Gutendex data. + * + * @return void + */ + public function clearCache(): void + { + Cache::flush(); + Log::info('Gutendex: Cache cleared'); + } +}