adapt-authoring-api 1.6.1 → 2.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/index.js +2 -1
- package/lib/AbstractApiModule.js +13 -6
- package/lib/utils/argsFromReq.js +17 -0
- package/lib/utils/generateApiMetadata.js +69 -0
- package/lib/utils/httpMethodToAction.js +19 -0
- package/lib/utils/httpMethodToDBFunction.js +15 -0
- package/lib/utils.js +4 -0
- package/package.json +2 -2
- package/tests/utils-argsFromReq.spec.js +42 -0
- package/tests/utils-generateApiMetadata.spec.js +113 -0
- package/tests/utils-httpMethodToAction.spec.js +27 -0
- package/tests/utils-httpMethodToDBFunction.spec.js +30 -0
- package/lib/AbstractApiUtils.js +0 -145
- package/tests/AbstractApiUtils.spec.js +0 -139
package/index.js
CHANGED
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
* @namespace api
|
|
4
4
|
*/
|
|
5
5
|
export { default as AbstractApiModule } from './lib/AbstractApiModule.js'
|
|
6
|
-
export { default as AbstractApiUtils } from './lib/AbstractApiUtils.js'
|
|
7
6
|
export { default } from './lib/AbstractApiModule.js'
|
|
7
|
+
/** @deprecated Use named import { stringifyValues } from 'adapt-authoring-core' instead */
|
|
8
|
+
export { stringifyValues } from 'adapt-authoring-core'
|
package/lib/AbstractApiModule.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import _ from 'lodash'
|
|
2
|
-
import { AbstractModule, Hook } from 'adapt-authoring-core'
|
|
3
|
-
import
|
|
2
|
+
import { AbstractModule, Hook, stringifyValues } from 'adapt-authoring-core'
|
|
3
|
+
import { argsFromReq, generateApiMetadata, httpMethodToDBFunction } from './utils.js'
|
|
4
4
|
import DataCache from './DataCache.js'
|
|
5
5
|
/**
|
|
6
6
|
* Abstract module for creating APIs
|
|
@@ -177,7 +177,14 @@ class AbstractApiModule extends AbstractModule {
|
|
|
177
177
|
return this.log('error', 'Must set API root before calling useDefaultConfig function')
|
|
178
178
|
}
|
|
179
179
|
/** @ignore */ this.routes = this.DEFAULT_ROUTES
|
|
180
|
-
|
|
180
|
+
this.generateApiMetadata()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Generates REST API metadata and stores on route config
|
|
185
|
+
*/
|
|
186
|
+
generateApiMetadata () {
|
|
187
|
+
generateApiMetadata(this)
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
/**
|
|
@@ -349,7 +356,7 @@ class AbstractApiModule extends AbstractModule {
|
|
|
349
356
|
requestHandler () {
|
|
350
357
|
const requestHandler = async (req, res, next) => {
|
|
351
358
|
const method = req.method.toLowerCase()
|
|
352
|
-
const func = this[
|
|
359
|
+
const func = this[httpMethodToDBFunction(method)]
|
|
353
360
|
if (!func) {
|
|
354
361
|
return next(this.app.errors.HTTP_METHOD_NOT_SUPPORTED.setData({ method }))
|
|
355
362
|
}
|
|
@@ -361,7 +368,7 @@ class AbstractApiModule extends AbstractModule {
|
|
|
361
368
|
if (preCheck) {
|
|
362
369
|
await this.checkAccess(req, req.apiData.query)
|
|
363
370
|
}
|
|
364
|
-
data = await func.apply(this,
|
|
371
|
+
data = await func.apply(this, argsFromReq(req))
|
|
365
372
|
if (postCheck) {
|
|
366
373
|
data = await this.checkAccess(req, data)
|
|
367
374
|
}
|
|
@@ -654,7 +661,7 @@ class AbstractApiModule extends AbstractModule {
|
|
|
654
661
|
|
|
655
662
|
if (options.invokePreHook !== false) await this.preUpdateHook.invoke(originalDoc, formattedData.$set, options, mongoOptions)
|
|
656
663
|
formattedData.$set = await this.validate(options.schemaName, {
|
|
657
|
-
...
|
|
664
|
+
...stringifyValues(originalDoc),
|
|
658
665
|
...formattedData.$set
|
|
659
666
|
}, options)
|
|
660
667
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a list of arguments to be passed to the MongoDBModule from a request object
|
|
3
|
+
* @param {external:ExpressRequest} req
|
|
4
|
+
* @return {Array<*>}
|
|
5
|
+
* @memberof api
|
|
6
|
+
*/
|
|
7
|
+
export function argsFromReq (req) {
|
|
8
|
+
const opts = { schemaName: req.apiData.schemaName, collectionName: req.apiData.collectionName }
|
|
9
|
+
switch (req.method) {
|
|
10
|
+
case 'GET': case 'DELETE':
|
|
11
|
+
return [req.apiData.query, opts]
|
|
12
|
+
case 'POST':
|
|
13
|
+
return [req.apiData.data, opts]
|
|
14
|
+
case 'PUT': case 'PATCH':
|
|
15
|
+
return [req.apiData.query, req.apiData.data, opts]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates REST API metadata and stores on route config
|
|
3
|
+
* @param {AbstractApiModule} instance The current AbstractApiModule instance
|
|
4
|
+
* @memberof api
|
|
5
|
+
*/
|
|
6
|
+
export function generateApiMetadata (instance) {
|
|
7
|
+
const getData = isList => {
|
|
8
|
+
const $ref = { $ref: `#/components/schemas/${instance.schemaName}` }
|
|
9
|
+
return {
|
|
10
|
+
description: `The ${instance.schemaName} data`,
|
|
11
|
+
content: { 'application/json': { schema: isList ? { type: 'array', items: $ref } : $ref } }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const queryParams = [
|
|
15
|
+
{
|
|
16
|
+
name: 'limit',
|
|
17
|
+
in: 'query',
|
|
18
|
+
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')})`
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'page',
|
|
22
|
+
in: 'query',
|
|
23
|
+
description: 'The page of results to return (determined from the limit value)'
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
const verbMap = {
|
|
27
|
+
put: 'Replace',
|
|
28
|
+
get: 'Retrieve',
|
|
29
|
+
patch: 'Update',
|
|
30
|
+
delete: 'Delete',
|
|
31
|
+
post: 'Insert'
|
|
32
|
+
}
|
|
33
|
+
instance.routes.forEach(r => {
|
|
34
|
+
r.meta = {}
|
|
35
|
+
Object.keys(r.handlers).forEach(method => {
|
|
36
|
+
let summary, parameters, requestBody, responses
|
|
37
|
+
switch (r.route) {
|
|
38
|
+
case '/':
|
|
39
|
+
if (method === 'post') {
|
|
40
|
+
summary = `${verbMap.post} a new ${instance.schemaName} document`
|
|
41
|
+
requestBody = getData()
|
|
42
|
+
responses = { 201: getData() }
|
|
43
|
+
} else {
|
|
44
|
+
summary = `${verbMap.get} all ${instance.collectionName} documents`
|
|
45
|
+
parameters = queryParams
|
|
46
|
+
responses = { 200: getData(true) }
|
|
47
|
+
}
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
case '/:_id':
|
|
51
|
+
summary = `${verbMap[method]} an existing ${instance.schemaName} document`
|
|
52
|
+
requestBody = method === 'put' || method === 'patch' ? getData() : method === 'delete' ? undefined : {}
|
|
53
|
+
responses = { [method === 'delete' ? 204 : 200]: getData() }
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
case '/query':
|
|
57
|
+
summary = `Query all ${instance.collectionName}`
|
|
58
|
+
parameters = queryParams
|
|
59
|
+
responses = { 200: getData(true) }
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
case '/schema':
|
|
63
|
+
summary = `Retrieve ${instance.schemaName} schema`
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
r.meta[method] = { summary, parameters, requestBody, responses }
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts HTTP methods to a corresponding 'action' for use in auth
|
|
3
|
+
* @param {String} method The HTTP method
|
|
4
|
+
* @return {String}
|
|
5
|
+
* @memberof api
|
|
6
|
+
*/
|
|
7
|
+
export function httpMethodToAction (method) {
|
|
8
|
+
switch (method.toLowerCase()) {
|
|
9
|
+
case 'get':
|
|
10
|
+
return 'read'
|
|
11
|
+
case 'post':
|
|
12
|
+
case 'put':
|
|
13
|
+
case 'patch':
|
|
14
|
+
case 'delete':
|
|
15
|
+
return 'write'
|
|
16
|
+
default:
|
|
17
|
+
return ''
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts HTTP methods to a corresponding database function
|
|
3
|
+
* @param {String} method The HTTP method
|
|
4
|
+
* @return {String}
|
|
5
|
+
* @memberof api
|
|
6
|
+
*/
|
|
7
|
+
export function httpMethodToDBFunction (method) {
|
|
8
|
+
switch (method.toLowerCase()) {
|
|
9
|
+
case 'post': return 'insert'
|
|
10
|
+
case 'get': return 'find'
|
|
11
|
+
case 'put': case 'patch': return 'update'
|
|
12
|
+
case 'delete': return 'delete'
|
|
13
|
+
default: return ''
|
|
14
|
+
}
|
|
15
|
+
}
|
package/lib/utils.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-api",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Abstract module for creating APIs",
|
|
5
5
|
"homepage": "https://github.com/adapt-security/adapt-authoring-api",
|
|
6
6
|
"license": "GPL-3.0",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"test": "node --test 'tests/**/*.spec.js'"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"adapt-authoring-core": "^
|
|
14
|
+
"adapt-authoring-core": "^2.0.0",
|
|
15
15
|
"lodash": "^4.17.21"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { argsFromReq } from '../lib/utils/argsFromReq.js'
|
|
4
|
+
|
|
5
|
+
describe('argsFromReq()', () => {
|
|
6
|
+
const baseApiData = {
|
|
7
|
+
query: { _id: '123' },
|
|
8
|
+
data: { name: 'test' },
|
|
9
|
+
schemaName: 'testSchema',
|
|
10
|
+
collectionName: 'testCollection'
|
|
11
|
+
}
|
|
12
|
+
const expectedOpts = { schemaName: 'testSchema', collectionName: 'testCollection' }
|
|
13
|
+
|
|
14
|
+
it('should return [query, opts] for GET', () => {
|
|
15
|
+
const result = argsFromReq({ method: 'GET', apiData: baseApiData })
|
|
16
|
+
assert.deepEqual(result, [baseApiData.query, expectedOpts])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should return [query, opts] for DELETE', () => {
|
|
20
|
+
const result = argsFromReq({ method: 'DELETE', apiData: baseApiData })
|
|
21
|
+
assert.deepEqual(result, [baseApiData.query, expectedOpts])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should return [data, opts] for POST', () => {
|
|
25
|
+
const result = argsFromReq({ method: 'POST', apiData: baseApiData })
|
|
26
|
+
assert.deepEqual(result, [baseApiData.data, expectedOpts])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should return [query, data, opts] for PUT', () => {
|
|
30
|
+
const result = argsFromReq({ method: 'PUT', apiData: baseApiData })
|
|
31
|
+
assert.deepEqual(result, [baseApiData.query, baseApiData.data, expectedOpts])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should return [query, data, opts] for PATCH', () => {
|
|
35
|
+
const result = argsFromReq({ method: 'PATCH', apiData: baseApiData })
|
|
36
|
+
assert.deepEqual(result, [baseApiData.query, baseApiData.data, expectedOpts])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should return undefined for unknown methods', () => {
|
|
40
|
+
assert.equal(argsFromReq({ method: 'OPTIONS', apiData: baseApiData }), undefined)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { generateApiMetadata } from '../lib/utils/generateApiMetadata.js'
|
|
4
|
+
|
|
5
|
+
describe('generateApiMetadata()', () => {
|
|
6
|
+
function createInstance (routes) {
|
|
7
|
+
return {
|
|
8
|
+
schemaName: 'TestSchema',
|
|
9
|
+
collectionName: 'testcollection',
|
|
10
|
+
app: {
|
|
11
|
+
config: {
|
|
12
|
+
get: (key) => {
|
|
13
|
+
const map = {
|
|
14
|
+
'adapt-authoring-api.defaultPageSize': 50,
|
|
15
|
+
'adapt-authoring-api.maxPageSize': 200
|
|
16
|
+
}
|
|
17
|
+
return map[key]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
routes: routes || []
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
it('should set meta on each route', () => {
|
|
26
|
+
const instance = createInstance([
|
|
27
|
+
{ route: '/', handlers: { post: () => {}, get: () => {} } }
|
|
28
|
+
])
|
|
29
|
+
generateApiMetadata(instance)
|
|
30
|
+
assert.ok(instance.routes[0].meta)
|
|
31
|
+
assert.ok(instance.routes[0].meta.post)
|
|
32
|
+
assert.ok(instance.routes[0].meta.get)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should generate correct summary for POST /', () => {
|
|
36
|
+
const instance = createInstance([
|
|
37
|
+
{ route: '/', handlers: { post: () => {} } }
|
|
38
|
+
])
|
|
39
|
+
generateApiMetadata(instance)
|
|
40
|
+
assert.equal(instance.routes[0].meta.post.summary, 'Insert a new TestSchema document')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should generate correct summary for GET /', () => {
|
|
44
|
+
const instance = createInstance([
|
|
45
|
+
{ route: '/', handlers: { get: () => {} } }
|
|
46
|
+
])
|
|
47
|
+
generateApiMetadata(instance)
|
|
48
|
+
assert.equal(instance.routes[0].meta.get.summary, 'Retrieve all testcollection documents')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should include query parameters for GET /', () => {
|
|
52
|
+
const instance = createInstance([
|
|
53
|
+
{ route: '/', handlers: { get: () => {} } }
|
|
54
|
+
])
|
|
55
|
+
generateApiMetadata(instance)
|
|
56
|
+
const params = instance.routes[0].meta.get.parameters
|
|
57
|
+
assert.ok(Array.isArray(params))
|
|
58
|
+
assert.equal(params.length, 2)
|
|
59
|
+
assert.equal(params[0].name, 'limit')
|
|
60
|
+
assert.equal(params[1].name, 'page')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should generate correct summary for /:_id routes', () => {
|
|
64
|
+
const instance = createInstance([
|
|
65
|
+
{ route: '/:_id', handlers: { get: () => {}, put: () => {}, patch: () => {}, delete: () => {} } }
|
|
66
|
+
])
|
|
67
|
+
generateApiMetadata(instance)
|
|
68
|
+
assert.equal(instance.routes[0].meta.get.summary, 'Retrieve an existing TestSchema document')
|
|
69
|
+
assert.equal(instance.routes[0].meta.put.summary, 'Replace an existing TestSchema document')
|
|
70
|
+
assert.equal(instance.routes[0].meta.patch.summary, 'Update an existing TestSchema document')
|
|
71
|
+
assert.equal(instance.routes[0].meta.delete.summary, 'Delete an existing TestSchema document')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should set 201 response for POST and 204 for DELETE', () => {
|
|
75
|
+
const instance = createInstance([
|
|
76
|
+
{ route: '/', handlers: { post: () => {} } },
|
|
77
|
+
{ route: '/:_id', handlers: { delete: () => {} } }
|
|
78
|
+
])
|
|
79
|
+
generateApiMetadata(instance)
|
|
80
|
+
assert.ok(instance.routes[0].meta.post.responses[201])
|
|
81
|
+
assert.ok(instance.routes[1].meta.delete.responses[204])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should generate correct summary for /query route', () => {
|
|
85
|
+
const instance = createInstance([
|
|
86
|
+
{ route: '/query', handlers: { post: () => {} } }
|
|
87
|
+
])
|
|
88
|
+
generateApiMetadata(instance)
|
|
89
|
+
assert.equal(instance.routes[0].meta.post.summary, 'Query all testcollection')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should generate correct summary for /schema route', () => {
|
|
93
|
+
const instance = createInstance([
|
|
94
|
+
{ route: '/schema', handlers: { get: () => {} } }
|
|
95
|
+
])
|
|
96
|
+
generateApiMetadata(instance)
|
|
97
|
+
assert.equal(instance.routes[0].meta.get.summary, 'Retrieve TestSchema schema')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should handle multiple routes', () => {
|
|
101
|
+
const instance = createInstance([
|
|
102
|
+
{ route: '/', handlers: { post: () => {}, get: () => {} } },
|
|
103
|
+
{ route: '/:_id', handlers: { get: () => {}, delete: () => {} } },
|
|
104
|
+
{ route: '/query', handlers: { post: () => {} } },
|
|
105
|
+
{ route: '/schema', handlers: { get: () => {} } }
|
|
106
|
+
])
|
|
107
|
+
generateApiMetadata(instance)
|
|
108
|
+
assert.equal(Object.keys(instance.routes[0].meta).length, 2)
|
|
109
|
+
assert.equal(Object.keys(instance.routes[1].meta).length, 2)
|
|
110
|
+
assert.equal(Object.keys(instance.routes[2].meta).length, 1)
|
|
111
|
+
assert.equal(Object.keys(instance.routes[3].meta).length, 1)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { httpMethodToAction } from '../lib/utils/httpMethodToAction.js'
|
|
4
|
+
|
|
5
|
+
describe('httpMethodToAction()', () => {
|
|
6
|
+
it('should return "read" for GET', () => {
|
|
7
|
+
assert.equal(httpMethodToAction('get'), 'read')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('should be case-insensitive', () => {
|
|
11
|
+
assert.equal(httpMethodToAction('GET'), 'read')
|
|
12
|
+
assert.equal(httpMethodToAction('Get'), 'read')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const writeMethods = ['post', 'put', 'patch', 'delete']
|
|
16
|
+
writeMethods.forEach(method => {
|
|
17
|
+
it(`should return "write" for ${method.toUpperCase()}`, () => {
|
|
18
|
+
assert.equal(httpMethodToAction(method), 'write')
|
|
19
|
+
assert.equal(httpMethodToAction(method.toUpperCase()), 'write')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should return empty string for unknown methods', () => {
|
|
24
|
+
assert.equal(httpMethodToAction('options'), '')
|
|
25
|
+
assert.equal(httpMethodToAction('head'), '')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { httpMethodToDBFunction } from '../lib/utils/httpMethodToDBFunction.js'
|
|
4
|
+
|
|
5
|
+
describe('httpMethodToDBFunction()', () => {
|
|
6
|
+
const cases = [
|
|
7
|
+
{ method: 'post', expected: 'insert' },
|
|
8
|
+
{ method: 'get', expected: 'find' },
|
|
9
|
+
{ method: 'put', expected: 'update' },
|
|
10
|
+
{ method: 'patch', expected: 'update' },
|
|
11
|
+
{ method: 'delete', expected: 'delete' }
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
cases.forEach(({ method, expected }) => {
|
|
15
|
+
it(`should return "${expected}" for ${method.toUpperCase()}`, () => {
|
|
16
|
+
assert.equal(httpMethodToDBFunction(method), expected)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should be case-insensitive', () => {
|
|
21
|
+
assert.equal(httpMethodToDBFunction('POST'), 'insert')
|
|
22
|
+
assert.equal(httpMethodToDBFunction('Get'), 'find')
|
|
23
|
+
assert.equal(httpMethodToDBFunction('DELETE'), 'delete')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should return empty string for unknown methods', () => {
|
|
27
|
+
assert.equal(httpMethodToDBFunction('options'), '')
|
|
28
|
+
assert.equal(httpMethodToDBFunction('head'), '')
|
|
29
|
+
})
|
|
30
|
+
})
|
package/lib/AbstractApiUtils.js
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
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
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import AbstractApiUtils from '../lib/AbstractApiUtils.js'
|
|
4
|
-
|
|
5
|
-
describe('AbstractApiUtils', () => {
|
|
6
|
-
describe('.httpMethodToAction()', () => {
|
|
7
|
-
it('should return "read" for GET', () => {
|
|
8
|
-
assert.equal(AbstractApiUtils.httpMethodToAction('get'), 'read')
|
|
9
|
-
assert.equal(AbstractApiUtils.httpMethodToAction('GET'), 'read')
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
const writeMethods = ['post', 'put', 'patch', 'delete']
|
|
13
|
-
writeMethods.forEach(method => {
|
|
14
|
-
it(`should return "write" for ${method.toUpperCase()}`, () => {
|
|
15
|
-
assert.equal(AbstractApiUtils.httpMethodToAction(method), 'write')
|
|
16
|
-
assert.equal(AbstractApiUtils.httpMethodToAction(method.toUpperCase()), 'write')
|
|
17
|
-
})
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('should return empty string for unknown methods', () => {
|
|
21
|
-
assert.equal(AbstractApiUtils.httpMethodToAction('options'), '')
|
|
22
|
-
assert.equal(AbstractApiUtils.httpMethodToAction('head'), '')
|
|
23
|
-
})
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
describe('.httpMethodToDBFunction()', () => {
|
|
27
|
-
const cases = [
|
|
28
|
-
{ method: 'post', expected: 'insert' },
|
|
29
|
-
{ method: 'get', expected: 'find' },
|
|
30
|
-
{ method: 'put', expected: 'update' },
|
|
31
|
-
{ method: 'patch', expected: 'update' },
|
|
32
|
-
{ method: 'delete', expected: 'delete' }
|
|
33
|
-
]
|
|
34
|
-
cases.forEach(({ method, expected }) => {
|
|
35
|
-
it(`should return "${expected}" for ${method.toUpperCase()}`, () => {
|
|
36
|
-
assert.equal(AbstractApiUtils.httpMethodToDBFunction(method), expected)
|
|
37
|
-
})
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('should be case-insensitive', () => {
|
|
41
|
-
assert.equal(AbstractApiUtils.httpMethodToDBFunction('POST'), 'insert')
|
|
42
|
-
assert.equal(AbstractApiUtils.httpMethodToDBFunction('Get'), 'find')
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('should return empty string for unknown methods', () => {
|
|
46
|
-
assert.equal(AbstractApiUtils.httpMethodToDBFunction('options'), '')
|
|
47
|
-
})
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
describe('.argsFromReq()', () => {
|
|
51
|
-
const baseApiData = {
|
|
52
|
-
query: { _id: '123' },
|
|
53
|
-
data: { name: 'test' },
|
|
54
|
-
schemaName: 'testSchema',
|
|
55
|
-
collectionName: 'testCollection'
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
it('should return [query, opts] for GET', () => {
|
|
59
|
-
const req = { method: 'GET', apiData: baseApiData }
|
|
60
|
-
const result = AbstractApiUtils.argsFromReq(req)
|
|
61
|
-
assert.deepEqual(result[0], baseApiData.query)
|
|
62
|
-
assert.deepEqual(result[1], { schemaName: 'testSchema', collectionName: 'testCollection' })
|
|
63
|
-
assert.equal(result.length, 2)
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('should return [query, opts] for DELETE', () => {
|
|
67
|
-
const req = { method: 'DELETE', apiData: baseApiData }
|
|
68
|
-
const result = AbstractApiUtils.argsFromReq(req)
|
|
69
|
-
assert.deepEqual(result[0], baseApiData.query)
|
|
70
|
-
assert.equal(result.length, 2)
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('should return [data, opts] for POST', () => {
|
|
74
|
-
const req = { method: 'POST', apiData: baseApiData }
|
|
75
|
-
const result = AbstractApiUtils.argsFromReq(req)
|
|
76
|
-
assert.deepEqual(result[0], baseApiData.data)
|
|
77
|
-
assert.equal(result.length, 2)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('should return [query, data, opts] for PUT', () => {
|
|
81
|
-
const req = { method: 'PUT', apiData: baseApiData }
|
|
82
|
-
const result = AbstractApiUtils.argsFromReq(req)
|
|
83
|
-
assert.deepEqual(result[0], baseApiData.query)
|
|
84
|
-
assert.deepEqual(result[1], baseApiData.data)
|
|
85
|
-
assert.equal(result.length, 3)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('should return [query, data, opts] for PATCH', () => {
|
|
89
|
-
const req = { method: 'PATCH', apiData: baseApiData }
|
|
90
|
-
const result = AbstractApiUtils.argsFromReq(req)
|
|
91
|
-
assert.deepEqual(result[0], baseApiData.query)
|
|
92
|
-
assert.deepEqual(result[1], baseApiData.data)
|
|
93
|
-
assert.equal(result.length, 3)
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
it('should return undefined for unknown methods', () => {
|
|
97
|
-
const req = { method: 'OPTIONS', apiData: baseApiData }
|
|
98
|
-
assert.equal(AbstractApiUtils.argsFromReq(req), undefined)
|
|
99
|
-
})
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
describe('.stringifyValues()', () => {
|
|
103
|
-
it('should pass through plain values unchanged', () => {
|
|
104
|
-
const data = { a: 'hello', b: 42, c: true, d: null }
|
|
105
|
-
const result = AbstractApiUtils.stringifyValues(data)
|
|
106
|
-
assert.deepEqual(result, data)
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it('should convert Date values to strings', () => {
|
|
110
|
-
const date = new Date('2025-01-01T00:00:00.000Z')
|
|
111
|
-
const result = AbstractApiUtils.stringifyValues({ date })
|
|
112
|
-
assert.equal(typeof result.date, 'string')
|
|
113
|
-
assert.equal(result.date, date.toString())
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it('should recursively process nested objects', () => {
|
|
117
|
-
const data = { nested: { value: 'test', date: new Date('2025-01-01') } }
|
|
118
|
-
const result = AbstractApiUtils.stringifyValues(data)
|
|
119
|
-
assert.equal(typeof result.nested, 'object')
|
|
120
|
-
assert.equal(typeof result.nested.date, 'string')
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
it('should recursively process arrays', () => {
|
|
124
|
-
const date = new Date('2025-01-01')
|
|
125
|
-
const data = { items: [date, 'text', 42] }
|
|
126
|
-
const result = AbstractApiUtils.stringifyValues(data)
|
|
127
|
-
assert.ok(Array.isArray(result.items))
|
|
128
|
-
assert.equal(typeof result.items[0], 'string')
|
|
129
|
-
assert.equal(result.items[1], 'text')
|
|
130
|
-
assert.equal(result.items[2], 42)
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it('should return an array when input is an array', () => {
|
|
134
|
-
const result = AbstractApiUtils.stringifyValues([{ a: 1 }, { b: 2 }])
|
|
135
|
-
assert.ok(Array.isArray(result))
|
|
136
|
-
assert.equal(result.length, 2)
|
|
137
|
-
})
|
|
138
|
-
})
|
|
139
|
-
})
|