diff --git a/app/Activity/Controllers/TagApiController.php b/app/Activity/Controllers/TagApiController.php new file mode 100644 index 00000000000..f5c5e95d420 --- /dev/null +++ b/app/Activity/Controllers/TagApiController.php @@ -0,0 +1,68 @@ + [ + 'name' => ['required', 'string'], + ], + ]; + } + + /** + * Get a list of tag names used in the system. + * Only the name field can be used in filters. + */ + public function listNames(): JsonResponse + { + $tagQuery = $this->tagRepo + ->queryWithTotalsForApi(''); + + return $this->apiListingResponse($tagQuery, [ + 'name', 'values', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count', + ], [], [ + 'name' + ]); + } + + /** + * Get a list of tag values, which have been set for the given tag name, + * which must be provided as a query parameter on the request. + * Only the value field can be used in filters. + */ + public function listValues(Request $request): JsonResponse + { + $data = $this->validate($request, $this->rules()['listValues']); + $name = $data['name']; + + $tagQuery = $this->tagRepo->queryWithTotalsForApi($name); + + return $this->apiListingResponse($tagQuery, [ + 'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count', + ], [], [ + 'value', + ]); + } +} diff --git a/app/Activity/Controllers/TagController.php b/app/Activity/Controllers/TagController.php index 0af8835ca77..723dc4ab474 100644 --- a/app/Activity/Controllers/TagController.php +++ b/app/Activity/Controllers/TagController.php @@ -24,9 +24,9 @@ public function index(Request $request) 'usages' => trans('entities.tags_usages'), ]); - $nameFilter = $request->get('name', ''); + $nameFilter = $request->input('name', ''); $tags = $this->tagRepo - ->queryWithTotals($listOptions, $nameFilter) + ->queryWithTotalsForList($listOptions, $nameFilter) ->paginate(50) ->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [ 'name' => $nameFilter, diff --git a/app/Activity/TagRepo.php b/app/Activity/TagRepo.php index 82c26b00e28..3e8d5545ab6 100644 --- a/app/Activity/TagRepo.php +++ b/app/Activity/TagRepo.php @@ -18,9 +18,10 @@ public function __construct( } /** - * Start a query against all tags in the system. + * Start a query against all tags in the system, with total counts for their usage, + * suitable for a system interface list with listing options. */ - public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder + public function queryWithTotalsForList(SimpleListOptions $listOptions, string $nameFilter): Builder { $searchTerm = $listOptions->getSearch(); $sort = $listOptions->getSort(); @@ -28,17 +29,34 @@ public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilt $sort = 'value'; } + $query = $this->baseQueryWithTotals($nameFilter, $searchTerm) + ->orderBy($sort, $listOptions->getOrder()); + + return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + } + + /** + * Start a query against all tags in the system, with total counts for their usage, + * which can be used via the API. + */ + public function queryWithTotalsForApi(string $nameFilter): Builder + { + $query = $this->baseQueryWithTotals($nameFilter, ''); + return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + } + + protected function baseQueryWithTotals(string $nameFilter, string $searchTerm): Builder + { $query = Tag::query() ->select([ 'name', ($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'), DB::raw('COUNT(id) as usages'), - DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'), - DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'), - DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'), - DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'), + DB::raw('CAST(SUM(IF(entity_type = \'page\', 1, 0)) as UNSIGNED) as page_count'), + DB::raw('CAST(SUM(IF(entity_type = \'chapter\', 1, 0)) as UNSIGNED) as chapter_count'), + DB::raw('CAST(SUM(IF(entity_type = \'book\', 1, 0)) as UNSIGNED) as book_count'), + DB::raw('CAST(SUM(IF(entity_type = \'bookshelf\', 1, 0)) as UNSIGNED) as shelf_count'), ]) - ->orderBy($sort, $listOptions->getOrder()) ->whereHas('entity'); if ($nameFilter) { @@ -57,7 +75,7 @@ public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilt }); } - return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + return $query; } /** diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index a59cb8198e2..53cb2890a7e 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -195,11 +195,12 @@ protected function getReflectionClass(string $className): ReflectionClass protected function getFlatApiRoutes(): Collection { return collect(Route::getRoutes()->getRoutes())->filter(function ($route) { - return strpos($route->uri, 'api/') === 0; + return str_starts_with($route->uri, 'api/'); })->map(function ($route) { [$controller, $controllerMethod] = explode('@', $route->action['uses']); $baseModelName = explode('.', explode('/', $route->uri)[1])[0]; - $shortName = $baseModelName . '-' . $controllerMethod; + $controllerMethodKebab = Str::kebab($controllerMethod); + $shortName = $baseModelName . '-' . $controllerMethodKebab; return [ 'name' => $shortName, @@ -207,7 +208,7 @@ protected function getFlatApiRoutes(): Collection 'method' => $route->methods[0], 'controller' => $controller, 'controller_method' => $controllerMethod, - 'controller_method_kebab' => Str::kebab($controllerMethod), + 'controller_method_kebab' => $controllerMethodKebab, 'base_model' => $baseModelName, ]; }); diff --git a/app/Api/ListingResponseBuilder.php b/app/Api/ListingResponseBuilder.php index 44117bad975..6b9cfdd7d0d 100644 --- a/app/Api/ListingResponseBuilder.php +++ b/app/Api/ListingResponseBuilder.php @@ -18,6 +18,13 @@ class ListingResponseBuilder */ protected array $fields; + /** + * Which fields are filterable. + * When null, the $fields above are used instead (Allow all fields). + * @var string[]|null + */ + protected array|null $filterableFields = null; + /** * @var array */ @@ -54,7 +61,7 @@ public function toResponse(): JsonResponse { $filteredQuery = $this->filterQuery($this->query); - $total = $filteredQuery->count(); + $total = $filteredQuery->getCountForPagination(); $data = $this->fetchData($filteredQuery)->each(function ($model) { foreach ($this->resultModifiers as $modifier) { $modifier($model); @@ -77,6 +84,14 @@ public function modifyResults(callable $modifier): void $this->resultModifiers[] = $modifier; } + /** + * Limit filtering to just the given set of fields. + */ + public function setFilterableFields(array $fields): void + { + $this->filterableFields = $fields; + } + /** * Fetch the data to return within the response. */ @@ -94,7 +109,7 @@ protected function fetchData(Builder $query): Collection protected function filterQuery(Builder $query): Builder { $query = clone $query; - $requestFilters = $this->request->get('filter', []); + $requestFilters = $this->request->input('filter', []); if (!is_array($requestFilters)) { return $query; } @@ -114,10 +129,11 @@ protected function filterQuery(Builder $query): Builder protected function requestFilterToQueryFilter($fieldKey, $value): ?array { $splitKey = explode(':', $fieldKey); - $field = $splitKey[0]; + $field = strtolower($splitKey[0]); $filterOperator = $splitKey[1] ?? 'eq'; - if (!in_array($field, $this->fields)) { + $filterFields = $this->filterableFields ?? $this->fields; + if (!in_array($field, $filterFields)) { return null; } @@ -140,8 +156,8 @@ protected function sortQuery(Builder $query): Builder $defaultSortName = $this->fields[0]; $direction = 'asc'; - $sort = $this->request->get('sort', ''); - if (strpos($sort, '-') === 0) { + $sort = $this->request->input('sort', ''); + if (str_starts_with($sort, '-')) { $direction = 'desc'; } @@ -160,9 +176,9 @@ protected function sortQuery(Builder $query): Builder protected function countAndOffsetQuery(Builder $query): Builder { $query = clone $query; - $offset = max(0, $this->request->get('offset', 0)); + $offset = max(0, $this->request->input('offset', 0)); $maxCount = config('api.max_item_count'); - $count = $this->request->get('count', config('api.default_item_count')); + $count = $this->request->input('count', config('api.default_item_count')); $count = max(min($maxCount, $count), 1); return $query->skip($offset)->take($count); diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index 8c0f206d0d5..f1b74783f8a 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -20,10 +20,14 @@ abstract class ApiController extends Controller * Provide a paginated listing JSON response in a standard format * taking into account any pagination parameters passed by the user. */ - protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse + protected function apiListingResponse(Builder $query, array $fields, array $modifiers = [], array $filterableFields = []): JsonResponse { $listing = new ListingResponseBuilder($query, request(), $fields); + if (count($filterableFields) > 0) { + $listing->setFilterableFields($filterableFields); + } + foreach ($modifiers as $modifier) { $listing->modifyResults($modifier); } diff --git a/dev/api/requests/image-gallery-readDataForUrl.http b/dev/api/requests/image-gallery-read-data-for-url.http similarity index 100% rename from dev/api/requests/image-gallery-readDataForUrl.http rename to dev/api/requests/image-gallery-read-data-for-url.http diff --git a/dev/api/requests/tags-list-values.http b/dev/api/requests/tags-list-values.http new file mode 100644 index 00000000000..6dd3f49fc0b --- /dev/null +++ b/dev/api/requests/tags-list-values.http @@ -0,0 +1 @@ +GET /api/tags/values-for-name?name=Category diff --git a/dev/api/responses/tags-list-names.json b/dev/api/responses/tags-list-names.json new file mode 100644 index 00000000000..c0c8e7b2231 --- /dev/null +++ b/dev/api/responses/tags-list-names.json @@ -0,0 +1,32 @@ +{ + "data": [ + { + "name": "Category", + "values": 8, + "usages": 184, + "page_count": 3, + "chapter_count": 8, + "book_count": 171, + "shelf_count": 2 + }, + { + "name": "Review Due", + "values": 2, + "usages": 2, + "page_count": 1, + "chapter_count": 0, + "book_count": 1, + "shelf_count": 0 + }, + { + "name": "Type", + "values": 2, + "usages": 2, + "page_count": 0, + "chapter_count": 1, + "book_count": 1, + "shelf_count": 0 + } + ], + "total": 3 +} \ No newline at end of file diff --git a/dev/api/responses/tags-list-values.json b/dev/api/responses/tags-list-values.json new file mode 100644 index 00000000000..37926b8463c --- /dev/null +++ b/dev/api/responses/tags-list-values.json @@ -0,0 +1,32 @@ +{ + "data": [ + { + "name": "Category", + "value": "Cool Stuff", + "usages": 3, + "page_count": 1, + "chapter_count": 0, + "book_count": 2, + "shelf_count": 0 + }, + { + "name": "Category", + "value": "Top Content", + "usages": 168, + "page_count": 0, + "chapter_count": 3, + "book_count": 165, + "shelf_count": 0 + }, + { + "name": "Category", + "value": "Learning", + "usages": 2, + "page_count": 0, + "chapter_count": 0, + "book_count": 0, + "shelf_count": 2 + } + ], + "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 024a5ecdf04..543ef092ee5 100644 --- a/resources/views/api-docs/parts/endpoint.blade.php +++ b/resources/views/api-docs/parts/endpoint.blade.php @@ -1,7 +1,7 @@
{{ $endpoint['method'] }}
- @if($endpoint['controller_method_kebab'] === 'list') + @if(str_starts_with($endpoint['controller_method_kebab'], 'list') && !str_contains($endpoint['uri'], '{')) {{ url($endpoint['uri']) }} @else {{ url($endpoint['uri']) }} diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php index 663389047ce..ebe3838ef1f 100644 --- a/resources/views/api-docs/parts/getting-started.blade.php +++ b/resources/views/api-docs/parts/getting-started.blade.php @@ -2,7 +2,7 @@

This documentation covers use of the REST API.
- Examples of API usage, in a variety of programming languages, can be found in the BookStack api-scripts repo on GitHub. + Examples of API usage, in a variety of programming languages, can be found in the BookStack api-scripts repo on Codeberg.

Some alternative options for extension and customization can be found below: diff --git a/routes/api.php b/routes/api.php index 308a95d8c28..5a9df3cc422 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,7 @@ */ use BookStack\Activity\Controllers as ActivityControllers; +use BookStack\Activity\Controllers\TagApiController; use BookStack\Api\ApiDocsController; use BookStack\App\SystemApiController; use BookStack\Entities\Controllers as EntityControllers; @@ -109,6 +110,9 @@ Route::get('system', [SystemApiController::class, 'read']); +Route::get('tags/names', [TagApiController::class, 'listNames']); +Route::get('tags/values-for-name', [TagApiController::class, 'listValues']); + Route::get('users', [UserApiController::class, 'list']); Route::post('users', [UserApiController::class, 'create']); Route::get('users/{id}', [UserApiController::class, 'read']); diff --git a/tests/Api/TagsApiTest.php b/tests/Api/TagsApiTest.php new file mode 100644 index 00000000000..a079fa63915 --- /dev/null +++ b/tests/Api/TagsApiTest.php @@ -0,0 +1,109 @@ + 'MyGreatApiTag', 'value' => 'cat']; + $pagesToTag = Page::query()->take(10)->get(); + $booksToTag = Book::query()->take(3)->get(); + $chaptersToTag = Chapter::query()->take(5)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag($tagInfo))); + $booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag($tagInfo))); + $chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag($tagInfo))); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag'); + $resp->assertStatus(200); + $resp->assertJson([ + 'data' => [ + [ + 'name' => 'MyGreatApiTag', + 'values' => 1, + 'usages' => 18, + 'page_count' => 10, + 'book_count' => 3, + 'chapter_count' => 5, + 'shelf_count' => 0, + ] + ], + 'total' => 1, + ]); + } + + public function test_list_names_is_limited_by_permission_visibility(): void + { + $pagesToTag = Page::query()->take(10)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id]))); + + $this->permissions->disableEntityInheritedPermissions($pagesToTag[3]); + $this->permissions->disableEntityInheritedPermissions($pagesToTag[6]); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag'); + $resp->assertStatus(200); + $resp->assertJson([ + 'data' => [ + [ + 'name' => 'MyGreatApiTag', + 'values' => 8, + 'usages' => 8, + 'page_count' => 8, + 'book_count' => 0, + 'chapter_count' => 0, + 'shelf_count' => 0, + ] + ], + 'total' => 1, + ]); + } + + public function test_list_values_returns_values_for_set_tag() + { + $pagesToTag = Page::query()->take(10)->get(); + $booksToTag = Book::query()->take(3)->get(); + $chaptersToTag = Chapter::query()->take(5)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-page' . $page->id]))); + $booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-book' . $book->id]))); + $chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-chapter' . $chapter->id]))); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyValueApiTag'); + + $resp->assertStatus(200); + $resp->assertJson(['total' => 18]); + $resp->assertJsonFragment([ + [ + 'name' => 'MyValueApiTag', + 'value' => 'tag-page' . $pagesToTag[0]->id, + 'usages' => 1, + 'page_count' => 1, + 'book_count' => 0, + 'chapter_count' => 0, + 'shelf_count' => 0, + ] + ]); + } + + public function test_list_values_is_limited_by_permission_visibility(): void + { + $pagesToTag = Page::query()->take(10)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id]))); + + $this->permissions->disableEntityInheritedPermissions($pagesToTag[3]); + $this->permissions->disableEntityInheritedPermissions($pagesToTag[6]); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyGreatApiTag'); + $resp->assertStatus(200); + $resp->assertJson(['total' => 8]); + $resp->assertJsonMissing(['value' => 'cat' . $pagesToTag[3]->id]); + } +}