adapt-authoring-server 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintignore ADDED
@@ -0,0 +1 @@
1
+ node_modules
package/.eslintrc ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "env": {
3
+ "browser": false,
4
+ "node": true,
5
+ "commonjs": false,
6
+ "es2020": true
7
+ },
8
+ "extends": [
9
+ "standard"
10
+ ],
11
+ "parserOptions": {
12
+ "ecmaVersion": 2020
13
+ }
14
+ }
@@ -0,0 +1,55 @@
1
+ name: Bug Report
2
+ description: File a bug report
3
+ labels: ["bug"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to fill out this bug report!
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: What happened?
13
+ description: Please describe the issue
14
+ validations:
15
+ required: true
16
+ - type: textarea
17
+ id: expected
18
+ attributes:
19
+ label: Expected behaviour
20
+ description: Tell us what should have happened
21
+ - type: textarea
22
+ id: repro-steps
23
+ attributes:
24
+ label: Steps to reproduce
25
+ description: Tell us how to reproduce the issue
26
+ validations:
27
+ required: true
28
+ - type: input
29
+ id: aat-version
30
+ attributes:
31
+ label: Authoring tool version
32
+ description: What version of the Adapt authoring tool are you running?
33
+ validations:
34
+ required: true
35
+ - type: input
36
+ id: fw-version
37
+ attributes:
38
+ label: Framework version
39
+ description: What version of the Adapt framework are you running?
40
+ - type: dropdown
41
+ id: browsers
42
+ attributes:
43
+ label: What browsers are you seeing the problem on?
44
+ multiple: true
45
+ options:
46
+ - Firefox
47
+ - Chrome
48
+ - Safari
49
+ - Microsoft Edge
50
+ - type: textarea
51
+ id: logs
52
+ attributes:
53
+ label: Relevant log output
54
+ description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
55
+ render: sh
@@ -0,0 +1 @@
1
+ blank_issues_enabled: false
@@ -0,0 +1,22 @@
1
+ name: Feature request
2
+ description: Request a new feature
3
+ labels: ["enhancement"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to request a new feature in the Adapt authoring tool! The Adapt team will consider all new feature requests, but unfortunately cannot commit to every one.
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: Feature description
13
+ description: Please describe your feature request
14
+ validations:
15
+ required: true
16
+ - type: checkboxes
17
+ id: contribute
18
+ attributes:
19
+ label: Can you work on this feature?
20
+ description: If you are able to commit your own time to work on this feature, it will greatly increase the liklihood of it being implemented by the core dev team. Otherwise, it will be triaged and prioritised alongside the core team's current priorities.
21
+ options:
22
+ - label: I can contribute
@@ -0,0 +1,11 @@
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for all configuration options:
4
+ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "npm" # See documentation for possible values
9
+ directory: "/" # Location of package manifests
10
+ schedule:
11
+ interval: "weekly"
@@ -0,0 +1,25 @@
1
+ [//]: # (Please title your PR according to eslint commit conventions)
2
+ [//]: # (See https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-eslint#eslint-convention for details)
3
+
4
+ [//]: # (Add a link to the original issue)
5
+
6
+ [//]: # (Delete as appropriate)
7
+ ### Fix
8
+ * A sentence describing each fix
9
+
10
+ ### Update
11
+ * A sentence describing each udpate
12
+
13
+ ### New
14
+ * A sentence describing each new feature
15
+
16
+ ### Breaking
17
+ * A sentence describing each breaking change
18
+
19
+ [//]: # (List appropriate steps for testing if needed)
20
+ ### Testing
21
+ 1. Steps for testing
22
+
23
+ [//]: # (Mention any other dependencies)
24
+
25
+
@@ -0,0 +1,19 @@
1
+ name: Add to main project
2
+
3
+ on:
4
+ issues:
5
+ types:
6
+ - opened
7
+ pull_request:
8
+ types:
9
+ - opened
10
+
11
+ jobs:
12
+ add-to-project:
13
+ name: Add to main project
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/add-to-project@v0.1.0
17
+ with:
18
+ project-url: https://github.com/orgs/adapt-security/projects/5
19
+ github-token: ${{ secrets.PROJECTS_SECRET }}
@@ -0,0 +1,8 @@
1
+ {
2
+ "documentation": {
3
+ "enable": true,
4
+ "manualPages": {
5
+ "server-requests.md": "advanced"
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "host": {
6
+ "description": "Name of the host machine the server is running from",
7
+ "type": "string"
8
+ },
9
+ "port": {
10
+ "description": "Port to be used for listening to incoming connections",
11
+ "type": ["number", "string"]
12
+ },
13
+ "url": {
14
+ "description": "URL the server can be accessed from",
15
+ "type": "string",
16
+ "format": "uri",
17
+ "_adapt": {
18
+ "isPublic": true
19
+ }
20
+ },
21
+ "trustProxy": {
22
+ "description": "Whether to trust the client's x-Forwarded-For header for the request IP address. Only enable if using your own trusted reverse proxy",
23
+ "type": ["number", "boolean"],
24
+ "default": 0
25
+ },
26
+ "debugRequestTime": {
27
+ "description": "Will log the execution time of each request",
28
+ "type": "boolean",
29
+ "default": false
30
+ },
31
+ "verboseErrorLogging": {
32
+ "description": "Whether to log errors in their entirety",
33
+ "type": "boolean",
34
+ "default": false
35
+ }
36
+ },
37
+ "required": ["host", "port", "url"]
38
+ }
@@ -0,0 +1,29 @@
1
+ # Handling server requests
2
+ This page gives you some pointers on how to handle incoming requests in a way that will reduce the work needed by you as a developer, and create consistent and easy-to-process responses.
3
+
4
+ ## Extend the `AbstractApiModule` class
5
+ If you're building a non-trivial API (particularly one that uses the database), we highly recommend that you use `AbstractApiModule` as a base, as this includes a lot of boilerplate code and helper functions to make handling HTTP requests much easier. See [this page](writing-an-api) for more info on using the `AbstractApiModule` class.
6
+
7
+ ## Use HTTP status codes
8
+ This may go without saying, but please stick to standardised HTTP response codes; they state your intentions and make it nice and easy for other devs to work with and react to.
9
+
10
+ See the [Mozilla Developer Network docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) for a full list of HTTP response status codes and what they mean.
11
+
12
+ ## Use the built-in error handler
13
+ The Server module adds a `sendError` utility function to the ServerResponse object that's passed to every route handler in the stack. Making use of this in your own code will ensure errors are formatted in a consistent way.
14
+
15
+ Using the helper function is as simple as:
16
+
17
+ ```js
18
+ async myHandler(req, res, next) {
19
+ try {
20
+ // do some stuff
21
+ } catch() {
22
+ res.sendError(e);
23
+ }
24
+ }
25
+ ```
26
+
27
+ See the Express.js documentation for information on the extra functions available:
28
+ - [Request](https://expressjs.com/en/4x/api.html#req)
29
+ - [Response](https://expressjs.com/en/4x/api.html#res)
@@ -0,0 +1,24 @@
1
+ {
2
+ "ENDPOINT_NOT_FOUND": {
3
+ "data": {
4
+ "endpoint": "The missing endpoint",
5
+ "method": "The HTTP method"
6
+ },
7
+ "description": "API endpoint does not exist",
8
+ "statusCode": 404
9
+ },
10
+ "NO_NET_CONN": {
11
+ "data": {
12
+ "hostname": "The hostname we were trying to reach"
13
+ },
14
+ "description": "No network connection",
15
+ "statusCode": 400
16
+ },
17
+ "SERVER_START": {
18
+ "data": {
19
+ "error": "The error"
20
+ },
21
+ "description": "Failed to start server",
22
+ "statusCode": 500
23
+ }
24
+ }
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * HTTP server functionality using Express.js
3
+ * @namespace server
4
+ */
5
+ export { default } from './lib/ServerModule.js'
package/lib/Router.js ADDED
@@ -0,0 +1,272 @@
1
+ import _ from 'lodash'
2
+ import { App } from 'adapt-authoring-core'
3
+ import express from 'express'
4
+ import ServerUtils from './ServerUtils.js'
5
+ /**
6
+ * Handles the Express routing functionality
7
+ * @memberof server
8
+ */
9
+ class Router {
10
+ /**
11
+ * If passing an {@link ExpressRouter} as the parentRouter, it is assumed that the Express Router is the top of the router 'heirarchy' (which will have an impact of some of the {@link Router} methods)
12
+ * @param {String} root Route API endpoint for this router
13
+ * @param {Router|ExpressRouter} parentRouter Parent to mount router
14
+ * @param {Array<Route>} routes Array of routes
15
+ */
16
+ constructor (root, parentRouter, routes) {
17
+ /**
18
+ * The root route the router will be mounted at
19
+ * @type {String}
20
+ */
21
+ this.root = root
22
+ /**
23
+ * Routes config
24
+ * @type {Array<Route>}
25
+ */
26
+ this.routes = routes ?? []
27
+ /**
28
+ * Express router instance
29
+ * @type {external:ExpressRouter}
30
+ */
31
+ this.expressRouter = express.Router()
32
+ /**
33
+ * Express router instance
34
+ * @type {ExpressApp|Router}
35
+ */
36
+ this.parentRouter = parentRouter
37
+ /**
38
+ * List of sub-routers
39
+ * @type {Array<Router>}
40
+ */
41
+ this.childRouters = []
42
+ /**
43
+ * Middleware stack to be added directly to the router
44
+ * @type {Array<Function>}
45
+ */
46
+ this.routerMiddleware = [
47
+ ServerUtils.addErrorHandler
48
+ ]
49
+ /**
50
+ * Middleware stack to be added before route handlers (useful if you need access to specific request attributes that don't exist when standard middleware runs)
51
+ * @type {Array<Function>}
52
+ */
53
+ this.handlerMiddleware = [
54
+ ServerUtils.addExistenceProps,
55
+ ServerUtils.handleInternalRoutes
56
+ ]
57
+ /** @ignore */this._initialised = false
58
+ }
59
+
60
+ /**
61
+ * Returns the map of routes attached to this router
62
+ * @type {Object}
63
+ */
64
+ get map () {
65
+ return ServerUtils.generateRouterMap(this)
66
+ }
67
+
68
+ /**
69
+ * Generates this router's path from its ancestors
70
+ * @type {String}
71
+ */
72
+ get path () {
73
+ let p = ''
74
+
75
+ if (_.isString(this.parentRouter.path)) {
76
+ p += this.parentRouter.path
77
+ }
78
+ if (p[p.length - 1] !== '/' && this.root[0] !== '/') {
79
+ p += '/'
80
+ }
81
+ return p + this.root
82
+ }
83
+
84
+ /**
85
+ * Returns the URL for the router
86
+ * @return {String} The URL
87
+ */
88
+ get url () {
89
+ try {
90
+ const serverUrl = App.instance.dependencyloader.instances['adapt-authoring-server'].url
91
+ return serverUrl + this.path
92
+ } catch (e) {
93
+ this.log('error', e)
94
+ return ''
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Adds middleware to the router stack. Accepts multiple params.
100
+ * @param {...Function} func Middleware function(s) to be added
101
+ * @return {AbstractApiModule} This instance, for chaining
102
+ * @see https://expressjs.com/en/guide/using-middleware.html
103
+ */
104
+ addMiddleware (...func) {
105
+ return this._addMiddleware(this.routerMiddleware, ...func)
106
+ }
107
+
108
+ /**
109
+ * Adds middleware to be called prior to any route handlers. Accepts multiple params. Useful if you need access to specific request attributes that don't exist when standard middleware runs.
110
+ * @param {...Function} func Middleware function(s) to be added
111
+ * @return {AbstractApiModule} This instance, for chaining
112
+ * @see https://expressjs.com/en/guide/using-middleware.html
113
+ */
114
+ addHandlerMiddleware (...func) {
115
+ return this._addMiddleware(this.handlerMiddleware, ...func)
116
+ }
117
+
118
+ /** @ignore */ _addMiddleware (stack, ...func) {
119
+ if (func.length) {
120
+ this.warnOnInited('middleware may not be called before any route handlers')
121
+ func.forEach(f => _.isFunction(f) && !stack.includes(f) && stack.push(f))
122
+ }
123
+ return this
124
+ }
125
+
126
+ /**
127
+ * Recursively gets middleware of the current router heirarchy
128
+ * @param {Router} router The current router (used when run recursively)
129
+ * @param {Array<function>} middleware Middleware function(s) (used when run recursively)
130
+ * @return {Array<Function>}
131
+ */
132
+ getHandlerMiddleware (router = this, middleware = []) {
133
+ if (!(router instanceof Router)) {
134
+ return middleware
135
+ }
136
+ return _.uniq(this.getHandlerMiddleware(router.parentRouter, [...router.handlerMiddleware, ...middleware]))
137
+ }
138
+
139
+ /**
140
+ * Store route definition. Accepts multiple params.
141
+ * @param {...Route} route Config of route(s) to be added
142
+ * @return {AbstractApiModule} This instance, for chaining
143
+ */
144
+ addRoute (...route) {
145
+ const inited = this.warnOnInited(`cannot set further routes (${this.path} ${route.map(r => r.route).join(', ')})`)
146
+ if (!inited && route.length) {
147
+ this.routes.push(...route.filter(this.validateRoute, this))
148
+ }
149
+ return this
150
+ }
151
+
152
+ /**
153
+ * Function for filtering bad route configs
154
+ * @param {Route} r Route config
155
+ * @return {Boolean}
156
+ */
157
+ validateRoute (r) {
158
+ const ePrefix = `invalid route config for ${this.route} router`
159
+ if (!_.isString(r.route)) {
160
+ this.log('warn', `${ePrefix}, route must be a string`)
161
+ return false
162
+ }
163
+ if (!r.handlers) {
164
+ this.log('warn', `${ePrefix}, no route handlers defined`)
165
+ return false
166
+ }
167
+ // handlers can be single function or array of functions
168
+ const allHandlersFuncs = Object.entries(r.handlers).every(([m, h]) => {
169
+ if (this.expressRouter[m] === undefined) {
170
+ this.log('warn', `${ePrefix}, ${m} must be a valid Express.js function`)
171
+ return false
172
+ }
173
+ if (!_.isFunction(h) && !(_.isArray(h) && h.every(_.isFunction))) {
174
+ this.log('warn', `${ePrefix} ${m.toUpperCase()} ${r.route}, all route handlers must be functions`)
175
+ return false
176
+ }
177
+ return true
178
+ })
179
+ if (!allHandlersFuncs) {
180
+ return false
181
+ }
182
+ return true
183
+ }
184
+
185
+ /**
186
+ * Creates and adds a sub-router to this router.
187
+ * @param {string} root The root of the child router
188
+ * @param {Array<Route>} routes Array of Routes to add
189
+ * @return {Router} The new router instance
190
+ */
191
+ createChildRouter (root, routes) {
192
+ if (this.warnOnInited(`cannot create further child routers (${this.path}/${root})`)) {
193
+ return this
194
+ }
195
+ const router = new Router(root, this, routes)
196
+
197
+ this.childRouters.push(router)
198
+
199
+ this.log('debug', 'ADD_ROUTER', router.path)
200
+
201
+ return router
202
+ }
203
+
204
+ /**
205
+ * Initialises the API
206
+ */
207
+ init () {
208
+ if (this.warnOnInited(`(${this.path})`)) {
209
+ return
210
+ }
211
+ if (this.routerMiddleware.length) {
212
+ this.expressRouter.use(...this.routerMiddleware)
213
+ }
214
+ if (this.childRouters.length) {
215
+ this.childRouters.forEach(c => {
216
+ c.init()
217
+ this.expressRouter.use(c.root, c.expressRouter)
218
+ })
219
+ }
220
+ if (this.routes.length) {
221
+ this.routes.forEach(r => {
222
+ Object.entries(r.handlers).forEach(([method, handler]) => {
223
+ this.expressRouter[method](r.route, ServerUtils.cacheRouteConfig(r), ...this.getHandlerMiddleware(), handler)
224
+ this.log('debug', 'ADD_ROUTE', method.toUpperCase(), `${this.path !== '/' ? this.path : ''}${r.route}`)
225
+ })
226
+ })
227
+ }
228
+ // add to the parent stack
229
+ if (this.parentRouter instanceof Router) {
230
+ this.parentRouter.expressRouter.use(`/${this.root}`, this.expressRouter)
231
+ } else {
232
+ const route = this.root[0] !== '/' ? `/${this.root}` : this.root
233
+ this.parentRouter.use(route, this.expressRouter)
234
+ }
235
+ this._initialised = true
236
+ }
237
+
238
+ /**
239
+ * Shortcut for checking Router has initialised, logging a warning if not
240
+ * @param {String} message Message to log on error
241
+ * @return {Boolean}
242
+ */
243
+ warnOnInited (message) {
244
+ if (this._initialised) {
245
+ this.log('warn', `router has already initialised, ${message}`)
246
+ }
247
+ return this._initialised
248
+ }
249
+
250
+ /**
251
+ * Creates an array defining the router inheritance hierarchy
252
+ * @param {Router} router The root router
253
+ * @return {Array}
254
+ */
255
+ flattenRouters (router = this) {
256
+ return router.childRouters.reduce((a, c) => {
257
+ c.childRouters ? a.push(...this.flattenRouters(c)) : a.push(c)
258
+ return a
259
+ }, [router])
260
+ }
261
+
262
+ /**
263
+ * Logs a message
264
+ * @param {String} level Level of log
265
+ * @param {...*} args Arguments to be logged
266
+ */
267
+ log (level, ...args) {
268
+ App.instance.logger.log(level, this.constructor.name.toLowerCase(), ...args)
269
+ }
270
+ }
271
+
272
+ export default Router
@@ -0,0 +1,153 @@
1
+ import express from 'express'
2
+ import { AbstractModule, Hook } from 'adapt-authoring-core'
3
+ import Router from './Router.js'
4
+ import ServerUtils from './ServerUtils.js'
5
+ /**
6
+ * Adds an Express server to the authoring tool
7
+ * @memberof server
8
+ * @extends {AbstractModule}
9
+ */
10
+ class ServerModule extends AbstractModule {
11
+ /** @override */
12
+ async init () {
13
+ /**
14
+ * The main Express Application
15
+ * @type {external:ExpressApp}
16
+ */
17
+ this.expressApp = express()
18
+ /**
19
+ * The default/'root' router for the application
20
+ * @type {Router}
21
+ */
22
+ this.root = new Router('/', this.expressApp)
23
+ /**
24
+ * The router which handles all APIs
25
+ * @type {Router}
26
+ */
27
+ this.api = new Router('api', this.root)
28
+ /**
29
+ * Whether the HTTP server is listening for requests
30
+ * @type {Boolean}
31
+ */
32
+ this.isListening = false
33
+ /**
34
+ * Hook invoked when the HTTP server is listening
35
+ * @type {Hook}
36
+ */
37
+ this.listeningHook = new Hook()
38
+ /**
39
+ * Hook for interrupting requests
40
+ * @type {Hook}
41
+ */
42
+ this.requestHook = new Hook({ mutable: true })
43
+ /**
44
+ * Reference to the HTTP server used by Express
45
+ * @type {external:HttpServer}
46
+ */
47
+ this.httpServer = undefined
48
+
49
+ this.expressApp.set('trust proxy', this.getConfig('trustProxy'))
50
+ this.expressApp.set('view engine', 'hbs')
51
+ /**
52
+ * Need to wait for other modules to load before we properly initialise &
53
+ * start listening for incoming connections
54
+ */
55
+ this.app.onReady().then(this.start.bind(this)).catch(e => this.log('error', e))
56
+ }
57
+
58
+ /**
59
+ * Starts the HTTP server
60
+ */
61
+ async start () {
62
+ // Initialise the root router
63
+ this.root.init()
64
+ // Initialise the API router
65
+ this.api.expressRouter.get('/', ServerUtils.mapHandler(this.api).bind(this))
66
+ this.api.addMiddleware(
67
+ ServerUtils.debugRequestTime
68
+ )
69
+ this.api.init()
70
+ // add not-found handlers
71
+ this.api.expressRouter.use(ServerUtils.apiNotFoundHandler.bind(this))
72
+ this.root.expressRouter.use(ServerUtils.rootNotFoundHandler.bind(this))
73
+ // add generic error handling
74
+ this.expressApp.use(ServerUtils.genericErrorHandler.bind(this))
75
+
76
+ await this.listen()
77
+ this.log('info', `listening on ${this.port}`)
78
+ }
79
+
80
+ /**
81
+ * Server hostname
82
+ * @type {String}
83
+ */
84
+ get host () {
85
+ return this.getConfig('host')
86
+ }
87
+
88
+ /**
89
+ * Port the app should listen on
90
+ * @type {String}
91
+ */
92
+ get port () {
93
+ return this.getConfig('port')
94
+ }
95
+
96
+ /**
97
+ * The URL for the server from its config
98
+ * @type {String}
99
+ */
100
+ get url () {
101
+ return this.getConfig('url') || `${this.getConfig('host')}:${this.port}`
102
+ }
103
+
104
+ /**
105
+ * Middleware function to allow serving of static files
106
+ * @see https://expressjs.com/en/4x/api.html#express.static
107
+ * @param {String} root The root directory from which to serve static assets
108
+ * @param {Object} options Options to pass to the function
109
+ * @return {Function}
110
+ */
111
+ static (root, options) {
112
+ return express.static(root, options)
113
+ }
114
+
115
+ /**
116
+ * Start the Express server (shortcut to the Express function of the same name).
117
+ * @see https://expressjs.com/en/4x/api.html#app.listen
118
+ * @param {function} func Callback function to be called on connection.
119
+ * @return {Promise} Resolves with the server instance
120
+ */
121
+ listen () {
122
+ return new Promise((resolve, reject) => {
123
+ this.httpServer = this.expressApp.listen(this.port, this.host, () => {
124
+ this.isListening = true
125
+ this.listeningHook.invoke()
126
+ resolve(this.httpServer)
127
+ }).once('error', e => reject(this.app.errors.SERVER_START.setData({ error: e })))
128
+ })
129
+ }
130
+
131
+ /**
132
+ * Stops the Express server
133
+ * @return {Promise}
134
+ */
135
+ close () {
136
+ return new Promise((resolve, reject) => {
137
+ if (!this.httpServer) {
138
+ this.log('warn', 'cannot stop server, it wasn\'t running!')
139
+ return resolve()
140
+ }
141
+ if (!this.isListening) return this.listeningHook.tap(this.close.bind(this))
142
+ else this.listeningHook.untap(this.close)
143
+
144
+ this.httpServer.close(() => {
145
+ this.httpServer = undefined
146
+ this.log('info', `no longer listening on ${this.port}`)
147
+ return resolve()
148
+ })
149
+ })
150
+ }
151
+ }
152
+
153
+ export default ServerModule
@@ -0,0 +1,191 @@
1
+ import _ from 'lodash'
2
+ import { App } from 'adapt-authoring-core'
3
+ /**
4
+ * Server-related utilities
5
+ * @memberof server
6
+ */
7
+ class ServerUtils {
8
+ /**
9
+ * Middleware for handling 404 errors on the API router
10
+ * @param {external:ExpressRequest} req
11
+ * @param {external:ExpressResponse} res
12
+ * @param {Function} next
13
+ */
14
+ static apiNotFoundHandler (req, res, next) {
15
+ next(App.instance.errors.ENDPOINT_NOT_FOUND.setData({ endpoint: req.originalUrl, method: req.method }))
16
+ }
17
+
18
+ /**
19
+ * Generic error handling middleware for the API router
20
+ * @param {Error} error
21
+ * @param {external:ExpressRequest} req
22
+ * @param {external:ExpressResponse} res
23
+ * @param {Function} next
24
+ */
25
+ static genericErrorHandler (error, req, res, next) {
26
+ this.log('error', App.instance.lang.translate(undefined, error), this.getConfig('verboseErrorLogging') && error.stack ? error.stack : '')
27
+ res.sendError(error)
28
+ }
29
+
30
+ /**
31
+ * Middleware for handling 404 errors on the root router
32
+ * @param {external:ExpressRequest} req
33
+ * @param {external:ExpressResponse} res
34
+ */
35
+ static rootNotFoundHandler (req, res) {
36
+ res.status(App.instance.errors.NOT_FOUND.statusCode).end()
37
+ }
38
+
39
+ /**
40
+ * Handler for returning an API map
41
+ * @param {Router} topRouter
42
+ * @return {Function} Middleware function
43
+ */
44
+ static mapHandler (topRouter) {
45
+ return (req, res) => res.json(topRouter.map)
46
+ }
47
+
48
+ /**
49
+ * Generates a map for a given router
50
+ * @param {Router} topRouter
51
+ * @return {Object} The route map
52
+ */
53
+ static generateRouterMap (topRouter) {
54
+ return topRouter.flattenRouters()
55
+ .sort((a, b) => a.root.localeCompare(b.root))
56
+ .reduce((m, r) => {
57
+ const key = `${getRelativeRoute(topRouter, r)}endpoints`
58
+ const endpoints = getEndpoints(r)
59
+ return endpoints.length ? { ...m, [key]: endpoints } : m
60
+ }, {})
61
+ }
62
+
63
+ /**
64
+ * Adds extra properties to the request object to allow for easy translations
65
+ * @param {Function} next
66
+ */
67
+ static addErrorHandler (req, res, next) {
68
+ res.sendError = error => {
69
+ if (error.constructor.name !== 'AdaptError') {
70
+ const e = App.instance.errors[error.code]
71
+ if (e) {
72
+ if (error.statusCode) e.statusCode = error.statusCode
73
+ e.error = error.message
74
+ error = e
75
+ } else {
76
+ error = App.instance.errors.SERVER_ERROR
77
+ }
78
+ }
79
+ res
80
+ .status(error.statusCode)
81
+ .json({ code: error.code, message: req.translate?.(error) ?? error.message })
82
+ }
83
+ next()
84
+ }
85
+
86
+ /**
87
+ * Adds logs for debugging each request time
88
+ * @param {external:ExpressRequest} req
89
+ * @param {external:ExpressResponse} res
90
+ * @param {Function} next
91
+ */
92
+ static async debugRequestTime (req, res, next) {
93
+ const server = await App.instance.waitForModule('server')
94
+ if (server.getConfig('debugRequestTime')) {
95
+ const start = new Date()
96
+ res.on('finish', () => server.log('debug', 'REQUEST_DURATION', req.method, req.originalUrl, new Date() - start))
97
+ }
98
+ next()
99
+ }
100
+
101
+ /**
102
+ * Adds extra properties to the request object to allow for easy existence checking of common request objects
103
+ * @param {external:ExpressRequest} req
104
+ * @param {external:ExpressResponse} res
105
+ * @param {Function} next
106
+ * @example
107
+ * "IMPORTANT NOTE: body data is completely ignored for GET requests, any code
108
+ * requiring it should switch to use POST."
109
+ *
110
+ * let req = { 'params': { 'foo':'bar' }, 'query': {}, 'body': {} };
111
+ * req.hasParams // true
112
+ * req.hasQuery // false
113
+ * req.hasBody // false
114
+ */
115
+ static addExistenceProps (req, res, next) {
116
+ if (req.method === 'GET') {
117
+ req.body = {}
118
+ }
119
+ const storeVal = (key, exists) => {
120
+ req[`has${_.capitalize(key)}`] = exists
121
+ }
122
+ ['body', 'params', 'query'].forEach(attr => {
123
+ if (!req[attr]) {
124
+ return storeVal(attr, true)
125
+ }
126
+ const entries = Object.entries(req[attr])
127
+ let deleted = 0
128
+ if (entries.length === 0) {
129
+ return storeVal(attr, false)
130
+ }
131
+ entries.forEach(([key, val]) => {
132
+ if (val === undefined || val === null) {
133
+ delete req[attr][key]
134
+ deleted++
135
+ }
136
+ })
137
+ storeVal(attr, deleted < entries.length)
138
+ })
139
+ next()
140
+ }
141
+
142
+ /**
143
+ * Handles restriction of routes marked as internal
144
+ * @param {external:ExpressRequest} req
145
+ * @param {external:ExpressResponse} res
146
+ * @param {Function} next
147
+ */
148
+ static async handleInternalRoutes (req, res, next) {
149
+ const server = await App.instance.waitForModule('server')
150
+ const isInternalIp = server.getConfig('host') === req.ip || req.ip === '127.0.0.1' || req.ip === '::1'
151
+ if (req.routeConfig.internal && !isInternalIp) {
152
+ return next(App.instance.errors.UNAUTHORISED.setData({ url: req.originalUrl, method: req.method }))
153
+ }
154
+ next()
155
+ }
156
+
157
+ /**
158
+ * Caches the route config on the incoming request
159
+ * @param {Route} routeConfig
160
+ * @return {Function}
161
+ */
162
+ static cacheRouteConfig (routeConfig) {
163
+ return (req, res, next) => {
164
+ req.routeConfig = routeConfig
165
+ next()
166
+ }
167
+ }
168
+ }
169
+ /** @ignore */ function getEndpoints (r) {
170
+ return r.routes.map(route => {
171
+ return {
172
+ url: `${r.url}${route.route}`,
173
+ accepted_methods: Object.keys(route.handlers).reduce((memo, method) => {
174
+ return {
175
+ ...memo,
176
+ [method]: route?.meta?.[method] ?? {}
177
+ }
178
+ }, {})
179
+ }
180
+ })
181
+ }
182
+ /** @ignore */ function getRelativeRoute (relFrom, relTo) {
183
+ if (relFrom === relTo) {
184
+ return `${relFrom.route}_`
185
+ }
186
+ let route = ''
187
+ for (let r = relTo; r !== relFrom; r = r.parentRouter) route = `${r.root}_${route}`
188
+ return route
189
+ }
190
+
191
+ export default ServerUtils
@@ -0,0 +1,57 @@
1
+ /**
2
+ * This file exists to define the below types for documentation purposes.
3
+ */
4
+ /**
5
+ * Built-in HTTP server
6
+ * @memberof server
7
+ * @external HttpServer
8
+ * @see {@link https://nodejs.org/api/http.html#http_class_http_server}
9
+ */
10
+ /**
11
+ * Express.js top-level application
12
+ * @memberof server
13
+ * @external ExpressApp
14
+ * @see {@link https://expressjs.com/en/4x/api.html#app}
15
+ */
16
+ /**
17
+ * Express.js HTTP router
18
+ * @memberof server
19
+ * @external ExpressRouter
20
+ * @see {@link https://expressjs.com/en/4x/api.html#router}
21
+ */
22
+ /**
23
+ * Express.js HTTP request
24
+ * @memberof server
25
+ * @external ExpressRequest
26
+ * @see {@link https://expressjs.com/en/4x/api.html#req}
27
+ */
28
+ /**
29
+ * Express.js HTTP response
30
+ * @memberof server
31
+ * @external ExpressResponse
32
+ * @see {@link https://nodejs.org/api/http.html#http_class_http_serverresponse}
33
+ */
34
+ /**
35
+ * Defines how an individual API route should be handled
36
+ * @memberof server
37
+ * @typedef {Object} Route
38
+ * @property {String} route The name of the api (this will be used as the API endpoint)
39
+ * @property {Object} handlers Object mapping HTTP methods to request handler functions. Note: Any HTTP methods not specified in `handlers` will not be exposed.
40
+ * @property {Array<Function>|Function} [handlers.post] POST handlers for the route
41
+ * @property {Array<Function>|Function} [handlers.get] GET handlers for the route
42
+ * @property {Array<Function>|Function} [handlers.put] PUT handlers for the route
43
+ * @property {Array<Function>|Function} [handlers.delete] DELETE handlers for the route
44
+ * @example
45
+ * {
46
+ * route: '/:id?',
47
+ * handlers: {
48
+ * // can be an array of middleware/handlers
49
+ * post: [beforePost, handlePostRequest, afterPost],
50
+ * // or an individual function
51
+ * get: getRequest,
52
+ * put: putRequest,
53
+ * // or an in-line function
54
+ * delete: (req, res, next) => { next(); }
55
+ * }
56
+ * }
57
+ */
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "adapt-authoring-server",
3
+ "version": "0.0.1",
4
+ "description": "Provides an Express application routing and more",
5
+ "homepage": "https://github.com/adapt-security/adapt-authoring-server",
6
+ "license": "GPL-3.0",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "repository": "github:adapt-security/adapt-authoring-server",
10
+ "dependencies": {
11
+ "express": "^5.1.0",
12
+ "hbs": "^4.2.0",
13
+ "lodash": "^4.17.21"
14
+ },
15
+ "peerDependencies": {
16
+ "adapt-authoring-core": "github:adapt-security/adapt-authoring-core"
17
+ },
18
+ "devDependencies": {
19
+ "eslint": "^9.12.0",
20
+ "standard": "^17.1.0"
21
+ }
22
+ }
@@ -0,0 +1,142 @@
1
+ const { App } = require('adapt-authoring-core');
2
+ const express = require('express');
3
+ const Router = require('../lib/Router');
4
+ const should = require('should');
5
+ const supertest = require('supertest');
6
+
7
+ describe('Server router', function() {
8
+ before(function() {
9
+ this.createRouter = (r = 'test') => new Router(r, express());
10
+ this.testRequest = (router, done, expectedStatus = 200) => {
11
+ const l = router.parentRouter.listen(undefined, function() {
12
+ supertest(router.parentRouter)
13
+ .get(`${router.path}`)
14
+ .expect(expectedStatus)
15
+ .end((e, res) => l.close(done))
16
+ });
17
+ };
18
+ });
19
+ describe('#addRoute()', function() {
20
+ const r1 = { route: 'test1', handlers: { get: () => {} } };
21
+ const r2 = { route: 'test2', handlers: { get: () => {} } };
22
+ const r3 = { route: 'test3', handlers: { get: () => {} } };
23
+ it('should add a route function to the stack', function() {
24
+ const router = this.createRouter();
25
+ router.addRoute(r1);
26
+ router.routes.should.containEql(r1);
27
+ });
28
+ it('should add multiple routes to the stack', function() {
29
+ const router = this.createRouter();
30
+ router.addRoute(r1, r2);
31
+ router.routes.should.containEql(r2);
32
+ });
33
+ it('should return router reference so as to be chainable', function() {
34
+ const router = this.createRouter();
35
+ const r = router.addRoute(r2);
36
+ r.should.equal(router);
37
+ });
38
+ it('should call route handler on client request', function(done) {
39
+ const router = this.createRouter();
40
+ router.addRoute({
41
+ route: '/',
42
+ handlers: { get: (req, res, next) => res.end() }
43
+ });
44
+ router.init();
45
+ this.testRequest(router, done);
46
+ });
47
+ });
48
+ describe('#addMiddleware()', function() {
49
+ const m1 = (req, res, next) => next();
50
+ const m2 = (req, res, next) => next();
51
+ it('should add a middleware function to the stack', function() {
52
+ const router = this.createRouter();
53
+ router.addMiddleware(m1);
54
+ router.middleware.should.containEql(m1);
55
+ });
56
+ it('should add multiple middleware functions to the stack', function() {
57
+ const router = this.createRouter();
58
+ router.addMiddleware(m1, m2);
59
+ router.middleware.should.containEql(m2);
60
+ });
61
+ it('should return router reference so as to be chainable', function() {
62
+ const router = this.createRouter();
63
+ const r = router.addMiddleware(m2);
64
+ r.should.equal(router);
65
+ });
66
+ it('should call custom middleware on client request', function(done) {
67
+ const router = this.createRouter();
68
+ router.addRoute({
69
+ route: '/',
70
+ handlers: { get: (req, res, next) => res.status(500).end() }
71
+ });
72
+ router.addMiddleware((req, res, next) => res.status(200).end());
73
+ router.init();
74
+ this.testRequest(router, done);
75
+ });
76
+ });
77
+ describe('#createChildRouter()', function() {
78
+ let parent, child;
79
+ before(function() {
80
+ parent = this.createRouter();
81
+ child = parent.createChildRouter('child');
82
+ });
83
+ it('should return a router instance', function() {
84
+ child.should.be.instanceof(Router);
85
+ });
86
+ it('should expose specified route', function() {
87
+ child.route.should.equal('child');
88
+ });
89
+ it('should be added to child routers', function() {
90
+ parent.childRouters.should.containEql(child);
91
+ });
92
+ it('should reference current router as parent', function() {
93
+ child.parentRouter.should.equal(parent);
94
+ });
95
+ });
96
+ describe('#path()', function() {
97
+ it('should generate the endpoint of the router', function() {
98
+ const child = new Router('child', new Router('parent', {}));
99
+ child.path.should.equal('/parent/child');
100
+ });
101
+ });
102
+ describe('#handleRequest()', function() {
103
+ it('should invoke listeners to Server#requestHook', function(done) {
104
+ let isDone = false;
105
+ App.instance.getModule('server').requestHook.tap(() => {
106
+ if(!isDone) done();
107
+ isDone = true;
108
+ });
109
+ const router = this.createRouter();
110
+ router.addRoute({
111
+ route: '/',
112
+ handlers: { get: (req, res, next) => {} }
113
+ });
114
+ router.init();
115
+ this.testRequest(router, () => {}, 500);
116
+ });
117
+ });
118
+ describe('#genericNotFoundHandler()', function() {
119
+ it('should return 404 HTTP status', function(done) {
120
+ const router = this.createRouter();
121
+ router.addRoute({
122
+ route: '/',
123
+ handlers: { get: (req, res, next) => res.end() }
124
+ });
125
+ router.addMiddleware(router.genericNotFoundHandler);
126
+ router.init();
127
+ this.testRequest(router, done, 404);
128
+ });
129
+ });
130
+ describe('#genericErrorHandler()', function() {
131
+ it('should return error HTTP status', function(done) {
132
+ const router = this.createRouter();
133
+ router.addRoute({
134
+ route: '/',
135
+ handlers: { get: (req, res, next) => next(new Error('Something bad happened')) }
136
+ });
137
+ router.addMiddleware(router.genericErrorHandler);
138
+ router.init();
139
+ this.testRequest(router, done, 500);
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,51 @@
1
+ const { Hook } = require('adapt-authoring-core');
2
+ const Router = require('../lib/Router');
3
+ const ServerModule = require('../lib/ServerModule');
4
+ const should = require('should');
5
+ const supertest = require('supertest');
6
+
7
+ describe('Server module', function() {
8
+ before(function() {
9
+ this.server = new ServerModule(global.ADAPT.app, { name: require('../package.json').name });
10
+ });
11
+ describe('#constructor()', function() {
12
+ it('should expose the Express application', function() {
13
+ false.should.be.true();
14
+ });
15
+ it('should expose a `root` Router', function() {
16
+ this.server.root.should.be.an.instanceof(Router);
17
+ });
18
+ it('should expose an `api` Router', function() {
19
+ this.server.api.should.be.an.instanceof(Router);
20
+ });
21
+ it('should expose a hook to modify requests', function() {
22
+ this.server.requestHook.should.be.an.instanceof(Hook);
23
+ });
24
+ });
25
+ describe('#url()', function() {
26
+ it('should return the URL of the server', function() {
27
+ this.server.url.should.be.a.String();
28
+ });
29
+ });
30
+ describe('#static()', function() {
31
+ it('should expose Express#static', function() {
32
+ false.should.be.true();
33
+ });
34
+ });
35
+ describe('#listen()', function() {
36
+ it('should accept requests on the specified URL/port', function(done) {
37
+ supertest(this.server.expressApp)
38
+ .get(`${this.server.api.path}`)
39
+ .expect(200)
40
+ .end(done);
41
+ });
42
+ it('should not accept requests on unspecified URLs/ports', function(done) {
43
+ false.should.be.true();
44
+ });
45
+ });
46
+ describe('#close()', function() {
47
+ it('should stop accepting requests', function() {
48
+ false.should.be.true();
49
+ });
50
+ });
51
+ });