From 95ae72a6d2b8f1876f5fd77ac3a69cce508d6222 Mon Sep 17 00:00:00 2001 From: Thomas Taylor Date: Tue, 30 Jun 2026 15:05:02 +0100 Subject: [PATCH] New: Add POST /validate endpoint to AbstractApiModule (fixes #110) --- default-routes.json | 28 +++++++++++++++++ docs/writing-an-api.md | 2 ++ lib/AbstractApiModule.js | 16 ++++++++++ tests/AbstractApiModule.spec.js | 54 ++++++++++++++++++++++++++++++++- 4 files changed, 99 insertions(+), 1 deletion(-) diff --git a/default-routes.json b/default-routes.json index 05594cab..4e9fc6e7 100644 --- a/default-routes.json +++ b/default-routes.json @@ -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}" } + } + } + } + } + } + } } ] } diff --git a/docs/writing-an-api.md b/docs/writing-an-api.md index fe1bfaa4..93b8f27f 100644 --- a/docs/writing-an-api.md +++ b/docs/writing-an-api.md @@ -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 @@ -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`. diff --git a/lib/AbstractApiModule.js b/lib/AbstractApiModule.js index cd33a842..b8f149bb 100644 --- a/lib/AbstractApiModule.js +++ b/lib/AbstractApiModule.js @@ -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 diff --git a/tests/AbstractApiModule.spec.js b/tests/AbstractApiModule.spec.js index 70a1db9f..9a45b991 100644 --- a/tests/AbstractApiModule.spec.js +++ b/tests/AbstractApiModule.spec.js @@ -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', () => { @@ -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()', () => {