Added include func for search api

This commit is contained in:
Rashad 2024-10-21 02:42:49 +05:30
parent 6f1c54d018
commit 3e656efb00
5 changed files with 217 additions and 20 deletions

View File

@ -3,6 +3,7 @@
namespace BookStack\Api;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
class ApiEntityListFormatter
{
@ -12,6 +13,11 @@ 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).
@ -20,8 +26,16 @@ class ApiEntityListFormatter
* @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)
@ -62,6 +76,30 @@ class ApiEntityListFormatter
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[]

View File

@ -14,12 +14,23 @@ class SearchApiController extends ApiController
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;
@ -33,6 +44,13 @@ class SearchApiController extends ApiController
* 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
@ -45,22 +63,49 @@ class SearchApiController extends ApiController
$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])
);
}
}

View File

@ -1 +1 @@
GET /api/search?query=cats+{created_by:me}&page=1&count=2
GET /api/search?query=cats+{created_by:me}&page=1&count=2&include=titles,tags

View File

@ -9,6 +9,7 @@
"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"
@ -27,6 +28,8 @@
"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..."
@ -56,6 +59,8 @@
"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."
@ -64,4 +69,4 @@
}
],
"total": 3
}
}

View File

@ -2,6 +2,7 @@
namespace Tests\Api;
use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
@ -45,7 +46,7 @@ class SearchApiTest extends TestCase
$resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue');
$resp->assertJsonFragment([
'type' => 'page',
'url' => $page->getUrl(),
'url' => $page->getUrl(),
]);
}
@ -57,10 +58,10 @@ class SearchApiTest extends TestCase
$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',
],
]);
@ -74,4 +75,112 @@ class SearchApiTest extends TestCase
$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',
],
],
],
],
]);
}
}