diff --git a/src/framework/ajv/index.ts b/src/framework/ajv/index.ts index 067467c4..5dda5ffa 100644 --- a/src/framework/ajv/index.ts +++ b/src/framework/ajv/index.ts @@ -117,19 +117,26 @@ function createAjv( compile: (sch, p, it) => { if (sch) { const validate: DataValidateFunction = (data, ctx) => { - const isValid = data == null; - if (!isValid) { - validate.errors = [ - { - keyword: 'readOnly', - instancePath: ctx.instancePath, - schemaPath: it.schemaPath.str, - message: `is read-only`, - params: { writeOnly: ctx.parentDataProperty }, - }, - ]; + if (options.removeAdditional == true || options.removeAdditional == "all" || options.removeAdditional == "failing") { + // Remove readonly properties in request + delete ctx.parentData[ctx.parentDataProperty]; + return true; + } + else { + const isValid = data == null; + if (!isValid) { + validate.errors = [ + { + keyword: 'readOnly', + instancePath: ctx.instancePath, + schemaPath: it.schemaPath.str, + message: `is read-only`, + params: { writeOnly: ctx.parentDataProperty }, + }, + ]; + } + return false; } - return false; }; return validate; } @@ -178,19 +185,26 @@ function createAjv( compile: (sch, p, it) => { if (sch) { const validate: DataValidateFunction = (data, ctx) => { - const isValid = data == null; - if (!isValid) { - validate.errors = [ - { - keyword: 'writeOnly', - instancePath: ctx.instancePath, - schemaPath: it.schemaPath.str, - message: `is write-only`, - params: { writeOnly: ctx.parentDataProperty }, - }, - ]; + if (options.removeAdditional == true || options.removeAdditional == "all" || options.removeAdditional == "failing") { + // Remove readonly properties in request + delete ctx.parentData[ctx.parentDataProperty]; + return true; + } + else { + const isValid = data == null; + if (!isValid) { + validate.errors = [ + { + keyword: 'writeOnly', + instancePath: ctx.instancePath, + schemaPath: it.schemaPath.str, + message: `is write-only`, + params: {writeOnly: ctx.parentDataProperty}, + }, + ]; + } + return false; } - return false; }; return validate; } diff --git a/test/read.only.removeadditional.spec.ts b/test/read.only.removeadditional.spec.ts new file mode 100644 index 00000000..2f065c04 --- /dev/null +++ b/test/read.only.removeadditional.spec.ts @@ -0,0 +1,214 @@ +import * as path from 'path'; +import { expect } from 'chai'; +import * as request from 'supertest'; +import { createApp } from './common/app'; +import * as packageJson from '../package.json'; + +describe(packageJson.name, () => { + let app = null; + + before(async () => { + // Set up the express app + const apiSpec = path.join('test', 'resources', 'read.only.yaml'); + app = await createApp({ apiSpec, validateRequests: {removeAdditional:true}, validateResponses: true }, 3005, (app) => + app + .post(`${app.basePath}/products`, (req, res) => res.json(req.body)) + .get(`${app.basePath}/products`, (req, res) => + res.json([ + { + id: 'id_1', + name: 'name_1', + price: 9.99, + created_at: new Date().toISOString(), + }, + ]), + ) + .post(`${app.basePath}/products/inlined`, (req, res) => + res.json(req.body), + ) + .post(`${app.basePath}/user`, (req, res) => + res.json({ + ...req.body, + ...(req.query.include_id ? { id: 'test_id' } : {}), + }), + ) + .post(`${app.basePath}/user_inlined`, (req, res) => + res.json({ + ...req.body, + ...(req.query.include_id ? { id: 'test_id' } : {}), + }), + ) + .post(`${app.basePath}/products/nested`, (req, res) => { + const body = req.body; + body.id = 'test'; + body.created_at = new Date().toISOString(); + body.reviews = body.reviews.map((r) => ({ + id: 99, + rating: r.rating ?? 2, + })); + res.json(body); + }) + .post(`${app.basePath}/readonly_required_allof`, (req, res) => { + const json = { + name: 'My Name', + ...(req.query.include_id ? { id: 'test_id' } : {}), + }; + res.json(json); + }), + ); + }); + + after(() => { + app.server.close(); + }); + + it('should remove read only properties in requests thanks to removeAdditional', async () => + request(app) + .post(`${app.basePath}/products`) + .set('content-type', 'application/json') + .send({ + id: 'id_1', + name: 'some name', + price: 10.99, + created_at: new Date().toISOString(), + }) + .expect(200) + .then((r) => { + const body = r.body; + // id is a readonly property and should not be allowed in the request + // but, as removeAdditional is true for requests, it should be deleted before entering in the route + expect(body.id).to.be.undefined; + })); + + + it('should allow read only properties in responses', async () => + request(app) + .get(`${app.basePath}/products`) + .expect(200) + .then((r) => { + expect(r.body).to.be.an('array').with.length(1); + })); + + it('should remove read only inlined properties in requests thanks to removeAdditional', async () => + await request(app) + .post(`${app.basePath}/products/inlined`) + .set('content-type', 'application/json') + .send({ + id: 'id_1', + name: 'some name', + price: 10.99, + created_at: new Date().toISOString(), + }) + .expect(200) + .then((r) => { + const body = r.body; + // id is a readonly property and should not not be allowed in the request + // but, as removeAdditional is true for requests, it should be deleted before entering in the route + expect(body.id).to.be.undefined; + })); + + + it('should remove read only properties in requests (nested and deep nested schema $refs) thanks to removeAdditional', async () => + request(app) + .post(`${app.basePath}/products/nested`) + .set('content-type', 'application/json') + .send({ + id: 'id_1', + name: 'some name', + price: 10.99, + created_at: new Date().toISOString(), + reviews: [{ + id: 10, + rating: 5, + }], + }) + .expect(200) + .then((r) => { + const body = r.body; + // id is a readonly property and should not not be allowed in the request + // but, as removeAdditional is true for requests, it should be deleted before entering in the route + expect(body.id).to.be.equal('test'); + expect(body.reviews[0].id).to.be.equal(99); + })); + + it('should pass validation if required read only properties to be missing from request ($ref)', async () => + request(app) + .post(`${app.basePath}/user`) + .set('content-type', 'application/json') + .query({ + include_id: true, + }) + .send({ + username: 'test', + }) + .expect(200) + .then((r) => { + expect(r.body).to.be.an('object').with.property('id'); + expect(r.body).to.have.property('username'); + })); + + it('should pass validation if required read only properties to be missing from request (inlined)', async () => + request(app) + .post(`${app.basePath}/user_inlined`) + .set('content-type', 'application/json') + .query({ + include_id: true, + }) + .send({ + username: 'test', + }) + .expect(200) + .then((r) => { + expect(r.body).to.be.an('object').with.property('id'); + expect(r.body).to.have.property('username'); + })); + + it('should pass validation if required read only properties to be missing from request (with charset)', async () => + request(app) + .post(`${app.basePath}/user_inlined`) + .set('content-type', 'application/json; charset=utf-8') + .query({ + include_id: true, + }) + .send({ + username: 'test', + }) + .expect(200) + .then((r) => { + expect(r.body).to.be.an('object').with.property('id'); + expect(r.body).to.have.property('username'); + })); + + it('should fail validation if required read only properties is missing from the response', async () => + request(app) + .post(`${app.basePath}/user`) + .set('content-type', 'application/json') + .send({ + username: 'test', + }) + .expect(500) + .then((r) => { + expect(r.body.errors[0]) + .to.have.property('message') + .equals("must have required property 'id'"); + })); + + it('should require readonly required property in response', async () => + request(app) + .post(`${app.basePath}/readonly_required_allof`) + .query({ include_id: true }) + .send({ optional: 'test' }) + .set('content-type', 'application/json') + .expect(200)); + + it('should return 500 if readonly required property is missing from response', async () => + request(app) + .post(`${app.basePath}/readonly_required_allof`) + .query({ include_id: false }) + .send({ optional: 'test' }) + .set('content-type', 'application/json') + .expect(500) + .then((r) => { + expect(r.body.message).includes("must have required property 'id'"); + })); +}); diff --git a/test/read.only.spec.ts b/test/read.only.spec.ts index 0680306a..da4ffc0e 100644 --- a/test/read.only.spec.ts +++ b/test/read.only.spec.ts @@ -95,7 +95,7 @@ describe(packageJson.name, () => { id: 'id_1', name: 'some name', price: 10.99, - created_at: new Date().toUTCString(), + created_at: new Date().toISOString(), }) .expect(400) .then((r) => { @@ -113,10 +113,10 @@ describe(packageJson.name, () => { name: 'some name', price: 10.99, created_at: new Date().toISOString(), - reviews: { + reviews: [{ id: 'review_id', rating: 5, - }, + }], }) .expect(400) .then((r) => { diff --git a/test/write.only.removeadditional.spec.ts b/test/write.only.removeadditional.spec.ts new file mode 100644 index 00000000..e50da1d5 --- /dev/null +++ b/test/write.only.removeadditional.spec.ts @@ -0,0 +1,98 @@ +import * as path from 'path'; +import { expect } from 'chai'; +import * as request from 'supertest'; +import { createApp } from './common/app'; +import * as packageJson from '../package.json'; + +describe(packageJson.name, () => { + let app = null; + + before(async () => { + // Set up the express app + const apiSpec = path.join('test', 'resources', 'write.only.yaml'); + app = await createApp({ apiSpec, validateResponses: {removeAdditional : true} }, 3005, app => + app + .post(`${app.basePath}/products/inlined`, (req, res) => { + const body = req.body; + const excludeWriteOnly = req.query.exclude_write_only; + if (excludeWriteOnly) { + delete body.role; + } + res.json(body); + }) + .post(`${app.basePath}/products/nested`, (req, res) => { + const body = req.body; + const excludeWriteOnly = req.query.exclude_write_only; + body.id = 'test'; + body.created_at = new Date().toISOString(); + body.reviews = body.reviews.map(r => ({ + ...(excludeWriteOnly ? {} : { role_x: 'admin' }), + rating: r.rating ?? 2, + })); + + if (excludeWriteOnly) { + delete body.role; + } + res.json(body); + }), + ); + }); + + after(() => { + app.server.close(); + }); + + it('should remove write only inlined properties in responses thanks to removeAdditional', async () => + request(app) + .post(`${app.basePath}/products/inlined`) + .set('content-type', 'application/json') + .send({ + name: 'some name', + role: 'admin', + price: 10.99, + }) + .expect(200) + .then(r => { + const body = r.body; + expect(body.message).to.be.undefined; + expect(body.role).to.be.undefined; + expect(body.price).to.be.equal(10.99); + })); + + it('should return 200 if no write-only properties are in the responses', async () => + request(app) + .post(`${app.basePath}/products/inlined`) + .query({ + exclude_write_only: true, + }) + .set('content-type', 'application/json') + .send({ + name: 'some name', + role: 'admin', + price: 10.99, + }) + .expect(200) + ); + + it('should remove write only properties in responses (nested schema $refs) thanks to removeAdditional', async () => + request(app) + .post(`${app.basePath}/products/nested`) + .set('content-type', 'application/json') + .send({ + name: 'some name', + price: 10.99, + reviews: [ + { + rating: 5 + }, + ], + }) + .expect(200) + .then(r => { + const body = r.body; + console.log('%j', r.body); + expect(body.reviews[0].role_x).to.be.undefined; + expect(body.reviews[0].rating).to.be.equal(5); + })); + +});