{
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;
* 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
{
--- /dev/null
+<?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'],
+ ]);
+ }
+
+
+}
--- /dev/null
+GET /api/search?query=cats+{created_by:me}&page=1&count=2
\ No newline at end of file
--- /dev/null
+{
+ "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
@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>
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;
/**
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']);
--- /dev/null
+<?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();
+ }
+}