namespace BookStack\Api;
use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
class ApiEntityListFormatter
{
*/
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).
* @var array<string|int, string|callable>
*/
protected array $fields = [
- 'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft',
- 'template', 'priority', 'created_at', 'updated_at',
+ 'id',
+ 'name',
+ 'slug',
+ 'book_id',
+ 'chapter_id',
+ 'draft',
+ 'template',
+ 'priority',
+ 'created_at',
+ 'updated_at',
];
public function __construct(array $list)
return $this;
}
+ /**
+ * Enable the inclusion of related book and chapter titles in the response.
+ */
+ public function withRelatedTitles(): self
+ {
+ $this->includeRelatedTitles = true;
+
+ $this->withField('book_title', function (Entity $entity) {
+ if (method_exists($entity, 'book')) {
+ return $entity->book?->name;
+ }
+ return null;
+ });
+
+ $this->withField('chapter_title', function (Entity $entity) {
+ if ($entity instanceof Page && $entity->chapter_id) {
+ return optional($entity->getAttribute('chapter'))->name;
+ }
+ return null;
+ });
+
+ return $this;
+ }
+
/**
* Format the data and return an array of formatted content.
* @return array[]
protected $rules = [
'all' => [
- 'query' => ['required'],
- 'page' => ['integer', 'min:1'],
- 'count' => ['integer', 'min:1', 'max:100'],
+ '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);
- $data = (new ApiEntityListFormatter($results['results']->all()))
- ->withType()->withTags()
- ->withField('preview_html', function (Entity $entity) {
- return [
- 'name' => (string) $entity->getAttribute('preview_name'),
- 'content' => (string) $entity->getAttribute('preview_content'),
- ];
- })->format();
+ $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'),
+ ];
+ });
return response()->json([
- 'data' => $data,
+ 'data' => $formatter->format(),
'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
\ No newline at end of file
+GET /api/search?query=cats+{created_by:me}&page=1&count=2&include=titles,tags
"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"
"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..."
"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."
}
],
"total": 3
-}
\ No newline at end of file
+}
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->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue');
$resp->assertJsonFragment([
'type' => 'page',
- 'url' => $page->getUrl(),
+ 'url' => $page->getUrl(),
]);
}
$resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue');
$resp->assertJsonFragment([
- 'type' => 'book',
- 'url' => $book->getUrl(),
+ 'type' => 'book',
+ 'url' => $book->getUrl(),
'preview_html' => [
- 'name' => 'name with <strong>superuniquevalue</strong> within',
+ 'name' => 'name with <strong>superuniquevalue</strong> within',
'content' => 'Description with <strong>superuniquevalue</strong> within',
],
]);
$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',
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+
}