@ikaru5/heimdall-controller
Version:
Structure your REST requests by communicating with contracts.
464 lines (415 loc) • 17.1 kB
JavaScript
import Package from "./package.js"
import HeimdallController from "./heimdall-controller.js"
import ControllerBase from "./controller-base.js"
/**
* Base Class
* Needs to be initialized before usage!
* Import in your index.js (or your equivalent) with at least heimdall.init()
*/
class HeimdallAPI {
/**
* Create empty controller map. The Controller will use the _registerController Method to populate it.
* Also set the default simple connection error handler (Just logs an error to the console). It should be replaced if you want real error handling!
*/
constructor() {
this._controller = {}
this._onDispatchCallbacks = []
this._onInboundCallbacks = []
this.connectionFailureCallback = function (error) {
console.error(`Network Error: ${error}`)
}
}
/**
* Subscribe to every outgoing dispatch. Called with {messageId, receiver}
* right before the package leaves - the place to register in-flight commands.
* @param {function} callback
* @returns {function} unsubscribe
*/
onDispatch(callback) {
this._onDispatchCallbacks.push(callback)
return () => { this._onDispatchCallbacks = this._onDispatchCallbacks.filter(cb => cb !== callback) }
}
/**
* Subscribe to every inbound package (including synthetic transport failures).
* Called with the received Package BEFORE routing - fires even when no
* controller is registered for the receiver. The place to clear in-flight
* commands via receivedPackage.correlatesTo.
* @param {function} callback
* @returns {function} unsubscribe
*/
onInbound(callback) {
this._onInboundCallbacks.push(callback)
return () => { this._onInboundCallbacks = this._onInboundCallbacks.filter(cb => cb !== callback) }
}
_emit(callbacks, argument) {
for (const callback of callbacks) {
try {
callback(argument)
} catch (error) {
console.error(`Heimdall subscriber threw: ${error}`)
}
}
}
/**
* Load defaults and gives the options to set things up.
* Will also read default host information if nothing provided. (Not on React Native)
* If it is a simple webapp its completely fine to provide nothing if it fits you.
* @param {string} path
* @param {number} port
* @param {string} protocol - TODO better name?
* @param {string} host
* @param {boolean} handleCSRF
* @param {undefined | function} afterCSRF
* @param {undefined | function} decorateForReceiver - optional function to decorate the payload before sending it out
* @param {boolean} useCustomConnection - use some custom protocol like websockets for example
* @param {undefined | function} connectCustomConnection - callback to connect to a custom connection (e.g. websockets)
* @param {undefined | function} disconnectAllCustomConnections - callback to disconnect from all custom connections (e.g. websockets)
* @param {undefined | function} disconnectCustomConnection - callback to disconnect from a custom connection (e.g. websockets)
* @returns {HeimdallAPI} - no need to use your configuration output, since its singleton.
*/
init({
path = "/api",
port,
protocol = "HTTP",
host,
handleCSRF = false,
afterCSRF,
decorateForReceiver,
useCustomConnection = false,
connectCustomConnection,
disconnectAllCustomConnections,
disconnectCustomConnection
}) {
// simply default values, can be changed for a package
this.path = path
this.protocol = protocol
this.host = host
this.handleCSRF = handleCSRF
if (typeof window !== "undefined") {
if (undefined === this.host && undefined === this.port) {
if (0 === window.location.port.length) {
this.host = window.location.origin
} else {
this.host = window.location.origin.slice(0, -(window.location.port.length + 1))
}
this.port = window.location.port
} else if (undefined === this.host) {
if (0 === window.location.port.length) {
this.host = window.location.origin
} else {
this.host = window.location.origin.slice(0, -(window.location.port.length + 1))
}
}
}
this.port = this.port || 80
this.decorateForReceiver = decorateForReceiver
if (this.handleCSRF) {
this.csrfToken = undefined
this._getCSRFToken()
this._afterCSRF = afterCSRF
}
if (useCustomConnection) {
this.useCustomConnection = useCustomConnection
this._connectCustomConnection = connectCustomConnection
this._customCreateReturnCollection = []
if (disconnectAllCustomConnections) this._disconnectAllCustomConnections = disconnectAllCustomConnections
if (disconnectCustomConnection) this._disconnectCustomConnection = disconnectCustomConnection
}
return this
}
listenToCustom(params) {
if (!this.useCustomConnection || !this._connectCustomConnection) {
console.error(
"Could not define channel listener because not enabled or connectCustomConnection not defined." +
"Make sure you used the init method with useCustomConnection set to true and connectCustomConnection defined."
)
return
}
const connection = this._connectCustomConnection(
{
params,
onConnected: this._connectedToCustom,
onDisconnected: this._disconnectedFromCustom,
onReceive: this._onCustomReceive
}
)
if (connection) this._customCreateReturnCollection.push({connection, params})
return connection
}
stopListenToCustom(params) {
console.info("Disconnecting from custom channel with params:")
console.info(params)
this._disconnectCustomConnection({params, collection: this._customCreateReturnCollection})
}
disconnectCustoms() {
this._disconnectAllCustomConnections()
}
_connectedToCustom = (params) => {
console.info("Connected to custom channel with params:")
console.info(params)
}
_disconnectedFromCustom = (params) => {
this._customCreateReturnCollection = this._customCreateReturnCollection.filter(
e => JSON.stringify(e.params) !== JSON.stringify(params)
)
console.info("Disconnected from custom channel with params:")
console.info(params)
}
_onCustomReceive = ({data, protocol}) => {
this._receivePackage(JSON.parse(data), protocol)
}
/**
* Use this to handle network errors.
* @param {function} callback
*/
setConnectionFailureCallback(callback) {
this.connectionFailureCallback = callback
}
/**
* @typedef DispatchOptions
* @property {string} receiver - Backend Endpoint like User.Show
* @property {Array} [files] - files to send (picture, video, ...)
* @property {string} [path] - override the default path
* @property {string} [port] - override default port
* @property {string} [host] - override default host
* @property {string} [protocol] - override default protocol
*/
/**
* Build a heimdall formed package with provided payload and dispatch it to the backend.
* Payload can be a contract and heimdall will extract the JSON itself. ;)
* @param {Object} payload
* @param {DispatchOptions} options
*/
dispatch(payload, {receiver, path, files, port, host, protocol}) {
const sendPackage =
Package.buildSend(payload,
{
receiver: receiver,
path: path,
files: files,
port: port,
host: host,
protocol: protocol
}
)
this._emit(this._onDispatchCallbacks, {messageId: sendPackage.messageId, receiver: sendPackage.receiver})
sendPackage.sendOut()
return sendPackage.messageId
}
/**
* Get the first CSRF Token. TODO maybe extract it to an external file make it overrideable to make the handling customizable.
* @private
*/
_getCSRFToken() {
Package.buildSend({}, {receiver: "Heimdall.CSRF", protocol: "GET"}).sendOut()
}
/**
* Controller must register itself, so Heimdall knows where to route incoming packages.
* @param controllerInstance
* @param controllerName
* @param {Array<string|Action>} actions
* @private
*/
_registerController(controllerInstance, controllerName, actions) {
if (!this._controller[controllerName]) this._controller[controllerName] = {}
this._controller[controllerName].instance = controllerInstance
actions?.forEach(action => this._registerAction(action.controller ?? controllerName, action))
}
/**
* @typedef ContractLike
* Heimdall never imports a contract library - it duck-types against three methods.
* Any class implementing them works (e.g. @ikaru5/heimdall-contract in any version).
* @property {function(Object): void} assign - fills the instance with the received payload
* @property {function(string=): boolean} isValid - validates the instance after assignment
* @property {function(): Object} toObject - serializes the instance for dispatching
*/
/**
* @typedef Action
* @property {string} name - action name
* @property {string} [controller] - overwrite name of controller
* @property {Function} [contract] - contract-like class (see ContractLike); received payload is assigned to a fresh instance
* @property {function} [to] - callback where action should be passed to
* @property {function} [onInvalid] - callback where action should be passed to if contract is invalid
* @property {boolean} [validate] - validate contract
*/
/**
* Register action and the controller, if not already happened.
* BUT! without instance -> if its stays without instance, action must have a "to" callback defined!
* @param controllerName
* @param {string|Action} action
* @private
*/
_registerAction(controllerName, action) {
if (!this._controller[controllerName]) this._controller[controllerName] = {}
if (!this._controller[controllerName].actions) this._controller[controllerName].actions = []
if ("string" === typeof action) {
this._controller[controllerName].actions.push({name: action})
} else {
this._controller[controllerName].actions.push(action)
}
}
/**
* This is the sender method. It will make the technical decisions.
* Remember: Target of Heimdall is to take away thoughts about this stuff from the developer.
* @param payloadJSON
* @param protocol
* @param host
* @param path
* @param port
* @private
*/
_sendOut(payloadJSON, protocol = this.protocol, host = this.host, path = this.path, port = this.port, files, meta = {}) {
switch (protocol) {
case "HTTP":
this._dispatchPOST(payloadJSON, host, path, port, files, meta)
break
case "POST":
this._dispatchPOST(payloadJSON, host, path, port, files, meta)
break
case "GET":
if (undefined !== files) console.warn(`Unexpected File Input for protocol: ${protocol}`)
this._dispatchGET(payloadJSON, host, path, port, meta)
break
default:
console.error(`Unknown protocol: ${protocol}`)
this.connectionFailureCallback(`Unknown protocol: ${protocol}`)
}
}
/**
* The HTTP POST communication request.
* @param payloadJSON
* @param host
* @param path
* @param port
* @param files
* @returns {Promise<void>}
* @private
*/
async _dispatchPOST(payloadJSON, host, path, port, files, meta = {}) {
let data
try {
let url = 80 === port ? `${host}${path}` : `${host}:${port}${path}`
let headers = {}
if (this.handleCSRF) headers["X-CSRF-TOKEN"] = this.csrfToken
let response
if (undefined === files) {
headers['Content-Type'] = 'application/json'
response = await fetch(url, {method: "POST", body: payloadJSON, headers: headers})
} else {
let payloadData = new FormData()
payloadData.append('file-count', files.length)
payloadData.append('_json', payloadJSON)
for (let fileId in files) {
payloadData.append(`file_${1 + parseInt(fileId)}`, files[fileId])
}
response = await fetch(url, {method: "POST", body: payloadData, headers: headers})
}
data = await response.json()
} catch (error) {
this.connectionFailureCallback(error)
this._receiveTransportFailure(meta, error)
return
}
// deliberately OUTSIDE the transport try/catch: routing, contract and
// handler errors are application failures - reporting them as
// Heimdall.transportFailed would misdirect every debugging session
this._receivePackage(data, "HTTP")
}
async _dispatchGET(payloadJSON, host, path, port, meta = {}) {
let data
try {
let url = 80 === port ? `${host}${path}` : `${host}:${port}${path}`
let encodedPayload = encodeURIComponent(payloadJSON)
url = `${url}?_json=${encodedPayload}`
let response = await fetch(url, {method: "GET", headers: {'Content-Type': 'application/json'}})
data = await response.json()
} catch (error) {
this.connectionFailureCallback(error)
this._receiveTransportFailure(meta, error)
return
}
this._receivePackage(data, "HTTP")
}
/**
* Transport failures become inbound messages: the app handles network
* problems with the same mechanics as everything else. correlatesTo carries
* the failed command's messageId so in-flight tracking can settle it.
* @private
*/
_receiveTransportFailure(meta, error) {
if (undefined === meta.messageId) return
try {
this._receivePackage({
receiver: "Heimdall.transportFailed",
correlatesTo: meta.messageId,
payload: {message: String(error), originalReceiver: meta.receiver}
}, "INTERNAL")
} catch (routingError) {
console.error(`Could not route transport failure: ${routingError}`)
}
}
_receivePackage(rawData, protocol) {
const parseData = (rawData, protocol, priority = 0) => {
const receivedPackage = Package.buildReceive(rawData, {protocol: protocol})
// subscribers see every inbound package, even ones no controller handles
this._emit(this._onInboundCallbacks, receivedPackage)
const controllerName = receivedPackage.receiver.split(".").slice(0, -1).join(".") + "Controller"
const action = receivedPackage.receiver.split(".").slice(-1)[0]
let controller = this._controller[controllerName]
if (!controller) throw `No controller found for ${receivedPackage.receiver}. Maybe you forgot to register it or minification changed the name?
You can use constant names for controllers to prevent minification changes. See the documentation for more information.`
// lookup definition for action
const actionDef = controller.actions.find(a => a.name === action)
if (!actionDef?.silent) console.info(`Received package priority: ${priority} to ${controllerName}->${action}`)
// VALIDATE ACTION DEFINITION
const isActionDefValid = actionDef && (
(!!controller.instance && actionDef.name) ||
!!actionDef.to
)
if (!isActionDefValid) {
console.error(`Path for ${receivedPackage.receiver}, which was interpreted as ${controllerName}->${action} not found.`)
return
}
// PREPARE DATA FOR ACTION
const data = {}
data.receivedPackage = receivedPackage
// if contract defined build instance
if (actionDef.contract) {
data.contract = new actionDef.contract()
data.contract.assign(receivedPackage.payload)
if (actionDef.validate !== false) { // set validate explicit to false if you don't want to validate your contract
if (!data.contract.isValid(actionDef.context)) {
console.error(`Path for ${receivedPackage.receiver}, which was interpreted as ${controllerName}->${action} received invalid data for contract ${data.contract.constructor?.name}.`)
console.info(data.contract.errors)
if (actionDef.onInvalid) actionDef.onInvalid(data)
return
}
}
}
// CALL DEFINED ACTION
if (!!actionDef.to) {
actionDef.to(data)
} else {
controller.instance._callAction(actionDef.name, data)
}
}
if (undefined === rawData.length) {
parseData(rawData, protocol)
} else {
// sort by priority if provided: higher priority first, missing priority
// sorts to the front. Equal priorities return 0 so their relative order
// stays stable - the old comparator returned -1 for ties, leaving the
// order of equal messages undefined.
const sortedData = rawData.sort(
(lE, rE) => (rE.priority ?? Number.MAX_SAFE_INTEGER) - (lE.priority ?? Number.MAX_SAFE_INTEGER)
)
for (const data of sortedData) {
parseData(data, protocol, data.priority)
}
}
}
}
const heimdallAPI = new HeimdallAPI()
Package.api = heimdallAPI // late binding - avoids a circular package.js <-> index.js import
export default heimdallAPI
export {HeimdallController, ControllerBase}