diff options
| -rw-r--r-- | app/Activity/Controllers/TagApiController.php | 68 | ||||
| -rw-r--r-- | app/Activity/Controllers/TagController.php | 4 | ||||
| -rw-r--r-- | app/Activity/TagRepo.php | 34 | ||||
| -rw-r--r-- | app/Api/ApiDocsGenerator.php | 7 | ||||
| -rw-r--r-- | app/Api/ListingResponseBuilder.php | 32 | ||||
| -rw-r--r-- | app/Http/ApiController.php | 6 | ||||
| -rw-r--r-- | dev/api/requests/image-gallery-read-data-for-url.http (renamed from dev/api/requests/image-gallery-readDataForUrl.http) | 0 | ||||
| -rw-r--r-- | dev/api/requests/tags-list-values.http | 1 | ||||
| -rw-r--r-- | dev/api/responses/tags-list-names.json | 32 | ||||
| -rw-r--r-- | dev/api/responses/tags-list-values.json | 32 | ||||
| -rw-r--r-- | resources/views/api-docs/parts/endpoint.blade.php | 2 | ||||
| -rw-r--r-- | resources/views/api-docs/parts/getting-started.blade.php | 2 | ||||
| -rw-r--r-- | routes/api.php | 4 | ||||
| -rw-r--r-- | tests/Api/TagsApiTest.php | 109 |
14 files changed, 309 insertions, 24 deletions
diff --git a/app/Activity/Controllers/TagApiController.php b/app/Activity/Controllers/TagApiController.php new file mode 100644 index 000000000..f5c5e95d4 --- /dev/null +++ b/app/Activity/Controllers/TagApiController.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace BookStack\Activity\Controllers; + +use BookStack\Activity\TagRepo; +use BookStack\Http\ApiController; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; + +/** + * Endpoints to query data about tags in the system. + * You'll only see results based on tags applied to content you have access to. + * There are no general create/update/delete endpoints here since tags do not exist + * by themselves, they are managed via the items they are assigned to. + */ +class TagApiController extends ApiController +{ + public function __construct( + protected TagRepo $tagRepo, + ) { + } + + protected function rules(): array + { + return [ + 'listValues' => [ + '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 0af8835ca..723dc4ab4 100644 --- a/app/Activity/Controllers/TagController.php +++ b/app/Activity/Controllers/TagController.php @@ -24,9 +24,9 @@ class TagController extends Controller '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 82c26b00e..3e8d5545a 100644 --- a/app/Activity/TagRepo.php +++ b/app/Activity/TagRepo.php @@ -18,9 +18,10 @@ class TagRepo } /** - * 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 @@ class TagRepo $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 @@ class TagRepo }); } - 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 a59cb8198..53cb2890a 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -195,11 +195,12 @@ class ApiDocsGenerator 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 @@ class ApiDocsGenerator '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 44117bad9..6b9cfdd7d 100644 --- a/app/Api/ListingResponseBuilder.php +++ b/app/Api/ListingResponseBuilder.php @@ -19,6 +19,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<callable> */ protected array $resultModifiers = []; @@ -54,7 +61,7 @@ class ListingResponseBuilder { $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); @@ -78,6 +85,14 @@ class ListingResponseBuilder } /** + * 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. */ protected function fetchData(Builder $query): Collection @@ -94,7 +109,7 @@ class ListingResponseBuilder 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 @@ class ListingResponseBuilder 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 @@ class ListingResponseBuilder $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 @@ class ListingResponseBuilder 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 8c0f206d0..f1b74783f 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 index 1892600f4..1892600f4 100644 --- a/dev/api/requests/image-gallery-readDataForUrl.http +++ b/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 000000000..6dd3f49fc --- /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 000000000..c0c8e7b22 --- /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 000000000..37926b846 --- /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 024a5ecdf..543ef092e 100644 --- a/resources/views/api-docs/parts/endpoint.blade.php +++ b/resources/views/api-docs/parts/endpoint.blade.php @@ -1,7 +1,7 @@ <div class="flex-container-row items-center gap-m"> <span class="api-method text-mono" data-method="{{ $endpoint['method'] }}">{{ $endpoint['method'] }}</span> <h5 id="{{ $endpoint['name'] }}" class="text-mono pb-xs"> - @if($endpoint['controller_method_kebab'] === 'list') + @if(str_starts_with($endpoint['controller_method_kebab'], 'list') && !str_contains($endpoint['uri'], '{')) <a style="color: inherit;" target="_blank" rel="noopener" href="{{ url($endpoint['uri']) }}">{{ url($endpoint['uri']) }}</a> @else <span>{{ url($endpoint['uri']) }}</span> diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php index 663389047..ebe3838ef 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 @@ <p class="mb-none"> This documentation covers use of the REST API. <br> - Examples of API usage, in a variety of programming languages, can be found in the <a href="https://codeberg.org/bookstack/api-scripts" target="_blank" rel="noopener noreferrer">BookStack api-scripts repo on GitHub</a>. + Examples of API usage, in a variety of programming languages, can be found in the <a href="https://codeberg.org/bookstack/api-scripts" target="_blank" rel="noopener noreferrer">BookStack api-scripts repo on Codeberg</a>. <br> <br> Some alternative options for extension and customization can be found below: diff --git a/routes/api.php b/routes/api.php index 308a95d8c..5a9df3cc4 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('search', [SearchApiController::class, 'all']); 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 000000000..a079fa639 --- /dev/null +++ b/tests/Api/TagsApiTest.php @@ -0,0 +1,109 @@ +<?php + +namespace Api; + +use BookStack\Activity\Models\Tag; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Page; +use Tests\Api\TestsApi; +use Tests\TestCase; + +class TagsApiTest extends TestCase +{ + use TestsApi; + + public function test_list_names_provides_rolled_up_tag_info(): void + { + $tagInfo = ['name' => '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]); + } +} |
