-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #78 from pixelprodev/uplift
Uplift
- Loading branch information
Showing
46 changed files
with
1,607 additions
and
845 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.