adapt-authoring-api 2.1.3 → 2.2.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.
@@ -2,6 +2,7 @@ import _ from 'lodash'
2
2
  import { AbstractModule, Hook, stringifyValues } from 'adapt-authoring-core'
3
3
  import { argsFromReq, generateApiMetadata, httpMethodToDBFunction } from './utils.js'
4
4
  import DataCache from './DataCache.js'
5
+ import { loadRouteConfig } from 'adapt-authoring-server'
5
6
  /**
6
7
  * Abstract module for creating APIs
7
8
  * @memberof api
@@ -149,6 +150,17 @@ class AbstractApiModule extends AbstractModule {
149
150
  * @type {String}
150
151
  */
151
152
  this.schemaName = undefined
153
+
154
+ const config = await loadRouteConfig(this.rootDir, this, {
155
+ schema: 'apiroutes',
156
+ handlerAliases: {
157
+ default: this.requestHandler(),
158
+ query: this.queryHandler(),
159
+ serveSchema: this.serveSchema.bind(this)
160
+ },
161
+ defaults: new URL('./default-routes.json', import.meta.url).pathname
162
+ })
163
+ if (config) this.applyRouteConfig(config)
152
164
  }
153
165
 
154
166
  /**
@@ -189,6 +201,7 @@ class AbstractApiModule extends AbstractModule {
189
201
 
190
202
  /**
191
203
  * Checks required values have been set
204
+ * @deprecated Validation is handled by the routes schema for modules using routes.json
192
205
  */
193
206
  validateValues () {
194
207
  if (!this.root && !this.router) {
@@ -226,6 +239,30 @@ class AbstractApiModule extends AbstractModule {
226
239
  Object.values(uniqueRoutes).forEach(r => this.addRoute(r, auth))
227
240
  }
228
241
 
242
+ /**
243
+ * Applies route configuration loaded from routes.json.
244
+ * Expands `${scope}` permission placeholders with `this.permissionsScope || this.root`.
245
+ * @param {Object} config The route config object returned by loadRouteConfig
246
+ */
247
+ applyRouteConfig (config) {
248
+ /** @ignore */ this.root = config.root
249
+ if (config.schemaName !== undefined) this.schemaName = config.schemaName
250
+ 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
+ })
264
+ }
265
+
229
266
  /**
230
267
  * Adds a single route definition
231
268
  * @param {Route} config The route config
@@ -0,0 +1,26 @@
1
+ {
2
+ "routes": [
3
+ {
4
+ "route": "/",
5
+ "handlers": { "post": "default", "get": "default" },
6
+ "permissions": { "post": ["write:${scope}"], "get": ["read:${scope}"] }
7
+ },
8
+ {
9
+ "route": "/schema",
10
+ "handlers": { "get": "serveSchema" },
11
+ "permissions": { "get": ["read:schema"] }
12
+ },
13
+ {
14
+ "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}"] }
17
+ },
18
+ {
19
+ "route": "/query",
20
+ "validate": false,
21
+ "modifying": false,
22
+ "handlers": { "post": "query" },
23
+ "permissions": { "post": ["read:${scope}"] }
24
+ }
25
+ ]
26
+ }
@@ -2,6 +2,7 @@
2
2
  * Generates REST API metadata and stores on route config
3
3
  * @param {AbstractApiModule} instance The current AbstractApiModule instance
4
4
  * @memberof api
5
+ * @deprecated For modules with routes.json, define metadata in the meta field of each route entry instead
5
6
  */
6
7
  export function generateApiMetadata (instance) {
7
8
  const getData = isList => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-api",
3
- "version": "2.1.3",
3
+ "version": "2.2.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",
@@ -15,13 +15,14 @@
15
15
  "lodash": "^4.17.21"
16
16
  },
17
17
  "peerDependencies": {
18
- "adapt-authoring-auth": "^1.0.6",
18
+ "adapt-authoring-auth": "^2.0.0",
19
19
  "adapt-authoring-jsonschema": "^1.2.0",
20
20
  "adapt-authoring-mongodb": "^3.0.0",
21
- "adapt-authoring-server": "^1.2.1"
21
+ "adapt-authoring-server": "^2.1.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@semantic-release/git": "^10.0.1",
25
+ "adapt-authoring-server": "^2.1.0",
25
26
  "conventional-changelog-eslint": "^6.0.0",
26
27
  "semantic-release": "^25.0.2",
27
28
  "standard": "^17.1.0"
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$anchor": "apiroutes",
4
+ "$merge": {
5
+ "source": { "$ref": "routes" },
6
+ "with": {
7
+ "properties": {
8
+ "schemaName": {
9
+ "type": "string",
10
+ "description": "Schema name for the module's data model"
11
+ },
12
+ "collectionName": {
13
+ "type": "string",
14
+ "description": "MongoDB collection name"
15
+ },
16
+ "useDefaultRoutes": {
17
+ "type": "boolean",
18
+ "description": "Whether to generate default CRUD routes",
19
+ "default": true
20
+ },
21
+ "routes": {
22
+ "type": "array",
23
+ "items": { "$ref": "routeitem" }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
@@ -2,6 +2,20 @@ import { describe, it } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import AbstractApiModule from '../lib/AbstractApiModule.js'
4
4
 
5
+ function createInstance (overrides = {}) {
6
+ const instance = Object.create(AbstractApiModule.prototype)
7
+ instance.root = 'test'
8
+ instance.permissionsScope = undefined
9
+ instance.schemaName = undefined
10
+ instance.collectionName = undefined
11
+ instance.routes = []
12
+ instance.requestHandler = () => function defaultRequestHandler () {}
13
+ instance.queryHandler = () => function queryHandler () {}
14
+ instance.serveSchema = function serveSchema () {}
15
+ Object.assign(instance, overrides)
16
+ return instance
17
+ }
18
+
5
19
  describe('AbstractApiModule', () => {
6
20
  describe('#mapStatusCode()', () => {
7
21
  const instance = Object.create(AbstractApiModule.prototype)
@@ -56,4 +70,112 @@ describe('AbstractApiModule', () => {
56
70
  assert.equal(options, undefined)
57
71
  })
58
72
  })
73
+
74
+ describe('#applyRouteConfig()', () => {
75
+ it('should set root, schemaName, and collectionName from config', async () => {
76
+ const instance = createInstance()
77
+ await instance.applyRouteConfig({
78
+ root: 'content',
79
+ schemaName: 'content',
80
+ collectionName: 'content',
81
+ useDefaultRoutes: false,
82
+ routes: []
83
+ })
84
+ assert.equal(instance.root, 'content')
85
+ assert.equal(instance.schemaName, 'content')
86
+ assert.equal(instance.collectionName, 'content')
87
+ })
88
+
89
+ it('should not override schemaName or collectionName when not in config', async () => {
90
+ const instance = createInstance({ schemaName: 'existing', collectionName: 'existing' })
91
+ await instance.applyRouteConfig({ root: 'content', useDefaultRoutes: false, routes: [] })
92
+ assert.equal(instance.schemaName, 'existing')
93
+ assert.equal(instance.collectionName, 'existing')
94
+ })
95
+
96
+ it('should set routes to custom routes only when useDefaultRoutes is false', async () => {
97
+ const instance = createInstance()
98
+ const customRoute = { route: '/custom', handlers: { get: () => {} } }
99
+ await instance.applyRouteConfig({ root: 'test', useDefaultRoutes: false, routes: [customRoute] })
100
+ assert.equal(instance.routes.length, 1)
101
+ assert.equal(instance.routes[0].route, '/custom')
102
+ })
103
+
104
+ it('should set routes to empty array when config has no routes', async () => {
105
+ const instance = createInstance()
106
+ await instance.applyRouteConfig({ root: 'test', routes: [] })
107
+ assert.deepEqual(instance.routes, [])
108
+ })
109
+
110
+ it('should expand ${scope} placeholders in permissions using root', async () => { // eslint-disable-line no-template-curly-in-string
111
+ const instance = createInstance({ root: 'content' })
112
+ await instance.applyRouteConfig({
113
+ root: 'content',
114
+ routes: [{ route: '/', permissions: { get: ['read:${scope}'], post: ['write:${scope}'] } }] // eslint-disable-line no-template-curly-in-string
115
+ })
116
+ assert.deepEqual(instance.routes[0].permissions.get, ['read:content'])
117
+ assert.deepEqual(instance.routes[0].permissions.post, ['write:content'])
118
+ })
119
+
120
+ it('should prefer permissionsScope over root for ${scope} expansion', async () => { // eslint-disable-line no-template-curly-in-string
121
+ const instance = createInstance({ root: 'content', permissionsScope: 'custom' })
122
+ await instance.applyRouteConfig({
123
+ root: 'content',
124
+ routes: [{ route: '/', permissions: { get: ['read:${scope}'] } }] // eslint-disable-line no-template-curly-in-string
125
+ })
126
+ assert.deepEqual(instance.routes[0].permissions.get, ['read:custom'])
127
+ })
128
+
129
+ it('should pass through null permissions unchanged', async () => {
130
+ const instance = createInstance({ root: 'content' })
131
+ await instance.applyRouteConfig({
132
+ root: 'content',
133
+ routes: [{ route: '/', permissions: { get: null } }]
134
+ })
135
+ assert.equal(instance.routes[0].permissions.get, null)
136
+ })
137
+ })
138
+
139
+ describe('#DEFAULT_ROUTES', () => {
140
+ it('should return an array of route objects', async () => {
141
+ const instance = createInstance()
142
+ const routes = await instance.DEFAULT_ROUTES
143
+ assert.ok(Array.isArray(routes))
144
+ assert.ok(routes.length > 0)
145
+ })
146
+
147
+ it('should include routes for /, /schema, /:_id, and /query', async () => {
148
+ const instance = createInstance()
149
+ const routes = await instance.DEFAULT_ROUTES
150
+ const routePaths = routes.map(r => r.route)
151
+ assert.ok(routePaths.includes('/'))
152
+ assert.ok(routePaths.includes('/schema'))
153
+ assert.ok(routePaths.includes('/:_id'))
154
+ assert.ok(routePaths.includes('/query'))
155
+ })
156
+
157
+ it('should use permissionsScope when set', async () => {
158
+ const instance = createInstance({ root: 'content', permissionsScope: 'custom' })
159
+ const routes = await instance.DEFAULT_ROUTES
160
+ const rootRoute = routes.find(r => r.route === '/')
161
+ assert.ok(rootRoute.permissions.post.includes('write:custom'))
162
+ assert.ok(rootRoute.permissions.get.includes('read:custom'))
163
+ })
164
+
165
+ it('should fall back to root for permissions when permissionsScope is not set', async () => {
166
+ const instance = createInstance({ root: 'content', permissionsScope: undefined })
167
+ const routes = await instance.DEFAULT_ROUTES
168
+ const rootRoute = routes.find(r => r.route === '/')
169
+ assert.ok(rootRoute.permissions.post.includes('write:content'))
170
+ assert.ok(rootRoute.permissions.get.includes('read:content'))
171
+ })
172
+
173
+ it('should set validate: false and modifying: false on /query route', async () => {
174
+ const instance = createInstance()
175
+ const routes = await instance.DEFAULT_ROUTES
176
+ const queryRoute = routes.find(r => r.route === '/query')
177
+ assert.equal(queryRoute.validate, false)
178
+ assert.equal(queryRoute.modifying, false)
179
+ })
180
+ })
59
181
  })