mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-05-01 06:59:52 +00:00
respective book and chapter structure added.
This commit is contained in:
parent
90a8070518
commit
f606711463
5 changed files with 103 additions and 246 deletions
app
dev/api
tests/Api
|
@ -13,11 +13,6 @@ class ApiEntityListFormatter
|
||||||
*/
|
*/
|
||||||
protected array $list = [];
|
protected array $list = [];
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to include related titles in the response.
|
|
||||||
*/
|
|
||||||
protected bool $includeRelatedTitles = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The fields to show in the formatted data.
|
* The fields to show in the formatted data.
|
||||||
* Can be a plain string array item for a direct model field (If existing on model).
|
* Can be a plain string array item for a direct model field (If existing on model).
|
||||||
|
@ -79,20 +74,18 @@ class ApiEntityListFormatter
|
||||||
/**
|
/**
|
||||||
* Enable the inclusion of related book and chapter titles in the response.
|
* Enable the inclusion of related book and chapter titles in the response.
|
||||||
*/
|
*/
|
||||||
public function withRelatedTitles(): self
|
public function withRelatedData(): self
|
||||||
{
|
{
|
||||||
$this->includeRelatedTitles = true;
|
$this->withField('book', function (Entity $entity) {
|
||||||
|
|
||||||
$this->withField('book_title', function (Entity $entity) {
|
|
||||||
if (method_exists($entity, 'book')) {
|
if (method_exists($entity, 'book')) {
|
||||||
return $entity->book?->name;
|
return $entity->book()->select(['id', 'name', 'slug'])->first();
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->withField('chapter_title', function (Entity $entity) {
|
$this->withField('chapter', function (Entity $entity) {
|
||||||
if ($entity instanceof Page && $entity->chapter_id) {
|
if ($entity instanceof Page && $entity->chapter_id) {
|
||||||
return optional($entity->getAttribute('chapter'))->name;
|
return $entity->chapter()->select(['id', 'name', 'slug'])->first();
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
@ -106,9 +99,7 @@ class ApiEntityListFormatter
|
||||||
*/
|
*/
|
||||||
public function format(): array
|
public function format(): array
|
||||||
{
|
{
|
||||||
if ($this->includeRelatedTitles) {
|
$this->loadRelatedData();
|
||||||
$this->loadRelatedTitles();
|
|
||||||
}
|
|
||||||
|
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
|
@ -122,7 +113,7 @@ class ApiEntityListFormatter
|
||||||
/**
|
/**
|
||||||
* Eager load the related book and chapter data when needed.
|
* Eager load the related book and chapter data when needed.
|
||||||
*/
|
*/
|
||||||
protected function loadRelatedTitles(): void
|
protected function loadRelatedData(): void
|
||||||
{
|
{
|
||||||
$pages = collect($this->list)->filter(fn($item) => $item instanceof Page);
|
$pages = collect($this->list)->filter(fn($item) => $item instanceof Page);
|
||||||
|
|
||||||
|
|
|
@ -17,20 +17,9 @@ class SearchApiController extends ApiController
|
||||||
'query' => ['required'],
|
'query' => ['required'],
|
||||||
'page' => ['integer', 'min:1'],
|
'page' => ['integer', 'min:1'],
|
||||||
'count' => ['integer', 'min:1', 'max:100'],
|
'count' => ['integer', 'min:1', 'max:100'],
|
||||||
'include' => ['string', 'regex:/^[a-zA-Z,]*$/'],
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid include parameters and their corresponding formatter methods.
|
|
||||||
* These parameters allow for additional related data, like titles or tags,
|
|
||||||
* to be included in the search results when requested via the API.
|
|
||||||
*/
|
|
||||||
protected const VALID_INCLUDES = [
|
|
||||||
'titles' => 'withRelatedTitles',
|
|
||||||
'tags' => 'withTags',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter)
|
public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter)
|
||||||
{
|
{
|
||||||
$this->searchRunner = $searchRunner;
|
$this->searchRunner = $searchRunner;
|
||||||
|
@ -44,13 +33,6 @@ class SearchApiController extends ApiController
|
||||||
* for a full list of search term options. Results contain a 'type' property to distinguish
|
* for a full list of search term options. Results contain a 'type' property to distinguish
|
||||||
* between: bookshelf, book, chapter & page.
|
* between: bookshelf, book, chapter & page.
|
||||||
*
|
*
|
||||||
* This method now supports the 'include' parameter, which allows API clients to specify related
|
|
||||||
* fields (such as titles or tags) that should be included in the search results.
|
|
||||||
*
|
|
||||||
* The 'include' parameter is a comma-separated string. For example, adding `include=titles,tags`
|
|
||||||
* will include both titles and tags in the API response. If the parameter is not provided, only
|
|
||||||
* basic entity data will be returned.
|
|
||||||
*
|
|
||||||
* The paging parameters and response format emulates a standard listing endpoint
|
* The paging parameters and response format emulates a standard listing endpoint
|
||||||
* but standard sorting and filtering cannot be done on this endpoint. If a count value
|
* but standard sorting and filtering cannot be done on this endpoint. If a count value
|
||||||
* is provided this will only be taken as a suggestion. The results in the response
|
* is provided this will only be taken as a suggestion. The results in the response
|
||||||
|
@ -63,49 +45,22 @@ class SearchApiController extends ApiController
|
||||||
$options = SearchOptions::fromString($request->get('query') ?? '');
|
$options = SearchOptions::fromString($request->get('query') ?? '');
|
||||||
$page = intval($request->get('page', '0')) ?: 1;
|
$page = intval($request->get('page', '0')) ?: 1;
|
||||||
$count = min(intval($request->get('count', '0')) ?: 20, 100);
|
$count = min(intval($request->get('count', '0')) ?: 20, 100);
|
||||||
$includes = $this->parseIncludes($request->get('include', ''));
|
|
||||||
|
|
||||||
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
|
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
|
||||||
$this->resultsFormatter->format($results['results']->all(), $options);
|
$this->resultsFormatter->format($results['results']->all(), $options);
|
||||||
|
|
||||||
$formatter = new ApiEntityListFormatter($results['results']->all());
|
$data = (new ApiEntityListFormatter($results['results']->all()))
|
||||||
$formatter->withType(); // Always include type as it's essential for search results
|
->withType()->withTags()->withRelatedData()
|
||||||
|
->withField('preview_html', function (Entity $entity) {
|
||||||
foreach ($includes as $include) {
|
return [
|
||||||
if (isset(self::VALID_INCLUDES[$include])) {
|
'name' => (string) $entity->getAttribute('preview_name'),
|
||||||
$method = self::VALID_INCLUDES[$include];
|
'content' => (string) $entity->getAttribute('preview_content'),
|
||||||
$formatter->$method();
|
];
|
||||||
}
|
})->format();
|
||||||
}
|
|
||||||
|
|
||||||
$formatter->withField('preview_html', function (Entity $entity) {
|
|
||||||
return [
|
|
||||||
'name' => (string) $entity->getAttribute('preview_name'),
|
|
||||||
'content' => (string) $entity->getAttribute('preview_content'),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $formatter->format(),
|
'data' => $data,
|
||||||
'total' => $results['total'],
|
'total' => $results['total'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse and validate the include parameter.
|
|
||||||
*
|
|
||||||
* @param string $includeString Comma-separated list of includes
|
|
||||||
* @return array<string>
|
|
||||||
*/
|
|
||||||
protected function parseIncludes(string $includeString): array
|
|
||||||
{
|
|
||||||
if (empty($includeString)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_filter(
|
|
||||||
explode(',', strtolower($includeString)),
|
|
||||||
fn($include) => isset (self::VALID_INCLUDES[$include])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
GET /api/search?query=cats+{created_by:me}&page=1&count=2&include=titles,tags
|
GET /api/search?query=cats+{created_by:me}&page=1&count=2
|
||||||
|
|
|
@ -1,72 +1,92 @@
|
||||||
{
|
{
|
||||||
"data": [
|
"data": [
|
||||||
{
|
|
||||||
"id": 84,
|
|
||||||
"book_id": 1,
|
|
||||||
"slug": "a-chapter-for-cats",
|
|
||||||
"name": "A chapter for cats",
|
|
||||||
"created_at": "2021-11-14T15:57:35.000000Z",
|
|
||||||
"updated_at": "2021-11-14T15:57:35.000000Z",
|
|
||||||
"type": "chapter",
|
|
||||||
"url": "https://example.com/books/my-book/chapter/a-chapter-for-cats",
|
|
||||||
"book_title": "Cats",
|
|
||||||
"preview_html": {
|
|
||||||
"name": "A chapter for <strong>cats</strong>",
|
|
||||||
"content": "...once a bunch of <strong>cats</strong> named tony...behaviour of <strong>cats</strong> is unsuitable"
|
|
||||||
},
|
|
||||||
"tags": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "The hows and whys of cats",
|
|
||||||
"id": 396,
|
|
||||||
"slug": "the-hows-and-whys-of-cats",
|
|
||||||
"book_id": 1,
|
|
||||||
"chapter_id": 75,
|
|
||||||
"draft": false,
|
|
||||||
"template": false,
|
|
||||||
"created_at": "2021-05-15T16:28:10.000000Z",
|
|
||||||
"updated_at": "2021-11-14T15:56:49.000000Z",
|
|
||||||
"type": "page",
|
|
||||||
"url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats",
|
|
||||||
"book_title": "Cats",
|
|
||||||
"chapter_title": "A chapter for cats",
|
|
||||||
"preview_html": {
|
|
||||||
"name": "The hows and whys of <strong>cats</strong>",
|
|
||||||
"content": "...people ask why <strong>cats</strong>? but there are...the reason that <strong>cats</strong> are fast are due to..."
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
{
|
{
|
||||||
"name": "Animal",
|
"id": 84,
|
||||||
"value": "Cat",
|
"book_id": 1,
|
||||||
"order": 0
|
"slug": "a-chapter-for-cats",
|
||||||
|
"name": "A chapter for cats",
|
||||||
|
"created_at": "2021-11-14T15:57:35.000000Z",
|
||||||
|
"updated_at": "2021-11-14T15:57:35.000000Z",
|
||||||
|
"type": "chapter",
|
||||||
|
"url": "https://example.com/books/my-book/chapter/a-chapter-for-cats",
|
||||||
|
"book": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Cats",
|
||||||
|
"slug": "cats"
|
||||||
|
},
|
||||||
|
"preview_html": {
|
||||||
|
"name": "A chapter for <strong>cats</strong>",
|
||||||
|
"content": "...once a bunch of <strong>cats</strong> named tony...behaviour of <strong>cats</strong> is unsuitable"
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Category",
|
"name": "The hows and whys of cats",
|
||||||
"value": "Top Content",
|
"id": 396,
|
||||||
"order": 0
|
"slug": "the-hows-and-whys-of-cats",
|
||||||
|
"book_id": 1,
|
||||||
|
"chapter_id": 75,
|
||||||
|
"draft": false,
|
||||||
|
"template": false,
|
||||||
|
"created_at": "2021-05-15T16:28:10.000000Z",
|
||||||
|
"updated_at": "2021-11-14T15:56:49.000000Z",
|
||||||
|
"type": "page",
|
||||||
|
"url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats",
|
||||||
|
"book": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Cats",
|
||||||
|
"slug": "cats"
|
||||||
|
},
|
||||||
|
"chapter": {
|
||||||
|
"id": 84,
|
||||||
|
"name": "A chapter for cats",
|
||||||
|
"slug": "a-chapter-for-cats"
|
||||||
|
},
|
||||||
|
"preview_html": {
|
||||||
|
"name": "The hows and whys of <strong>cats</strong>",
|
||||||
|
"content": "...people ask why <strong>cats</strong>? but there are...the reason that <strong>cats</strong> are fast are due to..."
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "Animal",
|
||||||
|
"value": "Cat",
|
||||||
|
"order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Category",
|
||||||
|
"value": "Top Content",
|
||||||
|
"order": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "How advanced are cats?",
|
||||||
|
"id": 362,
|
||||||
|
"slug": "how-advanced-are-cats",
|
||||||
|
"book_id": 13,
|
||||||
|
"chapter_id": 73,
|
||||||
|
"draft": false,
|
||||||
|
"template": false,
|
||||||
|
"created_at": "2020-11-29T21:55:07.000000Z",
|
||||||
|
"updated_at": "2021-11-14T16:02:39.000000Z",
|
||||||
|
"type": "page",
|
||||||
|
"url": "https://example.com/books/my-book/page/how-advanced-are-cats",
|
||||||
|
"book": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Cats",
|
||||||
|
"slug": "cats"
|
||||||
|
},
|
||||||
|
"chapter": {
|
||||||
|
"id": 84,
|
||||||
|
"name": "A chapter for cats",
|
||||||
|
"slug": "a-chapter-for-cats"
|
||||||
|
},
|
||||||
|
"preview_html": {
|
||||||
|
"name": "How advanced are <strong>cats</strong>?",
|
||||||
|
"content": "<strong>cats</strong> are some of the most advanced animals in the world."
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
},
|
"total": 3
|
||||||
{
|
|
||||||
"name": "How advanced are cats?",
|
|
||||||
"id": 362,
|
|
||||||
"slug": "how-advanced-are-cats",
|
|
||||||
"book_id": 13,
|
|
||||||
"chapter_id": 73,
|
|
||||||
"draft": false,
|
|
||||||
"template": false,
|
|
||||||
"created_at": "2020-11-29T21:55:07.000000Z",
|
|
||||||
"updated_at": "2021-11-14T16:02:39.000000Z",
|
|
||||||
"type": "page",
|
|
||||||
"url": "https://example.com/books/my-book/page/how-advanced-are-cats",
|
|
||||||
"book_title": "Cats",
|
|
||||||
"chapter_title": "A chapter for cats",
|
|
||||||
"preview_html": {
|
|
||||||
"name": "How advanced are <strong>cats</strong>?",
|
|
||||||
"content": "<strong>cats</strong> are some of the most advanced animals in the world."
|
|
||||||
},
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 3
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace Tests\Api;
|
namespace Tests\Api;
|
||||||
|
|
||||||
use BookStack\Activity\Models\Tag;
|
|
||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
use BookStack\Entities\Models\Bookshelf;
|
use BookStack\Entities\Models\Bookshelf;
|
||||||
use BookStack\Entities\Models\Chapter;
|
use BookStack\Entities\Models\Chapter;
|
||||||
|
@ -75,112 +74,4 @@ class SearchApiTest extends TestCase
|
||||||
$resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue');
|
$resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue');
|
||||||
$resp->assertOk();
|
$resp->assertOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_all_endpoint_includes_book_and_chapter_titles_when_requested()
|
|
||||||
{
|
|
||||||
$this->actingAsApiEditor();
|
|
||||||
|
|
||||||
$book = $this->entities->book();
|
|
||||||
$chapter = $this->entities->chapter();
|
|
||||||
$page = $this->entities->newPage();
|
|
||||||
|
|
||||||
$book->name = 'My Test Book';
|
|
||||||
$book->save();
|
|
||||||
|
|
||||||
$chapter->name = 'My Test Chapter';
|
|
||||||
$chapter->book_id = $book->id;
|
|
||||||
$chapter->save();
|
|
||||||
|
|
||||||
$page->name = 'My Test Page With UniqueSearchTerm';
|
|
||||||
$page->book_id = $book->id;
|
|
||||||
$page->chapter_id = $chapter->id;
|
|
||||||
$page->save();
|
|
||||||
|
|
||||||
$page->indexForSearch();
|
|
||||||
|
|
||||||
// Test without include parameter
|
|
||||||
$resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm');
|
|
||||||
$resp->assertOk();
|
|
||||||
$resp->assertDontSee('book_title');
|
|
||||||
$resp->assertDontSee('chapter_title');
|
|
||||||
|
|
||||||
// Test with include parameter
|
|
||||||
$resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=titles');
|
|
||||||
$resp->assertOk();
|
|
||||||
$resp->assertJsonFragment([
|
|
||||||
'name' => 'My Test Page With UniqueSearchTerm',
|
|
||||||
'book_title' => 'My Test Book',
|
|
||||||
'chapter_title' => 'My Test Chapter',
|
|
||||||
'type' => 'page'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_all_endpoint_validates_include_parameter()
|
|
||||||
{
|
|
||||||
$this->actingAsApiEditor();
|
|
||||||
|
|
||||||
// Test invalid include value
|
|
||||||
$resp = $this->getJson($this->baseEndpoint . '?query=test&include=invalid');
|
|
||||||
$resp->assertOk();
|
|
||||||
$resp->assertDontSee('book_title');
|
|
||||||
|
|
||||||
// Test SQL injection attempt
|
|
||||||
$resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles;DROP TABLE users');
|
|
||||||
$resp->assertStatus(422);
|
|
||||||
|
|
||||||
// Test multiple includes
|
|
||||||
$resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles,tags');
|
|
||||||
$resp->assertOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_all_endpoint_includes_tags_when_requested()
|
|
||||||
{
|
|
||||||
$this->actingAsApiEditor();
|
|
||||||
|
|
||||||
// Create a page and give it a unique name for search
|
|
||||||
$page = $this->entities->page();
|
|
||||||
$page->name = 'Page With UniqueSearchTerm';
|
|
||||||
$page->save();
|
|
||||||
|
|
||||||
// Save tags to the page using the existing saveTagsToEntity method
|
|
||||||
$tags = [
|
|
||||||
['name' => 'SampleTag', 'value' => 'SampleValue']
|
|
||||||
];
|
|
||||||
app(\BookStack\Activity\TagRepo::class)->saveTagsToEntity($page, $tags);
|
|
||||||
|
|
||||||
// Ensure the page is indexed for search
|
|
||||||
$page->indexForSearch();
|
|
||||||
|
|
||||||
// Test without the "tags" include
|
|
||||||
$resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm');
|
|
||||||
$resp->assertOk();
|
|
||||||
$resp->assertDontSee('tags');
|
|
||||||
|
|
||||||
// Test with the "tags" include
|
|
||||||
$resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=tags');
|
|
||||||
$resp->assertOk();
|
|
||||||
|
|
||||||
// Assert that tags are included in the response
|
|
||||||
$resp->assertJsonFragment([
|
|
||||||
'name' => 'SampleTag',
|
|
||||||
'value' => 'SampleValue',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Optionally: check the structure to match the tag order as well
|
|
||||||
$resp->assertJsonStructure([
|
|
||||||
'data' => [
|
|
||||||
'*' => [
|
|
||||||
'tags' => [
|
|
||||||
'*' => [
|
|
||||||
'name',
|
|
||||||
'value',
|
|
||||||
'order',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue