]> BookStack Code Mirror - bookstack/commitdiff
Added include func for search api
authorRashad <redacted>
Sun, 20 Oct 2024 21:12:49 +0000 (02:42 +0530)
committerRashad <redacted>
Sun, 20 Oct 2024 21:12:49 +0000 (02:42 +0530)
app/Api/ApiEntityListFormatter.php
app/Search/SearchApiController.php
dev/api/requests/search-all.http
dev/api/responses/search-all.json
tests/Api/SearchApiTest.php

index 436d66d598e8f2fb86fc27622f9016900b2a7c8e..23fa8e6ea7249274759e5b4fa98cf49b27a50a8a 100644 (file)
@@ -3,6 +3,7 @@
 namespace BookStack\Api;
 
 use BookStack\Entities\Models\Entity;
 namespace BookStack\Api;
 
 use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
 
 class ApiEntityListFormatter
 {
 
 class ApiEntityListFormatter
 {
@@ -12,6 +13,11 @@ 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.
      * Can be a plain string array item for a direct model field (If existing on model).
     /**
      * 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 = [
      * @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)
     ];
 
     public function __construct(array $list)
@@ -62,6 +76,30 @@ class ApiEntityListFormatter
         return $this;
     }
 
         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[]
     /**
      * Format the data and return an array of formatted content.
      * @return array[]
index d1619e118672f7c0d217c4f4519ec6cbbe3458de..5072bd3b4631e745efaff7d3a5e4ff68715f0435 100644 (file)
@@ -14,12 +14,23 @@ class SearchApiController extends ApiController
 
     protected $rules = [
         'all' => [
 
     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;
     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.
      *
      * 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
      * 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);
         $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);
 
 
         $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([
 
         return response()->json([
-            'data'  => $data,
+            'data' => $formatter->format(),
             '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])
+        );
+    }
 }
 }
index ee522381641827e6dc20277f3d3a3c7177182395..7fa1a304e21b4d9f9e1d55466b462176598724e1 100644 (file)
@@ -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
index 2c7584e3fd0c5adf859b4fa697303ccff33cfc42..bb45b7959033eeb159436c8af6fc275fd6b8a4b3 100644 (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",
       "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"
       "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",
       "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..."
       "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",
       "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."
       "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
     }
   ],
   "total": 3
-}
\ No newline at end of file
+}
index 2a186e8d6328c4133b86eeb643d1436f40d5b78b..b80ed4530ba73f601ab2cfe70d8ae015cf18bc6a 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Tests\Api;
 
 
 namespace Tests\Api;
 
+use BookStack\Activity\Models\Tag;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
 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',
         $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([
 
         $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue');
         $resp->assertJsonFragment([
-            'type'         => 'book',
-            'url'          => $book->getUrl(),
+            'type' => 'book',
+            'url' => $book->getUrl(),
             'preview_html' => [
             'preview_html' => [
-                'name'    => 'name with <strong>superuniquevalue</strong> within',
+                'name' => 'name with <strong>superuniquevalue</strong> within',
                 'content' => 'Description 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();
     }
         $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',
+                        ],
+                    ],
+                ],
+            ],
+        ]);
+    }
+
+
 }
 }