@aeternity/aepp-sdk
Version:
SDK for the æternity blockchain
501 lines (459 loc) • 15.2 kB
JavaScript
/*
* ISC License (ISC)
* Copyright (c) 2018 aeternity developers
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*/
/**
* Swagger module
* @module @aeternity/aepp-sdk/es/utils/swagger
* @export Swagger
* @example import Swagger from '@aeternity/aepp-sdk/es/utils/swagger'
*/
import JsonBig from './json-big'
import stampit from '@stamp/it'
import AsyncInit from './async-init'
import axios from 'axios'
import * as R from 'ramda'
import { snakeToPascal, pascalToSnake } from './string'
/**
* Perform path string interpolation
* @static
* @rtype (path: String, replacements: Object) => String
* @param {String} s - String to convert
* @return {String} Converted string
*/
function expandPath (path, replacements) {
return R.toPairs(replacements).reduce((path, [key, value]) => path.replace(`{${key}}`, value), path)
}
/**
* Lookup type
* @rtype (path: [String...], spec: Object, types: Object) => Object
* @param {String[]} path - Path to look up
* @param {Object} spec
* @param {Object} types
* @return {Object} Looked up type definition
*/
function lookupType (path, spec, types) {
const type = (() => {
const match = R.path(path, spec).match(/^#\/definitions\/(.+)/)
if (match !== void 0) {
return match[1]
} else {
throw Error(`Reference path does not meet specification: ${path}`)
}
})()
if (type in types) {
return types[type]
} else {
throw Error(`Couldn't find definition for ${type}`)
}
}
/**
* Intercept errors thrown by `fn()`, extending them with information from `key`
* @rtype (key: String, fn: Function) => Any
* @param {String} key - Information to attach
* @param {Function} fn - Thunk
* @return {Any} Execution result
*/
function extendingErrorPath (key, fn) {
try {
return fn()
} catch (e) {
throw Object.assign(e, { path: [key].concat(e.path || []) })
}
}
/**
* Construct Error with additional type information (not thrown)
* @rtype (msg: String, spec: String, value: String) => Error
* @param {String} msg - Error message
* @param {String} spec
* @param {String} value
* @return {Error} Enhanced Error
*/
function TypeError (msg, spec, value) {
const e = Error(msg)
return Object.assign(e, { spec, value })
}
/**
* Per-type {@link conform} dispatcher
* @rtype [(dispatch(value: String, spec: Object, types: Object) => Any, throws: Error)...]
*/
const conformTypes = {
integer (value, spec, types) {
if (R.type(value) === 'Number') {
return Math.floor(value)
} else {
throw TypeError('Not an integer', spec, value)
}
},
enum (value, spec, types) {
const { enum: values } = spec
if (R.contains(value, values)) {
return value
} else {
throw TypeError(`Not one of [${R.join(', ', values)}]`, spec, value)
}
},
string (value, spec, types) {
if (R.type(value) === 'String') {
return value
} else {
throw TypeError(`Not a string`, spec, value)
}
},
object (value, spec, types) {
if (R.type(value) === 'Object') {
const required = (spec.required || []).map(snakeToPascal)
const properties = pascalizeKeys(spec.properties)
const missing = R.difference(required, R.keys(value))
if (missing.length > 0) {
throw TypeError(`Required properties missing: ${R.join(', ', missing)}`, spec, value)
} else {
return R.mapObjIndexed((value, key) => extendingErrorPath(key, () => conform(value, properties[key], types)), R.reject(R.isNil, R.pick(R.keys(properties), value)))
}
} else {
throw TypeError(`Not an object`, spec, value)
}
},
array (value, spec, types) {
if (R.type(value) === 'Array') {
return value.map(o => conform(o, spec.items, types))
} else {
throw TypeError(`Not an array`, spec, value)
}
},
schema (value, spec, types) {
return conform(value, lookupType(['schema', '$ref'], spec, types), types)
},
$ref (value, spec, types) {
return conform(value, lookupType(['$ref'], spec, types), types)
},
allOf (value, spec, types) {
return R.mergeAll(spec.allOf.map(spec => conform(value, spec, types)))
}
}
/**
* {@link conform} dispatcher
* @rtype (spec: Object) => String, throws: Error
* @param {Object} spec
* @return {String} Value to dispatch on
*/
function conformDispatch (spec) {
if ('schema' in spec) {
return 'schema'
} else if ('$ref' in spec) {
return '$ref'
} else if ('enum' in spec) {
return 'enum'
} else if ('allOf' in spec) {
return 'allOf'
} else if ('type' in spec) {
return spec.type
} else {
throw Object.assign(Error('Could not determine type'), { spec })
}
}
/**
* Conform `value` against its `spec`
* @static
* @rtype (value: Any, spec: Object, types: Object) => Any, throws: Error
* @param {Object} value - Value to conform (validate and transform)
* @param {Object} spec - Specification object
* @param {Object} types - Types specification
* @return {Object} Conformed value
*/
function conform (value, spec, types) {
return (conformTypes[conformDispatch(spec)] || (() => {
throw Object.assign(Error('Unsupported type'), { spec })
}))(value, spec, types)
}
const httpConfig = {
headers: { 'Content-Type': 'application/json' },
transformResponse: [(data) => {
try {
return JsonBig.parse(data)
} catch (e) {
return data
}
}]
}
const httpClients = {
get: (config) => (url) => axios.get(url, R.mergeDeepRight(httpConfig, config)),
post: (config) => (url, params) => axios.post(url, params, R.mergeDeepRight(httpConfig, config))
}
/**
* Classify given `parameters`
* @rtype (parameters: [{required: Boolean, in: String}...]) => {pathArgs: [...Object], queryArgs: [...Object], bodyArgs: [...Object], req: [...Object], opts: [...Object]}
* @param {Object[]} parameters - Parameters to classify
* @return {Object[]} Classified parameters
*/
function classifyParameters (parameters) {
const { req, opts } = R.groupBy(p => p.required ? 'req' : 'opts', parameters)
const { path, query, body } = R.groupBy(p => p.in, parameters)
return {
pathArgs: R.pluck('name', path || []),
queryArgs: R.pluck('name', query || []),
bodyArgs: R.pluck('name', body || []),
req: req || [],
opts: opts || []
}
}
/**
* Convert `name` attributes in `parameters` from snake_case to PascalCase
* @rtype (parameters: [{name: String}...]) => [{name: String}...]
* @param {Object[]} parameters - Parameters to pascalize
* @return {Object[]} Pascalized parameters
*/
function pascalizeParameters (parameters) {
return parameters.map(o => R.assoc('name', snakeToPascal(o.name), o))
}
/**
* Key traversal metafunction
* @static
* @function
* @rtype (fn: (s: String) => String) => (o: Object) => Object
* @param {Function} fn - Key transformation function
* @param {Object} o - Object to traverse
* @return {Object} Transformed object
*/
const traverseKeys = R.curry((fn, o) => {
const dispatch = {
Object: o => R.fromPairs(R.toPairs(o).map(function (arr) {
const k = arr[0]
const v = arr[1]
return [fn(k), traverseKeys(fn, v)]
})),
Array: o => o.map(traverseKeys(fn))
}
return (dispatch[R.type(o)] || R.identity)(o)
})
/**
* snake_case key traversal
* @static
* @rtype (o: Object) => Object
* @param {Object} o - Object to traverse
* @return {Object} Transformed object
* @see pascalToSnake
*/
function snakizeKeys (o) {
return traverseKeys(pascalToSnake, o)
}
/**
* PascalCase key traversal
* @static
* @rtype (o: Object) => Object
* @param {Object} o - Object to traverse
* @return {Object} Transformed object
* @see snakeToPascal
*/
function pascalizeKeys (o) {
return traverseKeys(snakeToPascal, o)
}
/**
* Obtain readable signature for operation
* @rtype (name: String, req: [...Object], opts: [...Object]) => Object
* @param {String} name - Name of operation
* @param {Object[]} req - Required parameters to operation
* @param {Object[]} opts - Optional parameters to operation
* @return {String} Signature
*/
function operationSignature (name, req, opts) {
const args = req.length ? `${R.join(', ', R.pluck('name', req))}` : null
const opt = opts.length ? `{${R.join(', ', R.pluck('name', opts))}}` : null
return `${name} (${R.join(', ', [args, opt].filter(R.identity))})`
}
/**
* Assert that `coll` is a sequence with a length of 1 and extract the only element
* @static
* @rtype (coll: [...Any]) => Any, throws: Error
* @param {Object[]} coll
* @return {Object}
*/
function assertOne (coll) {
if (coll.length === 1) {
return R.head(coll)
} else {
throw Error(`Expected exactly one element in ${coll}`)
}
}
/**
* Destructure HTTP client `error`
* @rtype (error: Error) => String
* @param {Error} error
* @return {String}
*/
function destructureClientError (error) {
const { method, url } = error.config
const { status, data } = error.response
const reason = R.has('reason', data) ? data.reason : R.toString(data)
return `${method.toUpperCase()} to ${url} failed with ${status}: ${reason}`
}
/**
* Generate callable operation
* @function
* @static
* @rtype (path: String, method: String, definition: Object, types: Object) => (instance: Swagger, url: String) => Promise[Any], throws: Error
* @param {String} path - Path to call in URL
* @param {String} method - HTTP method
* @param {Object} definition - Complex definition
* @param {Object} types - Swagger types
* @return {Function}
*/
const operation = (path, method, definition, types, { config, errorHandler } = {}) => {
config = config || {}
delete config.transformResponse // Prevent of overwriting transform response
const { operationId, description } = definition
let { parameters } = definition
const name = `${R.head(operationId).toLowerCase()}${R.drop(1, operationId)}`
const pascalized = pascalizeParameters(parameters)
const { pathArgs, queryArgs, bodyArgs, req, opts } = classifyParameters(pascalized)
const optNames = R.pluck('name', opts)
const indexedParameters = R.indexBy(R.prop('name'), pascalized)
const signature = operationSignature(name, req, opts)
const client = httpClients[method](config)
return (instance, url) => {
const fn = async function () {
const { defaults } = this.Swagger
try {
const [arg, opt] = (() => {
if (arguments.length === req.length) {
return [Array.from(arguments), defaults]
} else if (arguments.length === req.length + 1) {
return [R.dropLast(1, arguments), R.merge(defaults, R.last(arguments))]
} else {
throw Error(`Function call doesn't conform to ${signature}`)
}
})()
if (opt.debug) {
console.log(`Invoked ${name} with ${R.toString(arg)} ${R.toString(opt)}`)
}
const values = R.merge(R.reject(R.isNil, R.pick(optNames, opt)), R.zipObj(R.pluck('name', req), arg))
const conformed = R.mapObjIndexed((val, key) => {
try {
return conform(val, indexedParameters[key], types)
} catch (e) {
const path = [key].concat(e.path || [])
throw Object.assign(e, {
path,
value: val,
message: `validating ${R.join(' -> ', path)}: ${e.message}`
})
}
}, values)
const expandedPath = expandPath(path, snakizeKeys(R.pick(pathArgs, conformed)))
const params = snakizeKeys((() => {
if (method === 'get') {
return { params: R.pick(queryArgs, conformed) }
} else if (method === 'post') {
return conformed[assertOne(bodyArgs)]
} else {
throw Error(`Unsupported method ${method}`)
}
})())
if (opt.debug) {
console.log(`Going to ${method.toUpperCase()} ${url}${expandedPath} with ${R.toString(params)}`)
}
try {
const response = await client(`${url}${expandedPath}`, params).catch(this.axiosError(errorHandler))
// return opt.fullResponse ? response : conform(pascalizeKeys(response.data), responses['200'], types)
return opt.fullResponse ? response : pascalizeKeys(response.data)
} catch (e) {
if (R.path(['response', 'data'], e)) {
e.message = destructureClientError(e)
}
throw e
}
} catch (e) {
e.message = `While calling ${signature}, ${e.message}`
throw e
}
}.bind(instance)
Object.assign(fn, {
signature,
description
})
return Object.defineProperties(fn, {
name: {
value: name,
writable: false
},
length: {
value: req.length + (opts.length ? 1 : 0),
writable: false
}
})
}
}
/**
* Swagger Stamp
* @function
* @alias module:@aeternity/aepp-sdk/es/utils/swagger
* @rtype Stamp
* @param {Object} options - Initializer object
* @param {Object} options.swag - Swagger definition
* @param {Object} options.axiosConfig - Object with axios configuration. Example { config: {}, errorHandler: (err) => throw err }
* @return {Object} Account instance
* @example Swagger({swag})
*/
const Swagger = stampit(AsyncInit, {
async init ({ swag = this.swag, axiosConfig }, { stamp }) {
const { paths, definitions } = swag
const methods = R.indexBy(
R.prop('name'),
R.flatten(
R.values(
R.mapObjIndexed(
(methods, path) => R.values(
R.mapObjIndexed((definition, method) => {
const op = operation(path, method, definition, definitions, axiosConfig)
return op(this, this.urlFor(swag.basePath, definition))
}, methods)),
paths
)
)
)
)
return Object.assign(this, {
methods: R.keys(methods),
api: methods
})
},
deepProps: {
Swagger: {
defaults: {
debug: false,
txEncoding: 'json'
}
}
},
statics: { debugSwagger (bool) { return this.deepProps({ Swagger: { defaults: { debug: bool } } }) } }
})
/**
* Reconfigure Swagger to (not) spill debugging logs
* @function debugSwagger
* @static
* @rtype (bool: Boolean) => Stamp
* @param {boolean} bool - Whether to debug
* @return {Stamp} Reconfigured Swagger Stamp
*/
export default Swagger
export {
conform,
operation,
expandPath,
assertOne,
snakeToPascal,
pascalToSnake,
traverseKeys
}