feat(bookstack) : add BookStackApiService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
235
src/Service/BookStackApiService.php
Normal file
235
src/Service/BookStackApiService.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\BookStackConfiguration;
|
||||
use App\Exception\BookStackApiException;
|
||||
use App\Repository\BookStackConfigurationRepository;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
final class BookStackApiService
|
||||
{
|
||||
/** @var array<int, int[]> */
|
||||
private array $shelfBookCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly BookStackConfigurationRepository $configRepository,
|
||||
private readonly TokenEncryptor $tokenEncryptor,
|
||||
) {}
|
||||
|
||||
public function testConnection(): bool
|
||||
{
|
||||
try {
|
||||
$this->request('GET', '/api/docs.json');
|
||||
|
||||
return true;
|
||||
} catch (BookStackApiException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{id: int, name: string}>
|
||||
*/
|
||||
public function listShelves(): array
|
||||
{
|
||||
$result = [];
|
||||
$offset = 0;
|
||||
$count = 100;
|
||||
|
||||
do {
|
||||
$data = $this->request('GET', '/api/shelves', [
|
||||
'query' => ['count' => $count, 'offset' => $offset],
|
||||
]);
|
||||
$items = $data['data'] ?? [];
|
||||
$result = array_merge($result, $items);
|
||||
$offset += $count;
|
||||
} while (!empty($items) && $count === count($items));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for pages and books within a specific shelf.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Fetch the shelf's book IDs
|
||||
* 2. Run two search queries (one for pages, one for books)
|
||||
* 3. Filter results: pages must belong to a book on the shelf, books must be on the shelf
|
||||
*
|
||||
* @return array<array{id: int, type: string, name: string, url: string}>
|
||||
*/
|
||||
public function searchInShelf(int $shelfId, string $query): array
|
||||
{
|
||||
$bookIds = $this->getShelfBookIds($shelfId);
|
||||
|
||||
if (empty($bookIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = $this->getConfiguration();
|
||||
$baseUrl = rtrim($config->getUrl() ?? '', '/');
|
||||
$trimmed = trim($query);
|
||||
|
||||
// BookStack search API accepts {type:X} for one type at a time — run two queries
|
||||
$pageResults = $this->request('GET', '/api/search', [
|
||||
'query' => ['query' => $trimmed.' {type:page}', 'count' => 50],
|
||||
]);
|
||||
$bookResults = $this->request('GET', '/api/search', [
|
||||
'query' => ['query' => $trimmed.' {type:book}', 'count' => 50],
|
||||
]);
|
||||
|
||||
$allResults = array_merge($pageResults['data'] ?? [], $bookResults['data'] ?? []);
|
||||
|
||||
// Build a map of bookId → bookSlug for URL construction
|
||||
$shelfData = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
|
||||
$bookSlugs = [];
|
||||
foreach ($shelfData['books'] ?? [] as $book) {
|
||||
$bookSlugs[$book['id']] = $book['slug'] ?? '';
|
||||
}
|
||||
|
||||
$filtered = [];
|
||||
foreach ($allResults as $item) {
|
||||
$type = $item['type'] ?? '';
|
||||
|
||||
if ('page' === $type) {
|
||||
$bookId = $item['book_id'] ?? 0;
|
||||
if (in_array($bookId, $bookIds, true)) {
|
||||
$bookSlug = $bookSlugs[$bookId] ?? '';
|
||||
$filtered[] = [
|
||||
'id' => $item['id'],
|
||||
'type' => 'page',
|
||||
'name' => $item['name'] ?? '',
|
||||
'url' => $baseUrl.'/books/'.$bookSlug.'/page/'.$item['slug'],
|
||||
];
|
||||
}
|
||||
} elseif ('book' === $type) {
|
||||
if (in_array($item['id'], $bookIds, true)) {
|
||||
$filtered[] = [
|
||||
'id' => $item['id'],
|
||||
'type' => 'book',
|
||||
'name' => $item['name'] ?? '',
|
||||
'url' => $baseUrl.'/books/'.$item['slug'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string, slug: string}
|
||||
*/
|
||||
public function getPage(int $id): array
|
||||
{
|
||||
return $this->request('GET', sprintf('/api/pages/%d', $id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string, slug: string}
|
||||
*/
|
||||
public function getBook(int $id): array
|
||||
{
|
||||
return $this->request('GET', sprintf('/api/books/%d', $id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private function getShelfBookIds(int $shelfId): array
|
||||
{
|
||||
if (isset($this->shelfBookCache[$shelfId])) {
|
||||
return $this->shelfBookCache[$shelfId];
|
||||
}
|
||||
|
||||
$data = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
|
||||
$books = $data['books'] ?? [];
|
||||
|
||||
$ids = array_map(static fn (array $book): int => $book['id'], $books);
|
||||
$this->shelfBookCache[$shelfId] = $ids;
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function getConfiguration(): BookStackConfiguration
|
||||
{
|
||||
$config = $this->configRepository->findSingleton();
|
||||
if (null === $config) {
|
||||
throw new BookStackApiException('BookStack is not configured.');
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tokenId: string, tokenSecret: string}
|
||||
*/
|
||||
private function getDecryptedTokens(BookStackConfiguration $config): array
|
||||
{
|
||||
$encryptedId = $config->getEncryptedTokenId();
|
||||
$encryptedSecret = $config->getEncryptedTokenSecret();
|
||||
|
||||
if (null === $encryptedId || null === $encryptedSecret) {
|
||||
throw new BookStackApiException('BookStack tokens are not set.');
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
'tokenId' => $this->tokenEncryptor->decrypt($encryptedId),
|
||||
'tokenSecret' => $this->tokenEncryptor->decrypt($encryptedSecret),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
throw new BookStackApiException('Failed to decrypt BookStack tokens: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function extractError(HttpExceptionInterface $e): string
|
||||
{
|
||||
try {
|
||||
$body = $e->getResponse()->getContent(false);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if (is_array($data)) {
|
||||
return $data['message'] ?? $data['error'] ?? $body;
|
||||
}
|
||||
|
||||
return $body ?: 'Unknown BookStack error';
|
||||
} catch (ExceptionInterface) {
|
||||
return 'BookStack API error (HTTP '.$e->getResponse()->getStatusCode().')';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function request(string $method, string $path, array $options = []): array
|
||||
{
|
||||
$config = $this->getConfiguration();
|
||||
$tokens = $this->getDecryptedTokens($config);
|
||||
|
||||
$options['headers'] = array_merge($options['headers'] ?? [], [
|
||||
'Authorization' => sprintf('Token %s:%s', $tokens['tokenId'], $tokens['tokenSecret']),
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
$options['timeout'] = 10;
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request($method, rtrim($config->getUrl(), '/').$path, $options);
|
||||
|
||||
return $response->toArray();
|
||||
} catch (HttpExceptionInterface $e) {
|
||||
$message = $this->extractError($e);
|
||||
|
||||
throw new BookStackApiException($message, $e->getResponse()->getStatusCode(), $e);
|
||||
} catch (ExceptionInterface $e) {
|
||||
throw new BookStackApiException('BookStack API error: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user