-
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
base: feature/prd-687-verify-a-generated-client-calls-list-count-form-and-execute
Are you sure you want to change the base?
Changes from all commits
c9160f5
6518b88
9c7f85f
8fb4a19
c9aa822
755cdb6
2344ada
9eea40d
787fc3e
7b9caca
a21b213
8b02367
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 749ce16. The init moved out of the fetch chain into a |
||
| 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> | ||
| `; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| 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. | ||
| */ | ||
| function resolveBundle(): string | undefined { | ||
| const copied = path.join(__dirname, 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; | ||
| }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.