Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions app/Activity/Controllers/TagApiController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace BookStack\Activity\Controllers;

use BookStack\Activity\TagRepo;
use BookStack\Http\ApiController;
use Illuminate\Http\JsonResponse;

/**
* 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,
) {
}

/**
* 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.
* 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',
]);
}
}
4 changes: 2 additions & 2 deletions app/Activity/Controllers/TagController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 26 additions & 8 deletions app/Activity/TagRepo.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,45 @@ 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();
if ($sort === 'name' && $nameFilter) {
$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) {
Expand All @@ -57,7 +75,7 @@ public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilt
});
}

return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
return $query;
}

/**
Expand Down
7 changes: 4 additions & 3 deletions app/Api/ApiDocsGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,19 +195,20 @@ 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,
'uri' => $route->uri,
'method' => $route->methods[0],
'controller' => $controller,
'controller_method' => $controllerMethod,
'controller_method_kebab' => Str::kebab($controllerMethod),
'controller_method_kebab' => $controllerMethodKebab,
'base_model' => $baseModelName,
];
});
Expand Down
32 changes: 24 additions & 8 deletions app/Api/ListingResponseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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>
*/
Expand Down Expand Up @@ -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);
Expand All @@ -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.
*/
Expand All @@ -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;
}
Expand All @@ -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;
}

Expand All @@ -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';
}

Expand All @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion app/Http/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
32 changes: 32 additions & 0 deletions dev/api/responses/tags-list-names.json
Original file line number Diff line number Diff line change
@@ -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
}
32 changes: 32 additions & 0 deletions dev/api/responses/tags-list-values.json
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion resources/views/api-docs/parts/endpoint.blade.php
Original file line number Diff line number Diff line change
@@ -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>
Expand Down
2 changes: 1 addition & 1 deletion resources/views/api-docs/parts/getting-started.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -109,6 +110,9 @@

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']);
Expand Down
Loading