*/
protected array $list = [];
- /**
- * Whether to include related titles in the response.
- */
- protected bool $includeRelatedTitles = false;
-
/**
* The fields to show in the formatted data.
* Can be a plain string array item for a direct model field (If existing on model).
/**
* 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_title', function (Entity $entity) {
+ $this->withField('book', function (Entity $entity) {
if (method_exists($entity, 'book')) {
- return $entity->book?->name;
+ return $entity->book()->select(['id', 'name', 'slug'])->first();
}
return null;
});
- $this->withField('chapter_title', function (Entity $entity) {
+ $this->withField('chapter', function (Entity $entity) {
if ($entity instanceof Page && $entity->chapter_id) {
- return optional($entity->getAttribute('chapter'))->name;
+ return $entity->chapter()->select(['id', 'name', 'slug'])->first();
}
return null;
});
*/
public function format(): array
{
- if ($this->includeRelatedTitles) {
- $this->loadRelatedTitles();
- }
+ $this->loadRelatedData();
$results = [];
/**
* 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);
'query' => ['required'],
'page' => ['integer', 'min:1'],
'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)
{
$this->searchRunner = $searchRunner;
* for a full list of search term options. Results contain a 'type' property to distinguish
* 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
* 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
$options = SearchOptions::fromString($request->get('query') ?? '');
$page = intval($request->get('page', '0')) ?: 1;
$count = min(intval($request->get('count', '0')) ?: 20, 100);
- $includes = $this->parseIncludes($request->get('include', ''));
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
$this->resultsFormatter->format($results['results']->all(), $options);
- $formatter = new ApiEntityListFormatter($results['results']->all());
- $formatter->withType(); // Always include type as it's essential for search results
-
- foreach ($includes as $include) {
- if (isset(self::VALID_INCLUDES[$include])) {
- $method = self::VALID_INCLUDES[$include];
- $formatter->$method();
- }
- }
-
- $formatter->withField('preview_html', function (Entity $entity) {
- return [
- 'name' => (string) $entity->getAttribute('preview_name'),
- 'content' => (string) $entity->getAttribute('preview_content'),
- ];
- });
+ $data = (new ApiEntityListFormatter($results['results']->all()))
+ ->withType()->withTags()->withRelatedData()
+ ->withField('preview_html', function (Entity $entity) {
+ return [
+ 'name' => (string) $entity->getAttribute('preview_name'),
+ 'content' => (string) $entity->getAttribute('preview_content'),
+ ];
+ })->format();
return response()->json([
- 'data' => $formatter->format(),
+ 'data' => $data,
'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])
- );
- }
}
-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
{
- "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": [
+ "data": [
{
- "name": "Animal",
- "value": "Cat",
- "order": 0
+ "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": {
+ "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",
- "value": "Top Content",
- "order": 0
+ "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": {
+ "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": []
}
- ]
- },
- {
- "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
+ ],
+ "total": 3
}
namespace Tests\Api;
-use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
$resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue');
$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',
- ],
- ],
- ],
- ],
- ]);
- }
-
-
}