├── test ├── .eslintrc ├── data │ ├── image.dcm │ └── multipart_study ├── dimseTest.js ├── otherTest.js ├── stowTest.js ├── wadoTest.js └── qidoTest.js ├── testAuth ├── .eslintrc ├── basicAuthTest.js └── oidcAuthTest.js ├── .eslintignore ├── image.png ├── dockersupport ├── dicomweb-server-service.sh ├── conditionally-start-pouchdb.sh └── supervisord.conf ├── .dockerignore ├── .gitignore ├── .prettierrc ├── config ├── test.auth.basic.js ├── test.auth.oidc.js ├── test.js ├── index.js ├── patients.js ├── development.js ├── getBulkDataURI.js ├── wado.js ├── buildResponse.js ├── qido_series.js ├── qido_instances.js ├── btoa.js ├── qido_study.js ├── views.js ├── viewTags.js ├── returnValueFromVR.js └── schemas │ ├── patients_output_schema.json │ ├── series_output_schema.json │ ├── instances_output_schema.json │ └── studies_output_schema.json ├── .eslintrc.json ├── routes ├── other.js ├── stow.js ├── qido.js └── wado.js ├── Dockerfile ├── utils └── Errors.js ├── docker-compose.yml ├── package.json ├── .circleci └── config.yml ├── plugins ├── DIMSE.js └── CouchDB.js ├── README.md ├── CODE_OF_CONDUCT.md ├── server.js └── LICENSE /test/.eslintrc: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | mocha: true 4 | -------------------------------------------------------------------------------- /testAuth/.eslintrc: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | mocha: true 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | utils/dcmtk-node/* 2 | plugins/DIMSE.js 3 | test/dimseTest.js -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcmjs-org/dicomweb-server/HEAD/image.png -------------------------------------------------------------------------------- /dockersupport/dicomweb-server-service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd /home/node/app 3 | npm start -------------------------------------------------------------------------------- /test/data/image.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcmjs-org/dicomweb-server/HEAD/test/data/image.dcm -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .circleci 3 | .prettierrc 4 | .eslintignore 5 | .git 6 | couchdb-data 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | config/*.json 4 | couchdb-data 5 | .idea 6 | yarn.lock 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /test/data/multipart_study: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcmjs-org/dicomweb-server/HEAD/test/data/multipart_study -------------------------------------------------------------------------------- /config/test.auth.basic.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: 'test', 3 | dbServer: 'http://localhost', 4 | db: 'testdb_dicomweb', 5 | dbPort: process.env.PORT || 5984, 6 | auth: 'basic', 7 | logger: false, 8 | }; 9 | -------------------------------------------------------------------------------- /config/test.auth.oidc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: 'test', 3 | dbServer: 'http://localhost', 4 | db: 'testdb_dicomweb', 5 | dbPort: process.env.PORT || 5984, 6 | auth: 'oidc', 7 | logger: false, 8 | }; 9 | -------------------------------------------------------------------------------- /dockersupport/conditionally-start-pouchdb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ $USE_POUCHDB = "true" ] 4 | then 5 | echo "USE_POUCHDB is true, starting PouchDB service" 6 | /home/node/app/node_modules/pouchdb-server/bin/pouchdb-server --in-memory --host 0.0.0.0 7 | else 8 | exit 0 9 | fi 10 | -------------------------------------------------------------------------------- /config/test.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: 'test', 3 | dbServer: 'http://admin:admin@localhost', 4 | db: 'testdb_dicomweb', 5 | dbPort: process.env.PORT || 5984, 6 | auth: 'none', 7 | DIMSE: { 8 | tempDir: './data', 9 | AET: 'PACS', 10 | port: 4002, 11 | }, 12 | maxConcurrent: 5, 13 | logger: { 14 | level: 'warn', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | const env = process.env.NODE_ENV || 'development'; 2 | const config = require(`./${env}`); // eslint-disable-line 3 | config.authConfig = {}; 4 | if (config.auth && config.auth != 'none') config.authConfig = require(`./${config.auth}.json`); // eslint-disable-line 5 | config.maxConcurrent = config.maxConcurrent || 5; 6 | config.prefix = config.prefix || ''; 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true 6 | }, 7 | "extends": ["airbnb-base", "prettier"], 8 | "plugins": ["prettier"], 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2018 15 | }, 16 | "rules": { 17 | "prettier/prettier": ["error", { "trailingComma": "es5" }] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /config/patients.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var buildResponse = require('./buildResponse'); 3 | var { patientTags } = require('./viewTags'); 4 | 5 | module.exports = function applyView(doc) { 6 | if (!doc.dataset) { 7 | return; 8 | } 9 | 10 | var key = buildResponse(doc.dataset, patientTags); 11 | 12 | emit(JSON.stringify([key['00080080'],key['00100020'],key['00100010'],key['00100030'],key['00100040'],key['00080005'],key['00081190']]), null) 13 | } -------------------------------------------------------------------------------- /config/development.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: 'development', 3 | dbServer: process.env.DB_SERVER || 'http://localhost', 4 | db: process.env.DB_NAME || 'chronicle', 5 | dbPort: process.env.DB_PORT || 5984, 6 | prefix: process.env.PREFIX || '', 7 | auth: process.env.AUTH || 'none', 8 | logger: process.env.LOGGER || true, 9 | DIMSE: { 10 | tempDir: process.env.TEMPDIR || './data', 11 | AET: process.env.AET || 'PACS', 12 | port: process.env.DIMSE_PORT || 4002, 13 | }, 14 | maxConcurrent: process.env.MAXCONCURRENT || 5, 15 | }; 16 | -------------------------------------------------------------------------------- /dockersupport/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | user=node 3 | logfile=/home/node/log/supervisor/supervisord.log 4 | nodaemon=true 5 | 6 | [program:pouchdb] 7 | command=bash /home/node/app/dockersupport/conditionally-start-pouchdb.sh 8 | stdout_logfile=/home/node/log/supervisor/%(program_name)s.log 9 | stderr_logfile=/home/node/log/supervisor/%(program_name)s.log 10 | autorestart=true 11 | 12 | [program:dicomweb-server] 13 | command=bash /home/node/app/dockersupport/dicomweb-server-service.sh 14 | stdout_logfile=/home/node/log/supervisor/%(program_name)s.log 15 | stderr_logfile=/home/node/log/supervisor/%(program_name)s.log 16 | autorestart=true 17 | -------------------------------------------------------------------------------- /config/getBulkDataURI.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | module.exports = function getBulkDataURI(studyUID, seriesUID, instanceUID, tag) { 3 | // e.g. http://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs/studies/1.2.840.113619.2.5.1762583153.215519.978957063.78/series/1.2.840.113619.2.5.1762583153.215519.978957063.121/instances/1.2.840.113619.2.5.1762583153.215519.978957063.128/bulkdata/00431029 4 | var server = ''; 5 | var bulkDataURI = server; 6 | bulkDataURI += '/studies/' + studyUID; 7 | bulkDataURI += '/series/' + seriesUID; 8 | bulkDataURI += '/instances/' + instanceUID; 9 | bulkDataURI += '/bulkdata/' + tag.toUpperCase(); 10 | 11 | return bulkDataURI; 12 | } 13 | -------------------------------------------------------------------------------- /routes/other.js: -------------------------------------------------------------------------------- 1 | // defines routes that are not specified by the DICOMweb standard 2 | async function routes(fastify) { 3 | // GET {s}/patients 4 | fastify.route({ 5 | method: 'GET', 6 | url: '/patients', 7 | schema: { 8 | response: { 9 | 200: 'patients_schema#', 10 | }, 11 | }, 12 | 13 | handler: fastify.getPatients, 14 | }); 15 | 16 | fastify.route({ 17 | method: 'DELETE', 18 | url: '/studies/:study/series/:series', 19 | handler: fastify.deleteSeries, 20 | }); 21 | 22 | fastify.route({ 23 | method: 'DELETE', 24 | url: '/studies/:study', 25 | handler: fastify.deleteStudy, 26 | }); 27 | } 28 | 29 | module.exports = routes; 30 | -------------------------------------------------------------------------------- /config/wado.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var buildResponse = require('./buildResponse'); 3 | 4 | module.exports = function applyView(doc) { 5 | if (!doc.dataset) { 6 | return; 7 | } 8 | 9 | var studyUID = "NA"; 10 | var seriesUID = "NA"; 11 | var instanceUID = "NA"; 12 | 13 | if (doc.dataset["0020000D"].Value[0]) { 14 | studyUID = doc.dataset["0020000D"].Value[0]; 15 | } 16 | 17 | if (doc.dataset["0020000E"].Value[0]) { 18 | seriesUID = doc.dataset["0020000E"].Value[0]; 19 | } 20 | 21 | if (doc.dataset["00080018"].Value[0]) { 22 | instanceUID = doc.dataset["00080018"].Value[0]; 23 | } 24 | 25 | var key = buildResponse(doc.dataset); 26 | 27 | emit([studyUID, seriesUID, instanceUID], key); 28 | } -------------------------------------------------------------------------------- /routes/stow.js: -------------------------------------------------------------------------------- 1 | // defines stow route 2 | async function stowRoutes(fastify) { 3 | fastify.route({ 4 | method: 'POST', 5 | url: '/studies', 6 | schema: { 7 | params: { 8 | type: 'object', 9 | properties: { 10 | study: { 11 | type: 'string', 12 | }, 13 | }, 14 | }, 15 | }, 16 | handler: fastify.stow, 17 | }); 18 | 19 | fastify.route({ 20 | method: 'POST', 21 | url: '/linkFolder', 22 | schema: { 23 | query: { 24 | type: 'object', 25 | properties: { 26 | path: { 27 | type: 'string', 28 | }, 29 | }, 30 | }, 31 | }, 32 | handler: fastify.linkFolder, 33 | }); 34 | } 35 | 36 | module.exports = stowRoutes; 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # docker build -t ohif/dicomweb-server:latest . 2 | # docker run -p 5985:5985 ohif/dicomweb-server:latest 3 | # If you want to use PouchDB in this container, add -p 5984:5984 4 | # docker run -p 5985:5985 -p 5984:5984 -e USE_POUCHDB=true -e DB_SERVER=http://0.0.0.0 ohif/dicomweb-server:latest 5 | FROM node:13.10.1-slim 6 | 7 | # Install prerequisites 8 | RUN apt-get update 9 | RUN apt-get -y install supervisor 10 | 11 | USER node 12 | RUN mkdir -p /home/node/app 13 | RUN mkdir -p /home/node/log/supervisor 14 | WORKDIR /home/node/app 15 | ADD . /home/node/app 16 | 17 | # Restore deps 18 | RUN npm ci 19 | RUN npm install pouchdb-server@4.2.0 20 | 21 | ENV USE_POUCHDB=false 22 | 23 | EXPOSE 5984 5985 24 | CMD ["supervisord", "-n", "-c", "/home/node/app/dockersupport/supervisord.conf"] 25 | -------------------------------------------------------------------------------- /utils/Errors.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | // based on https://rclayton.silvrback.com/custom-errors-in-node-js 3 | class ResourceNotFoundError extends Error { 4 | constructor(resourceType, resourceId, error) { 5 | super(`${resourceType} ${resourceId} was not found. Error: ${error.message}`); 6 | this.data = { resourceType, resourceId }; 7 | } 8 | } 9 | 10 | class InternalError extends Error { 11 | constructor(reason, error) { 12 | super(`${reason}. Error: ${error.message}`); 13 | this.data = { error, reason }; 14 | } 15 | } 16 | 17 | class BadRequestError extends Error { 18 | constructor(reason, error) { 19 | super(`${reason}. Error: ${error.message}`); 20 | this.data = { error, reason }; 21 | } 22 | } 23 | 24 | module.exports = { 25 | ResourceNotFoundError, 26 | InternalError, 27 | BadRequestError, 28 | }; 29 | -------------------------------------------------------------------------------- /config/buildResponse.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const returnValueFromVR = require('./returnValueFromVR'); 3 | 4 | module.exports = function buildResponse(dataset, fields) { 5 | var response = {}; 6 | var val; 7 | 8 | if (fields && fields.length) { 9 | for (var i = 0; i < fields.length; i++) { 10 | var tag = fields[i]; 11 | var t = tag[1]; 12 | var fieldVR = tag[3]; 13 | var required = tag[4]; 14 | 15 | val = returnValueFromVR(dataset, dataset[t], t, fieldVR, required); 16 | 17 | if (val) { 18 | response[t] = val; 19 | } 20 | } 21 | } else { 22 | for (var t in dataset) { 23 | val = returnValueFromVR(dataset, dataset[t], t, dataset[t].vr, false, true); 24 | 25 | if (val) { 26 | response[t] = val; 27 | } 28 | } 29 | } 30 | 31 | return response; 32 | } -------------------------------------------------------------------------------- /config/qido_series.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var buildResponse = require('./buildResponse'); 3 | var { seriesTags } = require('./viewTags'); 4 | 5 | module.exports = function applyView(doc) { 6 | if (!doc.dataset) { 7 | return; 8 | } 9 | 10 | var seriesKey = buildResponse(doc.dataset, seriesTags); 11 | var studyUID = 'NA'; 12 | var seriesUID = 'NA'; 13 | 14 | if (doc.dataset['0020000D'].Value[0]) { 15 | studyUID = doc.dataset['0020000D'].Value[0]; 16 | } 17 | 18 | if (doc.dataset['0020000E'].Value[0]) { 19 | seriesUID = doc.dataset['0020000E'].Value[0]; 20 | } 21 | 22 | emit([studyUID, seriesUID, JSON.stringify([seriesKey['00080005'],seriesKey['00080060'],seriesKey['00080201'],seriesKey['0008103E'],seriesKey['00081190'],seriesKey['0020000E'],seriesKey['00200011'],seriesKey['00201209'],seriesKey['00080054'],seriesKey['00080056'],seriesKey['0020000D'],seriesKey['00100010'],seriesKey['00100020']])], null) 23 | } -------------------------------------------------------------------------------- /config/qido_instances.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var buildResponse = require('./buildResponse'); 3 | var { instanceTags } = require('./viewTags'); 4 | 5 | module.exports = function applyView(doc) { 6 | 7 | var key = buildResponse(doc.dataset, instanceTags); 8 | var studyUID = 'NA'; 9 | var seriesUID = 'NA'; 10 | var instanceUID = 'NA'; 11 | 12 | if (doc.dataset['0020000D'].Value[0]) { 13 | studyUID = doc.dataset['0020000D'].Value[0]; 14 | } 15 | 16 | if (doc.dataset['0020000E'].Value[0]) { 17 | seriesUID = doc.dataset['0020000E'].Value[0]; 18 | } 19 | 20 | if (doc.dataset['00080018'].Value[0]) { 21 | instanceUID = doc.dataset['00080018'].Value[0]; 22 | } 23 | 24 | emit([studyUID, seriesUID, instanceUID, JSON.stringify([key['00080005'],key['00080016'],key['00080018'],key['00080056'],key['00080201'],key['00081190'],key['00200013'],key['00280010'],key['00280011'],key['00280100'],key['00280008'],key['0020000D'],key['0020000E'],key['00080054']])], null) 25 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # docker-compose up 2 | # curl -X PUT http://127.0.0.1:5984/_users 3 | # curl -X PUT http://127.0.0.1:5984/_replicator 4 | # curl -X PUT http://127.0.0.1:5984/_global_changes 5 | # curl -X PUT http://127.0.0.1:5984/chronicle 6 | # http://localhost:5984/_utils/ 7 | version: '3.8' 8 | 9 | services: 10 | dicomweb: 11 | build: 12 | context: . 13 | dockerfile: Dockerfile 14 | image: ohif/dicomweb-server:latest 15 | hostname: dicomweb-server 16 | environment: 17 | - PORT=5984 18 | - DB_SERVER=http://couchdb 19 | ports: 20 | - '5985:5985' 21 | depends_on: 22 | - couchdb 23 | restart: unless-stopped 24 | 25 | ## 26 | # LINK: https://hub.docker.com/_/couchdb 27 | ## 28 | couchdb: 29 | image: couchdb:2.3.1 30 | hostname: couchdb 31 | volumes: 32 | - ./couchdb-data:/opt/couchdb/data 33 | environment: 34 | - COUCHDB_USER=admin 35 | - COUCHDB_PASSWORD=password 36 | ports: 37 | - '5984:5984' 38 | restart: unless-stopped 39 | -------------------------------------------------------------------------------- /config/btoa.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // Doesn't seem to be supported in CouchDB 4 | // https://github.com/jo/couch64/issues/1 5 | // var btoa = require('./btoa.js'); 6 | 7 | /** 8 | * Binary to ASCII (encode data to Base64) 9 | * @param {String} data 10 | * @returns {String} 11 | * 12 | * https://base64.guru/developers/javascript/examples/polyfill 13 | */ 14 | module.exports = function btoa(data) { 15 | var ascii = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; 16 | var len = data.length - 1; 17 | var i = -1; 18 | var b64 = ''; 19 | 20 | while (i < len) { 21 | var code = (data.charCodeAt(++i) << 16) | (data.charCodeAt(++i) << 8) | data.charCodeAt(++i); 22 | b64 += 23 | ascii[(code >>> 18) & 63] + 24 | ascii[(code >>> 12) & 63] + 25 | ascii[(code >>> 6) & 63] + 26 | ascii[code & 63]; 27 | } 28 | 29 | var pads = data.length % 3; 30 | if (pads > 0) { 31 | b64 = b64.slice(0, pads - 3); 32 | 33 | while (b64.length % 4 !== 0) { 34 | b64 += '='; 35 | } 36 | } 37 | 38 | return b64; 39 | }; 40 | -------------------------------------------------------------------------------- /config/qido_study.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var buildResponse = require('./buildResponse'); 3 | var { studyTags } = require('./viewTags'); 4 | 5 | module.exports = function applyView(doc) { 6 | if (!doc.dataset) { 7 | return; 8 | } 9 | 10 | var studyKey = buildResponse(doc.dataset, studyTags); 11 | var studyUID = 'NA'; 12 | 13 | if (doc.dataset['0020000D'].Value[0]) { 14 | studyUID = doc.dataset['0020000D'].Value[0]; 15 | } 16 | var seriesUID = 'NA'; 17 | var modality = 'NA'; 18 | if (doc.dataset['0020000E'].Value[0]) { 19 | seriesUID = doc.dataset['0020000E'].Value[0]; 20 | } 21 | if (doc.dataset['00080060'].Value[0]) { 22 | modality = doc.dataset['00080060'].Value[0]; 23 | } 24 | 25 | emit([studyUID, modality, seriesUID, JSON.stringify([studyKey['0020000D'],studyKey['00080005'], studyKey['00080020'],studyKey['00080030'],studyKey['00080050'],studyKey['00080056'],studyKey['00080061'], studyKey['00080090'], studyKey['00080201'],studyKey['00081190'], studyKey['00100010'] , studyKey['00100020'],studyKey['00100030'],studyKey['00100040'], studyKey['00200010'], studyKey['00201206'] , studyKey['00201208'], studyKey['00080054'], studyKey['00081030']])], null) 26 | } -------------------------------------------------------------------------------- /test/dimseTest.js: -------------------------------------------------------------------------------- 1 | require('./stowTest'); 2 | const fs = require('fs-extra'); 3 | const path = require('path'); 4 | const chai = require('chai'); 5 | const chaiHttp = require('chai-http'); 6 | 7 | const config = require('../config/index'); 8 | 9 | chai.use(chaiHttp); 10 | const { expect } = chai; 11 | 12 | describe('DIMSE', () => { 13 | // if no DIMSE setting skip the tests 14 | if (!(config.DIMSE && fs.existsSync(path.join(__dirname, '../../dcmtk-node')))) return; 15 | const dcmtk = require('../../dcmtk-node')({ 16 | verbose: false, 17 | }); 18 | it('should be able to echoscu', done => { 19 | dcmtk.echoscu( 20 | { 21 | args: ['-aet', config.DIMSE.AET, '-aec', 'TEST', 'localhost', config.DIMSE.port], 22 | }, 23 | (err, output) => { 24 | expect(output.parsed.accepted).to.be.equal(true); 25 | done(err); 26 | } 27 | ); 28 | }); 29 | 30 | // TODO no storescu in dcmtk-node 31 | // it('should store with storescu', done => { 32 | // dcmtk.storescu( 33 | // { 34 | // args: ['localhost', config.DIMSE.port, 'test/data/dimse_files'], 35 | // }, 36 | // (err, output) => { 37 | // console.log(output); 38 | // expect(output.parsed.accepted).to.be.equal(true); 39 | // done(err); 40 | // } 41 | // ); 42 | // }); 43 | }); 44 | -------------------------------------------------------------------------------- /routes/qido.js: -------------------------------------------------------------------------------- 1 | // defines QIDO routes 2 | async function qidoRoutes(fastify) { 3 | // QIDO Retrieve Studies 4 | // GET {s}/studies 5 | fastify.route({ 6 | method: 'GET', 7 | url: '/studies', 8 | schema: { 9 | response: { 10 | 200: 'studies_schema#', 11 | }, 12 | }, 13 | handler: fastify.getQIDOStudies, 14 | }); 15 | 16 | // QIDO Retrieve Series 17 | // GET {s}/studies/:study/series 18 | fastify.route({ 19 | method: 'GET', 20 | url: '/studies/:study/series', 21 | schema: { 22 | params: { 23 | type: 'object', 24 | properties: { 25 | study: { 26 | type: 'string', 27 | }, 28 | }, 29 | }, 30 | response: { 31 | 200: 'series_schema#', 32 | }, 33 | }, 34 | 35 | handler: fastify.getQIDOSeries, 36 | }); 37 | 38 | // QIDO Retrieve Instances 39 | // GET {s}/studies/:study/series/:series/instances 40 | fastify.route({ 41 | method: 'GET', 42 | url: '/studies/:study/series/:series/instances', 43 | schema: { 44 | params: { 45 | type: 'object', 46 | properties: { 47 | study: { 48 | type: 'string', 49 | }, 50 | series: { 51 | type: 'string', 52 | }, 53 | }, 54 | }, 55 | response: { 56 | 200: 'instances_schema#', 57 | }, 58 | }, 59 | 60 | handler: fastify.getQIDOInstances, 61 | }); 62 | } 63 | 64 | module.exports = qidoRoutes; 65 | -------------------------------------------------------------------------------- /test/otherTest.js: -------------------------------------------------------------------------------- 1 | require('./stowTest'); 2 | const chai = require('chai'); 3 | const chaiHttp = require('chai-http'); 4 | const config = require('../config/index'); 5 | 6 | chai.use(chaiHttp); 7 | const { expect } = chai; 8 | 9 | describe('Patients API', () => { 10 | it('it should GET all patients (one patient from stowed data)', done => { 11 | chai 12 | .request(`http://${process.env.host}:${process.env.port}`) 13 | .get(`${config.prefix}/patients`) 14 | .then(res => { 15 | if (res.statusCode >= 400) { 16 | done(new Error(res.body.error, res.body.message)); 17 | 18 | return; 19 | } 20 | 21 | expect(res.statusCode).to.equal(200); 22 | expect(res.body).to.be.a('array'); 23 | expect(res.body.length).to.be.eql(1); 24 | done(); 25 | }) 26 | .catch(e => { 27 | done(e); 28 | }); 29 | }); 30 | 31 | it('returned patient should be MRI-DIR-T2_3', done => { 32 | chai 33 | .request(`http://${process.env.host}:${process.env.port}`) 34 | .get(`${config.prefix}/patients`) 35 | .then(res => { 36 | if (res.statusCode >= 400) { 37 | done(new Error(res.body.error, res.body.message)); 38 | 39 | return; 40 | } 41 | 42 | expect(res.statusCode).to.equal(200); 43 | expect(res.body[0]['00100010'].Value[0].Alphabetic).to.be.eql('MRI-DIR-T2_3'); 44 | done(); 45 | }) 46 | .catch(e => { 47 | done(e); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /config/views.js: -------------------------------------------------------------------------------- 1 | const wado = require('./wado.js'); 2 | const qidoStudy = require('./qido_study.js'); 3 | const qidoSeries = require('./qido_series.js'); 4 | const qidoInstances = require('./qido_instances.js'); 5 | const patients = require('./patients.js'); 6 | const buildResponse = require('./buildResponse'); 7 | const returnValueFromVR = require('./returnValueFromVR'); 8 | const btoa = require('./btoa'); 9 | const getBulkDataURI = require('./getBulkDataURI'); 10 | const tags = require('./viewTags'); 11 | 12 | function stringifyViewWithDependencies(func, tags2put) { 13 | return ` 14 | function(doc) { 15 | ${tags2put ? `var ${tags2put} = ${JSON.stringify(tags[tags2put])};` : ''} 16 | ${btoa.toString()} 17 | ${getBulkDataURI.toString()} 18 | ${returnValueFromVR.toString()} 19 | ${buildResponse.toString()} 20 | ${func.toString()} 21 | 22 | return applyView(doc); 23 | } 24 | `; 25 | } 26 | 27 | module.exports.views = { 28 | wado_metadata: { 29 | map: stringifyViewWithDependencies(wado), 30 | }, 31 | patients: { 32 | map: stringifyViewWithDependencies(patients, 'patientTags'), 33 | reduce: '_count', 34 | }, 35 | qido_study: { 36 | map: stringifyViewWithDependencies(qidoStudy, 'studyTags'), 37 | reduce: '_count', 38 | }, 39 | qido_series: { 40 | map: stringifyViewWithDependencies(qidoSeries, 'seriesTags'), 41 | reduce: '_count', 42 | }, 43 | qido_instances: { 44 | map: stringifyViewWithDependencies(qidoInstances, 'instanceTags'), 45 | reduce: '_count', 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dicomweb-server", 3 | "version": "0.0.0-development", 4 | "description": "Lightweight DICOMweb Server with CouchDB", 5 | "repository": "https://github.com/dcmjs-org/dicomweb-server", 6 | "main": "server.js", 7 | "dependencies": { 8 | "atob": "^2.1.2", 9 | "axios": "^1.6.0", 10 | "chai": "^4.2.0", 11 | "chai-http": "^4.3.0", 12 | "dcmjs": "0.29.11", 13 | "fastify": "^2.10.0", 14 | "fastify-basic-auth": "^0.5.0", 15 | "fastify-cors": "^3.0.0", 16 | "fastify-couchdb": "^0.2.0", 17 | "fastify-plugin": "^1.6.0", 18 | "fastify-static": "^4.2.4", 19 | "fs-extra": "^8.1.0", 20 | "http": "0.0.0", 21 | "keycloak-backend": "^3.0.0", 22 | "md5": "^2.2.1", 23 | "nano": "^8.1.0", 24 | "p-queue": "^6.2.1", 25 | "split2": "^3.1.1", 26 | "to-array-buffer": "^3.2.0", 27 | "underscore": "^1.9.1", 28 | "xmlhttprequest": "^1.8.0" 29 | }, 30 | "devDependencies": { 31 | "eslint": "^6.7.2", 32 | "eslint-config-airbnb-base": "^14.0.0", 33 | "eslint-config-prettier": "^6.7.0", 34 | "eslint-plugin-import": "^2.18.2", 35 | "eslint-plugin-mocha": "^6.2.2", 36 | "eslint-plugin-prettier": "^3.1.1", 37 | "mocha": "^10.2.0", 38 | "prettier": "1.19.1" 39 | }, 40 | "scripts": { 41 | "start": "node server.js", 42 | "pretest": "eslint --ignore-path .gitignore --ignore-path .eslintignore .", 43 | "test": "NODE_ENV=test mocha --timeout 10000", 44 | "lint": "eslint -c .eslintrc.json --fix . && prettier --write *.js", 45 | "test_basic": "NODE_ENV=test.auth.basic mocha testAuth/basicAuthTest.js --timeout 10000", 46 | "test_oidc": "NODE_ENV=test.auth.oidc mocha testAuth/oidcAuthTest.js --timeout 10000" 47 | }, 48 | "keywords": [ 49 | "DICOMweb", 50 | "PACS", 51 | "server", 52 | "CouchDB" 53 | ], 54 | "author": "Emel Alkim", 55 | "license": "ISC" 56 | } 57 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | defaults: &defaults 4 | working_directory: ~/repo 5 | docker: 6 | - image: circleci/node:latest 7 | - image: ibmcom/couchdb3:latest 8 | command: 9 | environment: 10 | COUCHDB_USER: "admin" 11 | COUCHDB_PASSWORD: "admin" 12 | 13 | jobs: 14 | test: 15 | <<: *defaults 16 | steps: 17 | - checkout 18 | - restore_cache: 19 | keys: 20 | - v1-dependencies-{{ checksum "package.json" }} 21 | - v1-dependencies- 22 | - run: npm ci 23 | - save_cache: 24 | paths: 25 | - node_modules 26 | key: v1-dependencies-{{ checksum "package.json" }} 27 | # Create the Chronicle Database 28 | - run: 29 | command: curl -X PUT localhost:5984/chronicle 30 | - run: npm run test 31 | 32 | # Used to publish latest 33 | deploy: 34 | <<: *defaults 35 | steps: 36 | - checkout 37 | - restore_cache: 38 | keys: 39 | - v1-dependencies-{{ checksum "package.json" }} 40 | - v1-dependencies- 41 | - run: npm ci 42 | - save_cache: 43 | paths: 44 | - node_modules 45 | key: v1-dependencies-{{ checksum "package.json" }} 46 | - run: 47 | name: Write NPM Token to ~/.npmrc 48 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc 49 | - run: 50 | name: Publish package 51 | command: npx semantic-release@17.0.4 52 | 53 | workflows: 54 | version: 2 55 | 56 | # PULL REQUEST 57 | test: 58 | jobs: 59 | - test: 60 | filters: 61 | branches: 62 | ignore: 63 | - master 64 | 65 | # MERGE TO MASTER 66 | build-test-deploy: 67 | jobs: 68 | - deploy: 69 | filters: 70 | branches: 71 | only: master 72 | -------------------------------------------------------------------------------- /testAuth/basicAuthTest.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const chaiHttp = require('chai-http'); 3 | 4 | chai.use(chaiHttp); 5 | const { expect } = chai; 6 | 7 | const username = 'admin'; 8 | const password = 'admin'; 9 | 10 | // as these are outside any describe, they are global to all tests! 11 | let server; 12 | before(async () => { 13 | process.env.host = '0.0.0.0'; 14 | process.env.port = 5987; 15 | server = require('../server'); // eslint-disable-line 16 | await server.ready(); 17 | }); 18 | after(() => { 19 | server.close(); 20 | }); 21 | 22 | describe('Basic Auth', () => { 23 | it('patients call should fail unauthorized with wrong authentication info', done => { 24 | chai 25 | .request(`http://${process.env.host}:${process.env.port}`) 26 | .get('/patients') 27 | .auth(username, 'aaaaaa') 28 | .then(res => { 29 | expect(res.statusCode).to.equal(401); 30 | done(); 31 | }) 32 | .catch(e => { 33 | done(e); 34 | }); 35 | }); 36 | 37 | it('patients call should fail unauthorized without authentication', done => { 38 | chai 39 | .request(`http://${process.env.host}:${process.env.port}`) 40 | .get('/patients') 41 | .then(res => { 42 | expect(res.statusCode).to.equal(401); 43 | done(); 44 | }) 45 | .catch(e => { 46 | done(e); 47 | }); 48 | }); 49 | 50 | // test successful auth finally as we are not revoking token for now 51 | it('it should GET no patient with basic auth', done => { 52 | chai 53 | .request(`http://${process.env.host}:${process.env.port}`) 54 | .get('/patients') 55 | .auth(username, password) 56 | .then(res => { 57 | expect(res.statusCode).to.equal(200); 58 | expect(res.body).to.be.a('array'); 59 | expect(res.body.length).to.be.eql(0); 60 | done(); 61 | }) 62 | .catch(e => { 63 | done(e); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /plugins/DIMSE.js: -------------------------------------------------------------------------------- 1 | const fp = require('fastify-plugin'); 2 | 3 | const split2 = require('split2'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | const dcmtk = require('../../dcmtk-node')({ 7 | verbose: false, 8 | }); 9 | 10 | async function dimse(fastify, options) { 11 | let storeServer; 12 | fastify.decorate('initDIMSE', async () => { 13 | try { 14 | if (options.aet === undefined || options.port === undefined) 15 | throw new Error('Missing AETitle or Port'); 16 | if (options.tempDir === undefined) { 17 | fastify.log.warn('No tempdirname in options, creating dir named tempDataDir'); 18 | fs.mkdirSync(path.join(__dirname, 'tempDataDir')); 19 | } 20 | 21 | if (!fs.existsSync(path.join(__dirname, options.tempDir))) { 22 | // create directory, no need to fail 23 | fs.mkdirSync(path.join(__dirname, options.tempDir)); 24 | } 25 | 26 | storeServer = dcmtk.storescp({ 27 | args: [ 28 | '-od', 29 | options.tempDir, 30 | '-aet', 31 | options.aet, 32 | '--fork', 33 | options.port, 34 | '--exec-sync', 35 | '--exec-on-reception', 36 | 'echo "file:#f"', 37 | ], 38 | }); 39 | storeServer.on('error', err => { 40 | fastify.log.error(`Error on storescp server: ${err.message}`); 41 | }); 42 | storeServer.on('close', (code, signal) => { 43 | fastify.log.warn(`Closed storescp server with code ${code} and signal ${signal}`); 44 | }); 45 | storeServer.stdout.pipe(split2()).on('data', data => { 46 | if (data.startsWith('file:')) { 47 | const filePath = path.join(__dirname, `../${options.tempDir}/${data.replace('file:', '')}`); 48 | fastify.dbPqueue 49 | .add(() => { 50 | fastify.updateViews(); 51 | fastify.log.info(`Saving DIMSE file ${filePath}`); 52 | return fastify.saveFile(filePath); 53 | }) 54 | .then(() => { 55 | fastify.log.info(`Deleting DIMSE temp file ${filePath}`); 56 | fs.unlinkSync(filePath); 57 | }); 58 | } 59 | }); 60 | fastify.log.info(`DIMSE protocol started listening on port ${options.port}`); 61 | } catch (err) { 62 | fastify.log.warn(`DIMSE protocol starting error: ${err.message}`); 63 | } 64 | }); 65 | 66 | fastify.after(async () => { 67 | try { 68 | await fastify.initDIMSE(); 69 | } catch (err) { 70 | fastify.log.error(`Cannot init DIMSE (err:${err.message}), shutting down the server`); 71 | fastify.close(); 72 | } 73 | // need to add hook for close to remove the db if test; 74 | fastify.addHook('onClose', async (instance, done) => { 75 | storeServer.kill(); 76 | done(); 77 | }); 78 | }); 79 | } 80 | // expose as plugin so the module using it can access the decorated methods 81 | module.exports = fp(dimse); 82 | -------------------------------------------------------------------------------- /config/viewTags.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const studyTags = [ 3 | ['studyUID', '0020000D', '', 'UI', 1], 4 | ['charset', '00080005', '', 'CS', 1], 5 | ['studyDate', '00080020', '', 'DA', 1], 6 | ['studyTime', '00080030', '', 'TM', 1], 7 | ['accessionNumber', '00080050', '', 'SH', 1], 8 | ['instanceAvailability', '00080056', '', 'CS', 1], 9 | ['modalitiesInStudy', '00080061', '', 'CS', 1], 10 | ['referringPhysicianName', '00080090', '', 'PN', 1], 11 | ['timezoneOffsetFromUTC', '00080201', '', 'SH', 0], 12 | ['retrieveURL', '00081190', '', 'UR', 1], 13 | ['patientName', '00100010', '', 'PN', 1], 14 | ['patientID', '00100020', '', 'LO', 1], 15 | ['patientBirthDate', '00100030', '', 'DA', 1], 16 | ['patientSex', '00100040', '', 'CS', 1], 17 | ['studyID', '00200010', '', 'SH', 1], 18 | ['numberOfStudyRelatedSeries', '00201206', '', 'IS', 1], 19 | ['numberOfStudyRelatedInstances', '00201208', '', 'IS', 1], 20 | ['retrieveAETitle', '00080054', '', 'AE', 0], 21 | ['studyDescription', '00081030', '', 'LO', 1], 22 | ]; 23 | 24 | var seriesTags = [ 25 | ['charset', '00080005', '', 'CS', 1,], 26 | ['modality', '00080060', '', 'CS', 1], 27 | ['timezoneOffsetFromUTC', '00080201', '', 'SH', 0], 28 | ['seriesDescription', '0008103E', '', 'LO', 1], 29 | ['retrieveURL', '00081190', '', 'UR', 1], 30 | ['seriesUID', '0020000E', '', 'UI', 1], 31 | ['seriesNumber', '00200011', '', 'IS', 1], 32 | ['numberOfSeriesRelatedInstances', '00201209', '', 'IS', 1], 33 | ['retrieveAETitle', '00080054', '', 'AE', 0], 34 | ['instanceAvailability', '00080056', '', 'CS', 1], 35 | ['studyUID', '0020000D', '', 'UI', 0], 36 | ['patientName', '00100010', '', 'PN', 1], 37 | ['patientID', '00100020', '', 'LO', 1] 38 | ]; 39 | 40 | var instanceTags = [ 41 | ['charset', '00080005', '', 'CS', 1], 42 | ['SOPClassUID', '00080016', '', 'UI', 1], 43 | ['SOPInstanceUID', '00080018', '', 'UI', 1], 44 | ['instanceAvailability', '00080056', '', 'CS', 1], 45 | ['timezoneOffsetFromUTC', '00080201', '', 'SH', 0], 46 | ['retrieveURL', '00081190', '', 'UR', 1], 47 | ['instanceNumber', '00200013', '', 'IS', 1], 48 | ['rows', '00280010', '', 'US', 0], 49 | ['columns', '00280011', '', 'US', 0], 50 | ['bitsAllocated', '00280100', '', 'US', 0], 51 | ['numberOfFrames', '00280008', '', 'IS', 0], 52 | ['studyUID', '0020000D', '', 'UI', 1], 53 | ['seriesUID', '0020000E', '', 'UI', 1], 54 | ['retrieveAETitle', '00080054', '', 'AE', 1] 55 | ]; 56 | 57 | var patientTags = [ 58 | ['institution', '00080080', '', 'CS'], 59 | ['patientID', '00100020', '', 'LO'], 60 | ['patientName', '00100010', '', 'PN'], 61 | ['patientBirthDate', '00100030', '', 'DA'], 62 | ['patientSex', '00100040', '', 'CS'], 63 | ['charset', '00080005', '', 'CS'], 64 | ['retrieveURL', '00081190', '', 'UR'] 65 | ]; 66 | 67 | 68 | module.exports = { 69 | studyTags, 70 | seriesTags, 71 | instanceTags, 72 | patientTags 73 | } -------------------------------------------------------------------------------- /testAuth/oidcAuthTest.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const chaiHttp = require('chai-http'); 3 | 4 | chai.use(chaiHttp); 5 | const { expect } = chai; 6 | 7 | const config = require('../config/index'); 8 | // I need to import this after config as it uses config values 9 | // eslint-disable-next-line import/order 10 | const keycloak = require('keycloak-backend')({ 11 | realm: config.authConfig.realm, // required for verify 12 | 'auth-server-url': config.authConfig.authServerUrl, // required for verify 13 | client_id: config.authConfig.clientId, 14 | client_secret: config.authConfig.clientSecret, 15 | }); 16 | 17 | const username = 'admin'; 18 | const password = 'admin'; 19 | let token = ''; 20 | 21 | // as these are outside any describe, they are global to all tests! 22 | let server; 23 | before(async () => { 24 | process.env.host = '0.0.0.0'; 25 | process.env.port = 5987; 26 | server = require('../server'); // eslint-disable-line 27 | await server.ready(); 28 | }); 29 | after(() => { 30 | server.close(); 31 | }); 32 | 33 | describe('OIDC Auth', () => { 34 | it('patients call should fail unauthorized with wrong authentication token', done => { 35 | chai 36 | .request(`http://${process.env.host}:${process.env.port}`) 37 | .get('/patients') 38 | .set('Authorization', 'Bearer sillytoken') 39 | .then(res => { 40 | expect(res.statusCode).to.equal(401); 41 | done(); 42 | }) 43 | .catch(e => { 44 | done(e); 45 | }); 46 | }); 47 | 48 | it('patients call should fail unauthorized without authentication', done => { 49 | chai 50 | .request(`http://${process.env.host}:${process.env.port}`) 51 | .get('/patients') 52 | .then(res => { 53 | expect(res.statusCode).to.equal(401); 54 | done(); 55 | }) 56 | .catch(e => { 57 | done(e); 58 | }); 59 | }); 60 | 61 | // test successful auth finally as we are not revoking token for now 62 | it('it should authenticate through keycloak to get the token (not API functionality, for debugging purposes only)', done => { 63 | keycloak.accessToken.config.username = username; 64 | keycloak.accessToken.config.password = password; 65 | // see if we can authenticate 66 | // keycloak supports oidc, this is a workaround to support basic authentication 67 | token = keycloak.accessToken 68 | .get() 69 | .then(accessToken => { 70 | token = accessToken; 71 | done(); 72 | }) 73 | .catch(err => done(err)); 74 | }); 75 | 76 | // test successful auth finally as we are not revoking token for now 77 | it('it should GET no patient with oidc auth', done => { 78 | chai 79 | .request(`http://${process.env.host}:${process.env.port}`) 80 | .get('/patients') 81 | .set('Authorization', `Bearer ${token}`) 82 | .then(res => { 83 | expect(res.statusCode).to.equal(200); 84 | expect(res.body).to.be.a('array'); 85 | expect(res.body.length).to.be.eql(0); 86 | done(); 87 | }) 88 | .catch(e => { 89 | done(e); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /config/returnValueFromVR.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var getBulkDataURI = require('./getBulkDataURI'); 3 | var btoa = require('./btoa.js'); 4 | 5 | module.exports = function returnValueFromVR(dataset, field, tag, fieldVR, required, withVR) { 6 | if (!field && fieldVR && !!required) { 7 | if (withVR) { 8 | // Special case for things like numberOfStudyRelatedSeries 9 | // which are built after the view 10 | return { 11 | 'vr': fieldVR 12 | }; 13 | } 14 | return; 15 | } else if (!field) { 16 | return; 17 | } 18 | 19 | var Value = field.Value; 20 | var vr = field.vr; 21 | 22 | var result = ''; 23 | if (withVR) { 24 | result = { 25 | 'vr': vr 26 | }; 27 | } 28 | 29 | var studyUID = 'NA'; 30 | var seriesUID = 'NA'; 31 | var instanceUID = 'NA'; 32 | 33 | if (dataset['0020000D'].Value[0]) { 34 | studyUID = dataset['0020000D'].Value[0]; 35 | } 36 | 37 | if (dataset['0020000E'].Value[0]) { 38 | seriesUID = dataset['0020000E'].Value[0]; 39 | } 40 | 41 | if (dataset['00080018'].Value[0]) { 42 | instanceUID = dataset['00080018'].Value[0]; 43 | } 44 | 45 | switch (vr) { 46 | case "PN": 47 | if (Value && Value[0]) { 48 | if (withVR) { 49 | if (Value[0].Alphabetic) 50 | result.Value = [ 51 | { 52 | Alphabetic: Value[0].Alphabetic, 53 | }, 54 | ]; 55 | else 56 | result.Value = [ 57 | { 58 | "Alphabetic": Value[0] 59 | }, 60 | ]; 61 | } else result = Value; 62 | } 63 | break; 64 | case "DS": 65 | // Note: Some implementations, such as dcm4chee, 66 | // include .0 on all decimal strings, but we don't. 67 | if (withVR) result.Value = Value.map(parseFloat); 68 | else result = Value.map(parseFloat); 69 | break; 70 | case "IS": 71 | if (withVR) 72 | result.Value = Value.map(function(a) { 73 | return parseInt(a, 10); 74 | }); 75 | else 76 | result = Value.map(function(a) { 77 | return parseInt(a, 10); 78 | }); 79 | break; 80 | case "UN": 81 | // TODO: Not sure what the actual limit should be, 82 | // but dcm4chee will use BulkDataURI if the Value 83 | // is too large. We should do the same 84 | if (Value[0].length < 100) { 85 | var converted = btoa(Value[0]); 86 | if (converted) { 87 | if (withVR) result.InlineBinary = converted; 88 | else result = converted; 89 | } 90 | } else { 91 | if (withVR) result.BulkDataURI = getBulkDataURI(studyUID, seriesUID, instanceUID, tag); 92 | else result = getBulkDataURI(studyUID, seriesUID, instanceUID, tag); 93 | } 94 | 95 | break; 96 | case "OW": 97 | if (withVR) result.BulkDataURI = getBulkDataURI(studyUID, seriesUID, instanceUID, tag); 98 | else result = getBulkDataURI(studyUID, seriesUID, instanceUID, tag); 99 | break; 100 | default: 101 | if ( 102 | Value && 103 | Value.length && 104 | !(Value.length === 1 && (Value[0] === undefined || Value[0] === '')) 105 | ) { 106 | if (withVR) result.Value = Value; 107 | else result = Value; 108 | } 109 | } 110 | 111 | return result; 112 | }; 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dicomweb-server 2 | Lightweight DICOMweb Server with CouchDB 3 | 4 | *Note: this is a work in progress and not intended for production or clinical use.* 5 | 6 | More background information can found in https://na-mic.github.io/ProjectWeek/PW30_2019_GranCanaria/Projects/DICOMweb-CouchDB/ 7 | 8 | ## Architecture 9 | 10 | The dicomweb-server is a fastify server that speaks DICOMweb to clients and fullfills their requests using CouchDB or other plugin services. 11 | ![Overall design](image.png) 12 | 13 | ## Authentification 14 | 15 | By default, the authentication is none and the application mode is development. 16 | You can change the authentication method by changing the auth attribute in config/development.js 17 | The value you put in should be the name of a json file in the config directory. A sample config for authentication should have the following information 18 | 19 | 20 | `{ 21 | "realm": "your-realm", 22 | "authServerUrl": "your-auth-server-port-and-port", 23 | "clientId": "your-client-id", 24 | "clientSecret": "your-secret" 25 | }` 26 | 27 | If using the default authentication of couchdb with an admin account, you will need to specify the admin username and password in config/development.js in the below style: 28 | 29 | `{ 30 | dbServer: process.env.DB_SERVER || 'http://username:password@localhost' 31 | }` 32 | 33 | 34 | 35 | ## Installation 36 | 37 | ``` 38 | git clone git://github.com/dcmjs-org/dicomweb-server 39 | 40 | cd dicomweb-server 41 | npm install 42 | ``` 43 | 44 | Install [CouchDB](http://couchdb.apache.org/). 45 | 46 | Initially your CouchDB database starts empty, but dicomweb-server will set up the internal database 47 | and design documents so there is no need to configure it. 48 | 49 | You can run tests by running `npm test`. 50 | 51 | ## Running 52 | 53 | Be sure to have [CouchDB](http://couchdb.apache.org/) running at localhost:5984 (the default), then start the dicomweb-server: 54 | 55 | ``` 56 | npm start 57 | ``` 58 | 59 | ## Usage 60 | 61 | The server should be ultimately compatible with any DICOMweb client library. 62 | 63 | We test with a Python implementation [dicomweb_client](https://github.com/clindatsci/dicomweb-client). 64 | 65 | Get study list: 66 | 67 | `dicomweb_client --url http://localhost:5985 search studies` 68 | 69 | Store a DATA_DIRECTORY of DICOM image files (here with the ".IMA" extension). Adjust the command line to match the location and naming of your files. (The `-n25` option to xargs is for batching files, leading to fewer calls and thus less overhead.) 70 | 71 | `find DATA_DIRECTORY -iname \*.IMA -print0 | xargs -0 -n25 dicomweb_client --url http://localhost:5985 store instances` 72 | 73 | 74 | ## Use with a viewer 75 | 76 | It's possible to use this server as a backend to the [OHIF Viewer](http://ohif.org) using a configuration like this. (See [this file](https://github.com/OHIF/Viewers/blob/master/platform/viewer/public/config/default.js#L1-L31)). 77 | 78 | ``` 79 | const dicomweb_serverConfig = { 80 | routerBasename: "/ohif", 81 | rootUrl: "http://localhost:2016/ohif", 82 | servers: { 83 | "dicomWeb": [ 84 | { 85 | "name": "dicomweb_server", 86 | "wadoUriRoot": "http://localhost:5985", 87 | "qidoRoot": "http://localhost:5985", 88 | "wadoRoot": "http://localhost:5985", 89 | "qidoSupportsIncludeField": true, 90 | "imageRendering": "wadouri", 91 | "thumbnailRendering": "wadors", 92 | "requestOptions": { 93 | "requestFromBrowser": true 94 | } 95 | }, 96 | ] 97 | } 98 | }; 99 | ``` 100 | 101 | Note that currently the `imageRendering` option must be `wadouri` 102 | -------------------------------------------------------------------------------- /config/schemas/patients_output_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "patients_schema", 3 | "type": "array", 4 | "items": 5 | { 6 | "type": "object", 7 | "properties": { 8 | "00080005": { 9 | "type": "object", 10 | "properties": { 11 | "Value": { 12 | "type": "array", 13 | "items": 14 | { 15 | "type": "string" 16 | } 17 | }, 18 | "vr": { 19 | "type": "string" 20 | } 21 | }, 22 | "required": [ 23 | "vr" 24 | ] 25 | }, 26 | "00081190": { 27 | "type": "object", 28 | "properties": { 29 | "Value": { 30 | "type": "array", 31 | "items": 32 | { 33 | "type": "string" 34 | } 35 | }, 36 | "vr": { 37 | "type": "string" 38 | } 39 | }, 40 | "required": [ 41 | "vr" 42 | ] 43 | }, 44 | "00080080": { 45 | "type": "object", 46 | "properties": { 47 | "Value": { 48 | "type": "array", 49 | "items": 50 | { 51 | "type": "string" 52 | } 53 | }, 54 | "vr": { 55 | "type": "string" 56 | } 57 | }, 58 | "required": [ 59 | "vr" 60 | ] 61 | }, 62 | "00100020": { 63 | "type": "object", 64 | "properties": { 65 | "Value": { 66 | "type": "array", 67 | "items": 68 | { 69 | "type": "string" 70 | } 71 | }, 72 | "vr": { 73 | "type": "string" 74 | } 75 | }, 76 | "required": [ 77 | "vr" 78 | ] 79 | }, 80 | "00100010": { 81 | "type": "object", 82 | "properties": { 83 | "Value": { 84 | "type": "array", 85 | "items": 86 | { 87 | "type": "object", 88 | "properties": { 89 | "Alphabetic": { 90 | "type": "string" 91 | } 92 | } 93 | } 94 | }, 95 | "vr": { 96 | "type": "string" 97 | } 98 | }, 99 | "required": [ 100 | "vr" 101 | ] 102 | }, 103 | "00101030": { 104 | "type": "object", 105 | "properties": { 106 | "Value": { 107 | "type": "array", 108 | "items": 109 | { 110 | "type": "string" 111 | } 112 | }, 113 | "vr": { 114 | "type": "string" 115 | } 116 | }, 117 | "required": [ 118 | "vr" 119 | ] 120 | }, 121 | "00100040": { 122 | "type": "object", 123 | "properties": { 124 | "Value": { 125 | "type": "array", 126 | "items": 127 | { 128 | "type": "string" 129 | } 130 | }, 131 | "vr": { 132 | "type": "string" 133 | } 134 | }, 135 | "required": [ 136 | "vr" 137 | ] 138 | } 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /routes/wado.js: -------------------------------------------------------------------------------- 1 | // defines WADO routes 2 | async function wadoRoutes(fastify) { 3 | // WADO URI Retrieve Instance 4 | // GET {s}?{querystring} 5 | fastify.route({ 6 | method: 'GET', 7 | url: '/', 8 | schema: { 9 | querystring: { 10 | type: 'object', 11 | properties: { 12 | studyUID: { 13 | type: 'string', 14 | }, 15 | seriesUID: { 16 | type: 'string', 17 | }, 18 | objectUID: { 19 | type: 'string', 20 | }, 21 | contentType: { 22 | type: 'string', 23 | }, 24 | transferSyntax: { 25 | type: 'string', 26 | }, 27 | frame: { 28 | type: 'string', 29 | }, 30 | }, 31 | required: ['objectUID'], 32 | }, 33 | }, 34 | 35 | handler: fastify.retrieveInstance, 36 | }); 37 | 38 | // WADO Retrieve Instance 39 | // GET {s}/studies/{study}/series/{series}/instances/{instance} 40 | fastify.route({ 41 | method: 'GET', 42 | url: '/studies/:study/series/:series/instances/:instance', 43 | schema: { 44 | params: { 45 | type: 'object', 46 | properties: { 47 | study: { 48 | type: 'string', 49 | }, 50 | series: { 51 | type: 'string', 52 | }, 53 | instance: { 54 | type: 'string', 55 | }, 56 | }, 57 | }, 58 | }, 59 | 60 | handler: fastify.retrieveInstanceRS, 61 | }); 62 | 63 | // WADO Retrieve Instance frame 64 | // GET {s}/studies/{study}/series/{series}/instances/{instance}/frames/{frames} 65 | fastify.route({ 66 | method: 'GET', 67 | url: '/studies/:study/series/:series/instances/:instance/frames/:frames', 68 | schema: { 69 | params: { 70 | type: 'object', 71 | properties: { 72 | study: { 73 | type: 'string', 74 | }, 75 | series: { 76 | type: 'string', 77 | }, 78 | instance: { 79 | type: 'string', 80 | }, 81 | frames: { 82 | type: 'string', 83 | }, 84 | }, 85 | }, 86 | }, 87 | 88 | handler: fastify.retrieveInstanceFrames, 89 | }); 90 | 91 | // WADO Retrieve Study Metadata 92 | // GET {s}/studies/{study}/metadata 93 | fastify.route({ 94 | method: 'GET', 95 | url: '/studies/:study/metadata', 96 | schema: { 97 | params: { 98 | type: 'object', 99 | properties: { 100 | study: { 101 | type: 'string', 102 | }, 103 | }, 104 | }, 105 | }, 106 | 107 | handler: fastify.getStudyMetadata, 108 | }); 109 | 110 | // WADO Retrieve Series Metadata 111 | // GET {s}/studies/{study}/series/{series}/metadata 112 | fastify.route({ 113 | method: 'GET', 114 | url: '/studies/:study/series/:series/metadata', 115 | schema: { 116 | params: { 117 | type: 'object', 118 | properties: { 119 | study: { 120 | type: 'string', 121 | }, 122 | series: { 123 | type: 'string', 124 | }, 125 | }, 126 | }, 127 | }, 128 | 129 | handler: fastify.getSeriesMetadata, 130 | }); 131 | 132 | // WADO Retrieve Instance Metadata 133 | // GET {s}/studies/{study}/series/{series}/instances/{instance}/metadata 134 | fastify.route({ 135 | method: 'GET', 136 | url: '/studies/:study/series/:series/instances/:instance/metadata', 137 | schema: { 138 | params: { 139 | type: 'object', 140 | properties: { 141 | study: { 142 | type: 'string', 143 | }, 144 | series: { 145 | type: 'string', 146 | }, 147 | instance: { 148 | type: 'string', 149 | }, 150 | }, 151 | }, 152 | }, 153 | 154 | handler: fastify.getInstanceMetadata, 155 | }); 156 | 157 | fastify.route({ 158 | method: 'GET', 159 | url: '/studies/:study', 160 | schema: { 161 | params: { 162 | type: 'object', 163 | properties: { 164 | study: { 165 | type: 'string', 166 | }, 167 | }, 168 | }, 169 | }, 170 | handler: fastify.getWado, 171 | }); 172 | 173 | fastify.route({ 174 | method: 'GET', 175 | url: '/studies/:study/series/:series', 176 | schema: { 177 | params: { 178 | type: 'object', 179 | properties: { 180 | study: { 181 | type: 'string', 182 | }, 183 | series: { 184 | type: 'string', 185 | }, 186 | }, 187 | }, 188 | }, 189 | handler: fastify.getWado, 190 | }); 191 | } 192 | 193 | module.exports = wadoRoutes; 194 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | epad-feedback@lists.stanford.edu. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 121 | 122 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 123 | enforcement ladder](https://github.com/mozilla/diversity). 124 | 125 | [homepage]: https://www.contributor-covenant.org 126 | 127 | For answers to common questions about this code of conduct, see the FAQ at 128 | https://www.contributor-covenant.org/faq. Translations are available at 129 | https://www.contributor-covenant.org/translations. 130 | 131 | -------------------------------------------------------------------------------- /config/schemas/series_output_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "series_schema", 3 | "type": "array", 4 | "items": 5 | { 6 | "type": "object", 7 | "properties": { 8 | "00080005": { 9 | "type": "object", 10 | "properties": { 11 | "vr": { 12 | "type": "string" 13 | }, 14 | "Value": { 15 | "type": "array", 16 | "items": 17 | { 18 | "type": "string" 19 | } 20 | } 21 | }, 22 | "required": [ 23 | "vr" 24 | ] 25 | }, 26 | "00080054": { 27 | "type": "object", 28 | "properties": { 29 | "vr": { 30 | "type": "string" 31 | }, 32 | "Value": { 33 | "type": "array", 34 | "items": 35 | { 36 | "type": "string" 37 | } 38 | } 39 | }, 40 | "required": [ 41 | "vr" 42 | ] 43 | }, 44 | "00080056": { 45 | "type": "object", 46 | "properties": { 47 | "vr": { 48 | "type": "string" 49 | }, 50 | "Value": { 51 | "type": "array", 52 | "items": 53 | { 54 | "type": "string" 55 | } 56 | } 57 | }, 58 | "required": [ 59 | "vr" 60 | ] 61 | }, 62 | "00080060": { 63 | "type": "object", 64 | "properties": { 65 | "vr": { 66 | "type": "string" 67 | }, 68 | "Value": { 69 | "type": "array", 70 | "items": 71 | { 72 | "type": "string" 73 | } 74 | } 75 | }, 76 | "required": [ 77 | "vr" 78 | ] 79 | }, 80 | "0008103E": { 81 | "type": "object", 82 | "properties": { 83 | "vr": { 84 | "type": "string" 85 | }, 86 | "Value": { 87 | "type": "array", 88 | "items": 89 | { 90 | "type": "string" 91 | } 92 | } 93 | }, 94 | "required": [ 95 | "vr" 96 | ] 97 | }, 98 | "00081190": { 99 | "type": "object", 100 | "properties": { 101 | "vr": { 102 | "type": "string" 103 | }, 104 | "Value": { 105 | "type": "array", 106 | "items": 107 | { 108 | "type": "string" 109 | } 110 | } 111 | }, 112 | "required": [ 113 | "vr" 114 | ] 115 | }, 116 | "0020000D": { 117 | "type": "object", 118 | "properties": { 119 | "vr": { 120 | "type": "string" 121 | }, 122 | "Value": { 123 | "type": "array", 124 | "items": 125 | { 126 | "type": "string" 127 | } 128 | } 129 | }, 130 | "required": [ 131 | "vr" 132 | ] 133 | }, 134 | "0020000E": { 135 | "type": "object", 136 | "properties": { 137 | "vr": { 138 | "type": "string" 139 | }, 140 | "Value": { 141 | "type": "array", 142 | "items": 143 | { 144 | "type": "string" 145 | } 146 | } 147 | }, 148 | "required": [ 149 | "vr" 150 | ] 151 | }, 152 | "00200011": { 153 | "type": "object", 154 | "properties": { 155 | "vr": { 156 | "type": "string" 157 | }, 158 | "Value": { 159 | "type": "array", 160 | "items": 161 | { 162 | "type": "string" 163 | } 164 | } 165 | }, 166 | "required": [ 167 | "vr" 168 | ] 169 | }, 170 | "00201209": { 171 | "type": "object", 172 | "properties": { 173 | "vr": { 174 | "type": "string" 175 | }, 176 | "Value": { 177 | "type": "array", 178 | "items": 179 | { 180 | "type": "integer" 181 | } 182 | } 183 | }, 184 | "required": [ 185 | "vr" 186 | ] 187 | }, 188 | "00100020": { 189 | "type": "object", 190 | "properties": { 191 | "vr": { 192 | "type": "string" 193 | }, 194 | "Value": { 195 | "type": "array", 196 | "items": 197 | { 198 | "type": "string" 199 | } 200 | } 201 | }, 202 | "required": [ 203 | "vr" 204 | ] 205 | }, 206 | "00100010": { 207 | "type": "object", 208 | "properties": { 209 | "vr": { 210 | "type": "string" 211 | }, 212 | "Value": { 213 | "type": "array", 214 | "items": 215 | { 216 | "type": "object", 217 | "properties": { 218 | "Alphabetic": { 219 | "type": "string" 220 | } 221 | }, 222 | "required": [ 223 | "Alphabetic" 224 | ] 225 | } 226 | } 227 | }, 228 | "required": [ 229 | "vr" 230 | ] 231 | } 232 | }, 233 | "required": [ 234 | "00080005", 235 | "00080056", 236 | "00080060", 237 | "0008103E", 238 | "00081190", 239 | "0020000D", 240 | "0020000E", 241 | "00200011", 242 | "00201209" 243 | ] 244 | } 245 | } -------------------------------------------------------------------------------- /config/schemas/instances_output_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "instances_schema", 3 | "type": "array", 4 | "items": 5 | { 6 | "type": "object", 7 | "properties": { 8 | "00080005": { 9 | "type": "object", 10 | "properties": { 11 | "vr": { 12 | "type": "string" 13 | }, 14 | "Value": { 15 | "type": "array", 16 | "items": 17 | { 18 | "type": "string" 19 | } 20 | } 21 | }, 22 | "required": [ 23 | "vr" 24 | ] 25 | }, 26 | "00080016": { 27 | "type": "object", 28 | "properties": { 29 | "vr": { 30 | "type": "string" 31 | }, 32 | "Value": { 33 | "type": "array", 34 | "items": 35 | { 36 | "type": "string" 37 | } 38 | } 39 | }, 40 | "required": [ 41 | "vr" 42 | ] 43 | }, 44 | "00080018": { 45 | "type": "object", 46 | "properties": { 47 | "vr": { 48 | "type": "string" 49 | }, 50 | "Value": { 51 | "type": "array", 52 | "items": 53 | { 54 | "type": "string" 55 | } 56 | } 57 | }, 58 | "required": [ 59 | "vr" 60 | ] 61 | }, 62 | "00080054": { 63 | "type": "object", 64 | "properties": { 65 | "vr": { 66 | "type": "string" 67 | }, 68 | "Value": { 69 | "type": "array", 70 | "items": 71 | { 72 | "type": "string" 73 | } 74 | } 75 | }, 76 | "required": [ 77 | "vr" 78 | ] 79 | }, 80 | "00080056": { 81 | "type": "object", 82 | "properties": { 83 | "vr": { 84 | "type": "string" 85 | }, 86 | "Value": { 87 | "type": "array", 88 | "items": 89 | { 90 | "type": "string" 91 | } 92 | } 93 | }, 94 | "required": [ 95 | "vr" 96 | ] 97 | }, 98 | "00081190": { 99 | "type": "object", 100 | "properties": { 101 | "vr": { 102 | "type": "string" 103 | }, 104 | "Value": { 105 | "type": "array", 106 | "items": 107 | { 108 | "type": "string" 109 | } 110 | } 111 | }, 112 | "required": [ 113 | "vr" 114 | ] 115 | }, 116 | "0020000D": { 117 | "type": "object", 118 | "properties": { 119 | "vr": { 120 | "type": "string" 121 | }, 122 | "Value": { 123 | "type": "array", 124 | "items": 125 | { 126 | "type": "string" 127 | } 128 | } 129 | }, 130 | "required": [ 131 | "vr" 132 | ] 133 | }, 134 | "0020000E": { 135 | "type": "object", 136 | "properties": { 137 | "vr": { 138 | "type": "string" 139 | }, 140 | "Value": { 141 | "type": "array", 142 | "items": 143 | { 144 | "type": "string" 145 | } 146 | } 147 | }, 148 | "required": [ 149 | "vr" 150 | ] 151 | }, 152 | "00200013": { 153 | "type": "object", 154 | "properties": { 155 | "vr": { 156 | "type": "string" 157 | }, 158 | "Value": { 159 | "type": "array", 160 | "items": 161 | { 162 | "type": "integer" 163 | } 164 | } 165 | }, 166 | "required": [ 167 | "vr" 168 | ] 169 | }, 170 | "00280010": { 171 | "type": "object", 172 | "properties": { 173 | "vr": { 174 | "type": "string" 175 | }, 176 | "Value": { 177 | "type": "array", 178 | "items": 179 | { 180 | "type": "integer" 181 | } 182 | } 183 | }, 184 | "required": [ 185 | "vr" 186 | ] 187 | }, 188 | "00280011": { 189 | "type": "object", 190 | "properties": { 191 | "vr": { 192 | "type": "string" 193 | }, 194 | "Value": { 195 | "type": "array", 196 | "items": 197 | { 198 | "type": "integer" 199 | } 200 | } 201 | }, 202 | "required": [ 203 | "vr" 204 | ] 205 | }, 206 | "00280100": { 207 | "type": "object", 208 | "properties": { 209 | "vr": { 210 | "type": "string" 211 | }, 212 | "Value": { 213 | "type": "array", 214 | "items": 215 | { 216 | "type": "integer" 217 | } 218 | } 219 | }, 220 | "required": [ 221 | "vr" 222 | ] 223 | }, 224 | "00280008": { 225 | "type": "object", 226 | "properties": { 227 | "vr": { 228 | "type": "string" 229 | }, 230 | "Value": { 231 | "type": "array", 232 | "items": 233 | { 234 | "type": "integer" 235 | } 236 | } 237 | }, 238 | "required": [ 239 | "vr" 240 | ] 241 | } 242 | }, 243 | "required": [ 244 | "00080005", 245 | "00080016", 246 | "00080018", 247 | "00080054", 248 | "00080056", 249 | "00081190", 250 | "0020000D", 251 | "0020000E", 252 | "00200013" 253 | ] 254 | } 255 | } -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const path = require('path'); 3 | // eslint-disable-next-line import/order 4 | const config = require('./config/index'); 5 | const { default: PQueue } = require('p-queue'); 6 | // Require the framework and instantiate it 7 | const fastify = require('fastify')({ 8 | logger: config.logger || false, 9 | https: 10 | config.https === true && 11 | fs.existsSync(path.join(__dirname, 'tls.key')) && 12 | fs.existsSync(path.join(__dirname, 'tls.crt')) 13 | ? { 14 | key: fs.readFileSync(path.join(__dirname, 'tls.key')), 15 | cert: fs.readFileSync(path.join(__dirname, 'tls.crt')), 16 | } 17 | : '', 18 | }); 19 | 20 | const atob = require('atob'); 21 | 22 | // I need to import this after config as it uses config values 23 | const keycloak = require('keycloak-backend')({ 24 | realm: config.authConfig.realm, // required for verify 25 | 'auth-server-url': config.authConfig.authServerUrl, // required for verify 26 | client_id: config.authConfig.clientId, 27 | client_secret: config.authConfig.clientSecret, 28 | }); 29 | 30 | const { InternalError, ResourceNotFoundError } = require('./utils/Errors'); 31 | 32 | fastify.addContentTypeParser('*', (req, done) => { 33 | let data = []; 34 | req.on('data', chunk => { 35 | data.push(chunk); 36 | }); 37 | req.on('end', () => { 38 | data = Buffer.concat(data); 39 | done(null, data); 40 | }); 41 | }); 42 | 43 | // require schema jsons 44 | const patientsSchema = require('./config/schemas/patients_output_schema.json'); 45 | const studiesSchema = require('./config/schemas/studies_output_schema.json'); 46 | const seriesSchema = require('./config/schemas/series_output_schema.json'); 47 | const instancesSchema = require('./config/schemas/instances_output_schema.json'); 48 | 49 | // add schemas to fastify to use by id 50 | fastify.addSchema(patientsSchema); 51 | fastify.addSchema(studiesSchema); 52 | fastify.addSchema(seriesSchema); 53 | fastify.addSchema(instancesSchema); 54 | 55 | // enable cors 56 | fastify.register(require('fastify-cors'), { 57 | origin: '*', 58 | }); 59 | 60 | // register CouchDB plugin we created 61 | fastify.register(require('./plugins/CouchDB'), { 62 | url: `${config.dbServer}:${config.dbPort}`, 63 | }); 64 | 65 | // register DIMSE plugin we created 66 | if (config.DIMSE && fs.existsSync(path.join(__dirname, '../dcmtk-node'))) { 67 | // eslint-disable-next-line global-require 68 | fastify.register(require('./plugins/DIMSE'), { 69 | tempDir: config.DIMSE.tempDir, 70 | aet: config.DIMSE.AET, 71 | port: config.DIMSE.port, 72 | }); 73 | } else { 74 | config.DIMSE = undefined; 75 | fastify.log.warn( 76 | 'DIMSE is not supported. Either it is not enabled or dcmtk-node not available in the same directory with dicomweb-server' 77 | ); 78 | } 79 | // register routes 80 | // this should be done after CouchDB plugin to be able to use the accessor methods 81 | fastify.register(require('./routes/qido'), { prefix: config.prefix }); // eslint-disable-line global-require 82 | fastify.register(require('./routes/wado'), { prefix: config.prefix }); // eslint-disable-line global-require 83 | fastify.register(require('./routes/stow'), { prefix: config.prefix }); // eslint-disable-line global-require 84 | fastify.register(require('./routes/other'), { prefix: config.prefix }); // eslint-disable-line global-require 85 | 86 | // authCheck routine checks if there is a bearer token or encoded basic authentication 87 | // info in the authorization header and does the authentication or verification of token 88 | // in keycloak 89 | const authCheck = async (authHeader, res) => { 90 | if (authHeader.startsWith('Bearer ')) { 91 | // Extract the token 92 | const token = authHeader.slice(7, authHeader.length); 93 | if (token) { 94 | // verify token online 95 | try { 96 | const verifyToken = await keycloak.jwt.verify(token); 97 | if (verifyToken.isExpired()) { 98 | res.code(401).send({ 99 | message: 'Token is expired', 100 | }); 101 | } 102 | } catch (e) { 103 | res.code(401).send({ 104 | message: e.message, 105 | }); 106 | } 107 | } 108 | } else if (authHeader.startsWith('Basic ')) { 109 | // Extract the encoded part 110 | const authToken = authHeader.slice(6, authHeader.length); 111 | if (authToken) { 112 | // Decode and extract username and password 113 | const auth = atob(authToken); 114 | const [username, password] = auth.split(':'); 115 | // put the username and password in keycloak object 116 | keycloak.accessToken.config.username = username; 117 | keycloak.accessToken.config.password = password; 118 | try { 119 | // see if we can authenticate 120 | // keycloak supports oidc, this is a workaround to support basic authentication 121 | const accessToken = await keycloak.accessToken.get(); 122 | if (!accessToken) { 123 | res.code(401).send({ 124 | message: 'Authentication unsuccessful', 125 | }); 126 | } 127 | } catch (err) { 128 | res.code(401).send({ 129 | message: `Authentication error ${err.message}`, 130 | }); 131 | } 132 | } 133 | } else { 134 | res.code(401).send({ 135 | message: 'Bearer token does not exist', 136 | }); 137 | } 138 | }; 139 | 140 | fastify.decorate('auth', async (req, res) => { 141 | if (config.auth && config.auth !== 'none') { 142 | // if auth has been given in config, verify authentication 143 | fastify.log.info('Request needs to be authenticated, checking the authorization header'); 144 | const authHeader = req.headers['x-access-token'] || req.headers.authorization; 145 | if (authHeader) { 146 | await authCheck(authHeader, res); 147 | } else { 148 | res.code(401).send({ 149 | message: 'Authentication info does not exist or conform with the server', 150 | }); 151 | } 152 | } 153 | }); 154 | 155 | // add authentication prehandler, all requests need to be authenticated 156 | fastify.addHook('preHandler', fastify.auth); 157 | fastify.addHook('onError', (request, reply, error, done) => { 158 | if (error instanceof ResourceNotFoundError) reply.code(404); 159 | else if (error instanceof InternalError) reply.code(500); 160 | fastify.log.error(error.message); 161 | done(); 162 | }); 163 | 164 | fastify.log.info( 165 | `Starting a promise queue with ${config.maxConcurrent} concurrent promisses for managing couchdb operations` 166 | ); 167 | const dbPq = new PQueue({ concurrency: config.maxConcurrent }); 168 | let count = 0; 169 | dbPq.on('active', () => { 170 | count += 1; 171 | fastify.log.info( 172 | `P-queue working on item #${count}. Size: ${dbPq.size} Pending: ${dbPq.pending}` 173 | ); 174 | }); 175 | fastify.decorate('dbPqueue', dbPq); 176 | 177 | const port = process.env.port || '5985'; 178 | const host = process.env.host || '0.0.0.0'; 179 | // Run the server! 180 | fastify.listen(port, host); 181 | 182 | module.exports = fastify; 183 | -------------------------------------------------------------------------------- /test/stowTest.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const chaiHttp = require('chai-http'); 3 | const fs = require('fs'); 4 | const config = require('../config/index'); 5 | 6 | chai.use(chaiHttp); 7 | const { expect } = chai; 8 | 9 | // as these are outside any describe, they are global to all tests! 10 | let server; 11 | before(async () => { 12 | process.env.host = '0.0.0.0'; 13 | process.env.port = 5987; 14 | server = require('../server'); // eslint-disable-line 15 | await server.ready(); 16 | }); 17 | after(() => { 18 | server.close(); 19 | }); 20 | 21 | describe('STOW Tests', () => { 22 | it('studies should be empty', done => { 23 | chai 24 | .request(`http://${process.env.host}:${process.env.port}`) 25 | .get(`${config.prefix}/studies`) 26 | .then(res => { 27 | if (res.statusCode >= 400) { 28 | done(new Error(res.body.error, res.body.message)); 29 | 30 | return; 31 | } 32 | 33 | expect(res.statusCode).to.equal(200); 34 | expect(res.body).to.be.a('array'); 35 | expect(res.body.length).to.be.eql(0); 36 | done(); 37 | }) 38 | .catch(e => { 39 | done(e); 40 | }); 41 | }); 42 | 43 | const binaryParser = (res, cb) => { 44 | res.setEncoding('binary'); 45 | res.data = ''; 46 | res.on('data', chunk => { 47 | res.data += chunk; 48 | }); 49 | res.on('end', () => { 50 | cb(null, Buffer.from(res.data, 'binary')); 51 | }); 52 | }; 53 | 54 | it('wado image should not exist', done => { 55 | chai 56 | .request(`http://${process.env.host}:${process.env.port}`) 57 | .get( 58 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series/1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126/instances/1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694` 59 | ) 60 | .buffer() 61 | .parse(binaryParser) 62 | .end((err, res) => { 63 | if (err) { 64 | done(err); 65 | } 66 | expect(res.statusCode).to.equal(404); 67 | done(); 68 | }); 69 | }); 70 | 71 | it(`linkFolder should not work for data folder when called with process host`, done => { 72 | chai 73 | .request(`http://${process.env.host}:${process.env.port}`) 74 | .post(`${config.prefix}/linkFolder?path=test/data`) 75 | .send() 76 | .then(res => { 77 | expect(res.statusCode).to.equal(400); 78 | done(); 79 | }) 80 | .catch(e => { 81 | done(e); 82 | }); 83 | }); 84 | 85 | it('linkFolder should work for data folder with localhost', done => { 86 | chai 87 | .request(`http://localhost:${process.env.port}`) 88 | .post(`${config.prefix}/linkFolder?path=test/data`) 89 | .send() 90 | .then(res => { 91 | expect(res.statusCode).to.equal(200); 92 | done(); 93 | }) 94 | .catch(e => { 95 | done(e); 96 | }); 97 | }); 98 | 99 | it('wado image should return correct amount of data', done => { 100 | chai 101 | .request(`http://${process.env.host}:${process.env.port}`) 102 | .get( 103 | `${config.prefix}/?requestType=WADO&studyUID=1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313&seriesUID=1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126&objectUID=1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694` 104 | ) 105 | .buffer() 106 | .parse(binaryParser) 107 | .end((err, res) => { 108 | if (err) { 109 | done(err); 110 | } 111 | if (res.statusCode >= 400) { 112 | done(new Error(res.body.error, res.body.message)); 113 | 114 | return; 115 | } 116 | expect(res.statusCode).to.equal(200); 117 | const { size } = fs.statSync('test/data/image.dcm'); 118 | expect(Buffer.byteLength(res.body)).to.equal(size); 119 | done(); 120 | }); 121 | }); 122 | 123 | it('stow should succeed with multipart study', done => { 124 | const buffer = fs.readFileSync('test/data/multipart_study'); 125 | chai 126 | .request(`http://${process.env.host}:${process.env.port}`) 127 | .post(`${config.prefix}/studies`) 128 | .set( 129 | 'content-type', 130 | 'multipart/related; type=application/dicom; boundary=--594b1491-fdae-4585-9b48-4d7cd999edb3' 131 | ) 132 | .send(buffer) 133 | .then(res => { 134 | if (res.statusCode >= 400) { 135 | done(new Error(res.body.error, res.body.message)); 136 | 137 | return; 138 | } 139 | 140 | expect(res.statusCode).to.equal(200); 141 | done(); 142 | }) 143 | .catch(e => { 144 | done(e); 145 | }); 146 | }); 147 | 148 | it('wado study should return correct amount of data', done => { 149 | chai 150 | .request(`http://${process.env.host}:${process.env.port}`) 151 | .get( 152 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313?format=stream` 153 | ) 154 | .buffer() 155 | .parse(binaryParser) 156 | .end((err, res) => { 157 | if (err) { 158 | done(err); 159 | } 160 | if (res.statusCode >= 400) { 161 | done(new Error(res.body.error, res.body.message)); 162 | 163 | return; 164 | } 165 | expect(res.statusCode).to.equal(200); 166 | expect(Buffer.byteLength(res.body)).to.equal(Number(res.header['content-length'])); 167 | expect(Buffer.byteLength(res.body)).to.equal(9570313); 168 | done(); 169 | }); 170 | }); 171 | 172 | it('wado series should return correct amount of data', done => { 173 | chai 174 | .request(`http://${process.env.host}:${process.env.port}`) 175 | .get( 176 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series/1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126?format=stream` 177 | ) 178 | .buffer() 179 | .parse(binaryParser) 180 | .end((err, res) => { 181 | if (err) { 182 | done(err); 183 | } 184 | if (res.statusCode >= 400) { 185 | done(new Error(res.body.error, res.body.message)); 186 | 187 | return; 188 | } 189 | expect(res.statusCode).to.equal(200); 190 | expect(Buffer.byteLength(res.body)).to.equal(Number(res.header['content-length'])); 191 | expect(Buffer.byteLength(res.body)).to.equal(9486962); 192 | done(); 193 | }); 194 | }); 195 | 196 | it('stow should fail with dicom file', done => { 197 | const buffer = fs.readFileSync('test/data/image.dcm'); 198 | chai 199 | .request(`http://${process.env.host}:${process.env.port}`) 200 | .post(`${config.prefix}/studies`) 201 | .set( 202 | 'content-type', 203 | 'multipart; type=application/dicom; boundary=--594b1491-fdae-4585-9b48-4d7cd999edb3' 204 | ) 205 | .send(buffer) 206 | .then(res => { 207 | expect(res.statusCode).to.equal(500); 208 | done(); 209 | }) 210 | .catch(e => { 211 | done(e); 212 | }); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /test/wadoTest.js: -------------------------------------------------------------------------------- 1 | require('./stowTest'); 2 | const chai = require('chai'); 3 | const chaiHttp = require('chai-http'); 4 | const config = require('../config/index'); 5 | 6 | chai.use(chaiHttp); 7 | const { expect } = chai; 8 | 9 | const binaryParser = (res, cb) => { 10 | res.setEncoding('binary'); 11 | res.data = ''; 12 | res.on('data', chunk => { 13 | res.data += chunk; 14 | }); 15 | res.on('end', () => { 16 | cb(null, Buffer.from(res.data, 'binary')); 17 | }); 18 | }; 19 | 20 | describe('WADO Tests', () => { 21 | it('it should get dicom file for instance 1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694', done => { 22 | chai 23 | .request(`http://${process.env.host}:${process.env.port}`) 24 | .get( 25 | `${config.prefix}/?requestType=WADO&studyUID=1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313&seriesUID=1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126&objectUID=1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694` 26 | ) 27 | .buffer() 28 | .parse(binaryParser) 29 | .then(res => { 30 | if (res.statusCode >= 400) { 31 | done(new Error(res.body.error, res.body.message)); 32 | 33 | return; 34 | } 35 | 36 | expect(res.statusCode).to.equal(200); 37 | expect(res).to.have.header( 38 | 'Content-Disposition', 39 | 'attachment; filename=1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694.dcm' 40 | ); 41 | expect(Buffer.byteLength(res.body)).to.equal(526934); 42 | done(); 43 | }) 44 | .catch(e => { 45 | done(e); 46 | }); 47 | }); 48 | 49 | it('it should get first frame of dicom file for instance 1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694', done => { 50 | chai 51 | .request(`http://${process.env.host}:${process.env.port}`) 52 | .get( 53 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series/1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126/instances/1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694/frames/1` 54 | ) 55 | .buffer() 56 | .parse(binaryParser) 57 | .then(res => { 58 | if (res.statusCode >= 400) { 59 | done(new Error(res.body.error, res.body.message)); 60 | 61 | return; 62 | } 63 | 64 | expect(res.statusCode).to.equal(200); 65 | expect(Buffer.byteLength(res.body)).to.equal(Number(res.header['content-length'])); 66 | expect(Buffer.byteLength(res.body)).to.equal(524414); 67 | done(); 68 | }) 69 | .catch(e => { 70 | done(e); 71 | }); 72 | }); 73 | 74 | it('it should GET metadata of study 1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313', done => { 75 | chai 76 | .request(`http://${process.env.host}:${process.env.port}`) 77 | .get( 78 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/metadata` 79 | ) 80 | .then(res => { 81 | if (res.statusCode >= 400) { 82 | done(new Error(res.body.error, res.body.message)); 83 | 84 | return; 85 | } 86 | 87 | expect(res.statusCode).to.equal(200); 88 | expect(res.body).to.be.a('array'); 89 | expect(res.body.length).to.be.eql(19); 90 | done(); 91 | }) 92 | .catch(e => { 93 | done(e); 94 | }); 95 | }); 96 | 97 | it('it should GET empty metadata for madeup study 1.3.6.1.4.1.65476457.5.2.1.1706.4996.6436336251031414136865313', done => { 98 | chai 99 | .request(`http://${process.env.host}:${process.env.port}`) 100 | .get( 101 | `${config.prefix}/studies/1.3.6.1.4.1.65476457.5.2.1.1706.4996.6436336251031414136865313/metadata` 102 | ) 103 | .then(res => { 104 | if (res.statusCode >= 400) { 105 | done(new Error(res.body.error, res.body.message)); 106 | 107 | return; 108 | } 109 | 110 | expect(res.statusCode).to.equal(200); 111 | expect(res.body).to.be.a('array'); 112 | expect(res.body.length).to.be.eql(0); 113 | done(); 114 | }) 115 | .catch(e => { 116 | done(e); 117 | }); 118 | }); 119 | 120 | it('it should GET metadata of series 1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126', done => { 121 | chai 122 | .request(`http://${process.env.host}:${process.env.port}`) 123 | .get( 124 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series/1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126/metadata` 125 | ) 126 | .then(res => { 127 | if (res.statusCode >= 400) { 128 | done(new Error(res.body.error, res.body.message)); 129 | 130 | return; 131 | } 132 | 133 | expect(res.statusCode).to.equal(200); 134 | expect(res.body).to.be.a('array'); 135 | expect(res.body.length).to.be.eql(18); 136 | done(); 137 | }) 138 | .catch(e => { 139 | done(e); 140 | }); 141 | }); 142 | 143 | it('it should GET empty metadata for madeup study study 1111111111 and series 1.3.6.1.4.1.54747.5.2.1.1706.4996.4562342246724757457', done => { 144 | chai 145 | .request(`http://${process.env.host}:${process.env.port}`) 146 | .get( 147 | `${config.prefix}/studies/1111111111/series/1.3.6.1.4.1.54747.5.2.1.1706.4996.4562342246724757457/metadata` 148 | ) 149 | .then(res => { 150 | if (res.statusCode >= 400) { 151 | done(new Error(res.body.error, res.body.message)); 152 | 153 | return; 154 | } 155 | 156 | expect(res.statusCode).to.equal(200); 157 | expect(res.body).to.be.a('array'); 158 | expect(res.body.length).to.be.eql(0); 159 | done(); 160 | }) 161 | .catch(e => { 162 | done(e); 163 | }); 164 | }); 165 | 166 | it('it should GET metadata of instance 1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694', done => { 167 | chai 168 | .request(`http://${process.env.host}:${process.env.port}`) 169 | .get( 170 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series/1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126/instances/1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694/metadata` 171 | ) 172 | .then(res => { 173 | if (res.statusCode >= 400) { 174 | done(new Error(res.body.error, res.body.message)); 175 | 176 | return; 177 | } 178 | 179 | expect(res.statusCode).to.equal(200); 180 | expect(res.body).to.be.a('array'); 181 | expect(res.body.length).to.be.eql(1); 182 | done(); 183 | }) 184 | .catch(e => { 185 | done(e); 186 | }); 187 | }); 188 | 189 | it('the metadata of instance 1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694 should contain the uid', done => { 190 | chai 191 | .request(`http://${process.env.host}:${process.env.port}`) 192 | .get( 193 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series/1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126/instances/1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694/metadata` 194 | ) 195 | .then(res => { 196 | if (res.statusCode >= 400) { 197 | done(new Error(res.body.error, res.body.message)); 198 | 199 | return; 200 | } 201 | expect(res.statusCode).to.equal(200); 202 | expect(res.body[0]['00080018'].Value[0]).to.be.eql( 203 | '1.3.6.1.4.1.14519.5.2.1.1706.4996.101091068805920483719105146694' 204 | ); 205 | done(); 206 | }) 207 | .catch(e => { 208 | done(e); 209 | }); 210 | }); 211 | 212 | it('it should GET empty metadata for madeup study study 1111111111, series 222222222222 and instance 3333333333333', done => { 213 | chai 214 | .request(`http://${process.env.host}:${process.env.port}`) 215 | .get( 216 | `${config.prefix}/studies/1111111111/series/222222222222/instances/3333333333333/metadata` 217 | ) 218 | .then(res => { 219 | if (res.statusCode >= 400) { 220 | done(new Error(res.body.error, res.body.message)); 221 | 222 | return; 223 | } 224 | 225 | expect(res.statusCode).to.equal(200); 226 | expect(res.body).to.be.a('array'); 227 | expect(res.body.length).to.be.eql(0); 228 | done(); 229 | }) 230 | .catch(e => { 231 | done(e); 232 | }); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /config/schemas/studies_output_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "studies_schema", 3 | "type": "array", 4 | "items": 5 | { 6 | "type": "object", 7 | "properties": { 8 | "00080005": { 9 | "type": "object", 10 | "properties": { 11 | "vr": { 12 | "type": "string" 13 | }, 14 | "Value": { 15 | "type": "array", 16 | "items": 17 | { 18 | "type": "string" 19 | } 20 | } 21 | }, 22 | "required": [ 23 | "vr" 24 | ] 25 | }, 26 | "00080020": { 27 | "type": "object", 28 | "properties": { 29 | "vr": { 30 | "type": "string" 31 | }, 32 | "Value": { 33 | "type": "array", 34 | "items": 35 | { 36 | "type": "string" 37 | } 38 | } 39 | }, 40 | "required": [ 41 | "vr" 42 | ] 43 | }, 44 | "00080030": { 45 | "type": "object", 46 | "properties": { 47 | "vr": { 48 | "type": "string" 49 | }, 50 | "Value": { 51 | "type": "array", 52 | "items": 53 | { 54 | "type": "string" 55 | } 56 | } 57 | }, 58 | "required": [ 59 | "vr" 60 | ] 61 | }, 62 | "00080050": { 63 | "type": "object", 64 | "properties": { 65 | "vr": { 66 | "type": "string" 67 | }, 68 | "Value": { 69 | "type": "array", 70 | "items": 71 | { 72 | "type": "string" 73 | } 74 | } 75 | }, 76 | "required": [ 77 | "vr" 78 | ] 79 | }, 80 | "00080054": { 81 | "type": "object", 82 | "properties": { 83 | "vr": { 84 | "type": "string" 85 | }, 86 | "Value": { 87 | "type": "array", 88 | "items": 89 | { 90 | "type": "string" 91 | } 92 | } 93 | }, 94 | "required": [ 95 | "vr" 96 | ] 97 | }, 98 | "00080056": { 99 | "type": "object", 100 | "properties": { 101 | "vr": { 102 | "type": "string" 103 | }, 104 | "Value": { 105 | "type": "array", 106 | "items": 107 | { 108 | "type": "string" 109 | } 110 | } 111 | }, 112 | "required": [ 113 | "vr" 114 | ] 115 | }, 116 | "00080061": { 117 | "type": "object", 118 | "properties": { 119 | "vr": { 120 | "type": "string" 121 | }, 122 | "Value": { 123 | "type": "array", 124 | "items": 125 | { 126 | "type": "string" 127 | } 128 | } 129 | }, 130 | "required": [ 131 | "vr" 132 | ] 133 | }, 134 | "00080090": { 135 | "type": "object", 136 | "properties": { 137 | "vr": { 138 | "type": "string" 139 | }, 140 | "Value": { 141 | "type": "array", 142 | "items": 143 | { 144 | "type": "object", 145 | "properties": { 146 | "Alphabetic": { 147 | "type": "string" 148 | } 149 | }, 150 | "required": [ 151 | "Alphabetic" 152 | ] 153 | } 154 | } 155 | }, 156 | "required": [ 157 | "vr" 158 | ] 159 | }, 160 | "00081190": { 161 | "type": "object", 162 | "properties": { 163 | "vr": { 164 | "type": "string" 165 | }, 166 | "Value": { 167 | "type": "array", 168 | "items": 169 | { 170 | "type": "string" 171 | } 172 | } 173 | }, 174 | "required": [ 175 | "vr" 176 | ] 177 | }, 178 | "00100010": { 179 | "type": "object", 180 | "properties": { 181 | "vr": { 182 | "type": "string" 183 | }, 184 | "Value": { 185 | "type": "array", 186 | "items": 187 | { 188 | "type": "object", 189 | "properties": { 190 | "Alphabetic": { 191 | "type": "string" 192 | } 193 | }, 194 | "required": [ 195 | "Alphabetic" 196 | ] 197 | } 198 | } 199 | }, 200 | "required": [ 201 | "vr" 202 | ] 203 | }, 204 | "00100020": { 205 | "type": "object", 206 | "properties": { 207 | "vr": { 208 | "type": "string" 209 | }, 210 | "Value": { 211 | "type": "array", 212 | "items": 213 | { 214 | "type": "string" 215 | } 216 | } 217 | }, 218 | "required": [ 219 | "vr" 220 | ] 221 | }, 222 | "00100030": { 223 | "type": "object", 224 | "properties": { 225 | "vr": { 226 | "type": "string" 227 | }, 228 | "Value": { 229 | "type": "array", 230 | "items": 231 | { 232 | "type": "string" 233 | } 234 | } 235 | }, 236 | "required": [ 237 | "vr" 238 | ] 239 | }, 240 | "00100040": { 241 | "type": "object", 242 | "properties": { 243 | "vr": { 244 | "type": "string" 245 | }, 246 | "Value": { 247 | "type": "array", 248 | "items": 249 | { 250 | "type": "string" 251 | } 252 | } 253 | }, 254 | "required": [ 255 | "vr" 256 | ] 257 | }, 258 | "0020000D": { 259 | "type": "object", 260 | "properties": { 261 | "vr": { 262 | "type": "string" 263 | }, 264 | "Value": { 265 | "type": "array", 266 | "items": 267 | { 268 | "type": "string" 269 | } 270 | } 271 | }, 272 | "required": [ 273 | "vr" 274 | ] 275 | }, 276 | "00200010": { 277 | "type": "object", 278 | "properties": { 279 | "vr": { 280 | "type": "string" 281 | }, 282 | "Value": { 283 | "type": "array", 284 | "items": 285 | { 286 | "type": "string" 287 | } 288 | } 289 | }, 290 | "required": [ 291 | "vr" 292 | ] 293 | }, 294 | "00201206": { 295 | "type": "object", 296 | "properties": { 297 | "vr": { 298 | "type": "string" 299 | }, 300 | "Value": { 301 | "type": "array", 302 | "items": 303 | { 304 | "type": "integer" 305 | } 306 | } 307 | }, 308 | "required": [ 309 | "vr" 310 | ] 311 | }, 312 | "00201208": { 313 | "type": "object", 314 | "properties": { 315 | "vr": { 316 | "type": "string" 317 | }, 318 | "Value": { 319 | "type": "array", 320 | "items": 321 | { 322 | "type": "integer" 323 | } 324 | } 325 | }, 326 | "required": [ 327 | "vr" 328 | ] 329 | }, 330 | "00081030": { 331 | "type": "object", 332 | "properties": { 333 | "vr": { 334 | "type": "string" 335 | }, 336 | "Value": { 337 | "type": "array", 338 | "items": 339 | { 340 | "type": "string" 341 | } 342 | } 343 | }, 344 | "required": [ 345 | "vr" 346 | ] 347 | } 348 | }, 349 | "required": [ 350 | "00080005", 351 | "00080020", 352 | "00080030", 353 | "00080050", 354 | "00080056", 355 | "00080061", 356 | "00080090", 357 | "00081190", 358 | "00100010", 359 | "00100020", 360 | "00100030", 361 | "00100040", 362 | "0020000D", 363 | "00200010", 364 | "00201206", 365 | "00201208" 366 | ] 367 | } 368 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /test/qidoTest.js: -------------------------------------------------------------------------------- 1 | require('./stowTest'); 2 | const chai = require('chai'); 3 | const chaiHttp = require('chai-http'); 4 | const config = require('../config/index'); 5 | 6 | chai.use(chaiHttp); 7 | const { expect } = chai; 8 | 9 | describe('QIDO Tests', () => { 10 | it('it should GET all studies (one study that was stowed)', done => { 11 | chai 12 | .request(`http://${process.env.host}:${process.env.port}`) 13 | .get(`${config.prefix}/studies`) 14 | .then(res => { 15 | if (res.statusCode >= 400) { 16 | done(new Error(res.body.error, res.body.message)); 17 | 18 | return; 19 | } 20 | 21 | expect(res.statusCode).to.equal(200); 22 | expect(res.body).to.be.a('array'); 23 | expect(res.body.length).to.be.eql(1); 24 | done(); 25 | }) 26 | .catch(e => { 27 | done(e); 28 | }); 29 | }); 30 | 31 | it('should no study with madeup study uid', done => { 32 | chai 33 | .request(`http://${process.env.host}:${process.env.port}`) 34 | .get(`${config.prefix}/studies?StudyInstanceUID=1.2.34.5.7.56.457.547.`) 35 | .then(res => { 36 | if (res.statusCode >= 400) { 37 | done(new Error(res.body.error, res.body.message)); 38 | 39 | return; 40 | } 41 | 42 | expect(res.statusCode).to.equal(200); 43 | expect(res.body).to.be.a('array'); 44 | expect(res.body.length).to.be.eql(0); 45 | done(); 46 | }) 47 | .catch(e => { 48 | done(e); 49 | }); 50 | }); 51 | 52 | it('should GET study with studyuid filter', done => { 53 | chai 54 | .request(`http://${process.env.host}:${process.env.port}`) 55 | .get( 56 | `${config.prefix}/studies?StudyInstanceUID=1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313` 57 | ) 58 | .then(res => { 59 | if (res.statusCode >= 400) { 60 | done(new Error(res.body.error, res.body.message)); 61 | 62 | return; 63 | } 64 | 65 | expect(res.statusCode).to.equal(200); 66 | expect(res.body).to.be.a('array'); 67 | expect(res.body.length).to.be.eql(1); 68 | done(); 69 | }) 70 | .catch(e => { 71 | done(e); 72 | }); 73 | }); 74 | 75 | it('should GET study with studyuid filter', done => { 76 | chai 77 | .request(`http://${process.env.host}:${process.env.port}`) 78 | .get( 79 | `${config.prefix}/studies?StudyInstanceUID=1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313` 80 | ) 81 | .then(res => { 82 | if (res.statusCode >= 400) { 83 | done(new Error(res.body.error, res.body.message)); 84 | 85 | return; 86 | } 87 | 88 | expect(res.statusCode).to.equal(200); 89 | expect(res.body).to.be.a('array'); 90 | expect(res.body.length).to.be.eql(1); 91 | done(); 92 | }) 93 | .catch(e => { 94 | done(e); 95 | }); 96 | }); 97 | it('should GET study with patient id and name filter', done => { 98 | chai 99 | .request(`http://${process.env.host}:${process.env.port}`) 100 | .get(`${config.prefix}/studies?PatientID=MRI-DIR-T2_3&PatientName=MRI-DIR-T2_3`) 101 | .then(res => { 102 | if (res.statusCode >= 400) { 103 | done(new Error(res.body.error, res.body.message)); 104 | 105 | return; 106 | } 107 | 108 | expect(res.statusCode).to.equal(200); 109 | expect(res.body).to.be.a('array'); 110 | expect(res.body.length).to.be.eql(1); 111 | done(); 112 | }) 113 | .catch(e => { 114 | done(e); 115 | }); 116 | }); 117 | 118 | it('should GET study with AccessionNumber filter', done => { 119 | chai 120 | .request(`http://${process.env.host}:${process.env.port}`) 121 | .get(`${config.prefix}/studies?AccessionNumber=2819497684894126`) 122 | .then(res => { 123 | if (res.statusCode >= 400) { 124 | done(new Error(res.body.error, res.body.message)); 125 | 126 | return; 127 | } 128 | 129 | expect(res.statusCode).to.equal(200); 130 | expect(res.body).to.be.a('array'); 131 | expect(res.body.length).to.be.eql(1); 132 | done(); 133 | }) 134 | .catch(e => { 135 | done(e); 136 | }); 137 | }); 138 | 139 | it('returned study should have uid: 1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313', done => { 140 | chai 141 | .request(`http://${process.env.host}:${process.env.port}`) 142 | .get(`${config.prefix}/studies`) 143 | .then(res => { 144 | if (res.statusCode >= 400) { 145 | done(new Error(res.body.error, res.body.message)); 146 | 147 | return; 148 | } 149 | 150 | expect(res.statusCode).to.equal(200); 151 | expect(res.body[0]['0020000D'].Value[0]).to.be.eql( 152 | '1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313' 153 | ); 154 | done(); 155 | }) 156 | .catch(e => { 157 | done(e); 158 | }); 159 | }); 160 | 161 | it('returned study should have number of series in tags: 2', done => { 162 | chai 163 | .request(`http://${process.env.host}:${process.env.port}`) 164 | .get(`${config.prefix}/studies`) 165 | .then(res => { 166 | if (res.statusCode >= 400) { 167 | done(new Error(res.body.error, res.body.message)); 168 | 169 | return; 170 | } 171 | 172 | expect(res.statusCode).to.equal(200); 173 | expect(res.body[0]['00201206'].Value[0]).to.be.eql(2); 174 | done(); 175 | }) 176 | .catch(e => { 177 | done(e); 178 | }); 179 | }); 180 | 181 | it('returned study should have number of images: 19', done => { 182 | chai 183 | .request(`http://${process.env.host}:${process.env.port}`) 184 | .get(`${config.prefix}/studies`) 185 | .then(res => { 186 | if (res.statusCode >= 400) { 187 | done(new Error(res.body.error, res.body.message)); 188 | 189 | return; 190 | } 191 | 192 | expect(res.statusCode).to.equal(200); 193 | expect(res.body[0]['00201208'].Value[0]).to.be.eql(19); 194 | done(); 195 | }) 196 | .catch(e => { 197 | done(e); 198 | }); 199 | }); 200 | 201 | it('series endpoint should return 2 series for study 1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313', done => { 202 | chai 203 | .request(`http://${process.env.host}:${process.env.port}`) 204 | .get( 205 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series` 206 | ) 207 | .then(res => { 208 | if (res.statusCode >= 400) { 209 | done(new Error(res.body.error, res.body.message)); 210 | 211 | return; 212 | } 213 | expect(res.statusCode).to.equal(200); 214 | expect(res.body).to.be.a('array'); 215 | expect(res.body.length).to.be.eql(2); 216 | done(); 217 | }) 218 | .catch(e => { 219 | done(e); 220 | }); 221 | }); 222 | 223 | it('series endpoint should return 1 series for study 1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313 with modality MR filter', done => { 224 | chai 225 | .request(`http://${process.env.host}:${process.env.port}`) 226 | .get( 227 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series?Modality=MR` 228 | ) 229 | .then(res => { 230 | if (res.statusCode >= 400) { 231 | done(new Error(res.body.error, res.body.message)); 232 | 233 | return; 234 | } 235 | 236 | expect(res.statusCode).to.equal(200); 237 | expect(res.body).to.be.a('array'); 238 | expect(res.body.length).to.be.eql(1); 239 | done(); 240 | }) 241 | .catch(e => { 242 | done(e); 243 | }); 244 | }); 245 | 246 | it('series endpoint should return 1 series for study 1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313 with SeriesInstanceUID filter', done => { 247 | chai 248 | .request(`http://${process.env.host}:${process.env.port}`) 249 | .get( 250 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126` 251 | ) 252 | .then(res => { 253 | if (res.statusCode >= 400) { 254 | done(new Error(res.body.error, res.body.message)); 255 | 256 | return; 257 | } 258 | 259 | expect(res.statusCode).to.equal(200); 260 | expect(res.body).to.be.a('array'); 261 | expect(res.body.length).to.be.eql(1); 262 | done(); 263 | }) 264 | .catch(e => { 265 | done(e); 266 | }); 267 | }); 268 | 269 | it('series endpoint should return 1 series for study 1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313 with series id filter', done => { 270 | chai 271 | .request(`http://${process.env.host}:${process.env.port}`) 272 | .get( 273 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series?SeriesNumber=5` 274 | ) 275 | .then(res => { 276 | if (res.statusCode >= 400) { 277 | done(new Error(res.body.error, res.body.message)); 278 | 279 | return; 280 | } 281 | 282 | expect(res.statusCode).to.equal(200); 283 | expect(res.body).to.be.a('array'); 284 | expect(res.body.length).to.be.eql(1); 285 | done(); 286 | }) 287 | .catch(e => { 288 | done(e); 289 | }); 290 | }); 291 | 292 | it('series endpoint should return no series for madeup study 1.3.6.1.4.1.675457.5.2.1.1706.4996.2675014637636865313', done => { 293 | chai 294 | .request(`http://${process.env.host}:${process.env.port}`) 295 | .get(`${config.prefix}/studies/1.3.6.1.4.1.675457.5.2.1.1706.4996.2675014637636865313/series`) 296 | .then(res => { 297 | if (res.statusCode >= 400) { 298 | done(new Error(res.body.error, res.body.message)); 299 | 300 | return; 301 | } 302 | 303 | expect(res.statusCode).to.equal(200); 304 | expect(res.body).to.be.a('array'); 305 | expect(res.body.length).to.be.eql(0); 306 | done(); 307 | }) 308 | .catch(e => { 309 | done(e); 310 | }); 311 | }); 312 | 313 | it('instances endpoint should return 18 instances for series 1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126', done => { 314 | chai 315 | .request(`http://${process.env.host}:${process.env.port}`) 316 | .get( 317 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series/1.3.6.1.4.1.14519.5.2.1.1706.4996.170872952012850866993878606126/instances` 318 | ) 319 | .then(res => { 320 | if (res.statusCode >= 400) { 321 | done(new Error(res.body.error, res.body.message)); 322 | 323 | return; 324 | } 325 | 326 | expect(res.statusCode).to.equal(200); 327 | expect(res.body).to.be.a('array'); 328 | expect(res.body.length).to.be.eql(18); 329 | done(); 330 | }) 331 | .catch(e => { 332 | done(e); 333 | }); 334 | }); 335 | 336 | it('instances endpoint should return 1 instance for series 1.3.6.1.4.1.14519.5.2.1.1706.4996.125234324154032773868316308352', done => { 337 | chai 338 | .request(`http://${process.env.host}:${process.env.port}`) 339 | .get( 340 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series/1.3.6.1.4.1.14519.5.2.1.1706.4996.125234324154032773868316308352/instances` 341 | ) 342 | .then(res => { 343 | if (res.statusCode >= 400) { 344 | done(new Error(res.body.error, res.body.message)); 345 | 346 | return; 347 | } 348 | 349 | expect(res.statusCode).to.equal(200); 350 | expect(res.body).to.be.a('array'); 351 | expect(res.body.length).to.be.eql(1); 352 | done(); 353 | }) 354 | .catch(e => { 355 | done(e); 356 | }); 357 | }); 358 | 359 | it('instances endpoint should return no instance for madeup series 1.3.6.1.4.1.54747.5.2.1.1706.4996.4562342246724757457', done => { 360 | chai 361 | .request(`http://${process.env.host}:${process.env.port}`) 362 | .get( 363 | `${config.prefix}/studies/1.3.6.1.4.1.14519.5.2.1.1706.4996.267501199180251031414136865313/series/1.3.6.1.4.1.54747.5.2.1.1706.4996.4562342246724757457/instances` 364 | ) 365 | .then(res => { 366 | if (res.statusCode >= 400) { 367 | done(new Error(res.body.error, res.body.message)); 368 | 369 | return; 370 | } 371 | 372 | expect(res.statusCode).to.equal(200); 373 | expect(res.body).to.be.a('array'); 374 | expect(res.body.length).to.be.eql(0); 375 | done(); 376 | }) 377 | .catch(e => { 378 | done(e); 379 | }); 380 | }); 381 | 382 | it('instances endpoint should return no instance for madeup study 1111111111 and series 1.3.6.1.4.1.54747.5.2.1.1706.4996.4562342246724757457', done => { 383 | chai 384 | .request(`http://${process.env.host}:${process.env.port}`) 385 | .get( 386 | `${config.prefix}/studies/1111111111/series/1.3.6.1.4.1.54747.5.2.1.1706.4996.4562342246724757457/instances` 387 | ) 388 | .then(res => { 389 | if (res.statusCode >= 400) { 390 | done(new Error(res.body.error, res.body.message)); 391 | 392 | return; 393 | } 394 | 395 | expect(res.statusCode).to.equal(200); 396 | expect(res.body).to.be.a('array'); 397 | expect(res.body.length).to.be.eql(0); 398 | done(); 399 | }) 400 | .catch(e => { 401 | done(e); 402 | }); 403 | }); 404 | }); 405 | -------------------------------------------------------------------------------- /plugins/CouchDB.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle, no-async-promise-executor */ 2 | const fp = require('fastify-plugin'); 3 | const _ = require('underscore'); 4 | const toArrayBuffer = require('to-array-buffer'); 5 | // eslint-disable-next-line no-global-assign 6 | window = {}; 7 | const dcmjs = require('dcmjs'); 8 | const Axios = require('axios'); 9 | const http = require('http'); 10 | const fs = require('fs'); 11 | const md5 = require('md5'); 12 | 13 | const config = require('../config/index'); 14 | const viewsjs = require('../config/views'); 15 | const { studyTags, seriesTags, instanceTags, patientTags } = require('../config/viewTags'); 16 | const { InternalError, ResourceNotFoundError, BadRequestError } = require('../utils/Errors'); 17 | 18 | async function couchdb(fastify, options) { 19 | fastify.decorate('init', async () => { 20 | try { 21 | await fastify.couch.db.list(); 22 | fastify.log.info('Connected to couchdb server'); 23 | return fastify.checkAndCreateDb(); 24 | } catch (err) { 25 | fastify.log.info('Waiting for couchdb server'); 26 | setTimeout(fastify.init, 3000); 27 | } 28 | return null; 29 | }); 30 | 31 | // Update the views in couchdb with the ones defined in the code 32 | fastify.decorate( 33 | 'checkAndCreateDb', 34 | () => 35 | new Promise(async (resolve, reject) => { 36 | try { 37 | const databases = await fastify.couch.db.list(); 38 | // check if the db exists 39 | if (databases.indexOf(config.db) < 0) { 40 | await fastify.couch.db.create(config.db); 41 | } 42 | const dicomDB = fastify.couch.db.use(config.db); 43 | // define an empty design document 44 | let viewDoc = {}; 45 | viewDoc.views = {}; 46 | // try and get the design document 47 | try { 48 | viewDoc = await dicomDB.get('_design/instances'); 49 | } catch (e) { 50 | fastify.log.info('View document not found! Creating new one'); 51 | } 52 | const keys = Object.keys(viewsjs.views); 53 | const values = Object.values(viewsjs.views); 54 | // update the views 55 | for (let i = 0; i < keys.length; i += 1) { 56 | viewDoc.views[keys[i]] = values[i]; 57 | } 58 | // insert the updated/created design document 59 | await dicomDB.insert(viewDoc, '_design/instances', insertErr => { 60 | if (insertErr) { 61 | fastify.log.info(`Error updating the design document ${insertErr.message}`); 62 | reject(insertErr); 63 | } else { 64 | fastify.log.info('Design document updated successfully '); 65 | resolve(); 66 | } 67 | }); 68 | } catch (err) { 69 | fastify.log.error(`Error connecting to couchdb: ${err.message}`); 70 | reject(err); 71 | } 72 | }) 73 | ); 74 | 75 | // pass the actual obj 76 | fastify.decorate('queryObj', (query, obj, keys) => { 77 | const keysInQuery = Object.keys(query); 78 | for (let i = 0; i < keysInQuery.length; i += 1) { 79 | if ( 80 | keys[keysInQuery[i]] && 81 | !( 82 | obj[keys[keysInQuery[i]]] && 83 | obj[keys[keysInQuery[i]]].Value && 84 | obj[keys[keysInQuery[i]]].Value[0] && 85 | ((obj[keys[keysInQuery[i]]].Value[0].Alphabetic && 86 | obj[keys[keysInQuery[i]]].Value[0].Alphabetic === query[keysInQuery[i]]) || 87 | obj[keys[keysInQuery[i]]].Value[0].toString() === query[keysInQuery[i]]) 88 | ) 89 | ) { 90 | return false; 91 | } 92 | } 93 | return true; 94 | }); 95 | 96 | // get the values from couchdb and format the object with and according to VRs 97 | fastify.decorate('formatValuesWithVR', (values, tags) => { 98 | try { 99 | const arr = JSON.parse(values); 100 | const result = {}; 101 | for (let j = 0; j < tags.length; j += 1) { 102 | result[tags[j][1]] = { vr: tags[j][3] }; 103 | 104 | // if (arr[j]) newobj[tags[j][1]].Value = [arr[j]]; 105 | const Value = arr[j]; 106 | switch (tags[j][3]) { 107 | case 'PN': 108 | if (Value && Value[0]) { 109 | if (Value[0].Alphabetic) 110 | result[tags[j][1]].Value = [ 111 | { 112 | Alphabetic: Value[0].Alphabetic, 113 | }, 114 | ]; 115 | else 116 | result[tags[j][1]].Value = [ 117 | { 118 | Alphabetic: Value[0], 119 | }, 120 | ]; 121 | } 122 | 123 | break; 124 | case 'UN': 125 | // TODO: Not sure what the actual limit should be, 126 | // but dcm4chee will use BulkDataURI if the Value 127 | // is too large. We should do the same 128 | if (Value.startsWith('http') || Value.startsWith('/studies')) { 129 | result[tags[j][1]].BulkDataURI = Value; 130 | } else { 131 | result[tags[j][1]].InlineBinary = Value; 132 | } 133 | 134 | break; 135 | case 'OW': 136 | result[tags[j][1]].BulkDataURI = Value; 137 | break; 138 | default: 139 | if ( 140 | Value && 141 | Value.length && 142 | !(Value.length === 1 && (Value[0] === undefined || Value[0] === '')) 143 | ) { 144 | result[tags[j][1]].Value = Value; 145 | } 146 | } 147 | } 148 | return result; 149 | } catch (err) { 150 | fastify.log.error( 151 | `Couldn't format Values With VR ${err.message}. Values: ${JSON.stringify( 152 | values 153 | )} Tags: ${JSON.stringify(tags)}` 154 | ); 155 | } 156 | return {}; 157 | }); 158 | 159 | // needs to support query with following keys 160 | // StudyDate 00080020 161 | // StudyTime 00080030 162 | // AccessionNumber 00080050 163 | // ModalitiesInStudy 00080061 164 | // ReferringPhysicianName 00080090 165 | // PatientName 00100010 166 | // PatientID 00100020 167 | // StudyInstanceUID 0020000D 168 | // StudyID 00200010 169 | // add accessor methods with decorate 170 | fastify.decorate('getQIDOStudies', (request, reply) => { 171 | try { 172 | // TODO: Commented out StudyDate because it doesn't actually 173 | // filter by StudyDate unless it's an exact match, and so it 174 | // was returning no results for the OHIF Study List 175 | const queryKeys = { 176 | // StudyDate: '00080020', 177 | StudyTime: '00080030', 178 | AccessionNumber: '00080050', 179 | // ModalitiesInStudy: '00080061', // not here 180 | ReferringPhysicianName: '00080090', 181 | PatientName: '00100010', 182 | PatientID: '00100020', 183 | StudyInstanceUID: '0020000D', 184 | StudyID: '00200010', 185 | }; 186 | 187 | const dicomDB = fastify.couch.db.use(config.db); 188 | // const dbfilter = request.query.PatientID 189 | // ? { 190 | // startkey: [request.query.PatientID, '', '', '', ''], 191 | // endkey: [`${request.query.PatientID}\u9999`, '{}', '{}', '{}', '{}'], 192 | // } 193 | // : {}; 194 | const bodyStudies = new Promise((resolve, reject) => { 195 | dicomDB.view( 196 | 'instances', 197 | 'qido_study', 198 | { 199 | // ...dbfilter, 200 | reduce: true, 201 | group_level: 4, 202 | stale: 'ok', 203 | }, 204 | (error, body) => { 205 | if (!error) { 206 | resolve(body); 207 | } else { 208 | reject(error); 209 | } 210 | } 211 | ); 212 | }); 213 | 214 | bodyStudies 215 | .then(values => { 216 | const studies = {}; 217 | studies.rows = values.rows; 218 | const res = []; 219 | // couch returns ordered list, merge if the study occurs multiple times consequently (due to seres listing different tags) 220 | for (let i = 0; i < studies.rows.length; i += 1) { 221 | const study = studies.rows[i]; 222 | const newobj = fastify.formatValuesWithVR(study.key[3], studyTags); 223 | // add numberOfStudyRelatedInstances 224 | newobj['00201208'].Value = []; 225 | newobj['00201208'].Value.push(study.value); 226 | // add numberOfStudyRelatedSeries 227 | newobj['00201206'].Value = []; 228 | newobj['00201206'].Value.push(1); 229 | newobj['00080061'].Value = [study.key[1]]; 230 | 231 | study.key[3] = newobj; 232 | } 233 | 234 | for (let i = 0; i < studies.rows.length; i += 1) { 235 | const study = studies.rows[i]; 236 | const studySeriesObj = study.key[3]; 237 | if (fastify.queryObj(request.query, studySeriesObj, queryKeys)) { 238 | // see if there are consequent records with the same studyuid 239 | const currentStudyUID = study.key[0]; 240 | for (let j = i + 1; j < studies.rows.length; j += 1) { 241 | const consequentStudyUID = studies.rows[j].key[0]; 242 | if (currentStudyUID === consequentStudyUID) { 243 | // same study merge 244 | const consequentStudySeriesObj = studies.rows[j].key[3]; 245 | Object.keys(consequentStudySeriesObj).forEach(tag => { 246 | if (tag === '00201208') { 247 | // numberOfStudyRelatedInstances needs to be cumulated 248 | studySeriesObj['00201208'].Value[0] += studies.rows[j].value; 249 | } else if (tag === '00201206') { 250 | // numberOfStudyRelatedSeries needs to be cumulated 251 | studySeriesObj['00201206'].Value[0] += 252 | consequentStudySeriesObj['00201206'].Value[0]; 253 | } else if (studySeriesObj[tag] !== consequentStudySeriesObj[tag]) { 254 | if (consequentStudySeriesObj[tag].Value) 255 | consequentStudySeriesObj[tag].Value.forEach(val => { 256 | if (!studySeriesObj[tag].Value) studySeriesObj[tag].Value = [val]; 257 | else if ( 258 | // if both studies have values, cumulate them but don't make duplicates 259 | (typeof studySeriesObj[tag].Value[0] === 'string' && 260 | !studySeriesObj[tag].Value.includes(val)) || 261 | !_.findIndex(studySeriesObj[tag].Value, val) === -1 262 | ) { 263 | studySeriesObj[tag].Value.push(val); 264 | } 265 | }); 266 | } 267 | }); 268 | // skip the consequent study entries 269 | i = j; 270 | } 271 | } 272 | res.push(studySeriesObj); 273 | } 274 | } 275 | try { 276 | if (request.query.limit) { 277 | reply.code(200).send(res.slice(0, Number(request.query.limit))); 278 | } else reply.code(200).send(res); 279 | } catch (limitErr) { 280 | fastify.log.warn( 281 | `Cannot limit. invalid value ${request.query.limit}. Error: ${limitErr.message}` 282 | ); 283 | } 284 | }) 285 | .catch(err => { 286 | // TODO send correct error codes 287 | // per http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.7.html#table_6.7-1 288 | reply.send(new InternalError('QIDO Studies retreival from couchdb', err)); 289 | }); 290 | } catch (err) { 291 | reply.send(new InternalError('QIDO Studies retreival', err)); 292 | } 293 | }); 294 | 295 | // needs to support query with following keys 296 | // Modality 00080060 297 | // SeriesInstanceUID 0020000E 298 | // SeriesNumber 00200011 299 | // we don't have the rest in results 300 | // PerformedProcedureStepStartDate 00400244 301 | // PerformedProcedureStepStartTime 00400245 302 | // RequestAttributeSequence 00400275 303 | // >ScheduledProcedureStepID 00400009 304 | // >RequestedProcedureID 00401001 305 | fastify.decorate('getQIDOSeries', (request, reply) => { 306 | try { 307 | const queryKeys = { 308 | Modality: '00080060', 309 | SeriesInstanceUID: '0020000E', 310 | SeriesNumber: '00200011', 311 | // PerformedProcedureStepStartDate: '00400244', 312 | // PerformedProcedureStepStartTime: '00400245', 313 | // RequestAttributeSequence: '00400275', 314 | // ScheduledProcedureStepID: '00400009', 315 | // RequestedProcedureID: '00401001', 316 | }; 317 | 318 | const dicomDB = fastify.couch.db.use(config.db); 319 | dicomDB.view( 320 | 'instances', 321 | 'qido_series', 322 | { 323 | startkey: [request.params.study], 324 | endkey: [request.params.study, {}, {}], 325 | reduce: true, 326 | group_level: 3, 327 | stale: 'ok', 328 | }, 329 | (error, body) => { 330 | if (!error) { 331 | const res = []; 332 | body.rows.forEach(series => { 333 | // get the actual instance object (tags only) 334 | const seriesObj = fastify.formatValuesWithVR(series.key[2], seriesTags); 335 | if (fastify.queryObj(request.query, seriesObj, queryKeys)) { 336 | seriesObj['00201209'].Value = []; 337 | seriesObj['00201209'].Value.push(series.value); 338 | res.push(seriesObj); 339 | } 340 | }); 341 | try { 342 | if (request.query.limit) { 343 | reply.code(200).send(res.slice(0, Number(request.query.limit))); 344 | } else reply.code(200).send(res); 345 | } catch (limitErr) { 346 | fastify.log.warn( 347 | `Cannot limit. invalid value ${request.query.limit}. Error: ${limitErr.message}` 348 | ); 349 | } 350 | } else { 351 | // TODO send correct error codes 352 | // per http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.7.html#table_6.7-1 353 | reply.send(new InternalError('QIDO series retreival from couchdb', error)); 354 | } 355 | } 356 | ); 357 | } catch (err) { 358 | reply.send(new InternalError('QIDO series retreival', err)); 359 | } 360 | }); 361 | 362 | fastify.decorate('getQIDOInstances', (request, reply) => { 363 | try { 364 | const dicomDB = fastify.couch.db.use(config.db); 365 | dicomDB.view( 366 | 'instances', 367 | 'qido_instances', 368 | { 369 | startkey: [request.params.study, request.params.series], 370 | endkey: [request.params.study, request.params.series, {}], 371 | reduce: true, 372 | group: true, 373 | group_level: 4, 374 | stale: 'ok', 375 | }, 376 | (error, body) => { 377 | if (!error) { 378 | const res = []; 379 | body.rows.forEach(instance => { 380 | // get the actual instance object (tags only) 381 | const instanceObj = fastify.formatValuesWithVR(instance.key[3], instanceTags); 382 | res.push(instanceObj); 383 | }); 384 | reply.code(200).send(res); 385 | } else { 386 | // TODO send correct error codes 387 | // per http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.7.html#table_6.7-1 388 | reply.send(new InternalError('QIDO instances retreival from couchdb', error)); 389 | } 390 | } 391 | ); 392 | } catch (err) { 393 | reply.send(new InternalError('QIDO instances retreival', err)); 394 | } 395 | }); 396 | 397 | fastify.decorate('retrieveInstance', async (request, reply) => { 398 | try { 399 | // if the query params have frame use retrieveInstanceFrames instead 400 | if (request.query.frame) fastify.retrieveInstanceFrames(request, reply); 401 | else { 402 | const dicomDB = fastify.couch.db.use(config.db); 403 | const instance = request.query.objectUID; 404 | reply.header('Content-Disposition', `attachment; filename=${instance}.dcm`); 405 | const stream = await fastify.getDicomFileAsStream(instance, dicomDB); 406 | reply.code(200).send(stream); 407 | } 408 | } catch (err) { 409 | reply.send( 410 | new ResourceNotFoundError( 411 | 'Instance', 412 | request.params.instance || request.query.objectUID, 413 | err 414 | ) 415 | ); 416 | } 417 | }); 418 | 419 | fastify.decorate('retrieveInstanceRS', async (request, reply) => { 420 | try { 421 | // if the query params have frame use retrieveInstanceFrames instead 422 | if (request.params.frames) fastify.retrieveInstanceFrames(request, reply); 423 | else { 424 | try { 425 | const dicomDB = fastify.couch.db.use(config.db); 426 | const { instance } = request.params; 427 | const dataset = await fastify.getDicomBuffer(instance, dicomDB); 428 | const { data, boundary } = await fastify.packMultipartDicomsInternal([dataset]); 429 | // send response 430 | reply.header( 431 | 'Content-Type', 432 | `multipart/related; type=application/dicom; boundary=${boundary}` 433 | ); 434 | reply.header('content-length', Buffer.byteLength(data)); 435 | reply.send(Buffer.from(data)); 436 | } catch (err) { 437 | if (err.statusCode === 404) 438 | reply.send( 439 | new ResourceNotFoundError( 440 | 'Instance', 441 | request.params.instance || request.query.objectUID, 442 | err 443 | ) 444 | ); 445 | else 446 | reply.send( 447 | new InternalError(`getWado with params ${JSON.stringify(request.params)}`, err) 448 | ); 449 | } 450 | } 451 | } catch (err) { 452 | reply.send( 453 | new ResourceNotFoundError( 454 | 'Instance', 455 | request.params.instance || request.query.objectUID, 456 | err 457 | ) 458 | ); 459 | } 460 | }); 461 | 462 | fastify.decorate('retrieveInstanceFrames', async (request, reply) => { 463 | // wado-rs frame retrieve 464 | // 465 | // http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_8.6.html#sect_8.6.1.2 466 | // 467 | // - Accepts frames as a comma separated list of frame numbers (starting at 1): 468 | // -- most likely/common use case will be a single number 1. This is what OHIF requests. 469 | // -- this means just skipping past the dicom header and returning just the PixelData. 470 | // - in general, need to skip to the correct frame location for each requested frame 471 | // -- need to figure offsets out from the instance metadata. 472 | // For attachments it makes a head call for attachment size and gets the document for the necessary header values, calculates the ofset and makes a range query 473 | // Couchdb attachments can be accessed via ranges: 474 | // http://docs.couchdb.org/en/stable/api/document/attachments.html#api-doc-attachment-range 475 | // Not clear how to do this via nano. resolved with http range query for attachments for now 476 | // Issue filed here: https://github.com/apache/couchdb-nano/issues/166 477 | // For linked files it makes a stats call for the size, calculates the offset and makes a range read from the stream 478 | // - Adds multipart header and content separators 479 | try { 480 | const dicomDB = fastify.couch.db.use(config.db); 481 | const instance = request.params.instance || request.query.objectUID; 482 | const framesParam = request.params.frames || request.query.frame; 483 | let doc = instance; 484 | if (typeof instance === 'string') doc = await dicomDB.get(instance); 485 | // get tags of the instance 486 | const numOfFrames = doc.dataset['00280008'] ? doc.dataset['00280008'].Value[0] : 1; 487 | const numOfBits = doc.dataset['00280100'].Value[0]; 488 | const rows = doc.dataset['00280010'].Value[0]; 489 | const cols = doc.dataset['00280011'].Value[0]; 490 | const samplesForPixel = doc.dataset['00280002'].Value[0]; 491 | const frameSize = Math.ceil((rows * cols * numOfBits * samplesForPixel) / 8); 492 | let framePromises = []; 493 | const frames = []; 494 | if (doc.filePath) 495 | framePromises = await fastify.retrieveInstanceFramesFromLink(doc, framesParam, { 496 | numOfFrames, 497 | numOfBits, 498 | rows, 499 | cols, 500 | samplesForPixel, 501 | frameSize, 502 | }); 503 | else 504 | framePromises = await fastify.retrieveInstanceFramesFromAttachment(doc, framesParam, { 505 | numOfFrames, 506 | numOfBits, 507 | rows, 508 | cols, 509 | samplesForPixel, 510 | frameSize, 511 | }); 512 | // pack the frames in a multipart and send 513 | const frameResponses = await Promise.all(framePromises); 514 | frameResponses.forEach(response => frames.push(response)); 515 | 516 | const { data, boundary } = dcmjs.utilities.message.multipartEncode( 517 | frames, 518 | undefined, 519 | 'application/octet-stream' 520 | ); 521 | try { 522 | reply.headers({ 523 | 'Content-Type': `multipart/related; application/octet-stream; boundary=${boundary}`, 524 | maxContentLength: Buffer.byteLength(data) + 1, 525 | }); 526 | reply.code(200).send(Buffer.from(data)); 527 | } catch (replyErr) { 528 | reply.send(new InternalError('Packing frames', replyErr)); 529 | } 530 | } catch (err) { 531 | reply.send( 532 | new ResourceNotFoundError('Frame', request.params.frames || request.query.frame, err) 533 | ); 534 | } 535 | }); 536 | 537 | fastify.decorate( 538 | 'retrieveInstanceFramesFromAttachment', 539 | (doc, framesParam, calcVars) => 540 | new Promise(async (resolve, reject) => { 541 | try { 542 | this.request = Axios.create({ 543 | baseURL: `${config.dbServer}:${config.dbPort}/${config.db}`, 544 | }); 545 | const id = doc.id ? doc.id : doc._id; 546 | // make a head query to get the attachment size 547 | // TODO nano doesn't support db.attachment.head 548 | this.request 549 | .head(`/${id}/object.dcm`) 550 | .then(head => { 551 | fastify.log.info( 552 | `Content length of the attachment is ${head.headers['content-length']}` 553 | ); 554 | const attachmentSize = Number(head.headers['content-length']); 555 | 556 | try { 557 | // TODO Number should be removed after IS is corrected 558 | const headerSize = 559 | attachmentSize - calcVars.frameSize * Number(calcVars.numOfFrames); 560 | fastify.log.info( 561 | `numOfFrames: ${calcVars.numOfFrames}, numOfBits: ${calcVars.numOfBits}, rows : ${calcVars.rows}, cols: ${calcVars.cols}, samplesForPixel: ${calcVars.samplesForPixel}, frameSize: ${calcVars.frameSize}, headerSize: ${calcVars.headerSize}` 562 | ); 563 | 564 | // get range from couch for each frame, just forward the url for now 565 | // TODO update nano 566 | const framePromises = []; 567 | const frameNums = framesParam.split(','); 568 | fastify.log.info(`frameNums that are sent : ${frameNums}`); 569 | frameNums.forEach(frameNum => { 570 | const frameNo = Number(frameNum); 571 | // calculate offset using frame count * frame size (row*col*pixel byte*samples for pixel) 572 | const range = `bytes=${headerSize + 573 | calcVars.frameSize * (frameNo - 1)}-${headerSize - 574 | 1 + 575 | calcVars.frameSize * frameNo}`; 576 | fastify.log.info( 577 | `headerSize: ${headerSize}, frameNo: ${frameNo}, range: ${range}` 578 | ); 579 | const authAndHost = config.dbServer.replace('http://', '').split('@'); 580 | framePromises.push( 581 | new Promise((rangeResolve, rangeReject) => { 582 | const opt = { 583 | hostname: authAndHost[1], 584 | auth: authAndHost[0], 585 | port: config.dbPort, 586 | path: `/${config.db}/${id}/object.dcm`, 587 | method: 'GET', 588 | headers: { Range: range }, 589 | }; 590 | const data = []; 591 | // node request is failing range requests with a parser error after reading the full content 592 | // curl and web browser xhr works (probably ignores the remaining) (couchdb has javascript tests for range query which are done with web browser xhr) 593 | // tried xmlhttprequest npm package but it uses node's request on nodejs side 594 | // also tried adding range query capability to nano, but it uses node's request package and throws the parser error 595 | // this code retrieves the range request using http.request and ignores if it encounters an error although it has buffer data 596 | // returns the retrieved buffer 597 | const req = http.request(opt, res => { 598 | try { 599 | res.on('data', d => { 600 | data.push(d); 601 | }); 602 | res.on('end', () => { 603 | const databuffer = Buffer.concat(data); 604 | rangeResolve(databuffer); 605 | }); 606 | } catch (e) { 607 | if (data.length === 0) rangeReject(new Error('Empty buffer')); 608 | else { 609 | const databuffer = Buffer.concat(data); 610 | fastify.log.info( 611 | `Threw error in catch. Error: ${e.message}, sending buffer of size 612 | ${databuffer.length} anyway` 613 | ); 614 | rangeResolve(databuffer); 615 | } 616 | } 617 | }); 618 | 619 | req.on('error', error => { 620 | if (data.length === 0) rangeReject(new Error('Empty buffer')); 621 | else { 622 | const databuffer = Buffer.concat(data); 623 | fastify.log.info( 624 | `Threw error ${error.message}, sending buffer of size 625 | ${databuffer.length} anyway` 626 | ); 627 | rangeResolve(databuffer); 628 | } 629 | }); 630 | 631 | req.end(); 632 | }) 633 | ); 634 | }); 635 | resolve(framePromises); 636 | } catch (frameErr) { 637 | reject(new InternalError('Not able to get frame', frameErr)); 638 | } 639 | }) 640 | .catch(err => { 641 | reject(new InternalError(`Couldn't get content length for the attachment`, err)); 642 | }); 643 | } catch (err) { 644 | reject(new ResourceNotFoundError('Frame', framesParam, err)); 645 | } 646 | }) 647 | ); 648 | 649 | fastify.decorate( 650 | 'retrieveInstanceFramesFromLink', 651 | (doc, framesParam, calcVars) => 652 | new Promise(async (resolve, reject) => { 653 | try { 654 | const fileSize = fs.statSync(doc.filePath).size; 655 | 656 | // TODO Number should be removed after IS is corrected 657 | const headerSize = fileSize - calcVars.frameSize * Number(calcVars.numOfFrames); 658 | fastify.log.info( 659 | `numOfFrames: ${calcVars.numOfFrames}, numOfBits: ${calcVars.numOfBits}, rows : ${calcVars.rows}, cols: ${calcVars.cols}, samplesForPixel: ${calcVars.samplesForPixel}, frameSize: ${calcVars.frameSize}, headerSize: ${headerSize}` 660 | ); 661 | 662 | fs.open(doc.filePath, 'r', (error, fd) => { 663 | if (error) { 664 | reject(new InternalError('Opening linked DICOM file', error)); 665 | } 666 | // get range from couch for each frame, just forward the url for now 667 | // TODO update nano 668 | const framePromises = []; 669 | const frameNums = framesParam.split(','); 670 | fastify.log.info(`frameNums that are sent : ${frameNums}`); 671 | frameNums.forEach(frameNum => { 672 | const frameNo = Number(frameNum); 673 | // calculate offset using frame count * frame size (row*col*pixel byte*samples for pixel) 674 | const offset = headerSize + calcVars.frameSize * (frameNo - 1); 675 | fastify.log.info(`headerSize: ${headerSize}, frameNo: ${frameNo}, offset: ${offset}`); 676 | const buffer = Buffer.alloc(calcVars.frameSize); 677 | framePromises.push( 678 | new Promise((rangeResolve, rangeReject) => { 679 | fs.read(fd, buffer, 0, calcVars.frameSize, offset, (err, __, databuffer) => { 680 | if (err) rangeReject(new InternalError('Reading frame buffer', err)); 681 | rangeResolve(databuffer); 682 | }); 683 | }) 684 | ); 685 | }); 686 | resolve(framePromises); 687 | }); 688 | } catch (err) { 689 | reject(new ResourceNotFoundError('Frame', framesParam, err)); 690 | } 691 | }) 692 | ); 693 | 694 | fastify.decorate('getStudyMetadata', (request, reply) => { 695 | try { 696 | const dicomDB = fastify.couch.db.use(config.db); 697 | dicomDB.view( 698 | 'instances', 699 | 'wado_metadata', 700 | { 701 | startkey: [request.params.study], 702 | endkey: [request.params.study, {}, {}], 703 | }, 704 | (error, body) => { 705 | if (!error) { 706 | const res = []; 707 | body.rows.forEach(instance => { 708 | // get the actual instance object (tags only) 709 | res.push(instance.value); 710 | }); 711 | reply.code(200).send(res); 712 | } else { 713 | reply.send(new InternalError('Retrieve study metadata from couchdb', error)); 714 | } 715 | } 716 | ); 717 | } catch (err) { 718 | reply.send(new InternalError('Retrieve study metadata', err)); 719 | } 720 | }); 721 | 722 | fastify.decorate('getSeriesMetadata', (request, reply) => { 723 | try { 724 | const dicomDB = fastify.couch.db.use(config.db); 725 | dicomDB.view( 726 | 'instances', 727 | 'wado_metadata', 728 | { 729 | startkey: [request.params.study, request.params.series], 730 | endkey: [request.params.study, request.params.series, {}], 731 | }, 732 | (error, body) => { 733 | if (!error) { 734 | const res = []; 735 | body.rows.forEach(instance => { 736 | // get the actual instance object (tags only) 737 | res.push(instance.value); 738 | }); 739 | reply.code(200).send(res); 740 | } else { 741 | reply.send(new InternalError('Retrieve series metadata from couchdb', error)); 742 | } 743 | } 744 | ); 745 | } catch (err) { 746 | reply.send(new InternalError('Retrieve study metadata', err)); 747 | } 748 | }); 749 | 750 | fastify.decorate('getInstanceMetadata', (request, reply) => { 751 | try { 752 | const dicomDB = fastify.couch.db.use(config.db); 753 | dicomDB.view( 754 | 'instances', 755 | 'wado_metadata', 756 | { 757 | key: [request.params.study, request.params.series, request.params.instance], 758 | }, 759 | (error, body) => { 760 | if (!error) { 761 | const res = []; 762 | body.rows.forEach(instance => { 763 | // get the actual instance object (tags only) 764 | res.push(instance.value); 765 | }); 766 | reply.code(200).send(res); 767 | } else { 768 | reply.send(new InternalError('Retrieve instance metadata from couchdb', error)); 769 | } 770 | } 771 | ); 772 | } catch (err) { 773 | reply.send(new InternalError('Retrieve instance metadata from couchdb', err)); 774 | } 775 | }); 776 | 777 | fastify.decorate('getPatients', (request, reply) => { 778 | try { 779 | const dicomDB = fastify.couch.db.use(config.db); 780 | dicomDB.view( 781 | 'instances', 782 | 'patients', 783 | { 784 | reduce: true, 785 | group_level: 3, 786 | }, 787 | (error, body) => { 788 | if (!error) { 789 | const res = []; 790 | body.rows.forEach(patient => { 791 | const patientObj = fastify.formatValuesWithVR(patient.key, patientTags); 792 | res.push(patientObj); 793 | }); 794 | reply.code(200).send(res); 795 | } else { 796 | reply.send(new InternalError('Retrieve patients from couchdb', error)); 797 | } 798 | } 799 | ); 800 | } catch (err) { 801 | reply.send(new InternalError('Retrieve patients from couchdb', err)); 802 | } 803 | }); 804 | 805 | fastify.decorate('saveFile', filePath => { 806 | const arrayBuffer = fs.readFileSync(filePath).buffer; 807 | return fastify.saveBuffer(arrayBuffer); 808 | }); 809 | 810 | fastify.decorate('saveBuffer', (arrayBuffer, dicomDB, filePath) => { 811 | // eslint-disable-next-line no-param-reassign 812 | if (dicomDB === undefined) dicomDB = fastify.couch.db.use(config.db); 813 | // TODO: Check if this needs to be Buffer or not. 814 | const body = Buffer.from(arrayBuffer); 815 | const incomingMd5 = md5(body); 816 | const dicomData = dcmjs.data.DicomMessage.readFile(arrayBuffer, {}); 817 | const couchDoc = { 818 | _id: dicomData.dict['00080018'].Value[0], 819 | dataset: dicomData.dict, 820 | md5hash: incomingMd5, 821 | }; 822 | if (filePath) couchDoc.filePath = filePath; 823 | return new Promise((resolve, reject) => 824 | dicomDB.get(couchDoc._id, (error, existing) => { 825 | if (!error) { 826 | couchDoc._rev = existing._rev; 827 | // old documents won't have md5 828 | if (existing.md5hash) { 829 | // get the md5 of the buffer 830 | if ( 831 | existing.md5hash === incomingMd5 && // same md5 832 | ((filePath && existing.filePath && existing.filePath === filePath) || // filepath sent (saving as a link) and it was saved as a link to same path before 833 | (!filePath && !existing.filePath)) // no filepath (saving as attachment) and it wasn't saved as a link before 834 | ) { 835 | fastify.log.info(`${couchDoc._id} is already in the system with same hash`); 836 | resolve('File already in system'); 837 | return; 838 | } 839 | } 840 | fastify.log.info(`Updating document for dicom ${couchDoc._id}`); 841 | } 842 | 843 | dicomDB.insert(couchDoc, (err, data) => { 844 | if (err) { 845 | reject(err); 846 | } 847 | if (!filePath) 848 | dicomDB.attachment.insert( 849 | couchDoc._id, 850 | 'object.dcm', 851 | body, 852 | 'application/dicom', 853 | { rev: data.rev }, 854 | attachmentErr => { 855 | if (attachmentErr) { 856 | reject(attachmentErr); 857 | } 858 | resolve('Saving successful'); 859 | } 860 | ); 861 | else resolve('Saving successful'); 862 | }); 863 | }) 864 | ); 865 | }); 866 | 867 | fastify.decorate( 868 | 'processFolder', 869 | (linkDir, dicomDB) => 870 | new Promise((resolve, reject) => { 871 | fastify.log.info(`Processing folder ${linkDir}`); 872 | // success variable is to check if there was at least one successful processing 873 | const result = { success: false, errors: [] }; 874 | fs.readdir(linkDir, async (err, files) => { 875 | if (err) { 876 | reject(new InternalError(`Reading directory ${linkDir}`, err)); 877 | } else { 878 | try { 879 | const promises = []; 880 | for (let i = 0; i < files.length; i += 1) { 881 | if (files[i] !== '__MACOSX') 882 | if (fs.statSync(`${linkDir}/${files[i]}`).isDirectory() === true) 883 | try { 884 | // eslint-disable-next-line no-await-in-loop 885 | const subdirResult = await fastify.processFolder( 886 | `${linkDir}/${files[i]}`, 887 | dicomDB 888 | ); 889 | if (subdirResult && subdirResult.errors && subdirResult.errors.length > 0) { 890 | result.errors = result.errors.concat(subdirResult.errors); 891 | } 892 | if (subdirResult && subdirResult.success) { 893 | result.success = result.success || subdirResult.success; 894 | } 895 | } catch (folderErr) { 896 | reject(folderErr); 897 | } 898 | else 899 | promises.push(() => { 900 | return ( 901 | fastify 902 | .processFile(linkDir, files[i], dicomDB) 903 | // eslint-disable-next-line no-loop-func 904 | .catch(error => { 905 | result.errors.push(error); 906 | }) 907 | ); 908 | }); 909 | } 910 | fastify.dbPqueue.addAll(promises).then(async values => { 911 | try { 912 | for (let i = 0; values.length; i += 1) { 913 | if ( 914 | values[i] === undefined || 915 | (values[i].errors && values[i].errors.length === 0) 916 | ) { 917 | // one success is enough 918 | result.success = result.success || true; 919 | break; 920 | } 921 | } 922 | resolve(result); 923 | } catch (saveDicomErr) { 924 | reject(saveDicomErr); 925 | } 926 | }); 927 | } catch (errDir) { 928 | reject(errDir); 929 | } 930 | } 931 | }); 932 | }) 933 | ); 934 | 935 | fastify.decorate( 936 | 'processFile', 937 | (dir, filename, dicomDB) => 938 | new Promise((resolve, reject) => { 939 | try { 940 | let buffer = []; 941 | const readableStream = fs.createReadStream(`${dir}/${filename}`); 942 | readableStream.on('data', chunk => { 943 | buffer.push(chunk); 944 | }); 945 | readableStream.on('error', readErr => { 946 | fastify.log.error(`Error in save when reading file ${dir}/${filename}: ${readErr}`); 947 | reject(new InternalError(`Reading file ${dir}/${filename}`, readErr)); 948 | }); 949 | readableStream.on('close', () => { 950 | readableStream.destroy(); 951 | }); 952 | readableStream.on('end', async () => { 953 | buffer = Buffer.concat(buffer); 954 | try { 955 | const arrayBuffer = toArrayBuffer(buffer); 956 | await fastify.saveBuffer(arrayBuffer, dicomDB, `${dir}/${filename}`); 957 | resolve({ success: true, errors: [] }); 958 | } catch (err) { 959 | fastify.log.warn(`File not supported ignoring ${filename}`); 960 | resolve({ success: true, errors: [] }); 961 | } 962 | }); 963 | } catch (err) { 964 | reject(new InternalError(`Processing file ${filename}`, err)); 965 | } 966 | }) 967 | ); 968 | 969 | fastify.decorate('linkFolder', async (request, reply) => { 970 | try { 971 | if (request.req.hostname.startsWith('localhost')) { 972 | const dicomDB = fastify.couch.db.use(config.db); 973 | const result = await fastify.processFolder(request.query.path, dicomDB); 974 | if (result.success) { 975 | fastify.updateViews(dicomDB); 976 | fastify.log.info(`Folder ${request.query.path} linked successfully`); 977 | reply.code(200).send('success'); 978 | } else { 979 | reply.send(new InternalError('linkFolder', new Error(JSON.stringify(result.errors)))); 980 | } 981 | } else { 982 | reply.send( 983 | new BadRequestError( 984 | 'Not supported', 985 | new Error('Linkfolder functionality is only supported for localhost') 986 | ) 987 | ); 988 | } 989 | } catch (e) { 990 | // TODO Proper error reporting implementation required 991 | // per http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.6.html#table_6.6.1-1 992 | reply.send(new InternalError('linkFolder', e)); 993 | } 994 | }); 995 | fastify.decorate('updateViews', dbConn => { 996 | let dicomDB = dbConn; 997 | if (!dicomDB) dicomDB = fastify.couch.db.use(config.db); 998 | // trigger view updates 999 | const updateViewPromisses = []; 1000 | updateViewPromisses.push(() => { 1001 | return dicomDB.view('instances', 'qido_study', {}); 1002 | }); 1003 | updateViewPromisses.push(() => { 1004 | return dicomDB.view('instances', 'qido_series', {}); 1005 | }); 1006 | updateViewPromisses.push(() => { 1007 | return dicomDB.view('instances', 'qido_instances', {}); 1008 | }); 1009 | // I don't need to wait 1010 | fastify.dbPqueue.addAll(updateViewPromisses); 1011 | }); 1012 | 1013 | fastify.decorate('stow', (request, reply) => { 1014 | try { 1015 | const dicomDB = fastify.couch.db.use(config.db); 1016 | const res = toArrayBuffer(request.body); 1017 | const parts = dcmjs.utilities.message.multipartDecode(res); 1018 | const promises = []; 1019 | for (let i = 0; i < parts.length; i += 1) { 1020 | const arrayBuffer = parts[i]; 1021 | promises.push(() => { 1022 | return fastify.saveBuffer(arrayBuffer, dicomDB); 1023 | }); 1024 | } 1025 | fastify.dbPqueue 1026 | .addAll(promises) 1027 | .then(() => { 1028 | fastify.updateViews(dicomDB); 1029 | fastify.log.info(`Stow is done successfully`); 1030 | reply.code(200).send('success'); 1031 | }) 1032 | .catch(err => { 1033 | // TODO Proper error reporting implementation required 1034 | // per http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.6.html#table_6.6.1-1 1035 | fastify.log.error(`Error in STOW: ${err}`); 1036 | reply.send(new InternalError('STOW save', err)); 1037 | }); 1038 | } catch (e) { 1039 | // TODO Proper error reporting implementation required 1040 | // per http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.6.html#table_6.6.1-1 1041 | reply.send(new InternalError('STOW', e)); 1042 | } 1043 | }); 1044 | 1045 | fastify.decorate('deleteStudy', (request, reply) => { 1046 | try { 1047 | const dicomDB = fastify.couch.db.use(config.db); 1048 | dicomDB.view( 1049 | 'instances', 1050 | 'qido_instances', 1051 | { 1052 | startkey: [request.params.study], 1053 | endkey: [request.params.study, {}, {}], 1054 | reduce: false, 1055 | include_docs: true, 1056 | }, 1057 | async (error, body) => { 1058 | if (!error) { 1059 | const docs = _.map(body.rows, instance => { 1060 | return { _id: instance.key[2], _rev: instance.doc._rev, _deleted: true }; 1061 | }); 1062 | await fastify.dbPqueue.add(() => { 1063 | return new Promise((resolve, reject) => { 1064 | dicomDB 1065 | .bulk({ docs }) 1066 | .then(() => { 1067 | resolve(); 1068 | }) 1069 | .catch(deleteError => { 1070 | fastify.log.info( 1071 | `Error deleting study ${request.params.study} with ${docs.length} dicoms` 1072 | ); 1073 | reject(deleteError); 1074 | }); 1075 | }); 1076 | }); 1077 | fastify.updateViews(dicomDB); 1078 | fastify.log.info(`Deleted study ${request.params.study} with ${docs.length} dicoms`); 1079 | reply.code(200).send('Deleted successfully'); 1080 | } 1081 | } 1082 | ); 1083 | } catch (e) { 1084 | // TODO Proper error reporting implementation required 1085 | // per http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.6.html#table_6.6.1-1 1086 | reply.send(new InternalError('Delete study', e)); 1087 | } 1088 | }); 1089 | 1090 | fastify.decorate('deleteSeries', (request, reply) => { 1091 | try { 1092 | const dicomDB = fastify.couch.db.use(config.db); 1093 | dicomDB.view( 1094 | 'instances', 1095 | 'qido_instances', 1096 | { 1097 | startkey: [request.params.study, request.params.series], 1098 | endkey: [request.params.study, request.params.series, {}], 1099 | reduce: false, 1100 | include_docs: true, 1101 | }, 1102 | async (error, body) => { 1103 | if (!error) { 1104 | const docs = _.map(body.rows, instance => { 1105 | return { _id: instance.key[2], _rev: instance.doc._rev, _deleted: true }; 1106 | }); 1107 | await fastify.dbPqueue.add(() => { 1108 | return new Promise((resolve, reject) => { 1109 | dicomDB 1110 | .bulk({ docs }) 1111 | .then(() => { 1112 | resolve(); 1113 | }) 1114 | .catch(deleteError => { 1115 | fastify.log.info( 1116 | `Error deleting series ${request.params.series} with ${docs.length} dicoms` 1117 | ); 1118 | reject(deleteError); 1119 | }); 1120 | }); 1121 | }); 1122 | fastify.updateViews(dicomDB); 1123 | fastify.log.info(`Deleted series ${request.params.series} with ${docs.length} dicoms`); 1124 | reply.code(200).send('Deleted successfully'); 1125 | } 1126 | } 1127 | ); 1128 | } catch (e) { 1129 | // TODO Proper error reporting implementation required 1130 | // per http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.6.html#table_6.6.1-1 1131 | reply.send(new InternalError('Delete series', e)); 1132 | } 1133 | }); 1134 | 1135 | fastify.decorate('getDicomFileAsStream', async (instance, dicomDB) => { 1136 | let doc = instance; 1137 | if (typeof instance === 'string') doc = await dicomDB.get(instance); 1138 | if (doc.filePath) { 1139 | return fs.createReadStream(doc.filePath); 1140 | } 1141 | // if the document is retrieved via metadata the id is in id, if document retrieved it is _id 1142 | const id = doc.id ? doc.id : doc._id; 1143 | return dicomDB.attachment.getAsStream(id, 'object.dcm'); 1144 | }); 1145 | 1146 | fastify.decorate('getWado', (request, reply) => { 1147 | try { 1148 | // get the datasets 1149 | const dicomDB = fastify.couch.db.use(config.db); 1150 | let isFiltered = false; 1151 | const startKey = []; 1152 | const endKey = []; 1153 | if (request.params.study) { 1154 | startKey.push(request.params.study); 1155 | endKey.push(request.params.study); 1156 | isFiltered = true; 1157 | } 1158 | if (request.params.series) { 1159 | startKey.push(request.params.series); 1160 | endKey.push(request.params.series); 1161 | isFiltered = true; 1162 | } 1163 | for (let i = endKey.length; i < 3; i += 1) endKey.push({}); 1164 | let filterOptions = {}; 1165 | if (isFiltered) { 1166 | filterOptions = { 1167 | startkey: startKey, 1168 | endkey: endKey, 1169 | reduce: false, 1170 | include_docs: true, 1171 | }; 1172 | dicomDB.view('instances', 'qido_instances', filterOptions, async (error, body) => { 1173 | if (!error) { 1174 | try { 1175 | const datasetsReqs = []; 1176 | body.rows.forEach(instance => { 1177 | datasetsReqs.push(fastify.getDicomBuffer(instance.doc, dicomDB)); 1178 | }); 1179 | const datasets = await Promise.all(datasetsReqs); 1180 | const { data, boundary } = await fastify.packMultipartDicomsInternal(datasets); 1181 | // send response 1182 | reply.header( 1183 | 'Content-Type', 1184 | `multipart/related; type=application/dicom; boundary=${boundary}` 1185 | ); 1186 | reply.header('content-length', Buffer.byteLength(data)); 1187 | reply.send(Buffer.from(data)); 1188 | } catch (err) { 1189 | reply.send( 1190 | new InternalError(`getWado with params ${JSON.stringify(request.params)}`, err) 1191 | ); 1192 | } 1193 | } else { 1194 | reply.send(new InternalError('Retrieve series metadata from couchdb', error)); 1195 | } 1196 | }); 1197 | } else { 1198 | reply.send( 1199 | new BadRequestError('Not supported', new Error('Wado retrieve with no parameters')) 1200 | ); 1201 | } 1202 | } catch (e) { 1203 | // TODO Proper error reporting implementation required 1204 | // per http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.6.html#table_6.6.1-1 1205 | reply.send(new InternalError(`getWado with params ${JSON.stringify(request.params)}`, e)); 1206 | } 1207 | }); 1208 | 1209 | fastify.decorate( 1210 | 'packMultipartDicomsInternal', 1211 | datasets => 1212 | new Promise(async (resolve, reject) => { 1213 | try { 1214 | fastify.log.info(`Packing ${datasets.length} dicoms`); 1215 | const { data, boundary } = dcmjs.utilities.message.multipartEncode(datasets); 1216 | fastify.log.info(`Packed ${Buffer.byteLength(data)} bytes of data `); 1217 | resolve({ data, boundary }); 1218 | } catch (err) { 1219 | reject(err); 1220 | } 1221 | }) 1222 | ); 1223 | 1224 | fastify.decorate( 1225 | 'getDicomBuffer', 1226 | (instance, dicomDB) => 1227 | new Promise(async (resolve, reject) => { 1228 | try { 1229 | const stream = await fastify.getDicomFileAsStream(instance, dicomDB); 1230 | const bufs = []; 1231 | stream.on('data', d => { 1232 | bufs.push(d); 1233 | }); 1234 | stream.on('end', () => { 1235 | const buf = Buffer.concat(bufs); 1236 | resolve(toArrayBuffer(buf)); 1237 | }); 1238 | } catch (err) { 1239 | reject(err); 1240 | } 1241 | }) 1242 | ); 1243 | 1244 | fastify.addHook('onError', (request, reply, error, done) => { 1245 | if (error instanceof ResourceNotFoundError) reply.code(404); 1246 | else if (error instanceof InternalError) reply.code(500); 1247 | else if (error instanceof BadRequestError) reply.code(400); 1248 | done(); 1249 | }); 1250 | 1251 | fastify.log.info(`Using db: ${config.db}`); 1252 | // register couchdb 1253 | // disables eslint check as I want this module to be standalone to be (un)pluggable 1254 | // eslint-disable-next-line global-require 1255 | fastify.register(require('fastify-couchdb'), { 1256 | // eslint-disable-line global-require 1257 | url: options.url, 1258 | }); 1259 | fastify.after(async () => { 1260 | try { 1261 | await fastify.init(); 1262 | // update views on startup 1263 | fastify.updateViews(); 1264 | } catch (err) { 1265 | fastify.log.info(`Cannot connect to couchdb (err:${err}), shutting down the server`); 1266 | fastify.close(); 1267 | } 1268 | // need to add hook for close to remove the db if test; 1269 | fastify.addHook('onClose', async (instance, done) => { 1270 | if (config.env === 'test') { 1271 | try { 1272 | // if it is test remove the database 1273 | await instance.couch.db.destroy(config.db); 1274 | fastify.log.info('Destroying test database'); 1275 | } catch (err) { 1276 | fastify.log.info(`Cannot destroy test database (err:${err})`); 1277 | } 1278 | done(); 1279 | } 1280 | }); 1281 | }); 1282 | } 1283 | // expose as plugin so the module using it can access the decorated methods 1284 | module.exports = fp(couchdb); 1285 | --------------------------------------------------------------------------------