From 3e656efb0088b180665e224a68adde061e86786b Mon Sep 17 00:00:00 2001 From: Rashad Date: Mon, 21 Oct 2024 02:42:49 +0530 Subject: [PATCH] Added include func for search api --- app/Api/ApiEntityListFormatter.php | 42 ++++++++++- app/Search/SearchApiController.php | 69 ++++++++++++++--- dev/api/requests/search-all.http | 2 +- dev/api/responses/search-all.json | 7 +- tests/Api/SearchApiTest.php | 117 ++++++++++++++++++++++++++++- 5 files changed, 217 insertions(+), 20 deletions(-) diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php index 436d66d59..23fa8e6ea 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -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 */ 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[] diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index d1619e118..5072bd3b4 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -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 + */ + protected function parseIncludes(string $includeString): array + { + if (empty($includeString)) { + return []; + } + + return array_filter( + explode(',', strtolower($includeString)), + fn($include) => isset (self::VALID_INCLUDES[$include]) + ); + } } diff --git a/dev/api/requests/search-all.http b/dev/api/requests/search-all.http index ee5223816..7fa1a304e 100644 --- a/dev/api/requests/search-all.http +++ b/dev/api/requests/search-all.http @@ -1 +1 @@ -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 diff --git a/dev/api/responses/search-all.json b/dev/api/responses/search-all.json index 2c7584e3f..bb45b7959 100644 --- a/dev/api/responses/search-all.json +++ b/dev/api/responses/search-all.json @@ -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 cats", "content": "...once a bunch of cats named tony...behaviour of cats 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 cats", "content": "...people ask why cats? but there are...the reason that cats 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 cats?", "content": "cats are some of the most advanced animals in the world." @@ -64,4 +69,4 @@ } ], "total": 3 -} \ No newline at end of file +} diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 2a186e8d6..b80ed4530 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -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 superuniquevalue within', + 'name' => 'name with superuniquevalue within', 'content' => 'Description with superuniquevalue 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', + ], + ], + ], + ], + ]); + } + + }