Skip to content

Commit 3e656ef

Browse files
committed
Added include func for search api
1 parent 6f1c54d commit 3e656ef

File tree

5 files changed

+217
-20
lines changed

5 files changed

+217
-20
lines changed

app/Api/ApiEntityListFormatter.php

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace BookStack\Api;
44

55
use BookStack\Entities\Models\Entity;
6+
use BookStack\Entities\Models\Page;
67

78
class ApiEntityListFormatter
89
{
@@ -12,6 +13,11 @@ class ApiEntityListFormatter
1213
*/
1314
protected array $list = [];
1415

16+
/**
17+
* Whether to include related titles in the response.
18+
*/
19+
protected bool $includeRelatedTitles = false;
20+
1521
/**
1622
* The fields to show in the formatted data.
1723
* Can be a plain string array item for a direct model field (If existing on model).
@@ -20,8 +26,16 @@ class ApiEntityListFormatter
2026
* @var array<string|int, string|callable>
2127
*/
2228
protected array $fields = [
23-
'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft',
24-
'template', 'priority', 'created_at', 'updated_at',
29+
'id',
30+
'name',
31+
'slug',
32+
'book_id',
33+
'chapter_id',
34+
'draft',
35+
'template',
36+
'priority',
37+
'created_at',
38+
'updated_at',
2539
];
2640

2741
public function __construct(array $list)
@@ -62,6 +76,30 @@ public function withTags(): self
6276
return $this;
6377
}
6478

79+
/**
80+
* Enable the inclusion of related book and chapter titles in the response.
81+
*/
82+
public function withRelatedTitles(): self
83+
{
84+
$this->includeRelatedTitles = true;
85+
86+
$this->withField('book_title', function (Entity $entity) {
87+
if (method_exists($entity, 'book')) {
88+
return $entity->book?->name;
89+
}
90+
return null;
91+
});
92+
93+
$this->withField('chapter_title', function (Entity $entity) {
94+
if ($entity instanceof Page && $entity->chapter_id) {
95+
return optional($entity->getAttribute('chapter'))->name;
96+
}
97+
return null;
98+
});
99+
100+
return $this;
101+
}
102+
65103
/**
66104
* Format the data and return an array of formatted content.
67105
* @return array[]

app/Search/SearchApiController.php

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,23 @@ class SearchApiController extends ApiController
1414

1515
protected $rules = [
1616
'all' => [
17-
'query' => ['required'],
18-
'page' => ['integer', 'min:1'],
19-
'count' => ['integer', 'min:1', 'max:100'],
17+
'query' => ['required'],
18+
'page' => ['integer', 'min:1'],
19+
'count' => ['integer', 'min:1', 'max:100'],
20+
'include' => ['string', 'regex:/^[a-zA-Z,]*$/'],
2021
],
2122
];
2223

24+
/**
25+
* Valid include parameters and their corresponding formatter methods.
26+
* These parameters allow for additional related data, like titles or tags,
27+
* to be included in the search results when requested via the API.
28+
*/
29+
protected const VALID_INCLUDES = [
30+
'titles' => 'withRelatedTitles',
31+
'tags' => 'withTags',
32+
];
33+
2334
public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter)
2435
{
2536
$this->searchRunner = $searchRunner;
@@ -33,6 +44,13 @@ public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $
3344
* for a full list of search term options. Results contain a 'type' property to distinguish
3445
* between: bookshelf, book, chapter & page.
3546
*
47+
* This method now supports the 'include' parameter, which allows API clients to specify related
48+
* fields (such as titles or tags) that should be included in the search results.
49+
*
50+
* The 'include' parameter is a comma-separated string. For example, adding `include=titles,tags`
51+
* will include both titles and tags in the API response. If the parameter is not provided, only
52+
* basic entity data will be returned.
53+
*
3654
* The paging parameters and response format emulates a standard listing endpoint
3755
* but standard sorting and filtering cannot be done on this endpoint. If a count value
3856
* is provided this will only be taken as a suggestion. The results in the response
@@ -45,22 +63,49 @@ public function all(Request $request)
4563
$options = SearchOptions::fromString($request->get('query') ?? '');
4664
$page = intval($request->get('page', '0')) ?: 1;
4765
$count = min(intval($request->get('count', '0')) ?: 20, 100);
66+
$includes = $this->parseIncludes($request->get('include', ''));
4867

4968
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
5069
$this->resultsFormatter->format($results['results']->all(), $options);
5170

52-
$data = (new ApiEntityListFormatter($results['results']->all()))
53-
->withType()->withTags()
54-
->withField('preview_html', function (Entity $entity) {
55-
return [
56-
'name' => (string) $entity->getAttribute('preview_name'),
57-
'content' => (string) $entity->getAttribute('preview_content'),
58-
];
59-
})->format();
71+
$formatter = new ApiEntityListFormatter($results['results']->all());
72+
$formatter->withType(); // Always include type as it's essential for search results
73+
74+
foreach ($includes as $include) {
75+
if (isset(self::VALID_INCLUDES[$include])) {
76+
$method = self::VALID_INCLUDES[$include];
77+
$formatter->$method();
78+
}
79+
}
80+
81+
$formatter->withField('preview_html', function (Entity $entity) {
82+
return [
83+
'name' => (string) $entity->getAttribute('preview_name'),
84+
'content' => (string) $entity->getAttribute('preview_content'),
85+
];
86+
});
6087

6188
return response()->json([
62-
'data' => $data,
89+
'data' => $formatter->format(),
6390
'total' => $results['total'],
6491
]);
6592
}
93+
94+
/**
95+
* Parse and validate the include parameter.
96+
*
97+
* @param string $includeString Comma-separated list of includes
98+
* @return array<string>
99+
*/
100+
protected function parseIncludes(string $includeString): array
101+
{
102+
if (empty($includeString)) {
103+
return [];
104+
}
105+
106+
return array_filter(
107+
explode(',', strtolower($includeString)),
108+
fn($include) => isset (self::VALID_INCLUDES[$include])
109+
);
110+
}
66111
}

dev/api/requests/search-all.http

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
GET /api/search?query=cats+{created_by:me}&page=1&count=2
1+
GET /api/search?query=cats+{created_by:me}&page=1&count=2&include=titles,tags

dev/api/responses/search-all.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"updated_at": "2021-11-14T15:57:35.000000Z",
1010
"type": "chapter",
1111
"url": "https://example.com/books/my-book/chapter/a-chapter-for-cats",
12+
"book_title": "Cats",
1213
"preview_html": {
1314
"name": "A chapter for <strong>cats</strong>",
1415
"content": "...once a bunch of <strong>cats</strong> named tony...behaviour of <strong>cats</strong> is unsuitable"
@@ -27,6 +28,8 @@
2728
"updated_at": "2021-11-14T15:56:49.000000Z",
2829
"type": "page",
2930
"url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats",
31+
"book_title": "Cats",
32+
"chapter_title": "A chapter for cats",
3033
"preview_html": {
3134
"name": "The hows and whys of <strong>cats</strong>",
3235
"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 @@
5659
"updated_at": "2021-11-14T16:02:39.000000Z",
5760
"type": "page",
5861
"url": "https://example.com/books/my-book/page/how-advanced-are-cats",
62+
"book_title": "Cats",
63+
"chapter_title": "A chapter for cats",
5964
"preview_html": {
6065
"name": "How advanced are <strong>cats</strong>?",
6166
"content": "<strong>cats</strong> are some of the most advanced animals in the world."
@@ -64,4 +69,4 @@
6469
}
6570
],
6671
"total": 3
67-
}
72+
}

tests/Api/SearchApiTest.php

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Tests\Api;
44

5+
use BookStack\Activity\Models\Tag;
56
use BookStack\Entities\Models\Book;
67
use BookStack\Entities\Models\Bookshelf;
78
use BookStack\Entities\Models\Chapter;
@@ -45,7 +46,7 @@ public function test_all_endpoint_returns_entity_url()
4546
$resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue');
4647
$resp->assertJsonFragment([
4748
'type' => 'page',
48-
'url' => $page->getUrl(),
49+
'url' => $page->getUrl(),
4950
]);
5051
}
5152

@@ -57,10 +58,10 @@ public function test_all_endpoint_returns_items_with_preview_html()
5758

5859
$resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue');
5960
$resp->assertJsonFragment([
60-
'type' => 'book',
61-
'url' => $book->getUrl(),
61+
'type' => 'book',
62+
'url' => $book->getUrl(),
6263
'preview_html' => [
63-
'name' => 'name with <strong>superuniquevalue</strong> within',
64+
'name' => 'name with <strong>superuniquevalue</strong> within',
6465
'content' => 'Description with <strong>superuniquevalue</strong> within',
6566
],
6667
]);
@@ -74,4 +75,112 @@ public function test_all_endpoint_requires_query_parameter()
7475
$resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue');
7576
$resp->assertOk();
7677
}
78+
79+
public function test_all_endpoint_includes_book_and_chapter_titles_when_requested()
80+
{
81+
$this->actingAsApiEditor();
82+
83+
$book = $this->entities->book();
84+
$chapter = $this->entities->chapter();
85+
$page = $this->entities->newPage();
86+
87+
$book->name = 'My Test Book';
88+
$book->save();
89+
90+
$chapter->name = 'My Test Chapter';
91+
$chapter->book_id = $book->id;
92+
$chapter->save();
93+
94+
$page->name = 'My Test Page With UniqueSearchTerm';
95+
$page->book_id = $book->id;
96+
$page->chapter_id = $chapter->id;
97+
$page->save();
98+
99+
$page->indexForSearch();
100+
101+
// Test without include parameter
102+
$resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm');
103+
$resp->assertOk();
104+
$resp->assertDontSee('book_title');
105+
$resp->assertDontSee('chapter_title');
106+
107+
// Test with include parameter
108+
$resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=titles');
109+
$resp->assertOk();
110+
$resp->assertJsonFragment([
111+
'name' => 'My Test Page With UniqueSearchTerm',
112+
'book_title' => 'My Test Book',
113+
'chapter_title' => 'My Test Chapter',
114+
'type' => 'page'
115+
]);
116+
}
117+
118+
public function test_all_endpoint_validates_include_parameter()
119+
{
120+
$this->actingAsApiEditor();
121+
122+
// Test invalid include value
123+
$resp = $this->getJson($this->baseEndpoint . '?query=test&include=invalid');
124+
$resp->assertOk();
125+
$resp->assertDontSee('book_title');
126+
127+
// Test SQL injection attempt
128+
$resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles;DROP TABLE users');
129+
$resp->assertStatus(422);
130+
131+
// Test multiple includes
132+
$resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles,tags');
133+
$resp->assertOk();
134+
}
135+
136+
public function test_all_endpoint_includes_tags_when_requested()
137+
{
138+
$this->actingAsApiEditor();
139+
140+
// Create a page and give it a unique name for search
141+
$page = $this->entities->page();
142+
$page->name = 'Page With UniqueSearchTerm';
143+
$page->save();
144+
145+
// Save tags to the page using the existing saveTagsToEntity method
146+
$tags = [
147+
['name' => 'SampleTag', 'value' => 'SampleValue']
148+
];
149+
app(\BookStack\Activity\TagRepo::class)->saveTagsToEntity($page, $tags);
150+
151+
// Ensure the page is indexed for search
152+
$page->indexForSearch();
153+
154+
// Test without the "tags" include
155+
$resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm');
156+
$resp->assertOk();
157+
$resp->assertDontSee('tags');
158+
159+
// Test with the "tags" include
160+
$resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=tags');
161+
$resp->assertOk();
162+
163+
// Assert that tags are included in the response
164+
$resp->assertJsonFragment([
165+
'name' => 'SampleTag',
166+
'value' => 'SampleValue',
167+
]);
168+
169+
// Optionally: check the structure to match the tag order as well
170+
$resp->assertJsonStructure([
171+
'data' => [
172+
'*' => [
173+
'tags' => [
174+
'*' => [
175+
'name',
176+
'value',
177+
'order',
178+
],
179+
],
180+
],
181+
],
182+
]);
183+
}
184+
185+
77186
}

0 commit comments

Comments
 (0)