]> BookStack Code Mirror - bookstack/commitdiff
Added extendable/scalable formatter for webhook data
authorDan Brown <redacted>
Sat, 26 Mar 2022 16:44:34 +0000 (16:44 +0000)
committerDan Brown <redacted>
Sat, 26 Mar 2022 16:53:02 +0000 (16:53 +0000)
Creates a new organsied formatting system for webhook data, with
interfaces for extending with custom model formatting rules.
Allows easy usage & extension of the default bookstack formatting
behaviour when customizing webhook events via theme system, and keeps
default data customizations organised.

This also makes the following webhook data changes:
- owned_by/created_by/updated_by user details are loaded for events with
  Entity details. (POTENTIALLY BREAKING CHANGE).
- current_revision details are loaded for page update/create events.

Added testing to cover added model formatting rules.

For #3279 and #3218

app/Actions/DispatchWebhookJob.php
app/Actions/WebhookFormatter.php [new file with mode: 0644]
app/Entities/Models/Page.php
app/Entities/Models/PageRevision.php
app/Http/Controllers/PageRevisionController.php
app/Theming/ThemeEvents.php
resources/views/settings/webhooks/parts/format-example.blade.php
tests/Actions/WebhookFormatTesting.php [new file with mode: 0644]
tests/Entity/PageRevisionTest.php

index 8f78150a991c84bf243e8e3969e753a7f120d776..2d805228cd5e54d83526fb7aedda72513c9fdb71 100644 (file)
@@ -3,17 +3,14 @@
 namespace BookStack\Actions;
 
 use BookStack\Auth\User;
-use BookStack\Entities\Models\Entity;
 use BookStack\Facades\Theme;
 use BookStack\Interfaces\Loggable;
-use BookStack\Model;
 use BookStack\Theming\ThemeEvents;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
-use Illuminate\Support\Carbon;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
 
@@ -24,31 +21,16 @@ class DispatchWebhookJob implements ShouldQueue
     use Queueable;
     use SerializesModels;
 
-    /**
-     * @var Webhook
-     */
-    protected $webhook;
-
-    /**
-     * @var string
-     */
-    protected $event;
+    protected Webhook $webhook;
+    protected string $event;
+    protected User $initiator;
+    protected int $initiatedTime;
 
     /**
      * @var string|Loggable
      */
     protected $detail;
 
-    /**
-     * @var User
-     */
-    protected $initiator;
-
-    /**
-     * @var int
-     */
-    protected $initiatedTime;
-
     /**
      * Create a new job instance.
      *
@@ -70,8 +52,8 @@ class DispatchWebhookJob implements ShouldQueue
      */
     public function handle()
     {
-        $themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail);
-        $webhookData = $themeResponse ?? $this->buildWebhookData();
+        $themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime);
+        $webhookData = $themeResponse ?? WebhookFormatter::getDefault($this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime)->format();
         $lastError = null;
 
         try {
@@ -97,36 +79,4 @@ class DispatchWebhookJob implements ShouldQueue
 
         $this->webhook->save();
     }
-
-    protected function buildWebhookData(): array
-    {
-        $textParts = [
-            $this->initiator->name,
-            trans('activities.' . $this->event),
-        ];
-
-        if ($this->detail instanceof Entity) {
-            $textParts[] = '"' . $this->detail->name . '"';
-        }
-
-        $data = [
-            'event'                    => $this->event,
-            'text'                     => implode(' ', $textParts),
-            'triggered_at'             => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
-            'triggered_by'             => $this->initiator->attributesToArray(),
-            'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
-            'webhook_id'               => $this->webhook->id,
-            'webhook_name'             => $this->webhook->name,
-        ];
-
-        if (method_exists($this->detail, 'getUrl')) {
-            $data['url'] = $this->detail->getUrl();
-        }
-
-        if ($this->detail instanceof Model) {
-            $data['related_item'] = $this->detail->attributesToArray();
-        }
-
-        return $data;
-    }
 }
diff --git a/app/Actions/WebhookFormatter.php b/app/Actions/WebhookFormatter.php
new file mode 100644 (file)
index 0000000..48b1a39
--- /dev/null
@@ -0,0 +1,123 @@
+<?php
+
+namespace BookStack\Actions;
+
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Interfaces\Loggable;
+use BookStack\Model;
+use Illuminate\Support\Carbon;
+
+class WebhookFormatter
+{
+    protected Webhook $webhook;
+    protected string $event;
+    protected User $initiator;
+    protected int $initiatedTime;
+
+    /**
+     * @var string|Loggable
+     */
+    protected $detail;
+
+    /**
+     * @var array{condition: callable(string, Model):bool, format: callable(Model):void}[]
+     */
+    protected $modelFormatters = [];
+
+    public function __construct(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime)
+    {
+        $this->webhook = $webhook;
+        $this->event = $event;
+        $this->initiator = $initiator;
+        $this->initiatedTime = $initiatedTime;
+        $this->detail = is_object($detail) ? clone $detail : $detail;
+    }
+
+    public function format(): array
+    {
+        $data = [
+            'event'                    => $this->event,
+            'text'                     => $this->formatText(),
+            'triggered_at'             => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
+            'triggered_by'             => $this->initiator->attributesToArray(),
+            'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
+            'webhook_id'               => $this->webhook->id,
+            'webhook_name'             => $this->webhook->name,
+        ];
+
+        if (method_exists($this->detail, 'getUrl')) {
+            $data['url'] = $this->detail->getUrl();
+        }
+
+        if ($this->detail instanceof Model) {
+            $data['related_item'] = $this->formatModel();
+        }
+
+        return $data;
+    }
+
+    /**
+     * @param callable(string, Model):bool $condition
+     * @param callable(Model):void $format
+     */
+    public function addModelFormatter(callable $condition, callable $format): void
+    {
+        $this->modelFormatters[] = [
+            'condition' => $condition,
+            'format' => $format,
+        ];
+    }
+
+    public function addDefaultModelFormatters(): void
+    {
+        // Load entity owner, creator, updater details
+        $this->addModelFormatter(
+            fn($event, $model) => ($model instanceof Entity),
+            fn($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy'])
+        );
+
+        // Load revision detail for page update and create events
+        $this->addModelFormatter(
+            fn($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)),
+            fn($model) => $model->load('currentRevision')
+        );
+    }
+
+    protected function formatModel(): array
+    {
+        /** @var Model $model */
+        $model = $this->detail;
+        $model->unsetRelations();
+
+        foreach ($this->modelFormatters as $formatter) {
+            if ($formatter['condition']($this->event, $model)) {
+                $formatter['format']($model);
+            }
+        }
+
+        return $model->toArray();
+    }
+
+    protected function formatText(): string
+    {
+        $textParts = [
+            $this->initiator->name,
+            trans('activities.' . $this->event),
+        ];
+
+        if ($this->detail instanceof Entity) {
+            $textParts[] = '"' . $this->detail->name . '"';
+        }
+
+        return implode(' ', $textParts);
+    }
+
+    public static function getDefault(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime): self
+    {
+        $instance = new static($event, $webhook, $detail, $initiator, $initiatedTime);
+        $instance->addDefaultModelFormatters();
+        return $instance;
+    }
+}
\ No newline at end of file
index c28b9a3052c6649f54ced3822e268f91990dc89d..c8217af576d84af0e1aa4aa4d98526d222c471ac 100644 (file)
@@ -10,19 +10,22 @@ use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
 
 /**
  * Class Page.
  *
- * @property int        $chapter_id
- * @property string     $html
- * @property string     $markdown
- * @property string     $text
- * @property bool       $template
- * @property bool       $draft
- * @property int        $revision_count
- * @property Chapter    $chapter
- * @property Collection $attachments
+ * @property int          $chapter_id
+ * @property string       $html
+ * @property string       $markdown
+ * @property string       $text
+ * @property bool         $template
+ * @property bool         $draft
+ * @property int          $revision_count
+ * @property Chapter      $chapter
+ * @property Collection   $attachments
+ * @property Collection   $revisions
+ * @property PageRevision $currentRevision
  */
 class Page extends BookChild
 {
@@ -82,6 +85,19 @@ class Page extends BookChild
             ->orderBy('id', 'desc');
     }
 
+    /**
+     * Get the current revision for the page if existing.
+     *
+     * @return PageRevision|null
+     */
+    public function currentRevision(): HasOne
+    {
+        return $this->hasOne(PageRevision::class)
+            ->where('type', '=', 'version')
+            ->orderBy('created_at', 'desc')
+            ->orderBy('id', 'desc');
+    }
+
     /**
      * Get all revision instances assigned to this page.
      * Includes all types of revisions.
@@ -117,16 +133,6 @@ class Page extends BookChild
         return url('/' . implode('/', $parts));
     }
 
-    /**
-     * Get the current revision for the page if existing.
-     *
-     * @return PageRevision|null
-     */
-    public function getCurrentRevision()
-    {
-        return $this->revisions()->first();
-    }
-
     /**
      * Get this page for JSON display.
      */
index 4daf50536441dda8360648eb6ce12c5eee881965..aacc94586be9dc2ab88c6fbed1086bb10f0ced49 100644 (file)
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
 /**
  * Class PageRevision.
  *
+ * @property mixed $id
  * @property int    $page_id
  * @property string $slug
  * @property string $book_slug
@@ -27,6 +28,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
 class PageRevision extends Model
 {
     protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
+    protected $hidden = ['html', 'markdown', 'restricted', 'text'];
 
     /**
      * Get the user that created the page revision.
index d595a6e26fd797cb0c7693f473e0680a168caf3e..c6a4926d28b88b87c3caed0a32f97d377f339788 100644 (file)
@@ -124,11 +124,8 @@ class PageRevisionController extends Controller
             throw new NotFoundException("Revision #{$revId} not found");
         }
 
-        // Get the current revision for the page
-        $currentRevision = $page->getCurrentRevision();
-
-        // Check if its the latest revision, cannot delete latest revision.
-        if (intval($currentRevision->id) === intval($revId)) {
+        // Check if it's the latest revision, cannot delete the latest revision.
+        if (intval($page->currentRevision->id ?? null) === intval($revId)) {
             $this->showErrorNotification(trans('entities.revision_cannot_delete_latest'));
 
             return redirect($page->getUrl('/revisions'));
index 48073416ca6d72661685cae210623e0ea5faf53b..ce99c817cd0a27a57a3e1d0dadc479d31a77d9aa 100644 (file)
@@ -93,6 +93,8 @@ class ThemeEvents
      * @param string                                $event
      * @param \BookStack\Actions\Webhook            $webhook
      * @param string|\BookStack\Interfaces\Loggable $detail
+     * @param \BookStack\Auth\User                  $initiator
+     * @param int                                   $initiatedTime
      */
     const WEBHOOK_CALL_BEFORE = 'webhook_call_before';
 }
index 135d3193bf2d877887b6125ef71254ab9482b47a..e10a6c69e28e609d2a8d4242c3acb40b2a4fb55a 100644 (file)
         "priority": 2,
         "created_at": "2021-12-11T21:53:24.000000Z",
         "updated_at": "2021-12-11T22:25:10.000000Z",
-        "created_by": 1,
-        "updated_by": 1,
+        "created_by": {
+            "id": 1,
+            "name": "Benny",
+            "slug": "benny"
+        },
+        "updated_by": {
+            "id": 1,
+            "name": "Benny",
+            "slug": "benny"
+        },
         "draft": false,
         "revision_count": 9,
         "template": false,
-        "owned_by": 1
+        "owned_by": {
+            "id": 1,
+            "name": "Benny",
+            "slug": "benny"
+        },
+       "current_revision": {
+            "id": 597,
+            "page_id": 2598,
+            "name": "My wonderful updated page",
+            "created_by": 1,
+            "created_at": "2021-12-11T21:53:24.000000Z",
+            "updated_at": "2021-12-11T21:53:24.000000Z",
+            "slug": "my-wonderful-updated-page",
+            "book_slug": "my-awesome-book",
+            "type": "version",
+            "summary": "Updated the title and fixed some spelling",
+            "revision_number": 2
+        }
     }
 }</code></pre>
 </div>
\ No newline at end of file
diff --git a/tests/Actions/WebhookFormatTesting.php b/tests/Actions/WebhookFormatTesting.php
new file mode 100644 (file)
index 0000000..56a569c
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+namespace Tests\Actions;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Actions\Webhook;
+use BookStack\Actions\WebhookFormatter;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use Illuminate\Support\Arr;
+use Tests\TestCase;
+
+class WebhookFormatTesting extends TestCase
+{
+    public function test_entity_events_show_related_user_info()
+    {
+        $events = [
+            ActivityType::BOOK_UPDATE => Book::query()->first(),
+            ActivityType::CHAPTER_CREATE => Chapter::query()->first(),
+            ActivityType::PAGE_MOVE => Page::query()->first(),
+        ];
+
+        foreach ($events as $event => $entity) {
+            $data = $this->getWebhookData($event, $entity);
+
+            $this->assertEquals($entity->createdBy->name, Arr::get($data, 'related_item.created_by.name'));
+            $this->assertEquals($entity->updatedBy->id, Arr::get($data, 'related_item.updated_by.id'));
+            $this->assertEquals($entity->ownedBy->slug, Arr::get($data, 'related_item.owned_by.slug'));
+        }
+    }
+
+    public function test_page_create_and_update_events_show_revision_info()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']);
+
+        $data = $this->getWebhookData(ActivityType::PAGE_UPDATE, $page);
+        $this->assertEquals($page->currentRevision->id, Arr::get($data, 'related_item.current_revision.id'));
+        $this->assertEquals($page->currentRevision->type, Arr::get($data, 'related_item.current_revision.type'));
+        $this->assertEquals('Update a', Arr::get($data, 'related_item.current_revision.summary'));
+    }
+
+    protected function getWebhookData(string $event, $detail): array
+    {
+        $webhook = Webhook::factory()->make();
+        $user = $this->getEditor();
+        $formatter = WebhookFormatter::getDefault($event, $webhook, $detail, $user, time());
+        return $formatter->format();
+    }
+}
\ No newline at end of file
index 2ed7d3b411e86b8990cc0b000740ec2a79a26bbd..fc6678788636df222c405f34e58823643b4c3be2 100644 (file)
@@ -144,13 +144,14 @@ class PageRevisionTest extends TestCase
 
     public function test_revision_deletion()
     {
-        $page = Page::first();
+        /** @var Page $page */
+        $page = Page::query()->first();
         $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']);
 
-        $page = Page::find($page->id);
+        $page->refresh();
         $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']);
 
-        $page = Page::find($page->id);
+        $page->refresh();
         $beforeRevisionCount = $page->revisions->count();
 
         // Delete the first revision
@@ -158,18 +159,17 @@ class PageRevisionTest extends TestCase
         $resp = $this->asEditor()->delete($revision->getUrl('/delete/'));
         $resp->assertRedirect($page->getUrl('/revisions'));
 
-        $page = Page::find($page->id);
+        $page->refresh();
         $afterRevisionCount = $page->revisions->count();
 
         $this->assertTrue($beforeRevisionCount === ($afterRevisionCount + 1));
 
         // Try to delete the latest revision
         $beforeRevisionCount = $page->revisions->count();
-        $currentRevision = $page->getCurrentRevision();
-        $resp = $this->asEditor()->delete($currentRevision->getUrl('/delete/'));
+        $resp = $this->asEditor()->delete($page->currentRevision->getUrl('/delete/'));
         $resp->assertRedirect($page->getUrl('/revisions'));
 
-        $page = Page::find($page->id);
+        $page->refresh();
         $afterRevisionCount = $page->revisions->count();
         $this->assertTrue($beforeRevisionCount === $afterRevisionCount);
     }