adapt-authoring-api 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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Utilities for APIs
3
+ * @memberof api
4
+ */
5
+ class AbstractApiUtils {
6
+ /**
7
+ * Converts HTTP methods to a corresponding 'action' for use in auth
8
+ * @param {String} method The HTTP method
9
+ * @return {String}
10
+ */
11
+ static httpMethodToAction (method) {
12
+ switch (method.toLowerCase()) {
13
+ case 'get':
14
+ return 'read'
15
+ case 'post':
16
+ case 'put':
17
+ case 'patch':
18
+ case 'delete':
19
+ return 'write'
20
+ default:
21
+ return ''
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Converts HTTP methods to a corresponding database function
27
+ * @param {String} method The HTTP method
28
+ * @return {String}
29
+ */
30
+ static httpMethodToDBFunction (method) {
31
+ switch (method.toLowerCase()) {
32
+ case 'post': return 'insert'
33
+ case 'get': return 'find'
34
+ case 'put': case 'patch': return 'update'
35
+ case 'delete': return 'delete'
36
+ default: return ''
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Generates a list of arguments to be passed to the MongoDBModule from a request object
42
+ * @param {external:ExpressRequest} req
43
+ * @return {Array<*>}
44
+ */
45
+ static argsFromReq (req) {
46
+ const opts = { schemaName: req.apiData.schemaName, collectionName: req.apiData.collectionName }
47
+ switch (req.method) {
48
+ case 'GET': case 'DELETE':
49
+ return [req.apiData.query, opts]
50
+ case 'POST':
51
+ return [req.apiData.data, opts]
52
+ case 'PUT': case 'PATCH':
53
+ return [req.apiData.query, req.apiData.data, opts]
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Generates REST API metadata and stores on route config
59
+ * @param {AbstractApiModule} instance The current AbstractApiModule instance
60
+ */
61
+ static generateApiMetadata (instance) {
62
+ const getData = isList => {
63
+ const $ref = { $ref: `#/components/schemas/${instance.schemaName}` }
64
+ return {
65
+ description: `The ${instance.schemaName} data`,
66
+ content: { 'application/json': { schema: isList ? { type: 'array', items: $ref } : $ref } }
67
+ }
68
+ }
69
+ const queryParams = [
70
+ {
71
+ name: 'limit',
72
+ in: 'query',
73
+ description: `How many results should be returned Default value is ${instance.app.config.get('adapt-authoring-api.defaultPageSize')} (max value is ${instance.app.config.get('adapt-authoring-api.maxPageSize')})`
74
+ },
75
+ {
76
+ name: 'page',
77
+ in: 'query',
78
+ description: 'The page of results to return (determined from the limit value)'
79
+ }
80
+ ]
81
+ const verbMap = {
82
+ put: 'Replace',
83
+ get: 'Retrieve',
84
+ patch: 'Update',
85
+ delete: 'Delete',
86
+ post: 'Insert'
87
+ }
88
+ instance.routes.forEach(r => {
89
+ r.meta = {}
90
+ Object.keys(r.handlers).forEach(method => {
91
+ let summary, parameters, requestBody, responses
92
+ switch (r.route) {
93
+ case '/':
94
+ if (method === 'post') {
95
+ summary = `${verbMap.post} a new ${instance.schemaName} document`
96
+ requestBody = getData()
97
+ responses = { 201: getData() }
98
+ } else {
99
+ summary = `${verbMap.get} all ${instance.collectionName} documents`
100
+ parameters = queryParams
101
+ responses = { 200: getData(true) }
102
+ }
103
+ break
104
+
105
+ case '/:_id':
106
+ summary = `${verbMap[method]} an existing ${instance.schemaName} document`
107
+ requestBody = method === 'put' || method === 'patch' ? getData() : method === 'delete' ? undefined : {}
108
+ responses = { [method === 'delete' ? 204 : 200]: getData() }
109
+ break
110
+
111
+ case '/query':
112
+ summary = `Query all ${instance.collectionName}`
113
+ parameters = queryParams
114
+ responses = { 200: getData(true) }
115
+ break
116
+
117
+ case '/schema':
118
+ summary = `Retrieve ${instance.schemaName} schema`
119
+ break
120
+ }
121
+ r.meta[method] = { summary, parameters, requestBody, responses }
122
+ })
123
+ })
124
+ }
125
+
126
+ /**
127
+ * Clones an object and converts any Dates and ObjectIds to Strings
128
+ * @param {Object} data
129
+ * @returns A clone object with stringified ObjectIds
130
+ */
131
+ static stringifyValues (data) {
132
+ return Object.entries(data).reduce((cloned, [key, val]) => {
133
+ const type = val?.constructor?.name
134
+ cloned[key] =
135
+ type === 'Date' || type === 'ObjectId'
136
+ ? val.toString()
137
+ : type === 'Array' || type === 'Object'
138
+ ? this.stringifyValues(val)
139
+ : val
140
+ return cloned
141
+ }, Array.isArray(data) ? [] : {})
142
+ }
143
+ }
144
+
145
+ export default AbstractApiUtils
@@ -0,0 +1,46 @@
1
+ import { App } from 'adapt-authoring-core'
2
+ /**
3
+ * Time-limited data cache
4
+ * @memberof api
5
+ */
6
+ class DataCache {
7
+ /** @override */
8
+ constructor ({ enable, lifespan }) {
9
+ this.isEnabled = enable !== false
10
+ this.lifespan = lifespan ?? App.instance.config.get('adapt-authoring-api.defaultCacheLifespan')
11
+ this.cache = {}
12
+ }
13
+
14
+ /**
15
+ * Retrieve cached data, or run fresh query if no cache exists or cache is invalid
16
+ * @param {Object} query
17
+ * @param Object} options
18
+ * @param {Object} mongoOptions
19
+ * @returns {*} The cached data
20
+ */
21
+ async get (query, options, mongoOptions) {
22
+ const key = JSON.stringify(query) + JSON.stringify(options) + JSON.stringify(mongoOptions)
23
+ this.prune()
24
+ if (this.cache[key]) {
25
+ return this.cache[key].data
26
+ }
27
+ const mongodb = await App.instance.waitForModule('mongodb')
28
+ const data = await mongodb.find(options.collectionName, query, mongoOptions)
29
+ this.cache[key] = { data, timestamp: Date.now() }
30
+ return data
31
+ }
32
+
33
+ /**
34
+ * Removes invalid cache data
35
+ */
36
+ prune () {
37
+ Object.keys(this.cache).forEach(k => {
38
+ const cache = this.cache[k]
39
+ if (Date.now() > (cache.timestamp + this.lifespan)) {
40
+ delete this.cache[k]
41
+ }
42
+ })
43
+ }
44
+ }
45
+
46
+ export default DataCache
@@ -0,0 +1,67 @@
1
+ /**
2
+ * This file exists to define the below types for documentation purposes.
3
+ */
4
+ /**
5
+ * For AbstractApiModule subclasses the Express ClientRequest object is given an extra apiData property which contains useful data related to the incoming request.
6
+ * Extends Route definition with additional API-specific attributes
7
+ * @memberof api
8
+ * @typedef {Object} ApiRequestData
9
+ * @property {Object} config The API route's config data. Set when the route is initialised.
10
+ * @property {String} collectionName The DB collection name
11
+ * @property {Object} data The request body data
12
+ * @property {Object} query The request query data
13
+ * @property {String} schemaName The schema name for data validation
14
+ * @property {Boolean} modifying Whether the request modifies data
15
+ * @see {ApiRoute}
16
+ */
17
+ /**
18
+ * Extends the existing Route definition with additional API-specific attributes
19
+ * @memberof api
20
+ * @typedef {Route} ApiRoute
21
+ * @extends {Route}
22
+ * @property {Array<string>} modifiers Defines which HTTP methods can modify data (verbs must be lower-case). This only needs do be defined for routes are non standard. By default, it is assumed that 'get' requests are non-modifying, and 'post', 'put', 'patch' and 'delete' are modifying.
23
+ * @example
24
+ * modifiers: ['post']
25
+ * @property {Boolean} validate Whether the request data should be validated
26
+ * @property {Object} permissions Definition of permissions allowed required to access each handler
27
+ * @property {Array<string>} [permissions.post] Permissions scopes required to access this route
28
+ * @property {Array<string>} [permissions.get] Permissions scopes required to access this route
29
+ * @property {Array<string>} [permissions.put] Permissions scopes required to access this route
30
+ * @property {Array<string>} [permissions.delete] Permissions scopes required to access this route
31
+ * @example
32
+ * {
33
+ * route: '/',
34
+ * handlers: { post: postHandler },
35
+ * permissions: { post: ['write:scope'] }
36
+ * modifiers: ['post'],
37
+ * }
38
+ */
39
+ /**
40
+ * @memberof api
41
+ * @typedef {Object} InsertOptions
42
+ * @property {String} schemaName Name of the schema to validate against
43
+ * @property {String} collectionName DB collection to insert document into
44
+ * @property {String} validate Whether the incoming data should be validated
45
+ * @property {String} invokePostHook Whether the function should invoke the 'post' action hook on success
46
+ */
47
+ /**
48
+ * @memberof api
49
+ * @typedef {Object} FindOptions
50
+ * @property {String} schemaName Name of the schema to validate against
51
+ * @property {String} collectionName DB collection to insert document into
52
+ */
53
+ /**
54
+ * @memberof api
55
+ * @typedef {Object} UpdateOptions
56
+ * @property {String} schemaName Name of the schema to validate against
57
+ * @property {String} collectionName DB collection to insert document into
58
+ * @property {Boolean} validate Whether the incoming data should be validated
59
+ * @property {String} invokePostHook Whether the function should invoke the 'post' action hook on success
60
+ * @property {Boolean} rawUpdate Whether the provided data should be considered 'raw' (i.e. not format and apply $set MongoDB keyword)
61
+ */
62
+ /**
63
+ * @memberof api
64
+ * @typedef {Object} DeleteOptions
65
+ * @property {String} collectionName DB collection to remove document from
66
+ * @property {String} invokePostHook Whether the function should invoke the 'post' action hook on success
67
+ */
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "adapt-authoring-api",
3
+ "version": "0.0.1",
4
+ "description": "Abstract module for creating APIs",
5
+ "homepage": "https://github.com/adapt-security/adapt-authoring-api",
6
+ "license": "GPL-3.0",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "repository": "github:adapt-security/adapt-authoring-api",
10
+ "dependencies": {
11
+ "lodash": "^4.17.21"
12
+ },
13
+ "peerDependencies": {
14
+ "adapt-authoring-auth": "github:adapt-security/adapt-authoring-auth",
15
+ "adapt-authoring-core": "github:adapt-security/adapt-authoring-core",
16
+ "adapt-authoring-jsonschema": "github:adapt-security/adapt-authoring-jsonschema",
17
+ "adapt-authoring-mongodb": "github:adapt-security/adapt-authoring-mongodb"
18
+ },
19
+ "devDependencies": {
20
+ "eslint": "^9.14.0",
21
+ "standard": "^17.1.0"
22
+ }
23
+ }
@@ -0,0 +1,84 @@
1
+ const { App } = require('adapt-authoring-core');
2
+ const should = require('should');
3
+ const TestApiModule = require('./data/testApiModule');
4
+
5
+ describe('Abstract API module', function() {
6
+ before(function(done) {
7
+ const loadModule = (mod, done) => {
8
+ const m = this.app.getModule(mod);
9
+ m.preload (this.app, () => m.boot(this.app, done, done), done);
10
+ };
11
+ this.app = App.instance;
12
+
13
+ loadModule('server', () => loadModule('mongodb', done));
14
+
15
+ this.tmi = new TestApiModule(this.app, {});
16
+ this.tmi.router.should.not.be.undefined();
17
+ });
18
+ describe('#requestHandler()', function() {
19
+ it('should customise the request object', function() {
20
+ const req = { method: 'GET' };
21
+ TestApiModule.requestHandler()(req, {}, () => {});
22
+ should.exist(req.type);
23
+ should.exist(req.dsquery);
24
+ });
25
+ it('should correctly map HTTP methods to MongoDBModule functions', function() {
26
+ const m1 = this.app.getModule('mongodb');
27
+ const m2 = {
28
+ retrieve: () => {
29
+ return new Promise((resolve, reject) => { correctlyMapped = true; })
30
+ }
31
+ };
32
+ let correctlyMapped = false;
33
+ this.app.dependencyloader.modules['adapt-authoring-mongodb'] = m2;
34
+ TestApiModule.requestHandler()({ method: 'GET' }, {}, () => {});
35
+ this.app.dependencyloader.modules['adapt-authoring-mongodb'] = m1;
36
+ correctlyMapped.should.be.true();
37
+ });
38
+ });
39
+ describe('#preload()', function() {
40
+ it('should create a child Router with the correct route', function(done) {
41
+ this.tmi.preload(this.app, () => {
42
+ this.tmi.router.constructor.name.should.equal('Router');
43
+ done();
44
+ }, done);
45
+ });
46
+ });
47
+ describe('#initSchemas()', function() {
48
+ it('should add specified schemas to the DB', function(done) {
49
+ this.tmi.boot(this.app, () => {
50
+ should.exist(this.app.getModule('mongodb').connection.models.test);
51
+ done();
52
+ }, done);
53
+ });
54
+ it('should ignore badly configured data', function() {
55
+ const mongoModels = this.app.getModule('mongodb').connection.models;
56
+ Object.keys(mongoModels).length.should.equal(1);
57
+ });
58
+ });
59
+ describe('#initMiddleware()', function() {
60
+ it('should add middleware to the API router', function() {
61
+ const middleware = this.tmi.router.middleware;
62
+ middleware.length.should.equal(1);
63
+ middleware[0].name.should.equal('testMiddleware');
64
+ });
65
+ });
66
+ describe('#initRoutes()', function() {
67
+ it('should add routes defined as an object', function() {
68
+ const routes = this.tmi.router.routes.map(r => r.route);
69
+ routes.should.containEql('/objectroute');
70
+ });
71
+ it('should add routes defined as an array', function() {
72
+ const routes = this.tmi.router.routes.map(r => r.route);
73
+ routes.should.containEql('/arrayroute');
74
+ });
75
+ it('should set custom permissions scopes if specified', function() {
76
+ const postScopes = this.app.auth.routes.secure['/api/arrayroute'].post;
77
+ postScopes.should.containEql('testscope');
78
+ });
79
+ it('should set generic permissions scopes if not specified', function() {
80
+ const scopes = this.app.auth.routes.secure['/api/objectroute'];
81
+ scopes.should.not.be.undefined();
82
+ });
83
+ });
84
+ });
@@ -0,0 +1,49 @@
1
+ const should = require('should');
2
+
3
+ describe('Abstract API utilities', function() {
4
+ describe('#callDbFunction()', function() {
5
+ it('should fail if request parameter doesn\'t specify a type', function() {
6
+ false.should.be.true();
7
+ });
8
+ it('should fail if attempting to call an unknown DB function', function() {
9
+ false.should.be.true();
10
+ });
11
+ it('should return data in response', function() {
12
+ false.should.be.true();
13
+ });
14
+ it('should set response HTTP status', function() {
15
+ false.should.be.true();
16
+ });
17
+ });
18
+ describe('#httpMethodToAction()', function() {
19
+ it('should return a string', function() {
20
+ false.should.be.true();
21
+ });
22
+ it('should return action string for known action', function() {
23
+ false.should.be.true();
24
+ });
25
+ it('should return empty string for unknown action', function() {
26
+ false.should.be.true();
27
+ });
28
+ });
29
+ describe('#validateSchemaDef()', function() {
30
+ it('should fail if def isn\'t an object', function() {
31
+ false.should.be.true();
32
+ });
33
+ it('should fail if def has no name', function() {
34
+ false.should.be.true();
35
+ });
36
+ it('should fail if def has no model', function() {
37
+ false.should.be.true();
38
+ });
39
+ it('should fail if schemas isn\'t an array', function() {
40
+ false.should.be.true();
41
+ });
42
+ it('should fail if def has no routes', function() {
43
+ false.should.be.true();
44
+ });
45
+ it('should validate routes', function() {
46
+ false.should.be.true();
47
+ });
48
+ });
49
+ });
@@ -0,0 +1,50 @@
1
+ const AbstractAPIModule = require('../../lib/AbstractApiModule');
2
+
3
+ const TestSchema = {
4
+ name: 'test',
5
+ definition: {
6
+ isTest: {
7
+ type: "boolean", default: true
8
+ }
9
+ }
10
+ };
11
+
12
+ class TestApiModule extends AbstractAPIModule {
13
+ static get def() {
14
+ return {
15
+ name: 'test',
16
+ model: 'test',
17
+ schemas: [
18
+ TestSchema,
19
+ { name: 't2' },
20
+ { definition: {} }
21
+ ],
22
+ middleware: [testMiddleware],
23
+ routes: [
24
+ {
25
+ route: '/arrayroute',
26
+ handlers: ['post','get','put','delete'],
27
+ scopes: { post: 'testscope' }
28
+ },
29
+ {
30
+ route: '/objectroute',
31
+ handlers: {
32
+ post: testRouteHandler,
33
+ get: testRouteHandler,
34
+ put: testRouteHandler,
35
+ delete: testRouteHandler
36
+ }
37
+ }
38
+ ]
39
+ };
40
+ }
41
+ }
42
+
43
+ function testMiddleware(req, res, next) {
44
+ console.log('Test middleware called');
45
+ }
46
+ function testRouteHandler(req, res, next) {
47
+ console.log('Test handler called');
48
+ }
49
+
50
+ module.exports = TestApiModule;