diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php index 436d66d59..3c94d96ee 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -2,7 +2,9 @@ namespace BookStack\Api; +use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\Page; class ApiEntityListFormatter { @@ -20,8 +22,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 +72,28 @@ class ApiEntityListFormatter return $this; } + /** + * Include parent book/chapter info in the formatted data. + */ + public function withParents(): self + { + $this->withField('book', function (Entity $entity) { + if ($entity instanceof BookChild && $entity->book) { + return $entity->book->only(['id', 'name', 'slug']); + } + return null; + }); + + $this->withField('chapter', function (Entity $entity) { + if ($entity instanceof Page && $entity->chapter) { + return $entity->chapter->only(['id', 'name', 'slug']); + } + 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..79cd8cfab 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -9,21 +9,18 @@ use Illuminate\Http\Request; class SearchApiController extends ApiController { - protected SearchRunner $searchRunner; - protected SearchResultsFormatter $resultsFormatter; - 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'], ], ]; - public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter) - { - $this->searchRunner = $searchRunner; - $this->resultsFormatter = $resultsFormatter; + public function __construct( + protected SearchRunner $searchRunner, + protected SearchResultsFormatter $resultsFormatter + ) { } /** @@ -50,16 +47,16 @@ class SearchApiController extends ApiController $this->resultsFormatter->format($results['results']->all(), $options); $data = (new ApiEntityListFormatter($results['results']->all())) - ->withType()->withTags() + ->withType()->withTags()->withParents() ->withField('preview_html', function (Entity $entity) { return [ - 'name' => (string) $entity->getAttribute('preview_name'), + 'name' => (string) $entity->getAttribute('preview_name'), 'content' => (string) $entity->getAttribute('preview_content'), ]; })->format(); return response()->json([ - 'data' => $data, + 'data' => $data, 'total' => $results['total'], ]); } diff --git a/dev/api/requests/search-all.http b/dev/api/requests/search-all.http index ee5223816..f9c17fa16 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 diff --git a/dev/api/responses/search-all.json b/dev/api/responses/search-all.json index 2c7584e3f..2ad896416 100644 --- a/dev/api/responses/search-all.json +++ b/dev/api/responses/search-all.json @@ -8,7 +8,12 @@ "created_at": "2021-11-14T15:57:35.000000Z", "updated_at": "2021-11-14T15:57:35.000000Z", "type": "chapter", - "url": "https://example.com/books/my-book/chapter/a-chapter-for-cats", + "url": "https://example.com/books/cats/chapter/a-chapter-for-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, "preview_html": { "name": "A chapter for cats", "content": "...once a bunch of cats named tony...behaviour of cats is unsuitable" @@ -26,7 +31,17 @@ "created_at": "2021-05-15T16:28:10.000000Z", "updated_at": "2021-11-14T15:56:49.000000Z", "type": "page", - "url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats", + "url": "https://example.com/books/cats/page/the-hows-and-whys-of-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, + "chapter": { + "id": 75, + "name": "A chapter for cats", + "slug": "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..." @@ -55,7 +70,17 @@ "created_at": "2020-11-29T21:55:07.000000Z", "updated_at": "2021-11-14T16:02:39.000000Z", "type": "page", - "url": "https://example.com/books/my-book/page/how-advanced-are-cats", + "url": "https://example.com/books/big-cats/page/how-advanced-are-cats", + "book": { + "id": 13, + "name": "Big Cats", + "slug": "big-cats" + }, + "chapter": { + "id": 73, + "name": "A chapter for bigger cats", + "slug": "a-chapter-for-bigger-cats" + }, "preview_html": { "name": "How advanced are cats?", "content": "cats are some of the most advanced animals in the world." @@ -64,4 +89,4 @@ } ], "total": 3 -} \ No newline at end of file +} diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 2a186e8d6..9da7900ca 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -13,7 +13,7 @@ class SearchApiTest extends TestCase { use TestsApi; - protected $baseEndpoint = '/api/search'; + protected string $baseEndpoint = '/api/search'; public function test_all_endpoint_returns_search_filtered_results_with_query() { @@ -45,7 +45,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 +57,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 +74,46 @@ class SearchApiTest extends TestCase $resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue'); $resp->assertOk(); } + + public function test_all_endpoint_includes_parent_details_where_visible() + { + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $book = $page->book; + + $page->update(['name' => 'name with superextrauniquevalue within']); + $page->indexForSearch(); + + $editor = $this->users->editor(); + $this->actingAsApiEditor(); + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertJsonFragment([ + 'id' => $page->id, + 'type' => 'page', + 'book' => [ + 'id' => $book->id, + 'name' => $book->name, + 'slug' => $book->slug, + ], + 'chapter' => [ + 'id' => $chapter->id, + 'name' => $chapter->name, + 'slug' => $chapter->slug, + ], + ]); + + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->setEntityPermissions($page, ['view'], [$editor->roles()->first()]); + + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertJsonPath('data.0.id', $page->id); + $resp->assertJsonPath('data.0.book.name', $book->name); + $resp->assertJsonMissingPath('data.0.chapter'); + + $this->permissions->disableEntityInheritedPermissions($book); + + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertJsonPath('data.0.id', $page->id); + $resp->assertJsonMissingPath('data.0.book.name'); + } }