diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php new file mode 100644 index 000000000..c170ecf0c --- /dev/null +++ b/app/Api/ApiEntityListFormatter.php @@ -0,0 +1,107 @@ + + */ + 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; + } +} diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 6f11e8cbe..0ad424de2 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -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 { diff --git a/app/Http/Controllers/Api/BookApiController.php b/app/Http/Controllers/Api/BookApiController.php index 15565c361..d57b48a43 100644 --- a/app/Http/Controllers/Api/BookApiController.php +++ b/app/Http/Controllers/Api/BookApiController.php @@ -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); } diff --git a/app/Http/Controllers/Api/BookshelfApiController.php b/app/Http/Controllers/Api/BookshelfApiController.php index 620df1638..b6b78e80e 100644 --- a/app/Http/Controllers/Api/BookshelfApiController.php +++ b/app/Http/Controllers/Api/BookshelfApiController.php @@ -13,9 +13,6 @@ class BookshelfApiController extends ApiController { protected BookshelfRepo $bookshelfRepo; - /** - * BookshelfApiController constructor. - */ public function __construct(BookshelfRepo $bookshelfRepo) { $this->bookshelfRepo = $bookshelfRepo; diff --git a/app/Http/Controllers/Api/SearchApiController.php b/app/Http/Controllers/Api/SearchApiController.php index 7ef714390..bf59ec671 100644 --- a/app/Http/Controllers/Api/SearchApiController.php +++ b/app/Http/Controllers/Api/SearchApiController.php @@ -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'], ]); } diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json index 7de85addc..8d584f597 100644 --- a/dev/api/responses/books-read.json +++ b/dev/api/responses/books-read.json @@ -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 } diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index f426cff73..017322193 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -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();