diff --git a/api/api.yaml b/api/api.yaml index 6e25698..44756ae 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -48,7 +48,7 @@ tags: - name: /material - name: /condition - name: /measurement - - name: /templates + - name: /template - name: /model - name: /user diff --git a/api/material.yaml b/api/material.yaml index 8e3a039..a3b80da 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -70,7 +70,7 @@ 500: $ref: 'api.yaml#/components/responses/500' delete: - summary: TODO delete material + summary: delete material description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /material @@ -90,7 +90,7 @@ /material/new: post: - summary: TODO add material + summary: add material description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /material diff --git a/api/parameters.yaml b/api/parameters.yaml index f370c13..ba8d046 100644 --- a/api/parameters.yaml +++ b/api/parameters.yaml @@ -7,6 +7,7 @@ Id: example: 5ea0450ed851c30a90e70894 Name: name: name + description: has to be URL encoded in: path required: true schema: diff --git a/api/template.yaml b/api/template.yaml index 9219d81..5b362fb 100644 --- a/api/template.yaml +++ b/api/template.yaml @@ -1,9 +1,9 @@ /template/treatments: get: - summary: TODO all available treatment methods + summary: all available treatment methods description: 'Auth: basic, levels: read, write, maintain, dev, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: @@ -16,23 +16,26 @@ items: $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: heat aging parameters: - name: method range: - - copper + values: + - copper + - hot air 401: $ref: 'api.yaml#/components/responses/401' 500: $ref: 'api.yaml#/components/responses/500' -/templates/treatment/{name}: +/template/treatment/{name}: parameters: - $ref: 'api.yaml#/components/parameters/Name' get: - summary: TODO treatment method details + summary: treatment method details description: 'Auth: basic, levels: read, write, maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: @@ -44,13 +47,14 @@ allOf: - $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: heat aging parameters: - name: method range: - - copper - 400: - $ref: 'api.yaml#/components/responses/400' + values: + - copper + - hot air 401: $ref: 'api.yaml#/components/responses/401' 404: @@ -58,10 +62,10 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO add/change treatment method + summary: add/change treatment method description: 'Auth: basic, levels: maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] requestBody: @@ -76,7 +80,9 @@ parameters: - name: method range: - - copper + values: + - copper + - hot air responses: 200: description: treatment details @@ -86,11 +92,14 @@ allOf: - $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: heat aging parameters: - name: method range: - - copper + values: + - copper + - hot air 400: $ref: 'api.yaml#/components/responses/400' 401: @@ -102,10 +111,10 @@ 500: $ref: 'api.yaml#/components/responses/500' delete: - summary: TODO delete treatment method + summary: delete treatment method description: 'Auth: basic, levels: maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: @@ -123,10 +132,10 @@ $ref: 'api.yaml#/components/responses/500' /template/measurements: get: - summary: TODO all available measurement methods + summary: all available measurement methods description: 'Auth: basic, levels: read, write, maintain, dev, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: @@ -139,6 +148,7 @@ items: $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: humidity parameters: - name: kf @@ -149,14 +159,14 @@ $ref: 'api.yaml#/components/responses/401' 500: $ref: 'api.yaml#/components/responses/500' -/templates/measurement/{name}: +/template/measurement/{name}: parameters: - $ref: 'api.yaml#/components/parameters/Name' get: - summary: TODO measurement method details + summary: measurement method details description: 'Auth: basic, levels: read, write, maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: @@ -168,6 +178,7 @@ allOf: - $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: humidity parameters: - name: kf @@ -183,10 +194,10 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO add/change measurement method + summary: add/change measurement method description: 'Auth: basic, levels: maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] requestBody: @@ -197,6 +208,7 @@ allOf: - $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: humidity parameters: - name: kf @@ -212,6 +224,7 @@ allOf: - $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: humidity parameters: - name: kf @@ -229,10 +242,10 @@ 500: $ref: 'api.yaml#/components/responses/500' delete: - summary: TODO delete measurement method + summary: delete measurement method description: 'Auth: basic, levels: maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: diff --git a/package.json b/package.json index 4753647..d3f9e63 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "tsc": "tsc", "test": "mocha dist/**/**.spec.js", - "start": "tsc && node dist/index.js", + "start": "tsc && node dist/index.js || exit 1", "dev": "nodemon -e ts,yaml --exec \"npm run start\"" }, "keywords": [], diff --git a/src/db.ts b/src/db.ts index e624696..090e275 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,6 +1,8 @@ import mongoose from 'mongoose'; import cfenv from 'cfenv'; +// mongoose.set('debug', true); // enable mongoose debug + // database urls, prod db url is retrieved automatically const TESTING_URL = 'mongodb://localhost/dfopdb_test'; const DEV_URL = 'mongodb://localhost/dfopdb'; diff --git a/src/index.ts b/src/index.ts index f79a554..15bd504 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ app.use(require('./helpers/authorize')); // handle authentication app.use('/', require('./routes/root')); app.use('/', require('./routes/user')); app.use('/', require('./routes/material')); +app.use('/', require('./routes/template')); // static files app.use('/static', express.static('static')); diff --git a/src/models/measurement_template.ts b/src/models/measurement_template.ts new file mode 100644 index 0000000..c55cbc7 --- /dev/null +++ b/src/models/measurement_template.ts @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const MeasurementTemplateSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}}, + parameters: [{ + name: String, + range: mongoose.Schema.Types.Mixed + }] +}, {minimize: false}); // to allow empty objects + +export default mongoose.model('measurement_template', MeasurementTemplateSchema); \ No newline at end of file diff --git a/src/models/treatment_template.ts b/src/models/treatment_template.ts new file mode 100644 index 0000000..3b61164 --- /dev/null +++ b/src/models/treatment_template.ts @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const TreatmentTemplateSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}}, + parameters: [{ + name: String, + range: mongoose.Schema.Types.Mixed + }] +}, {minimize: false}); // to allow empty objects + +export default mongoose.model('treatment_template', TreatmentTemplateSchema); \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index e98da67..c69538a 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -130,7 +130,7 @@ describe('/material', () => { res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]} }); }); - it('returns keeps unchanged properties', done => { + it('keeps unchanged properties', done => { TestHelper.request(server, done, { method: 'put', url: '/material/100000000000000000000001', @@ -148,11 +148,12 @@ describe('/material', () => { httpStatus: 200, req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}]} , - }).end(err => { + }).end((err, res) => { if (err) return done(err); + should(res.body).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}]}); MaterialModel.findById('100000000000000000000001').lean().exec((err, data:any) => { if (err) return done(err); - data._id = data._id.toString(); + data._id = data._id.toString({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}]}); data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}], __v: 0} ); diff --git a/src/routes/material.ts b/src/routes/material.ts index f193b9f..c44afa7 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -57,7 +57,7 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { } function f() { // to resolve async - MaterialModel.findByIdAndUpdate(req.params.id, material).lean().exec((err, data) => { + MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).lean().exec((err, data) => { if (err) next(err); if (data) { res.json(MaterialValidate.output(data)); diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts new file mode 100644 index 0000000..5ee4d1a --- /dev/null +++ b/src/routes/template.spec.ts @@ -0,0 +1,579 @@ +import should from 'should/as-function'; +import TemplateTreatmentModel from '../models/treatment_template'; +import TemplateMeasurementModel from '../models/measurement_template'; +import TestHelper from "../helpers/test"; + + +describe('/template', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + + describe('/template/treatment', () => { + describe('GET /template/treatments', () => { + it('returns all treatment templates', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatments', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.treatment_templates.length); + should(res.body).matchEach(treatment => { + should(treatment).have.only.keys('_id', 'name', 'parameters'); + should(treatment).have.property('_id').be.type('string'); + should(treatment).have.property('name').be.type('string'); + should(treatment.parameters).matchEach(number => { + should(number).have.only.keys('name', 'range'); + should(number).have.property('name').be.type('string'); + should(number).have.property('range').be.type('object'); + }); + }); + done(); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatments', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatments', + httpStatus: 401 + }); + }); + }); + + describe('GET /template/treatment/{name}', () => { + it('returns the right treatment template', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatment/heat%20treatment', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects an unknown name', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatment/xxx', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatment/heat%20treatment', + httpStatus: 401 + }); + }); + }); + + describe('PUT /template/treatment/{name}', () => { + it('returns the right treatment template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]}, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '200000000000000000000001', name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}); + TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'parameters'); + should(data[0]).have.property('name', 'heat aging'); + should(data[0]).have.property('parameters').have.lengthOf(1); + should(data[0].parameters[0]).have.property('name', 'time'); + should(data[0].parameters[0]).have.property('range'); + should(data[0].parameters[0].range).have.property('min', 1); + done(); + }); + }); + }); + it('supports values ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'time', range: {values: [1, 2, 5]}}]}, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'time', range: {values: [1, 2, 5]}}]} + }); + }); + it('supports min max ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'time', range: {min: 1, max: 11}}]}, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'time', range: {min: 1, max: 11}}]} + }); + }); + it('supports empty ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'time', range: {}}]}, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'time', range: {}}]} + }); + }); + it('adds a new template for an unknown name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }).end(err => { + if (err) return done(err); + TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { + if (err) return done(err); + console.log(data); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); + should(data[0]).have.property('name', 'heat aging'); + should(data[0].parameters[0]).have.property('name', 'time'); + should(data[0].parameters[0]).have.property('range'); + should(data[0].parameters[0].range).have.property('min', 1); + done(); + }); + }); + }); + it('rejects an incomplete template for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'time'}]}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects already existing names', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat treatment 2', parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Template name already taken'} + }); + }); + it('rejects wrong properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'time'}], xx: 33}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('DELETE /template/treatment/{name}', () => { + it('deletes the template', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + TemplateTreatmentModel.find({name: 'heat treatment'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(0); + done(); + }); + }); + }); + it('rejects deleting a template still in use'); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/treatment/heat%20treatment', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }) + }); + it('returns 404 for an unknown name', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/treatment/xxx', + auth: {basic: 'admin'}, + httpStatus: 404 + }) + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/treatment/heat%20treatment', + httpStatus: 401 + }) + }); + }); + }); + + describe('/template/measurement', () => { + describe('GET /template/measurements', () => { + it('returns all measurement templates', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurements', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.measurement_templates.length); + should(res.body).matchEach(measurement => { + should(measurement).have.only.keys('_id', 'name', 'parameters'); + should(measurement).have.property('_id').be.type('string'); + should(measurement).have.property('name').be.type('string'); + should(measurement.parameters).matchEach(number => { + should(number).have.only.keys('name', 'range'); + should(number).have.property('name').be.type('string'); + should(number).have.property('range').be.type('object'); + }); + }); + done(); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurements', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurements', + httpStatus: 401 + }); + }); + }); + + describe('GET /template/measurement/{name}', () => { + it('returns the right measurement template', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/spectrum', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {}}]} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/spectrum', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects an unknown name', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/xxx', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/spectrum', + httpStatus: 401 + }); + }); + }); + + describe('PUT /template/measurement/{name}', () => { + it('returns the right measurement template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {}}]} + }); + }); + it('keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'spectrum', parameters: [{name: 'dpt', range: {}}]}, + res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {}}]} + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '300000000000000000000001', name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}); + TemplateMeasurementModel.find({name: 'IR spectrum'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'parameters'); + should(data[0]).have.property('name', 'IR spectrum'); + should(data[0]).have.property('parameters').have.lengthOf(1); + should(data[0].parameters[0]).have.property('name', 'data point table'); + should(data[0].parameters[0]).have.property('range'); + should(data[0].parameters[0].range).have.property('min', 0); + should(data[0].parameters[0].range).have.property('max', 1000); + done(); + }); + }); + }); + it('supports values ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]}, + res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]} + }); + }); + it('supports min max ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]}, + res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]} + }); + }); + it('supports empty ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/kf', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'weight %', range: {}}]}, + res: {_id: '300000000000000000000002', name: 'kf', parameters: [{name: 'weight %', range: {}}]} + }); + }); + it('adds a new template for an unknown name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/vz', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }).end(err => { + if (err) return done(err); + TemplateMeasurementModel.find({name: 'vz'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); + should(data[0]).have.property('name', 'vz'); + should(data[0]).have.property('parameters').have.lengthOf(1); + should(data[0].parameters[0]).have.property('name', 'vz'); + should(data[0].parameters[0]).have.property('range'); + should(data[0].parameters[0].range).have.property('min', 1); + done(); + }); + }); + }); + it('rejects an incomplete template for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/vz', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'vz'}]}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects already existing names', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'kf', parameters: [{name: 'dpt', range: {min: 1}}]}, + res: {status: 'Template name already taken'} + }); + }); + it('rejects wrong properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'dpt'}], xx: 33}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('DELETE /template/measurement/{name}', () => { + it('deletes the template', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + TemplateMeasurementModel.find({name: 'spectrum'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(0); + done(); + }); + }); + }); + it('rejects deleting a template still in use'); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/measurement/spectrum', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/measurement/spectrum', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }) + }); + it('returns 404 for an unknown name', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/measurement/xxx', + auth: {basic: 'admin'}, + httpStatus: 404 + }) + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/measurement/spectrum', + httpStatus: 401 + }) + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/template.ts b/src/routes/template.ts new file mode 100644 index 0000000..7e4aee7 --- /dev/null +++ b/src/routes/template.ts @@ -0,0 +1,91 @@ +import express from 'express'; + +import TemplateValidate from './validate/template'; +import TemplateTreatmentModel from '../models/treatment_template'; +import TemplateMeasurementModel from '../models/measurement_template'; + + +const router = express.Router(); + +router.get('/template/:collection(measurements|treatments)', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + + (req.params.collection === 'treatments' ? TemplateTreatmentModel : TemplateMeasurementModel) + .find({}).lean().exec((err, data) => { + if (err) next (err); + res.json(data.map(e => TemplateValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors + }); +}); + +router.get('/template/:collection(measurement|treatment)/:name', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + + (req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel) + .findOne({name: req.params.name}).lean().exec((err, data) => { + if (err) next (err); + if (data) { + res.json(TemplateValidate.output(data)); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + +router.put('/template/:collection(measurement|treatment)/:name', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + const collectionModel = req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel; + + collectionModel.findOne({name: req.params.name}).lean().exec((err, data) => { + if (err) next (err); + const templateState = data? 'change': 'new'; + const {error, value: template} = TemplateValidate.input(req.body, templateState); + if(error !== undefined) { + res.status(400).json({status: 'Invalid body format'}); + return; + } + + if (template.hasOwnProperty('name') && template.name !== req.params.name) { + collectionModel.find({name: template.name}).lean().exec((err, data) => { + if (err) next (err); + if (data.length > 0) { + res.status(400).json({status: 'Template name already taken'}); + return; + } + else { + f(); + } + }); + } + else { + f(); + } + + function f() { // to resolve async + collectionModel.findOneAndUpdate({name: req.params.name}, template, {new: true, upsert: true}).lean().exec((err, data) => { + if (err) next(err); + res.json(TemplateValidate.output(data)); + }); + } + }); +}); + +router.delete('/template/:collection(measurement|treatment)/:name', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + (req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel) + .findOneAndDelete({name: req.params.name}).lean().exec((err, data) => { + if (err) next(err); + if (data) { + res.json({status: 'OK'}) + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + + + +module.exports = router; \ No newline at end of file diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts new file mode 100644 index 0000000..6a0a23f --- /dev/null +++ b/src/routes/validate/template.ts @@ -0,0 +1,59 @@ +import joi from '@hapi/joi'; +import IdValidate from './id'; + +export default class TemplateValidate { + private static template = { + name: joi.string() + .max(128), + + parameters: joi.array() + .min(1) + .items( + joi.object({ + name: joi.string() + .max(128) + .required(), + + range: joi.object({ + values: joi.array() + .min(1), + + min: joi.number(), + + max: joi.number() + }) + .oxor('values', 'min') + .oxor('values', 'max') + .required() + }) + ) + }; + + static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) + if (param === 'new') { + return joi.object({ + name: this.template.name.required(), + parameters: this.template.parameters.required() + }).validate(data); + } + else if (param === 'change') { + return joi.object({ + name: this.template.name, + parameters: this.template.parameters + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { // validate output from database for needed properties, strip everything else + data._id = data._id.toString(); + const {value, error} = joi.object({ + _id: IdValidate.get(), + name: this.template.name, + parameters: this.template.parameters + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/test/db.json b/src/test/db.json index 0b4fd2f..d1bca35 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -84,6 +84,72 @@ ], "__v": 0 } + ], + "treatment_templates": [ + { + "_id": {"$oid":"200000000000000000000001"}, + "name": "heat treatment", + "parameters": [ + { + "name": "material", + "range": { + "values": [ + "copper", + "hot air" + ] + } + }, + { + "name": "weeks", + "range": { + "min": 1, + "max": 10 + } + } + ] + }, + { + "_id": {"$oid":"200000000000000000000002"}, + "name": "heat treatment 2", + "parameters": [ + { + "name": "material", + "range": {} + } + ] + } + ], + "measurement_templates": [ + { + "_id": {"$oid":"300000000000000000000001"}, + "name": "spectrum", + "parameters": [ + { + "name": "dpt", + "range": {} + } + ] + }, + { + "_id": {"$oid":"300000000000000000000002"}, + "name": "kf", + "parameters": [ + { + "name": "weight %", + "range": { + "min": 0, + "max": 1.5 + } + }, + { + "name": "standard deviation", + "range": { + "min": 0, + "max": 0.5 + } + } + ] + } ] } } \ No newline at end of file diff --git a/static/styles/swagger.css b/static/styles/swagger.css index ac69d38..33bebe1 100644 --- a/static/styles/swagger.css +++ b/static/styles/swagger.css @@ -144,6 +144,13 @@ body:after { border-color: var(--red); } +/*download button*/ +.swagger-ui .download-contents { + border-radius: 0; + height: 28px; + width: 80px; +} + /*model*/ .swagger-ui .model-box { border-radius: 0; @@ -153,6 +160,22 @@ body:after { .swagger-ui .btn.execute { background-color: var(--dark-blue); border-color: var(--dark-blue); + height: 30px; + line-height: 0.7; +} + +.swagger-ui .btn-group .btn:last-child { + border-radius: 0; + height: 30px; + border-color: var(--dark-blue); +} + +.swagger-ui .btn-group .btn:first-child { + border-radius: 0; +} + +.swagger-ui .btn-group { + padding: 0 20px; } /*parameter input*/ @@ -160,6 +183,14 @@ body:after { border-radius: 0; } +/*required label*/ +.swagger-ui .parameter__name.required > span { + color: var(--red) !important; +} + +.swagger-ui .parameter__name.required::after { + color: var(--red); +} /*Remove colored parameters bar*/ .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after { background: none;