Added chapters to the API

This commit is contained in:
Dan Brown 2020-05-23 00:28:41 +01:00
parent 24bad5034a
commit 8a6cf0cdec
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
24 changed files with 575 additions and 31 deletions

View File

@ -9,6 +9,7 @@ use BookStack\Model;
class Tag extends Model
{
protected $fillable = ['name', 'value', 'order'];
protected $hidden = ['id', 'entity_id', 'entity_type'];
/**
* Get the entity that this tag belongs to

View File

@ -106,14 +106,13 @@ class TagRepo
/**
* Save an array of tags to an entity
* @param \BookStack\Entities\Entity $entity
* @param array $tags
* @return array|\Illuminate\Database\Eloquent\Collection
*/
public function saveTagsToEntity(Entity $entity, $tags = [])
public function saveTagsToEntity(Entity $entity, array $tags = [])
{
$entity->tags()->delete();
$newTags = [];
foreach ($tags as $tag) {
if (trim($tag['name']) === '') {
continue;

View File

@ -49,7 +49,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected $hidden = [
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
'created_at', 'updated_at',
'created_at', 'updated_at', 'image_id',
];
/**

View File

@ -19,7 +19,7 @@ class Book extends Entity implements HasCoverImage
public $searchFactor = 2;
protected $fillable = ['name', 'description'];
protected $hidden = ['restricted', 'pivot'];
protected $hidden = ['restricted', 'pivot', 'image_id'];
/**
* Get the url for this book.

View File

@ -12,7 +12,7 @@ class Bookshelf extends Entity implements HasCoverImage
protected $fillable = ['name', 'description', 'image_id'];
protected $hidden = ['restricted'];
protected $hidden = ['restricted', 'image_id'];
/**
* Get the books in this shelf.

View File

@ -12,6 +12,7 @@ class Chapter extends BookChild
public $searchFactor = 1.3;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
protected $hidden = ['restricted', 'pivot'];
/**
* Get the pages that this chapter contains.

View File

@ -288,7 +288,7 @@ class Entity extends Ownable
public function rebuildPermissions()
{
/** @noinspection PhpUnhandledExceptionInspection */
Permissions::buildJointPermissionsForEntity($this);
Permissions::buildJointPermissionsForEntity(clone $this);
}
/**
@ -297,7 +297,7 @@ class Entity extends Ownable
public function indexForSearch()
{
$searchService = app()->make(SearchService::class);
$searchService->indexEntity($this);
$searchService->indexEntity(clone $this);
}
/**

View File

@ -27,6 +27,8 @@ class Page extends BookChild
public $textField = 'text';
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot'];
/**
* Get the entities that are visible to the current user.
*/

View File

@ -211,7 +211,7 @@ class PageRepo
*/
protected function savePageRevision(Page $page, string $summary = null)
{
$revision = new PageRevision($page->toArray());
$revision = new PageRevision($page->getAttributes());
if (setting('app-editor') !== 'markdown') {
$revision->markdown = '';

View File

@ -8,7 +8,7 @@ use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class BooksApiController extends ApiController
class BookApiController extends ApiController
{
protected $bookRepo;
@ -17,10 +17,12 @@ class BooksApiController extends ApiController
'create' => [
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
],
'update' => [
'name' => 'string|min:1|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
],
];

View File

@ -5,9 +5,8 @@ use BookStack\Entities\ExportService;
use BookStack\Entities\Repos\BookRepo;
use Throwable;
class BooksExportApiController extends ApiController
class BookExportApiController extends ApiController
{
protected $bookRepo;
protected $exportService;

View File

@ -0,0 +1,104 @@
<?php namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Book;
use BookStack\Entities\Chapter;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Facades\Activity;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Request;
class ChapterApiController extends ApiController
{
protected $chapterRepo;
protected $rules = [
'create' => [
'book_id' => 'required|integer',
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
],
'update' => [
'book_id' => 'integer',
'name' => 'string|min:1|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
],
];
/**
* ChapterController constructor.
*/
public function __construct(ChapterRepo $chapterRepo)
{
$this->chapterRepo = $chapterRepo;
}
/**
* Get a listing of chapters visible to the user.
*/
public function list()
{
$chapters = Chapter::visible();
return $this->apiListingResponse($chapters, [
'id', 'book_id', 'name', 'slug', 'description', 'priority',
'created_at', 'updated_at', 'created_by', 'updated_by',
]);
}
/**
* Create a new chapter in the system.
*/
public function create(Request $request)
{
$this->validate($request, $this->rules['create']);
$bookId = $request->get('book_id');
$book = Book::visible()->findOrFail($bookId);
$this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($request->all(), $book);
Activity::add($chapter, 'chapter_create', $book->id);
return response()->json($chapter->load(['tags']));
}
/**
* View the details of a single chapter.
*/
public function read(string $id)
{
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'pages' => function (HasMany $query) {
$query->visible()->get(['id', 'name', 'slug']);
}])->findOrFail($id);
return response()->json($chapter);
}
/**
* Update the details of a single chapter.
*/
public function update(Request $request, string $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$this->checkOwnablePermission('chapter-update', $chapter);
$updatedChapter = $this->chapterRepo->update($chapter, $request->all());
Activity::add($chapter, 'chapter_update', $chapter->book->id);
return response()->json($updatedChapter->load(['tags']));
}
/**
* Delete a chapter from the system.
*/
public function delete(string $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->chapterRepo->destroy($chapter);
Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
return response('', 204);
}
}

View File

@ -0,0 +1,54 @@
<?php namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Chapter;
use BookStack\Entities\ExportService;
use BookStack\Entities\Repos\BookRepo;
use Throwable;
class ChapterExportApiController extends ApiController
{
protected $chapterRepo;
protected $exportService;
/**
* ChapterExportController constructor.
*/
public function __construct(BookRepo $chapterRepo, ExportService $exportService)
{
$this->chapterRepo = $chapterRepo;
$this->exportService = $exportService;
parent::__construct();
}
/**
* Export a chapter as a PDF file.
* @throws Throwable
*/
public function exportPdf(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$pdfContent = $this->exportService->chapterToPdf($chapter);
return $this->downloadResponse($pdfContent, $chapter->slug . '.pdf');
}
/**
* Export a chapter as a contained HTML file.
* @throws Throwable
*/
public function exportHtml(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$htmlContent = $this->exportService->chapterToContainedHtml($chapter);
return $this->downloadResponse($htmlContent, $chapter->slug . '.html');
}
/**
* Export a chapter as a plain text file.
*/
public function exportPlainText(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$textContent = $this->exportService->chapterToPlainText($chapter);
return $this->downloadResponse($textContent, $chapter->slug . '.txt');
}
}

View File

@ -0,0 +1,9 @@
{
"book_id": 1,
"name": "My fantastic new chapter",
"description": "This is a great new chapter that I've created via the API",
"tags": [
{"name": "Category", "value": "Top Content"},
{"name": "Rating", "value": "Highest"}
]
}

View File

@ -0,0 +1,9 @@
{
"book_id": 1,
"name": "My fantastic updated chapter",
"description": "This is an updated chapter that I've altered via the API",
"tags": [
{"name": "Category", "value": "Kinda Good Content"},
{"name": "Rating", "value": "Medium"}
]
}

View File

@ -7,15 +7,12 @@
"updated_at": "2020-01-12 14:11:51",
"created_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"updated_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"image_id": 452,
"tags": [
{
"id": 13,

View File

@ -0,0 +1,38 @@
{
"book_id": 1,
"priority": 6,
"name": "My fantastic new chapter",
"description": "This is a great new chapter that I've created via the API",
"created_by": 1,
"updated_by": 1,
"slug": "my-fantastic-new-chapter",
"updated_at": "2020-05-22 22:59:55",
"created_at": "2020-05-22 22:59:55",
"id": 74,
"book": {
"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
},
"tags": [
{
"name": "Category",
"value": "Top Content",
"order": 0,
"created_at": "2020-05-22 22:59:55",
"updated_at": "2020-05-22 22:59:55"
},
{
"name": "Rating",
"value": "Highest",
"order": 0,
"created_at": "2020-05-22 22:59:55",
"updated_at": "2020-05-22 22:59:55"
}
]
}

View File

@ -0,0 +1,29 @@
{
"data": [
{
"id": 1,
"book_id": 1,
"name": "Content Creation",
"slug": "content-creation",
"description": "How to create documentation on whatever subject you need to write about.",
"priority": 3,
"created_at": "2019-05-05 21:49:56",
"updated_at": "2019-09-28 11:24:23",
"created_by": 1,
"updated_by": 1
},
{
"id": 2,
"book_id": 1,
"name": "Managing Content",
"slug": "managing-content",
"description": "How to keep things organised and orderly in the system for easier navigation and better user experience.",
"priority": 5,
"created_at": "2019-05-05 21:58:07",
"updated_at": "2019-10-17 15:05:34",
"created_by": 3,
"updated_by": 3
}
],
"total": 40
}

View File

@ -0,0 +1,59 @@
{
"id": 1,
"book_id": 1,
"slug": "content-creation",
"name": "Content Creation",
"description": "How to create documentation on whatever subject you need to write about.",
"priority": 3,
"created_at": "2019-05-05 21:49:56",
"updated_at": "2019-09-28 11:24:23",
"created_by": {
"id": 1,
"name": "Admin"
},
"updated_by": {
"id": 1,
"name": "Admin"
},
"tags": [
{
"name": "Category",
"value": "Guide",
"order": 0,
"created_at": "2020-05-22 22:51:51",
"updated_at": "2020-05-22 22:51:51"
}
],
"pages": [
{
"id": 1,
"book_id": 1,
"chapter_id": 1,
"name": "How to create page content",
"slug": "how-to-create-page-content",
"priority": 0,
"created_at": "2019-05-05 21:49:58",
"updated_at": "2019-08-26 14:32:59",
"created_by": 1,
"updated_by": 1,
"draft": 0,
"revision_count": 2,
"template": 0
},
{
"id": 7,
"book_id": 1,
"chapter_id": 1,
"name": "Good book structure",
"slug": "good-book-structure",
"priority": 1,
"created_at": "2019-05-05 22:01:55",
"updated_at": "2019-06-06 12:03:04",
"created_by": 3,
"updated_by": 3,
"draft": 0,
"revision_count": 1,
"template": 0
}
]
}

View File

@ -0,0 +1,38 @@
{
"id": 75,
"book_id": 1,
"slug": "my-fantastic-updated-chapter",
"name": "My fantastic updated chapter",
"description": "This is an updated chapter that I've altered via the API",
"priority": 7,
"created_at": "2020-05-22 23:03:35",
"updated_at": "2020-05-22 23:07:20",
"created_by": 1,
"updated_by": 1,
"book": {
"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
},
"tags": [
{
"name": "Category",
"value": "Kinda Good Content",
"order": 0,
"created_at": "2020-05-22 23:07:20",
"updated_at": "2020-05-22 23:07:20"
},
{
"name": "Rating",
"value": "Medium",
"order": 0,
"created_at": "2020-05-22 23:07:20",
"updated_at": "2020-05-22 23:07:20"
}
]
}

View File

@ -5,15 +5,12 @@
"description": "This is my shelf with some books",
"created_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"updated_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"image_id": 501,
"created_at": "2020-04-10 13:24:09",
"updated_at": "2020-04-10 13:31:04",
"tags": [

View File

@ -9,18 +9,28 @@
Route::get('docs', 'ApiDocsController@display');
Route::get('docs.json', 'ApiDocsController@json');
Route::get('books', 'BooksApiController@list');
Route::post('books', 'BooksApiController@create');
Route::get('books/{id}', 'BooksApiController@read');
Route::put('books/{id}', 'BooksApiController@update');
Route::delete('books/{id}', 'BooksApiController@delete');
Route::get('books', 'BookApiController@list');
Route::post('books', 'BookApiController@create');
Route::get('books/{id}', 'BookApiController@read');
Route::put('books/{id}', 'BookApiController@update');
Route::delete('books/{id}', 'BookApiController@delete');
Route::get('books/{id}/export/html', 'BooksExportApiController@exportHtml');
Route::get('books/{id}/export/pdf', 'BooksExportApiController@exportPdf');
Route::get('books/{id}/export/plaintext', 'BooksExportApiController@exportPlainText');
Route::get('books/{id}/export/html', 'BookExportApiController@exportHtml');
Route::get('books/{id}/export/pdf', 'BookExportApiController@exportPdf');
Route::get('books/{id}/export/plaintext', 'BookExportApiController@exportPlainText');
Route::get('chapters', 'ChapterApiController@list');
Route::post('chapters', 'ChapterApiController@create');
Route::get('chapters/{id}', 'ChapterApiController@read');
Route::put('chapters/{id}', 'ChapterApiController@update');
Route::delete('chapters/{id}', 'ChapterApiController@delete');
Route::get('chapters/{id}/export/html', 'ChapterExportApiController@exportHtml');
Route::get('chapters/{id}/export/pdf', 'ChapterExportApiController@exportPdf');
Route::get('chapters/{id}/export/plaintext', 'ChapterExportApiController@exportPlainText');
Route::get('shelves', 'BookshelfApiController@list');
Route::post('shelves', 'BookshelfApiController@create');
Route::get('shelves/{id}', 'BookshelfApiController@read');
Route::put('shelves/{id}', 'BookshelfApiController@update');
Route::delete('shelves/{id}', 'BookshelfApiController@delete');
Route::delete('shelves/{id}', 'BookshelfApiController@delete');

View File

@ -0,0 +1,186 @@
<?php namespace Tests\Api;
use BookStack\Entities\Book;
use BookStack\Entities\Chapter;
use Tests\TestCase;
class ChaptersApiTest extends TestCase
{
use TestsApi;
protected $baseEndpoint = '/api/chapters';
public function test_index_endpoint_returns_expected_chapter()
{
$this->actingAsApiEditor();
$firstChapter = Chapter::query()->orderBy('id', 'asc')->first();
$resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
$resp->assertJson(['data' => [
[
'id' => $firstChapter->id,
'name' => $firstChapter->name,
'slug' => $firstChapter->slug,
'book_id' => $firstChapter->book->id,
'priority' => $firstChapter->priority,
]
]]);
}
public function test_create_endpoint()
{
$this->actingAsApiEditor();
$book = Book::query()->first();
$details = [
'name' => 'My API chapter',
'description' => 'A chapter created via the API',
'book_id' => $book->id,
'tags' => [
[
'name' => 'tagname',
'value' => 'tagvalue',
]
]
];
$resp = $this->postJson($this->baseEndpoint, $details);
$resp->assertStatus(200);
$newItem = Chapter::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
$resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
$this->assertDatabaseHas('tags', [
'entity_id' => $newItem->id,
'entity_type' => $newItem->getMorphClass(),
'name' => 'tagname',
'value' => 'tagvalue',
]);
$resp->assertJsonMissing(['pages' => []]);
$this->assertActivityExists('chapter_create', $newItem);
}
public function test_chapter_name_needed_to_create()
{
$this->actingAsApiEditor();
$book = Book::query()->first();
$details = [
'book_id' => $book->id,
'description' => 'A chapter created via the API',
];
$resp = $this->postJson($this->baseEndpoint, $details);
$resp->assertStatus(422);
$resp->assertJson($this->validationResponse([
"name" => ["The name field is required."]
]));
}
public function test_chapter_book_id_needed_to_create()
{
$this->actingAsApiEditor();
$details = [
'name' => 'My api chapter',
'description' => 'A chapter created via the API',
];
$resp = $this->postJson($this->baseEndpoint, $details);
$resp->assertStatus(422);
$resp->assertJson($this->validationResponse([
"book_id" => ["The book id field is required."]
]));
}
public function test_read_endpoint()
{
$this->actingAsApiEditor();
$chapter = Chapter::visible()->first();
$page = $chapter->pages()->first();
$resp = $this->getJson($this->baseEndpoint . "/{$chapter->id}");
$resp->assertStatus(200);
$resp->assertJson([
'id' => $chapter->id,
'slug' => $chapter->slug,
'created_by' => [
'name' => $chapter->createdBy->name,
],
'book_id' => $chapter->book_id,
'updated_by' => [
'name' => $chapter->createdBy->name,
],
'pages' => [
[
'id' => $page->id,
'slug' => $page->slug,
'name' => $page->name,
]
],
]);
$resp->assertJsonCount($chapter->pages()->count(), 'pages');
}
public function test_update_endpoint()
{
$this->actingAsApiEditor();
$chapter = Chapter::visible()->first();
$details = [
'name' => 'My updated API chapter',
'description' => 'A chapter created via the API',
'tags' => [
[
'name' => 'freshtag',
'value' => 'freshtagval',
]
],
];
$resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details);
$chapter->refresh();
$resp->assertStatus(200);
$resp->assertJson(array_merge($details, [
'id' => $chapter->id, 'slug' => $chapter->slug, 'book_id' => $chapter->book_id
]));
$this->assertActivityExists('chapter_update', $chapter);
}
public function test_delete_endpoint()
{
$this->actingAsApiEditor();
$chapter = Chapter::visible()->first();
$resp = $this->deleteJson($this->baseEndpoint . "/{$chapter->id}");
$resp->assertStatus(204);
$this->assertActivityExists('chapter_delete');
}
public function test_export_html_endpoint()
{
$this->actingAsApiEditor();
$chapter = Chapter::visible()->first();
$resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/html");
$resp->assertStatus(200);
$resp->assertSee($chapter->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
}
public function test_export_plain_text_endpoint()
{
$this->actingAsApiEditor();
$chapter = Chapter::visible()->first();
$resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/plaintext");
$resp->assertStatus(200);
$resp->assertSee($chapter->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
}
public function test_export_pdf_endpoint()
{
$this->actingAsApiEditor();
$chapter = Chapter::visible()->first();
$resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/pdf");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
}
}

View File

@ -23,6 +23,16 @@ trait TestsApi
return ["error" => ["code" => $code, "message" => $message]];
}
/**
* Format the given (field_name => ["messages"]) array
* into a standard validation response format.
*/
protected function validationResponse(array $messages): array
{
$err = $this->errorResponse("The given data was invalid.", 422);
$err['error']['validation'] = $messages;
return $err;
}
/**
* Get an approved API auth header.
*/