Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions default-routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,34 @@
}
}
}
},
{
"route": "/validate",
"modifying": false,
"handlers": { "post": "validateHandler" },
"permissions": { "post": ["write:${scope}"] },
"meta": {
"post": {
"summary": "Validate a ${schemaName} document without persisting it",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/${schemaName}" }
}
}
},
"responses": {
"200": {
"description": "The validated ${schemaName} document",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/${schemaName}" }
}
}
}
}
}
}
}
]
}
2 changes: 2 additions & 0 deletions docs/writing-an-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ This creates the following endpoints:
| DELETE | `/api/notes/:_id` | Delete a note |
| GET | `/api/notes/schema` | Get the JSON schema |
| POST | `/api/notes/query` | Advanced query with pagination |
| POST | `/api/notes/validate` | Validate a note without persisting it |

## Module configuration

Expand Down Expand Up @@ -141,6 +142,7 @@ When `super.setValues()` is called, this file is loaded and merged with the defa
| PATCH | `/:_id` | `requestHandler` | `write:${scope}` |
| DELETE | `/:_id` | `requestHandler` | `write:${scope}` |
| POST | `/query` | `queryHandler` | `read:${scope}` |
| POST | `/validate` | `validateHandler` | `write:${scope}` |

The `${scope}` placeholder is replaced with `permissionsScope` (if set) or `root`.

Expand Down
16 changes: 16 additions & 0 deletions lib/AbstractApiModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,22 @@ class AbstractApiModule extends AbstractModule {
}
}

/**
* Express request handler which validates incoming data against the relevant schema without persisting it. Responds with the validated data, or forwards any validation error.
* @param {external:ExpressRequest} req
* @param {external:ExpressResponse} res
* @param {Function} next
* @return {function}
*/
async validateHandler (req, res, next) {
try {
const data = await this.validate(req.apiData.schemaName, req.apiData.data, {})
res.status(this.mapStatusCode('get')).json(data)
} catch (e) {
return next(e)
}
}

/**
* Parses an incoming query for use in the DB module
* @param {String} schemaName The schema name for the data being queried
Expand Down
54 changes: 53 additions & 1 deletion tests/AbstractApiModule.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,13 @@ describe('AbstractApiModule', () => {
assert.ok(defaultRoutes.routes.length > 0)
})

it('should include routes for /, /schema, /:_id, and /query', () => {
it('should include routes for /, /schema, /:_id, /query, and /validate', () => {
const routePaths = defaultRoutes.routes.map(r => r.route)
assert.ok(routePaths.includes('/'))
assert.ok(routePaths.includes('/schema'))
assert.ok(routePaths.includes('/:_id'))
assert.ok(routePaths.includes('/query'))
assert.ok(routePaths.includes('/validate'))
})

it('should use permissionsScope when set', () => {
Expand All @@ -229,6 +230,57 @@ describe('AbstractApiModule', () => {
assert.equal(queryRoute.validate, false)
assert.equal(queryRoute.modifying, false)
})

it('should set modifying: false and gate /validate on the write scope', () => {
const instance = createInstance({ root: 'content' })
instance.applyRouteConfig({ root: 'content', routes: defaultRoutes.routes })
const validateRoute = instance.routes.find(r => r.route === '/validate')
assert.equal(validateRoute.modifying, false)
assert.ok(validateRoute.permissions.post.includes('write:content'))
})
})

describe('#validateHandler()', () => {
function createValidateInstance (validate) {
const status = []
const json = []
const res = {
status (code) { status.push(code); return res },
json (data) { json.push(data); return res }
}
const instance = createInstance({
validate,
mapStatusCode: AbstractApiModule.prototype.mapStatusCode
})
return { instance, res, status, json }
}

it('should respond 200 with the validated data', async () => {
const validated = { _id: '1', title: 'valid' }
const calls = []
const { instance, res, status, json } = createValidateInstance(async (...args) => {
calls.push(args)
return validated
})
const req = { apiData: { schemaName: 'course', data: { title: 'valid' } } }
let nextErr = 'unset'
await instance.validateHandler(req, res, e => { nextErr = e })
assert.deepEqual(calls, [['course', { title: 'valid' }, {}]])
assert.deepEqual(status, [200])
assert.deepEqual(json, [validated])
assert.equal(nextErr, 'unset')
})

it('should forward a validation error to next and not respond', async () => {
const error = new Error('invalid')
const { instance, res, status, json } = createValidateInstance(async () => { throw error })
const req = { apiData: { schemaName: 'course', data: {} } }
let nextErr
await instance.validateHandler(req, res, e => { nextErr = e })
assert.equal(nextErr, error)
assert.equal(status.length, 0)
assert.equal(json.length, 0)
})
})

describe('#setUpPagination()', () => {
Expand Down
Loading