mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-04-23 20:30:22 +00: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
7 changed files with 212 additions and 33 deletions
app
Api
Entities/Tools
Http/Controllers/Api
dev/api/responses
tests/Api
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
|
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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
Loading…
Add table
Reference in a new issue