diff --git a/app/Auth/User.php b/app/Auth/User.php index 69f424cac..35b3cd54f 100644 --- a/app/Auth/User.php +++ b/app/Auth/User.php @@ -47,7 +47,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon * The attributes excluded from the model's JSON form. * @var array */ - protected $hidden = ['password', 'remember_token']; + protected $hidden = ['password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email']; /** * This holds the user's permissions when loaded. diff --git a/app/Entities/Book.php b/app/Entities/Book.php index 4e54457b8..919f60035 100644 --- a/app/Entities/Book.php +++ b/app/Entities/Book.php @@ -18,7 +18,8 @@ class Book extends Entity implements HasCoverImage { public $searchFactor = 2; - protected $fillable = ['name', 'description', 'image_id']; + protected $fillable = ['name', 'description']; + protected $hidden = ['restricted']; /** * Get the url for this book. diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index b3f1fb747..65a5bb99f 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -8,6 +8,8 @@ use Illuminate\Http\JsonResponse; class ApiController extends Controller { + protected $rules = []; + /** * Provide a paginated listing JSON response in a standard format * taking into account any pagination parameters passed by the user. @@ -17,4 +19,12 @@ class ApiController extends Controller $listing = new ListingResponseBuilder($query, request(), $fields); return $listing->toResponse(); } + + /** + * Get the validation rules for this controller. + */ + public function getValdationRules(): array + { + return $this->rules; + } } \ No newline at end of file diff --git a/app/Http/Controllers/Api/BooksApiController.php b/app/Http/Controllers/Api/BooksApiController.php index 3943b773a..e7a0217dc 100644 --- a/app/Http/Controllers/Api/BooksApiController.php +++ b/app/Http/Controllers/Api/BooksApiController.php @@ -1,47 +1,99 @@ <?php namespace BookStack\Http\Controllers\Api; use BookStack\Entities\Book; +use BookStack\Entities\Repos\BookRepo; +use BookStack\Facades\Activity; +use Illuminate\Http\Request; class BooksApiController extends ApiController { - public $validation = [ + + protected $bookRepo; + + protected $rules = [ 'create' => [ - // TODO + 'name' => 'required|string|max:255', + 'description' => 'string|max:1000', ], 'update' => [ - // TODO + 'name' => 'string|min:1|max:255', + 'description' => 'string|max:1000', ], ]; + /** + * BooksApiController constructor. + */ + public function __construct(BookRepo $bookRepo) + { + $this->bookRepo = $bookRepo; + } + /** * Get a listing of books visible to the user. + * @api listing */ public function index() { $books = Book::visible(); return $this->apiListingResponse($books, [ - 'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', - 'restricted', 'image_id', + 'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'image_id', ]); } - public function create() + /** + * Create a new book. + * @throws \Illuminate\Validation\ValidationException + */ + public function create(Request $request) { - // TODO - + $this->checkPermission('book-create-all'); + $requestData = $this->validate($request, $this->rules['create']); + + $book = $this->bookRepo->create($requestData); + Activity::add($book, 'book_create', $book->id); + + return response()->json($book); } - public function read() + /** + * View the details of a single book. + */ + public function read(string $id) { - // TODO - + $book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy'])->findOrFail($id); + return response()->json($book); } - public function update() + /** + * Update the details of a single book. + * @throws \Illuminate\Validation\ValidationException + */ + public function update(Request $request, string $id) { - // TODO - + $book = Book::visible()->findOrFail($id); + $this->checkOwnablePermission('book-update', $book); + + $requestData = $this->validate($request, $this->rules['update']); + $book = $this->bookRepo->update($book, $requestData); + Activity::add($book, 'book_update', $book->id); + + return response()->json($book); } - public function delete() + /** + * Delete a book from the system. + * @throws \BookStack\Exceptions\NotifyException + * @throws \Illuminate\Contracts\Container\BindingResolutionException + */ + public function delete(string $id) { - // TODO - + $book = Book::visible()->findOrFail($id); + $this->checkOwnablePermission('book-delete', $book); + + $this->bookRepo->destroy($book); + Activity::addMessage('book_delete', $book->name); + + return response('', 204); } } \ No newline at end of file diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php index 6fa5db2a5..c76979d7c 100644 --- a/app/Uploads/Image.php +++ b/app/Uploads/Image.php @@ -8,6 +8,7 @@ class Image extends Ownable { protected $fillable = ['name']; + protected $hidden = []; /** * Get a thumbnail for this image. diff --git a/dev/api/responses/books-create.json b/dev/api/responses/books-create.json new file mode 100644 index 000000000..0b4336ab2 --- /dev/null +++ b/dev/api/responses/books-create.json @@ -0,0 +1,10 @@ +{ + "name": "My new book", + "description": "This is a book created via the API", + "created_by": 1, + "updated_by": 1, + "slug": "my-new-book", + "updated_at": "2020-01-12 14:05:11", + "created_at": "2020-01-12 14:05:11", + "id": 15 +} \ No newline at end of file diff --git a/dev/api/responses/books-index.json b/dev/api/responses/books-index.json new file mode 100644 index 000000000..29e83b1c0 --- /dev/null +++ b/dev/api/responses/books-index.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "id": 1, + "name": "BookStack User Guide", + "slug": "bookstack-user-guide", + "description": "This is a general guide on using BookStack on a day-to-day basis.", + "created_at": "2019-05-05 21:48:46", + "updated_at": "2019-12-11 20:57:31", + "created_by": 1, + "updated_by": 1, + "image_id": 3 + }, + { + "id": 2, + "name": "Inventore inventore quia voluptatem.", + "slug": "inventore-inventore-quia-voluptatem", + "description": "Veniam nihil voluptas enim laborum corporis quos sint. Ab rerum voluptas ut iste voluptas magni quibusdam ut. Amet omnis enim voluptate neque facilis.", + "created_at": "2019-05-05 22:10:14", + "updated_at": "2019-12-11 20:57:23", + "created_by": 4, + "updated_by": 3, + "image_id": 34 + } + ], + "total": 14 +} \ No newline at end of file diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json new file mode 100644 index 000000000..e0570444f --- /dev/null +++ b/dev/api/responses/books-read.json @@ -0,0 +1,47 @@ +{ + "id": 16, + "name": "My own book", + "slug": "my-own-book", + "description": "This is my own little book", + "created_at": "2020-01-12 14:09:59", + "updated_at": "2020-01-12 14:11:51", + "created_by": { + "id": 1, + "name": "Admin", + "created_at": "2019-05-05 21:15:13", + "updated_at": "2019-12-16 12:18:37", + "image_id": 48 + }, + "updated_by": { + "id": 1, + "name": "Admin", + "created_at": "2019-05-05 21:15:13", + "updated_at": "2019-12-16 12:18:37", + "image_id": 48 + }, + "image_id": 452, + "tags": [ + { + "id": 13, + "entity_id": 16, + "entity_type": "BookStack\\Book", + "name": "Category", + "value": "Guide", + "order": 0, + "created_at": "2020-01-12 14:11:51", + "updated_at": "2020-01-12 14:11:51" + } + ], + "cover": { + "id": 452, + "name": "sjovall_m117hUWMu40.jpg", + "url": "http:\/\/bookstack.local\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg", + "created_at": "2020-01-12 14:11:51", + "updated_at": "2020-01-12 14:11:51", + "created_by": 1, + "updated_by": 1, + "path": "\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg", + "type": "cover_book", + "uploaded_to": 16 + } +} \ No newline at end of file diff --git a/dev/api/responses/books-update.json b/dev/api/responses/books-update.json new file mode 100644 index 000000000..8f20b5b9f --- /dev/null +++ b/dev/api/responses/books-update.json @@ -0,0 +1,11 @@ +{ + "id": 16, + "name": "My own book", + "slug": "my-own-book", + "description": "This is my own little book - updated", + "created_at": "2020-01-12 14:09:59", + "updated_at": "2020-01-12 14:16:10", + "created_by": 1, + "updated_by": 1, + "image_id": 452 +} \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 0604ffd29..3348d8907 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,11 +2,12 @@ /** * Routes for the BookStack API. - * * Routes have a uri prefix of /api/. + * Controllers are all within app/Http/Controllers/Api */ - -// TODO - Authenticate middleware - -Route::get('books', 'BooksApiController@index'); \ No newline at end of file +Route::get('books', 'BooksApiController@index'); +Route::post('books', 'BooksApiController@create'); +Route::get('books/{id}', 'BooksApiController@read'); +Route::put('books/{id}', 'BooksApiController@update'); +Route::delete('books/{id}', 'BooksApiController@delete'); diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php new file mode 100644 index 000000000..f560bfffd --- /dev/null +++ b/tests/Api/BooksApiTest.php @@ -0,0 +1,87 @@ +<?php namespace Tests; + +use BookStack\Entities\Book; + +class ApiAuthTest extends TestCase +{ + use TestsApi; + + protected $baseEndpoint = '/api/books'; + + public function test_index_endpoint_returns_expected_book() + { + $this->actingAsApiEditor(); + $firstBook = Book::query()->orderBy('id', 'asc')->first(); + + $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); + $resp->assertJson(['data' => [ + [ + 'id' => $firstBook->id, + 'name' => $firstBook->name, + 'slug' => $firstBook->slug, + ] + ]]); + } + + public function test_create_endpoint() + { + $this->actingAsApiEditor(); + $details = [ + 'name' => 'My API book', + 'description' => 'A book created via the API', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(200); + $newItem = Book::query()->orderByDesc('id')->where('name', '=', $details['name'])->first(); + $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug])); + $this->assertActivityExists('book_create', $newItem); + } + + public function test_read_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->first(); + + $resp = $this->getJson($this->baseEndpoint . "/{$book->id}"); + + $resp->assertStatus(200); + $resp->assertJson([ + 'id' => $book->id, + 'slug' => $book->slug, + 'created_by' => [ + 'name' => $book->createdBy->name, + ], + 'updated_by' => [ + 'name' => $book->createdBy->name, + ] + ]); + } + + public function test_update_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->first(); + $details = [ + 'name' => 'My updated API book', + 'description' => 'A book created via the API', + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$book->id}", $details); + $book->refresh(); + + $resp->assertStatus(200); + $resp->assertJson(array_merge($details, ['id' => $book->id, 'slug' => $book->slug])); + $this->assertActivityExists('book_update', $book); + } + + public function test_delete_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->first(); + $resp = $this->deleteJson($this->baseEndpoint . "/{$book->id}"); + + $resp->assertStatus(204); + $this->assertActivityExists('book_delete'); + } +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index 939a1a91e..f20b20fd8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,5 +1,6 @@ <?php namespace Tests; +use BookStack\Entities\Entity; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; @@ -60,4 +61,20 @@ abstract class TestCase extends BaseTestCase { return TestResponse::fromBaseResponse($response); } + + /** + * Assert that an activity entry exists of the given key. + * Checks the activity belongs to the given entity if provided. + */ + protected function assertActivityExists(string $key, Entity $entity = null) + { + $detailsToCheck = ['key' => $key]; + + if ($entity) { + $detailsToCheck['entity_type'] = $entity->getMorphClass(); + $detailsToCheck['entity_id'] = $entity->id; + } + + $this->assertDatabaseHas('activities', $detailsToCheck); + } } \ No newline at end of file