--- /dev/null
+<?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;
+ }
+}
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
{
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)
{
/**
* 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);
}
{
protected BookshelfRepo $bookshelfRepo;
- /**
- * BookshelfApiController constructor.
- */
public function __construct(BookshelfRepo $bookshelfRepo)
{
$this->bookshelfRepo = $bookshelfRepo;
namespace BookStack\Http\Controllers\Api;
+use BookStack\Api\ApiEntityListFormatter;
use BookStack\Entities\Models\Entity;
use BookStack\Search\SearchOptions;
use BookStack\Search\SearchResultsFormatter;
class SearchApiController extends ApiController
{
- protected $searchRunner;
- protected $resultsFormatter;
+ protected SearchRunner $searchRunner;
+ protected SearchResultsFormatter $resultsFormatter;
protected $rules = [
'all' => [
$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'],
]);
}
"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,
"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
}
]);
}
+ 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();