mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-30 07:32:39 +01:00
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:
parent
ccbc68b560
commit
0e94fd44a8
107
app/Api/ApiEntityListFormatter.php
Normal file
107
app/Api/ApiEntityListFormatter.php
Normal 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;
|
||||
}
|
||||
}
|
@ -11,22 +11,15 @@ use Illuminate\Support\Collection;
|
||||
|
||||
class BookContents
|
||||
{
|
||||
/**
|
||||
* @var Book
|
||||
*/
|
||||
protected $book;
|
||||
protected Book $book;
|
||||
|
||||
/**
|
||||
* BookContents constructor.
|
||||
*/
|
||||
public function __construct(Book $book)
|
||||
{
|
||||
$this->book = $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current priority of the last item
|
||||
* at the top-level of the book.
|
||||
* Get the current priority of the last item at the top-level of the book.
|
||||
*/
|
||||
public function getLastPriority(): int
|
||||
{
|
||||
|
@ -2,14 +2,18 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Api\ApiEntityListFormatter;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookApiController extends ApiController
|
||||
{
|
||||
protected $bookRepo;
|
||||
protected BookRepo $bookRepo;
|
||||
|
||||
public function __construct(BookRepo $bookRepo)
|
||||
{
|
||||
@ -47,11 +51,25 @@ class BookApiController extends ApiController
|
||||
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
|
@ -13,9 +13,6 @@ class BookshelfApiController extends ApiController
|
||||
{
|
||||
protected BookshelfRepo $bookshelfRepo;
|
||||
|
||||
/**
|
||||
* BookshelfApiController constructor.
|
||||
*/
|
||||
public function __construct(BookshelfRepo $bookshelfRepo)
|
||||
{
|
||||
$this->bookshelfRepo = $bookshelfRepo;
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Api\ApiEntityListFormatter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Search\SearchOptions;
|
||||
use BookStack\Search\SearchResultsFormatter;
|
||||
@ -10,8 +11,8 @@ use Illuminate\Http\Request;
|
||||
|
||||
class SearchApiController extends ApiController
|
||||
{
|
||||
protected $searchRunner;
|
||||
protected $resultsFormatter;
|
||||
protected SearchRunner $searchRunner;
|
||||
protected SearchResultsFormatter $resultsFormatter;
|
||||
|
||||
protected $rules = [
|
||||
'all' => [
|
||||
@ -50,24 +51,17 @@ class SearchApiController extends ApiController
|
||||
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
|
||||
$this->resultsFormatter->format($results['results']->all(), $options);
|
||||
|
||||
/** @var Entity $result */
|
||||
foreach ($results['results'] as $result) {
|
||||
$result->setVisible([
|
||||
'id', 'name', 'slug', 'book_id',
|
||||
'chapter_id', 'draft', 'template',
|
||||
'created_at', 'updated_at',
|
||||
'tags', 'type', 'preview_html', 'url',
|
||||
]);
|
||||
$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'),
|
||||
]);
|
||||
}
|
||||
$data = (new ApiEntityListFormatter($results['results']->all()))
|
||||
->withType()->withTags()
|
||||
->withField('preview_html', function (Entity $entity) {
|
||||
return [
|
||||
'name' => (string) $entity->getAttribute('preview_name'),
|
||||
'content' => (string) $entity->getAttribute('preview_content'),
|
||||
];
|
||||
})->format();
|
||||
|
||||
return response()->json([
|
||||
'data' => $results['results'],
|
||||
'data' => $data,
|
||||
'total' => $results['total'],
|
||||
]);
|
||||
}
|
||||
|
@ -17,6 +17,44 @@
|
||||
"id": 1,
|
||||
"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": [
|
||||
{
|
||||
"id": 13,
|
||||
@ -28,12 +66,12 @@
|
||||
"cover": {
|
||||
"id": 452,
|
||||
"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",
|
||||
"updated_at": "2020-01-12T14:11:51.000000Z",
|
||||
"created_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",
|
||||
"uploaded_to": 16
|
||||
}
|
||||
|
@ -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()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
|
Loading…
Reference in New Issue
Block a user