]> BookStack Code Mirror - bookstack/commitdiff
API: Added comment-read endpoint, added api docs section descriptions
authorDan Brown <redacted>
Wed, 22 Oct 2025 17:44:49 +0000 (18:44 +0100)
committerDan Brown <redacted>
Wed, 22 Oct 2025 17:44:49 +0000 (18:44 +0100)
app/Activity/Controllers/CommentApiController.php
app/Activity/Models/Comment.php
app/Api/ApiDocsGenerator.php
app/Entities/Controllers/BookApiController.php
resources/views/api-docs/index.blade.php
routes/api.php

index 25421077ab4f360b049428655cd482020a28fda5..3a4c33cd6c7adf44d17d10baeb57491fbbaa664c 100644 (file)
@@ -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);
+    }
 }
index caca7809f5a302177b515f1d4b7dddd1529723a6..91c91e7a8ae1160d925bb636b8c31fbe33c15f45 100644 (file)
@@ -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.
index 287c838779060b9e23fa600f61e758d862649658..eb8f5508c7022f0892e83b33c8a814877e53d0af 100644 (file)
@@ -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;
     }
 
     /**
index 807f5a69c8f3088b1a9fcc56cf66deebe87bc5e1..325f0583c671d9d6b3a295540fee971fbab769cf 100644 (file)
@@ -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 '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.
      */
index 9345a7bcead45420fdb3825b1cf51227edb21861..84c6d21acfbcba9279bf3340765edb8850150e56 100644 (file)
@@ -45,7 +45,9 @@
                 @foreach($docs as $model => $endpoints)
                     <section class="card content-wrap auto-height">
                         <h1 class="list-heading text-capitals">{{ $model }}</h1>
-
+                        @if($endpoints[0]['model_description'])
+                            <p>{{ $endpoints[0]['model_description'] }}</p>
+                        @endif
                         @foreach($endpoints as $endpoint)
                             @include('api-docs.parts.endpoint', ['endpoint' => $endpoint, 'loop' => $loop])
                         @endforeach
index b030ca7f73ba3ee5b2feb20da031bb53d6ae1e23..4b661da5d23638da5964a7b9f09cc1ad19605255 100644 (file)
@@ -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']);