adapt-authoring-api 2.2.0 → 3.1.0

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.
@@ -53,6 +53,8 @@ class NotesModule extends AbstractApiModule {
53
53
  export default NotesModule
54
54
  ```
55
55
 
56
+ > **Tip:** For new modules, consider defining routes in a `routes.json` file instead of calling `useDefaultRouteConfig()`. See [Custom routes](#custom-routes) for details.
57
+
56
58
  With a schema at `schema/note.schema.json`:
57
59
 
58
60
  ```json
@@ -12,11 +12,11 @@ class AbstractApiModule extends AbstractModule {
12
12
  get DEFAULT_ROUTES () {
13
13
  const readPerms = [`read:${this.permissionsScope || this.root}`]
14
14
  const writePerms = [`write:${this.permissionsScope || this.root}`]
15
- const handler = this.requestHandler()
15
+ const handler = this.requestHandler.bind(this)
16
16
  return [
17
17
  {
18
18
  route: '/',
19
- handlers: { post: handler, get: this.queryHandler() },
19
+ handlers: { post: handler, get: this.queryHandler.bind(this) },
20
20
  permissions: { post: writePerms, get: readPerms }
21
21
  },
22
22
  {
@@ -33,7 +33,7 @@ class AbstractApiModule extends AbstractModule {
33
33
  route: '/query',
34
34
  validate: false,
35
35
  modifying: false,
36
- handlers: { post: this.queryHandler() },
36
+ handlers: { post: this.queryHandler.bind(this) },
37
37
  permissions: { post: readPerms }
38
38
  }
39
39
  ]
@@ -153,11 +153,6 @@ class AbstractApiModule extends AbstractModule {
153
153
 
154
154
  const config = await loadRouteConfig(this.rootDir, this, {
155
155
  schema: 'apiroutes',
156
- handlerAliases: {
157
- default: this.requestHandler(),
158
- query: this.queryHandler(),
159
- serveSchema: this.serveSchema.bind(this)
160
- },
161
156
  defaults: new URL('./default-routes.json', import.meta.url).pathname
162
157
  })
163
158
  if (config) this.applyRouteConfig(config)
@@ -241,26 +236,43 @@ class AbstractApiModule extends AbstractModule {
241
236
 
242
237
  /**
243
238
  * Applies route configuration loaded from routes.json.
244
- * Expands `${scope}` permission placeholders with `this.permissionsScope || this.root`.
239
+ * Resolves `${scope}`, `${schemaName}`, and `${collectionName}` placeholders
240
+ * throughout the route config (permissions, meta, etc.).
245
241
  * @param {Object} config The route config object returned by loadRouteConfig
246
242
  */
247
243
  applyRouteConfig (config) {
248
244
  /** @ignore */ this.root = config.root
249
245
  if (config.schemaName !== undefined) this.schemaName = config.schemaName
250
246
  if (config.collectionName !== undefined) this.collectionName = config.collectionName
251
- const scope = this.permissionsScope || this.root
252
- this.routes = config.routes.map(r => {
253
- if (!r.permissions) return r
254
- return {
255
- ...r,
256
- permissions: Object.fromEntries(
257
- Object.entries(r.permissions).map(([method, perms]) => [
258
- method,
259
- Array.isArray(perms) ? perms.map(p => p.replace('${scope}', scope)) : perms // eslint-disable-line no-template-curly-in-string
260
- ])
261
- )
262
- }
263
- })
247
+ if (config.permissionsScope !== undefined) this.permissionsScope = config.permissionsScope
248
+ /* eslint-disable no-template-curly-in-string */
249
+ const replacements = {
250
+ '${scope}': this.permissionsScope || this.root,
251
+ '${schemaName}': this.schemaName,
252
+ '${collectionName}': this.collectionName
253
+ }
254
+ /* eslint-enable no-template-curly-in-string */
255
+ this.routes = config.routes.map(r => this.replacePlaceholders(r, replacements))
256
+ }
257
+
258
+ /**
259
+ * Recursively replaces placeholder strings in an object tree.
260
+ * Non-string values (functions, numbers, booleans, null) pass through unchanged.
261
+ * @param {*} obj The value to process
262
+ * @param {Object<string,string>} replacements Map of placeholder to replacement value
263
+ * @returns {*} The value with all placeholders resolved
264
+ */
265
+ replacePlaceholders (obj, replacements) {
266
+ if (typeof obj === 'string') {
267
+ return Object.entries(replacements).reduce((s, [k, v]) => v != null ? s.replaceAll(k, v) : s, obj)
268
+ }
269
+ if (Array.isArray(obj)) return obj.map(item => this.replacePlaceholders(item, replacements))
270
+ if (obj && typeof obj === 'object' && obj.constructor === Object) {
271
+ return Object.fromEntries(
272
+ Object.entries(obj).map(([k, v]) => [k, this.replacePlaceholders(v, replacements)])
273
+ )
274
+ }
275
+ return obj
264
276
  }
265
277
 
266
278
  /**
@@ -390,135 +402,129 @@ class AbstractApiModule extends AbstractModule {
390
402
  * Middleware to handle a generic API request. Supports POST, GET, PUT and DELETE of items in the database.
391
403
  * @return {Function} Express middleware function
392
404
  */
393
- requestHandler () {
394
- const requestHandler = async (req, res, next) => {
395
- const method = req.method.toLowerCase()
396
- const func = this[httpMethodToDBFunction(method)]
397
- if (!func) {
398
- return next(this.app.errors.HTTP_METHOD_NOT_SUPPORTED.setData({ method }))
399
- }
400
- let data
401
- try {
402
- await this.requestHook.invoke(req)
403
- const preCheck = method !== 'get' && method !== 'post'
404
- const postCheck = method === 'get'
405
- if (preCheck) {
406
- await this.checkAccess(req, req.apiData.query)
407
- }
408
- data = await func.apply(this, argsFromReq(req))
409
- if (postCheck) {
410
- data = await this.checkAccess(req, data)
411
- }
412
- data = await this.sanitise(req.apiData.schemaName, data, { isInternal: true, strict: false })
413
- } catch (e) {
414
- return next(e)
405
+ async requestHandler (req, res, next) {
406
+ const method = req.method.toLowerCase()
407
+ const func = this[httpMethodToDBFunction(method)]
408
+ if (!func) {
409
+ return next(this.app.errors.HTTP_METHOD_NOT_SUPPORTED.setData({ method }))
410
+ }
411
+ let data
412
+ try {
413
+ await this.requestHook.invoke(req)
414
+ const preCheck = method !== 'get' && method !== 'post'
415
+ const postCheck = method === 'get'
416
+ if (preCheck) {
417
+ await this.checkAccess(req, req.apiData.query)
415
418
  }
416
- if (Array.isArray(data) && req.params._id) { // special case for when _id param is present
417
- if (!data.length) {
418
- return next(this.app.errors.NOT_FOUND.setData({ id: req.params.id, type: req.apiData.schemaName }))
419
- }
420
- data = data[0]
419
+ data = await func.apply(this, argsFromReq(req))
420
+ if (postCheck) {
421
+ data = await this.checkAccess(req, data)
421
422
  }
422
- if (method !== 'get') {
423
- const resource = Array.isArray(data) ? req.apiData.query : data._id.toString()
424
- this.log('debug', `API_${func.name.toUpperCase()}`, resource, 'by', req.auth.user._id.toString())
423
+ data = await this.sanitise(req.apiData.schemaName, data, { isInternal: true, strict: false })
424
+ } catch (e) {
425
+ return next(e)
426
+ }
427
+ if (Array.isArray(data) && req.params._id) { // special case for when _id param is present
428
+ if (!data.length) {
429
+ return next(this.app.errors.NOT_FOUND.setData({ id: req.params.id, type: req.apiData.schemaName }))
425
430
  }
426
- res.status(this.mapStatusCode(method)).json(data)
431
+ data = data[0]
427
432
  }
428
- return requestHandler
433
+ if (method !== 'get') {
434
+ const resource = Array.isArray(data) ? req.apiData.query : data._id.toString()
435
+ this.log('debug', `API_${func.name.toUpperCase()}`, resource, 'by', req.auth.user._id.toString())
436
+ }
437
+ res.status(this.mapStatusCode(method)).json(data)
429
438
  }
430
439
 
431
440
  /**
432
441
  * Express request handler for advanced API queries. Supports collation/limit/page/skip/sort and pagination. For incoming query data to be correctly parsed, it must be sent as body data using a POST request.
433
442
  * @return {function}
434
443
  */
435
- queryHandler () {
436
- const queryHandler = async (req, res, next) => {
437
- try {
438
- const opts = {
439
- schemaName: req.apiData.schemaName,
440
- collectionName: req.apiData.collectionName
441
- }
442
- const mongoOpts = {}
443
- // find and remove mongo options from the query
444
- Object.entries(req.apiData.query).forEach(([key, val]) => {
445
- if (['collation', 'limit', 'page', 'skip', 'sort'].includes(key)) {
446
- try {
447
- mongoOpts[key] = JSON.parse(req.apiData.query[key])
448
- } catch (e) {
449
- this.log('warn', `failed to parse query ${key} param '${mongoOpts[key]}', ${e}`)
450
- }
451
- delete req.apiData.query[key]
452
- } else {
453
- // otherwise assume we have a query field or option and store for later processing
454
- opts[key] = val
455
- }
456
- })
457
- // handle search parameter
458
- const search = req.apiData.query.search
459
- if (search) {
460
- delete req.apiData.query.search
444
+ async queryHandler (req, res, next) {
445
+ try {
446
+ const opts = {
447
+ schemaName: req.apiData.schemaName,
448
+ collectionName: req.apiData.collectionName
449
+ }
450
+ const mongoOpts = {}
451
+ // find and remove mongo options from the query
452
+ Object.entries(req.apiData.query).forEach(([key, val]) => {
453
+ if (['collation', 'limit', 'page', 'skip', 'sort'].includes(key)) {
461
454
  try {
462
- const schema = await this.getSchema(req.apiData.schemaName)
463
- if (schema && schema.built && schema.built.properties) {
464
- const searchableFields = Object.keys(schema.built.properties).filter(
465
- field => schema.built.properties[field].isSearchable === true
466
- )
467
- if (searchableFields.length) {
468
- // escape special regex characters to prevent ReDoS attacks
469
- const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
470
- const regex = { $regex: escapedSearch, $options: 'i' }
471
- const searchConditions = searchableFields.map(f => ({ [f]: regex }))
472
- // merge with existing $or if present
473
- if (req.apiData.query.$or) {
474
- req.apiData.query.$and = [
475
- { $or: req.apiData.query.$or },
476
- { $or: searchConditions }
477
- ]
478
- delete req.apiData.query.$or
479
- } else {
480
- req.apiData.query.$or = searchConditions
481
- }
455
+ mongoOpts[key] = JSON.parse(req.apiData.query[key])
456
+ } catch (e) {
457
+ this.log('warn', `failed to parse query ${key} param '${mongoOpts[key]}', ${e}`)
458
+ }
459
+ delete req.apiData.query[key]
460
+ } else {
461
+ // otherwise assume we have a query field or option and store for later processing
462
+ opts[key] = val
463
+ }
464
+ })
465
+ // handle search parameter
466
+ const search = req.apiData.query.search
467
+ if (search) {
468
+ delete req.apiData.query.search
469
+ try {
470
+ const schema = await this.getSchema(req.apiData.schemaName)
471
+ if (schema && schema.built && schema.built.properties) {
472
+ const searchableFields = Object.keys(schema.built.properties).filter(
473
+ field => schema.built.properties[field].isSearchable === true
474
+ )
475
+ if (searchableFields.length) {
476
+ // escape special regex characters to prevent ReDoS attacks
477
+ const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
478
+ const regex = { $regex: escapedSearch, $options: 'i' }
479
+ const searchConditions = searchableFields.map(f => ({ [f]: regex }))
480
+ // merge with existing $or if present
481
+ if (req.apiData.query.$or) {
482
+ req.apiData.query.$and = [
483
+ { $or: req.apiData.query.$or },
484
+ { $or: searchConditions }
485
+ ]
486
+ delete req.apiData.query.$or
487
+ } else {
488
+ req.apiData.query.$or = searchConditions
482
489
  }
483
490
  }
484
- } catch (e) {
485
- this.log('warn', `failed to process search parameter, ${e.message}`)
486
491
  }
492
+ } catch (e) {
493
+ this.log('warn', `failed to process search parameter, ${e.message}`)
487
494
  }
488
- req.apiData.query = await this.parseQuery(req.apiData.schemaName, req.body, mongoOpts)
489
- // remove any valid query keys from the options
490
- Object.keys(req.apiData.query).forEach(key => delete opts[key])
495
+ }
496
+ req.apiData.query = await this.parseQuery(req.apiData.schemaName, req.body, mongoOpts)
497
+ // remove any valid query keys from the options
498
+ Object.keys(req.apiData.query).forEach(key => delete opts[key])
491
499
 
492
- await this.requestHook.invoke(req)
500
+ await this.requestHook.invoke(req)
493
501
 
494
- await this.setUpPagination(req, res, mongoOpts)
502
+ await this.setUpPagination(req, res, mongoOpts)
495
503
 
496
- let results = await this.find(req.apiData.query, opts, mongoOpts)
504
+ let results = await this.find(req.apiData.query, opts, mongoOpts)
497
505
 
498
- results = await this.checkAccess(req, results)
506
+ results = await this.checkAccess(req, results)
499
507
 
500
- // If checkAccess filtered some results, fetch more to fill the page
501
- const pageSize = mongoOpts.limit
502
- if (pageSize && results.length < pageSize) {
503
- let fetchSkip = mongoOpts.skip + pageSize
504
- while (results.length < pageSize) {
505
- const extra = await this.find(req.apiData.query, opts, { ...mongoOpts, skip: fetchSkip })
506
- if (!extra.length) break
507
- const filtered = await this.checkAccess(req, extra)
508
- results = results.concat(filtered)
509
- fetchSkip += extra.length
510
- }
511
- if (results.length > pageSize) results = results.slice(0, pageSize)
508
+ // If checkAccess filtered some results, fetch more to fill the page
509
+ const pageSize = mongoOpts.limit
510
+ if (pageSize && results.length < pageSize) {
511
+ let fetchSkip = mongoOpts.skip + pageSize
512
+ while (results.length < pageSize) {
513
+ const extra = await this.find(req.apiData.query, opts, { ...mongoOpts, skip: fetchSkip })
514
+ if (!extra.length) break
515
+ const filtered = await this.checkAccess(req, extra)
516
+ results = results.concat(filtered)
517
+ fetchSkip += extra.length
512
518
  }
519
+ if (results.length > pageSize) results = results.slice(0, pageSize)
520
+ }
513
521
 
514
- results = await this.sanitise(req.apiData.schemaName, results, { isInternal: true, strict: false })
522
+ results = await this.sanitise(req.apiData.schemaName, results, { isInternal: true, strict: false })
515
523
 
516
- res.status(this.mapStatusCode('get')).json(results)
517
- } catch (e) {
518
- return next(e)
519
- }
524
+ res.status(this.mapStatusCode('get')).json(results)
525
+ } catch (e) {
526
+ return next(e)
520
527
  }
521
- return queryHandler
522
528
  }
523
529
 
524
530
  /**
@@ -2,25 +2,147 @@
2
2
  "routes": [
3
3
  {
4
4
  "route": "/",
5
- "handlers": { "post": "default", "get": "default" },
6
- "permissions": { "post": ["write:${scope}"], "get": ["read:${scope}"] }
5
+ "handlers": { "post": "requestHandler", "get": "queryHandler" },
6
+ "permissions": { "post": ["write:${scope}"], "get": ["read:${scope}"] },
7
+ "meta": {
8
+ "post": {
9
+ "summary": "Insert a new ${schemaName} document",
10
+ "requestBody": {
11
+ "content": {
12
+ "application/json": {
13
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
14
+ }
15
+ }
16
+ },
17
+ "responses": {
18
+ "201": {
19
+ "description": "The created ${schemaName} document",
20
+ "content": {
21
+ "application/json": {
22
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
23
+ }
24
+ }
25
+ }
26
+ }
27
+ },
28
+ "get": {
29
+ "summary": "Retrieve all ${collectionName} documents",
30
+ "parameters": [
31
+ { "name": "limit", "in": "query", "description": "How many results to return" },
32
+ { "name": "page", "in": "query", "description": "The page of results to return" }
33
+ ],
34
+ "responses": {
35
+ "200": {
36
+ "description": "List of ${schemaName} documents",
37
+ "content": {
38
+ "application/json": {
39
+ "schema": { "type": "array", "items": { "$ref": "#/components/schemas/${schemaName}" } }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
7
46
  },
8
47
  {
9
48
  "route": "/schema",
10
49
  "handlers": { "get": "serveSchema" },
11
- "permissions": { "get": ["read:schema"] }
50
+ "permissions": { "get": ["read:schema"] },
51
+ "meta": {
52
+ "get": { "summary": "Retrieve ${schemaName} schema" }
53
+ }
12
54
  },
13
55
  {
14
56
  "route": "/:_id",
15
- "handlers": { "put": "default", "get": "default", "patch": "default", "delete": "default" },
16
- "permissions": { "put": ["write:${scope}"], "get": ["read:${scope}"], "patch": ["write:${scope}"], "delete": ["write:${scope}"] }
57
+ "handlers": { "put": "requestHandler", "get": "requestHandler", "patch": "requestHandler", "delete": "requestHandler" },
58
+ "permissions": { "put": ["write:${scope}"], "get": ["read:${scope}"], "patch": ["write:${scope}"], "delete": ["write:${scope}"] },
59
+ "meta": {
60
+ "put": {
61
+ "summary": "Replace an existing ${schemaName} document",
62
+ "requestBody": {
63
+ "content": {
64
+ "application/json": {
65
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
66
+ }
67
+ }
68
+ },
69
+ "responses": {
70
+ "200": {
71
+ "description": "The updated ${schemaName} document",
72
+ "content": {
73
+ "application/json": {
74
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ },
80
+ "get": {
81
+ "summary": "Retrieve an existing ${schemaName} document",
82
+ "responses": {
83
+ "200": {
84
+ "description": "The ${schemaName} document",
85
+ "content": {
86
+ "application/json": {
87
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ },
93
+ "patch": {
94
+ "summary": "Update an existing ${schemaName} document",
95
+ "requestBody": {
96
+ "content": {
97
+ "application/json": {
98
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
99
+ }
100
+ }
101
+ },
102
+ "responses": {
103
+ "200": {
104
+ "description": "The updated ${schemaName} document",
105
+ "content": {
106
+ "application/json": {
107
+ "schema": { "$ref": "#/components/schemas/${schemaName}" }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ },
113
+ "delete": {
114
+ "summary": "Delete an existing ${schemaName} document",
115
+ "responses": {
116
+ "204": { "description": "Document deleted successfully" }
117
+ }
118
+ }
119
+ }
17
120
  },
18
121
  {
19
122
  "route": "/query",
20
123
  "validate": false,
21
124
  "modifying": false,
22
- "handlers": { "post": "query" },
23
- "permissions": { "post": ["read:${scope}"] }
125
+ "handlers": { "post": "queryHandler" },
126
+ "permissions": { "post": ["read:${scope}"] },
127
+ "meta": {
128
+ "post": {
129
+ "summary": "Query all ${collectionName}",
130
+ "parameters": [
131
+ { "name": "limit", "in": "query", "description": "How many results to return" },
132
+ { "name": "page", "in": "query", "description": "The page of results to return" }
133
+ ],
134
+ "responses": {
135
+ "200": {
136
+ "description": "List of ${schemaName} documents",
137
+ "content": {
138
+ "application/json": {
139
+ "schema": { "type": "array", "items": { "$ref": "#/components/schemas/${schemaName}" } }
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
24
146
  }
25
147
  ]
26
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-api",
3
- "version": "2.2.0",
3
+ "version": "3.1.0",
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",
@@ -9,8 +9,8 @@ function createInstance (overrides = {}) {
9
9
  instance.schemaName = undefined
10
10
  instance.collectionName = undefined
11
11
  instance.routes = []
12
- instance.requestHandler = () => function defaultRequestHandler () {}
13
- instance.queryHandler = () => function queryHandler () {}
12
+ instance.requestHandler = function defaultRequestHandler () {}
13
+ instance.queryHandler = function queryHandler () {}
14
14
  instance.serveSchema = function serveSchema () {}
15
15
  Object.assign(instance, overrides)
16
16
  return instance