UNPKG

@curveball/browser

Version:

Automatic API browser generator. A middleware that turns your JSON responses into HTML if accessed by a browser.

253 lines (221 loc) 5.89 kB
import { Middleware, invokeMiddlewares } from '@curveball/kernel'; import generateHtmlIndex from './html-index.js'; import { Options, NavigationLinkMap } from './types.js'; import staticMw from '@curveball/static'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; export type { Options } from './types.js'; export const supportedContentTypes = [ 'application/json', 'application/hal+json', 'application/problem+json', 'application/schema+json', 'text/markdown', 'text/csv', 'application/prs.hal-forms+json', 'application/vnd.siren+json', ]; /* * Wanted links support * * source: * https://www.iana.org/assignments/link-relations/link-relations.xhtml * * - about * - stylesheet * - via * * source: * http://microformats.org/wiki/existing-rel-values * - icon */ const defaultNavigationLinks: NavigationLinkMap = { 'acl': true, 'alternate': { position: 'alternate', }, 'authenticate' : { showLabel: true, defaultTitle: 'Sign in', position: 'header-right', }, 'authenticated-as' : { showLabel: true, defaultTitle: 'Logged in', position: 'header-right', }, 'author': { showLabel: true, }, 'code-repository': true, 'collection' : { priority: -10, defaultTitle: 'Collection', icon: 'icon/up.svg', showLabel: true, }, 'create-form': { showLabel: true, defaultTitle: 'Create', }, 'describedby': { showLabel: true, defaultTitle: 'Schema' }, 'edit': { showLabel: true, defaultTitle: 'Edit', }, 'edit-form': { showLabel: true, defaultTitle: 'Edit', icon: 'icon/edit.svg', }, 'help': { priority: 10, }, 'home': { priority: -20, }, 'logout' : { priority: 30, showLabel: true, defaultTitle: 'Sign out', position: 'header-right', }, 'next': { position: 'pager', defaultTitle: 'Next page', priority: -10, }, 'up' : { priority: -10, showLabel: true, }, 'previous': { position: 'pager', defaultTitle: 'Previous page', priority: -20, }, 'register-user': { showLabel: true, defaultTitle: 'Register user', position: 'header-right', }, 'search': true, }; const assetsPath = join(fileURLToPath(new URL(import.meta.url)),'../../assets'); export default function browser(options?: Partial<Options>): Middleware { const stat = staticMw({ staticDir: assetsPath, pathPrefix: '/_hal-browser/assets', maxAge: 3600, }); const realOptions = normalizeOptions(options); return async (ctx, next) => { const requestOptions = { ...realOptions }; if (options?.fullBody === undefined && '_browser-fullbody' in ctx.query) { requestOptions.fullBody = true; } if (requestOptions.serveAssets && ctx.path.startsWith('/_hal-browser/')) { return invokeMiddlewares(ctx, [stat]); } // Check to see if the client even wants html. if (!ctx.accepts('text/html')) { return next(); } // If the url contained _browser-accept, we use that value to override the // Accept header. let oldAccept; if ('_browser-accept' in ctx.query) { oldAccept = ctx.request.headers.get('Accept'); ctx.request.headers.set('Accept', ctx.query['_browser-accept']); } // Don't do anything if the raw format was requested if ('_browser-raw' in ctx.query) { return next(); } // Doing the inner request await next(); if (oldAccept) { // Putting the old value back in place ctx.request.headers.set('Accept', oldAccept); } // We only care about transforming a few content-types if (!supportedContentTypes.includes(ctx.response.type)) { return; } // If Content-Disposition: attachment was set, it means the API author // intended to create a download, we will also not render HTML. const cd = ctx.response.headers.get('Content-Disposition'); if (cd?.startsWith('attachment')) { return; } // Find out the client prefers HTML over the content-type that was actually // returned. // // This is useful if the client submitted a lower q= score for text/html. // // In addition, we also want to make sure that requests for */* result in // the original contenttype. Users have to explicitly request text/html. if (ctx.accepts(...supportedContentTypes, 'text/html') === 'text/html') { await generateHtmlIndex(ctx, requestOptions); } }; } /** * This function does a whole bunch of cleanup of the options object, so * everything else can do less work. * * This makes the rest of the source simpler, and also saves time because it * only happens once. */ function normalizeOptions(options?: Partial<Options>): Options { if (typeof options === 'undefined') { options = {}; } const defaults: Partial<Options> = { title: 'API Browser', theme: 'default', stylesheets: [], defaultLinks: [ { context: '/', href: '/', rel: 'home', title: 'Home', } ], hiddenRels: [ 'self', 'curies', ], assetBaseUrl: '/_hal-browser/assets/', serveAssets: true, fullBody: false, allLinks: false, }; const tmpNavLinks = Object.assign( defaultNavigationLinks, options.navigationLinks === undefined ? {} : options.navigationLinks ); options.navigationLinks = {}; for (const navLinkRel of Object.keys(tmpNavLinks)) { const navLink = tmpNavLinks[navLinkRel]; if (navLink === null) { continue; } if (navLink === true) { options.navigationLinks[navLinkRel] = { defaultTitle: navLinkRel, position: 'header' }; } else { options.navigationLinks[navLinkRel] = navLink; } } const newOptions:Options = Object.assign(defaults, options) as Options; return newOptions; }