diff --git a/lib/api3/const.json b/lib/api3/const.json index 1c3dfd873ee..fd874a1175a 100644 --- a/lib/api3/const.json +++ b/lib/api3/const.json @@ -15,6 +15,7 @@ "UNAUTHORIZED": 401, "FORBIDDEN": 403, "NOT_FOUND": 404, + "NOT_ACCEPTABLE": 406, "GONE": 410, "PRECONDITION_FAILED": 412, "UNPROCESSABLE_ENTITY": 422, @@ -40,6 +41,7 @@ "HTTP_401_MISSING_OR_BAD_TOKEN": "Missing or bad access token or JWT", "HTTP_403_MISSING_PERMISSION": "Missing permission {0}", "HTTP_403_NOT_USING_HTTPS": "Not using SSL/TLS", + "HTTP_406_UNSUPPORTED_FORMAT": "Unsupported output format requested", "HTTP_422_READONLY_MODIFICATION": "Trying to modify read-only document", "HTTP_500_INTERNAL_ERROR": "Internal Server Error", "STORAGE_ERROR": "Database error", diff --git a/lib/api3/doc/formats.md b/lib/api3/doc/formats.md new file mode 100644 index 00000000000..5a01a802f3e --- /dev/null +++ b/lib/api3/doc/formats.md @@ -0,0 +1,88 @@ +# APIv3: Output formats + +### Choosing output format +In APIv3, the standard content type is JSON for both HTTP request and HTTP response. +However, in HTTP response, the response content type can be changed to XML or CSV +for READ, SEARCH, and HISTORY operations. + +The response content type can be requested in one of the following ways: +- add a file type extension to the URL, eg. + `/api/v3/entries.csv?...` + or `/api/v3/treatments/95e1a6e3-1146-5d6a-a3f1-41567cae0895.xml?...` +- set `Accept` HTTP request header to `text/csv` or `application/xml` + +The server replies with `406 Not Acceptable` HTTP status in case of not supported content type. + + +### JSON + +Default content type is JSON, output can look like this: + +``` +[ + { + "type":"sgv", + "sgv":"171", + "dateString":"2014-07-19T02:44:15.000-07:00", + "date":1405763055000, + "device":"dexcom", + "direction":"Flat", + "identifier":"5c5a2404e0196f4d3d9a718a", + "srvModified":1405763055000, + "srvCreated":1405763055000 + }, + { + "type":"sgv", + "sgv":"176", + "dateString":"2014-07-19T03:09:15.000-07:00", + "date":1405764555000, + "device":"dexcom", + "direction":"Flat", + "identifier":"5c5a2404e0196f4d3d9a7187", + "srvModified":1405764555000, + "srvCreated":1405764555000 + } +] +``` + +### XML + +Sample output: + +``` + + + + sgv + 171 + 2014-07-19T02:44:15.000-07:00 + 1405763055000 + dexcom + Flat + 5c5a2404e0196f4d3d9a718a + 1405763055000 + 1405763055000 + + + sgv + 176 + 2014-07-19T03:09:15.000-07:00 + 1405764555000 + dexcom + Flat + 5c5a2404e0196f4d3d9a7187 + 1405764555000 + 1405764555000 + + +``` + +### CSV + +Sample output: + +``` +type,sgv,dateString,date,device,direction,identifier,srvModified,srvCreated +sgv,171,2014-07-19T02:44:15.000-07:00,1405763055000,dexcom,Flat,5c5a2404e0196f4d3d9a718a,1405763055000,1405763055000 +sgv,176,2014-07-19T03:09:15.000-07:00,1405764555000,dexcom,Flat,5c5a2404e0196f4d3d9a7187,1405764555000,1405764555000 +``` diff --git a/lib/api3/generic/create/insert.js b/lib/api3/generic/create/insert.js index 4ac80a37e94..b643818569a 100644 --- a/lib/api3/generic/create/insert.js +++ b/lib/api3/generic/create/insert.js @@ -3,6 +3,7 @@ const apiConst = require('../../const.json') , security = require('../../security') , validate = require('./validate.js') + , path = require('path') ; /** @@ -33,7 +34,7 @@ async function insert (opCtx, doc) { throw new Error('empty identifier'); res.setHeader('Last-Modified', now.toUTCString()); - res.setHeader('Location', `${req.baseUrl}${req.path}/${identifier}`); + res.setHeader('Location', path.posix.join(req.baseUrl, req.path, identifier)); res.status(apiConst.HTTP.CREATED).send({ }); ctx.bus.emit('storage-socket-create', { colName: col.colName, doc }); diff --git a/lib/api3/generic/history/operation.js b/lib/api3/generic/history/operation.js index 0929a09cc4f..5151c8b2749 100644 --- a/lib/api3/generic/history/operation.js +++ b/lib/api3/generic/history/operation.js @@ -1,6 +1,7 @@ 'use strict'; const dateTools = require('../../shared/dateTools') + , renderer = require('../../shared/renderer') , apiConst = require('../../const.json') , security = require('../../security') , opTools = require('../../shared/operationTools') @@ -53,7 +54,8 @@ async function history (opCtx, fieldsProjector) { _.each(result, fieldsProjector.applyProjection); - res.status(apiConst.HTTP.OK).send(result); + res.status(apiConst.HTTP.OK); + renderer.render(res, result); } } diff --git a/lib/api3/generic/read/operation.js b/lib/api3/generic/read/operation.js index 04d6f03bc70..c2e65a4afcc 100644 --- a/lib/api3/generic/read/operation.js +++ b/lib/api3/generic/read/operation.js @@ -4,6 +4,7 @@ const apiConst = require('../../const.json') , security = require('../../security') , opTools = require('../../shared/operationTools') , dateTools = require('../../shared/dateTools') + , renderer = require('../../shared/renderer') , FieldsProjector = require('../../shared/fieldsProjector') ; @@ -48,7 +49,8 @@ async function read (opCtx) { fieldsProjector.applyProjection(doc); - res.status(apiConst.HTTP.OK).send(doc); + res.status(apiConst.HTTP.OK); + renderer.render(res, doc); } diff --git a/lib/api3/generic/search/operation.js b/lib/api3/generic/search/operation.js index 074f864d58a..c24f978dc27 100644 --- a/lib/api3/generic/search/operation.js +++ b/lib/api3/generic/search/operation.js @@ -3,6 +3,7 @@ const apiConst = require('../../const.json') , security = require('../../security') , opTools = require('../../shared/operationTools') + , renderer = require('../../shared/renderer') , input = require('./input') , _each = require('lodash/each') , FieldsProjector = require('../../shared/fieldsProjector') @@ -49,7 +50,8 @@ async function search (opCtx) { _each(result, fieldsProjector.applyProjection); - res.status(apiConst.HTTP.OK).send(result); + res.status(apiConst.HTTP.OK); + renderer.render(res, result); } } diff --git a/lib/api3/generic/update/replace.js b/lib/api3/generic/update/replace.js index ca490b31136..fdf803ed16f 100644 --- a/lib/api3/generic/update/replace.js +++ b/lib/api3/generic/update/replace.js @@ -3,6 +3,7 @@ const apiConst = require('../../const.json') , security = require('../../security') , validate = require('./validate.js') + , path = require('path') ; /** @@ -38,7 +39,7 @@ async function replace (opCtx, doc, storageDoc, options) { res.setHeader('Last-Modified', now.toUTCString()); if (storageDoc.identifier !== doc.identifier || isDeduplication) { - res.setHeader('Location', `${req.baseUrl}${req.path}/${doc.identifier}`); + res.setHeader('Location', path.posix.join(req.baseUrl, req.path, doc.identifier)); } res.status(apiConst.HTTP.NO_CONTENT).send({ }); diff --git a/lib/api3/index.js b/lib/api3/index.js index 5b1799c78fd..4bfe07a35fe 100644 --- a/lib/api3/index.js +++ b/lib/api3/index.js @@ -2,6 +2,7 @@ const express = require('express') , bodyParser = require('body-parser') + , renderer = require('./shared/renderer') , StorageSocket = require('./storageSocket') , apiConst = require('./const.json') , security = require('./security') @@ -47,6 +48,8 @@ function configure (env, ctx) { } }); + app.use(renderer.extension2accept); + // we don't need these here app.set('etag', false); app.set('x-powered-by', false); // this seems to be unreliable @@ -74,7 +77,7 @@ function configure (env, ctx) { app.get('/version', require('./specific/version')(app, ctx, env)); - if (app.get('env') === 'development' || app.get('ci')) { // for development and testing purposes only + if (app.get('env') === 'development' || app.get('ci')) { // for development and testing purposes only app.get('/test', async function test (req, res) { try { diff --git a/lib/api3/shared/renderer.js b/lib/api3/shared/renderer.js new file mode 100644 index 00000000000..a3588819a72 --- /dev/null +++ b/lib/api3/shared/renderer.js @@ -0,0 +1,99 @@ +'use strict'; + +const apiConst = require('../const.json') + , mime = require('mime') + , url = require('url') + , opTools = require('./operationTools') + , EasyXml = require('easyxml') + , csvStringify = require('csv-stringify') + ; + + +/** + * Middleware that converts url's extension to Accept HTTP request header + * @param {Object} req + * @param {Object} res + * @param {Function} next + */ +function extension2accept (req, res, next) { + + const pathSplit = req.path.split('.'); + + if (pathSplit.length < 2) + return next(); + + const pathBase = pathSplit[0] + , extension = pathSplit.slice(1).join('.'); + + if (!extension) + return next(); + + const mimeType = mime.getType(extension); + if (!mimeType) + return opTools.sendJSONStatus(res, apiConst.HTTP.NOT_ACCEPTABLE, apiConst.MSG.HTTP_406_UNSUPPORTED_FORMAT); + + req.extToAccept = { + url: req.url, + accept: req.headers.accept + }; + + req.headers.accept = mimeType; + const parsed = url.parse(req.url); + parsed.pathname = pathBase; + req.url = url.format(parsed); + + next(); +} + + +/** + * Sends data to output using the client's desired format + * @param {Object} res + * @param {any} data + */ +function render (res, data) { + res.format({ + 'json': () => res.send(data), + 'csv': () => renderCsv(res, data), + 'xml': () => renderXml(res, data), + 'default': () => + opTools.sendJSONStatus(res, apiConst.HTTP.NOT_ACCEPTABLE, apiConst.MSG.HTTP_406_UNSUPPORTED_FORMAT) + }); +} + + +/** + * Format data to output as .csv + * @param {Object} res + * @param {any} data + */ +function renderCsv (res, data) { + const csvSource = Array.isArray(data) ? data : [data]; + csvStringify(csvSource, { + header: true + }, + function csvStringified (err, output) { + res.send(output); + }); +} + + +/** + * Format data to output as .xml + * @param {Object} res + * @param {any} data + */ +function renderXml (res, data) { + const serializer = new EasyXml({ + rootElement: 'item', + dateFormat: 'ISO', + manifest: true + }); + res.send(serializer.render(data)); +} + + +module.exports = { + extension2accept, + render +}; \ No newline at end of file diff --git a/lib/api3/swagger.js b/lib/api3/swagger.js index 2d434e97f53..ff965061c87 100644 --- a/lib/api3/swagger.js +++ b/lib/api3/swagger.js @@ -10,7 +10,7 @@ function setupSwaggerUI (app) { const serveSwaggerDef = function serveSwaggerDef (req, res) { res.sendFile(__dirname + '/swagger.yaml'); }; - app.get('/swagger.yaml', serveSwaggerDef); + app.get('/swagger', serveSwaggerDef); const swaggerUiAssetPath = require('swagger-ui-dist').getAbsoluteFSPath(); const swaggerFiles = express.static(swaggerUiAssetPath); diff --git a/lib/api3/swagger.yaml b/lib/api3/swagger.yaml index 17db893e0ef..5cbb2a05544 100644 --- a/lib/api3/swagger.yaml +++ b/lib/api3/swagger.yaml @@ -2,7 +2,7 @@ openapi: 3.0.0 servers: - url: '/api/v3' info: - version: '3.0.1' + version: "3.0.1" title: Nightscout API contact: name: NS development discussion channel @@ -135,6 +135,8 @@ paths: $ref: '#/components/responses/403Forbidden' 404: $ref: '#/components/responses/404NotFound' + 406: + $ref: '#/components/responses/406NotAcceptable' ###################################################################################### @@ -240,6 +242,8 @@ paths: $ref: '#/components/responses/403Forbidden' 404: $ref: '#/components/responses/404NotFound' + 406: + $ref: '#/components/responses/406NotAcceptable' 410: $ref: '#/components/responses/410Gone' @@ -459,6 +463,8 @@ paths: $ref: '#/components/responses/403Forbidden' 404: $ref: '#/components/responses/404NotFound' + 406: + $ref: '#/components/responses/406NotAcceptable' ###################################################################################### @@ -518,6 +524,8 @@ paths: $ref: '#/components/responses/403Forbidden' 404: $ref: '#/components/responses/404NotFound' + 406: + $ref: '#/components/responses/406NotAcceptable' ###################################################################################### @@ -888,6 +896,9 @@ components: 404NotFound: description: The collection or document specified was not found. + 406NotAcceptable: + description: The requested content type (in `Accept` header) is not supported. + 412PreconditionFailed: description: The document has already been modified on the server since specified timestamp (in If-Unmodified-Since header). @@ -903,6 +914,12 @@ components: application/json: schema: $ref: '#/components/schemas/DocumentArray' + text/csv: + schema: + $ref: '#/components/schemas/DocumentArray' + application/xml: + schema: + $ref: '#/components/schemas/DocumentArray' search204: description: Successful operation - no documents matching the filtering criteria @@ -913,6 +930,12 @@ components: application/json: schema: $ref: '#/components/schemas/Document' + text/csv: + schema: + $ref: '#/components/schemas/Document' + application/xml: + schema: + $ref: '#/components/schemas/Document' headers: 'Last-Modified': $ref: '#/components/schemas/headerLastModified' @@ -924,6 +947,12 @@ components: application/json: schema: $ref: '#/components/schemas/DocumentArray' + text/csv: + schema: + $ref: '#/components/schemas/DocumentArray' + application/xml: + schema: + $ref: '#/components/schemas/DocumentArray' headers: 'Last-Modified': $ref: '#/components/schemas/headerLastModifiedMaximum' @@ -1417,6 +1446,8 @@ components: Document: description: Single document + xml: + name: 'item' type: object oneOf: - $ref: '#/components/schemas/DeviceStatus' @@ -1483,6 +1514,8 @@ components: DocumentArray: type: object + xml: + name: 'items' oneOf: - $ref: '#/components/schemas/DeviceStatusArray' - $ref: '#/components/schemas/EntryArray' diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index d80078c1143..5b4118aae7c 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -3179,6 +3179,17 @@ "cssom": "0.3.x" } }, + "csv-parse": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-4.8.3.tgz", + "integrity": "sha512-0GPxubzYzSn08lhNTWDCkcDKn8krmw0WuscqB2RrW6sugGGskbwaaEz7PCFFwbQ0phNGTTieiyfzzu3S/jZZ7Q==", + "dev": true + }, + "csv-stringify": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-5.3.5.tgz", + "integrity": "sha512-bFbaJqz7LcwnTzdryyJuhR6Pys2deU8+z7O8N0JBnNGm7vnJVr3K0n68bhb+rlMpwCmDbUtinr8yq5I2RlPMqw==" + }, "cyclist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", @@ -3684,6 +3695,15 @@ "stream-shift": "^1.0.0" } }, + "easyxml": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/easyxml/-/easyxml-2.0.1.tgz", + "integrity": "sha1-7qCShCyREwCox4GRPL5b04pQcRw=", + "requires": { + "elementtree": "^0.1.6", + "inflect": "^0.3.0" + } + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -3716,6 +3736,21 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.275.tgz", "integrity": "sha512-/YWtW/VapMnuYA1lNOaa1F4GhR1LBf+CUTp60lzDPEEh0XOzyOAyULyYZVF9vziZ3qSbTqCwmKwsyRXp66STbw==" }, + "elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha1-mskb5uUvtuYkTE5UpKw+2K6OKcA=", + "requires": { + "sax": "1.1.4" + }, + "dependencies": { + "sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha1-dLbTPJrh4AFRDxeakRaFiPGu2qk=" + } + } + }, "elliptic": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.1.tgz", @@ -5699,6 +5734,11 @@ "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==" }, + "inflect": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/inflect/-/inflect-0.3.0.tgz", + "integrity": "sha1-gdDqqja1CmAjC3UQBIs5xBQv5So=" + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -12117,6 +12157,22 @@ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==" }, + "xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "dev": true, + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + } + }, + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true + }, "xmlhttprequest-ssl": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", diff --git a/package.json b/package.json index 4a9b6ed7496..36d527c657e 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,9 @@ "compression": "^1.7.4", "css-loader": "^1.0.1", "cssmin": "^0.4.3", + "csv-stringify": "^5.3.5", "d3": "^5.12.0", + "easyxml": "^2.0.1", "ejs": "^2.6.2", "errorhandler": "^1.5.1", "event-stream": "3.3.4", @@ -121,6 +123,7 @@ "devDependencies": { "babel-eslint": "^10.0.3", "benv": "^3.3.0", + "csv-parse": "^4.8.3", "env-cmd": "^8.0.2", "eslint": "^6.2.1", "eslint-loader": "^2.2.1", @@ -133,7 +136,8 @@ "terser-webpack-plugin": "^1.4.1", "webpack-bundle-analyzer": "^3.4.1", "webpack-dev-middleware": "^3.7.2", - "webpack-hot-middleware": "^2.25.0" + "webpack-hot-middleware": "^2.25.0", + "xml2js": "^0.4.23" }, "browserslist": "> 0.25%, not dead" } diff --git a/tests/api3.generic.workflow.test.js b/tests/api3.generic.workflow.test.js index cd4b4a46b4b..f94238d6747 100644 --- a/tests/api3.generic.workflow.test.js +++ b/tests/api3.generic.workflow.test.js @@ -9,10 +9,8 @@ describe('Generic REST API3', function() { , instance = require('./fixtures/api3/instance') , authSubject = require('./fixtures/api3/authSubject') , opTools = require('../lib/api3/shared/operationTools') - , utils = require('./fixtures/api3/utils') ; - utils.randomString('32', 'aA#'); // let's have a brand new identifier for your testing document self.urlLastModified = '/api/v3/lastModified'; self.historyTimestamp = 0; diff --git a/tests/api3.renderer.test.js b/tests/api3.renderer.test.js new file mode 100644 index 00000000000..70401897025 --- /dev/null +++ b/tests/api3.renderer.test.js @@ -0,0 +1,268 @@ +/* eslint require-atomic-updates: 0 */ +'use strict'; + +require('should'); + +describe('API3 output renderers', function() { + const self = this + , testConst = require('./fixtures/api3/const.json') + , instance = require('./fixtures/api3/instance') + , authSubject = require('./fixtures/api3/authSubject') + , opTools = require('../lib/api3/shared/operationTools') + , _ = require('lodash') + , xml2js = require('xml2js') + , csvParse = require('csv-parse/lib/sync') + ; + + self.historyFrom = (new Date()).getTime() - 1000; // starting timestamp for HISTORY operations + + self.doc1 = testConst.SAMPLE_ENTRIES[0]; + self.doc1.date = (new Date()).getTime() - (5 * 60 * 1000); + self.doc1.identifier = opTools.calculateIdentifier(self.doc1); + + self.doc2 = testConst.SAMPLE_ENTRIES[1]; + self.doc2.date = (new Date()).getTime(); + self.doc2.identifier = opTools.calculateIdentifier(self.doc2); + + self.xmlParser = new xml2js.Parser({ + explicitArray: false + }); + + self.csvParserOptions = { + columns: true, + skip_empty_lines: true + }; + + self.timeout(15000); + + + before(async () => { + self.instance = await instance.create({}); + + self.app = self.instance.app; + self.env = self.instance.env; + self.url = '/api/v3/entries'; + + let authResult = await authSubject(self.instance.ctx.authorization.storage); + + self.subject = authResult.subject; + self.token = authResult.token; + }); + + + after(() => { + self.instance.server.close(); + }); + + + /** + * Checks if all properties from obj1 are string identical in obj2 + * (comparison of properties is made using toString()) + * @param {Object} obj1 + * @param {Object} obj2 + */ + self.checkProps = function checkProps (obj1, obj2) { + for (let propName in obj1) { + obj1[propName].toString().should.eql(obj2[propName].toString()); + } + }; + + + /** + * Checks if all objects from arrModel exist in arr + * (with string identical properties) + * @param arrModel + * @param arr + */ + self.checkItems = function checkItems (arrModel, arr) { + for (let itemModel of arrModel) { + const item = _.find(arr, (doc) => doc.identifier === itemModel.identifier); + item.should.not.be.empty(); + self.checkProps(itemModel, item); + } + }; + + + /** + * Checks if given text is valid XML. + * Next checks if all objects from arrModel exist in parsed array + * (with string identical properties) + * @param arrModel + * @param xmlText + * @returns {Promise} + */ + self.checkXmlItems = async function checkXmlItems (arrModel, xmlText) { + xmlText.should.startWith(''); + + const xml = await self.xmlParser.parseStringPromise(xmlText); + xml.items.should.not.be.empty(); + let items = xml.items.item; + items.should.be.Array(); + items.length.should.be.aboveOrEqual(arrModel.length); + + self.checkItems(arrModel, items); + }; + + + /** + * Checks if given text is valid CSV. + * Next checks if all objects from arrModel exist in parsed array + * (with string identical properties) + * @param arrModel + * @param csvText + * @returns {Promise} + */ + self.checkCsvItems = async function checkXmlItems (arrModel, csvText) { + csvText.should.not.be.empty(); + + const items = csvParse(csvText, self.csvParserOptions); + items.should.be.Array(); + items.length.should.be.aboveOrEqual(arrModel.length); + + self.checkItems(arrModel, items); + }; + + + it('should create 2 mock documents', async () => { + + async function createDoc (doc) { + + let res = await self.instance.post(`${self.url}?token=${self.token.create}`) + .send(doc) + .expect(201); + + res.body.should.be.empty(); + + res = await self.instance.get(`${self.url}/${doc.identifier}?token=${self.token.read}`) + .expect(200); + return res.body; + } + + self.doc1json = await createDoc(self.doc1); + self.doc2json = await createDoc(self.doc2); + }); + + + it('READ/SEARCH/HISTORY should not accept unsupported content type', async () => { + + async function check406 (request) { + const res = await request + .expect(406); + res.body.message.should.eql('Unsupported output format requested'); + } + + await check406(self.instance.get(`${self.url}/${self.doc1.identifier}.ttf?fields=_all&token=${self.token.read}`)); + await check406(self.instance.get(`${self.url}/${self.doc1.identifier}?fields=_all&token=${self.token.read}`) + .set('Accept', 'font/ttf')); + + await check406(self.instance.get(`${self.url}.ttf?fields=_all&token=${self.token.read}`)); + await check406(self.instance.get(`${self.url}?fields=_all&token=${self.token.read}`) + .set('Accept', 'font/ttf')); + + await check406(self.instance.get(`${self.url}/history/${self.doc1.date}.ttf?token=${self.token.read}`)); + await check406(self.instance.get(`${self.url}/history/${self.doc1.date}?token=${self.token.read}`) + .set('Accept', 'font/ttf')); + }); + + + it('READ should accept xml content type', async () => { + let res = await self.instance.get(`${self.url}/${self.doc1.identifier}.xml?fields=_all&token=${self.token.read}`) + .expect(200); + + res.text.should.startWith(''); + + const xml = await self.xmlParser.parseStringPromise(res.text); + xml.item.should.not.be.empty(); + self.checkProps(self.doc1, xml.item); + + let res2 = await self.instance.get(`${self.url}/${self.doc1.identifier}?fields=_all&token=${self.token.read}`) + .set('Accept', 'application/xml') + .expect(200); + + res.text.should.eql(res2.text); + }); + + + it('READ should accept csv content type', async () => { + let res = await self.instance.get(`${self.url}/${self.doc1.identifier}.csv?fields=_all&token=${self.token.read}`) + .expect(200); + + await self.checkCsvItems([self.doc1], res.text); + + let res2 = await self.instance.get(`${self.url}/${self.doc1.identifier}?fields=_all&token=${self.token.read}`) + .set('Accept', 'text/csv') + .expect(200); + + res.text.should.eql(res2.text); + }); + + + it('SEARCH should accept xml content type', async () => { + let res = await self.instance.get(`${self.url}.xml?token=${self.token.read}&date$gte=${self.doc1.date}`) + .expect(200); + + await self.checkXmlItems([self.doc1, self.doc2], res.text); + + let res2 = await self.instance.get(`${self.url}?token=${self.token.read}&date$gte=${self.doc1.date}`) + .set('Accept', 'application/xml') + .expect(200); + + res.text.should.be.eql(res2.text); + }); + + + it('SEARCH should accept csv content type', async () => { + let res = await self.instance.get(`${self.url}.csv?token=${self.token.read}&date$gte=${self.doc1.date}`) + .expect(200); + + await self.checkCsvItems([self.doc1, self.doc2], res.text); + + let res2 = await self.instance.get(`${self.url}?token=${self.token.read}&date$gte=${self.doc1.date}`) + .set('Accept', 'text/csv') + .expect(200); + + res.text.should.be.eql(res2.text); + }); + + + it('HISTORY should accept xml content type', async () => { + let res = await self.instance.get(`${self.url}/history/${self.historyFrom}.xml?token=${self.token.read}`) + .expect(200); + + await self.checkXmlItems([self.doc1, self.doc2], res.text); + + let res2 = await self.instance.get(`${self.url}/history/${self.historyFrom}?token=${self.token.read}`) + .set('Accept', 'application/xml') + .expect(200); + + res.text.should.be.eql(res2.text); + }); + + + it('HISTORY should accept csv content type', async () => { + let res = await self.instance.get(`${self.url}/history/${self.historyFrom}.csv?token=${self.token.read}`) + .expect(200); + + await self.checkCsvItems([self.doc1, self.doc2], res.text); + + let res2 = await self.instance.get(`${self.url}/history/${self.historyFrom}?token=${self.token.read}`) + .set('Accept', 'text/csv') + .expect(200); + + res.text.should.be.eql(res2.text); + }); + + + it('should remove mock documents', async () => { + + async function deleteDoc (identifier) { + await self.instance.delete(`${self.url}/${identifier}?token=${self.token.delete}`) + .query({ 'permanent': 'true' }) + .expect(204); + } + + await deleteDoc(self.doc1.identifier); + await deleteDoc(self.doc2.identifier); + }); +}); +