diff options
Diffstat (limited to 'app')
| -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 |
6 files changed, 129 insertions, 22 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); } |
