From 3ad1e31fcccd38bf864d54f4f813c81fb4574c4c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Oct 2025 18:44:49 +0100 Subject: [PATCH] API: Added comment-read endpoint, added api docs section descriptions --- .../Controllers/CommentApiController.php | 39 ++++++++++++++++--- app/Activity/Models/Comment.php | 6 ++- app/Api/ApiDocsGenerator.php | 24 ++++++++++-- .../Controllers/BookApiController.php | 2 +- resources/views/api-docs/index.blade.php | 4 +- routes/api.php | 4 ++ 6 files changed, 67 insertions(+), 12 deletions(-) diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php index 25421077a..3a4c33cd6 100644 --- a/app/Activity/Controllers/CommentApiController.php +++ b/app/Activity/Controllers/CommentApiController.php @@ -5,30 +5,31 @@ declare(strict_types=1); namespace BookStack\Activity\Controllers; use BookStack\Activity\CommentRepo; +use BookStack\Activity\Models\Comment; use BookStack\Http\ApiController; use Illuminate\Http\JsonResponse; +/** + * The comment data model has a 'local_id' property, which is a unique integer ID + * scoped to the page which the comment is on. The 'parent_id' is used for replies + * and refers to the 'local_id' of the parent comment on the same page, not the main + * globally unique 'id'. + */ class CommentApiController extends ApiController { // TODO - Add tree-style comment listing to page-show responses. - // TODO - list // TODO - create - // TODO - read // TODO - update // TODO - delete // TODO - Test visibility controls // TODO - Test permissions of each action - // TODO - Support intro block for API docs so we can explain the - // properties for comments in a shared kind of way? - public function __construct( protected CommentRepo $commentRepo, ) { } - /** * Get a listing of comments visible to the user. */ @@ -40,4 +41,30 @@ class CommentApiController extends ApiController 'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at' ]); } + + /** + * Read the details of a single comment, along with its direct replies. + */ + public function read(string $id): JsonResponse + { + $comment = $this->commentRepo->getQueryForVisible() + ->where('id', '=', $id)->firstOrFail(); + + $replies = $this->commentRepo->getQueryForVisible() + ->where('parent_id', '=', $comment->local_id) + ->where('commentable_id', '=', $comment->commentable_id) + ->where('commentable_type', '=', $comment->commentable_type) + ->get(); + + /** @var Comment[] $toProcess */ + $toProcess = [$comment, ...$replies]; + foreach ($toProcess as $commentToProcess) { + $commentToProcess->setAttribute('html', $commentToProcess->safeHtml()); + $commentToProcess->makeVisible('html'); + } + + $comment->setRelation('replies', $replies); + + return response()->json($comment); + } } diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index caca7809f..91c91e7a8 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -16,7 +16,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; /** * @property int $id - * @property string $text - Deprecated & now unused (#4821) * @property string $html * @property int|null $parent_id - Relates to local_id, not id * @property int $local_id @@ -31,6 +30,11 @@ class Comment extends Model implements Loggable, OwnableInterface use HasCreatorAndUpdater; protected $fillable = ['parent_id']; + protected $hidden = ['html']; + + protected $casts = [ + 'archived' => 'boolean', + ]; /** * Get the entity that this comment belongs to. diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index 287c83877..eb8f5508c 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -83,11 +83,19 @@ class ApiDocsGenerator protected function loadDetailsFromControllers(Collection $routes): Collection { return $routes->map(function (array $route) { + $class = $this->getReflectionClass($route['controller']); $method = $this->getReflectionMethod($route['controller'], $route['controller_method']); $comment = $method->getDocComment(); - $route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null; + $route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null; $route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']); + // Load class description for the model + // Not ideal to have it here on each route, but adding it in a more structured manner would break + // docs resulting JSON format and therefore be an API break. + // Save refactoring for a more significant set of changes. + $classComment = $class->getDocComment(); + $route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null; + return $route; }); } @@ -140,7 +148,7 @@ class ApiDocsGenerator /** * Parse out the description text from a class method comment. */ - protected function parseDescriptionFromMethodComment(string $comment): string + protected function parseDescriptionFromDocBlockComment(string $comment): string { $matches = []; preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches); @@ -155,6 +163,16 @@ class ApiDocsGenerator * @throws ReflectionException */ protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod + { + return $this->getReflectionClass($className)->getMethod($methodName); + } + + /** + * Get a reflection class from the given class name. + * + * @throws ReflectionException + */ + protected function getReflectionClass(string $className): ReflectionClass { $class = $this->reflectionClasses[$className] ?? null; if ($class === null) { @@ -162,7 +180,7 @@ class ApiDocsGenerator $this->reflectionClasses[$className] = $class; } - return $class->getMethod($methodName); + return $class; } /** diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index 807f5a69c..325f0583c 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -58,7 +58,7 @@ class BookApiController extends ApiController /** * View the details of a single book. - * The response data will contain 'content' property listing the chapter and pages directly within, in + * The response data will contain a 'content' property listing the chapter and pages directly within, in * the same structure as you'd see within the BookStack interface when viewing a book. Top-level * contents will have a 'type' property to distinguish between pages & chapters. */ diff --git a/resources/views/api-docs/index.blade.php b/resources/views/api-docs/index.blade.php index 9345a7bce..84c6d21ac 100644 --- a/resources/views/api-docs/index.blade.php +++ b/resources/views/api-docs/index.blade.php @@ -45,7 +45,9 @@ @foreach($docs as $model => $endpoints)

{{ $model }}

- + @if($endpoints[0]['model_description']) +

{{ $endpoints[0]['model_description'] }}

+ @endif @foreach($endpoints as $endpoint) @include('api-docs.parts.endpoint', ['endpoint' => $endpoint, 'loop' => $loop]) @endforeach diff --git a/routes/api.php b/routes/api.php index b030ca7f7..4b661da5d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -71,6 +71,10 @@ Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete'] Route::get('search', [SearchApiController::class, 'all']); Route::get('comments', [ActivityControllers\CommentApiController::class, 'list']); +Route::post('comments', [ActivityControllers\CommentApiController::class, 'create']); +Route::get('comments/{id}', [ActivityControllers\CommentApiController::class, 'read']); +Route::put('comments/{id}', [ActivityControllers\CommentApiController::class, 'update']); +Route::delete('comments/{id}', [ActivityControllers\CommentApiController::class, 'delete']); Route::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']); Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']);