Skip to content

Commit f14fc68

Browse files
committed
API: Added new tags API endpoints
1 parent 93f84a8 commit f14fc68

6 files changed

Lines changed: 112 additions & 19 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BookStack\Activity\Controllers;
6+
7+
use BookStack\Activity\TagRepo;
8+
use BookStack\Http\ApiController;
9+
use Illuminate\Http\JsonResponse;
10+
11+
class TagApiController extends ApiController
12+
{
13+
public function __construct(
14+
protected TagRepo $tagRepo,
15+
) {
16+
}
17+
18+
/**
19+
* Get a list of tag names used in the system.
20+
* You'll only see results based on tags applied to content you have access to.
21+
* Only the name field can be used in filters.
22+
*/
23+
public function listNames(): JsonResponse
24+
{
25+
$tagQuery = $this->tagRepo
26+
->queryWithTotalsForApi('');
27+
28+
return $this->apiListingResponse($tagQuery, [
29+
'name', 'values', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count',
30+
], [], [
31+
'name'
32+
]);
33+
}
34+
35+
/**
36+
* Get a list of tag values used in the system, which have been used for the given tag name.
37+
* You'll only see results based on tags applied to content you have access to.
38+
* Only the value field can be used in filters.
39+
*/
40+
public function listValues(string $name): JsonResponse
41+
{
42+
$tagQuery = $this->tagRepo
43+
->queryWithTotalsForApi($name);
44+
45+
return $this->apiListingResponse($tagQuery, [
46+
'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count',
47+
], [], [
48+
'name', 'value',
49+
]);
50+
}
51+
}

app/Activity/Controllers/TagController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ public function index(Request $request)
2424
'usages' => trans('entities.tags_usages'),
2525
]);
2626

27-
$nameFilter = $request->get('name', '');
27+
$nameFilter = $request->input('name', '');
2828
$tags = $this->tagRepo
29-
->queryWithTotals($listOptions, $nameFilter)
29+
->queryWithTotalsForList($listOptions, $nameFilter)
3030
->paginate(50)
3131
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
3232
'name' => $nameFilter,

app/Activity/TagRepo.php

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,45 @@ public function __construct(
1818
}
1919

2020
/**
21-
* Start a query against all tags in the system.
21+
* Start a query against all tags in the system, with total counts for their usage,
22+
* suitable for a system interface list with listing options.
2223
*/
23-
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
24+
public function queryWithTotalsForList(SimpleListOptions $listOptions, string $nameFilter): Builder
2425
{
2526
$searchTerm = $listOptions->getSearch();
2627
$sort = $listOptions->getSort();
2728
if ($sort === 'name' && $nameFilter) {
2829
$sort = 'value';
2930
}
3031

32+
$query = $this->baseQueryWithTotals($nameFilter, $searchTerm)
33+
->orderBy($sort, $listOptions->getOrder());
34+
35+
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
36+
}
37+
38+
/**
39+
* Start a query against all tags in the system, with total counts for their usage,
40+
* which can be used via the API.
41+
*/
42+
public function queryWithTotalsForApi(string $nameFilter): Builder
43+
{
44+
$query = $this->baseQueryWithTotals($nameFilter, '');
45+
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
46+
}
47+
48+
protected function baseQueryWithTotals(string $nameFilter, string $searchTerm): Builder
49+
{
3150
$query = Tag::query()
3251
->select([
3352
'name',
3453
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
3554
DB::raw('COUNT(id) as usages'),
36-
DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'),
37-
DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'),
38-
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
39-
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
55+
DB::raw('CAST(SUM(IF(entity_type = \'page\', 1, 0)) as UNSIGNED) as page_count'),
56+
DB::raw('CAST(SUM(IF(entity_type = \'chapter\', 1, 0)) as UNSIGNED) as chapter_count'),
57+
DB::raw('CAST(SUM(IF(entity_type = \'book\', 1, 0)) as UNSIGNED) as book_count'),
58+
DB::raw('CAST(SUM(IF(entity_type = \'bookshelf\', 1, 0)) as UNSIGNED) as shelf_count'),
4059
])
41-
->orderBy($sort, $listOptions->getOrder())
4260
->whereHas('entity');
4361

4462
if ($nameFilter) {
@@ -57,7 +75,7 @@ public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilt
5775
});
5876
}
5977

60-
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
78+
return $query;
6179
}
6280

6381
/**

app/Api/ListingResponseBuilder.php

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ class ListingResponseBuilder
1818
*/
1919
protected array $fields;
2020

21+
/**
22+
* Which fields are filterable.
23+
* When null, the $fields above are used instead (Allow all fields).
24+
* @var string[]|null
25+
*/
26+
protected array|null $filterableFields = null;
27+
2128
/**
2229
* @var array<callable>
2330
*/
@@ -54,7 +61,7 @@ public function toResponse(): JsonResponse
5461
{
5562
$filteredQuery = $this->filterQuery($this->query);
5663

57-
$total = $filteredQuery->count();
64+
$total = $filteredQuery->getCountForPagination();
5865
$data = $this->fetchData($filteredQuery)->each(function ($model) {
5966
foreach ($this->resultModifiers as $modifier) {
6067
$modifier($model);
@@ -77,6 +84,14 @@ public function modifyResults(callable $modifier): void
7784
$this->resultModifiers[] = $modifier;
7885
}
7986

87+
/**
88+
* Limit filtering to just the given set of fields.
89+
*/
90+
public function setFilterableFields(array $fields): void
91+
{
92+
$this->filterableFields = $fields;
93+
}
94+
8095
/**
8196
* Fetch the data to return within the response.
8297
*/
@@ -94,7 +109,7 @@ protected function fetchData(Builder $query): Collection
94109
protected function filterQuery(Builder $query): Builder
95110
{
96111
$query = clone $query;
97-
$requestFilters = $this->request->get('filter', []);
112+
$requestFilters = $this->request->input('filter', []);
98113
if (!is_array($requestFilters)) {
99114
return $query;
100115
}
@@ -114,10 +129,11 @@ protected function filterQuery(Builder $query): Builder
114129
protected function requestFilterToQueryFilter($fieldKey, $value): ?array
115130
{
116131
$splitKey = explode(':', $fieldKey);
117-
$field = $splitKey[0];
132+
$field = strtolower($splitKey[0]);
118133
$filterOperator = $splitKey[1] ?? 'eq';
119134

120-
if (!in_array($field, $this->fields)) {
135+
$filterFields = $this->filterableFields ?? $this->fields;
136+
if (!in_array($field, $filterFields)) {
121137
return null;
122138
}
123139

@@ -140,8 +156,8 @@ protected function sortQuery(Builder $query): Builder
140156
$defaultSortName = $this->fields[0];
141157
$direction = 'asc';
142158

143-
$sort = $this->request->get('sort', '');
144-
if (strpos($sort, '-') === 0) {
159+
$sort = $this->request->input('sort', '');
160+
if (str_starts_with($sort, '-')) {
145161
$direction = 'desc';
146162
}
147163

@@ -160,9 +176,9 @@ protected function sortQuery(Builder $query): Builder
160176
protected function countAndOffsetQuery(Builder $query): Builder
161177
{
162178
$query = clone $query;
163-
$offset = max(0, $this->request->get('offset', 0));
179+
$offset = max(0, $this->request->input('offset', 0));
164180
$maxCount = config('api.max_item_count');
165-
$count = $this->request->get('count', config('api.default_item_count'));
181+
$count = $this->request->input('count', config('api.default_item_count'));
166182
$count = max(min($maxCount, $count), 1);
167183

168184
return $query->skip($offset)->take($count);

app/Http/ApiController.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ abstract class ApiController extends Controller
2020
* Provide a paginated listing JSON response in a standard format
2121
* taking into account any pagination parameters passed by the user.
2222
*/
23-
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse
23+
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = [], array $filterableFields = []): JsonResponse
2424
{
2525
$listing = new ListingResponseBuilder($query, request(), $fields);
2626

27+
if (count($filterableFields) > 0) {
28+
$listing->setFilterableFields($filterableFields);
29+
}
30+
2731
foreach ($modifiers as $modifier) {
2832
$listing->modifyResults($modifier);
2933
}

routes/api.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
use BookStack\Activity\Controllers as ActivityControllers;
10+
use BookStack\Activity\Controllers\TagApiController;
1011
use BookStack\Api\ApiDocsController;
1112
use BookStack\App\SystemApiController;
1213
use BookStack\Entities\Controllers as EntityControllers;
@@ -109,6 +110,9 @@
109110

110111
Route::get('system', [SystemApiController::class, 'read']);
111112

113+
Route::get('tags/names', [TagApiController::class, 'listNames']);
114+
Route::get('tags/name/{name}/values', [TagApiController::class, 'listValues']);
115+
112116
Route::get('users', [UserApiController::class, 'list']);
113117
Route::post('users', [UserApiController::class, 'create']);
114118
Route::get('users/{id}', [UserApiController::class, 'read']);

0 commit comments

Comments
 (0)