From 5628567f106709a2975ac08699ba32bc1d1c8877 Mon Sep 17 00:00:00 2001 From: Michael Quigley Date: Tue, 2 Aug 2022 11:59:25 -0400 Subject: [PATCH] openapi client for the js ui (#14) --- bin/generate_rest.sh | 9 +- ui/api/gateway/index.js | 281 ++++++++++++++++++++++++++++++++++++ ui/api/gateway/spec.js | 23 +++ ui/api/identity.js | 40 +++++ ui/api/metadata.js | 14 ++ ui/api/tunnel.js | 55 +++++++ ui/api/types.js | 56 +++++++ ui/package-lock.json | 135 ++++++++++++++++- ui/package.json | 5 +- ui/src/App.js | 24 ++- ui/src/App.module.css | 0 ui/src/Version.js | 24 +++ ui/src/api/gateway/index.js | 281 ++++++++++++++++++++++++++++++++++++ ui/src/api/gateway/spec.js | 23 +++ ui/src/api/identity.js | 40 +++++ ui/src/api/metadata.js | 14 ++ ui/src/api/tunnel.js | 55 +++++++ ui/src/api/types.js | 56 +++++++ 18 files changed, 1124 insertions(+), 11 deletions(-) create mode 100644 ui/api/gateway/index.js create mode 100644 ui/api/gateway/spec.js create mode 100644 ui/api/identity.js create mode 100644 ui/api/metadata.js create mode 100644 ui/api/tunnel.js create mode 100644 ui/api/types.js create mode 100644 ui/src/App.module.css create mode 100644 ui/src/Version.js create mode 100644 ui/src/api/gateway/index.js create mode 100644 ui/src/api/gateway/spec.js create mode 100644 ui/src/api/identity.js create mode 100644 ui/src/api/metadata.js create mode 100644 ui/src/api/tunnel.js create mode 100644 ui/src/api/types.js diff --git a/bin/generate_rest.sh b/bin/generate_rest.sh index c359cf17..417d6663 100755 --- a/bin/generate_rest.sh +++ b/bin/generate_rest.sh @@ -3,10 +3,14 @@ set -euo pipefail command -v swagger >/dev/null 2>&1 || { - echo >&2 "Command 'swagger' not installed. See: https://github.com/go-swagger/go-swagger for installation" + echo >&2 "command 'swagger' not installed. see: https://github.com/go-swagger/go-swagger for installation" exit 1 } +command -v openapi >/dev/null 2>&1 || { + echo >&2 "command 'openapi' not installed. see: https://www.npmjs.com/package/openapi-client for installation" +} + scriptPath=$(realpath $0) scriptDir=$(dirname "$scriptPath") @@ -19,3 +23,6 @@ swagger generate server -P rest_model_zrok.Principal -f "$zrokSpec" -s rest_serv echo "...generating zrok client" swagger generate client -P rest_model_zrok.Principal -f "$zrokSpec" -c rest_client_zrok -t "$zrokDir" -m "rest_model_zrok" + +echo "...generating js client" +openapi -s specs/zrok.yml -o ui/api -l js \ No newline at end of file diff --git a/ui/api/gateway/index.js b/ui/api/gateway/index.js new file mode 100644 index 00000000..881798d7 --- /dev/null +++ b/ui/api/gateway/index.js @@ -0,0 +1,281 @@ +// Auto-generated, edits will be overwritten +import spec from './spec' + +export class ServiceError extends Error {} + +let options = {} + +export function init(serviceOptions) { + options = serviceOptions +} + +export function request(op, parameters, attempt) { + if (!attempt) attempt = 1; + return acquireRights(op, spec, options) + .then(rights => { + parameters = parameters || {} + const baseUrl = getBaseUrl(spec) + let reqInfo = { parameters, baseUrl } + if (options.processRequest) { + reqInfo = options.processRequest(op, reqInfo) + } + const req = buildRequest(op, reqInfo.baseUrl, reqInfo.parameters, rights) + return makeFetchRequest(req) + .then(res => processResponse(req, res, attempt, options), e => processError(req, e)) + .then(outcome => outcome.retry ? request(op, parameters, attempt + 1) : outcome.res) + }) +} + +function acquireRights(op, spec, options) { + if (op.security && options.getAuthorization) { + return op.security.reduce((promise, security) => { + return promise.then(rights => { + const securityDefinition = spec.securityDefinitions[security.id] + return options.getAuthorization(security, securityDefinition, op) + .then(auth => { + rights[security.id] = auth + return rights + }) + }) + }, Promise.resolve({})) + } + return Promise.resolve({}) +} + +function makeFetchRequest(req) { + let fetchOptions = { + compress: true, + method: (req.method || 'get').toUpperCase(), + headers: req.headers, + body: req.body ? JSON.stringify(req.body) : undefined + } + + if (options.fetchOptions) { + const opts = options.fetchOptions + const headers = opts.headers + ? Object.assign(fetchOptions.headers, opts.headers) + : fetchOptions.headers + + fetchOptions = Object.assign({}, fetchOptions, opts) + fetchOptions.headers = headers + } + + let promise = fetch(req.url, fetchOptions) + return promise +} + +function buildRequest(op, baseUrl, parameters, rights) { + let paramGroups = groupParams(op, parameters) + paramGroups = applyAuthorization(paramGroups, rights, spec) + const url = buildUrl(op, baseUrl, paramGroups, spec) + const headers = buildHeaders(op, paramGroups) + const body = buildBody(parameters.body) + return { + method: op.method, + url, + headers, + body + } +} + +function groupParams(op, parameters) { + const groups = ['header', 'path', 'query', 'formData'].reduce((groups, name) => { + groups[name] = formatParamsGroup(groups[name]) + return groups + }, parameters) + if (!groups.header) groups.header = {} + return groups +} + +function formatParamsGroup(groups) { + return Object.keys(groups || {}).reduce((g, name) => { + const param = groups[name] + if (param !== undefined) { + g[name] = formatParam(param) + } + return g + }, {}) +} + +function formatParam(param) { + if (param === undefined || param === null) return '' + else if (param instanceof Date) return param.toJSON() + else if (Array.isArray(param)) return param + else return param.toString() +} + +function buildUrl(op, baseUrl, parameters, spec) { + let url = `${baseUrl}${op.path}` + if (parameters.path) { + url = Object.keys(parameters.path) + .reduce((url, name) => url.replace(`{${name}}`, parameters.path[name]), url) + } + const query = createQueryString(parameters.query) + return url + query +} + +function getBaseUrl(spec) { + return options.url || `${spec.schemes[0] || 'https'}://${spec.host}${spec.basePath}` +} + +function createQueryParam(name, value) { + const v = formatParam(value) + if (v && typeof v === 'string') return `${name}=${encodeURIComponent(v)}` + return name; +} + +function createQueryString(query) { + const names = Object.keys(query || {}) + if (!names.length) return '' + const params = names.map(name => ({name, value: query[name]})) + .reduce((acc, value) => { + if (Array.isArray(value.value)) { + return acc.concat(value.value) + } else { + acc.push(createQueryParam(value.name, value.value)) + return acc + } + }, []) + return '?' + params.sort().join('&') +} + +function buildHeaders(op, parameters) { + const headers = {} + + let accepts + if (op.accepts && op.accepts.length) accepts = op.accepts + else if (spec.accepts && spec.accepts.length) accepts = spec.accepts + else accepts = [ 'application/json' ] + + headers.Accept = accepts.join(', ') + + let contentType + if (op.contentTypes && op.contentTypes[0]) contentType = op.contentTypes[0] + else if (spec.contentTypes && spec.contentTypes[0]) contentType = spec.contentTypes[0] + if (contentType) headers['Content-Type'] = contentType + + return Object.assign(headers, parameters.header) +} + +function buildBody(bodyParams) { + if (bodyParams) { + if (bodyParams.body) return bodyParams.body + const key = Object.keys(bodyParams)[0] + if (key) return bodyParams[key] + } + return undefined +} + +function resolveAuthHeaderName(headerName){ + if (options.authorizationHeader && headerName.toLowerCase() === 'authorization') { + return options.authorizationHeader + } else { + return headerName + } +} + +function applyAuthorization(req, rights, spec) { + Object.keys(rights).forEach(name => { + const rightsInfo = rights[name] + const definition = spec.securityDefinitions[name] + switch (definition.type) { + case 'basic': + const creds = `${rightsInfo.username}:${rightsInfo.password}` + const token = (typeof window !== 'undefined' && window.btoa) + ? window.btoa(creds) + : new Buffer(creds).toString('base64') + req.header[resolveAuthHeaderName('Authorization')] = `Basic ${token}` + break + case 'oauth2': + req.header[resolveAuthHeaderName('Authorization')] = `Bearer ${rightsInfo.token}` + break + case 'apiKey': + if (definition.in === 'header') { + req.header[resolveAuthHeaderName(definition.name)] = rightsInfo.apiKey + } else if (definition.in === 'query') { + req.query[definition.name] = rightsInfo.apiKey + } else { + throw new Error(`Api key must be in header or query not '${definition.in}'`) + } + break + default: + throw new Error(`Security definition type '${definition.type}' not supported`) + } + }) + return req +} + +function processResponse(req, response, attempt, options) { + const format = response.ok ? formatResponse : formatServiceError + const contentType = response.headers.get('content-type') || '' + + let parse + if (response.status === 204) { + parse = Promise.resolve() + } else if (~contentType.indexOf('json')) { + parse = response.json() + } else if (~contentType.indexOf('octet-stream')) { + parse = response.blob() + } else if (~contentType.indexOf('text')) { + parse = response.text() + } else { + parse = Promise.resolve() + } + + return parse + .then(data => format(response, data, options)) + .then(res => { + if (options.processResponse) return options.processResponse(req, res, attempt) + else return Promise.resolve({ res }) + }) +} + +function formatResponse(response, data, options) { + return { raw: response, data } +} + +function formatServiceError(response, data, options) { + if (options.formatServiceError) { + data = options.formatServiceError(response, data) + } else { + const serviceError = new ServiceError() + if (data) { + if (typeof data === 'string') serviceError.message = data + else { + if (data.message) serviceError.message = data.message + if (data.body) serviceError.body = data.body + else serviceError.body = data + } + + if (data.code) serviceError.code = data.code + } else { + serviceError.message = response.statusText + } + serviceError.status = response.status + data = serviceError + } + return { raw: response, data, error: true } +} + +function processError(req, error) { + const { processError } = options + const res = { res: { raw: {}, data: error, error: true } } + + return Promise.resolve(processError ? processError(req, res) : res) +} + +const COLLECTION_DELIM = { csv: ',', multi: '&', pipes: '|', ssv: ' ', tsv: '\t' } + +export function formatArrayParam(array, format, name) { + if (!array) return + if (format === 'multi') return array.map(value => createQueryParam(name, value)) + const delim = COLLECTION_DELIM[format] + if (!delim) throw new Error(`Invalid collection format '${format}'`) + return array.map(formatParam).join(delim) +} + +export function formatDate(date, format) { + if (!date) return + const str = date.toISOString() + return (format === 'date') ? str.split('T')[0] : str +} diff --git a/ui/api/gateway/spec.js b/ui/api/gateway/spec.js new file mode 100644 index 00000000..1e69e5a3 --- /dev/null +++ b/ui/api/gateway/spec.js @@ -0,0 +1,23 @@ + +// Auto-generated, edits will be overwritten +const spec = { + 'host': 'localhost', + 'schemes': [ + 'http' + ], + 'basePath': '/api/v1', + 'contentTypes': [ + 'application/zrok.v1+json' + ], + 'accepts': [ + 'application/zrok.v1+json' + ], + 'securityDefinitions': { + 'key': { + 'type': 'apiKey', + 'in': 'header', + 'name': 'x-token' + } + } +} +export default spec diff --git a/ui/api/identity.js b/ui/api/identity.js new file mode 100644 index 00000000..a13c4b3f --- /dev/null +++ b/ui/api/identity.js @@ -0,0 +1,40 @@ +/** @module identity */ +// Auto-generated, edits will be overwritten +import * as gateway from './gateway' + +/** + * @param {object} options Optional options + * @param {module:types.accountRequest} [options.body] + * @return {Promise} account created + */ +export function createAccount(options) { + if (!options) options = {} + const parameters = { + body: { + body: options.body + } + } + return gateway.request(createAccountOperation, parameters) +} + +/** + */ +export function enable() { + return gateway.request(enableOperation) +} + +const createAccountOperation = { + path: '/account', + contentTypes: ['application/zrok.v1+json'], + method: 'post' +} + +const enableOperation = { + path: '/enable', + method: 'post', + security: [ + { + id: 'key' + } + ] +} diff --git a/ui/api/metadata.js b/ui/api/metadata.js new file mode 100644 index 00000000..b782394b --- /dev/null +++ b/ui/api/metadata.js @@ -0,0 +1,14 @@ +/** @module metadata */ +// Auto-generated, edits will be overwritten +import * as gateway from './gateway' + +/** + */ +export function version() { + return gateway.request(versionOperation) +} + +const versionOperation = { + path: '/version', + method: 'get' +} diff --git a/ui/api/tunnel.js b/ui/api/tunnel.js new file mode 100644 index 00000000..3451aa6a --- /dev/null +++ b/ui/api/tunnel.js @@ -0,0 +1,55 @@ +/** @module tunnel */ +// Auto-generated, edits will be overwritten +import * as gateway from './gateway' + +/** + * @param {object} options Optional options + * @param {module:types.tunnelRequest} [options.body] + * @return {Promise} tunnel created + */ +export function tunnel(options) { + if (!options) options = {} + const parameters = { + body: { + body: options.body + } + } + return gateway.request(tunnelOperation, parameters) +} + +/** + * @param {object} options Optional options + * @param {module:types.untunnelRequest} [options.body] + * @return {Promise} tunnel removed + */ +export function untunnel(options) { + if (!options) options = {} + const parameters = { + body: { + body: options.body + } + } + return gateway.request(untunnelOperation, parameters) +} + +const tunnelOperation = { + path: '/tunnel', + contentTypes: ['application/zrok.v1+json'], + method: 'post', + security: [ + { + id: 'key' + } + ] +} + +const untunnelOperation = { + path: '/untunnel', + contentTypes: ['application/zrok.v1+json'], + method: 'delete', + security: [ + { + id: 'key' + } + ] +} diff --git a/ui/api/types.js b/ui/api/types.js new file mode 100644 index 00000000..6c2b49a7 --- /dev/null +++ b/ui/api/types.js @@ -0,0 +1,56 @@ +/** @module types */ +// Auto-generated, edits will be overwritten + +/** + * @typedef accountRequest + * @memberof module:types + * + * @property {string} username + * @property {string} password + */ + +/** + * @typedef accountResponse + * @memberof module:types + * + * @property {string} token + */ + +/** + * @typedef enableResponse + * @memberof module:types + * + * @property {string} identity + * @property {string} cfg + */ + +/** + * @typedef principal + * @memberof module:types + * + * @property {number} id + * @property {string} username + * @property {string} token + */ + +/** + * @typedef tunnelRequest + * @memberof module:types + * + * @property {string} identity + * @property {string} endpoint + */ + +/** + * @typedef tunnelResponse + * @memberof module:types + * + * @property {string} service + */ + +/** + * @typedef untunnelRequest + * @memberof module:types + * + * @property {string} service + */ diff --git a/ui/package-lock.json b/ui/package-lock.json index f4072a17..b7bac1d8 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,5 +1,5 @@ { - "name": "zrok-app", + "name": "ui", "version": "0.1.0", "lockfileVersion": 1, "requires": true, @@ -4674,6 +4674,26 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" }, + "encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "requires": { + "iconv-lite": "^0.6.2" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "enhanced-resolve": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", @@ -5970,6 +5990,23 @@ "function-bind": "^1.1.1" } }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true + } + } + }, "has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -6489,6 +6526,16 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "isomorphic-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", + "integrity": "sha512-9c4TNAKYXM5PRyVcwUZrF3W09nQ+sO7+jydgs4ZGW9dhsLG2VOlISJABombdQqQRXCwuYG3sYV/puGf5rp0qmA==", + "dev": true, + "requires": { + "node-fetch": "^1.0.1", + "whatwg-fetch": ">=0.10.0" + } + }, "istanbul-lib-coverage": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", @@ -8428,6 +8475,12 @@ "thunky": "^1.0.2" } }, + "mustache": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-2.3.2.tgz", + "integrity": "sha512-KpMNwdQsYz3O/SBS1qJ/o3sqUJ5wSb8gb0pul8CO0S56b9Y2ALm8zCfsjPXsqGFfoNBkDwZuZIAjhsZI03gYVQ==", + "dev": true + }, "nanoid": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", @@ -8464,6 +8517,24 @@ } } }, + "node-fetch": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "dev": true, + "requires": { + "encoding": "^0.1.11", + "is-stream": "^1.0.1" + }, + "dependencies": { + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true + } + } + }, "node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -8640,6 +8711,68 @@ "is-wsl": "^2.2.0" } }, + "openapi-client": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/openapi-client/-/openapi-client-1.0.5.tgz", + "integrity": "sha512-sxbNcyWJRVLfa83YwEnlV6psW0cty58HTyU+tDbCePq5QIgXW1ZDy4iRWwqnAz25jKt1V19A7Mp9D08sxlZiSg==", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "commander": "^2.9.0", + "isomorphic-fetch": "^2.2.1", + "js-yaml": "^3.6.1", + "mkdirp": "^0.5.1", + "mustache": "^2.2.1" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true + } + } + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", diff --git a/ui/package.json b/ui/package.json index ff7b61d9..19840c19 100644 --- a/ui/package.json +++ b/ui/package.json @@ -35,5 +35,8 @@ "last 1 safari version" ] }, - "proxy": "http://localhost:10888" + "proxy": "http://localhost:10888", + "devDependencies": { + "openapi-client": "^1.0.5" + } } diff --git a/ui/src/App.js b/ui/src/App.js index 60b44a55..7c81e160 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -1,11 +1,19 @@ -function App() { - return ( -
-
-

zrok

-
-
- ); +import Version from './Version'; +import * as gateway from "./api/gateway"; + +gateway.init({ + url: '/api/v1' +}); + +const App = () => { + return ( +
+
+

zrok

+ +
+
+ ); } export default App; diff --git a/ui/src/App.module.css b/ui/src/App.module.css new file mode 100644 index 00000000..e69de29b diff --git a/ui/src/Version.js b/ui/src/Version.js new file mode 100644 index 00000000..1b322186 --- /dev/null +++ b/ui/src/Version.js @@ -0,0 +1,24 @@ +import {useEffect, useState} from "react"; +import * as metadata from "./api/metadata"; + +const Version = () => { + const [v, setV] = useState(''); + + useEffect(() => { + let mounted = true; + metadata.version().then(resp => { + if(mounted) { + setV(resp.data); + } + }); + return () => { + mounted = false; + }; + }, []); + + return ( +

{v}

+ ); +} + +export default Version; \ No newline at end of file diff --git a/ui/src/api/gateway/index.js b/ui/src/api/gateway/index.js new file mode 100644 index 00000000..881798d7 --- /dev/null +++ b/ui/src/api/gateway/index.js @@ -0,0 +1,281 @@ +// Auto-generated, edits will be overwritten +import spec from './spec' + +export class ServiceError extends Error {} + +let options = {} + +export function init(serviceOptions) { + options = serviceOptions +} + +export function request(op, parameters, attempt) { + if (!attempt) attempt = 1; + return acquireRights(op, spec, options) + .then(rights => { + parameters = parameters || {} + const baseUrl = getBaseUrl(spec) + let reqInfo = { parameters, baseUrl } + if (options.processRequest) { + reqInfo = options.processRequest(op, reqInfo) + } + const req = buildRequest(op, reqInfo.baseUrl, reqInfo.parameters, rights) + return makeFetchRequest(req) + .then(res => processResponse(req, res, attempt, options), e => processError(req, e)) + .then(outcome => outcome.retry ? request(op, parameters, attempt + 1) : outcome.res) + }) +} + +function acquireRights(op, spec, options) { + if (op.security && options.getAuthorization) { + return op.security.reduce((promise, security) => { + return promise.then(rights => { + const securityDefinition = spec.securityDefinitions[security.id] + return options.getAuthorization(security, securityDefinition, op) + .then(auth => { + rights[security.id] = auth + return rights + }) + }) + }, Promise.resolve({})) + } + return Promise.resolve({}) +} + +function makeFetchRequest(req) { + let fetchOptions = { + compress: true, + method: (req.method || 'get').toUpperCase(), + headers: req.headers, + body: req.body ? JSON.stringify(req.body) : undefined + } + + if (options.fetchOptions) { + const opts = options.fetchOptions + const headers = opts.headers + ? Object.assign(fetchOptions.headers, opts.headers) + : fetchOptions.headers + + fetchOptions = Object.assign({}, fetchOptions, opts) + fetchOptions.headers = headers + } + + let promise = fetch(req.url, fetchOptions) + return promise +} + +function buildRequest(op, baseUrl, parameters, rights) { + let paramGroups = groupParams(op, parameters) + paramGroups = applyAuthorization(paramGroups, rights, spec) + const url = buildUrl(op, baseUrl, paramGroups, spec) + const headers = buildHeaders(op, paramGroups) + const body = buildBody(parameters.body) + return { + method: op.method, + url, + headers, + body + } +} + +function groupParams(op, parameters) { + const groups = ['header', 'path', 'query', 'formData'].reduce((groups, name) => { + groups[name] = formatParamsGroup(groups[name]) + return groups + }, parameters) + if (!groups.header) groups.header = {} + return groups +} + +function formatParamsGroup(groups) { + return Object.keys(groups || {}).reduce((g, name) => { + const param = groups[name] + if (param !== undefined) { + g[name] = formatParam(param) + } + return g + }, {}) +} + +function formatParam(param) { + if (param === undefined || param === null) return '' + else if (param instanceof Date) return param.toJSON() + else if (Array.isArray(param)) return param + else return param.toString() +} + +function buildUrl(op, baseUrl, parameters, spec) { + let url = `${baseUrl}${op.path}` + if (parameters.path) { + url = Object.keys(parameters.path) + .reduce((url, name) => url.replace(`{${name}}`, parameters.path[name]), url) + } + const query = createQueryString(parameters.query) + return url + query +} + +function getBaseUrl(spec) { + return options.url || `${spec.schemes[0] || 'https'}://${spec.host}${spec.basePath}` +} + +function createQueryParam(name, value) { + const v = formatParam(value) + if (v && typeof v === 'string') return `${name}=${encodeURIComponent(v)}` + return name; +} + +function createQueryString(query) { + const names = Object.keys(query || {}) + if (!names.length) return '' + const params = names.map(name => ({name, value: query[name]})) + .reduce((acc, value) => { + if (Array.isArray(value.value)) { + return acc.concat(value.value) + } else { + acc.push(createQueryParam(value.name, value.value)) + return acc + } + }, []) + return '?' + params.sort().join('&') +} + +function buildHeaders(op, parameters) { + const headers = {} + + let accepts + if (op.accepts && op.accepts.length) accepts = op.accepts + else if (spec.accepts && spec.accepts.length) accepts = spec.accepts + else accepts = [ 'application/json' ] + + headers.Accept = accepts.join(', ') + + let contentType + if (op.contentTypes && op.contentTypes[0]) contentType = op.contentTypes[0] + else if (spec.contentTypes && spec.contentTypes[0]) contentType = spec.contentTypes[0] + if (contentType) headers['Content-Type'] = contentType + + return Object.assign(headers, parameters.header) +} + +function buildBody(bodyParams) { + if (bodyParams) { + if (bodyParams.body) return bodyParams.body + const key = Object.keys(bodyParams)[0] + if (key) return bodyParams[key] + } + return undefined +} + +function resolveAuthHeaderName(headerName){ + if (options.authorizationHeader && headerName.toLowerCase() === 'authorization') { + return options.authorizationHeader + } else { + return headerName + } +} + +function applyAuthorization(req, rights, spec) { + Object.keys(rights).forEach(name => { + const rightsInfo = rights[name] + const definition = spec.securityDefinitions[name] + switch (definition.type) { + case 'basic': + const creds = `${rightsInfo.username}:${rightsInfo.password}` + const token = (typeof window !== 'undefined' && window.btoa) + ? window.btoa(creds) + : new Buffer(creds).toString('base64') + req.header[resolveAuthHeaderName('Authorization')] = `Basic ${token}` + break + case 'oauth2': + req.header[resolveAuthHeaderName('Authorization')] = `Bearer ${rightsInfo.token}` + break + case 'apiKey': + if (definition.in === 'header') { + req.header[resolveAuthHeaderName(definition.name)] = rightsInfo.apiKey + } else if (definition.in === 'query') { + req.query[definition.name] = rightsInfo.apiKey + } else { + throw new Error(`Api key must be in header or query not '${definition.in}'`) + } + break + default: + throw new Error(`Security definition type '${definition.type}' not supported`) + } + }) + return req +} + +function processResponse(req, response, attempt, options) { + const format = response.ok ? formatResponse : formatServiceError + const contentType = response.headers.get('content-type') || '' + + let parse + if (response.status === 204) { + parse = Promise.resolve() + } else if (~contentType.indexOf('json')) { + parse = response.json() + } else if (~contentType.indexOf('octet-stream')) { + parse = response.blob() + } else if (~contentType.indexOf('text')) { + parse = response.text() + } else { + parse = Promise.resolve() + } + + return parse + .then(data => format(response, data, options)) + .then(res => { + if (options.processResponse) return options.processResponse(req, res, attempt) + else return Promise.resolve({ res }) + }) +} + +function formatResponse(response, data, options) { + return { raw: response, data } +} + +function formatServiceError(response, data, options) { + if (options.formatServiceError) { + data = options.formatServiceError(response, data) + } else { + const serviceError = new ServiceError() + if (data) { + if (typeof data === 'string') serviceError.message = data + else { + if (data.message) serviceError.message = data.message + if (data.body) serviceError.body = data.body + else serviceError.body = data + } + + if (data.code) serviceError.code = data.code + } else { + serviceError.message = response.statusText + } + serviceError.status = response.status + data = serviceError + } + return { raw: response, data, error: true } +} + +function processError(req, error) { + const { processError } = options + const res = { res: { raw: {}, data: error, error: true } } + + return Promise.resolve(processError ? processError(req, res) : res) +} + +const COLLECTION_DELIM = { csv: ',', multi: '&', pipes: '|', ssv: ' ', tsv: '\t' } + +export function formatArrayParam(array, format, name) { + if (!array) return + if (format === 'multi') return array.map(value => createQueryParam(name, value)) + const delim = COLLECTION_DELIM[format] + if (!delim) throw new Error(`Invalid collection format '${format}'`) + return array.map(formatParam).join(delim) +} + +export function formatDate(date, format) { + if (!date) return + const str = date.toISOString() + return (format === 'date') ? str.split('T')[0] : str +} diff --git a/ui/src/api/gateway/spec.js b/ui/src/api/gateway/spec.js new file mode 100644 index 00000000..1e69e5a3 --- /dev/null +++ b/ui/src/api/gateway/spec.js @@ -0,0 +1,23 @@ + +// Auto-generated, edits will be overwritten +const spec = { + 'host': 'localhost', + 'schemes': [ + 'http' + ], + 'basePath': '/api/v1', + 'contentTypes': [ + 'application/zrok.v1+json' + ], + 'accepts': [ + 'application/zrok.v1+json' + ], + 'securityDefinitions': { + 'key': { + 'type': 'apiKey', + 'in': 'header', + 'name': 'x-token' + } + } +} +export default spec diff --git a/ui/src/api/identity.js b/ui/src/api/identity.js new file mode 100644 index 00000000..a13c4b3f --- /dev/null +++ b/ui/src/api/identity.js @@ -0,0 +1,40 @@ +/** @module identity */ +// Auto-generated, edits will be overwritten +import * as gateway from './gateway' + +/** + * @param {object} options Optional options + * @param {module:types.accountRequest} [options.body] + * @return {Promise} account created + */ +export function createAccount(options) { + if (!options) options = {} + const parameters = { + body: { + body: options.body + } + } + return gateway.request(createAccountOperation, parameters) +} + +/** + */ +export function enable() { + return gateway.request(enableOperation) +} + +const createAccountOperation = { + path: '/account', + contentTypes: ['application/zrok.v1+json'], + method: 'post' +} + +const enableOperation = { + path: '/enable', + method: 'post', + security: [ + { + id: 'key' + } + ] +} diff --git a/ui/src/api/metadata.js b/ui/src/api/metadata.js new file mode 100644 index 00000000..b782394b --- /dev/null +++ b/ui/src/api/metadata.js @@ -0,0 +1,14 @@ +/** @module metadata */ +// Auto-generated, edits will be overwritten +import * as gateway from './gateway' + +/** + */ +export function version() { + return gateway.request(versionOperation) +} + +const versionOperation = { + path: '/version', + method: 'get' +} diff --git a/ui/src/api/tunnel.js b/ui/src/api/tunnel.js new file mode 100644 index 00000000..3451aa6a --- /dev/null +++ b/ui/src/api/tunnel.js @@ -0,0 +1,55 @@ +/** @module tunnel */ +// Auto-generated, edits will be overwritten +import * as gateway from './gateway' + +/** + * @param {object} options Optional options + * @param {module:types.tunnelRequest} [options.body] + * @return {Promise} tunnel created + */ +export function tunnel(options) { + if (!options) options = {} + const parameters = { + body: { + body: options.body + } + } + return gateway.request(tunnelOperation, parameters) +} + +/** + * @param {object} options Optional options + * @param {module:types.untunnelRequest} [options.body] + * @return {Promise} tunnel removed + */ +export function untunnel(options) { + if (!options) options = {} + const parameters = { + body: { + body: options.body + } + } + return gateway.request(untunnelOperation, parameters) +} + +const tunnelOperation = { + path: '/tunnel', + contentTypes: ['application/zrok.v1+json'], + method: 'post', + security: [ + { + id: 'key' + } + ] +} + +const untunnelOperation = { + path: '/untunnel', + contentTypes: ['application/zrok.v1+json'], + method: 'delete', + security: [ + { + id: 'key' + } + ] +} diff --git a/ui/src/api/types.js b/ui/src/api/types.js new file mode 100644 index 00000000..6c2b49a7 --- /dev/null +++ b/ui/src/api/types.js @@ -0,0 +1,56 @@ +/** @module types */ +// Auto-generated, edits will be overwritten + +/** + * @typedef accountRequest + * @memberof module:types + * + * @property {string} username + * @property {string} password + */ + +/** + * @typedef accountResponse + * @memberof module:types + * + * @property {string} token + */ + +/** + * @typedef enableResponse + * @memberof module:types + * + * @property {string} identity + * @property {string} cfg + */ + +/** + * @typedef principal + * @memberof module:types + * + * @property {number} id + * @property {string} username + * @property {string} token + */ + +/** + * @typedef tunnelRequest + * @memberof module:types + * + * @property {string} identity + * @property {string} endpoint + */ + +/** + * @typedef tunnelResponse + * @memberof module:types + * + * @property {string} service + */ + +/** + * @typedef untunnelRequest + * @memberof module:types + * + * @property {string} service + */