summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/Activity/Controllers/TagApiController.php68
-rw-r--r--app/Activity/Controllers/TagController.php4
-rw-r--r--app/Activity/TagRepo.php34
-rw-r--r--app/Api/ApiDocsGenerator.php7
-rw-r--r--app/Api/ListingResponseBuilder.php32
-rw-r--r--app/Http/ApiController.php6
-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.http1
-rw-r--r--dev/api/responses/tags-list-names.json32
-rw-r--r--dev/api/responses/tags-list-values.json32
-rw-r--r--resources/views/api-docs/parts/endpoint.blade.php2
-rw-r--r--resources/views/api-docs/parts/getting-started.blade.php2
-rw-r--r--routes/api.php4
-rw-r--r--tests/Api/TagsApiTest.php109
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]);
+ }
+}