Skip to content

Commit

Permalink
Merge pull request #78 from pixelprodev/uplift
Browse files Browse the repository at this point in the history
Uplift
  • Loading branch information
pixelprodev committed Nov 14, 2023
2 parents e05d26e + 10e0069 commit f3a0020
Show file tree
Hide file tree
Showing 46 changed files with 1,607 additions and 845 deletions.
119 changes: 39 additions & 80 deletions lib/Appstrap.js
Original file line number Diff line number Diff line change
@@ -1,107 +1,66 @@
const express = require('express')
const bodyParser = require('body-parser')
const Logger = require('./Logger')
const Handlers = require('./Handlers')
const Fixtures = require('./Fixtures')
const InMemoryState = require('./InMemoryState')
const Interactor = require('./Interactor')
const Config = require('./Config')
const EventEmitter = require('node:events').EventEmitter
const Logger = require('./Logger')
const chokidar = require('chokidar')
const Interceptor = require('./Interceptor')

class Appstrap extends express {
constructor ({
watch = false,
configDir = './.appstrap',
gqlEndpoint,
logger = Logger
} = {}) {
constructor ({ watch = false, repository, gqlEndpoint, logger = Logger } = {}) {
super()
this.logger = logger
this.use(bodyParser.json())
this.history = []
this.gqlEndpoint = gqlEndpoint

// control cached responses
// disable cached responses
this.set('etag', false)
this.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next() })
this.use((req, res, next) => { next() })

this.configureRoutes = configureRoutes.bind(this)
this.loadConfiguration = loadConfiguration.bind(this)
this.updateConfiguration = updateConfiguration.bind(this)

this.config = new Config(configDir, this.logger)
this.loadConfiguration()
this.logger = logger
this.events = new EventEmitter()
this.events.on('log', (event) => { this.logger[event.level](event.message) })

this.events.on('config:ready', (config) => {
this.config = config
this.interactor = new Interactor({ config, events: this.events })
this.use((req, res, next) => this.interactor.router(req, res, next))
this.use(configureEndpoints.call(this, config))
})
this.config = new Config({ repository, gqlEndpoint, events: this.events })
if (watch) {
this.fileWatcher = chokidar.watch(this.config.absConfigDir, { ignoreInitial: true })
this.fileWatcher.on('all', () => { this.updateConfiguration() })
this.fileWatcher = chokidar.watch(repository, { ignoreInitial: true })
this.fileWatcher.on('all', () => this.events.emit('watcher:change'))
}
}

reset () {
this.loadConfiguration()
}
}

function loadConfiguration () {
this.handlers = new Handlers(this)
this.fixtures = new Fixtures(this)
this.memoryStore = new InMemoryState(this.config.initialState)
this.interactor = new Interactor(this)
this.configureRoutes()
}

function updateConfiguration () {
this.config.update()
this.handlers.update()
this.fixtures.update()
this.configureRoutes()
}

function configureRoutes () {
const routeCollection = new Set()
for (const proxyPath of Object.keys(this.config.proxyMap)) {
routeCollection.add(`ALL:::${proxyPath}`)
}

// gather GQL if specified
if (this.gqlEndpoint) {
routeCollection.add(`POST:::${this.gqlEndpoint}`)
}
// gather route handlers
const handlers = Array.from(this.handlers.collection.values())
handlers.forEach(handler => routeCollection.add(`${handler.method.toUpperCase()}:::${handler.path}`))
function configureEndpoints (config) {
const router = express.Router({})
for (const [url, endpoint] of config.endpoints.entries()) {
router.all(url, async (req, res, next) => {
const context = { req, res, next, state: config.state }
try {
const payload = await endpoint.execute(context, config.fixtures)
// todo handle manual rejects etc

// gather fixtures that may not have route handlers associated
const fixtures = Array.from(this.fixtures.collection.values())
fixtures.forEach(fixture => {
// filter out gql handlers, if specified, that route will already be added to the interceptor list
const restHandlers = fixture.handlers.filter(({ path, method }) => typeof path !== 'undefined' && typeof method !== 'undefined')
restHandlers.forEach(({ path, method }) => {
routeCollection.add(`${method.toUpperCase()}:::${path}`)
/*
* TODO: fix this hacky bs
* forwarding a request and maintaining headers is tough without streaming. Because forwardRequest
* handles returning the stream data, payload could be undefined. If we attempt to return again express
* will yell at us. This is temporary until I can find another solution to the request forwarding.
* */
if (payload) {
res.json(payload)
}
} catch (e) {
res.status(500).send({ message: e.message })
}
})
})

const router = express.Router({})
const interceptor = new Interceptor(this)
for (const route of routeCollection) {
const [method, path] = route.split(':::')
// if (!router[method]) { return }
router[method.toLowerCase()](path, (req, res, next) => interceptor.intercept(req, res, next, this.memoryStore.state))
}
// default fall-through
router.all('*', (req, res, next) => next())
this.routes = router

// always ensure a fresh router (for reloads)
const existingRoutesIndex = this._router.stack.findIndex(route => route.name === 'handleRoutes')
// ensure router doesn't exist in the stack already for hot reloads
const existingRoutesIndex = this._router.stack.findIndex(route => route.name === 'router')
if (existingRoutesIndex >= 0) { this._router.stack.splice(existingRoutesIndex, 1) }

const handleRoutes = (req, res, next) => this.routes(req, res, next)
this.use(handleRoutes)
this.use((req, res, next) => this.interactor.router(req, res, next))
return router
}

module.exports = Appstrap
30 changes: 0 additions & 30 deletions lib/Config.js

This file was deleted.

4 changes: 2 additions & 2 deletions lib/InMemoryState.js → lib/Config/State.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class InMemoryState {
class State {
constructor (initialState = {}) {
this._state = initialState
}
Expand All @@ -12,4 +12,4 @@ class InMemoryState {
}
}

exports = module.exports = InMemoryState
exports = module.exports = State
87 changes: 87 additions & 0 deletions lib/Config/endpoints/Endpoint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
const { sleep } = require('../../_helpers')
const { forwardRequest } = require('./forwardRequest')

const defaultModifiers = {
error: false,
errorCode: 500,
errorMessage: 'appstrap generated error',
latency: false,
latencyMS: 0,
enabled: true,
responseSequence: [],
allowRequestForwarding: true
}

class Endpoint {
constructor () {
this.enabled = true
this.requestForwardingURL = null
this.modifiers = new Map()
}

async execute (context, fixtures, fn, modifiers) {
if (!this.enabled || !modifiers.enabled) { return context.next() }
let payload = {}

if (modifiers.latency) { await sleep(modifiers.latencyMS) }

if (modifiers.error) { throw new Error(modifiers.errorMessage, { cause: { statusCode: modifiers.errorCode } }) }

if (modifiers.responseSequence.length > 0) {

} else {
if (modifiers.allowRequestForwarding && this.requestForwardingURL) {
// todo convert this from streaming to something we can keep all in the same request cycle
await forwardRequest(context, this.requestForwardingURL, fixtures.applyActive.bind(fixtures))
return
} else {
if (fn) {
payload = fn(context)
}
}
}

return fixtures.applyActive(context, payload)
}
}

class RestEndpoint extends Endpoint {
constructor ({ url, methods }) {
super({ url })
this.endpointType = 'REST'
this.methods = methods
for (const method of Object.keys(methods)) {
this.modifiers.set(method, defaultModifiers)
}
}

async execute (context, fixtures) {
const httpVerb = context.req.method
if (typeof this.methods[httpVerb] === 'undefined') { return context.next() }
const method = this.methods[httpVerb]
const modifiers = this.modifiers.get(httpVerb)
return super.execute(context, fixtures, method, modifiers)
}
}

class GraphEndpoint extends Endpoint {
constructor ({ url, operations }) {
super({ url })
this.endpointType = 'GQL'
this.operations = operations
for (const operation of operations.keys()) {
this.modifiers.set(operation, defaultModifiers)
}
}

async execute (context, fixtures) {
const operationName = context.req.body.operationName
const modifiers = this.modifiers.has(operationName) ? this.modifiers.get(operationName) : defaultModifiers
return super.execute(context, fixtures, this.operations.get(operationName), modifiers)
}
}

module.exports = exports = {
RestEndpoint,
GraphEndpoint
}
33 changes: 33 additions & 0 deletions lib/Config/endpoints/forwardRequest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const axios = require('axios')

async function forwardRequest (context, destination, applyFixtures) {
const options = {
responseType: 'stream',
method: context.req.method.toLowerCase(),
url: destination,
headers: context.req.headers,
useCredentials: true
}
if (context.req.body) {
options.data = context.req.body
}
try {
const { status, headers, data } = await axios(options)
let chunks = ''

data.on('data', (chunk) => (chunks += chunk))
data.on('end', () => {
context.res.status(status)
context.res.set(headers)
const data = JSON.parse(chunks.toString())
const updatedData = applyFixtures(context.req, data)
context.res.write(JSON.stringify(updatedData))
context.res.end()
})
} catch (e) {
console.error(e)
return {}
}
}

module.exports = exports = { forwardRequest }
70 changes: 70 additions & 0 deletions lib/Config/endpoints/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
const { validateGqlOperation, validateEndpoint } = require('../validators')
const { loadFile } = require('../../_helpers')
const path = require('path')
const { GraphEndpoint, RestEndpoint } = require('./Endpoint')
const { WARN_NO_GQL_ENDPOINT } = require('../../_errors')

function getByKey (key, endpointMap) {
if (key.startsWith('/')) {
return ({ key, endpoint: endpointMap.get(key) })
}
for (const [url, endpoint] of endpointMap.entries()) {
if (endpoint.endpointType === 'GQL') {
if (endpoint.operations.has(key)) {
return ({ key: url, endpoint: endpointMap.get(url) })
}
}
}
return {}
}

function initialize ({ files, gqlEndpoint, hostMap, events }) {
const collection = new Map()

// create graph endpoint
if (gqlEndpoint) {
const operations = new Map()
const gqlOperationFilePaths = files.filter(pathname => pathname.includes('/gql/'))
for (const gqlOperationFilePath of gqlOperationFilePaths) {
const gqlOperation = loadFile(gqlOperationFilePath, validateGqlOperation, events)
if (gqlOperation) {
operations.set(path.basename(gqlOperationFilePath, '.js'), gqlOperation)
}
}
collection.set(gqlEndpoint, new GraphEndpoint({ url: gqlEndpoint, operations }))
}

if (files.some(file => file.includes('/gql/')) && !gqlEndpoint) {
events.emit('log', { level: 'warn', message: WARN_NO_GQL_ENDPOINT })
}

// create rest endpoint(s)
const endpointFilePaths = files.filter(pathname => pathname.includes('/routes/'))
for (const endpointFilePath of endpointFilePaths) {
const endpointDefinition = loadFile(endpointFilePath, validateEndpoint, events)
if (endpointDefinition) {
const url = endpointFilePath.replace(/.*routes\//, '/').replace(/\[(.*?)\]/g, ':$1').replace('.js', '')
const { ...methods } = endpointDefinition
collection.set(url, new RestEndpoint({ url, methods }))
}
}

// apply host map forwarding rules
for (const [pattern, destination] of Object.entries(hostMap)) {
for (const endpoint of collection.keys()) {
const matcher = new RegExp(pattern.replace('*', '.*'))
if (matcher.test(endpoint)) {
const endpointInstance = collection.get(endpoint)
endpointInstance.requestForwardingURL = destination
collection.set(endpoint, endpointInstance)
}
}
}

return collection
}

module.exports = {
initialize,
getByKey
}
3 changes: 1 addition & 2 deletions lib/Fixture.js → lib/Config/fixtures/Fixture.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class Fixture {
this.handlers = handlers
}

execute (req, resPayload, logger) {
execute (req, resPayload) {
try {
let fixture
if (req.body && req.body.operationName) {
Expand All @@ -21,7 +21,6 @@ class Fixture {
switch (fixture.mode) {
case 'replace':
return result
case 'deepMerge':
case 'mergeDeep':
return mergeDeep(resPayload, result)
default: // merge
Expand Down
Loading

0 comments on commit f3a0020

Please sign in to comment.