Reviewed recycle bin API PR and made changes

Made the following changes, many of these are just to align with
existing conventions.

- Updated urls to be hypenated, instead of underscored, to match other system endpoints.
- Updated URL parameter to be `deletionId` instead of `id`, and removed the ID-based comment on controller methods, so the required ID model is clear from the URL alone, since its not clear from the URL endpoint alone like existing endpoints. This follows the pattern used in the "web" routes.
- Added extra detail on some controller method comments, and copied permission comment to each method.
- Removed existing field visibility mechanisms to use simpler model-based visibility since we didn't need anything too special here (After some of my other changes).
- Allowed the "deletable" model to be shown in response to provide a little more detail on the main deleted item.
- Updated parent/child-count loading to be on the "deletable" model instead of additional properties which results in simpler controller logic and enforces the idea these are relations on the deletable, not the deletion itself. It also removes additional exposure of model namespacing.
- Updated (int) casts to intval, just since that's our most common conversion method in the codebase.
- Testing: Removed `actingAsAuthorizedUser` and used the admin user instead to prevent extra auth steps on each test.
- Testing: Cut logic/data-checks from tests if already covered by other tests.
- Testing: Added simple assertions for delete/restore response data.
- Examples: Updated list example to reflect changes.

Review of PR #3377
To be followed up with changes to polymorphic relations to hide
namespacing.
This commit is contained in:
Dan Brown 2022-04-25 17:54:59 +01:00
parent 14bccae6bd
commit ff8dadefee
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
8 changed files with 147 additions and 134 deletions

View File

@ -10,10 +10,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
* @property int $id
* @property int $deleted_by
* @property string $deletable_type
* @property int $deletable_id
* @property Deletable $deletable * @property Deletable $deletable
*/ */
class Deletion extends Model implements Loggable class Deletion extends Model implements Loggable
{ {
protected $hidden = [];
/** /**
* Get the related deletable record. * Get the related deletable record.
*/ */

View File

@ -2,16 +2,16 @@
namespace BookStack\Http\Controllers\Api; namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Deletion; use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Repos\DeletionRepo; use BookStack\Entities\Repos\DeletionRepo;
use Closure; use Closure;
use Illuminate\Database\Eloquent\Builder;
class RecycleBinApiController extends ApiController class RecycleBinApiController extends ApiController
{ {
protected $fieldsToExpose = [
'id', 'deleted_by', 'created_at', 'updated_at', 'deletable_type', 'deletable_id',
];
public function __construct() public function __construct()
{ {
$this->middleware(function ($request, $next) { $this->middleware(function ($request, $next) {
@ -24,7 +24,11 @@ class RecycleBinApiController extends ApiController
/** /**
* Get a top-level listing of the items in the recycle bin. * Get a top-level listing of the items in the recycle bin.
* Requires the permission to manage settings and restrictions. * The "deletable" property will reflect the main item deleted.
* For books and chapters, counts of child pages/chapters will
* be loaded within this "deletable" data.
* For chapters & pages, the parent item will be loaded within this "deletable" data.
* Requires permission to manage both system settings and permissions.
*/ */
public function list() public function list()
{ {
@ -40,11 +44,11 @@ class RecycleBinApiController extends ApiController
/** /**
* Restore a single deletion from the recycle bin. * Restore a single deletion from the recycle bin.
* You must provide the deletion id, not the id of the corresponding deleted item. * Requires permission to manage both system settings and permissions.
*/ */
public function restore(DeletionRepo $deletionRepo, string $id) public function restore(DeletionRepo $deletionRepo, string $deletionId)
{ {
$restoreCount = $deletionRepo->restore((int) $id); $restoreCount = $deletionRepo->restore(intval($deletionId));
return response()->json(['restore_count' => $restoreCount]); return response()->json(['restore_count' => $restoreCount]);
} }
@ -52,44 +56,35 @@ class RecycleBinApiController extends ApiController
/** /**
* Remove a single deletion from the recycle bin. * Remove a single deletion from the recycle bin.
* Use this endpoint carefully as it will entirely remove the underlying deleted items from the system. * Use this endpoint carefully as it will entirely remove the underlying deleted items from the system.
* You must provide the deletion id, not the id of the corresponding deleted item. * Requires permission to manage both system settings and permissions.
*/ */
public function destroy(DeletionRepo $deletionRepo, string $id) public function destroy(DeletionRepo $deletionRepo, string $deletionId)
{ {
$deleteCount = $deletionRepo->destroy((int) $id); $deleteCount = $deletionRepo->destroy(intval($deletionId));
return response()->json(['delete_count' => $deleteCount]); return response()->json(['delete_count' => $deleteCount]);
} }
/**
* Load some related details for the deletion listing.
*/
protected function listFormatter(Deletion $deletion) protected function listFormatter(Deletion $deletion)
{ {
$deletion->makeVisible($this->fieldsToExpose);
$deletion->makeHidden('deletable');
$deletable = $deletion->deletable; $deletable = $deletion->deletable;
$isBook = $deletion->deletable_type === "BookStack\Book"; $withTrashedQuery = fn(Builder $query) => $query->withTrashed();
$parent = null;
$children = null;
if ($isBook) { if ($deletable instanceof BookChild) {
$chapterCount = $deletable->chapters()->withTrashed()->count(); $parent = $deletable->getParent();
$children['BookStack\Chapter'] = $chapterCount; $parent->setAttribute('type', $parent->getType());
$deletable->setRelation('parent', $parent);
} }
if ($isBook || $deletion->deletable_type === "BookStack\Chapter") { if ($deletable instanceof Book || $deletable instanceof Chapter) {
$pageCount = $deletable->pages()->withTrashed()->count(); $countsToLoad = ['pages' => $withTrashedQuery];
$children['BookStack\Page'] = $pageCount; if ($deletable instanceof Book) {
$countsToLoad['chapters'] = $withTrashedQuery;
}
$deletable->loadCount($countsToLoad);
} }
$parentEntity = $deletable->getParent();
$parent = null;
if ($parentEntity) {
$parent['type'] = $parentEntity->getMorphClass();
$parent['id'] = $parentEntity->getKey();
}
$deletion->setAttribute('parent', $parent);
$deletion->setAttribute('children', $children);
} }
} }

View File

@ -0,0 +1,64 @@
{
"data": [
{
"id": 18,
"deleted_by": 1,
"created_at": "2022-04-20T12:57:46.000000Z",
"updated_at": "2022-04-20T12:57:46.000000Z",
"deletable_type": "page",
"deletable_id": 2582,
"deletable": {
"id": 2582,
"book_id": 25,
"chapter_id": 0,
"name": "A Wonderful Page",
"slug": "a-wonderful-page",
"priority": 9,
"created_at": "2022-02-08T00:44:45.000000Z",
"updated_at": "2022-04-20T12:57:46.000000Z",
"created_by": 1,
"updated_by": 1,
"draft": false,
"revision_count": 1,
"template": false,
"owned_by": 1,
"editor": "wysiwyg",
"book_slug": "a-great-book",
"parent": {
"id": 25,
"name": "A Great Book",
"slug": "a-great-book",
"description": "",
"created_at": "2022-01-24T16:14:28.000000Z",
"updated_at": "2022-03-06T15:14:50.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1,
"type": "book"
}
}
},
{
"id": 19,
"deleted_by": 1,
"created_at": "2022-04-25T16:07:46.000000Z",
"updated_at": "2022-04-25T16:07:46.000000Z",
"deletable_type": "book",
"deletable_id": 13,
"deletable": {
"id": 13,
"name": "A Big Book!",
"slug": "a-big-book",
"description": "This is a very large book with loads of cool stuff in it!",
"created_at": "2021-11-08T11:26:43.000000Z",
"updated_at": "2022-04-25T16:07:47.000000Z",
"created_by": 27,
"updated_by": 1,
"owned_by": 1,
"pages_count": 208,
"chapters_count": 50
}
}
],
"total": 2
}

View File

@ -1,34 +0,0 @@
{
"data": [
{
"id": 25,
"deleted_by": 1,
"created_at": "2022-04-24T07:59:34.000000Z",
"updated_at": "2022-04-24T07:59:34.000000Z",
"deletable_type": "BookStack\\Book",
"deletable_id": 4,
"parent": {
"type": "BookStack\\Book",
"id": 25
},
"children": {
"BookStack\\Chapter": 0,
"BookStack\\Page": 1
}
},
{
"id": 26,
"deleted_by": 1,
"created_at": "2022-04-24T07:59:35.000000Z",
"updated_at": "2022-04-24T07:59:35.000000Z",
"deletable_type": "BookStack\\Book",
"deletable_id": 3,
"parent": [],
"children": {
"BookStack\\Chapter": 1,
"BookStack\\Page": 1
}
}
],
"total": 2
}

View File

@ -74,6 +74,6 @@ Route::get('users/{id}', [UserApiController::class, 'read']);
Route::put('users/{id}', [UserApiController::class, 'update']); Route::put('users/{id}', [UserApiController::class, 'update']);
Route::delete('users/{id}', [UserApiController::class, 'delete']); Route::delete('users/{id}', [UserApiController::class, 'delete']);
Route::get('recycle_bin', [RecycleBinApiController::class, 'list']); Route::get('recycle-bin', [RecycleBinApiController::class, 'list']);
Route::put('recycle_bin/{id}', [RecycleBinApiController::class, 'restore']); Route::put('recycle-bin/{deletionId}', [RecycleBinApiController::class, 'restore']);
Route::delete('recycle_bin/{id}', [RecycleBinApiController::class, 'destroy']); Route::delete('recycle-bin/{deletionId}', [RecycleBinApiController::class, 'destroy']);

View File

@ -12,12 +12,12 @@ class RecycleBinApiTest extends TestCase
{ {
use TestsApi; use TestsApi;
protected string $baseEndpoint = '/api/recycle_bin'; protected string $baseEndpoint = '/api/recycle-bin';
protected array $endpointMap = [ protected array $endpointMap = [
['get', '/api/recycle_bin'], ['get', '/api/recycle-bin'],
['put', '/api/recycle_bin/1'], ['put', '/api/recycle-bin/1'],
['delete', '/api/recycle_bin/1'], ['delete', '/api/recycle-bin/1'],
]; ];
public function test_settings_manage_permission_needed_for_all_endpoints() public function test_settings_manage_permission_needed_for_all_endpoints()
@ -48,13 +48,12 @@ class RecycleBinApiTest extends TestCase
public function test_index_endpoint_returns_expected_page() public function test_index_endpoint_returns_expected_page()
{ {
$this->actingAsAuthorizedUser(); $admin = $this->getAdmin();
$page = Page::query()->first(); $page = Page::query()->first();
$book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first(); $book = Book::query()->first();
$editor = $this->getEditor(); $this->actingAs($admin)->delete($page->getUrl());
$this->actingAs($editor)->delete($page->getUrl()); $this->delete($book->getUrl());
$this->actingAs($editor)->delete($book->getUrl());
$deletions = Deletion::query()->orderBy('id')->get(); $deletions = Deletion::query()->orderBy('id')->get();
@ -62,14 +61,17 @@ class RecycleBinApiTest extends TestCase
$expectedData = $deletions $expectedData = $deletions
->zip([$page, $book]) ->zip([$page, $book])
->map(function (Collection $data) use ($editor) { ->map(function (Collection $data) use ($admin) {
return [ return [
'id' => $data[0]->id, 'id' => $data[0]->id,
'deleted_by' => $editor->getKey(), 'deleted_by' => $admin->id,
'created_at' => $data[0]->created_at->toJson(), 'created_at' => $data[0]->created_at->toJson(),
'updated_at' => $data[0]->updated_at->toJson(), 'updated_at' => $data[0]->updated_at->toJson(),
'deletable_type' => $data[1]->getMorphClass(), 'deletable_type' => $data[1]->getMorphClass(),
'deletable_id' => $data[1]->getKey(), 'deletable_id' => $data[1]->id,
'deletable' => [
'name' => $data[1]->name,
],
]; ];
}); });
@ -79,13 +81,12 @@ class RecycleBinApiTest extends TestCase
]); ]);
} }
public function test_index_endpoint_returns_children() public function test_index_endpoint_returns_children_count()
{ {
$this->actingAsAuthorizedUser(); $admin = $this->getAdmin();
$book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first(); $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first();
$editor = $this->getEditor(); $this->actingAs($admin)->delete($book->getUrl());
$this->actingAs($editor)->delete($book->getUrl());
$deletion = Deletion::query()->orderBy('id')->first(); $deletion = Deletion::query()->orderBy('id')->first();
@ -93,17 +94,11 @@ class RecycleBinApiTest extends TestCase
$expectedData = [ $expectedData = [
[ [
'id' => $deletion->getKey(), 'id' => $deletion->id,
'deleted_by' => $editor->getKey(), 'deletable' => [
'created_at' => $deletion->created_at->toJson(), 'pages_count' => $book->pages_count,
'updated_at' => $deletion->updated_at->toJson(), 'chapters_count' => $book->chapters_count,
'deletable_type' => $book->getMorphClass(),
'deletable_id' => $book->getKey(),
'children' => [
'BookStack\Page' => $book->pages_count,
'BookStack\Chapter' => $book->chapters_count,
], ],
'parent' => null,
], ],
]; ];
@ -115,30 +110,24 @@ class RecycleBinApiTest extends TestCase
public function test_index_endpoint_returns_parent() public function test_index_endpoint_returns_parent()
{ {
$this->actingAsAuthorizedUser(); $admin = $this->getAdmin();
$page = Page::query()->whereHas('chapter')->with('chapter')->first(); $page = Page::query()->whereHas('chapter')->with('chapter')->first();
$editor = $this->getEditor(); $this->actingAs($admin)->delete($page->getUrl());
$this->actingAs($editor)->delete($page->getUrl());
$deletion = Deletion::query()->orderBy('id')->first(); $deletion = Deletion::query()->orderBy('id')->first();
$resp = $this->getJson($this->baseEndpoint); $resp = $this->getJson($this->baseEndpoint);
$expectedData = [ $expectedData = [
[ [
'id' => $deletion->getKey(), 'id' => $deletion->id,
'deleted_by' => $editor->getKey(), 'deletable' => [
'created_at' => $deletion->created_at->toJson(), 'parent' => [
'updated_at' => $deletion->updated_at->toJson(), 'id' => $page->chapter->id,
'deletable_type' => $page->getMorphClass(), 'name' => $page->chapter->name,
'deletable_id' => $page->getKey(), 'type' => 'chapter'
'parent' => [ ]
'type' => 'BookStack\Chapter', ]
'id' => $page->chapter->getKey(),
],
'children' => null,
], ],
]; ];
@ -150,53 +139,46 @@ class RecycleBinApiTest extends TestCase
public function test_restore_endpoint() public function test_restore_endpoint()
{ {
$this->actingAsAuthorizedUser();
$page = Page::query()->first(); $page = Page::query()->first();
$editor = $this->getEditor(); $this->asAdmin()->delete($page->getUrl());
$this->actingAs($editor)->delete($page->getUrl());
$page->refresh(); $page->refresh();
$deletion = Deletion::query()->orderBy('id')->first(); $deletion = Deletion::query()->orderBy('id')->first();
$this->assertDatabaseHas('pages', [ $this->assertDatabaseHas('pages', [
'id' => $page->getKey(), 'id' => $page->id,
'deleted_at' => $page->deleted_at, 'deleted_at' => $page->deleted_at,
]); ]);
$this->putJson($this->baseEndpoint . '/' . $deletion->getKey()); $resp = $this->putJson($this->baseEndpoint . '/' . $deletion->id);
$resp->assertJson([
'restore_count' => 1
]);
$this->assertDatabaseHas('pages', [ $this->assertDatabaseHas('pages', [
'id' => $page->getKey(), 'id' => $page->id,
'deleted_at' => null, 'deleted_at' => null,
]); ]);
} }
public function test_destroy_endpoint() public function test_destroy_endpoint()
{ {
$this->actingAsAuthorizedUser();
$page = Page::query()->first(); $page = Page::query()->first();
$editor = $this->getEditor(); $this->asAdmin()->delete($page->getUrl());
$this->actingAs($editor)->delete($page->getUrl());
$page->refresh(); $page->refresh();
$deletion = Deletion::query()->orderBy('id')->first(); $deletion = Deletion::query()->orderBy('id')->first();
$this->assertDatabaseHas('pages', [ $this->assertDatabaseHas('pages', [
'id' => $page->getKey(), 'id' => $page->id,
'deleted_at' => $page->deleted_at, 'deleted_at' => $page->deleted_at,
]); ]);
$this->deleteJson($this->baseEndpoint . '/' . $deletion->getKey()); $resp = $this->deleteJson($this->baseEndpoint . '/' . $deletion->id);
$this->assertDatabaseMissing('pages', ['id' => $page->getKey()]); $resp->assertJson([
} 'delete_count' => 1
]);
private function actingAsAuthorizedUser() $this->assertDatabaseMissing('pages', ['id' => $page->id]);
{
$editor = $this->getEditor();
$this->giveUserPermissions($editor, ['restrictions-manage-all']);
$this->giveUserPermissions($editor, ['settings-manage']);
$this->actingAs($editor);
} }
} }