adapt-authoring-server 2.0.1 → 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.
- package/docs/server-requests.md +66 -5
- package/index.js +1 -1
- package/lib/utils/loadRouteConfig.js +91 -0
- package/lib/utils/registerRoutes.js +20 -0
- package/lib/utils.js +2 -0
- package/package.json +2 -1
- package/schema/routeitem.schema.json +44 -0
- package/schema/routes.schema.json +16 -0
- package/tests/data/routes.json +19 -0
- package/tests/utils-loadRouteConfig.spec.js +478 -0
package/docs/server-requests.md
CHANGED
|
@@ -1,8 +1,69 @@
|
|
|
1
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.
|
|
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
|
+
## Defining routes with `routes.json`
|
|
5
|
+
|
|
6
|
+
For modules that expose HTTP endpoints, the preferred approach is to declare routes in a `routes.json` file in the module root. This keeps route definitions, permissions, and API metadata in one place.
|
|
7
|
+
|
|
8
|
+
```json
|
|
9
|
+
{
|
|
10
|
+
"root": "mymodule",
|
|
11
|
+
"routes": [
|
|
12
|
+
{
|
|
13
|
+
"route": "/action",
|
|
14
|
+
"handlers": { "post": "actionHandler" },
|
|
15
|
+
"permissions": { "post": ["write:myresource"] },
|
|
16
|
+
"meta": {
|
|
17
|
+
"post": {
|
|
18
|
+
"summary": "Perform an action",
|
|
19
|
+
"requestBody": {
|
|
20
|
+
"content": {
|
|
21
|
+
"application/json": {
|
|
22
|
+
"schema": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"name": { "type": "string" }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"responses": { "204": {} }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then in your module code, use `loadRouteConfig` and `registerRoutes` to load and wire up the routes:
|
|
40
|
+
|
|
41
|
+
```javascript
|
|
42
|
+
import { loadRouteConfig, registerRoutes } from 'adapt-authoring-server'
|
|
43
|
+
|
|
44
|
+
async init () {
|
|
45
|
+
const [auth, server] = await this.app.waitForModule('auth', 'server')
|
|
46
|
+
const config = await loadRouteConfig(this.rootDir, this)
|
|
47
|
+
const router = server.api.createChildRouter(config.root)
|
|
48
|
+
registerRoutes(router, config.routes, auth)
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Route properties
|
|
53
|
+
|
|
54
|
+
| Property | Required | Description |
|
|
55
|
+
| -------- | -------- | ----------- |
|
|
56
|
+
| `route` | Yes | Express-style route path (e.g. `"/reset/:id"`) |
|
|
57
|
+
| `handlers` | Yes | Object mapping HTTP methods to handler method names on the module |
|
|
58
|
+
| `permissions` | No | Object mapping HTTP methods to scope arrays (secured) or `null` (unsecured) |
|
|
59
|
+
| `meta` | No | Object mapping HTTP methods to OpenAPI operation metadata |
|
|
60
|
+
| `internal` | No | When `true`, restricts the route to localhost |
|
|
61
|
+
| `override` | No | When `true` and defaults are in use, merges this route onto the matching default route instead of adding a duplicate |
|
|
62
|
+
|
|
63
|
+
Handler strings are resolved to bound methods on the module instance automatically.
|
|
3
64
|
|
|
4
65
|
## 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.
|
|
66
|
+
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
67
|
|
|
7
68
|
## Use HTTP status codes
|
|
8
69
|
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.
|
|
@@ -18,12 +79,12 @@ Using the helper function is as simple as:
|
|
|
18
79
|
async myHandler(req, res, next) {
|
|
19
80
|
try {
|
|
20
81
|
// do some stuff
|
|
21
|
-
} catch() {
|
|
82
|
+
} catch (e) {
|
|
22
83
|
res.sendError(e);
|
|
23
84
|
}
|
|
24
85
|
}
|
|
25
86
|
```
|
|
26
87
|
|
|
27
88
|
See the Express.js documentation for information on the extra functions available:
|
|
28
|
-
- [Request](https://expressjs.com/en/
|
|
29
|
-
- [Response](https://expressjs.com/en/
|
|
89
|
+
- [Request](https://expressjs.com/en/5x/api.html#req)
|
|
90
|
+
- [Response](https://expressjs.com/en/5x/api.html#res)
|
package/index.js
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
* HTTP server functionality using Express.js
|
|
3
3
|
* @namespace server
|
|
4
4
|
*/
|
|
5
|
-
export { addExistenceProps, cacheRouteConfig, generateRouterMap, getAllRoutes, mapHandler } from './lib/utils.js'
|
|
5
|
+
export { addExistenceProps, cacheRouteConfig, generateRouterMap, getAllRoutes, loadRouteConfig, mapHandler, registerRoutes } from './lib/utils.js'
|
|
6
6
|
export { default } from './lib/ServerModule.js'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { App, readJson } from 'adapt-authoring-core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves handler strings in route definitions against a target object and handler aliases.
|
|
6
|
+
* @param {Array} routes Array of route definition objects
|
|
7
|
+
* @param {Object} target The object to resolve handler strings against
|
|
8
|
+
* @param {Object} aliases Map of handler string aliases to pre-resolved functions
|
|
9
|
+
* @return {Array} Routes with handler strings replaced by bound functions
|
|
10
|
+
*/
|
|
11
|
+
function resolveHandlers (routes, target, aliases) {
|
|
12
|
+
return routes.map(routeDef => {
|
|
13
|
+
const resolved = { ...routeDef }
|
|
14
|
+
if (routeDef.handlers) {
|
|
15
|
+
resolved.handlers = Object.fromEntries(
|
|
16
|
+
Object.entries(routeDef.handlers).map(([method, handlerStr]) => {
|
|
17
|
+
if (Object.hasOwn(aliases, handlerStr)) {
|
|
18
|
+
return [method, aliases[handlerStr]]
|
|
19
|
+
}
|
|
20
|
+
if (typeof target[handlerStr] !== 'function') {
|
|
21
|
+
throw new Error(`Cannot resolve handler '${handlerStr}': no such method on target`)
|
|
22
|
+
}
|
|
23
|
+
return [method, target[handlerStr].bind(target)]
|
|
24
|
+
})
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
return resolved
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reads and processes a routes.json file from a module's root directory,
|
|
33
|
+
* validating against the app's jsonschema module and resolving handler strings against a target object.
|
|
34
|
+
* @param {String} rootDir Path to the module root (where routes.json lives)
|
|
35
|
+
* @param {Object} target The object to resolve handler strings against
|
|
36
|
+
* @param {Object} [options] Optional configuration
|
|
37
|
+
* @param {String} [options.schema] Schema name to validate against (defaults to 'routes')
|
|
38
|
+
* @param {Object} [options.handlerAliases] Map of handler string aliases to pre-resolved functions
|
|
39
|
+
* @param {String} [options.defaults] Path to a default routes template JSON file. When provided and
|
|
40
|
+
* routes.json is found, the template's routes are resolved and prepended to config.routes.
|
|
41
|
+
* Custom routes with `override: true` are merged onto the matching default (by path) instead of
|
|
42
|
+
* being appended as duplicates.
|
|
43
|
+
* @return {Promise<Object|null>} Parsed config with resolved handlers, or null if no routes.json
|
|
44
|
+
* @memberof server
|
|
45
|
+
*/
|
|
46
|
+
export async function loadRouteConfig (rootDir, target, options = {}) {
|
|
47
|
+
const filePath = path.join(rootDir, 'routes.json')
|
|
48
|
+
let config
|
|
49
|
+
try {
|
|
50
|
+
config = await readJson(filePath)
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (e.code === 'ENOENT') return null
|
|
53
|
+
throw e
|
|
54
|
+
}
|
|
55
|
+
const jsonschema = await App.instance.waitForModule('jsonschema')
|
|
56
|
+
const schema = await jsonschema.getSchema(options.schema || 'routes')
|
|
57
|
+
try {
|
|
58
|
+
schema.validate(config)
|
|
59
|
+
} catch (e) {
|
|
60
|
+
throw new Error(`Invalid routes.json at ${filePath}: ${e.data?.errors || e.message}`)
|
|
61
|
+
}
|
|
62
|
+
const aliases = options.handlerAliases || {}
|
|
63
|
+
|
|
64
|
+
// Resolve handler strings in routes.json routes
|
|
65
|
+
const customRoutes = Array.isArray(config.routes)
|
|
66
|
+
? resolveHandlers(config.routes, target, aliases)
|
|
67
|
+
: []
|
|
68
|
+
|
|
69
|
+
// Prepend default routes from template if provided
|
|
70
|
+
if (options.defaults) {
|
|
71
|
+
const template = await readJson(options.defaults)
|
|
72
|
+
const defaultRoutes = resolveHandlers(template.routes || [], target, aliases)
|
|
73
|
+
// Apply override routes onto matching defaults
|
|
74
|
+
const overrides = new Map(
|
|
75
|
+
customRoutes.filter(r => r.override).map(r => [r.route, r])
|
|
76
|
+
)
|
|
77
|
+
const matched = new Set()
|
|
78
|
+
const mergedDefaults = defaultRoutes.map(d => {
|
|
79
|
+
const o = overrides.get(d.route)
|
|
80
|
+
if (!o) return d
|
|
81
|
+
matched.add(d.route)
|
|
82
|
+
const { override, ...rest } = o
|
|
83
|
+
return { ...d, ...rest, handlers: { ...d.handlers, ...rest.handlers } }
|
|
84
|
+
})
|
|
85
|
+
const remaining = customRoutes.filter(r => !r.override || !matched.has(r.route))
|
|
86
|
+
config.routes = [...mergedDefaults, ...remaining]
|
|
87
|
+
} else {
|
|
88
|
+
config.routes = customRoutes
|
|
89
|
+
}
|
|
90
|
+
return config
|
|
91
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers routes on a router and configures their permissions with auth.
|
|
3
|
+
* @param {Router} router The router to add routes to
|
|
4
|
+
* @param {Array} routes Array of route definition objects
|
|
5
|
+
* @param {Object} auth The auth module instance
|
|
6
|
+
* @memberof server
|
|
7
|
+
*/
|
|
8
|
+
export function registerRoutes (router, routes, auth) {
|
|
9
|
+
for (const r of routes) {
|
|
10
|
+
router.addRoute(r)
|
|
11
|
+
if (!r.permissions) continue
|
|
12
|
+
for (const [method, perms] of Object.entries(r.permissions)) {
|
|
13
|
+
if (perms) {
|
|
14
|
+
auth.secureRoute(`${router.path}${r.route}`, method, perms)
|
|
15
|
+
} else {
|
|
16
|
+
auth.unsecureRoute(`${router.path}${r.route}`, method)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/lib/utils.js
CHANGED
|
@@ -2,4 +2,6 @@ export { addExistenceProps } from './utils/addExistenceProps.js'
|
|
|
2
2
|
export { cacheRouteConfig } from './utils/cacheRouteConfig.js'
|
|
3
3
|
export { generateRouterMap } from './utils/generateRouterMap.js'
|
|
4
4
|
export { getAllRoutes } from './utils/getAllRoutes.js'
|
|
5
|
+
export { loadRouteConfig } from './utils/loadRouteConfig.js'
|
|
5
6
|
export { mapHandler } from './utils/mapHandler.js'
|
|
7
|
+
export { registerRoutes } from './utils/registerRoutes.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-server",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Provides an Express application routing and more",
|
|
5
5
|
"homepage": "https://github.com/adapt-security/adapt-authoring-server",
|
|
6
6
|
"license": "GPL-3.0",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@semantic-release/git": "^10.0.1",
|
|
21
|
+
"adapt-schemas": "^1.1.0",
|
|
21
22
|
"conventional-changelog-eslint": "^6.0.0",
|
|
22
23
|
"semantic-release": "^25.0.2",
|
|
23
24
|
"standard": "^17.1.0"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$anchor": "routeitem",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"route": {
|
|
7
|
+
"type": "string",
|
|
8
|
+
"description": "Express-style route path"
|
|
9
|
+
},
|
|
10
|
+
"handlers": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"description": "Keys are HTTP methods, values are handler name strings",
|
|
13
|
+
"propertyNames": { "enum": ["get", "post", "put", "patch", "delete"] },
|
|
14
|
+
"additionalProperties": { "type": "string" }
|
|
15
|
+
},
|
|
16
|
+
"internal": {
|
|
17
|
+
"type": "boolean",
|
|
18
|
+
"description": "Restrict route to localhost",
|
|
19
|
+
"default": false
|
|
20
|
+
},
|
|
21
|
+
"permissions": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"description": "Keys are HTTP methods, values are permission scope arrays or null for unsecured",
|
|
24
|
+
"propertyNames": { "enum": ["get", "post", "put", "patch", "delete"] },
|
|
25
|
+
"additionalProperties": {
|
|
26
|
+
"oneOf": [
|
|
27
|
+
{ "type": "array", "items": { "type": "string" } },
|
|
28
|
+
{ "type": "null" }
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"meta": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"description": "Keys are HTTP methods, values are OpenAPI operation objects",
|
|
35
|
+
"propertyNames": { "enum": ["get", "post", "put", "patch", "delete"] }
|
|
36
|
+
},
|
|
37
|
+
"override": {
|
|
38
|
+
"type": "boolean",
|
|
39
|
+
"description": "When true and a defaults template is in use, merges this route's properties onto the matching default route (by path) instead of adding a duplicate",
|
|
40
|
+
"default": false
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"required": ["route", "handlers"]
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$anchor": "routes",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"root": {
|
|
7
|
+
"type": "string",
|
|
8
|
+
"description": "Router root path"
|
|
9
|
+
},
|
|
10
|
+
"routes": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"description": "Route definitions"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"required": ["root"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"root": "content",
|
|
3
|
+
"routes": [
|
|
4
|
+
{
|
|
5
|
+
"route": "/insertrecursive",
|
|
6
|
+
"handlers": { "post": "insertRecursive" },
|
|
7
|
+
"internal": false,
|
|
8
|
+
"meta": {
|
|
9
|
+
"post": {
|
|
10
|
+
"summary": "Insert hierarchical content data"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"route": "/list",
|
|
16
|
+
"handlers": { "get": "listItems" }
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { writeFile, mkdir, rm } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { Schemas } from 'adapt-schemas'
|
|
7
|
+
import { App } from 'adapt-authoring-core'
|
|
8
|
+
import { loadRouteConfig } from '../lib/utils.js'
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
const SCHEMA_DIR = path.resolve(__dirname, '../schema')
|
|
12
|
+
const dataDir = path.join(__dirname, 'data')
|
|
13
|
+
const tmpDir = path.join(__dirname, 'tmp')
|
|
14
|
+
|
|
15
|
+
/** Shared schema registry backing the jsonschema module mock */
|
|
16
|
+
let schemas
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Writes a JSON file and returns its path.
|
|
20
|
+
* @param {String} filePath Absolute path to write the JSON file
|
|
21
|
+
* @param {Object} data Data to serialize as JSON
|
|
22
|
+
* @return {Promise<String>} The file path
|
|
23
|
+
* @ignore
|
|
24
|
+
*/
|
|
25
|
+
async function writeJson (filePath, data) {
|
|
26
|
+
await writeFile(filePath, JSON.stringify(data))
|
|
27
|
+
return filePath
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('loadRouteConfig()', () => {
|
|
31
|
+
before(async () => {
|
|
32
|
+
await mkdir(tmpDir, { recursive: true })
|
|
33
|
+
|
|
34
|
+
// Build shared schema registry with the server's base schemas, mirroring how
|
|
35
|
+
// adapt-authoring-jsonschema auto-discovers schema/ files from all dependencies at startup
|
|
36
|
+
schemas = new Schemas()
|
|
37
|
+
await schemas.init()
|
|
38
|
+
await schemas.registerSchema(path.join(SCHEMA_DIR, 'routes.schema.json'))
|
|
39
|
+
await schemas.registerSchema(path.join(SCHEMA_DIR, 'routeitem.schema.json'))
|
|
40
|
+
|
|
41
|
+
// Mock App.instance.waitForModule so loadRouteConfig can resolve 'jsonschema'
|
|
42
|
+
// without a running app instance
|
|
43
|
+
App.instance.waitForModule = async (modName) => {
|
|
44
|
+
if (modName === 'jsonschema') {
|
|
45
|
+
return { getSchema: (name) => schemas.getSchema(name) }
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`Module '${modName}' not available in test environment`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// App.init() runs in the background and fails in test context (no real modules),
|
|
51
|
+
// setting process.exitCode = 1. Wait for it to settle then reset exitCode.
|
|
52
|
+
await App.instance.onReady().catch(() => {})
|
|
53
|
+
process.exitCode = 0
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
after(async () => {
|
|
57
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should return null when routes.json does not exist', async () => {
|
|
61
|
+
const result = await loadRouteConfig(path.join(__dirname, 'nonexistent'), {})
|
|
62
|
+
assert.equal(result, null)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should read and return config from routes.json', async () => {
|
|
66
|
+
const target = {
|
|
67
|
+
insertRecursive: () => {},
|
|
68
|
+
listItems: () => {}
|
|
69
|
+
}
|
|
70
|
+
const config = await loadRouteConfig(dataDir, target)
|
|
71
|
+
|
|
72
|
+
assert.ok(config !== null)
|
|
73
|
+
assert.equal(config.root, 'content')
|
|
74
|
+
assert.ok(Array.isArray(config.routes))
|
|
75
|
+
assert.equal(config.routes.length, 2)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should resolve handler strings to bound functions', async () => {
|
|
79
|
+
let called = false
|
|
80
|
+
const target = {
|
|
81
|
+
insertRecursive () { called = true },
|
|
82
|
+
listItems: () => {}
|
|
83
|
+
}
|
|
84
|
+
const config = await loadRouteConfig(dataDir, target)
|
|
85
|
+
const handler = config.routes[0].handlers.post
|
|
86
|
+
|
|
87
|
+
assert.equal(typeof handler, 'function')
|
|
88
|
+
handler()
|
|
89
|
+
assert.ok(called)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should preserve non-handler fields on route definitions', async () => {
|
|
93
|
+
const target = {
|
|
94
|
+
insertRecursive: () => {},
|
|
95
|
+
listItems: () => {}
|
|
96
|
+
}
|
|
97
|
+
const config = await loadRouteConfig(dataDir, target)
|
|
98
|
+
const route = config.routes[0]
|
|
99
|
+
|
|
100
|
+
assert.equal(route.route, '/insertrecursive')
|
|
101
|
+
assert.equal(route.internal, false)
|
|
102
|
+
assert.ok(route.meta)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should use handlerAliases when provided', async () => {
|
|
106
|
+
const aliasHandler = () => 'alias'
|
|
107
|
+
const target = { listItems: () => {} }
|
|
108
|
+
const config = await loadRouteConfig(dataDir, target, { handlerAliases: { insertRecursive: aliasHandler } })
|
|
109
|
+
|
|
110
|
+
assert.equal(config.routes[0].handlers.post, aliasHandler)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should throw a clear error for unresolvable handler strings', async () => {
|
|
114
|
+
const target = { listItems: () => {} } // missing insertRecursive
|
|
115
|
+
await assert.rejects(
|
|
116
|
+
() => loadRouteConfig(dataDir, target),
|
|
117
|
+
/Cannot resolve handler 'insertRecursive'/
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should throw when routes.json fails schema validation (missing required root)', async () => {
|
|
122
|
+
const dir = path.join(tmpDir, 'no-root')
|
|
123
|
+
await mkdir(dir, { recursive: true })
|
|
124
|
+
await writeJson(path.join(dir, 'routes.json'), { routes: [] })
|
|
125
|
+
await assert.rejects(
|
|
126
|
+
() => loadRouteConfig(dir, {}),
|
|
127
|
+
/Invalid routes\.json.*must have required property 'root'/s
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should throw when routes.json fails schema validation (wrong type for root)', async () => {
|
|
132
|
+
const dir = path.join(tmpDir, 'wrong-root-type')
|
|
133
|
+
await mkdir(dir, { recursive: true })
|
|
134
|
+
await writeJson(path.join(dir, 'routes.json'), { root: 42, routes: [] })
|
|
135
|
+
await assert.rejects(
|
|
136
|
+
() => loadRouteConfig(dir, {}),
|
|
137
|
+
/Invalid routes\.json.*must be string/s
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('should preserve consumer-specific top-level fields after validation', async () => {
|
|
142
|
+
// Consumer schema mirrors how apiroutes/authroutes extend the base via $merge
|
|
143
|
+
const schemaFile = await writeJson(path.join(tmpDir, 'withschema.schema.json'), {
|
|
144
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
145
|
+
$anchor: 'withschema',
|
|
146
|
+
$merge: {
|
|
147
|
+
source: { $ref: 'routes' },
|
|
148
|
+
with: {
|
|
149
|
+
properties: { schemaName: { type: 'string' } },
|
|
150
|
+
required: ['schemaName']
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
await schemas.registerSchema(schemaFile)
|
|
155
|
+
try {
|
|
156
|
+
const dir = path.join(tmpDir, 'consumer-fields')
|
|
157
|
+
await mkdir(dir, { recursive: true })
|
|
158
|
+
await writeJson(path.join(dir, 'routes.json'), { root: 'content', schemaName: 'content', routes: [] })
|
|
159
|
+
const config = await loadRouteConfig(dir, {}, { schema: 'withschema' })
|
|
160
|
+
assert.equal(config.schemaName, 'content')
|
|
161
|
+
} finally {
|
|
162
|
+
schemas.deregisterSchema('withschema')
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('should validate route items via consumer schema using $merge', async () => {
|
|
167
|
+
// Note: real consumer schemas use items.$ref: 'routeitem' which is resolved by the
|
|
168
|
+
// jsonschema module at startup. In tests we inline the items constraint because AJV
|
|
169
|
+
// throws anchor conflicts when $ref targets an already-registered schema.
|
|
170
|
+
const schemaFile = await writeJson(path.join(tmpDir, 'strict-routes.schema.json'), {
|
|
171
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
172
|
+
$anchor: 'strict-routes',
|
|
173
|
+
$merge: {
|
|
174
|
+
source: { $ref: 'routes' },
|
|
175
|
+
with: {
|
|
176
|
+
properties: {
|
|
177
|
+
routes: {
|
|
178
|
+
type: 'array',
|
|
179
|
+
items: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
route: { type: 'string' },
|
|
183
|
+
handlers: { type: 'object' }
|
|
184
|
+
},
|
|
185
|
+
required: ['route', 'handlers']
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
await schemas.registerSchema(schemaFile)
|
|
193
|
+
try {
|
|
194
|
+
const dir = path.join(tmpDir, 'missing-route-field')
|
|
195
|
+
await mkdir(dir, { recursive: true })
|
|
196
|
+
await writeJson(path.join(dir, 'routes.json'), {
|
|
197
|
+
root: 'test',
|
|
198
|
+
routes: [{ handlers: { get: 'myHandler' } }]
|
|
199
|
+
})
|
|
200
|
+
await assert.rejects(
|
|
201
|
+
() => loadRouteConfig(dir, {}, { schema: 'strict-routes' }),
|
|
202
|
+
/Invalid routes\.json.*must have required property 'route'/s
|
|
203
|
+
)
|
|
204
|
+
} finally {
|
|
205
|
+
schemas.deregisterSchema('strict-routes')
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('should use a consumer-provided schema for top-level validation', async () => {
|
|
210
|
+
const schemaFile = await writeJson(path.join(tmpDir, 'custom.schema.json'), {
|
|
211
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
212
|
+
$anchor: 'custom',
|
|
213
|
+
$merge: {
|
|
214
|
+
source: { $ref: 'routes' },
|
|
215
|
+
with: {
|
|
216
|
+
properties: { schemaName: { type: 'string' } },
|
|
217
|
+
required: ['schemaName']
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
await schemas.registerSchema(schemaFile)
|
|
222
|
+
try {
|
|
223
|
+
const dir = path.join(tmpDir, 'missing-schemaname')
|
|
224
|
+
await mkdir(dir, { recursive: true })
|
|
225
|
+
await writeJson(path.join(dir, 'routes.json'), { root: 'test', routes: [] })
|
|
226
|
+
await assert.rejects(
|
|
227
|
+
() => loadRouteConfig(dir, {}, { schema: 'custom' }),
|
|
228
|
+
/Invalid routes\.json.*must have required property 'schemaName'/s
|
|
229
|
+
)
|
|
230
|
+
} finally {
|
|
231
|
+
schemas.deregisterSchema('custom')
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
describe('permissions field in route items', () => {
|
|
236
|
+
// Note: real consumer schemas use items.$ref: 'routeitem' which includes the permissions
|
|
237
|
+
// property. In tests we inline the constraint because AJV throws anchor conflicts when
|
|
238
|
+
// $ref targets an already-registered schema. The permissions definition here mirrors
|
|
239
|
+
// routeitem.schema.json to ensure the same validation behaviour.
|
|
240
|
+
const permSchema = {
|
|
241
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
242
|
+
$anchor: 'perm-routes',
|
|
243
|
+
$merge: {
|
|
244
|
+
source: { $ref: 'routes' },
|
|
245
|
+
with: {
|
|
246
|
+
properties: {
|
|
247
|
+
routes: {
|
|
248
|
+
type: 'array',
|
|
249
|
+
items: {
|
|
250
|
+
type: 'object',
|
|
251
|
+
properties: {
|
|
252
|
+
route: { type: 'string' },
|
|
253
|
+
handlers: { type: 'object' },
|
|
254
|
+
permissions: {
|
|
255
|
+
type: 'object',
|
|
256
|
+
propertyNames: { enum: ['get', 'post', 'put', 'patch', 'delete'] },
|
|
257
|
+
additionalProperties: {
|
|
258
|
+
oneOf: [
|
|
259
|
+
{ type: 'array', items: { type: 'string' } },
|
|
260
|
+
{ type: 'null' }
|
|
261
|
+
]
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
required: ['route', 'handlers']
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
before(async () => {
|
|
274
|
+
await schemas.registerSchema(await writeJson(path.join(tmpDir, 'perm-routes.schema.json'), permSchema))
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
after(() => schemas.deregisterSchema('perm-routes'))
|
|
278
|
+
|
|
279
|
+
it('should accept null permission values (unsecured routes)', async () => {
|
|
280
|
+
const dir = path.join(tmpDir, 'perms-null')
|
|
281
|
+
await mkdir(dir, { recursive: true })
|
|
282
|
+
await writeJson(path.join(dir, 'routes.json'), {
|
|
283
|
+
root: 'test',
|
|
284
|
+
routes: [{ route: '/test', handlers: { post: 'myHandler' }, permissions: { post: null } }]
|
|
285
|
+
})
|
|
286
|
+
const config = await loadRouteConfig(dir, { myHandler: () => {} }, { schema: 'perm-routes' })
|
|
287
|
+
assert.equal(config.routes[0].permissions.post, null)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('should reject invalid HTTP method keys in permissions', async () => {
|
|
291
|
+
const dir = path.join(tmpDir, 'perms-invalid-key')
|
|
292
|
+
await mkdir(dir, { recursive: true })
|
|
293
|
+
await writeJson(path.join(dir, 'routes.json'), {
|
|
294
|
+
root: 'test',
|
|
295
|
+
routes: [{ route: '/test', handlers: { post: 'myHandler' }, permissions: { invalidMethod: null } }]
|
|
296
|
+
})
|
|
297
|
+
await assert.rejects(
|
|
298
|
+
() => loadRouteConfig(dir, { myHandler: () => {} }, { schema: 'perm-routes' }),
|
|
299
|
+
/Invalid routes\.json.*property name must be valid/s
|
|
300
|
+
)
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
describe('defaults option', () => {
|
|
305
|
+
it('should prepend default routes from template when defaults path is provided', async () => {
|
|
306
|
+
const dir = path.join(tmpDir, 'with-defaults')
|
|
307
|
+
await mkdir(dir, { recursive: true })
|
|
308
|
+
await writeJson(path.join(dir, 'routes.json'), {
|
|
309
|
+
root: 'test',
|
|
310
|
+
routes: [{ route: '/custom', handlers: { get: 'listItems' } }]
|
|
311
|
+
})
|
|
312
|
+
const defaultsPath = path.join(tmpDir, 'defaults.json')
|
|
313
|
+
await writeJson(defaultsPath, {
|
|
314
|
+
routes: [{ route: '/', handlers: { post: 'insertRecursive' } }]
|
|
315
|
+
})
|
|
316
|
+
const target = {
|
|
317
|
+
insertRecursive: () => {},
|
|
318
|
+
listItems: () => {}
|
|
319
|
+
}
|
|
320
|
+
const config = await loadRouteConfig(dir, target, { defaults: defaultsPath })
|
|
321
|
+
assert.equal(config.routes.length, 2)
|
|
322
|
+
assert.equal(config.routes[0].route, '/')
|
|
323
|
+
assert.equal(config.routes[1].route, '/custom')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('should resolve handler strings in default routes using handlerAliases', async () => {
|
|
327
|
+
const dir = path.join(tmpDir, 'defaults-aliases')
|
|
328
|
+
await mkdir(dir, { recursive: true })
|
|
329
|
+
await writeJson(path.join(dir, 'routes.json'), { root: 'test', routes: [] })
|
|
330
|
+
const defaultsPath = path.join(tmpDir, 'defaults-aliases.json')
|
|
331
|
+
await writeJson(defaultsPath, {
|
|
332
|
+
routes: [{ route: '/', handlers: { post: 'myAlias' } }]
|
|
333
|
+
})
|
|
334
|
+
let called = false
|
|
335
|
+
const aliasHandler = () => { called = true }
|
|
336
|
+
const config = await loadRouteConfig(dir, {}, {
|
|
337
|
+
defaults: defaultsPath,
|
|
338
|
+
handlerAliases: { myAlias: aliasHandler }
|
|
339
|
+
})
|
|
340
|
+
assert.equal(typeof config.routes[0].handlers.post, 'function')
|
|
341
|
+
config.routes[0].handlers.post()
|
|
342
|
+
assert.ok(called)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('should resolve handler strings in default routes against target methods', async () => {
|
|
346
|
+
const dir = path.join(tmpDir, 'defaults-target')
|
|
347
|
+
await mkdir(dir, { recursive: true })
|
|
348
|
+
await writeJson(path.join(dir, 'routes.json'), { root: 'test', routes: [] })
|
|
349
|
+
const defaultsPath = path.join(tmpDir, 'defaults-target.json')
|
|
350
|
+
await writeJson(defaultsPath, {
|
|
351
|
+
routes: [{ route: '/', handlers: { get: 'myMethod' } }]
|
|
352
|
+
})
|
|
353
|
+
let called = false
|
|
354
|
+
const target = { myMethod () { called = true } }
|
|
355
|
+
const config = await loadRouteConfig(dir, target, { defaults: defaultsPath })
|
|
356
|
+
config.routes[0].handlers.get()
|
|
357
|
+
assert.ok(called)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('should not load defaults when routes.json does not exist', async () => {
|
|
361
|
+
const defaultsPath = path.join(tmpDir, 'unused-defaults.json')
|
|
362
|
+
await writeJson(defaultsPath, {
|
|
363
|
+
routes: [{ route: '/', handlers: { get: 'foo' } }]
|
|
364
|
+
})
|
|
365
|
+
const result = await loadRouteConfig(path.join(__dirname, 'nonexistent'), {}, { defaults: defaultsPath })
|
|
366
|
+
assert.equal(result, null)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('should preserve non-handler fields on default route definitions', async () => {
|
|
370
|
+
const dir = path.join(tmpDir, 'defaults-fields')
|
|
371
|
+
await mkdir(dir, { recursive: true })
|
|
372
|
+
await writeJson(path.join(dir, 'routes.json'), { root: 'test', routes: [] })
|
|
373
|
+
const defaultsPath = path.join(tmpDir, 'defaults-fields.json')
|
|
374
|
+
await writeJson(defaultsPath, {
|
|
375
|
+
routes: [{
|
|
376
|
+
route: '/',
|
|
377
|
+
handlers: { post: 'myHandler' },
|
|
378
|
+
permissions: { post: null },
|
|
379
|
+
meta: { post: { summary: 'Test' } }
|
|
380
|
+
}]
|
|
381
|
+
})
|
|
382
|
+
const config = await loadRouteConfig(dir, { myHandler: () => {} }, { defaults: defaultsPath })
|
|
383
|
+
assert.equal(config.routes[0].permissions.post, null)
|
|
384
|
+
assert.equal(config.routes[0].meta.post.summary, 'Test')
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('should merge override route properties onto matching default route', async () => {
|
|
388
|
+
const dir = path.join(tmpDir, 'override-merge')
|
|
389
|
+
await mkdir(dir, { recursive: true })
|
|
390
|
+
await writeJson(path.join(dir, 'routes.json'), {
|
|
391
|
+
root: 'test',
|
|
392
|
+
routes: [{
|
|
393
|
+
route: '/',
|
|
394
|
+
override: true,
|
|
395
|
+
handlers: { post: 'myHandler' },
|
|
396
|
+
meta: { post: { summary: 'Overridden' } }
|
|
397
|
+
}]
|
|
398
|
+
})
|
|
399
|
+
const defaultsPath = path.join(tmpDir, 'override-defaults.json')
|
|
400
|
+
await writeJson(defaultsPath, {
|
|
401
|
+
routes: [{ route: '/', handlers: { post: 'myHandler' }, permissions: { post: null } }]
|
|
402
|
+
})
|
|
403
|
+
const config = await loadRouteConfig(dir, { myHandler: () => {} }, { defaults: defaultsPath })
|
|
404
|
+
assert.equal(config.routes.length, 1)
|
|
405
|
+
assert.equal(config.routes[0].route, '/')
|
|
406
|
+
assert.equal(config.routes[0].meta.post.summary, 'Overridden')
|
|
407
|
+
assert.equal(config.routes[0].permissions.post, null)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('should keep default handler when override does not replace it', async () => {
|
|
411
|
+
const dir = path.join(tmpDir, 'override-keep-handler')
|
|
412
|
+
await mkdir(dir, { recursive: true })
|
|
413
|
+
await writeJson(path.join(dir, 'routes.json'), {
|
|
414
|
+
root: 'test',
|
|
415
|
+
routes: [{
|
|
416
|
+
route: '/',
|
|
417
|
+
override: true,
|
|
418
|
+
handlers: { post: 'myHandler' },
|
|
419
|
+
meta: { post: { summary: 'Added meta' } }
|
|
420
|
+
}]
|
|
421
|
+
})
|
|
422
|
+
const defaultsPath = path.join(tmpDir, 'override-keep-handler-defaults.json')
|
|
423
|
+
await writeJson(defaultsPath, {
|
|
424
|
+
routes: [{ route: '/', handlers: { post: 'defaultHandler' } }]
|
|
425
|
+
})
|
|
426
|
+
const target = {
|
|
427
|
+
myHandler: () => {},
|
|
428
|
+
defaultHandler () {}
|
|
429
|
+
}
|
|
430
|
+
const config = await loadRouteConfig(dir, target, { defaults: defaultsPath })
|
|
431
|
+
// override handler takes precedence
|
|
432
|
+
assert.equal(typeof config.routes[0].handlers.post, 'function')
|
|
433
|
+
assert.equal(config.routes[0].meta.post.summary, 'Added meta')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('should not remove override route from results when no matching default exists', async () => {
|
|
437
|
+
const dir = path.join(tmpDir, 'override-no-match')
|
|
438
|
+
await mkdir(dir, { recursive: true })
|
|
439
|
+
await writeJson(path.join(dir, 'routes.json'), {
|
|
440
|
+
root: 'test',
|
|
441
|
+
routes: [{
|
|
442
|
+
route: '/nomatch',
|
|
443
|
+
override: true,
|
|
444
|
+
handlers: { get: 'myHandler' },
|
|
445
|
+
meta: { get: { summary: 'Orphan' } }
|
|
446
|
+
}]
|
|
447
|
+
})
|
|
448
|
+
const defaultsPath = path.join(tmpDir, 'override-no-match-defaults.json')
|
|
449
|
+
await writeJson(defaultsPath, {
|
|
450
|
+
routes: [{ route: '/', handlers: { post: 'myHandler' } }]
|
|
451
|
+
})
|
|
452
|
+
const config = await loadRouteConfig(dir, { myHandler: () => {} }, { defaults: defaultsPath })
|
|
453
|
+
assert.equal(config.routes.length, 2)
|
|
454
|
+
assert.equal(config.routes[0].route, '/')
|
|
455
|
+
assert.equal(config.routes[1].route, '/nomatch')
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it('should strip the override flag from merged route', async () => {
|
|
459
|
+
const dir = path.join(tmpDir, 'override-strip-flag')
|
|
460
|
+
await mkdir(dir, { recursive: true })
|
|
461
|
+
await writeJson(path.join(dir, 'routes.json'), {
|
|
462
|
+
root: 'test',
|
|
463
|
+
routes: [{
|
|
464
|
+
route: '/',
|
|
465
|
+
override: true,
|
|
466
|
+
handlers: { post: 'myHandler' },
|
|
467
|
+
meta: { post: { summary: 'Test' } }
|
|
468
|
+
}]
|
|
469
|
+
})
|
|
470
|
+
const defaultsPath = path.join(tmpDir, 'override-strip-defaults.json')
|
|
471
|
+
await writeJson(defaultsPath, {
|
|
472
|
+
routes: [{ route: '/', handlers: { post: 'myHandler' } }]
|
|
473
|
+
})
|
|
474
|
+
const config = await loadRouteConfig(dir, { myHandler: () => {} }, { defaults: defaultsPath })
|
|
475
|
+
assert.equal(config.routes[0].override, undefined)
|
|
476
|
+
})
|
|
477
|
+
})
|
|
478
|
+
})
|