diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index d130304de..4cba7900b 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -55,10 +55,16 @@ class ApiDocsGenerator { return $routes->map(function (array $route) { $exampleTypes = ['request', 'response']; + $fileTypes = ['json', 'http']; foreach ($exampleTypes as $exampleType) { - $exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}.json"); - $exampleContent = file_exists($exampleFile) ? file_get_contents($exampleFile) : null; - $route["example_{$exampleType}"] = $exampleContent; + foreach ($fileTypes as $fileType) { + $exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}." . $fileType); + if (file_exists($exampleFile)) { + $route["example_{$exampleType}"] = file_get_contents($exampleFile); + continue 2; + } + } + $route["example_{$exampleType}"] = null; } return $route; diff --git a/app/Entities/Tools/SearchRunner.php b/app/Entities/Tools/SearchRunner.php index f6da871f4..04f4c5768 100644 --- a/app/Entities/Tools/SearchRunner.php +++ b/app/Entities/Tools/SearchRunner.php @@ -56,6 +56,8 @@ class SearchRunner * Search all entities in the system. * The provided count is for each entity to search, * Total returned could be larger and not guaranteed. + * + * @return array{total: int, count: int, has_more: bool, results: Entity[]} */ public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array { diff --git a/app/Http/Controllers/Api/SearchApiController.php b/app/Http/Controllers/Api/SearchApiController.php new file mode 100644 index 000000000..8fb249665 --- /dev/null +++ b/app/Http/Controllers/Api/SearchApiController.php @@ -0,0 +1,67 @@ +<?php + +namespace BookStack\Http\Controllers\Api; + +use BookStack\Entities\Models\Entity; +use BookStack\Entities\Tools\SearchOptions; +use BookStack\Entities\Tools\SearchRunner; +use Illuminate\Http\Request; + +class SearchApiController extends ApiController +{ + protected $searchRunner; + + protected $rules = [ + 'all' => [ + 'query' => ['required'], + 'page' => ['integer', 'min:1'], + 'count' => ['integer', 'min:1', 'max:100'], + ], + ]; + + public function __construct(SearchRunner $searchRunner) + { + $this->searchRunner = $searchRunner; + } + + /** + * Run a search query against all main content types (shelves, books, chapters & pages) + * in the system. Takes the same input as the main search bar within the BookStack + * interface as a 'query' parameter. See https://www.bookstackapp.com/docs/user/searching/ + * for a full list of search term options. Results contain a 'type' property to distinguish + * between: bookshelf, book, chapter & page. + * + * 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 + * may currently be up to 4x this value. + */ + public function all(Request $request) + { + $this->validate($request, $this->rules['all']); + + $options = SearchOptions::fromString($request->get('query') ?? ''); + $page = intval($request->get('page', '0')) ?: 1; + $count = min(intval($request->get('count', '0')) ?: 20, 100); + + $results = $this->searchRunner->searchEntities($options, 'all', $page, $count); + + /** @var Entity $result */ + foreach ($results['results'] as $result) { + $result->setVisible([ + 'id', 'name', 'slug', 'book_id', + 'chapter_id', 'draft', 'template', + 'created_at', 'updated_at', + 'tags', 'type', + ]); + $result->setAttribute('type', $result->getType()); + } + + return response()->json([ + 'data' => $results['results'], + 'total' => $results['total'], + ]); + } + + +} diff --git a/dev/api/requests/search-all.http b/dev/api/requests/search-all.http new file mode 100644 index 000000000..ee5223816 --- /dev/null +++ b/dev/api/requests/search-all.http @@ -0,0 +1 @@ +GET /api/search?query=cats+{created_by:me}&page=1&count=2 \ No newline at end of file diff --git a/dev/api/responses/search-all.json b/dev/api/responses/search-all.json new file mode 100644 index 000000000..3096d6da1 --- /dev/null +++ b/dev/api/responses/search-all.json @@ -0,0 +1,52 @@ +{ + "data": [ + { + "id": 84, + "book_id": 1, + "slug": "a-chapter-for-cats", + "name": "A chapter for cats", + "created_at": "2021-11-14T15:57:35.000000Z", + "updated_at": "2021-11-14T15:57:35.000000Z", + "type": "chapter", + "tags": [] + }, + { + "name": "The hows and whys of cats", + "id": 396, + "slug": "the-hows-and-whys-of-cats", + "book_id": 1, + "chapter_id": 75, + "draft": false, + "template": false, + "created_at": "2021-05-15T16:28:10.000000Z", + "updated_at": "2021-11-14T15:56:49.000000Z", + "type": "page", + "tags": [ + { + "name": "Animal", + "value": "Cat", + "order": 0 + }, + { + "name": "Category", + "value": "Top Content", + "order": 0 + } + ] + }, + { + "name": "How advanced are cats?", + "id": 362, + "slug": "how-advanced-are-cats", + "book_id": 13, + "chapter_id": 73, + "draft": false, + "template": false, + "created_at": "2020-11-29T21:55:07.000000Z", + "updated_at": "2021-11-14T16:02:39.000000Z", + "type": "page", + "tags": [] + } + ], + "total": 3 +} \ No newline at end of file diff --git a/resources/views/api-docs/parts/endpoint.blade.php b/resources/views/api-docs/parts/endpoint.blade.php index c1bce805b..6e3d93659 100644 --- a/resources/views/api-docs/parts/endpoint.blade.php +++ b/resources/views/api-docs/parts/endpoint.blade.php @@ -13,7 +13,7 @@ @if($endpoint['body_params'] ?? false) <details class="mb-m"> - <summary class="text-muted">Body Parameters</summary> + <summary class="text-muted">{{ $endpoint['method'] === 'GET' ? 'Query' : 'Body' }} Parameters</summary> <table class="table"> <tr> <th>Param Name</th> diff --git a/routes/api.php b/routes/api.php index 4ba499462..7876ba6d4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use BookStack\Http\Controllers\Api\ChapterApiController; use BookStack\Http\Controllers\Api\ChapterExportApiController; use BookStack\Http\Controllers\Api\PageApiController; use BookStack\Http\Controllers\Api\PageExportApiController; +use BookStack\Http\Controllers\Api\SearchApiController; use Illuminate\Support\Facades\Route; /** @@ -57,6 +58,8 @@ Route::get('pages/{id}/export/pdf', [PageExportApiController::class, 'exportPdf' Route::get('pages/{id}/export/plaintext', [PageExportApiController::class, 'exportPlainText']); Route::get('pages/{id}/export/markdown', [PageExportApiController::class, 'exportMarkDown']); +Route::get('search', [SearchApiController::class, 'all']); + Route::get('shelves', [BookshelfApiController::class, 'list']); Route::post('shelves', [BookshelfApiController::class, 'create']); Route::get('shelves/{id}', [BookshelfApiController::class, 'read']); diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php new file mode 100644 index 000000000..55ca0e009 --- /dev/null +++ b/tests/Api/SearchApiTest.php @@ -0,0 +1,47 @@ +<?php + +namespace Tests\Api; + +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Bookshelf; +use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\Page; +use Tests\TestCase; + +class SearchApiTest extends TestCase +{ + use TestsApi; + + protected $baseEndpoint = '/api/search'; + + public function test_all_endpoint_returns_search_filtered_results_with_query() + { + $this->actingAsApiEditor(); + $uniqueTerm = 'MySuperUniqueTermForSearching'; + + /** @var Entity $entityClass */ + foreach ([Page::class, Chapter::class, Book::class, Bookshelf::class] as $entityClass) { + /** @var Entity $first */ + $first = $entityClass::query()->first(); + $first->update(['name' => $uniqueTerm]); + $first->indexForSearch(); + } + + $resp = $this->getJson($this->baseEndpoint . '?query=' . $uniqueTerm . '&count=5&page=1'); + $resp->assertJsonCount(4, 'data'); + $resp->assertJsonFragment(['name' => $uniqueTerm, 'type' => 'book']); + $resp->assertJsonFragment(['name' => $uniqueTerm, 'type' => 'chapter']); + $resp->assertJsonFragment(['name' => $uniqueTerm, 'type' => 'page']); + $resp->assertJsonFragment(['name' => $uniqueTerm, 'type' => 'bookshelf']); + } + + public function test_all_endpoint_requires_query_parameter() + { + $resp = $this->actingAsApiEditor()->get($this->baseEndpoint); + $resp->assertStatus(422); + + $resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue'); + $resp->assertOk(); + } +}