Added include func for search api
This commit is contained in:
		
							parent
							
								
									6f1c54d018
								
							
						
					
					
						commit
						3e656efb00
					
				| 
						 | 
				
			
			@ -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[]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,9 +17,20 @@ class SearchApiController extends ApiController
 | 
			
		|||
            '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) {
 | 
			
		||||
        $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'),
 | 
			
		||||
            ];
 | 
			
		||||
            })->format();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        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])
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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."
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -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',
 | 
			
		||||
                        ],
 | 
			
		||||
                    ],
 | 
			
		||||
                ],
 | 
			
		||||
            ],
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue