evrythng
Version:
Official Javascript SDK for the EVRYTHNG API.
559 lines (498 loc) • 16.8 kB
JavaScript
import isString from 'lodash-es/isString'
import isFunction from 'lodash-es/isFunction'
import Scope from '../scope/Scope'
import Entity from '../entity/Entity'
import api from '../api'
import { success, failure } from '../util/callback'
import symbols from '../symbols'
import parseLinkHeader from '../util/parseLinkHeader'
/**
* A Resource is the base class that implements the CRUD methods behavior.
* All resource requests are scoped (i.e. they use the scope's API Key).
*
* Every resource operation tries to serialize/deserialize the type of object
* corresponding to the resource (i.e. when creating a Thng, the developer gets
* a Thng entity back, with nested methods).
*/
export default class Resource {
/**
* Returns a resource factory function for the given entity type.
*
* @static
* @param {Entity} type - Entity sub-class
* @param {string} path - Path for new resource
* @param {Function} MixinNestedResources - Mixin that extends Resource class
* with nested resources.
* @param {string} [typeName] - Name of the entity posessing this resource.
* @return {Function} - Resource factory function
*/
static factoryFor (type, path = '', MixinNestedResources, typeName) {
if (!type) {
throw new Error('Entity type is necessary for resource factory.')
}
// No "this" binding with arrow function! This needs to run in the context
// where it is mixed in / attached.
return function (id) {
// Allowed on Scopes, Resources and Entities.
let parentPath, parentScope, newPath
if (this instanceof Scope) {
parentScope = this
parentPath = ''
} else if (this instanceof Resource) {
parentScope = this.scope
parentPath = this.path
} else if (this instanceof Entity) {
parentScope = this[symbols.resource].scope
parentPath = this[symbols.resource].path
}
newPath = parentPath + path
if (id) {
if (!isString(id)) {
throw new TypeError('ID must be a string.')
}
newPath += `/${encodeURIComponent(id)}`
}
const XResource = MixinNestedResources ? MixinNestedResources(Resource) : Resource
return new XResource(parentScope, newPath, type, id, typeName)
}
}
/**
* A Resource requires a Scope sub-class (App, Operator, etc.) and the
* corresponding path in the Engine API. An Entity sub-class can be
* provided, in which case is it used to create instance of that Entity class
* when returning from API requests.
*
* @param {Scope} scope - Scope containing API Key
* @param {string} path - Relative path to API resource
* @param {Entity} [type] - Reference to Entity class (constructor)
* @param {string} [id] - The resource ID, if specified.
* @param {string} [typeName] - Name of the entity posessing this resource.
*/
constructor (scope, path, type, id, typeName) {
if (!(scope && scope instanceof Scope)) {
throw new TypeError('Scope should inherit from Scope (e.g. EVT.Application).')
}
if (!isString(path)) {
throw new TypeError('Resource must have a String path.')
}
// Setup scope for each of the subsequent calls.
this.scope = scope
// Setup path and allow to omit leading '/'.
this.path = `${path[0] !== '/' ? '/' : ''}${path}`
// Allow chainable parameter helpers
this.preParams = {}
// Remember the resource ID if specified
this.id = id
// Some sub-resources need to know what they belong to
this.typeName = typeName
// Setup entity for serializing and deserializing results. It must
// implement *toJSON()* method, as defined in the Entity base class.
if (type && (type === Entity || type.prototype instanceof Entity)) {
this.type = type
}
}
/**
* Convert an Entity instance into plain JSON payload for API requests.
*
* @param {Entity} entity - Instace of entity object
* @returns {Object}
*/
serialize (entity = {}) {
if (!(this.type && entity instanceof this.type)) {
return entity
}
return entity.json()
}
/**
* Opposite of serialization. Convert API responses into proper Entities
* using the entity class of the resource. If response is a collection,
* converts each of the items.
*
* @param {Object} response - JSON response from API
* @returns {Object|Entity|Response} - Entity if able to deserialize;
* Response if fullResponse option was used; Object otherwise.
*/
deserialize (response) {
if (response && this.type) {
if (Array.isArray(response)) {
if (typeof response[0] === 'object') {
// Deserialise each item, if they're objects
return response.map(this.deserialize.bind(this))
} else {
// Basic string type
return response
}
}
if (response.body) {
// Full response, add deserialize method to deserialize the Response's
// json body
response.deserialize = () => response.json().then(this.deserialize.bind(this))
} else {
// JSON response, base case.
// Create new entity with updated resource derived from current.
let newPath = this.path
// Expand resource path with ID of entity.
if (response.id && newPath.indexOf(response.id) === -1) {
newPath += `/${response.id}`
}
const newResource = new Resource(this.scope, newPath, this.type)
const EntityType = this.type
return new EntityType(newResource, response)
}
}
return response
}
/**
* Helper for the 'withScopes=true' param.
*
* E.g: user.thng().setWithScopes().read()
*
* @returns {Resource} this
*/
setWithScopes () {
this.preParams.withScopes = true
return this
}
/**
* Helper for the 'context=true' param.
*
* E.g: user.action('scans').setContext().read()
*
* @returns {Resource} this
*/
setContext () {
this.preParams.context = true
return this
}
/**
* Helper for the 'perPage' param.
*
* E.g: user.product().setPerPage(100).read()
*
* @param {number} value - The value to set to the 'perPage' parameter.
* @returns {Resource} this
*/
setPerPage (value) {
this.preParams.perPage = value
return this
}
/**
* Helper for the 'project' param.
*
* E.g: user.product().setProject('U6N4KcNNCSdyVHRaRmryhtPm').read()
*
* @param {string} id - The value to set to the 'project' parameter.
* @returns {Resource} this
*/
setProject (id) {
this.preParams.project = id
return this
}
/**
* Helper for the 'filter' param.
*
* E.g: user.product().setFilter('tags=load2').read()
*
* @param {number} value - The value to set to the 'filter' parameter.
* @returns {Resource} this
*/
setFilter (value) {
this.preParams.filter = value
return this
}
/**
* Helper for the 'ids' param.
*
* E.g: user.thng().setIds(['UNREK4bCUSCdGpRwaKMfdPfs', 'U7xPy2xqkbHyskaaR3MQUgrh']).read()
*
* @param {string[]} ids - Array of IDs.
* @returns {Resource} this
*/
setIds (ids) {
this.preParams.ids = ids
return this
}
/**
* Create a new entity for this resource.
*
* @param {Object|Entity} data - Entity to create
* @param {Settings} [options] - Options of the request
* @param {Function} [callback] - Error-first callback
* @returns {Promise}
*/
create (data, options, callback) {
if (!data || isFunction(data)) {
throw new TypeError('Create method must have payload.')
}
const req = {
url: options && options.url ? options.url : this.path,
data,
method: 'post'
}
return this._request(req, options, callback)
}
/**
* Reads resource entities.
*
* @param {Settings} [options] - Options of the request
* @param {Function} [callback] - Error-first callback
* @returns {Promise}
*/
read (options, callback) {
return this._request(
{
url: this.path,
method: 'get'
},
options,
callback
)
}
/**
* Updates entity via this resource.
*
* @param {Object|Entity} data - Entity to create
* @param {Settings} [options] - Options of the request
* @param {Function} [callback] - Error-first callback
* @returns {Promise}
*/
update (data, options, callback) {
if (!data || isFunction(data)) {
throw new TypeError('Update method must have payload.')
}
return this._request(
{
url: this.path,
data,
method: 'put'
},
options,
callback
)
}
/**
* Deletes entity in resource.
*
* @param {Settings} [options] - Options of the request
* @param {Function} [callback] - Error-first callback
* @returns {Promise}
*/
['delete'] (options, callback) {
return this._request(
{
url: this.path,
method: 'delete'
},
options,
callback
)
}
/**
* Returns a page iterator. It is essentially an async generator that returns
* a promise to the next page on every `.next()` invocation.
*
* @param {Settings} [options] - Options of the request
* @returns {AsyncGenerator}
* @example
*
* ```
* const it = operator.thng().pages()
* it.next().then(console.log)
*
* // or in ES7+
*
* for await (let page of pages) {
* console.log(page)
* }
* ```
*/
async * pages (options = {}) {
const fullResponse = options.fullResponse
let response
// Read first 'page' with user-defined options.
response = await this._linkRequest({ url: this.path }, fullResponse, options)
yield response.result
while (response.next) {
response = await this._linkRequest({ apiUrl: response.next }, fullResponse)
yield response.result
}
}
/**
* Set a new list of project scopes, and optionally a new list of user scopes.
* Existing project scopes are replaced, not augmented. Empty array is accepted to fully descope.
* If 'users' is not specified, existing user scopes are preserved.
*
* @param {string[]} projects - Array of project IDs to set as the resource's 'projects' scope.
* @param {string[]} [users] - Optional array of Application User IDs to set as the 'users' scope.
* @returns {Promise} Promise that resolves once the scope update has been completed.
*/
async rescope (projects, users) {
if (!projects) {
throw new Error('An array of project IDs to be scoped to must be provided')
}
const params = { withScopes: true }
const { scopes } = await this.read({ params })
scopes.projects = projects
if (Array.isArray(users)) {
scopes.users = users
}
return this.update({ scopes })
}
/**
* Update a resource by 'name' or an identifier key-value pair, and create it if it does not exist.
*
* If more than one match is found and the 'allowPlural' parameter is not set to 'true',
* an error will be thrown. If it is set, the *first* item returned will be updated.
*
* @param {object} data - Create/update payload to use.
* @param {object|string} updateKey - Name string or key-value identifier pair to search with.
* @param {boolean} [allowPlural] - Flag to not throw an error if more than one result is found.
* @returns {Promise} Promise that resolves once complete.
*/
async upsert (data, updateKey, allowPlural) {
if (!updateKey || !(typeof updateKey === 'string' || typeof updateKey === 'object')) {
throw new Error(
"updateKey must be a 'name' string or an object, eg: { shortId: 'a7ysf8hd' }"
)
}
const params = { filter: `name=${updateKey}` }
if (typeof updateKey === 'object') {
const [key, value] = Object.entries(updateKey)[0]
params.filter = `identifiers.${key}=${value}`
}
const found = await this.read({ params })
if (found.length > 1) {
if (!allowPlural) {
throw new Error(
"More than one resource was found. Set 'allowPlural' to 'true' as third parameter to update the first returned."
)
}
}
if (found.length) {
return found[0].update(data)
}
return this.create(data)
}
/**
* Find some of the resources by 'name' or a single identifier key-value pair.
* A convenience method for using the 'filter' parameter.
*
* @param {object|string} updateKey - String 'name' or object containing single key-value pair.
* @returns {Promise} Promise that resolves when the request returns.
*/
async find (updateKey) {
const params = { filter: `name=${updateKey}` }
if (typeof updateKey === 'object') {
const pairs = Object.entries(updateKey)
if (pairs.length > 1) {
throw new Error('Only one key-value pair may be specified for find()')
}
const [key, value] = pairs[0]
params.filter = `identifiers.${key}=${value}`
}
return this.read({ params })
}
/**
* Stream a set of resources, calling an async function for each item.
*
* The callback is passed the item and cumulative item index. If iteration should
* stop, the callback should return `true`.
*
* @param {function} eachItemCb - Async function to call for each item.
*/
async stream (eachItemCb) {
const iterator = this.pages()
let totalSoFar = 0
let page
let willStop
while (!(page = await iterator.next()).done) {
for (let i = 0; i < page.value.length; i += 1) {
if (typeof willStop === 'boolean' && willStop) {
return
}
willStop = await eachItemCb(page.value[i], totalSoFar + i)
}
totalSoFar += page.value.length
}
}
/**
* Stream a set of resources, calling an async function for each item.
* Similar to stream(), but the callback presents each page.
*
* The callback is passed the page and cumulative item total (before the current page).
* If iteration should stop, the callback should return `true`.
*
* @param {function} eachPageCb - Async function to call for each page of items.
*/
async streamPages (eachPageCb) {
const iterator = this.pages()
let totalSoFar = 0
let page
let willStop
while (!(page = await iterator.next()).done) {
willStop = await eachPageCb(page.value, totalSoFar)
totalSoFar += page.value.length
if (typeof willStop === 'boolean' && willStop) {
return
}
}
}
// PRIVATE
/**
* Actual request call. This method merges mandatory request options with
* user provided ones and forces the resource path and API Key, so they are
* not overriden. Serializes any body passed in, and deserializes response in
* the end.
*
* Callback may be used in place of userOptions.
*
* @param {Settings} requestOptions - Mandatory request options
* @param {Settings} [userOptions] - Optional user options
* @param {Function} [callback] - Error-first callback
* @returns {Promise.<Object|Entity|Response>}
* @private
*/
async _request (requestOptions, userOptions = {}, callback) {
if (isFunction(userOptions)) {
callback = userOptions
}
// Merge options, priority to mandatory ones.
const options = Object.assign({}, userOptions, requestOptions, {
apiKey: this.scope.apiKey
})
// Serialize Entity into JSON payload.
if (options.body) {
options.body = await this.serialize(options.body)
}
// Any preParams set?
if (Object.keys(this.preParams).length) {
Object.assign(options, { params: this.preParams })
this.preParams = {}
}
// Execute callback after deserialization.
try {
const response = await api(options)
const deserialized = await this.deserialize(response)
return success(callback)(deserialized)
} catch (err) {
throw failure(callback)(err)
}
}
/**
* Async request that parses the link header if any.
*
* @param {Settings} requestOptions - Mandatory request options
* @param {Boolean} fullResponse - Wrap Response or not
* @param {Settings} [userOptions] - Optional user options
* @returns {Promise.<{result: Response|Array, next: string}>}
* @private
*/
async _linkRequest (requestOptions, fullResponse, userOptions = {}) {
const opts = Object.assign({ fullResponse: true }, requestOptions)
const response = await this._request(opts, userOptions)
const linkHeader = parseLinkHeader(response.headers.get('link'))
const next = linkHeader.next && decodeURIComponent(linkHeader.next)
const result = await (fullResponse ? response : response.json())
return { result, next }
}
}