Added contents to book-show endpoint

Created a generic list formatting helper class for this, to align with
logic used on the search results endpoint and for easier future re-use
in a standardised way.
Also updated some class property types.
Added test to cover new books-contents results.
Related to #3734
This commit is contained in:
Dan Brown 2022-09-29 15:05:57 +01:00
parent ccbc68b560
commit 0e94fd44a8
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
7 changed files with 212 additions and 33 deletions

View File

@ -0,0 +1,107 @@
<?php
namespace BookStack\Api;
use BookStack\Entities\Models\Entity;
class ApiEntityListFormatter
{
/**
* The list to be formatted.
* @var Entity[]
*/
protected $list = [];
/**
* The fields to show in the formatted data.
* Can be a plain string array item for a direct model field (If existing on model).
* If the key is a string, with a callable value, the return value of the callable
* will be used for the resultant value. A null return value will omit the property.
* @var array<string|int, string|callable>
*/
protected $fields = [
'id', 'name', 'slug', 'book_id', 'chapter_id',
'draft', 'template', 'created_at', 'updated_at',
];
public function __construct(array $list)
{
$this->list = $list;
// Default dynamic fields
$this->withField('url', fn(Entity $entity) => $entity->getUrl());
}
/**
* Add a field to be used in the formatter, with the property using the given
* name and value being the return type of the given callback.
*/
public function withField(string $property, callable $callback): self
{
$this->fields[$property] = $callback;
return $this;
}
/**
* Show the 'type' property in the response reflecting the entity type.
* EG: page, chapter, bookshelf, book
* To be included in results with non-pre-determined types.
*/
public function withType(): self
{
$this->withField('type', fn(Entity $entity) => $entity->getType());
return $this;
}
/**
* Include tags in the formatted data.
*/
public function withTags(): self
{
$this->withField('tags', fn(Entity $entity) => $entity->tags);
return $this;
}
/**
* Format the data and return an array of formatted content.
* @return array[]
*/
public function format(): array
{
$results = [];
foreach ($this->list as $item) {
$results[] = $this->formatSingle($item);
}
return $results;
}
/**
* Format a single entity item to a plain array.
*/
protected function formatSingle(Entity $entity): array
{
$result = [];
$values = (clone $entity)->toArray();
foreach ($this->fields as $field => $callback) {
if (is_string($callback)) {
$field = $callback;
if (!isset($values[$field])) {
continue;
}
$value = $values[$field];
} else {
$value = $callback($entity);
if (is_null($value)) {
continue;
}
}
$result[$field] = $value;
}
return $result;
}
}

View File

@ -11,22 +11,15 @@ use Illuminate\Support\Collection;
class BookContents class BookContents
{ {
/** protected Book $book;
* @var Book
*/
protected $book;
/**
* BookContents constructor.
*/
public function __construct(Book $book) public function __construct(Book $book)
{ {
$this->book = $book; $this->book = $book;
} }
/** /**
* Get the current priority of the last item * Get the current priority of the last item at the top-level of the book.
* at the top-level of the book.
*/ */
public function getLastPriority(): int public function getLastPriority(): int
{ {

View File

@ -2,14 +2,18 @@
namespace BookStack\Http\Controllers\Api; namespace BookStack\Http\Controllers\Api;
use BookStack\Api\ApiEntityListFormatter;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class BookApiController extends ApiController class BookApiController extends ApiController
{ {
protected $bookRepo; protected BookRepo $bookRepo;
public function __construct(BookRepo $bookRepo) public function __construct(BookRepo $bookRepo)
{ {
@ -47,11 +51,25 @@ class BookApiController extends ApiController
/** /**
* View the details of a single book. * View the details of a single book.
* 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.
*/ */
public function read(string $id) public function read(string $id)
{ {
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id); $book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
$contents = (new BookContents($book))->getTree(true, false)->all();
$contentsApiData = (new ApiEntityListFormatter($contents))
->withType()
->withField('pages', function (Entity $entity) {
if ($entity instanceof Chapter) {
return (new ApiEntityListFormatter($entity->pages->all()))->format();
}
return null;
})->format();
$book->setAttribute('contents', $contentsApiData);
return response()->json($book); return response()->json($book);
} }

View File

@ -13,9 +13,6 @@ class BookshelfApiController extends ApiController
{ {
protected BookshelfRepo $bookshelfRepo; protected BookshelfRepo $bookshelfRepo;
/**
* BookshelfApiController constructor.
*/
public function __construct(BookshelfRepo $bookshelfRepo) public function __construct(BookshelfRepo $bookshelfRepo)
{ {
$this->bookshelfRepo = $bookshelfRepo; $this->bookshelfRepo = $bookshelfRepo;

View File

@ -2,6 +2,7 @@
namespace BookStack\Http\Controllers\Api; namespace BookStack\Http\Controllers\Api;
use BookStack\Api\ApiEntityListFormatter;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Search\SearchOptions; use BookStack\Search\SearchOptions;
use BookStack\Search\SearchResultsFormatter; use BookStack\Search\SearchResultsFormatter;
@ -10,8 +11,8 @@ use Illuminate\Http\Request;
class SearchApiController extends ApiController class SearchApiController extends ApiController
{ {
protected $searchRunner; protected SearchRunner $searchRunner;
protected $resultsFormatter; protected SearchResultsFormatter $resultsFormatter;
protected $rules = [ protected $rules = [
'all' => [ 'all' => [
@ -50,24 +51,17 @@ class SearchApiController extends ApiController
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count); $results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
$this->resultsFormatter->format($results['results']->all(), $options); $this->resultsFormatter->format($results['results']->all(), $options);
/** @var Entity $result */ $data = (new ApiEntityListFormatter($results['results']->all()))
foreach ($results['results'] as $result) { ->withType()->withTags()
$result->setVisible([ ->withField('preview_html', function (Entity $entity) {
'id', 'name', 'slug', 'book_id', return [
'chapter_id', 'draft', 'template', 'name' => (string) $entity->getAttribute('preview_name'),
'created_at', 'updated_at', 'content' => (string) $entity->getAttribute('preview_content'),
'tags', 'type', 'preview_html', 'url', ];
]); })->format();
$result->setAttribute('type', $result->getType());
$result->setAttribute('url', $result->getUrl());
$result->setAttribute('preview_html', [
'name' => (string) $result->getAttribute('preview_name'),
'content' => (string) $result->getAttribute('preview_content'),
]);
}
return response()->json([ return response()->json([
'data' => $results['results'], 'data' => $data,
'total' => $results['total'], 'total' => $results['total'],
]); ]);
} }

View File

@ -17,6 +17,44 @@
"id": 1, "id": 1,
"name": "Admin" "name": "Admin"
}, },
"contents": [
{
"id": 50,
"name": "Bridge Structures",
"slug": "bridge-structures",
"book_id": 16,
"created_at": "2021-12-19T15:22:11.000000Z",
"updated_at": "2021-12-21T19:42:29.000000Z",
"url": "https://example.com/books/my-own-book/chapter/bridge-structures",
"type": "chapter",
"pages": [
{
"id": 42,
"name": "Building Bridges",
"slug": "building-bridges",
"book_id": 16,
"chapter_id": 50,
"draft": false,
"template": false,
"created_at": "2021-12-19T15:22:11.000000Z",
"updated_at": "2022-09-29T13:44:15.000000Z",
"url": "https://example.com/books/my-own-book/page/building-bridges"
}
]
},
{
"id": 43,
"name": "Cool Animals",
"slug": "cool-animals",
"book_id": 16,
"chapter_id": 0,
"draft": false,
"template": false,
"created_at": "2021-12-19T18:22:11.000000Z",
"updated_at": "2022-07-29T13:44:15.000000Z",
"url": "https://example.com/books/my-own-book/page/cool-animals"
}
],
"tags": [ "tags": [
{ {
"id": 13, "id": 13,
@ -28,12 +66,12 @@
"cover": { "cover": {
"id": 452, "id": 452,
"name": "sjovall_m117hUWMu40.jpg", "name": "sjovall_m117hUWMu40.jpg",
"url": "http:\/\/bookstack.local\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg", "url": "https://example.com/uploads/images/cover_book/2020-01/sjovall_m117hUWMu40.jpg",
"created_at": "2020-01-12T14:11:51.000000Z", "created_at": "2020-01-12T14:11:51.000000Z",
"updated_at": "2020-01-12T14:11:51.000000Z", "updated_at": "2020-01-12T14:11:51.000000Z",
"created_by": 1, "created_by": 1,
"updated_by": 1, "updated_by": 1,
"path": "\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg", "path": "/uploads/images/cover_book/2020-01/sjovall_m117hUWMu40.jpg",
"type": "cover_book", "type": "cover_book",
"uploaded_to": 16 "uploaded_to": 16
} }

View File

@ -88,6 +88,38 @@ class BooksApiTest extends TestCase
]); ]);
} }
public function test_read_endpoint_includes_chapter_and_page_contents()
{
$this->actingAsApiEditor();
/** @var Book $book */
$book = Book::visible()->has('chapters')->has('pages')->first();
$chapter = $book->chapters()->first();
$chapterPage = $chapter->pages()->first();
$resp = $this->getJson($this->baseEndpoint . "/{$book->id}");
$directChildCount = $book->directPages()->count() + $book->chapters()->count();
$resp->assertStatus(200);
$resp->assertJsonCount($directChildCount, 'contents');
$resp->assertJson([
'contents' => [
[
'type' => 'chapter',
'id' => $chapter->id,
'name' => $chapter->name,
'slug' => $chapter->slug,
'pages' => [
[
'id' => $chapterPage->id,
'name' => $chapterPage->name,
'slug' => $chapterPage->slug,
]
]
]
]
]);
}
public function test_update_endpoint() public function test_update_endpoint()
{ {
$this->actingAsApiEditor(); $this->actingAsApiEditor();