API Docs: Added new endpoints to download docs as html/json

Had to rename underlying controller method to prevent name clashing.
This commit is contained in:
Dan Brown
2026-08-26 15:20:36 +01:00
parent fac26126d5
commit 35b6f57912
17 changed files with 200 additions and 89 deletions
+41 -2
View File
@@ -2,10 +2,22 @@
namespace BookStack\Api;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Http\ApiController;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class ApiDocsController extends ApiController
{
protected static array $gettingStartedSectionsById = [
'authentication' => 'Authentication',
'request-format' => 'Request Format',
'listing-endpoints' => 'Listing Endpoints',
'error-handling' => 'Error Handling',
'rate-limits' => 'Rate Limits',
'content-security' => 'Content Security',
];
/**
* Load the docs page for the API.
*/
@@ -16,6 +28,7 @@ class ApiDocsController extends ApiController
return view('api-docs.index', [
'docs' => $docs,
'gettingStartedSections' => static::$gettingStartedSectionsById,
]);
}
@@ -29,10 +42,36 @@ class ApiDocsController extends ApiController
return response()->json($docs);
}
/**
* Download the API docs as a cleaner HTML file or as JSON with embedded HTML guidance.
* Provide a ?format=json query parameter to download as JSON.
*/
public function download(Request $request)
{
$format = $request->query('format') ?? 'html';
$docs = ApiDocsGenerator::generateConsideringCache();
$downloadName = Str::slug(strtolower(setting('app-name')) . '-api-docs');
if ($format === 'json') {
$docs->prepend(
view('api-docs.parts.getting-started')->render(),
'getting-started-guide'
);
return $this->createDownload()->directly(json_encode($docs), "{$downloadName}.json");
}
$responseData = view('api-docs.download', [
'docs' => $docs,
'gettingStartedSections' => static::$gettingStartedSectionsById,
])->render();
return $this->createDownload()->directly($responseData, "{$downloadName}.html");
}
/**
* Redirect to the API docs page.
* Required as a controller method, instead of the Route::redirect helper,
* to ensure the URL is generated correctly.
* Required as a controller method, instead of the Route::redirect helper,
* to ensure the URL is generated correctly.
*/
public function redirect()
{
@@ -28,7 +28,7 @@ class BookExportApiController extends ApiController
$book = $this->queries->findVisibleByIdOrFail($id);
$pdfContent = $this->exportFormatter->bookToPdf($book);
return $this->download()->directly($pdfContent, $book->slug . '.pdf');
return $this->createDownload()->directly($pdfContent, $book->slug . '.pdf');
}
/**
@@ -41,7 +41,7 @@ class BookExportApiController extends ApiController
$book = $this->queries->findVisibleByIdOrFail($id);
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
return $this->download()->directly($htmlContent, $book->slug . '.html');
return $this->createDownload()->directly($htmlContent, $book->slug . '.html');
}
/**
@@ -52,7 +52,7 @@ class BookExportApiController extends ApiController
$book = $this->queries->findVisibleByIdOrFail($id);
$textContent = $this->exportFormatter->bookToPlainText($book);
return $this->download()->directly($textContent, $book->slug . '.txt');
return $this->createDownload()->directly($textContent, $book->slug . '.txt');
}
/**
@@ -63,7 +63,7 @@ class BookExportApiController extends ApiController
$book = $this->queries->findVisibleByIdOrFail($id);
$markdown = $this->exportFormatter->bookToMarkdown($book);
return $this->download()->directly($markdown, $book->slug . '.md');
return $this->createDownload()->directly($markdown, $book->slug . '.md');
}
/**
@@ -74,6 +74,6 @@ class BookExportApiController extends ApiController
$book = $this->queries->findVisibleByIdOrFail($id);
$zip = $builder->buildForBook($book);
return $this->download()->streamedFileDirectly($zip, $book->slug . '.zip', true);
return $this->createDownload()->streamedFileDirectly($zip, $book->slug . '.zip', true);
}
}
@@ -30,7 +30,7 @@ class BookExportController extends Controller
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$pdfContent = $this->exportFormatter->bookToPdf($book);
return $this->download()->directly($pdfContent, $bookSlug . '.pdf');
return $this->createDownload()->directly($pdfContent, $bookSlug . '.pdf');
}
/**
@@ -43,7 +43,7 @@ class BookExportController extends Controller
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
return $this->download()->directly($htmlContent, $bookSlug . '.html');
return $this->createDownload()->directly($htmlContent, $bookSlug . '.html');
}
/**
@@ -54,7 +54,7 @@ class BookExportController extends Controller
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$textContent = $this->exportFormatter->bookToPlainText($book);
return $this->download()->directly($textContent, $bookSlug . '.txt');
return $this->createDownload()->directly($textContent, $bookSlug . '.txt');
}
/**
@@ -65,7 +65,7 @@ class BookExportController extends Controller
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$textContent = $this->exportFormatter->bookToMarkdown($book);
return $this->download()->directly($textContent, $bookSlug . '.md');
return $this->createDownload()->directly($textContent, $bookSlug . '.md');
}
/**
@@ -77,6 +77,6 @@ class BookExportController extends Controller
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$zip = $builder->buildForBook($book);
return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', true);
return $this->createDownload()->streamedFileDirectly($zip, $bookSlug . '.zip', true);
}
}
@@ -28,7 +28,7 @@ class ChapterExportApiController extends ApiController
$chapter = $this->queries->findVisibleByIdOrFail($id);
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
return $this->download()->directly($pdfContent, $chapter->slug . '.pdf');
return $this->createDownload()->directly($pdfContent, $chapter->slug . '.pdf');
}
/**
@@ -41,7 +41,7 @@ class ChapterExportApiController extends ApiController
$chapter = $this->queries->findVisibleByIdOrFail($id);
$htmlContent = $this->exportFormatter->chapterToContainedHtml($chapter);
return $this->download()->directly($htmlContent, $chapter->slug . '.html');
return $this->createDownload()->directly($htmlContent, $chapter->slug . '.html');
}
/**
@@ -52,7 +52,7 @@ class ChapterExportApiController extends ApiController
$chapter = $this->queries->findVisibleByIdOrFail($id);
$textContent = $this->exportFormatter->chapterToPlainText($chapter);
return $this->download()->directly($textContent, $chapter->slug . '.txt');
return $this->createDownload()->directly($textContent, $chapter->slug . '.txt');
}
/**
@@ -63,7 +63,7 @@ class ChapterExportApiController extends ApiController
$chapter = $this->queries->findVisibleByIdOrFail($id);
$markdown = $this->exportFormatter->chapterToMarkdown($chapter);
return $this->download()->directly($markdown, $chapter->slug . '.md');
return $this->createDownload()->directly($markdown, $chapter->slug . '.md');
}
/**
@@ -74,6 +74,6 @@ class ChapterExportApiController extends ApiController
$chapter = $this->queries->findVisibleByIdOrFail($id);
$zip = $builder->buildForChapter($chapter);
return $this->download()->streamedFileDirectly($zip, $chapter->slug . '.zip', true);
return $this->createDownload()->streamedFileDirectly($zip, $chapter->slug . '.zip', true);
}
}
@@ -31,7 +31,7 @@ class ChapterExportController extends Controller
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
return $this->download()->directly($pdfContent, $chapterSlug . '.pdf');
return $this->createDownload()->directly($pdfContent, $chapterSlug . '.pdf');
}
/**
@@ -45,7 +45,7 @@ class ChapterExportController extends Controller
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$containedHtml = $this->exportFormatter->chapterToContainedHtml($chapter);
return $this->download()->directly($containedHtml, $chapterSlug . '.html');
return $this->createDownload()->directly($containedHtml, $chapterSlug . '.html');
}
/**
@@ -58,7 +58,7 @@ class ChapterExportController extends Controller
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$chapterText = $this->exportFormatter->chapterToPlainText($chapter);
return $this->download()->directly($chapterText, $chapterSlug . '.txt');
return $this->createDownload()->directly($chapterText, $chapterSlug . '.txt');
}
/**
@@ -71,7 +71,7 @@ class ChapterExportController extends Controller
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$chapterText = $this->exportFormatter->chapterToMarkdown($chapter);
return $this->download()->directly($chapterText, $chapterSlug . '.md');
return $this->createDownload()->directly($chapterText, $chapterSlug . '.md');
}
/**
@@ -83,6 +83,6 @@ class ChapterExportController extends Controller
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$zip = $builder->buildForChapter($chapter);
return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', true);
return $this->createDownload()->streamedFileDirectly($zip, $chapterSlug . '.zip', true);
}
}
@@ -28,7 +28,7 @@ class PageExportApiController extends ApiController
$page = $this->queries->findVisibleByIdOrFail($id);
$pdfContent = $this->exportFormatter->pageToPdf($page);
return $this->download()->directly($pdfContent, $page->slug . '.pdf');
return $this->createDownload()->directly($pdfContent, $page->slug . '.pdf');
}
/**
@@ -41,7 +41,7 @@ class PageExportApiController extends ApiController
$page = $this->queries->findVisibleByIdOrFail($id);
$htmlContent = $this->exportFormatter->pageToContainedHtml($page);
return $this->download()->directly($htmlContent, $page->slug . '.html');
return $this->createDownload()->directly($htmlContent, $page->slug . '.html');
}
/**
@@ -52,7 +52,7 @@ class PageExportApiController extends ApiController
$page = $this->queries->findVisibleByIdOrFail($id);
$textContent = $this->exportFormatter->pageToPlainText($page);
return $this->download()->directly($textContent, $page->slug . '.txt');
return $this->createDownload()->directly($textContent, $page->slug . '.txt');
}
/**
@@ -63,7 +63,7 @@ class PageExportApiController extends ApiController
$page = $this->queries->findVisibleByIdOrFail($id);
$markdown = $this->exportFormatter->pageToMarkdown($page);
return $this->download()->directly($markdown, $page->slug . '.md');
return $this->createDownload()->directly($markdown, $page->slug . '.md');
}
/**
@@ -74,6 +74,6 @@ class PageExportApiController extends ApiController
$page = $this->queries->findVisibleByIdOrFail($id);
$zip = $builder->buildForPage($page);
return $this->download()->streamedFileDirectly($zip, $page->slug . '.zip', true);
return $this->createDownload()->streamedFileDirectly($zip, $page->slug . '.zip', true);
}
}
@@ -34,7 +34,7 @@ class PageExportController extends Controller
$page->html = (new PageContent($page))->render();
$pdfContent = $this->exportFormatter->pageToPdf($page);
return $this->download()->directly($pdfContent, $pageSlug . '.pdf');
return $this->createDownload()->directly($pdfContent, $pageSlug . '.pdf');
}
/**
@@ -49,7 +49,7 @@ class PageExportController extends Controller
$page->html = (new PageContent($page))->render();
$containedHtml = $this->exportFormatter->pageToContainedHtml($page);
return $this->download()->directly($containedHtml, $pageSlug . '.html');
return $this->createDownload()->directly($containedHtml, $pageSlug . '.html');
}
/**
@@ -62,7 +62,7 @@ class PageExportController extends Controller
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$pageText = $this->exportFormatter->pageToPlainText($page);
return $this->download()->directly($pageText, $pageSlug . '.txt');
return $this->createDownload()->directly($pageText, $pageSlug . '.txt');
}
/**
@@ -75,7 +75,7 @@ class PageExportController extends Controller
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$pageText = $this->exportFormatter->pageToMarkdown($page);
return $this->download()->directly($pageText, $pageSlug . '.md');
return $this->createDownload()->directly($pageText, $pageSlug . '.md');
}
/**
@@ -87,6 +87,6 @@ class PageExportController extends Controller
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$zip = $builder->buildForPage($page);
return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', true);
return $this->createDownload()->streamedFileDirectly($zip, $pageSlug . '.zip', true);
}
}
+1 -1
View File
@@ -121,7 +121,7 @@ abstract class Controller extends BaseController
/**
* Create and return a new download response factory using the current request.
*/
protected function download(): DownloadResponseFactory
protected function createDownload(): DownloadResponseFactory
{
return new DownloadResponseFactory(request());
}
+1 -1
View File
@@ -24,7 +24,7 @@ class ThemeController extends Controller
abort(404);
}
$response = $this->download()->streamedFileInline($filePath);
$response = $this->createDownload()->streamedFileInline($filePath);
$response->setMaxAge(86400);
return $response;
@@ -232,10 +232,10 @@ class AttachmentController extends Controller
$attachmentSize = $this->attachmentService->getAttachmentFileSize($attachment);
if ($request->input('open') === 'true') {
return $this->download()->streamedInline($attachmentStream, $fileName, $attachmentSize);
return $this->createDownload()->streamedInline($attachmentStream, $fileName, $attachmentSize);
}
return $this->download()->streamedDirectly($attachmentStream, $fileName, $attachmentSize);
return $this->createDownload()->streamedDirectly($attachmentStream, $fileName, $attachmentSize);
}
/**
+5 -2
View File
@@ -253,6 +253,9 @@
/**
* API Docs
*/
.api-docs h2.list-heading {
font-size: 1.666rem;
}
.api-method {
font-size: 0.75rem;
background-color: #888;
@@ -282,11 +285,11 @@
position: sticky;
top: 0;
background: #F2F2F2;
background: linear-gradient(180deg,rgba(242, 242, 242, 1) 66%, rgba(242, 242, 242, 0) 100%);
background: linear-gradient(180deg,rgba(242, 242, 242, 1) 80%, rgba(242, 242, 242, 0) 100%);
z-index: 4;
}
}
.dark-mode .sticky-sidebar-header {
background: #111;
background: linear-gradient(180deg,rgba(17, 17, 17, 1) 66%, rgba(17, 17, 17, 0) 100%);
background: linear-gradient(180deg,rgba(17, 17, 17, 1) 80%, rgba(17, 17, 17, 0) 100%);
}
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<title>{{ setting('app-name') }} API Docs</title>
<style>
body {
max-width: 800px;
margin: 0 auto;
padding-bottom: 2rem;
font-family: sans-serif;
}
h2 {
margin-top: 2rem;
}
table.table {
border-collapse: collapse;
}
table.table th, table.table td {
border: 1px solid #ccc;
padding: 0.2em 0.4em;
}
</style>
</head>
<body>
<h1>{{ setting('app-name') }} API Documentation</h1>
<h2>Contents</h2>
<ul>
@foreach($gettingStartedSections as $id => $label)
<li><a href="#{{ $id }}">{{ $label }}</a></li>
@endforeach
<li style="list-style: none">---</li>
@foreach($docs as $model => $endpoints)
<li><a href="#section-{{ str_replace(' ', '-', $model) }}">{{ $model }}</a></li>
@endforeach
</ul>
<section id="section-getting-started" component="code-highlighter" class="card content-wrap auto-height">
@include('api-docs.parts.getting-started')
</section>
@foreach($docs as $model => $endpoints)
<section id="section-{{ str_replace(' ', '-', $model) }}" class="card content-wrap auto-height">
<h2>{{ $model }}</h2>
@if($endpoints[0]['model_description'])
<p>{{ $endpoints[0]['model_description'] }}</p>
@endif
@foreach($endpoints as $endpoint)
<div>
<h3 id="{{ $endpoint['name'] }}">{{ $endpoint['name'] }}</h3>
<p>{{ $endpoint['method'] }} {{ url($endpoint['uri']) }}</p>
</div>
@include('api-docs.parts.endpoint-details')
@endforeach
</section>
@endforeach
</body>
</html>
+11 -9
View File
@@ -2,14 +2,14 @@
@section('body')
<div component="api-nav" class="container">
<div component="api-nav" class="container api-docs">
<div class="grid right-focus reverse-collapse">
<div>
<div refs="api-nav@sidebar" class="sticky-sidebar">
<div class="sticky-sidebar-header py-xl">
<div class="sticky-sidebar-header pt-xl pb-m">
<select refs="api-nav@select" name="navigation" id="navigation">
<option value="getting-started" selected>Jump To Section</option>
<option value="getting-started">Getting Started</option>
@@ -22,15 +22,17 @@
</select>
</div>
<div class="flex-container-row wrap gap-xs mb-s">
<a href="{{ url('/api/docs/download?format=html') }}" title="Download API docs as HTML" class="button outline small">@icon('download')<span>HTML</span></a>
<a href="{{ url('/api/docs/download?format=json') }}" title="Download API docs as JSON" class="button outline small">@icon('download')<span>JSON</span></a>
</div>
<div class="mb-xl">
<p id="sidebar-header-getting-started" class="text-uppercase text-muted mb-xm"><strong>Getting Started</strong></p>
<div class="text-mono">
<div class="mb-xs"><a href="#authentication">Authentication</a></div>
<div class="mb-xs"><a href="#request-format">Request Format</a></div>
<div class="mb-xs"><a href="#listing-endpoints">Listing Endpoints</a></div>
<div class="mb-xs"><a href="#error-handling">Error Handling</a></div>
<div class="mb-xs"><a href="#rate-limits">Rate Limits</a></div>
<div class="mb-xs"><a href="#content-security">Content Security</a></div>
@foreach($gettingStartedSections as $id => $label)
<div class="mb-xs"><a href="#{{ $id }}">{{ $label }}</a></div>
@endforeach
</div>
</div>
@@ -61,7 +63,7 @@
@foreach($docs as $model => $endpoints)
<section id="section-{{ str_replace(' ', '-', $model) }}" class="card content-wrap auto-height">
<h1 class="list-heading text-capitals">{{ $model }}</h1>
<h2 class="list-heading text-capitals">{{ $model }}</h2>
@if($endpoints[0]['model_description'])
<p>{{ $endpoints[0]['model_description'] }}</p>
@endif
@@ -0,0 +1,41 @@
<div class="mb-m">
@foreach(explode("\n", $endpoint['description'] ?? '') as $descriptionBlock)
<p class="mb-xxs">{{ $descriptionBlock }}</p>
@endforeach
</div>
@if($endpoint['body_params'] ?? false)
<details class="mb-m">
<summary class="text-muted">{{ $endpoint['method'] === 'GET' ? 'Query' : 'Body' }} Parameters</summary>
<table class="table">
<tr>
<th>Param Name</th>
<th>Value Rules</th>
</tr>
@foreach($endpoint['body_params'] as $paramName => $rules)
<tr>
<td>{{ $paramName }}</td>
<td>
@foreach($rules as $rule)
<code class="mr-xs">{{ $rule }}</code>
@endforeach
</td>
</tr>
@endforeach
</table>
</details>
@endif
@if($endpoint['example_request'] ?? false)
<details component="details-highlighter" class="mb-m">
<summary class="text-muted">Example Request</summary>
<pre><code class="language-json">{{ $endpoint['example_request'] }}</code></pre>
</details>
@endif
@if($endpoint['example_response'] ?? false)
<details component="details-highlighter" class="mb-m">
<summary class="text-muted">Example Response</summary>
<pre><code class="language-json">{{ $endpoint['example_response'] }}</code></pre>
</details>
@endif
@@ -10,47 +10,7 @@
<h6 class="text-uppercase text-muted text-mono ml-auto">{{ $endpoint['controller_method_kebab'] }}</h6>
</div>
<div class="mb-m">
@foreach(explode("\n", $endpoint['description'] ?? '') as $descriptionBlock)
<p class="mb-xxs">{{ $descriptionBlock }}</p>
@endforeach
</div>
@if($endpoint['body_params'] ?? false)
<details class="mb-m">
<summary class="text-muted">{{ $endpoint['method'] === 'GET' ? 'Query' : 'Body' }} Parameters</summary>
<table class="table">
<tr>
<th>Param Name</th>
<th>Value Rules</th>
</tr>
@foreach($endpoint['body_params'] as $paramName => $rules)
<tr>
<td>{{ $paramName }}</td>
<td>
@foreach($rules as $rule)
<code class="mr-xs">{{ $rule }}</code>
@endforeach
</td>
</tr>
@endforeach
</table>
</details>
@endif
@if($endpoint['example_request'] ?? false)
<details component="details-highlighter" class="mb-m">
<summary class="text-muted">Example Request</summary>
<pre><code class="language-json">{{ $endpoint['example_request'] }}</code></pre>
</details>
@endif
@if($endpoint['example_response'] ?? false)
<details component="details-highlighter" class="mb-m">
<summary class="text-muted">Example Response</summary>
<pre><code class="language-json">{{ $endpoint['example_response'] }}</code></pre>
</details>
@endif
@include('api-docs.parts.endpoint-details')
@if(!$loop->last)
<hr>
@@ -1,4 +1,4 @@
<h1 class="list-heading text-capitals mb-l">Getting Started</h1>
<h2 class="list-heading text-capitals mb-l">Getting Started</h2>
<p class="mb-none">
This documentation covers use of the REST API. <br>
+1
View File
@@ -40,6 +40,7 @@ Route::middleware('auth')->group(function () {
// API docs routes
Route::get('/api', [ApiDocsController::class, 'redirect']);
Route::get('/api/docs', [ApiDocsController::class, 'display']);
Route::get('/api/docs/download', [ApiDocsController::class, 'download']);
Route::get('/pages/recently-updated', [EntityControllers\PageController::class, 'showRecentlyUpdated']);