API: Added new tags API endpoints

This commit is contained in:
Dan Brown
2026-04-12 18:26:00 +01:00
parent 93f84a81b2
commit f14fc68b66
6 changed files with 112 additions and 19 deletions

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace BookStack\Activity\Controllers;
use BookStack\Activity\TagRepo;
use BookStack\Http\ApiController;
use Illuminate\Http\JsonResponse;
class TagApiController extends ApiController
{
public function __construct(
protected TagRepo $tagRepo,
) {
}
/**
* Get a list of tag names used in the system.
* You'll only see results based on tags applied to content you have access to.
* 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 used in the system, which have been used for the given tag name.
* You'll only see results based on tags applied to content you have access to.
* Only the value field can be used in filters.
*/
public function listValues(string $name): JsonResponse
{
$tagQuery = $this->tagRepo
->queryWithTotalsForApi($name);
return $this->apiListingResponse($tagQuery, [
'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count',
], [], [
'name', 'value',
]);
}
}

View File

@@ -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,

View File

@@ -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;
}
/**

View File

@@ -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<callable>
*/
@@ -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);
@@ -77,6 +84,14 @@ class ListingResponseBuilder
$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 @@ 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);

View File

@@ -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);
}

View File

@@ -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/name/{name}/values', [TagApiController::class, 'listValues']);
Route::get('users', [UserApiController::class, 'list']);
Route::post('users', [UserApiController::class, 'create']);
Route::get('users/{id}', [UserApiController::class, 'read']);