-
Notifications
You must be signed in to change notification settings - Fork 12
feat(agent-bff): serve the OpenAPI document in a browser through Redoc #1829
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
nbouliol
merged 14 commits into
main
from
feature/prd-965-serve-the-bff-openapi-document-in-a-browser-through-redoc
Aug 24, 2026
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
809b251
feat(agent-bff): serve the OpenAPI document in a browser through Redoc
nbouliol c93f566
test(agent-bff): cover the install whose Redoc bundle never shipped
nbouliol 54fe731
fix(agent-bff): stop the docs page from ever putting the key in a URL
nbouliol 6f827a0
feat(agent-bff): theme the docs viewer with the Forest palette
nbouliol 5bdaf05
refactor(agent-bff): drop "Admin" from the product name in the BFF
nbouliol 396c3d3
feat(agent-bff): give the docs page the product favicon
nbouliol eba8b4c
fix(agent-bff): let the docs page apply only its current attempt
nbouliol c029c2f
fix(agent-bff): treat an unparsable document as a failure, not a docu…
nbouliol e061932
feat(agent-bff): group the unfolded document by collection
nbouliol 88543f7
feat(agent-bff): give every operation a curl, node and ruby sample
nbouliol 4a24eaf
fix(agent-bff): stop the samples from targeting a literal path template
nbouliol 8b78321
fix(agent-bff): quote sample values for the language that will run them
nbouliol 6f54b08
fix(agent-bff): bound every schema walk the sample generator makes
nbouliol 5bf0327
test(agent-bff): cover the bundle branch a published install actually…
nbouliol File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| /** | ||
| * The page is served WITHOUT credentials, so it must carry no schema: it is an empty shell that asks | ||
| * the caller for a BFF API key, fetches the document with it, and hands the parsed object to Redoc. | ||
| * That is the only design that is both openable in a browser — which sends no header when it | ||
| * navigates — and compatible with a document that is never reachable unauthenticated. | ||
| * | ||
| * The key is never persisted: it is read from the input, passed down as an argument, and the input is | ||
| * cleared. Once the document is fetched the page has no further use for it. | ||
| * | ||
| * Deliberately NOT a `<form>`. A form with no `action` navigates to `/docs?key=<the key>` the moment | ||
| * its default submit is not prevented — a CSP that blocks this inline script is enough — which would | ||
| * put the key in the browser history and in every access log on the way. A form submit is also what | ||
| * Chrome reads as a login, and it then offers to save the key whatever `autocomplete` says. With no | ||
| * form there is no default action to prevent and no submit to observe: without this script the button | ||
| * does nothing at all. | ||
| */ | ||
| import SAMPLES_SCRIPT from './docs-samples'; | ||
| import { FAVICON_SVG, PAGE_STYLES, REDOC_THEME } from './docs-theme'; | ||
|
|
||
| /** | ||
| * `untrustedSpec` because the descriptions in the document come from the agent's own schema, which is | ||
| * customer-authored, and Redoc renders their markdown as HTML unsanitized otherwise. | ||
| */ | ||
| const REDOC_OPTIONS = { hideDownloadButton: true, untrustedSpec: true, theme: REDOC_THEME }; | ||
|
|
||
| export default function renderDocsPage(documentPath: string, bundlePath: string): string { | ||
| return `<!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <meta name="robots" content="noindex" /> | ||
| <title>Forest BFF API</title> | ||
| <link rel="icon" href="data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}" /> | ||
| <style>${PAGE_STYLES} </style> | ||
| </head> | ||
| <body> | ||
| <div id="unlock"> | ||
| <strong>Forest<span>.</span></strong> | ||
| <label for="key">BFF API key</label> | ||
| <input id="key" type="password" autocomplete="off" spellcheck="false" /> | ||
| <button id="load" type="button">Load the API document</button> | ||
| </div> | ||
| <div id="error"></div> | ||
| <div id="redoc"></div> | ||
| <script src="${bundlePath}"></script> | ||
| <script> | ||
| (function () { | ||
| var DOCUMENT_PATH = ${JSON.stringify(documentPath)}; | ||
| var BUNDLE_PATH = ${JSON.stringify(bundlePath)}; | ||
| var REDOC_OPTIONS = ${JSON.stringify(REDOC_OPTIONS)}; | ||
| var unlock = document.getElementById('unlock'); | ||
| var input = document.getElementById('key'); | ||
| var button = document.getElementById('load'); | ||
| var errorBox = document.getElementById('error'); | ||
| var attempts = 0; | ||
| ${SAMPLES_SCRIPT} | ||
| function show(message) { | ||
| errorBox.textContent = message; | ||
| errorBox.setAttribute('data-shown', ''); | ||
| } | ||
|
|
||
| function hide() { | ||
| errorBox.removeAttribute('data-shown'); | ||
| } | ||
|
|
||
| function describe(status, body) { | ||
| var error = body && body.error; | ||
|
|
||
| if (error && error.type) { | ||
| return 'The BFF answered ' + status + ' ' + error.type + ': ' + (error.message || ''); | ||
| } | ||
|
|
||
| return 'The BFF answered ' + status + ': ' + JSON.stringify(body); | ||
| } | ||
|
|
||
| /** | ||
| * Kept out of the fetch chain: a throw from here is a viewer problem, and reporting it as | ||
| * "could not reach the document" would point the reader at the wrong thing. | ||
| */ | ||
| function render(spec) { | ||
| if (typeof Redoc === 'undefined') { | ||
| show('The Redoc viewer did not load from ' + BUNDLE_PATH + ', so the document cannot be rendered.'); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| unlock.style.display = 'none'; | ||
|
|
||
| try { | ||
| Redoc.init(withSamples(spec), REDOC_OPTIONS, document.getElementById('redoc')); | ||
| } catch (initError) { | ||
| unlock.style.display = ''; | ||
| show('The Redoc viewer could not render the document: ' + initError); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Every completion is checked against \`attempt\`: two submissions in quick succession — a | ||
| * mistyped key corrected straight away — resolve in whatever order the network gives, and a | ||
| * late answer from the abandoned one would otherwise render its document or report its error | ||
| * over the current attempt's result. | ||
| */ | ||
| function load(key) { | ||
| hide(); | ||
|
|
||
| var attempt = ++attempts; | ||
| var current = function () { | ||
| return attempt === attempts; | ||
| }; | ||
|
|
||
| fetch(DOCUMENT_PATH, { | ||
| cache: 'no-store', | ||
| headers: { 'X-Forest-Bff-Key': key }, | ||
| }) | ||
| .then(function (response) { | ||
| return response.text().then(function (text) { | ||
| try { | ||
| return { ok: response.ok, status: response.status, body: JSON.parse(text) }; | ||
| } catch (parseError) { | ||
| // Never successful, whatever the status said: a body we cannot parse is not a | ||
| // document, and handing this placeholder to Redoc would hide why. | ||
| return { | ||
| ok: false, | ||
| status: response.status, | ||
| body: { error: { type: 'unreadable_response', message: text.slice(0, 200) } }, | ||
| }; | ||
| } | ||
| }); | ||
| }) | ||
| .then(function (result) { | ||
| if (!current()) return; | ||
|
|
||
| if (!result.ok) { | ||
| show(describe(result.status, result.body)); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| render(result.body); | ||
| }) | ||
| .catch(function (fetchError) { | ||
|
Tonours marked this conversation as resolved.
|
||
| if (!current()) return; | ||
|
|
||
| show('Could not reach ' + DOCUMENT_PATH + ': ' + fetchError); | ||
| }); | ||
| } | ||
|
|
||
| function unlockDocument() { | ||
| var key = input.value.trim(); | ||
| input.value = ''; | ||
|
|
||
| if (key) load(key); | ||
| else show('A BFF API key is required: the document is never served unauthenticated.'); | ||
| } | ||
|
|
||
| button.addEventListener('click', unlockDocument); | ||
| input.addEventListener('keydown', function (event) { | ||
| if (event.key === 'Enter') unlockDocument(); | ||
| }); | ||
| })(); | ||
| </script> | ||
| </body> | ||
| </html> | ||
| `; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import type { Logger } from '../ports/logger-port'; | ||
| import type { Middleware } from 'koa'; | ||
|
|
||
| import { existsSync, readFileSync } from 'fs'; | ||
| import path from 'path'; | ||
|
|
||
| import renderDocsPage from './docs-page'; | ||
|
|
||
| export const DOCS_PATH = '/docs'; | ||
| export const DOCS_BUNDLE_PATH = '/docs/redoc.standalone.js'; | ||
|
|
||
| const BUNDLE_FILE = 'redoc.standalone.js'; | ||
| const READ_METHODS = new Set(['GET', 'HEAD']); | ||
|
|
||
| export interface DocsRoutesOptions { | ||
| enabled: boolean; | ||
| /** Where the shell fetches the document. Passed in so this module never reaches into `src/openapi`. */ | ||
| documentPath: string; | ||
| logger: Logger; | ||
| /** The bundle lookup, as a seam: an install that shipped without the asset is a real state to serve. */ | ||
| resolveBundlePath?: () => string | undefined; | ||
| } | ||
|
|
||
| /** | ||
| * The bundle is copied next to this module at build time (`build:copy`), which is what a published | ||
| * install serves. Running from `src` — tests, `build:watch` — there is nothing to copy to, so the | ||
| * `redoc` devDependency is resolved instead: the same file, from the package that pins its version. | ||
| * | ||
| * The directory is a parameter so both branches are reachable from a test. Running from `src` only | ||
| * ever takes the fallback, which would leave the branch a published install actually uses untested. | ||
| */ | ||
| export function resolveBundle(directory: string = __dirname): string | undefined { | ||
| const copied = path.join(directory, BUNDLE_FILE); | ||
|
|
||
| if (existsSync(copied)) return copied; | ||
|
|
||
| try { | ||
| return require.resolve(`redoc/bundles/${BUNDLE_FILE}`); | ||
| } catch { | ||
| /* istanbul ignore next — `redoc` is a devDependency of this package, so the lookup only fails in | ||
| a published install whose `build:copy` did not run. */ | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Serves the Redoc viewer OUTSIDE `/agent`, deliberately: the agent prefix answers 401 to a request | ||
| * with no credential (`auth-mode.ts`), and a browser navigating to a page sends none. Both routes are | ||
| * public, and both are inert — the shell carries no schema and the bundle is a third-party asset. | ||
| * The document itself stays gated. | ||
| * | ||
| * Disabled, or unable to find its bundle, the middleware falls through rather than throwing: `/docs` | ||
| * is not covered by the agent-scoped error middleware, so a thrown error would surface as a bare 500 | ||
| * instead of the BFF error contract. A 404 also keeps a disabled deployment from advertising a page | ||
| * it does not serve. | ||
| */ | ||
| export default function createDocsRoutes({ | ||
| enabled, | ||
| documentPath, | ||
| logger, | ||
| resolveBundlePath = resolveBundle, | ||
| }: DocsRoutesOptions): Middleware { | ||
| const bundle = enabled ? resolveBundlePath() : undefined; | ||
|
|
||
| if (enabled && !bundle) { | ||
| logger('Warn', `API documentation page disabled: ${BUNDLE_FILE} is missing from this install`); | ||
| } | ||
|
|
||
| const page = bundle ? renderDocsPage(documentPath, DOCS_BUNDLE_PATH) : undefined; | ||
| let script: string | undefined; | ||
|
|
||
| return async function docsRoutes(ctx, next) { | ||
| const isDocsPath = ctx.path === DOCS_PATH || ctx.path === DOCS_BUNDLE_PATH; | ||
|
|
||
| if (!bundle || !isDocsPath || !READ_METHODS.has(ctx.method)) { | ||
| await next(); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| if (ctx.path === DOCS_BUNDLE_PATH) { | ||
| // Read once and kept in memory: ~1 MB, served on every page load. A file that resolved at boot | ||
| // and is unreadable now falls through like a missing one: no error middleware covers this path. | ||
| if (script === undefined) { | ||
| try { | ||
| script = readFileSync(bundle, 'utf8'); | ||
| } catch (error) { | ||
| logger('Warn', `API documentation bundle unreadable: ${bundle}`, { error }); | ||
|
|
||
| await next(); | ||
|
|
||
| return; | ||
| } | ||
| } | ||
|
|
||
| ctx.status = 200; | ||
| ctx.type = 'application/javascript'; | ||
| ctx.set('Cache-Control', 'public, max-age=3600'); | ||
| ctx.body = script; | ||
|
|
||
| return; | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| ctx.status = 200; | ||
| ctx.type = 'text/html'; | ||
| ctx.set('Cache-Control', 'no-store'); | ||
| ctx.body = page; | ||
| }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.