= {
34 | add: 'Add',
35 | delete: 'Delete',
36 | edit: 'Edit',
37 | find: 'Find',
38 | remove: 'Remove',
39 | search: 'Search',
40 | select: 'Select',
41 | };
42 |
43 | export function Button(props: Props) {
44 |
45 | const label = props.title || guessTitle(props.titleHint) || methodLabel[props.method] || 'Submit';
46 |
47 | return ;
50 |
51 | }
52 |
53 | function guessTitle(titleHint: string|undefined): string|undefined {
54 |
55 | if (!titleHint) return;
56 | const firstWord = titleHint.split(' ')[0].toLowerCase();
57 | return titleHintGuess[firstWord];
58 |
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/forms/ketting-action-button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Action, Field } from 'ketting';
3 | import { Button } from './button.js';
4 |
5 | type FormProps = {
6 | csrfToken: string | null;
7 | action: Action;
8 | }
9 | type FieldProps = {
10 | field: Field;
11 | }
12 |
13 | /**
14 | * This component renders actions that can be expressed as a single button.
15 | *
16 | * This is only the case for actions that have no fields or only fields with
17 | * type=hidden
18 | */
19 | export function ButtonForm(props: FormProps) {
20 |
21 | const action = props.action;
22 | const fields = action.fields.map( field => );
23 | if (action.method === 'GET') {
24 | return ;
28 | } else {
29 | return ;
34 | }
35 |
36 | }
37 |
38 | function ActionField(props: FieldProps): React.ReactElement {
39 |
40 | const field = props.field;
41 | if (field.type !== 'hidden') {
42 | throw new Error('The ActionButtonForm can only render forms that have no fields');
43 | }
44 |
45 | return ;
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/forms/ketting-action.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useId } from 'react';
3 | import { Action, Field } from 'ketting';
4 | import { Button } from './button.js';
5 |
6 | type FormProps = {
7 | csrfToken: string | null;
8 | action: Action;
9 | }
10 | type FieldProps = {
11 | field: Field;
12 | }
13 |
14 | export function ActionForm(props: FormProps) {
15 |
16 | const action = props.action;
17 | return ;
25 |
26 | }
27 |
28 | export function ActionField(props: FieldProps): React.ReactElement {
29 |
30 | const id = useId();
31 | let input;
32 | let renderLabel = true;
33 |
34 | const field = props.field;
35 |
36 | field.type;
37 |
38 | switch(field.type) {
39 |
40 | case 'checkbox' :
41 | case 'radio' :
42 | input =
43 |
44 |
51 |
52 |
;
53 | renderLabel = false;
54 | break;
55 | case 'color' :
56 | case 'email' :
57 | case 'file' :
58 | case 'password' :
59 | case 'search' :
60 | case 'tel' :
61 | case 'url' :
62 | input = ;
71 | break;
72 | case 'hidden' :
73 | input = ;
78 | renderLabel = false;
79 | break;
80 | case 'date' :
81 | case 'datetime' :
82 | case 'datetime-local' :
83 | case 'number' :
84 | case 'month' :
85 | case 'range' :
86 | case 'time' :
87 | case 'week' : {
88 | let value;
89 | if (field.value instanceof Date) {
90 | value = field.value.toISOString().slice(0, -1);
91 | } else {
92 | value = field.value?.toString();
93 | }
94 | input = ;
106 | break;
107 | }
108 | case 'text' :
109 | input = ;
121 | break;
122 | case 'textarea' :
123 | input = ;
135 | break;
136 | case 'select' : {
137 |
138 | let options: Record;
139 | if ((field as any).options) {
140 | options = (field as any).options;
141 | } else {
142 | options = { 'n/a': 'Not yet supported' };
143 | }
144 |
145 | switch(field.renderAs) {
146 | case 'dropdown' :
147 | default :
148 | input = ;
155 | break;
156 | case 'radio' :
157 | case 'checkbox' : {
158 | const inputs = [];
159 | for(const [k, v] of Object.entries(options)) {
160 | inputs.push(
161 |
162 |
168 |
169 |
170 | );
171 | break;
172 |
173 | }
174 | renderLabel = false;
175 | input = <>{inputs}>;
176 | }
177 | }
178 | break;
179 | }
180 | default:
181 | ((x: never) => {
182 | throw new Error(`${(x as any).type} was unhandled!`);
183 | })(field);
184 |
185 | }
186 |
187 | if (!renderLabel) {
188 | return input;
189 | }
190 |
191 | return <>
192 |
193 | {input}
194 | >;
195 |
196 | }
197 |
--------------------------------------------------------------------------------
/src/components/forms/templated-links.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { PageProps } from '../../types.js';
3 | import { Button } from './button.js';
4 | import { getFieldsFromTemplatedUri } from '../../util.js';
5 |
6 | export function TemplatedLinks(props: PageProps) {
7 |
8 | const result = [];
9 | for (const link of props.resourceState.links.getAll()) {
10 |
11 | if (props.options.hiddenRels.includes(link.rel) || link.rel in props.options.navigationLinks) {
12 | continue;
13 | }
14 |
15 | // We're only interested in templated links
16 | if (!link.templated) {
17 | continue;
18 | }
19 |
20 | const fieldData = getFieldsFromTemplatedUri(link.href);
21 | if (!fieldData) {
22 | // Unparsable
23 | continue;
24 | }
25 | const [target, hiddenFields, fields] = fieldData;
26 | const title = link.title || link.rel;
27 |
28 | result.push();
39 |
40 | (link as any).rendered = true;
41 |
42 | }
43 |
44 | return result;
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/hal-body.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { PageProps, JsonSchema } from '../types.js';
3 |
4 | import JsonViewer from './json-viewer.js';
5 |
6 | export function HalBody(props: PageProps) {
7 |
8 | const body = props.originalBody;
9 | let schema: JsonSchema|null = null;
10 |
11 | const describedBy = props.resourceState.links.get('describedby');
12 | if (describedBy && describedBy.type === 'application/schema+json') {
13 | schema = props.jsonSchemas.get(describedBy.href) ?? null;
14 | }
15 |
16 | return <>
17 | Contents
18 |
19 | >;
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/json-viewer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { JsonSchema } from '../types.js';
3 |
4 | type Props = {
5 | data: string;
6 | schema: JsonSchema | null;
7 | }
8 |
9 | export default function JsonViewer(props: Props) {
10 |
11 | const data = JSON.parse(props.data);
12 | return {renderJsonValue(data, undefined, props.schema??undefined)}
;
13 |
14 | }
15 |
16 | type JsonValue = null | string | boolean | number | any[] | Record;
17 |
18 |
19 | function renderJsonValue(value: JsonValue, asLink?: boolean, schema?: JsonSchema): React.ReactNode {
20 |
21 | if (value===null) {
22 | return null;
23 | }
24 | if (typeof value === 'boolean') {
25 | return {value?'true':'false'};
26 | }
27 | if (typeof value === 'string') {
28 | if (asLink && isLegalLink(value)) {
29 | return "{value}";
30 | } else {
31 | return "{value}";
32 | }
33 | }
34 | if (typeof value === 'number') {
35 | return {value};
36 | }
37 | if (Array.isArray(value)) {
38 |
39 | let arrayItemSchema:JsonSchema | undefined = undefined;
40 | if (schema?.items && !Array.isArray(schema.items)) {
41 | arrayItemSchema = schema.items;
42 | }
43 |
44 | return <>
45 | [
46 | {(value.map((item, idx) => {
47 | return - {renderJsonValue(item, false, arrayItemSchema)}{idx < value.length -1 ? ,:null}
;
48 | }))}
49 |
]
50 | >;
51 | }
52 |
53 | return renderJsonObject(value, false, schema);
54 |
55 | }
56 |
57 | function renderJsonObject(value: Record, skipOpen = false, jsonSchema?: JsonSchema) {
58 |
59 | return <>
60 | {skipOpen ? null : <>{'{'}
>}
61 |
62 | {(Object.entries(value).map(([key, value], idx, arr) => {
63 | return renderCollapsableRow(
64 | key,
65 | value,
66 | idx >= arr.length -1,
67 | jsonSchema?.properties?.[key],
68 | );
69 | }))}
70 |
71 | {'}'}
72 | >;
73 |
74 | }
75 |
76 | function renderCollapsableRow(key: string, value: JsonValue, isLast: boolean, jsonSchema?: JsonSchema): React.ReactNode {
77 |
78 | const description = jsonSchema?.description ? {'// ' + jsonSchema.description} : undefined;
79 |
80 | if (isJsonObject(value)) {
81 |
82 | // Open by default, unless the key starts with undescore
83 | // This ensures that stuff like _links, _embedded starts closed
84 | const open = !key.startsWith('_');
85 |
86 | return
87 | {description}
88 |
89 |
90 | "
91 | {key}
92 | "
93 | : {'{'}
94 |
95 | {Object.keys(value).length} properties {'}'}
96 |
97 |
98 | {renderJsonObject(value, true, jsonSchema)}
99 | {isLast ? null : ,}
100 |
101 | ;
102 | } else {
103 | return
104 | {description}
105 |
106 | "
107 | {key}
108 | "
109 | :
110 | {renderJsonValue(value, isLikelyAUri(key), jsonSchema)}
111 | {isLast ? null : ,}
112 |
113 | ;
114 | }
115 |
116 | }
117 |
118 | /**
119 | * Checks at a name of a property and returns true if it's probably a uri
120 | */
121 | function isLikelyAUri(keyName: string) {
122 |
123 | const key = keyName.toLowerCase();
124 | return key.endsWith('href') || key.endsWith('uri') || key.endsWith('url');
125 |
126 | }
127 |
128 | const allowedSchemes = ['http', 'https', 'mailto', 'gopher'];
129 | function isLegalLink(uri: string): boolean {
130 |
131 | if (uri.includes('{')) {
132 | // Likely a templated URI
133 | return false;
134 | }
135 | if (uri.startsWith('/')) {
136 | return true;
137 | }
138 | for(const scheme of allowedSchemes) {
139 | if (uri.startsWith(scheme + ':')) {
140 | return true;
141 | }
142 | }
143 | return false;
144 |
145 | }
146 |
147 | /**
148 | * Ugly way to find out something is a JSON object
149 | */
150 | function isJsonObject(value: JsonValue): value is Record {
151 |
152 | if (typeof value !== 'object') return false;
153 | if (value===null || Array.isArray(value)) return false;
154 | return true;
155 |
156 | }
157 |
--------------------------------------------------------------------------------
/src/components/links-table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Link } from 'ketting';
3 | import { PageProps } from '../types.js';
4 | import { readFileSync } from 'node:fs';
5 | import { fileURLToPath } from 'node:url';
6 | import * as path from 'node:path';
7 |
8 | type LinkDescriptions = Record;
9 |
10 | function loadLinkData(fileName: string) {
11 | return JSON.parse(
12 | readFileSync(
13 | path.join(
14 | fileURLToPath(new URL(import.meta.url + '/..')),
15 | '../../data/',
16 | fileName + '.json'
17 | ),
18 | 'utf-8'
19 | )
20 | );
21 | }
22 |
23 | const linkDescriptions: LinkDescriptions = {
24 | ...loadLinkData('iana-links'),
25 | ...loadLinkData('editor-links'),
26 | ...loadLinkData('level3-rest-links')
27 | };
28 |
29 | export function LinksTable(props: PageProps) {
30 |
31 | const linkRows = [];
32 | // Grouping links by rel.
33 | const groups: { [rel: string]: Link[] } = {};
34 |
35 | for (const link of props.resourceState.links.getAll()) {
36 |
37 | if (!props.options.allLinks &&
38 | (props.options.hiddenRels.includes(link.rel) || link.rel in props.options.navigationLinks || (link as any).rendered))
39 | {
40 | continue;
41 | }
42 |
43 | if (groups[link.rel]) {
44 | groups[link.rel].push(link);
45 | } else {
46 | groups[link.rel] = [link];
47 | }
48 | }
49 |
50 | for (const group of Object.values(groups)) {
51 |
52 | const linkCount = group.length;
53 | let index = 0;
54 |
55 | for (const link of group) {
56 |
57 | const linkBadges = [];
58 | if (link.hints?.allow) {
59 | for(const method of link.hints.allow) {
60 | linkBadges.push({method.toUpperCase()});
61 | }
62 | }
63 | if (link.hints?.status) {
64 | switch (link.hints.status) {
65 | case 'deprecated' :
66 | linkBadges.push(Deprecated);
67 | break;
68 | case 'gone' :
69 | linkBadges.push(Gone);
70 | break;
71 | }
72 | }
73 | linkRows.push(
74 | {index===0 ? | : null}
75 | {link.templated ? {link.href} | : {link.href} | }
76 | {link.title}{linkBadges} |
77 |
);
78 |
79 | index++;
80 |
81 | }
82 |
83 |
84 | }
85 |
86 | if (!linkRows.length) return null;
87 |
88 | return <>
89 | Links
90 |
91 |
92 | Relationship | Url | Title |
93 |
94 | {linkRows}
95 |
96 | >;
97 |
98 | }
99 |
100 | type LinkRelProps = {
101 | link: Link;
102 | }
103 |
104 | function LinkRel(props: LinkRelProps) {
105 |
106 | const rel = props.link.rel;
107 | if (linkDescriptions[rel]!==undefined) {
108 | const link = linkDescriptions[rel];
109 | return {rel};
110 | } else {
111 | return <>{rel}>;
112 | }
113 |
114 | }
115 |
--------------------------------------------------------------------------------
/src/components/markdown-body.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import hljs from 'highlight.js';
3 | import md from 'markdown-it';
4 | import { PageProps } from '../types.js';
5 |
6 | export function MarkdownBody(props: PageProps) {
7 |
8 | const html = {
9 | __html: md({
10 | html: true,
11 | xhtmlOut: true,
12 | highlight: (str: string, lang: string) => {
13 |
14 | if (lang && hljs.getLanguage(lang)) {
15 | return hljs.highlight(lang, str).value;
16 | }
17 | // use external default escaping
18 | return '';
19 |
20 | }
21 | }).render(props.resourceState.data)
22 | };
23 |
24 | return ;
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/navigation.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Link } from 'ketting';
3 | import { NavigationLink, PageProps } from '../types.js';
4 | import { getNavLinks } from '../util.js';
5 |
6 | export function Navigation(props: PageProps) {
7 |
8 | const links = props.resourceState.links.getAll();
9 | const options = props.options;
10 |
11 | return <>
12 |
15 |
18 | >;
19 |
20 | }
21 |
22 | function NavLinks(props: {links: Array}) {
23 |
24 | const elems = props.links.map(link => {
25 | return
26 |
27 |
28 | {link.title}
29 |
30 | ;
31 | });
32 | return <>{elems}>;
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/pager.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { PageProps } from '../types.js';
3 | import { getNavLinks } from '../util.js';
4 |
5 | export function Pager(props: PageProps) {
6 |
7 | const elems = [];
8 | for (const link of getNavLinks(props.resourceState.links.getAll(), props.options, 'pager')) {
9 |
10 | elems.push(
11 |
12 |
{link.title}
13 | );
14 |
15 | }
16 |
17 | if (!elems.length) {
18 | return null;
19 | }
20 |
21 | return ;
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/resource.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { Description } from './description.js';
4 | import { PageProps } from '../types.js';
5 | import { Embedded } from './embedded.js';
6 | import { Forms } from './forms.js';
7 | import { LinksTable } from './links-table.js';
8 | import { Pager } from './pager.js';
9 | import { Body } from './body.js';
10 |
11 | export function Resource(props: PageProps) {
12 |
13 | return <>
14 |
15 |
16 |
17 |
18 |
19 |
20 | >;
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/search.tsx:
--------------------------------------------------------------------------------
1 | import { State } from 'ketting';
2 | import * as querystring from 'querystring';
3 | import * as url from 'url';
4 | import * as React from 'react';
5 | import { Options } from '../types.js';
6 |
7 | type Props = {
8 | options: Options;
9 | resourceState: State;
10 | };
11 |
12 | export function Search(props: Props) {
13 |
14 | const searchLink = props.resourceState.links.get('search');
15 |
16 | // No search link
17 | if (!searchLink) return null;
18 |
19 | // Search link was not templated, so it's useless.
20 | if (!searchLink.templated) return null;
21 |
22 | // We only support a very specific format. The link must be
23 | // templated, have at most 1 templated field, and that field must
24 | // appear in the query parameter.
25 | //
26 | // Sample format:
27 | // {
28 | // href: "/search?language=nl{?q}",
29 | // templated: true
30 | // }
31 | //
32 | // This regex might capture invalid templates, but it's not up to us to
33 | // validate it.
34 | const matches = searchLink.href.match(/^([^{]+){\?([a-zA-z0-9]+)}([^{]*)$/);
35 | if (matches === null) {
36 | return null;
37 | }
38 |
39 | // Url with the template variable stripped.
40 | const newUrl = matches[1] + matches[3];
41 |
42 | const [action, queryStr] = newUrl.split('?');
43 | const query:Record = querystring.parse(queryStr);
44 |
45 | const hiddenFields = Object.entries(query).map( entry => {
46 | return ;
47 |
48 | });
49 |
50 | return ;
55 |
56 | }
57 |
--------------------------------------------------------------------------------
/src/html-index.tsx:
--------------------------------------------------------------------------------
1 | import { Context } from '@curveball/kernel';
2 | import { Options, JsonSchema } from './types.js';
3 | import * as ReactDOMServer from 'react-dom/server';
4 | import * as React from 'react';
5 |
6 | import { App } from './components/app.js';
7 | import { contextToState } from './util.js';
8 |
9 | import '@curveball/validator';
10 |
11 | export default async function generateHtmlIndex(ctx: Context, options: Options) {
12 |
13 | normalizeBody(ctx);
14 |
15 | if (!ctx.response.body) {
16 | return;
17 | }
18 |
19 | const state = await contextToState(ctx);
20 |
21 | for(const link of options.defaultLinks) {
22 | state.links.add(link);
23 | }
24 |
25 | let csrfToken = null;
26 | // We are casting to any here. If the 'session' middleware is included,
27 | // the getCsrf method is available, but we don't want to create a dependency
28 | // to it.
29 | //
30 | // Now we can will use getCsrf(), but only if it's defined.
31 | if ((ctx as any).getCsrf!==undefined) {
32 | csrfToken = await (ctx as any).getCsrf();
33 | }
34 |
35 | const jsonSchemas = new Map();
36 |
37 | if (ctx.schemas) {
38 | for(const schema of ctx.schemas) {
39 | jsonSchemas.set(schema.id, schema.schema);
40 | }
41 | }
42 |
43 | ctx.response.type = 'text/html; charset=utf-8';
44 | ctx.response.body = '\n' + ReactDOMServer.renderToString(
45 |
52 | );
53 |
54 | }
55 |
56 | function normalizeBody(ctx: Context) {
57 |
58 | if (!ctx.response.body && ctx.response.status === 201) {
59 |
60 | // A default response body for 201 respones.
61 | ctx.response.body = {
62 | title: '201 Created',
63 | };
64 | if (ctx.response.headers.has('Location')) {
65 | ctx.response.body._links = {
66 | next: {
67 | href: ctx.response.headers.get('Location')
68 | }
69 | };
70 | ctx.response.type = 'application/hal+json';
71 | }
72 |
73 | }
74 | if (ctx.response.body instanceof Buffer || ctx.response.body === null) {
75 | return;
76 | }
77 |
78 |
79 | if (typeof ctx.response.body === 'object') {
80 | ctx.response.body = JSON.stringify(ctx.response.body);
81 | return;
82 | }
83 |
84 |
85 |
86 | }
87 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Middleware, invokeMiddlewares } from '@curveball/kernel';
2 | import generateHtmlIndex from './html-index.js';
3 | import { Options, NavigationLinkMap } from './types.js';
4 | import staticMw from '@curveball/static';
5 | import { fileURLToPath } from 'node:url';
6 | import { join } from 'node:path';
7 |
8 | export type { Options } from './types.js';
9 |
10 | export const supportedContentTypes = [
11 | 'application/json',
12 | 'application/hal+json',
13 | 'application/problem+json',
14 | 'application/schema+json',
15 | 'text/markdown',
16 | 'text/csv',
17 | 'application/prs.hal-forms+json',
18 | 'application/vnd.siren+json',
19 | ];
20 |
21 |
22 | /*
23 | * Wanted links support
24 | *
25 | * source:
26 | * https://www.iana.org/assignments/link-relations/link-relations.xhtml
27 | *
28 | * - about
29 | * - stylesheet
30 | * - via
31 | *
32 | * source:
33 | * http://microformats.org/wiki/existing-rel-values
34 | * - icon
35 | */
36 |
37 | const defaultNavigationLinks: NavigationLinkMap = {
38 | 'acl': true,
39 | 'alternate': {
40 | position: 'alternate',
41 | },
42 | 'authenticate' : {
43 | showLabel: true,
44 | defaultTitle: 'Sign in',
45 | position: 'header-right',
46 | },
47 | 'authenticated-as' : {
48 | showLabel: true,
49 | defaultTitle: 'Logged in',
50 | position: 'header-right',
51 | },
52 | 'author': {
53 | showLabel: true,
54 | },
55 | 'code-repository': true,
56 | 'collection' : {
57 | priority: -10,
58 | defaultTitle: 'Collection',
59 | icon: 'icon/up.svg',
60 | showLabel: true,
61 | },
62 | 'create-form': {
63 | showLabel: true,
64 | defaultTitle: 'Create',
65 | },
66 | 'describedby': {
67 | showLabel: true,
68 | defaultTitle: 'Schema'
69 | },
70 | 'edit': {
71 | showLabel: true,
72 | defaultTitle: 'Edit',
73 | },
74 | 'edit-form': {
75 | showLabel: true,
76 | defaultTitle: 'Edit',
77 | icon: 'icon/edit.svg',
78 | },
79 | 'help': {
80 | priority: 10,
81 | },
82 | 'home': {
83 | priority: -20,
84 | },
85 | 'logout' : {
86 | priority: 30,
87 | showLabel: true,
88 | defaultTitle: 'Sign out',
89 | position: 'header-right',
90 | },
91 | 'next': {
92 | position: 'pager',
93 | defaultTitle: 'Next page',
94 | priority: -10,
95 | },
96 | 'up' : {
97 | priority: -10,
98 | showLabel: true,
99 | },
100 | 'previous': {
101 | position: 'pager',
102 | defaultTitle: 'Previous page',
103 | priority: -20,
104 | },
105 | 'register-user': {
106 | showLabel: true,
107 | defaultTitle: 'Register user',
108 | position: 'header-right',
109 | },
110 | 'search': true,
111 | };
112 |
113 | const assetsPath = join(fileURLToPath(new URL(import.meta.url)),'../../assets');
114 |
115 | export default function browser(options?: Partial): Middleware {
116 |
117 | const stat = staticMw({
118 | staticDir: assetsPath,
119 | pathPrefix: '/_hal-browser/assets',
120 | maxAge: 3600,
121 | });
122 |
123 | const realOptions = normalizeOptions(options);
124 | return async (ctx, next) => {
125 |
126 | const requestOptions = {
127 | ...realOptions
128 | };
129 | if (options?.fullBody === undefined && '_browser-fullbody' in ctx.query) {
130 | requestOptions.fullBody = true;
131 | }
132 | if (requestOptions.serveAssets && ctx.path.startsWith('/_hal-browser/')) {
133 | return invokeMiddlewares(ctx, [stat]);
134 | }
135 |
136 | // Check to see if the client even wants html.
137 | if (!ctx.accepts('text/html')) {
138 | return next();
139 | }
140 |
141 | // If the url contained _browser-accept, we use that value to override the
142 | // Accept header.
143 | let oldAccept;
144 | if ('_browser-accept' in ctx.query) {
145 | oldAccept = ctx.request.headers.get('Accept');
146 | ctx.request.headers.set('Accept', ctx.query['_browser-accept']);
147 | }
148 |
149 | // Don't do anything if the raw format was requested
150 | if ('_browser-raw' in ctx.query) {
151 | return next();
152 | }
153 |
154 | // Doing the inner request
155 | await next();
156 |
157 | if (oldAccept) {
158 | // Putting the old value back in place
159 | ctx.request.headers.set('Accept', oldAccept);
160 | }
161 |
162 | // We only care about transforming a few content-types
163 | if (!supportedContentTypes.includes(ctx.response.type)) {
164 | return;
165 | }
166 |
167 | // If Content-Disposition: attachment was set, it means the API author
168 | // intended to create a download, we will also not render HTML.
169 | const cd = ctx.response.headers.get('Content-Disposition');
170 | if (cd?.startsWith('attachment')) {
171 | return;
172 | }
173 |
174 | // Find out the client prefers HTML over the content-type that was actually
175 | // returned.
176 | //
177 | // This is useful if the client submitted a lower q= score for text/html.
178 | //
179 | // In addition, we also want to make sure that requests for */* result in
180 | // the original contenttype. Users have to explicitly request text/html.
181 | if (ctx.accepts(...supportedContentTypes, 'text/html') === 'text/html') {
182 | await generateHtmlIndex(ctx, requestOptions);
183 | }
184 |
185 | };
186 |
187 | }
188 |
189 | /**
190 | * This function does a whole bunch of cleanup of the options object, so
191 | * everything else can do less work.
192 | *
193 | * This makes the rest of the source simpler, and also saves time because it
194 | * only happens once.
195 | */
196 | function normalizeOptions(options?: Partial): Options {
197 |
198 | if (typeof options === 'undefined') {
199 | options = {};
200 | }
201 |
202 | const defaults: Partial = {
203 | title: 'API Browser',
204 | theme: 'default',
205 |
206 | stylesheets: [],
207 | defaultLinks: [
208 | {
209 | context: '/',
210 | href: '/',
211 | rel: 'home',
212 | title: 'Home',
213 | }
214 | ],
215 | hiddenRels: [
216 | 'self',
217 | 'curies',
218 | ],
219 | assetBaseUrl: '/_hal-browser/assets/',
220 | serveAssets: true,
221 | fullBody: false,
222 | allLinks: false,
223 | };
224 |
225 | const tmpNavLinks = Object.assign(
226 | defaultNavigationLinks,
227 | options.navigationLinks === undefined ? {} : options.navigationLinks
228 | );
229 |
230 | options.navigationLinks = {};
231 |
232 | for (const navLinkRel of Object.keys(tmpNavLinks)) {
233 |
234 | const navLink = tmpNavLinks[navLinkRel];
235 | if (navLink === null) {
236 | continue;
237 | }
238 | if (navLink === true) {
239 | options.navigationLinks[navLinkRel] = {
240 | defaultTitle: navLinkRel,
241 | position: 'header'
242 | };
243 | } else {
244 | options.navigationLinks[navLinkRel] = navLink;
245 | }
246 | }
247 |
248 | const newOptions:Options = Object.assign(defaults, options) as Options;
249 | return newOptions;
250 |
251 | }
252 |
253 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { Link, State } from 'ketting';
2 |
3 | export type Theme = 'default' | 'spicy-oj' | 'lfo' | 'curveball' | 'xmas' | 'halloween' | null;
4 |
5 | /**
6 | * Options after clean-up.
7 | *
8 | * Basically the same but nothing is optional.
9 | */
10 | export type Options = {
11 | /**
12 | * Application title
13 | */
14 | title: string;
15 |
16 | /**
17 | * Theme
18 | *
19 | * Possible options: spicy-oj, lfo, curveball
20 | */
21 | theme: Theme;
22 |
23 | /**
24 | * List of custom stylesheets to embed.
25 | */
26 | stylesheets: string[];
27 |
28 | /**
29 | * List of links that should be lifted to navigation sections
30 | */
31 | navigationLinks: SureNavigationLinkMap;
32 |
33 | /**
34 | * Where the base assets are located
35 | */
36 | assetBaseUrl: string;
37 |
38 | /**
39 | * Should this plugin handle serving the assets.
40 | *
41 | * Disable if the assets are hosted elsewhere
42 | */
43 | serveAssets: boolean;
44 |
45 | /**
46 | * List of hardcoded links that should show up on every page.
47 | */
48 | defaultLinks: Link[];
49 |
50 | /**
51 | * List of uninteresting link relationships that should be hidden by default.
52 | */
53 | hiddenRels: string[];
54 |
55 | /**
56 | * If turned on, full JSON bodies are always rendered.
57 | *
58 | * This can also be turned on during runtime by adding a ?_browser-fullbody
59 | * query parameter
60 | */
61 | fullBody: boolean;
62 |
63 | /**
64 | * By default the Browser will hide links from the 'Links' table that will
65 | * be rendered as 'navigation buttons', forms (templated links), or are
66 | * considered special (the 'self' link).
67 | *
68 | * While this might be a nicer interface for an average user browsing the
69 | * hypermedia graph, as a developer you might just want to see all the links
70 | *
71 | * Turning 'allLinks' on ensures that everything always shows.
72 | */
73 | allLinks: boolean;
74 | };
75 |
76 | /**
77 | * An object containing a list of navigation links.
78 | */
79 | export type NavigationLinkMap = {
80 |
81 | // Navigation links are nullable so they can be overridden
82 | [rel: string]: NavigationLink | null | true;
83 |
84 | };
85 |
86 | /**
87 | * A normalized NavigationLinkMap.
88 | *
89 | * The nulls are removed, and 'true' is transformed to some default settings.
90 | */
91 | export type SureNavigationLinkMap = {
92 |
93 | // Navigation links are nullable so they can be overridden
94 | [rel: string]: NavigationLink;
95 |
96 | };
97 |
98 | /**
99 | * Where Navigation links should appear
100 | */
101 | export type NavigationPosition = 'header' | 'header-right' | 'pager' | 'alternate';
102 |
103 | /**
104 | * A "NavigationLink" specifies a link that will automatically get recognized
105 | * by the hal-browser and placed in the Navigation bar
106 | */
107 | export type NavigationLink = {
108 |
109 | // A CSS class. If it's not specified, we'll default to "rel-" + rel
110 | cssClass?: string;
111 |
112 | // A title we'll put in the HTML title= attribute if it wasn't overriden.
113 | defaultTitle?: string;
114 |
115 | // A relative URI to an icon. If it's not specified, we'll default to
116 | // [rel].svg
117 | icon?: string;
118 |
119 | // Where the icon should appear
120 | position?: NavigationPosition;
121 |
122 | // Set this to to make an icon appear earlier or later. Default is 0, lower is earlier.
123 | priority?: number;
124 |
125 | // Whether or not to show the 'title' as the button label.
126 | showLabel?: boolean;
127 |
128 | };
129 |
130 | export type PageProps = {
131 | resourceState: State;
132 | originalBody: string;
133 | options: Options;
134 | csrfToken: string | null;
135 | jsonSchemas: Map;
136 | }
137 |
138 | /**
139 | * A very basic type for json schema. It's just barely enough.
140 | */
141 | export type JsonSchema = {
142 | '$id'?: string;
143 | description?: string;
144 | properties?: Record;
145 | items?: JsonSchema | JsonSchema[];
146 | }
147 |
--------------------------------------------------------------------------------
/src/util.ts:
--------------------------------------------------------------------------------
1 | import { Link } from 'ketting';
2 | import * as url from 'url';
3 | import {
4 | NavigationLink,
5 | NavigationPosition,
6 | Options,
7 | Theme,
8 | } from './types.js';
9 | import { State, Client } from 'ketting';
10 | import { Context } from '@curveball/kernel';
11 |
12 | /**
13 | * Returns the list of links for a section.
14 | *
15 | * This function sorts and normalizes the link.
16 | */
17 | export function getNavLinks(links: Link[], options: Options, position: NavigationPosition): Array {
18 |
19 | const result = [];
20 | for (const link of links) {
21 |
22 | // Don't handle templated links.
23 | if (link.templated) {
24 | continue;
25 | }
26 | if (options.navigationLinks[link.rel] === undefined) {
27 | continue;
28 | }
29 | const nl = options.navigationLinks[link.rel];
30 |
31 | if (
32 | (typeof nl.position === 'undefined' && position !== 'header') ||
33 | (typeof nl.position !== 'undefined' && nl.position !== position)
34 | ) {
35 | continue;
36 | }
37 |
38 | result.push({
39 | rel: link.rel,
40 | context: link.context,
41 | href: link.href,
42 | title: link.title ? link.title : ( nl.defaultTitle ? nl.defaultTitle : link.rel ),
43 | type: link.type,
44 | icon: url.resolve(options.assetBaseUrl, nl.icon ? nl.icon : 'icon/' + link.rel + '.svg'),
45 | priority: nl.priority ? nl.priority : 0,
46 | showLabel: nl.showLabel,
47 | });
48 |
49 | }
50 |
51 | return result.sort( (a, b) => a.priority - b.priority);
52 |
53 | }
54 |
55 | /**
56 | * We use Ketting for a lot of the parsing.
57 | *
58 | * The main object that contains a response in Ketting is a 'State' object,
59 | * to get a State, we need a Response object, as it's part of the fetch()
60 | * specification.
61 | *
62 | * So we go from ctx.response, to Response, to State.
63 | */
64 | export async function contextToState(ctx: Context): Promise {
65 |
66 | /**
67 | * We need a fake bookmark url
68 | */
69 | const client = new Client('http://hal-browser.test');
70 | const headers: Record = {};
71 | for(const [name, value] of Object.entries(ctx.response.headers.getAll())) {
72 | if (typeof value === 'number') {
73 | headers[name] = value.toString();
74 | } else if (Array.isArray(value)) {
75 | headers[name] = value.join(', ');
76 | } else {
77 | headers[name] = value;
78 | }
79 | }
80 |
81 | const response = new Response(ctx.response.body, {
82 | status: ctx.status,
83 | headers,
84 | });
85 |
86 | return client.getStateForResponse(ctx.path, response as any);
87 |
88 | }
89 |
90 | /**
91 | * This is a very rudimentary Templated URI parser.
92 | *
93 | * Currently we don't parse the entire syntax, because we are using templated
94 | * URIs to generate Javascript-free standard HTML forms.
95 | *
96 | * In these HTML forms, we can only let users define things in the 'query'
97 | * part of the URI.
98 | *
99 | * If the parser encounters a templated URI that's not parsable, it returns
100 | * null, otherwise it returns a tuple with three elements:
101 | *
102 | * 1. The URI that's the target of the form (without query params)
103 | * 2. An object containing values that should be added as hidden fields and
104 | * values.
105 | * 3. An array with field names that should be rendered as input fields.
106 | *
107 | * Examples of supported formats:
108 | * http://example/?foo={bar}
109 | * http://example/{?foo}
110 | * http://example/?foo=bar{&bar}
111 | * http://example/?foo=1{&bar,baz,zim}
112 | */
113 | export function getFieldsFromTemplatedUri(input: string): null | [string, Record, string[]] {
114 |
115 | const fields: string[] = [];
116 | const hiddenFields: Record = {};
117 |
118 | // We only support 2 styles of templated links:
119 | //
120 | // https://foo/{?a}{?b}
121 | //
122 | // and
123 | //
124 | // https://foo/?foo={bar}
125 | //
126 | // More formats might be added if they are requested.
127 |
128 | // This regex finds blocks like {?foo}
129 | const reg = /{(\?|&)[A-Za-z,]+}/g;
130 |
131 | const matches = input.match(reg);
132 | if (matches) {
133 | for (const match of matches) {
134 | // Stripping off {? and }
135 | const fieldNames = match.slice(2, -1);
136 |
137 | // Splitting at ','
138 | for (const fieldName of fieldNames.split(',')) {
139 | fields.push(fieldName);
140 | }
141 | }
142 | }
143 |
144 | // Removing {?foo} blocks.
145 | const [target, querystring] = input.replace(reg, '').split('?');
146 |
147 | if (target.indexOf('{') !== -1) {
148 | // We don't support expressions in the path/hostname part of the uri
149 | return null;
150 | }
151 |
152 | if (querystring) {
153 | for (const qsPart of querystring.split('&')) {
154 |
155 | const [field, value] = qsPart.split('=');
156 | if (!value) {
157 | // No equals sign
158 | hiddenFields[field] = '';
159 | } else if (value.match(/{.*}$/)) {
160 | // Its a parameter in the form foo={bar}
161 | fields.push(field);
162 | } else {
163 | // It's a regular field such as 'foo=bar'
164 | hiddenFields[field] = decodeURIComponent(value);
165 | }
166 |
167 | }
168 | }
169 |
170 | return [
171 | target,
172 | hiddenFields,
173 | fields
174 | ];
175 |
176 | }
177 |
178 | /**
179 | * Returns the default theme
180 | *
181 | * This might alternate depending on the time of the year.
182 | */
183 | export function getDefaultTheme(): Theme {
184 |
185 | const d = new Date();
186 | if (d.getMonth()===9 && d.getDate()>25 || d.getMonth()===10 && d.getDate()<2) return 'halloween';
187 | if (d.getMonth()===11 && d.getDate()>14) return 'xmas';
188 |
189 | return 'curveball';
190 |
191 | }
192 |
--------------------------------------------------------------------------------
/test/json-test.ts:
--------------------------------------------------------------------------------
1 | import { Application } from '@curveball/kernel';
2 | import browser from '../src/index.js';
3 | import { strict as assert } from 'node:assert';
4 | import { describe, it } from 'node:test';
5 |
6 | describe('Browser middleware integration test', () => {
7 |
8 | it('should render a HTML page when an `Accept` header with text/html is emitted', async() => {
9 |
10 | const app = new Application();
11 | const mw = browser();
12 | app.use(mw);
13 |
14 | app.use( ctx => {
15 | ctx.response.body = { hello: 'world' };
16 | ctx.response.type = 'application/json';
17 | });
18 |
19 | const resp = await app.subRequest('GET', '/', {
20 | Accept: 'text/html'
21 | });
22 |
23 | assert.equal(resp.status, 200);
24 | assert.ok(resp.is('html'));
25 | assert.ok(resp.body.includes(''));
26 |
27 | });
28 |
29 | });
30 |
--------------------------------------------------------------------------------
/test/templated-uri.ts:
--------------------------------------------------------------------------------
1 | import { getFieldsFromTemplatedUri } from '../src/util.js';
2 | import { describe, it } from 'node:test';
3 | import { strict as assert } from 'node:assert';
4 |
5 | describe('Templated URI parser', () => {
6 |
7 | const tests: any = [
8 | [
9 | 'http://example',
10 | ['http://example', {}, []]
11 | ],
12 | [
13 | 'http://example?foo=bar',
14 | ['http://example', {foo: 'bar'}, []]
15 | ],
16 | [
17 | 'http://example{?foo}',
18 | ['http://example', {}, ['foo']]
19 | ],
20 | [
21 | 'http://example{?foo,bar}',
22 | ['http://example', {}, ['foo', 'bar']]
23 | ],
24 | [
25 | 'http://example?foo=1{&bar}',
26 | ['http://example', {foo: '1'}, ['bar']]
27 | ],
28 | [
29 | 'http://example?foo=1{&bar,baz}',
30 | ['http://example', {foo: '1'}, ['bar', 'baz']]
31 | ],
32 | [
33 | 'http://example?foo=1{&bar,baz}&zim=2',
34 | ['http://example', {foo: '1', zim: '2'}, ['bar', 'baz']]
35 | ],
36 | [
37 | 'http://example/?foo=1{&bar,baz}&zim=2&fizz',
38 | ['http://example/', {foo: '1', zim: '2', 'fizz': ''}, ['bar', 'baz']]
39 | ],
40 | [
41 | 'http://{host}/blabla',
42 | null,
43 | ],
44 | [
45 | 'http://example/?foo={foo}&bar={bar}',
46 | ['http://example/', {}, ['foo', 'bar']]
47 | ]
48 | ];
49 |
50 | for(const [input, expected] of tests) {
51 |
52 | it(`should correctly parse "${input}"`, () => {
53 |
54 | assert.deepEqual(
55 | getFieldsFromTemplatedUri(input),
56 | expected
57 | );
58 |
59 | });
60 |
61 | }
62 |
63 | });
64 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "node16",
4 | "target": "es2022",
5 |
6 | "strict": true,
7 | "noFallthroughCasesInSwitch": true,
8 | "noUnusedLocals": true,
9 |
10 | "sourceMap": true,
11 | "outDir": "dist",
12 | "baseUrl": ".",
13 | "paths": {
14 | "*": [
15 | "src/types/*"
16 | ]
17 | },
18 | "lib": [
19 | "DOM",
20 | "ES2022"
21 | ],
22 | "declaration": true,
23 | "jsx": "React"
24 | },
25 | "include": [
26 | "src/**/*"
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/util/fetch-link-relation-data.mjs:
--------------------------------------------------------------------------------
1 | import { JSDOM } from 'jsdom';
2 | import { writeFileSync } from 'node:fs';
3 |
4 | const response = await fetch('https://www.iana.org/assignments/link-relations/link-relations.xml');
5 | const body = await response.text();
6 |
7 | const dom = new JSDOM(body).window.document;
8 |
9 | const relationships = {};
10 |
11 | for(const record of dom.getElementsByTagName('record')) {
12 |
13 | const rel = record.getElementsByTagName('value')[0].textContent;
14 | const description = record.getElementsByTagName('description')[0].textContent
15 | .replace('\n ','')
16 | .trim('\n').trim(' ');
17 |
18 | const xref = record.getElementsByTagName('xref')[0];
19 | const xrefType = xref.getAttribute('type');
20 | const xrefData = xref.getAttribute('data');
21 |
22 | let href;
23 |
24 | switch(xrefType) {
25 |
26 | case 'uri' :
27 | href = xrefData;
28 | break;
29 | case 'rfc' :
30 | href = 'https://datatracker.ietf.org/doc/html/' + xrefData;
31 | break;
32 | default:
33 | console.error('Oh no: %s', xrefType);
34 | break;
35 |
36 | }
37 |
38 | relationships[rel] = {
39 | description,
40 | href,
41 | };
42 |
43 | }
44 |
45 | // console.log(relationships);
46 | console.log(JSON.stringify(relationships, undefined, 2));
47 |
--------------------------------------------------------------------------------