├── .babelrc ├── .eslintrc.js ├── .github └── workflows │ ├── publish-package.yml │ └── tests.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── changelog.md ├── dcm4chee-docker-compose.env ├── dcm4chee-docker-compose.yml ├── examples ├── index.html └── retry.html ├── karma.conf.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── schema └── WADO-RS-RetrieveMetadata.json ├── src ├── api.js ├── dicomweb-client.js ├── message.js ├── utils.js └── version.js ├── test.sh ├── test └── test.js ├── testData ├── US-PAL-8-10x-echo.dcm ├── sample.dcm ├── sample2.dcm └── sample3.dcm ├── test_ci.sh ├── tsconfig.json └── types └── types.d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "browsers": ["ie 11"] 6 | } 7 | }] 8 | ] 9 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['airbnb-base', 'prettier'], 4 | rules: { 5 | 'import/extensions': "always", // Better for native ES Module usage 6 | 'no-console': 0, // We can remove this later 7 | 'no-underscore-dangle': 0, 8 | 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 9 | }, 10 | env: { 11 | browser: 1, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /.github/workflows/publish-package.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to NPM 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish-package: 10 | runs-on: ubuntu-20.04 11 | environment: publish 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 16 21 | 22 | - name: Install packages 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Run build 26 | run: npm run build 27 | 28 | - name: Run tests 29 | run: npm run test 30 | 31 | - name: Semantic release 32 | uses: cycjimmy/semantic-release-action@v3 33 | with: 34 | semantic_version: 19.0.2 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-20.04 10 | 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 16 19 | 20 | - name: Install packages 21 | uses: bahmutov/npm-install@v1 22 | 23 | - name: Run build 24 | run: npm run build 25 | 26 | - name: Run tests 27 | run: npm run test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Build directory 33 | build/ 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # Yarn lock file 58 | yarn.lock 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | .idea 64 | 65 | # docker files created by the docker containers for testing 66 | tmp 67 | 68 | types/* 69 | !types/types.d.ts 70 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test/actual 3 | test/fixtures 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "lts/*" 5 | 6 | services: 7 | - docker 8 | 9 | cache: 10 | directories: 11 | - $HOME/docker 12 | - node_modules 13 | 14 | before_script: 15 | # first, stop postgres so we can use our own from dcm4chee docker 16 | - sudo service postgresql stop 17 | # wait for postgresql to shutdown 18 | - while sudo lsof -Pi :5432 -sTCP:LISTEN -t; do sleep 1; done 19 | 20 | script: 21 | - npm install 22 | - npm run build 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 dcmjs-org 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/dcmjs-org/dicomweb-client.svg?branch=master)](https://travis-ci.com/dcmjs-org/dicomweb-client) 2 | 3 | # DICOMweb Client 4 | 5 | JavaScript client implementation of [DICOMweb](https://www.dicomstandard.org/dicomweb/). 6 | 7 | For further details please refer to [PS3.18 of the DICOM standard](http://dicom.nema.org/medical/dicom/current/output/chtml/part18/PS3.18.html). 8 | 9 | 10 | ## Goal 11 | 12 | **This is work-in-progress and should not be used in clinical practice. Use at your own risk.** 13 | 14 | The main motivations for this project is: 15 | * Support for storing, quering, retrieving DICOM objects over the web using RESTful services STOW-RS, QIDO-RS and WADO-RS, respectively 16 | * Building a lightweight library to facilitate integration into web applications 17 | 18 | ## Installation 19 | 20 | Install the [dicomweb-client](https://www.npmjs.com/package/dicomweb-client) package using the `npm` package manager: 21 | 22 | ```None 23 | npm install dicomweb-client 24 | ``` 25 | 26 | ## Building and testing 27 | 28 | Build and test code locally: 29 | 30 | ```None 31 | git clone https://github.com/dcmjs-org/dicomweb-client ~/dicomweb-client 32 | cd ~/dicomweb-client 33 | npm install 34 | npm run build 35 | npm test 36 | ``` 37 | 38 | ## Usage 39 | 40 | ```html 41 | 42 | ``` 43 | 44 | ```js 45 | const url = 'http://localhost:8080/dicomweb'; 46 | const client = new DICOMwebClient.api.DICOMwebClient({url}); 47 | client.searchForStudies().then(studies => { 48 | console.log(studies) 49 | }); 50 | ``` 51 | 52 | ## Configuration Options 53 | The API can be configured with a number of custom configuration options to control the requests. These are: 54 | * url to retrieve from for the base requests 55 | * singlepart, either true or a set of parts from `bulkdata,image,video` to request as single part responses 56 | * headers to add to the retrieve 57 | * `XMLHttpRequest` can be passed to `storeInstances` as a property of the `options` parameter. When present, instead of creating a new `XMLHttpRequest` instance, the passed instance is used instead. One use of this would be to track the progress of a DICOM store and/or cancel it. 58 | 59 | An example use of `XMLHttpRequest` being passed into the store is shown in the js snippet below 60 | as an example of where the upload's percentage progress is output to the console. 61 | 62 | ```js 63 | const url = 'http://localhost:8080/dicomweb'; 64 | const client = new DICOMwebClient.api.DICOMwebClient({url}); 65 | 66 | // an ArrayBuffer of the DICOM object/file 67 | const dataSet = ... ; 68 | 69 | // A custom HTTP request 70 | const request = new XMLHttpRequest(); 71 | 72 | // A callback that outputs the percentage complete to the console. 73 | const progressCallback = evt => { 74 | if (!evt.lengthComputable) { 75 | // Progress computation is not possible. 76 | return; 77 | } 78 | 79 | const percentComplete = Math.round((100 * evt.loaded) / evt.total); 80 | console.log("storeInstances is " + percentComplete + "%"); 81 | }; 82 | 83 | // Add the progress callback as a listener to the request upload object. 84 | request.upload.addEventListener('progress', progressCallback); 85 | 86 | const storeInstancesOptions = { 87 | dataSets, 88 | request, 89 | } 90 | client.storeInstances(storeInstancesOptions).then( () => console.log("storeInstances completed successfully.") ); 91 | 92 | ``` 93 | 94 | ## For maintainers 95 | 96 | Use `semantic` commit messages to generate releases and change log entries: [Semantic Release: How does it work?](https://semantic-release.gitbook.io/semantic-release/#how-does-it-work). Github actions are used to trigger building and uploading new npm packages. 97 | 98 | ## Citation 99 | 100 | Please cite the following article when using the client for scientific studies: [Herrmann et al. J Path Inform. 2018](http://www.jpathinformatics.org/article.asp?issn=2153-3539;year=2018;volume=9;issue=1;spage=37;epage=37;aulast=Herrmann): 101 | 102 | ```None 103 | @article{jpathinform-2018-9-37, 104 | Author={ 105 | Herrmann, M. D. and Clunie, D. A. and Fedorov A. and Doyle, S. W. and Pieper, S. and 106 | Klepeis, V. and Le, L. P. and Mutter, G. L. and Milstone, D. S. and Schultz, T. J. and 107 | Kikinis, R. and Kotecha, G. K. and Hwang, D. H. and Andriole, K, P. and Iafrate, A. J. and 108 | Brink, J. A. and Boland, G. W. and Dreyer, K. J. and Michalski, M. and 109 | Golden, J. A. and Louis, D. N. and Lennerz, J. K. 110 | }, 111 | Title={Implementing the {DICOM} standard for digital pathology}, 112 | Journal={Journal of Pathology Informatics}, 113 | Year={2018}, 114 | Number={1}, 115 | Volume={9}, 116 | Number={37} 117 | } 118 | 119 | ``` 120 | 121 | ## Support 122 | 123 | The developers gratefully acknowledge their reseach support: 124 | * Open Health Imaging Foundation ([OHIF](http://ohif.org)) 125 | * Quantitative Image Informatics for Cancer Research ([QIICR](http://qiicr.org)) 126 | * [Radiomics](http://radiomics.io) 127 | * The [Neuroimage Analysis Center](http://nac.spl.harvard.edu) 128 | * The [National Center for Image Guided Therapy](http://ncigt.org) 129 | * The [MGH & BWH Center for Clinical Data Science](https://www.ccds.io/) 130 | 131 | 132 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.3.2] - 2018-09-26 8 | ### Added 9 | - Added DICOMWebClient.version and associated NPM script to make it easier to determine which version of the library is running in an application 10 | 11 | ### Changed 12 | - Added Babel to the Rollup configuration to produce a transpiled version of the library which can run on all browsers above Internet Explorer 11 13 | 14 | ## [0.3.1] - 2018-09-25 15 | ### Fixed 16 | - Removed reference to 'window' so the library can be used in Node.js more easily 17 | 18 | ## [0.3.0] - 2018-09-24 19 | ### Added 20 | - Added support for WADO-RS [RetrieveStudy](http://dicom.nema 21 | .org/medical/dicom/current/output/chtml/part18/sect_6.5.html#sect_6.5.1) 22 | - Added support for WADO-RS [RetrieveSeries](http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.5.2.html) 23 | - Added support for WADO-RS [RetrieveInstance](http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.5.3.html) 24 | - Added support for WADO-RS [RetrieveBulkData](http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.5.5.html) 25 | - Added support for constructing [WADO-URI](http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.2.html) URLs from the necessary parameters 26 | 27 | ### Changed 28 | - Switched from console.error() to throw new Error() for invalid handling input 29 | 30 | ### Fixed 31 | - Removed duplicated tests for StoreInstances 32 | 33 | ## [0.2.0] - 2018-09-20 34 | ### Added 35 | - Added support for STOW-RS StoreInstances 36 | 37 | ### Changed 38 | - Switched from Mochify to Karma for running tests 39 | 40 | ### Fixed 41 | - Fixed exit code from test.sh script to ensure Continuous Integration server properly reports failures. 42 | 43 | -------------------------------------------------------------------------------- /dcm4chee-docker-compose.env: -------------------------------------------------------------------------------- 1 | STORAGE_DIR=/storage/fs1 2 | POSTGRES_DB=pacsdb 3 | POSTGRES_USER=pacs 4 | POSTGRES_PASSWORD=pacs 5 | TZ=Europe/Paris -------------------------------------------------------------------------------- /dcm4chee-docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | ldap: 4 | image: dcm4che/slapd-dcm4chee:2.4.44-13.3 5 | logging: 6 | driver: json-file 7 | options: 8 | max-size: "10m" 9 | expose: 10 | - "389" 11 | env_file: dcm4chee-docker-compose.env 12 | volumes: 13 | - ./tmp/dcm4chee-arc/ldap:/var/lib/ldap 14 | - ./tmp/dcm4chee-arc/slapd.d:/etc/ldap/slapd.d 15 | db: 16 | image: dcm4che/postgres-dcm4chee:10.0-13 17 | logging: 18 | driver: json-file 19 | options: 20 | max-size: "10m" 21 | expose: 22 | - "5432" 23 | env_file: dcm4chee-docker-compose.env 24 | volumes: 25 | - ./tmp/dcm4chee-arc/db:/var/lib/postgresql/data 26 | arc: 27 | image: dcm4che/dcm4chee-arc-psql:5.13.3 28 | logging: 29 | driver: json-file 30 | options: 31 | max-size: "10m" 32 | ports: 33 | - "8008:8080" 34 | env_file: dcm4chee-docker-compose.env 35 | environment: 36 | WILDFLY_CHOWN: /opt/wildfly/standalone /storage 37 | WILDFLY_WAIT_FOR: ldap:389 db:5432 38 | depends_on: 39 | - ldap 40 | - db 41 | volumes: 42 | - ./tmp/dcm4chee-arc/wildfly:/opt/wildfly/standalone 43 | - ./tmp/dcm4chee-arc/storage:/storage 44 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |

9 | Example 10 |

11 |
12 | Request hooks 13 |
14 |
15 |
16 |
17 |
18 |
19 | 20 | 21 | 22 | 23 | 29 | 30 | 49 | 50 | -------------------------------------------------------------------------------- /examples/retry.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |

9 | Example: Request Hooks 10 |

11 |
12 |
13 |               const options = {
14 |                 url: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs",
15 |                 requestHooks: [
16 |                   (request, metadata) => {
17 |                     const requestMetadata = document.createElement('div');
18 |                     requestMetadata.innerHTML = `Method: ${metadata.method} URL: ${metadata.url}`;
19 |                     document.getElementById('requests').append(requestMetadata);
20 |                     return request;
21 |                   }
22 |                 ],
23 |               };
24 |             
25 |
26 |

Requests:

27 |
(Logged using request hooks)
28 |

29 |             
30 |
31 |
32 |
33 |
34 |
35 | 36 | 37 | 38 | 39 | 45 | 46 | 72 | 73 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Wed Sep 19 2018 21:13:00 GMT+0200 (CEST) 3 | process.env.CHROME_BIN = require('puppeteer').executablePath(); 4 | 5 | module.exports = function(config) { 6 | config.set({ 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | // frameworks to use 11 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 12 | frameworks: ['jasmine'], 13 | 14 | // list of files / patterns to load in the browser 15 | files: [ 16 | 'build/dicomweb-client.js', 17 | { pattern: 'testData/*', included: false, served: true }, 18 | 'test/*.js', 19 | ], 20 | 21 | // list of files / patterns to exclude 22 | exclude: [], 23 | 24 | // preprocess matching files before serving them to the browser 25 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 26 | preprocessors: {}, 27 | 28 | // test results reporter to use 29 | // possible values: 'dots', 'progress' 30 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 31 | reporters: ['progress'], 32 | 33 | // web server port 34 | port: 9876, 35 | 36 | // enable / disable colors in the output (reporters and logs) 37 | colors: true, 38 | 39 | // level of logging 40 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 41 | logLevel: config.LOG_INFO, 42 | 43 | // enable / disable watching file and executing tests whenever any file changes 44 | autoWatch: true, 45 | 46 | // start these browsers 47 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 48 | browsers: ['Chrome', 'Chrome_without_security'], // You may use 'ChromeCanary', 'Chromium' or any other supported browser 49 | 50 | // you can define custom flags 51 | customLaunchers: { 52 | Chrome_without_security: { 53 | base: 'Chrome', 54 | flags: ['--disable-web-security'], 55 | }, 56 | 57 | ChromeHeadless_without_security: { 58 | base: 'ChromeHeadless', 59 | flags: ['--disable-web-security'], 60 | }, 61 | }, 62 | 63 | // Continuous Integration mode 64 | // if true, Karma captures browsers, runs the tests and exits 65 | singleRun: false, 66 | 67 | // Concurrency level 68 | // how many browser should be started simultaneous 69 | concurrency: Infinity, 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dicomweb-client", 3 | "version": "0.8.4", 4 | "description": "Implementation of DICOMweb client code", 5 | "main": "build/dicomweb-client.js", 6 | "module": "build/dicomweb-client.es.js", 7 | "types": "types/dicomweb-client.d.ts", 8 | "scripts": { 9 | "test": "./test_ci.sh", 10 | "test:watch": "./test.sh", 11 | "start": "rollup -c", 12 | "build": "rollup -c && npx tsc", 13 | "watch": "rollup -c -w", 14 | "version": "node -p -e \"'export default \\'' + require('./package.json').version + '\\';'\" > src/version.js", 15 | "lint": "eslint -c .eslintrc.js --fix src && prettier --write src/**/*.js" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/dcmjs-org/dicomweb-client.git" 20 | }, 21 | "keywords": [ 22 | "dicom", 23 | "dcmjs", 24 | "dicomweb", 25 | "wado-rs", 26 | "qido-rs", 27 | "stow-rs" 28 | ], 29 | "author": "Steve Pieper, Markus Herrmann", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/dcmjs-org/dicomweb-client/issues" 33 | }, 34 | "homepage": "https://github.com/dcmjs-org/dicomweb-client#readme", 35 | "devDependencies": { 36 | "@babel/core": "^7.1.0", 37 | "@babel/preset-env": "^7.1.0", 38 | "eslint": "^8.38.0", 39 | "eslint-config-airbnb-base": "^15.0.0", 40 | "eslint-config-prettier": "^8.8.0", 41 | "eslint-plugin-import": "^2.27.5", 42 | "karma": "^6.4.1", 43 | "karma-chai": "^0.1.0", 44 | "karma-chrome-launcher": "^3.1.1", 45 | "karma-jasmine": "^5.1.0", 46 | "prettier": "^1.19.1", 47 | "puppeteer": "^1.18.1", 48 | "rollup": "^0.63.2", 49 | "rollup-plugin-babel": "^4.0.3", 50 | "typescript": "^4.8.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import pkg from './package.json' 3 | 4 | export default { 5 | input: 'src/dicomweb-client.js', 6 | output: [{ 7 | file: pkg.main, 8 | format: 'umd', 9 | name: 'DICOMwebClient', 10 | sourcemap: true, 11 | }, 12 | { 13 | file: pkg.module, 14 | format: 'es', 15 | sourcemap: true, 16 | exports: 'named' 17 | } 18 | ], 19 | plugins: [ 20 | babel({ 21 | runtimeHelpers: true, 22 | exclude: 'node_modules/**', 23 | }) 24 | ] 25 | }; 26 | -------------------------------------------------------------------------------- /schema/WADO-RS-RetrieveMetadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "WADO-RS RetrieveMetadata", 4 | "description": "http://dicom.nema.org/medical/dicom/current/output/html/part18.html#sect_6.5.6", 5 | "type": "object", 6 | "properties": { 7 | "studyInstanceUID": { 8 | "type": "string" 9 | }, 10 | "seriesInstanceUID": { 11 | "type": "string" 12 | }, 13 | "sopInstanceUID": { 14 | "type": "string" 15 | } 16 | }, 17 | "required": [ "studyInstanceUID" ], 18 | "dependencies": { 19 | "sopInstanceUID": { "required": ["seriesInstanceUID"] } 20 | } 21 | } -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | import { multipartEncode, multipartDecode } from './message.js'; 2 | 3 | 4 | 5 | function isObject(obj) { 6 | return typeof obj === 'object' && obj !== null; 7 | } 8 | 9 | function isEmptyObject(obj) { 10 | return Object.keys(obj).length === 0 && obj.constructor === Object; 11 | } 12 | 13 | function areValidRequestHooks(requestHooks) { 14 | const isValid = 15 | Array.isArray(requestHooks) && 16 | requestHooks.every( 17 | requestHook => 18 | typeof requestHook === 'function' && requestHook.length === 2, 19 | ); 20 | 21 | if (!isValid) { 22 | console.warn( 23 | 'Request hooks should have the following signature: ' + 24 | 'function requestHook(request, metadata) { return request; }', 25 | ); 26 | } 27 | 28 | return isValid; 29 | } 30 | 31 | /** 32 | * @typedef {Object} Request 33 | * @property {XMLHttpRequest} [instance] - If specified, the request to use, otherwise one will be created. 34 | * @property {function(ProgressEvent):void} [progressCallback] - A callback function to handle progress events. 35 | * @property {string} [responseType] - The response type of the request. 36 | * @property {boolean} [withCredentials] - Whether to include credentials in the request. 37 | */ 38 | 39 | /** 40 | * @param {Request} request - Request options. 41 | */ 42 | const getRequestOptions = (request = {}) => ({ 43 | instance: request.instance || new XMLHttpRequest(), 44 | progressCallback: request.progressCallback || false, 45 | withCredentials: request.withCredentials || false, 46 | responseType: request.responseType 47 | }) 48 | 49 | const getFirstResult = result => result[0]; 50 | 51 | const MEDIATYPES = { 52 | DICOM: 'application/dicom', 53 | DICOM_JSON: 'application/dicom+json', 54 | OCTET_STREAM: 'application/octet-stream', 55 | PDF: 'application/pdf', 56 | JPEG: 'image/jpeg', 57 | PNG: 'image/png', 58 | }; 59 | 60 | /** 61 | * debugLog is a function that can be called with console.log arguments, and will 62 | * be conditionally displayed, only when debug logging is enabled. 63 | */ 64 | let debugLog = () => {}; 65 | 66 | /** 67 | * @typedef { import("../types/types").InstanceMetadata } InstanceMetadata 68 | */ 69 | 70 | /** 71 | * A callback with the request instance and metadata information 72 | * of the currently request being executed that should necessarily 73 | * return the given request optionally modified. 74 | * @typedef {function} RequestHook 75 | * @param {XMLHttpRequest} request - The original XMLHttpRequest instance. 76 | * @param {object} metadata - The metadata used by the request. 77 | */ 78 | 79 | /** 80 | * Class for interacting with DICOMweb RESTful services. 81 | */ 82 | class DICOMwebClient { 83 | /** 84 | * @constructor 85 | * @param {Object} options 86 | * @param {String} options.url - URL of the DICOMweb RESTful Service endpoint 87 | * @param {String=} options.qidoURLPrefix - URL path prefix for QIDO-RS 88 | * @param {String=} options.wadoURLPrefix - URL path prefix for WADO-RS 89 | * @param {String=} options.stowURLPrefix - URL path prefix for STOW-RS 90 | * @param {String=} options.username - Username 91 | * @param {String=} options.password - Password 92 | * @param {Object=} options.headers - HTTP headers 93 | * @param {Array.=} options.requestHooks - Request hooks. 94 | * @param {Object=} options.verbose - print to console request warnings and errors, default true 95 | * @param {Object=} options.debug - print to the console debug level information/status updates. 96 | * @param {boolean|String} options.singlepart - retrieve singlepart for the named types. 97 | * The available types are: bulkdata, video, image. true means all. 98 | */ 99 | constructor(options) { 100 | this.baseURL = options.url; 101 | if (!this.baseURL) { 102 | console.error('no DICOMweb base url provided - calls that require a URL will fail'); 103 | } 104 | 105 | if ('username' in options) { 106 | this.username = options.username; 107 | if (!('password' in options)) { 108 | console.error( 109 | 'no password provided to authenticate with DICOMweb service', 110 | ); 111 | } 112 | this.password = options.password; 113 | } 114 | 115 | if ('qidoURLPrefix' in options) { 116 | debugLog(`use URL prefix for QIDO-RS: ${options.qidoURLPrefix}`); 117 | this.qidoURL = `${this.baseURL}/${options.qidoURLPrefix}`; 118 | } else { 119 | this.qidoURL = this.baseURL; 120 | } 121 | 122 | if ('wadoURLPrefix' in options) { 123 | debugLog(`use URL prefix for WADO-RS: ${options.wadoURLPrefix}`); 124 | this.wadoURL = `${this.baseURL}/${options.wadoURLPrefix}`; 125 | } else { 126 | this.wadoURL = this.baseURL; 127 | } 128 | 129 | if ('stowURLPrefix' in options) { 130 | debugLog(`use URL prefix for STOW-RS: ${options.stowURLPrefix}`); 131 | this.stowURL = `${this.baseURL}/${options.stowURLPrefix}`; 132 | } else { 133 | this.stowURL = this.baseURL; 134 | } 135 | 136 | if (options.singlepart) { 137 | debugLog('use singlepart', options.singlepart); 138 | this.singlepart = options.singlepart === true ? 'bulkdata,video,image' : options.singlepart; 139 | } else { 140 | this.singlepart = ''; 141 | } 142 | 143 | if ('requestHooks' in options) { 144 | this.requestHooks = options.requestHooks; 145 | } 146 | 147 | // Headers to pass to requests. 148 | this.headers = options.headers || {}; 149 | 150 | // Optional error interceptor callback to handle any failed request. 151 | this.errorInterceptor = options.errorInterceptor || (() => undefined); 152 | 153 | // Verbose - print to console request warnings and errors, default true 154 | this.verbose = options.verbose !== false; 155 | 156 | this.setDebug(options.debug); 157 | 158 | 159 | } 160 | 161 | /** 162 | * Allows setting the debug log information. 163 | * Note this is different from verbose in that verbose is whether to include warning/error information, defaulting to true 164 | * 165 | * @param {boolean} debugLevel 166 | * @param {function} debugLogFunction to call with the debug output arguments. 167 | */ 168 | setDebug(debugLevel = false, debugLogFunction = null) { 169 | this.debugLevel = !!debugLevel; 170 | debugLog = debugLogFunction || debugLevel ? console.log : () => {}; 171 | } 172 | 173 | /** 174 | * Gets debug flag 175 | * 176 | * @returns true if debug logging is enabled 177 | */ 178 | getDebug() { 179 | return this.debugLevel; 180 | } 181 | 182 | /** 183 | * Sets verbose flag. 184 | * 185 | * @param {Boolean} verbose 186 | */ 187 | setVerbose(verbose) { 188 | this.verbose = verbose; 189 | } 190 | 191 | /** 192 | * Gets verbose flag. 193 | * 194 | * @return {Boolean} verbose 195 | */ 196 | getVerbose() { 197 | return this.verbose; 198 | } 199 | 200 | static _parseQueryParameters(params = {}) { 201 | let queryString = '?'; 202 | Object.keys(params).forEach((key, index) => { 203 | if (index !== 0) { 204 | queryString += '&'; 205 | } 206 | queryString += `${key}=${encodeURIComponent(params[key])}`; 207 | }); 208 | return queryString; 209 | } 210 | 211 | /** 212 | * Performs an HTTP request. 213 | * 214 | * @param {String} url 215 | * @param {String} method 216 | * @param {Object} headers 217 | * @param {Request} [request] - Request Options 218 | * @param {Array} [request.data] - Data that should be stored 219 | * @return {*} 220 | * @private 221 | */ 222 | _httpRequest(url, method, headers = {}, request = {}) { 223 | const { errorInterceptor, requestHooks } = this; 224 | return new Promise((resolve, reject) => { 225 | let requestInstance = request.instance ? request.instance : new XMLHttpRequest(); 226 | 227 | requestInstance.open(method, url, true); 228 | if ('responseType' in request) { 229 | requestInstance.responseType = request.responseType; 230 | } 231 | 232 | if (typeof headers === 'object') { 233 | Object.keys(headers).forEach(key => { 234 | requestInstance.setRequestHeader(key, headers[key]); 235 | }); 236 | } 237 | 238 | // now add custom headers from the user 239 | // (e.g. access tokens) 240 | const userHeaders = this.headers; 241 | Object.keys(userHeaders).forEach(key => { 242 | requestInstance.setRequestHeader(key, userHeaders[key]); 243 | }); 244 | 245 | // Event triggered when upload starts 246 | requestInstance.onloadstart = function onloadstart() { 247 | debugLog('upload started: ', url) 248 | }; 249 | 250 | // Event triggered when upload ends 251 | requestInstance.onloadend = function onloadend() { 252 | debugLog('upload finished') 253 | }; 254 | 255 | // Handle response message 256 | requestInstance.onreadystatechange = () => { 257 | if (requestInstance.readyState === 4) { 258 | if (requestInstance.status === 200) { 259 | const contentType = requestInstance.getResponseHeader('Content-Type'); 260 | // Automatically distinguishes between multipart and singlepart in an array buffer, and 261 | // converts them into a consistent type. 262 | if (contentType && contentType.indexOf('multipart') !== -1) { 263 | resolve(multipartDecode(requestInstance.response)); 264 | } else if (requestInstance.responseType === 'arraybuffer') { 265 | resolve([requestInstance.response]); 266 | } else { 267 | resolve(requestInstance.response); 268 | } 269 | } else if (requestInstance.status === 202) { 270 | if (this.verbose) { 271 | console.warn('some resources already existed: ', requestInstance); 272 | } 273 | resolve(requestInstance.response); 274 | } else if (requestInstance.status === 204) { 275 | if (this.verbose) { 276 | console.warn('empty response for request: ', requestInstance); 277 | } 278 | resolve([]); 279 | } else { 280 | const error = new Error('request failed'); 281 | error.request = requestInstance; 282 | error.response = requestInstance.response; 283 | error.status = requestInstance.status; 284 | if (this.verbose) { 285 | console.error('request failed: ', requestInstance); 286 | console.error(error); 287 | console.error(error.response); 288 | } 289 | 290 | errorInterceptor(error); 291 | 292 | reject(error); 293 | } 294 | } 295 | }; 296 | 297 | // Event triggered while download progresses 298 | if ('progressCallback' in request) { 299 | if (typeof request.progressCallback === 'function') { 300 | requestInstance.onprogress = request.progressCallback; 301 | } 302 | } 303 | 304 | if (requestHooks && areValidRequestHooks(requestHooks)) { 305 | const combinedHeaders = Object.assign({}, headers, this.headers); 306 | const metadata = { method, url, headers: combinedHeaders }; 307 | const pipeRequestHooks = functions => args => 308 | functions.reduce((props, fn) => fn(props, metadata), args); 309 | const pipedRequest = pipeRequestHooks(requestHooks); 310 | requestInstance = pipedRequest(requestInstance); 311 | } 312 | 313 | // Add withCredentials to request if needed 314 | if ('withCredentials' in request) { 315 | if (request.withCredentials) { 316 | requestInstance.withCredentials = true; 317 | } 318 | } 319 | 320 | if ('data' in request) { 321 | requestInstance.send(request.data); 322 | } else { 323 | requestInstance.send(); 324 | } 325 | }); 326 | } 327 | 328 | /** 329 | * Performs an HTTP GET request. 330 | * 331 | * @param {String} url 332 | * @param {Object} headers 333 | * @param {Request} request - Request Options 334 | * @return {*} 335 | * @private 336 | */ 337 | _httpGet(url, headers, request) { 338 | return this._httpRequest(url, 'get', headers, request); 339 | } 340 | 341 | /** 342 | * Performs an HTTP GET request that accepts a message with application/json 343 | * media type. 344 | * 345 | * @param {String} url 346 | * @param {Object} params 347 | * @param {Request} request - Request Options 348 | * @return {*} 349 | * @private 350 | */ 351 | _httpGetApplicationJson(url, params = {}, request = {}) { 352 | let urlWithQueryParams = url; 353 | 354 | if (typeof params === 'object') { 355 | if (!isEmptyObject(params)) { 356 | urlWithQueryParams += DICOMwebClient._parseQueryParameters(params); 357 | } 358 | } 359 | const headers = { Accept: MEDIATYPES.DICOM_JSON }; 360 | 361 | request.responseType = 'json'; 362 | 363 | return this._httpGet( 364 | urlWithQueryParams, 365 | headers, 366 | request 367 | ); 368 | } 369 | 370 | /** 371 | * Performs an HTTP GET request that accepts a message with application/pdf 372 | * media type. 373 | * 374 | * @param {String} url 375 | * @param {Object} params 376 | * @param {Request} request - Request Options 377 | * @return {*} 378 | * @private 379 | */ 380 | _httpGetApplicationPdf(url, params = {}, request = {}) { 381 | let urlWithQueryParams = url; 382 | 383 | if (typeof params === 'object') { 384 | if (!isEmptyObject(params)) { 385 | urlWithQueryParams += DICOMwebClient._parseQueryParameters(params); 386 | } 387 | } 388 | const headers = { Accept: MEDIATYPES.PDF }; 389 | 390 | request.responseType = 'json' 391 | 392 | return this._httpGet( 393 | urlWithQueryParams, 394 | headers, 395 | request, 396 | ); 397 | } 398 | 399 | /** 400 | * Performs an HTTP GET request that accepts a message with an image 401 | media type. 402 | * 403 | * @param {String} url 404 | * @param {Object[]} mediaTypes 405 | * @param {Object} params 406 | * @param {Request} request - Request Options 407 | * @return {*} 408 | * @private 409 | */ 410 | _httpGetImage( 411 | url, 412 | mediaTypes, 413 | params = {}, 414 | request = {} 415 | ) { 416 | let urlWithQueryParams = url; 417 | 418 | if (typeof params === 'object') { 419 | if (!isEmptyObject(params)) { 420 | urlWithQueryParams += DICOMwebClient._parseQueryParameters(params); 421 | } 422 | } 423 | 424 | const supportedMediaTypes = [ 425 | 'image/', 426 | 'image/*', 427 | 'image/jpeg', 428 | 'image/jp2', 429 | 'image/gif', 430 | 'image/png', 431 | ]; 432 | 433 | const acceptHeaderFieldValue = DICOMwebClient._buildAcceptHeaderFieldValue( 434 | mediaTypes, 435 | supportedMediaTypes, 436 | ); 437 | const headers = { Accept: acceptHeaderFieldValue }; 438 | request.responseType = 'arraybuffer' 439 | 440 | return this._httpGet( 441 | urlWithQueryParams, 442 | headers, 443 | request, 444 | ); 445 | } 446 | 447 | /** 448 | * Performs an HTTP GET request that accepts a message with a text 449 | media type. 450 | * 451 | * @param {String} url 452 | * @param {Object[]} mediaTypes 453 | * @param {Object} params 454 | * @param {Request} request - Request Options 455 | * @return {*} 456 | * @private 457 | */ 458 | _httpGetText( 459 | url, 460 | mediaTypes, 461 | params = {}, 462 | request = {} 463 | ) { 464 | let urlWithQueryParams = url; 465 | 466 | if (typeof params === 'object') { 467 | if (!isEmptyObject(params)) { 468 | urlWithQueryParams += DICOMwebClient._parseQueryParameters(params); 469 | } 470 | } 471 | 472 | const supportedMediaTypes = [ 473 | 'text/', 474 | 'text/*', 475 | 'text/html', 476 | 'text/plain', 477 | 'text/rtf', 478 | 'text/xml', 479 | ]; 480 | 481 | const acceptHeaderFieldValue = DICOMwebClient._buildAcceptHeaderFieldValue( 482 | mediaTypes, 483 | supportedMediaTypes, 484 | ); 485 | const headers = { Accept: acceptHeaderFieldValue }; 486 | request.responseType = 'arraybuffer'; 487 | 488 | return this._httpGet( 489 | urlWithQueryParams, 490 | headers, 491 | request, 492 | ); 493 | } 494 | 495 | /** 496 | * Performs an HTTP GET request that accepts a message with a video 497 | media type. 498 | * 499 | * @param {String} url 500 | * @param {Object[]} mediaTypes 501 | * @param {Object} params 502 | * @param {Request} request - Request Options 503 | * @return {*} 504 | * @private 505 | */ 506 | _httpGetVideo( 507 | url, 508 | mediaTypes, 509 | params = {}, 510 | request = {} 511 | ) { 512 | let urlWithQueryParams = url; 513 | 514 | if (typeof params === 'object') { 515 | if (!isEmptyObject(params)) { 516 | urlWithQueryParams += DICOMwebClient._parseQueryParameters(params); 517 | } 518 | } 519 | 520 | const supportedMediaTypes = [ 521 | 'video/', 522 | 'video/*', 523 | 'video/mpeg', 524 | 'video/mp4', 525 | 'video/H265', 526 | ]; 527 | 528 | const acceptHeaderFieldValue = DICOMwebClient._buildAcceptHeaderFieldValue( 529 | mediaTypes, 530 | supportedMediaTypes, 531 | ); 532 | const headers = { Accept: acceptHeaderFieldValue }; 533 | request.responseType = 'arraybuffer'; 534 | 535 | return this._httpGet( 536 | urlWithQueryParams, 537 | headers, 538 | request, 539 | ); 540 | } 541 | 542 | /** 543 | * Asserts that a given media type is valid. 544 | * 545 | * @params {String} mediaType media type 546 | */ 547 | static _assertMediaTypeIsValid(mediaType) { 548 | if (!mediaType) { 549 | throw new Error(`Not a valid media type: ${mediaType}`); 550 | } 551 | 552 | const sepIndex = mediaType.indexOf('/'); 553 | if (sepIndex === -1) { 554 | throw new Error(`Not a valid media type: ${mediaType}`); 555 | } 556 | 557 | const mediaTypeType = mediaType.slice(0, sepIndex); 558 | const types = ['application', 'image', 'text', 'video']; 559 | if (!types.includes(mediaTypeType)) { 560 | throw new Error(`Not a valid media type: ${mediaType}`); 561 | } 562 | 563 | if (mediaType.slice(sepIndex + 1).includes('/')) { 564 | throw new Error(`Not a valid media type: ${mediaType}`); 565 | } 566 | } 567 | 568 | /** 569 | * Performs an HTTP GET request that accepts a multipart message with an image media type. 570 | * 571 | * @param {String} url - Unique resource locator 572 | * @param {Object[]} mediaTypes - Acceptable media types and optionally the UIDs of the 573 | corresponding transfer syntaxes 574 | * @param {Array} byteRange - Start and end of byte range 575 | * @param {Object} params - Additional HTTP GET query parameters 576 | * @param {Boolean} rendered - Whether resource should be requested using rendered media types 577 | * @param {Request} request - Request Options 578 | * @private 579 | * @returns {Promise} Content of HTTP message body parts 580 | */ 581 | _httpGetMultipartImage( 582 | url, 583 | mediaTypes, 584 | byteRange, 585 | params, 586 | rendered = false, 587 | request = {} 588 | ) { 589 | const headers = {}; 590 | let supportedMediaTypes; 591 | if (rendered) { 592 | supportedMediaTypes = [ 593 | 'image/jpeg', 594 | 'image/gif', 595 | 'image/png', 596 | 'image/jp2', 597 | ]; 598 | } else { 599 | supportedMediaTypes = { 600 | '1.2.840.10008.1.2.5': ['image/x-dicom-rle'], 601 | '1.2.840.10008.1.2.4.50': ['image/jpeg'], 602 | '1.2.840.10008.1.2.4.51': ['image/jpeg'], 603 | '1.2.840.10008.1.2.4.57': ['image/jpeg'], 604 | '1.2.840.10008.1.2.4.70': ['image/jpeg'], 605 | '1.2.840.10008.1.2.4.80': ['image/x-jls', 'image/jls'], 606 | '1.2.840.10008.1.2.4.81': ['image/x-jls', 'image/jls'], 607 | '1.2.840.10008.1.2.4.90': ['image/jp2'], 608 | '1.2.840.10008.1.2.4.91': ['image/jp2'], 609 | '1.2.840.10008.1.2.4.92': ['image/jpx'], 610 | '1.2.840.10008.1.2.4.93': ['image/jpx'], 611 | }; 612 | 613 | if (byteRange) { 614 | headers.Range = DICOMwebClient._buildRangeHeaderFieldValue(byteRange); 615 | } 616 | } 617 | 618 | headers.Accept = DICOMwebClient._buildMultipartAcceptHeaderFieldValue( 619 | mediaTypes, 620 | supportedMediaTypes, 621 | ); 622 | 623 | request.responseType = 'arraybuffer'; 624 | 625 | return this._httpGet(url, headers, request); 626 | } 627 | 628 | /** 629 | * Performs an HTTP GET request that accepts a multipart message with a video media type. 630 | * 631 | * @param {String} url - Unique resource locator 632 | * @param {Object[]} mediaTypes - Acceptable media types and optionally the UIDs of the 633 | corresponding transfer syntaxes 634 | * @param {Array} byteRange - Start and end of byte range 635 | * @param {Object} params - Additional HTTP GET query parameters 636 | * @param {Boolean} rendered - Whether resource should be requested using rendered media types 637 | * @param {Request} request - Request Options 638 | * @private 639 | * @returns {Promise} Content of HTTP message body parts 640 | */ 641 | _httpGetMultipartVideo( 642 | url, 643 | mediaTypes, 644 | byteRange, 645 | params, 646 | rendered = false, 647 | request = {} 648 | ) { 649 | const headers = {}; 650 | let supportedMediaTypes; 651 | if (rendered) { 652 | supportedMediaTypes = [ 653 | 'video/', 654 | 'video/*', 655 | 'video/mpeg2', 656 | 'video/mp4', 657 | 'video/H265', 658 | ]; 659 | } else { 660 | supportedMediaTypes = { 661 | '1.2.840.10008.1.2.4.100': ['video/mpeg2'], 662 | '1.2.840.10008.1.2.4.101': ['video/mpeg2'], 663 | '1.2.840.10008.1.2.4.102': ['video/mp4'], 664 | '1.2.840.10008.1.2.4.103': ['video/mp4'], 665 | '1.2.840.10008.1.2.4.104': ['video/mp4'], 666 | '1.2.840.10008.1.2.4.105': ['video/mp4'], 667 | '1.2.840.10008.1.2.4.106': ['video/mp4'], 668 | }; 669 | 670 | if (byteRange) { 671 | headers.Range = DICOMwebClient._buildRangeHeaderFieldValue(byteRange); 672 | } 673 | } 674 | 675 | headers.Accept = DICOMwebClient._buildMultipartAcceptHeaderFieldValue( 676 | mediaTypes, 677 | supportedMediaTypes, 678 | ); 679 | 680 | request.responseType = 'arraybuffer'; 681 | 682 | return this._httpGet(url, headers, request); 683 | } 684 | 685 | /** 686 | * Performs an HTTP GET request that accepts a multipart message 687 | * with a application/dicom media type. 688 | * 689 | * @param {String} url - Unique resource locator 690 | * @param {Object[]} mediaTypes - Acceptable media types and optionally the UIDs of the 691 | corresponding transfer syntaxes 692 | * @param {Object} params - Additional HTTP GET query parameters 693 | * @param {Request} request - request options 694 | * @private 695 | * @returns {Promise} Content of HTTP message body parts 696 | */ 697 | _httpGetMultipartApplicationDicom( 698 | url, 699 | mediaTypes, 700 | params, 701 | request = {} 702 | ) { 703 | const headers = {}; 704 | const defaultMediaType = 'application/dicom'; 705 | const supportedMediaTypes = { 706 | '1.2.840.10008.1.2.1': [defaultMediaType], 707 | '1.2.840.10008.1.2.5': [defaultMediaType], 708 | '1.2.840.10008.1.2.4.50': [defaultMediaType], 709 | '1.2.840.10008.1.2.4.51': [defaultMediaType], 710 | '1.2.840.10008.1.2.4.57': [defaultMediaType], 711 | '1.2.840.10008.1.2.4.70': [defaultMediaType], 712 | '1.2.840.10008.1.2.4.80': [defaultMediaType], 713 | '1.2.840.10008.1.2.4.81': [defaultMediaType], 714 | '1.2.840.10008.1.2.4.90': [defaultMediaType], 715 | '1.2.840.10008.1.2.4.91': [defaultMediaType], 716 | '1.2.840.10008.1.2.4.92': [defaultMediaType], 717 | '1.2.840.10008.1.2.4.93': [defaultMediaType], 718 | '1.2.840.10008.1.2.4.100': [defaultMediaType], 719 | '1.2.840.10008.1.2.4.101': [defaultMediaType], 720 | '1.2.840.10008.1.2.4.102': [defaultMediaType], 721 | '1.2.840.10008.1.2.4.103': [defaultMediaType], 722 | '1.2.840.10008.1.2.4.104': [defaultMediaType], 723 | '1.2.840.10008.1.2.4.105': [defaultMediaType], 724 | '1.2.840.10008.1.2.4.106': [defaultMediaType], 725 | }; 726 | 727 | let acceptableMediaTypes = mediaTypes; 728 | if (!mediaTypes) { 729 | acceptableMediaTypes = [{ mediaType: defaultMediaType }]; 730 | } 731 | 732 | headers.Accept = DICOMwebClient._buildMultipartAcceptHeaderFieldValue( 733 | acceptableMediaTypes, 734 | supportedMediaTypes, 735 | ); 736 | 737 | request.responseType = 'arraybuffer'; 738 | 739 | return this._httpGet(url, headers, request); 740 | } 741 | 742 | /** 743 | * Performs an HTTP GET request that accepts a multipart message 744 | * with a application/octet-stream, OR any of the equivalencies for that (eg 745 | * application/pdf etc) 746 | * 747 | * @param {String} url - Unique resource locator 748 | * @param {Object[]} mediaTypes - Acceptable media types and optionally the UIDs of the 749 | corresponding transfer syntaxes 750 | * @param {Array} byteRange start and end of byte range 751 | * @param {Object} params - Additional HTTP GET query parameters 752 | * @param {Request} request - Request Options 753 | * @private 754 | * @returns {Promise} Content of HTTP message body parts 755 | */ 756 | _httpGetMultipartApplicationOctetStream( 757 | url, 758 | mediaTypes, 759 | byteRange, 760 | params, 761 | request = {} 762 | ) { 763 | const headers = {}; 764 | const defaultMediaType = 'application/octet-stream'; 765 | const supportedMediaTypes = { 766 | '1.2.840.10008.1.2.1': [...Object.values(MEDIATYPES)], 767 | }; 768 | 769 | let acceptableMediaTypes = mediaTypes; 770 | if (!mediaTypes) { 771 | acceptableMediaTypes = [{ mediaType: defaultMediaType }]; 772 | } 773 | 774 | if (byteRange) { 775 | headers.Range = DICOMwebClient._buildRangeHeaderFieldValue(byteRange); 776 | } 777 | 778 | headers.Accept = DICOMwebClient._buildMultipartAcceptHeaderFieldValue( 779 | acceptableMediaTypes, 780 | supportedMediaTypes, 781 | ); 782 | 783 | request.responseType = 'arraybuffer' 784 | 785 | return this._httpGet(url, headers, request); 786 | } 787 | 788 | /** 789 | * Performs an HTTP POST request. 790 | * 791 | * @param {String} url - Unique resource locator 792 | * @param {Object} headers - HTTP header fields 793 | * @param {Array} data - Data that should be stored 794 | * @param {Request} request - Request Options 795 | * @private 796 | * @returns {Promise} Response 797 | */ 798 | _httpPost(url, headers, data, request) { 799 | return this._httpRequest(url, 'post', headers, { 800 | ...request, data 801 | }); 802 | } 803 | 804 | /** 805 | * Performs an HTTP POST request with content-type application/dicom+json. 806 | * 807 | * @param {String} url - Unique resource locator 808 | * @param {Object} headers - HTTP header fields 809 | * @param {Array} data - Data that should be stored 810 | * @param {Request} request - Request Options 811 | * @private 812 | * @returns {Promise} Response 813 | */ 814 | _httpPostApplicationJson(url, data, request) { 815 | const headers = { 'Content-Type': MEDIATYPES.DICOM_JSON }; 816 | return this._httpPost( 817 | url, 818 | headers, 819 | data, 820 | request, 821 | ); 822 | } 823 | 824 | /** 825 | * Parses media type and extracts its type and subtype. 826 | * 827 | * @param {String} mediaType - HTTP media type (e.g. image/jpeg) 828 | * @private 829 | * @returns {String[]} Media type and subtype 830 | */ 831 | static _parseMediaType(mediaType) { 832 | DICOMwebClient._assertMediaTypeIsValid(mediaType); 833 | 834 | return mediaType.split('/'); 835 | } 836 | 837 | /** 838 | * Builds an accept header field value for HTTP GET request messages. 839 | * 840 | * @param {Object[]} mediaTypes - Acceptable media types 841 | * @param {Object[]} supportedMediaTypes - Supported media types 842 | * @return {*} 843 | * @private 844 | */ 845 | static _buildAcceptHeaderFieldValue(mediaTypes, supportedMediaTypes) { 846 | if (!Array.isArray(mediaTypes)) { 847 | throw new Error('Acceptable media types must be provided as an Array'); 848 | } 849 | 850 | const fieldValueParts = mediaTypes.map(item => { 851 | const { mediaType } = item; 852 | 853 | DICOMwebClient._assertMediaTypeIsValid(mediaType); 854 | if (!supportedMediaTypes.includes(mediaType)) { 855 | throw new Error( 856 | `Media type ${mediaType} is not supported for requested resource`, 857 | ); 858 | } 859 | 860 | return mediaType; 861 | }); 862 | 863 | return fieldValueParts.join(', '); 864 | } 865 | 866 | /** 867 | * Builds an accept header field value for HTTP GET multipart request 868 | * messages. Will throw an exception if no media types are found which are acceptable, 869 | * but will only log a verbose level message when types are specified which are 870 | * not acceptable. This allows requesting several types with having to know 871 | * whether they are all acceptable or not. 872 | * 873 | * @param {Object[]} mediaTypes - Acceptable media types 874 | * @param {Object[]} supportedMediaTypes - Supported media types 875 | * @private 876 | */ 877 | static _buildMultipartAcceptHeaderFieldValue( 878 | mediaTypes, 879 | supportedMediaTypes, 880 | ) { 881 | if (!Array.isArray(mediaTypes)) { 882 | throw new Error('Acceptable media types must be provided as an Array'); 883 | } 884 | 885 | if (!Array.isArray(supportedMediaTypes) && !isObject(supportedMediaTypes)) { 886 | throw new Error( 887 | 'Supported media types must be provided as an Array or an Object', 888 | ); 889 | } 890 | 891 | const fieldValueParts = []; 892 | 893 | mediaTypes.forEach(item => { 894 | const { transferSyntaxUID, mediaType } = item; 895 | DICOMwebClient._assertMediaTypeIsValid(mediaType); 896 | let fieldValue = `multipart/related; type="${mediaType}"`; 897 | 898 | if (isObject(supportedMediaTypes)) { 899 | // SupportedMediaTypes is a lookup table that maps Transfer Syntax UID 900 | // to one or more Media Types 901 | if ( 902 | !Object.values(supportedMediaTypes) 903 | .flat(1) 904 | .includes(mediaType) 905 | ) { 906 | if (!mediaType.endsWith('/*') || !mediaType.endsWith('/')) { 907 | debugLog( 908 | `Media type ${mediaType} is not supported for requested resource`, 909 | ); 910 | return; 911 | } 912 | } 913 | 914 | if (transferSyntaxUID) { 915 | if (transferSyntaxUID !== '*') { 916 | if (!Object.keys(supportedMediaTypes).includes(transferSyntaxUID)) { 917 | throw new Error( 918 | `Transfer syntax ${transferSyntaxUID} is not supported for requested resource`, 919 | ); 920 | } 921 | 922 | const expectedMediaTypes = supportedMediaTypes[transferSyntaxUID]; 923 | 924 | if (!expectedMediaTypes.includes(mediaType)) { 925 | const actualType = DICOMwebClient._parseMediaType(mediaType)[0]; 926 | expectedMediaTypes.map(expectedMediaType => { 927 | const expectedType = DICOMwebClient._parseMediaType( 928 | expectedMediaType, 929 | )[0]; 930 | const haveSameType = actualType === expectedType; 931 | 932 | if ( 933 | haveSameType && 934 | (mediaType.endsWith('/*') || mediaType.endsWith('/')) 935 | ) { 936 | return; 937 | } 938 | 939 | throw new Error( 940 | `Transfer syntax ${transferSyntaxUID} is not supported for requested resource`, 941 | ); 942 | }); 943 | } 944 | } 945 | 946 | fieldValue += `; transfer-syntax=${transferSyntaxUID}`; 947 | } 948 | } else if ( 949 | Array.isArray(supportedMediaTypes) && 950 | !supportedMediaTypes.includes(mediaType) 951 | ) { 952 | if( this.verbose ) { 953 | console.warn( 954 | `Media type ${mediaType} is not supported for requested resource`, 955 | ); 956 | } 957 | return; 958 | } 959 | 960 | fieldValueParts.push(fieldValue); 961 | }); 962 | 963 | if( !fieldValueParts.length ) { 964 | throw new Error(`No acceptable media types found among ${JSON.stringify(mediaTypes)}`); 965 | } 966 | 967 | return fieldValueParts.join(', '); 968 | } 969 | 970 | /** 971 | * Builds a range header field value for HTTP GET request messages. 972 | * 973 | * @param {Array} byteRange - Start and end of byte range 974 | * @returns {String} Range header field value 975 | * @private 976 | */ 977 | static _buildRangeHeaderFieldValue(byteRange = []) { 978 | if (byteRange.length === 1) { 979 | return `bytes=${byteRange[0]}-`; 980 | } 981 | if (byteRange.length === 2) { 982 | return `bytes=${byteRange[0]}-${byteRange[1]}`; 983 | } 984 | 985 | return 'bytes=0-'; 986 | } 987 | 988 | /** 989 | * Gets types that are shared among acceptable media types. 990 | * 991 | * @param {Object[]} mediaTypes - Acceptable media types and optionally the UIDs of the 992 | corresponding transfer syntaxes 993 | * @private 994 | * @returns {String[]} Types that are shared among acceptable media types 995 | */ 996 | static _getSharedMediaTypes(mediaTypes) { 997 | const types = new Set(); 998 | 999 | if (!mediaTypes || !mediaTypes.length) { 1000 | return types; 1001 | } 1002 | 1003 | mediaTypes.forEach(item => { 1004 | const { mediaType } = item; 1005 | const type = DICOMwebClient._parseMediaType(mediaType)[0]; 1006 | types.add(`${type}/`); 1007 | }); 1008 | 1009 | return Array.from(types); 1010 | } 1011 | 1012 | /** 1013 | * Gets common base type of acceptable media types and asserts that only 1014 | one type is specified. For example, ``("image/jpeg", "image/jp2")`` 1015 | will pass, but ``("image/jpeg", "video/mpeg2")`` will raise an 1016 | exception. 1017 | * 1018 | * @param {Object[]} mediaTypes - Acceptable media types and optionally the UIDs of the 1019 | corresponding transfer syntaxes 1020 | * @private 1021 | * @returns {String[]} Common media type, eg `image/` for the above example. 1022 | */ 1023 | static _getCommonMediaType(mediaTypes) { 1024 | if (!mediaTypes || !mediaTypes.length) { 1025 | throw new Error('No acceptable media types provided'); 1026 | } 1027 | 1028 | const sharedMediaTypes = DICOMwebClient._getSharedMediaTypes(mediaTypes); 1029 | if (sharedMediaTypes.length === 0) { 1030 | throw new Error('No common acceptable media type could be identified.'); 1031 | } else if (sharedMediaTypes.length > 1) { 1032 | throw new Error('Acceptable media types must have the same type.'); 1033 | } 1034 | 1035 | return sharedMediaTypes[0]; 1036 | } 1037 | 1038 | /** 1039 | * Searches for DICOM studies. 1040 | * 1041 | * @param {Object} options 1042 | * @param {Object} [options.queryParams] - HTTP query parameters 1043 | * @param {Request} request - Request Options 1044 | * @return {Object[]} Study representations (http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.7.html#table_6.7.1-2) 1045 | */ 1046 | searchForStudies(options = {}) { 1047 | debugLog('search for studies'); 1048 | let url = `${this.qidoURL}/studies`; 1049 | if ('queryParams' in options) { 1050 | url += DICOMwebClient._parseQueryParameters(options.queryParams); 1051 | } 1052 | const request = getRequestOptions(options.request) 1053 | return this._httpGetApplicationJson(url, {}, request); 1054 | } 1055 | 1056 | /** 1057 | * Retrieves metadata for a DICOM study. 1058 | * 1059 | * @param {Object} options 1060 | * @param {String} options.studyInstanceUID - Study Instance UID 1061 | * @param {Request} options.request - Request Options 1062 | * @returns {Promise} Metadata elements in DICOM JSON format for each instance 1063 | belonging to the study 1064 | */ 1065 | retrieveStudyMetadata(options) { 1066 | if (!('studyInstanceUID' in options)) { 1067 | throw new Error( 1068 | 'Study Instance UID is required for retrieval of study metadata', 1069 | ); 1070 | } 1071 | debugLog(`retrieve metadata of study ${options.studyInstanceUID}`); 1072 | const url = `${this.wadoURL}/studies/${options.studyInstanceUID}/metadata`; 1073 | const request =getRequestOptions(options.request) 1074 | return this._httpGetApplicationJson(url, {}, request); 1075 | } 1076 | 1077 | /** 1078 | * Searches for DICOM series. 1079 | * 1080 | * @param {Object} options 1081 | * @param {String} [options.studyInstanceUID] - Study Instance UID 1082 | * @param {Object} [options.queryParams] - HTTP query parameters 1083 | * @param {Request} request - Request Options 1084 | * @returns {Object[]} Series representations (http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.7.html#table_6.7.1-2a) 1085 | */ 1086 | searchForSeries(options = {}) { 1087 | let url = this.qidoURL; 1088 | if ('studyInstanceUID' in options) { 1089 | debugLog(`search series of study ${options.studyInstanceUID}`); 1090 | url += `/studies/${options.studyInstanceUID}`; 1091 | } 1092 | url += '/series'; 1093 | if ('queryParams' in options) { 1094 | url += DICOMwebClient._parseQueryParameters(options.queryParams); 1095 | } 1096 | 1097 | const request = getRequestOptions(options.request) 1098 | 1099 | return this._httpGetApplicationJson(url, {}, request); 1100 | } 1101 | 1102 | /** 1103 | * Retrieves metadata for a DICOM series. 1104 | * 1105 | * @param {Object} options 1106 | * @param {String} options.studyInstanceUID - Study Instance UID 1107 | * @param {String} options.seriesInstanceUID - Series Instance UID 1108 | * @param {Request} options.request - Request Options 1109 | * @returns {Promise} Metadata elements in DICOM JSON format for each instance 1110 | belonging to the series 1111 | */ 1112 | retrieveSeriesMetadata(options) { 1113 | if (!('studyInstanceUID' in options)) { 1114 | throw new Error( 1115 | 'Study Instance UID is required for retrieval of series metadata', 1116 | ); 1117 | } 1118 | if (!('seriesInstanceUID' in options)) { 1119 | throw new Error( 1120 | 'Series Instance UID is required for retrieval of series metadata', 1121 | ); 1122 | } 1123 | 1124 | debugLog(`retrieve metadata of series ${options.seriesInstanceUID}`); 1125 | const url = `${this.wadoURL}/studies/${options.studyInstanceUID}/series/${options.seriesInstanceUID}/metadata`; 1126 | 1127 | const request = getRequestOptions(options.request) 1128 | return this._httpGetApplicationJson(url, {}, false, withCredentials); 1129 | } 1130 | 1131 | /** 1132 | * Searches for DICOM Instances. 1133 | * 1134 | * @param {Object} options 1135 | * @param {String} [options.studyInstanceUID] - Study Instance UID 1136 | * @param {String} [options.seriesInstanceUID] - Series Instance UID 1137 | * @param {Object} [options.queryParams] - HTTP query parameters 1138 | * @param {Request} [options.request] - Request Options 1139 | * @returns {Object[]} Instance representations (http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.7.html#table_6.7.1-2b) 1140 | */ 1141 | searchForInstances(options = {}) { 1142 | let url = this.qidoURL; 1143 | let withCredentials = false; 1144 | if ('studyInstanceUID' in options) { 1145 | url += `/studies/${options.studyInstanceUID}`; 1146 | if ('seriesInstanceUID' in options) { 1147 | debugLog( 1148 | `search for instances of series ${options.seriesInstanceUID}`, 1149 | ); 1150 | url += `/series/${options.seriesInstanceUID}`; 1151 | } else { 1152 | debugLog( 1153 | `search for instances of study ${options.studyInstanceUID}`, 1154 | ); 1155 | } 1156 | } else { 1157 | debugLog('search for instances'); 1158 | } 1159 | url += '/instances'; 1160 | if ('queryParams' in options) { 1161 | url += DICOMwebClient._parseQueryParameters(options.queryParams); 1162 | } 1163 | const request = getRequestOptions(options.request) 1164 | return this._httpGetApplicationJson(url, {}, request); 1165 | } 1166 | 1167 | /** Returns a WADO-URI URL for an instance 1168 | * 1169 | * @param {Object} options 1170 | * @param {String} options.studyInstanceUID - Study Instance UID 1171 | * @param {String} options.seriesInstanceUID - Series Instance UID 1172 | * @param {String} options.sopInstanceUID - SOP Instance UID 1173 | * @returns {String} WADO-URI URL 1174 | */ 1175 | buildInstanceWadoURIUrl(options) { 1176 | if (!('studyInstanceUID' in options)) { 1177 | throw new Error('Study Instance UID is required.'); 1178 | } 1179 | if (!('seriesInstanceUID' in options)) { 1180 | throw new Error('Series Instance UID is required.'); 1181 | } 1182 | if (!('sopInstanceUID' in options)) { 1183 | throw new Error('SOP Instance UID is required.'); 1184 | } 1185 | 1186 | const contentType = options.contentType || MEDIATYPES.DICOM; 1187 | const transferSyntax = options.transferSyntax || '*'; 1188 | const params = []; 1189 | 1190 | params.push('requestType=WADO'); 1191 | params.push(`studyUID=${options.studyInstanceUID}`); 1192 | params.push(`seriesUID=${options.seriesInstanceUID}`); 1193 | params.push(`objectUID=${options.sopInstanceUID}`); 1194 | params.push(`contentType=${contentType}`); 1195 | params.push(`transferSyntax=${transferSyntax}`); 1196 | 1197 | const paramString = params.join('&'); 1198 | 1199 | return `${this.wadoURL}?${paramString}`; 1200 | } 1201 | 1202 | /** 1203 | * Retrieves metadata for a DICOM Instance. 1204 | * 1205 | * @param {Object} options object 1206 | * @param {String} options.studyInstanceUID - Study Instance UID 1207 | * @param {String} options.seriesInstanceUID - Series Instance UID 1208 | * @param {String} options.sopInstanceUID - SOP Instance UID 1209 | * @param {Request} request - Request Options 1210 | * @returns {Promise} metadata elements in DICOM JSON format 1211 | */ 1212 | retrieveInstanceMetadata(options) { 1213 | if (!('studyInstanceUID' in options)) { 1214 | throw new Error( 1215 | 'Study Instance UID is required for retrieval of instance metadata', 1216 | ); 1217 | } 1218 | if (!('seriesInstanceUID' in options)) { 1219 | throw new Error( 1220 | 'Series Instance UID is required for retrieval of instance metadata', 1221 | ); 1222 | } 1223 | if (!('sopInstanceUID' in options)) { 1224 | throw new Error( 1225 | 'SOP Instance UID is required for retrieval of instance metadata', 1226 | ); 1227 | } 1228 | debugLog(`retrieve metadata of instance ${options.sopInstanceUID}`); 1229 | const url = `${this.wadoURL}/studies/${options.studyInstanceUID}/series/${options.seriesInstanceUID}/instances/${options.sopInstanceUID}/metadata`; 1230 | 1231 | const request = getRequestOptions(options.request) 1232 | return this._httpGetApplicationJson(url, {}, request); 1233 | } 1234 | 1235 | /** 1236 | * Retrieves frames for a DICOM Instance. 1237 | * @param {Object} options options object 1238 | * @param {String} options.studyInstanceUID - Study Instance UID 1239 | * @param {String} options.seriesInstanceUID - Series Instance UID 1240 | * @param {String} options.sopInstanceUID - SOP Instance UID 1241 | * @param {String} options.frameNumbers - One-based indices of Frame Items 1242 | * @param {Request} options.request - Request Options 1243 | * @returns {Array} frame items as byte arrays of the pixel data element 1244 | */ 1245 | retrieveInstanceFrames(options) { 1246 | if (!('studyInstanceUID' in options)) { 1247 | throw new Error( 1248 | 'Study Instance UID is required for retrieval of instance frames', 1249 | ); 1250 | } 1251 | if (!('seriesInstanceUID' in options)) { 1252 | throw new Error( 1253 | 'Series Instance UID is required for retrieval of instance frames', 1254 | ); 1255 | } 1256 | if (!('sopInstanceUID' in options)) { 1257 | throw new Error( 1258 | 'SOP Instance UID is required for retrieval of instance frames', 1259 | ); 1260 | } 1261 | if (!('frameNumbers' in options)) { 1262 | throw new Error( 1263 | 'frame numbers are required for retrieval of instance frames', 1264 | ); 1265 | } 1266 | debugLog( 1267 | `retrieve frames ${options.frameNumbers.toString()} of instance ${ 1268 | options.sopInstanceUID 1269 | }`, 1270 | ); 1271 | const url = `${this.wadoURL}/studies/${options.studyInstanceUID}/series/${ 1272 | options.seriesInstanceUID 1273 | }/instances/${ 1274 | options.sopInstanceUID 1275 | }/frames/${options.frameNumbers.toString()}`; 1276 | 1277 | const { mediaTypes } = options; 1278 | 1279 | const request = getRequestOptions(options.request) 1280 | 1281 | if (!mediaTypes) { 1282 | return this._httpGetMultipartApplicationOctetStream( 1283 | url, 1284 | false, 1285 | false, 1286 | false, 1287 | request, 1288 | ); 1289 | } 1290 | 1291 | const sharedMediaTypes = DICOMwebClient._getSharedMediaTypes(mediaTypes); 1292 | if (sharedMediaTypes.length > 1) { 1293 | /** 1294 | * Enable request of frames that are stored either compressed 1295 | * (image/* media type) or uncompressed (application/octet-stream 1296 | * media type). 1297 | */ 1298 | const supportedMediaTypes = { 1299 | '1.2.840.10008.1.2.1': ['application/octet-stream'], 1300 | '1.2.840.10008.1.2.5': ['image/x-dicom-rle'], 1301 | '1.2.840.10008.1.2.4.50': ['image/jpeg'], 1302 | '1.2.840.10008.1.2.4.51': ['image/jpeg'], 1303 | '1.2.840.10008.1.2.4.57': ['image/jpeg'], 1304 | '1.2.840.10008.1.2.4.70': ['image/jpeg'], 1305 | '1.2.840.10008.1.2.4.80': ['image/x-jls', 'image/jls'], 1306 | '1.2.840.10008.1.2.4.81': ['image/x-jls', 'image/jls'], 1307 | '1.2.840.10008.1.2.4.90': ['image/jp2'], 1308 | '1.2.840.10008.1.2.4.91': ['image/jp2'], 1309 | '1.2.840.10008.1.2.4.92': ['image/jpx'], 1310 | '1.2.840.10008.1.2.4.93': ['image/jpx'], 1311 | '1.2.840.10008.1.2.4.201': ['image/jhc'], 1312 | '1.2.840.10008.1.2.4.202': ['image/jhc'], 1313 | }; 1314 | 1315 | const headers = { 1316 | Accept: DICOMwebClient._buildMultipartAcceptHeaderFieldValue( 1317 | mediaTypes, 1318 | supportedMediaTypes, 1319 | ), 1320 | }; 1321 | request.responseType = 'arraybuffer'; 1322 | return this._httpGet(url, headers, request); 1323 | } 1324 | 1325 | const commonMediaType = DICOMwebClient._getCommonMediaType(mediaTypes); 1326 | 1327 | if (commonMediaType.startsWith('application')) { 1328 | return this._httpGetMultipartApplicationOctetStream( 1329 | url, 1330 | mediaTypes, 1331 | false, 1332 | false, 1333 | request, 1334 | ); 1335 | } 1336 | if (commonMediaType.startsWith('image')) { 1337 | return this._httpGetMultipartImage( 1338 | url, 1339 | mediaTypes, 1340 | false, 1341 | false, 1342 | false, 1343 | request, 1344 | ); 1345 | } 1346 | if (commonMediaType.startsWith('video')) { 1347 | return this._httpGetMultipartVideo( 1348 | url, 1349 | mediaTypes, 1350 | false, 1351 | false, 1352 | false, 1353 | request, 1354 | ); 1355 | } 1356 | 1357 | throw new Error( 1358 | `Media type ${commonMediaType} is not supported for retrieval of frames.`, 1359 | ); 1360 | } 1361 | 1362 | /** 1363 | * Element in mediaTypes parameter 1364 | * @typedef {Object} MediaType 1365 | * @param {String} [MediaType.mediaType] - ie 'image/jpeg', 'image/png'... 1366 | */ 1367 | 1368 | /** 1369 | * Retrieves an individual, server-side rendered DICOM Instance. 1370 | * 1371 | * @param {Object} options 1372 | * @param {String} options.studyInstanceUID - Study Instance UID 1373 | * @param {String} options.seriesInstanceUID - Series Instance UID 1374 | * @param {String} options.sopInstanceUID - SOP Instance UID 1375 | * @param {MediaType[]} [options.mediaTypes] - Acceptable HTTP media types 1376 | * @param {Object} [options.queryParams] - HTTP query parameters 1377 | * @param {Request} [options.request] - Request Options - Request Options 1378 | * @returns {Promise} Rendered DICOM Instance 1379 | */ 1380 | retrieveInstanceRendered(options) { 1381 | if (!('studyInstanceUID' in options)) { 1382 | throw new Error( 1383 | 'Study Instance UID is required for retrieval of rendered instance', 1384 | ); 1385 | } 1386 | if (!('seriesInstanceUID' in options)) { 1387 | throw new Error( 1388 | 'Series Instance UID is required for retrieval of rendered instance', 1389 | ); 1390 | } 1391 | if (!('sopInstanceUID' in options)) { 1392 | throw new Error( 1393 | 'SOP Instance UID is required for retrieval of rendered instance', 1394 | ); 1395 | } 1396 | 1397 | let url = `${this.wadoURL}/studies/${options.studyInstanceUID}/series/${options.seriesInstanceUID}/instances/${options.sopInstanceUID}/rendered`; 1398 | 1399 | const { mediaTypes, queryParams } = options; 1400 | const headers = {}; 1401 | const request = getRequestOptions(options.request) 1402 | 1403 | if (!mediaTypes) { 1404 | request.responseType = 'arraybuffer'; 1405 | if (queryParams) { 1406 | url += DICOMwebClient._parseQueryParameters(queryParams); 1407 | } 1408 | return this._httpGet( 1409 | url, 1410 | headers, 1411 | request, 1412 | ); 1413 | } 1414 | 1415 | const commonMediaType = DICOMwebClient._getCommonMediaType(mediaTypes); 1416 | if (commonMediaType.startsWith('image')) { 1417 | return this._httpGetImage( 1418 | url, 1419 | mediaTypes, 1420 | queryParams, 1421 | request, 1422 | ); 1423 | } 1424 | if (commonMediaType.startsWith('video')) { 1425 | return this._httpGetVideo( 1426 | url, 1427 | mediaTypes, 1428 | queryParams, 1429 | request 1430 | ); 1431 | } 1432 | if (commonMediaType.startsWith('text')) { 1433 | return this._httpGetText( 1434 | url, 1435 | mediaTypes, 1436 | queryParams, 1437 | request, 1438 | ); 1439 | } 1440 | if (commonMediaType === MEDIATYPES.PDF) { 1441 | return this._httpGetApplicationPdf( 1442 | url, 1443 | queryParams, 1444 | request, 1445 | ); 1446 | } 1447 | 1448 | throw new Error( 1449 | `Media type ${commonMediaType} is not supported ` + 1450 | 'for retrieval of rendered instance.', 1451 | ); 1452 | } 1453 | 1454 | /** 1455 | * Retrieves a thumbnail of an DICOM Instance. 1456 | * 1457 | * @param {Object} options 1458 | * @param {String} options.studyInstanceUID - Study Instance UID 1459 | * @param {String} options.seriesInstanceUID - Series Instance UID 1460 | * @param {String} options.sopInstanceUID - SOP Instance UID 1461 | * @param {MediaType[]} [options.mediaTypes] - Acceptable HTTP media types 1462 | * @param {Object} [options.queryParams] - HTTP query parameters 1463 | * @param {Request} [options.request] - Request Options - Request Options 1464 | * @returns {ArrayBuffer} Thumbnail 1465 | */ 1466 | retrieveInstanceThumbnail(options) { 1467 | if (!('studyInstanceUID' in options)) { 1468 | throw new Error( 1469 | 'Study Instance UID is required for retrieval of rendered instance', 1470 | ); 1471 | } 1472 | if (!('seriesInstanceUID' in options)) { 1473 | throw new Error( 1474 | 'Series Instance UID is required for retrieval of rendered instance', 1475 | ); 1476 | } 1477 | if (!('sopInstanceUID' in options)) { 1478 | throw new Error( 1479 | 'SOP Instance UID is required for retrieval of rendered instance', 1480 | ); 1481 | } 1482 | 1483 | let url = `${this.wadoURL}/studies/${options.studyInstanceUID}/series/${options.seriesInstanceUID}/instances/${options.sopInstanceUID}/thumbnail`; 1484 | 1485 | const { mediaTypes, queryParams } = options; 1486 | const headers = {}; 1487 | 1488 | const request = getRequestOptions(options.request) 1489 | 1490 | if (!mediaTypes) { 1491 | request.responseType = 'arraybuffer'; 1492 | if (queryParams) { 1493 | url += DICOMwebClient._parseQueryParameters(queryParams); 1494 | } 1495 | return this._httpGet( 1496 | url, 1497 | headers, 1498 | request 1499 | ); 1500 | } 1501 | 1502 | const commonMediaType = DICOMwebClient._getCommonMediaType(mediaTypes); 1503 | if (commonMediaType.startsWith('image')) { 1504 | return this._httpGetImage( 1505 | url, 1506 | mediaTypes, 1507 | queryParams, 1508 | request 1509 | ); 1510 | } 1511 | 1512 | throw new Error( 1513 | `Media type ${commonMediaType} is not supported ` + 1514 | 'for retrieval of rendered instance.', 1515 | ); 1516 | } 1517 | 1518 | /** 1519 | * Retrieves rendered frames for a DICOM Instance. 1520 | * 1521 | * @param {Object} options 1522 | * @param {String} options.studyInstanceUID - Study Instance UID 1523 | * @param {String} options.seriesInstanceUID - Series Instance UID 1524 | * @param {String} options.sopInstanceUID - SOP Instance UID 1525 | * @param {String} options.frameNumbers - One-based indices of Frame Items 1526 | * @param {MediaType[]} [options.mediaTypes] - Acceptable HTTP media types 1527 | * @param {Object} [options.queryParams] - HTTP query parameters 1528 | * @param {Request} [options.request] - Request Options - Request Options 1529 | * @returns {ArrayBuffer[]} Rendered Frame Items as byte arrays 1530 | */ 1531 | retrieveInstanceFramesRendered(options) { 1532 | if (!('studyInstanceUID' in options)) { 1533 | throw new Error( 1534 | 'Study Instance UID is required for retrieval of rendered instance frames', 1535 | ); 1536 | } 1537 | if (!('seriesInstanceUID' in options)) { 1538 | throw new Error( 1539 | 'Series Instance UID is required for retrieval of rendered instance frames', 1540 | ); 1541 | } 1542 | if (!('sopInstanceUID' in options)) { 1543 | throw new Error( 1544 | 'SOP Instance UID is required for retrieval of rendered instance frames', 1545 | ); 1546 | } 1547 | if (!('frameNumbers' in options)) { 1548 | throw new Error( 1549 | 'frame numbers are required for retrieval of rendered instance frames', 1550 | ); 1551 | } 1552 | 1553 | debugLog( 1554 | `retrieve rendered frames ${options.frameNumbers.toString()} of instance ${ 1555 | options.sopInstanceUID 1556 | }`, 1557 | ); 1558 | let url = `${this.wadoURL}/studies/${options.studyInstanceUID}/series/${ 1559 | options.seriesInstanceUID 1560 | }/instances/${ 1561 | options.sopInstanceUID 1562 | }/frames/${options.frameNumbers.toString()}/rendered`; 1563 | 1564 | const { mediaTypes, queryParams } = options; 1565 | const headers = {}; 1566 | const request = getRequestOptions(options.request) 1567 | 1568 | if (!mediaTypes) { 1569 | if (queryParams) { 1570 | request.responseType = 'arraybuffer'; 1571 | url += DICOMwebClient._parseQueryParameters(queryParams); 1572 | } 1573 | return this._httpGet(url, headers, request); 1574 | } 1575 | 1576 | const commonMediaType = DICOMwebClient._getCommonMediaType(mediaTypes); 1577 | if (commonMediaType.startsWith('image')) { 1578 | return this._httpGetImage( 1579 | url, 1580 | mediaTypes, 1581 | queryParams, 1582 | request 1583 | ); 1584 | } 1585 | if (commonMediaType.startsWith('video')) { 1586 | return this._httpGetVideo( 1587 | url, 1588 | mediaTypes, 1589 | queryParams, 1590 | request 1591 | ); 1592 | } 1593 | 1594 | throw new Error( 1595 | `Media type ${commonMediaType} is not supported ` + 1596 | 'for retrieval of rendered frame.', 1597 | ); 1598 | } 1599 | 1600 | /** 1601 | * Retrieves thumbnail of frames for a DICOM Instance. 1602 | * 1603 | * @param {Object} options 1604 | * @param {String} options.studyInstanceUID - Study Instance UID 1605 | * @param {String} options.seriesInstanceUID - Series Instance UID 1606 | * @param {String} options.sopInstanceUID - SOP Instance UID 1607 | * @param {String} options.frameNumbers - One-based indices of Frame Items 1608 | * @param {Object} [options.queryParams] - HTTP query parameters 1609 | * @param {Request} [options.request] - Request Options - Request Options 1610 | * @returns {ArrayBuffer[]} Rendered Frame Items as byte arrays 1611 | */ 1612 | retrieveInstanceFramesThumbnail(options) { 1613 | if (!('studyInstanceUID' in options)) { 1614 | throw new Error( 1615 | 'Study Instance UID is required for retrieval of rendered instance frames', 1616 | ); 1617 | } 1618 | if (!('seriesInstanceUID' in options)) { 1619 | throw new Error( 1620 | 'Series Instance UID is required for retrieval of rendered instance frames', 1621 | ); 1622 | } 1623 | if (!('sopInstanceUID' in options)) { 1624 | throw new Error( 1625 | 'SOP Instance UID is required for retrieval of rendered instance frames', 1626 | ); 1627 | } 1628 | if (!('frameNumbers' in options)) { 1629 | throw new Error( 1630 | 'frame numbers are required for retrieval of rendered instance frames', 1631 | ); 1632 | } 1633 | 1634 | console.debug( 1635 | `retrieve rendered frames ${options.frameNumbers.toString()} of instance ${ 1636 | options.sopInstanceUID 1637 | }`, 1638 | ); 1639 | let url = `${this.wadoURL}/studies/${options.studyInstanceUID}/series/${ 1640 | options.seriesInstanceUID 1641 | }/instances/${ 1642 | options.sopInstanceUID 1643 | }/frames/${options.frameNumbers.toString()}/thumbnail`; 1644 | 1645 | const { mediaTypes, queryParams } = options; 1646 | const headers = {}; 1647 | const request = getRequestOptions(options.request); 1648 | 1649 | if (!mediaTypes) { 1650 | request.responseType = 'arraybuffer'; 1651 | if (queryParams) { 1652 | url += DICOMwebClient._parseQueryParameters(queryParams); 1653 | } 1654 | return this._httpGet( 1655 | url, 1656 | headers, 1657 | request 1658 | ); 1659 | } 1660 | 1661 | const commonMediaType = DICOMwebClient._getCommonMediaType(mediaTypes); 1662 | if (commonMediaType.startsWith('image')) { 1663 | return this._httpGetImage( 1664 | url, 1665 | mediaTypes, 1666 | queryParams, 1667 | request 1668 | ); 1669 | } 1670 | 1671 | throw new Error( 1672 | `Media type ${commonMediaType} is not supported ` + 1673 | 'for retrieval of rendered frame.', 1674 | ); 1675 | } 1676 | 1677 | /** 1678 | * Retrieves a DICOM Instance. 1679 | * 1680 | * @param {Object} options 1681 | * @param {String} options.studyInstanceUID - Study Instance UID 1682 | * @param {String} options.seriesInstanceUID - Series Instance UID 1683 | * @param {String} options.sopInstanceUID - SOP Instance UID 1684 | * @param {string[]} options.mediaTypes 1685 | * @param {Request} options.request - Request Options 1686 | * @returns {Promise} DICOM Part 10 file as Arraybuffer 1687 | */ 1688 | retrieveInstance(options) { 1689 | if (!('studyInstanceUID' in options)) { 1690 | throw new Error('Study Instance UID is required'); 1691 | } 1692 | if (!('seriesInstanceUID' in options)) { 1693 | throw new Error('Series Instance UID is required'); 1694 | } 1695 | if (!('sopInstanceUID' in options)) { 1696 | throw new Error('SOP Instance UID is required'); 1697 | } 1698 | const url = `${this.wadoURL}/studies/${options.studyInstanceUID}/series/${options.seriesInstanceUID}/instances/${options.sopInstanceUID}`; 1699 | 1700 | const { mediaTypes } = options; 1701 | 1702 | const request = getRequestOptions(options.request) 1703 | 1704 | if (!mediaTypes) { 1705 | return this._httpGetMultipartApplicationDicom( 1706 | url, 1707 | false, 1708 | false, 1709 | request 1710 | ).then(getFirstResult); 1711 | } 1712 | 1713 | const commonMediaType = DICOMwebClient._getCommonMediaType(mediaTypes); 1714 | if (commonMediaType === MEDIATYPES.DICOM) { 1715 | return this._httpGetMultipartApplicationDicom( 1716 | url, 1717 | mediaTypes, 1718 | false, 1719 | request 1720 | ).then(getFirstResult); 1721 | } 1722 | 1723 | throw new Error( 1724 | `Media type ${commonMediaType} is not supported for retrieval of instance.`, 1725 | ); 1726 | } 1727 | 1728 | /** 1729 | * Retrieves all DICOM Instances of a Series. 1730 | * 1731 | * @param {Object} options 1732 | * @param {String} options.studyInstanceUID - Study Instance UID 1733 | * @param {String} options.seriesInstanceUID - Series Instance UID 1734 | * @param {Request} options.request - Request Options 1735 | * @returns {Promise} DICOM Instances 1736 | */ 1737 | retrieveSeries(options) { 1738 | if (!('studyInstanceUID' in options)) { 1739 | throw new Error('Study Instance UID is required'); 1740 | } 1741 | if (!('seriesInstanceUID' in options)) { 1742 | throw new Error('Series Instance UID is required'); 1743 | } 1744 | 1745 | const url = `${this.wadoURL}/studies/${options.studyInstanceUID}/series/${options.seriesInstanceUID}`; 1746 | 1747 | const { mediaTypes } = options; 1748 | const request = getRequestOptions(options.request) 1749 | 1750 | if (!mediaTypes) { 1751 | return this._httpGetMultipartApplicationDicom( 1752 | url, 1753 | false, 1754 | false, 1755 | request 1756 | ); 1757 | } 1758 | 1759 | const commonMediaType = DICOMwebClient._getCommonMediaType(mediaTypes); 1760 | if (commonMediaType === MEDIATYPES.DICOM) { 1761 | return this._httpGetMultipartApplicationDicom( 1762 | url, 1763 | mediaTypes, 1764 | false, 1765 | request 1766 | ); 1767 | } 1768 | 1769 | throw new Error( 1770 | `Media type ${commonMediaType} is not supported for retrieval of series.`, 1771 | ); 1772 | } 1773 | 1774 | /** 1775 | * Retrieves all DICOM Instances of a Study. 1776 | * 1777 | * @param {Object} options 1778 | * @param {String} options.studyInstanceUID - Study Instance UID 1779 | * @param {Request} options.request - Request Options 1780 | * @returns {ArrayBuffer[]} DICOM Instances 1781 | */ 1782 | retrieveStudy(options) { 1783 | if (!('studyInstanceUID' in options)) { 1784 | throw new Error('Study Instance UID is required'); 1785 | } 1786 | 1787 | const url = `${this.wadoURL}/studies/${options.studyInstanceUID}`; 1788 | 1789 | const { mediaTypes } = options; 1790 | const request = getRequestOptions(options.request); 1791 | 1792 | if (!mediaTypes) { 1793 | return this._httpGetMultipartApplicationDicom( 1794 | url, 1795 | false, 1796 | false, 1797 | request 1798 | ); 1799 | } 1800 | 1801 | const commonMediaType = DICOMwebClient._getCommonMediaType(mediaTypes); 1802 | if (commonMediaType === MEDIATYPES.DICOM) { 1803 | return this._httpGetMultipartApplicationDicom( 1804 | url, 1805 | mediaTypes, 1806 | false, 1807 | request 1808 | ); 1809 | } 1810 | 1811 | throw new Error( 1812 | `Media type ${commonMediaType} is not supported for retrieval of study.`, 1813 | ); 1814 | } 1815 | 1816 | /** 1817 | * Retrieves and parses BulkData from a BulkDataURI location. 1818 | * Decodes the multipart encoded data and returns the resulting data 1819 | * as an ArrayBuffer. 1820 | * 1821 | * See http://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_6.5.5.html 1822 | * 1823 | * @param {Object} options 1824 | * @param {string} options.BulkDataURI to retrieve 1825 | * @param {Array} options.mediaTypes to use to fetch the URI 1826 | * @param {string} options.byteRange to request a sub-range (only valid on single part) 1827 | * @param {Request} options.request - Request Options 1828 | * @returns {Promise} Bulkdata parts 1829 | */ 1830 | retrieveBulkData(options) { 1831 | if (!('BulkDataURI' in options)) { 1832 | throw new Error('BulkDataURI is required.'); 1833 | } 1834 | 1835 | const url = options.BulkDataURI; 1836 | const { mediaTypes, byteRange } = options; 1837 | const request = getRequestOptions(options.request); 1838 | 1839 | if (this.singlepart.indexOf('bulkdata') !== -1) { 1840 | request.responseType = 'arraybuffer'; 1841 | return this._httpGet(url, options.headers, request); 1842 | } 1843 | 1844 | if (mediaTypes) { 1845 | try { 1846 | const commonMediaType = DICOMwebClient._getCommonMediaType(mediaTypes); 1847 | 1848 | if (commonMediaType==='image/') { 1849 | return this._httpGetMultipartImage( 1850 | url, 1851 | mediaTypes, 1852 | byteRange, 1853 | false, 1854 | false, 1855 | progressCallback, 1856 | withCredentials, 1857 | ); 1858 | } 1859 | } catch(e) { 1860 | // No-op - this happens sometimes if trying to fetch the specific desired type but want to fallback to octet-stream 1861 | } 1862 | } 1863 | 1864 | // Just use the media types provided 1865 | return this._httpGetMultipartApplicationOctetStream( 1866 | url, 1867 | mediaTypes, 1868 | byteRange, 1869 | false, 1870 | request 1871 | ); 1872 | } 1873 | 1874 | /** 1875 | * Stores DICOM Instances. 1876 | * 1877 | * @param {Object} options 1878 | * @param {ArrayBuffer[]} options.datasets - DICOM Instances in PS3.10 format 1879 | * @param {String} [options.studyInstanceUID] - Study Instance UID 1880 | * @param {Request} [options.request] - Request Options 1881 | * @returns {Promise} Response message 1882 | */ 1883 | storeInstances(options) { 1884 | if (!('datasets' in options)) { 1885 | throw new Error('datasets are required for storing'); 1886 | } 1887 | 1888 | let url = `${this.stowURL}/studies`; 1889 | if ('studyInstanceUID' in options) { 1890 | url += `/${options.studyInstanceUID}`; 1891 | } 1892 | 1893 | const { data, boundary } = multipartEncode(options.datasets); 1894 | const headers = { 1895 | 'Content-Type': `multipart/related; type="application/dicom"; boundary="${boundary}"`, 1896 | }; 1897 | 1898 | const request = getRequestOptions(options.request); 1899 | return this._httpPost( 1900 | url, 1901 | headers, 1902 | data, 1903 | request 1904 | ); 1905 | } 1906 | } 1907 | 1908 | 1909 | export { DICOMwebClient }; 1910 | export default DICOMwebClient; 1911 | -------------------------------------------------------------------------------- /src/dicomweb-client.js: -------------------------------------------------------------------------------- 1 | import { DICOMwebClient } from './api.js'; 2 | import { 3 | getStudyInstanceUIDFromUri, 4 | getSeriesInstanceUIDFromUri, 5 | getSOPInstanceUIDFromUri, 6 | getFrameNumbersFromUri, 7 | } from './utils.js'; 8 | 9 | const api = { 10 | DICOMwebClient, 11 | }; 12 | const utils = { 13 | getStudyInstanceUIDFromUri, 14 | getSeriesInstanceUIDFromUri, 15 | getSOPInstanceUIDFromUri, 16 | getFrameNumbersFromUri, 17 | }; 18 | 19 | export { default as version } from './version.js'; 20 | 21 | export { api, utils }; 22 | -------------------------------------------------------------------------------- /src/message.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a Uint8Array to a String. 3 | * @param {Uint8Array} array that should be converted 4 | * @param {Number} offset array offset in case only subset of array items should 5 | be extracted (default: 0) 6 | * @param {Number} limit maximum number of array items that should be extracted 7 | (defaults to length of array) 8 | * @returns {String} 9 | */ 10 | function uint8ArrayToString(arr, offset = 0, limit) { 11 | const itemLimit = limit || arr.length - offset; 12 | let str = ''; 13 | for (let i = offset; i < offset + itemLimit; i++) { 14 | str += String.fromCharCode(arr[i]); 15 | } 16 | return str; 17 | } 18 | 19 | /** 20 | * Converts a String to a Uint8Array. 21 | * @param {String} str string that should be converted 22 | * @returns {Uint8Array} 23 | */ 24 | function stringToUint8Array(str) { 25 | const arr = new Uint8Array(str.length); 26 | for (let i = 0, j = str.length; i < j; i++) { 27 | arr[i] = str.charCodeAt(i); 28 | } 29 | return arr; 30 | } 31 | 32 | /** 33 | * Identifies the boundary in a multipart/related message header. 34 | * @param {String} header message header 35 | * @returns {String} boundary 36 | */ 37 | function identifyBoundary(header) { 38 | const parts = header.split('\r\n'); 39 | 40 | for (let i = 0; i < parts.length; i++) { 41 | if (parts[i].substr(0, 2) === '--') { 42 | return parts[i]; 43 | } 44 | } 45 | 46 | return null; 47 | } 48 | 49 | /** 50 | * Checks whether a given token is contained by a message at a given offset. 51 | * @param {Uint8Array} message message content 52 | * @param {Uint8Array} token substring that should be present 53 | * @param {Number} offset offset in message content from where search should start 54 | * @returns {Boolean} whether message contains token at offset 55 | */ 56 | function containsToken(message, token, offset = 0) { 57 | if (offset + token.length > message.length) { 58 | return false; 59 | } 60 | 61 | let index = offset; 62 | for (let i = 0; i < token.length; i++) { 63 | if (token[i] !== message[index]) { 64 | return false; 65 | } 66 | 67 | index += 1; 68 | } 69 | return true; 70 | } 71 | 72 | /** 73 | * Finds a given token in a message at a given offset. 74 | * @param {Uint8Array} message message content 75 | * @param {Uint8Array} token substring that should be found 76 | * @param {String} offset message body offset from where search should start 77 | * @returns {Boolean} whether message has a part at given offset or not 78 | */ 79 | function findToken(message, token, offset = 0, maxSearchLength) { 80 | let searchLength = message.length; 81 | if (maxSearchLength) { 82 | searchLength = Math.min(offset + maxSearchLength, message.length); 83 | } 84 | 85 | for (let i = offset; i < searchLength; i++) { 86 | // If the first value of the message matches 87 | // the first value of the token, check if 88 | // this is the full token. 89 | if (message[i] === token[0]) { 90 | if (containsToken(message, token, i)) { 91 | return i; 92 | } 93 | } 94 | } 95 | 96 | return -1; 97 | } 98 | 99 | /** 100 | * Create a random GUID 101 | * 102 | * @return {string} 103 | */ 104 | function guid() { 105 | function s4() { 106 | return Math.floor((1 + Math.random()) * 0x10000) 107 | .toString(16) 108 | .substring(1); 109 | } 110 | return `${s4() + s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; 111 | } 112 | 113 | /** 114 | * @typedef {Object} MultipartEncodedData 115 | * @property {ArrayBuffer} data The encoded Multipart Data 116 | * @property {String} boundary The boundary used to divide pieces of the encoded data 117 | */ 118 | 119 | /** 120 | * Encode one or more DICOM datasets into a single body so it can be 121 | * sent using the Multipart Content-Type. 122 | * 123 | * @param {ArrayBuffer[]} datasets Array containing each file to be encoded in the 124 | multipart body, passed as ArrayBuffers. 125 | * @param {String} [boundary] Optional string to define a boundary between each part 126 | of the multipart body. If this is not specified, a random 127 | GUID will be generated. 128 | * @return {MultipartEncodedData} The Multipart encoded data returned as an Object. This 129 | contains both the data itself, and the boundary string 130 | used to divide it. 131 | */ 132 | function multipartEncode( 133 | datasets, 134 | boundary = guid(), 135 | contentType = 'application/dicom', 136 | ) { 137 | const contentTypeString = `Content-Type: ${contentType}`; 138 | const header = `\r\n--${boundary}\r\n${contentTypeString}\r\n\r\n`; 139 | const footer = `\r\n--${boundary}--`; 140 | const headerArray = stringToUint8Array(header); 141 | const footerArray = stringToUint8Array(footer); 142 | const headerLength = headerArray.length; 143 | const footerLength = footerArray.length; 144 | 145 | let length = 0; 146 | 147 | // Calculate the total length for the final array 148 | const contentArrays = datasets.map(datasetBuffer => { 149 | const contentArray = new Uint8Array(datasetBuffer); 150 | const contentLength = contentArray.length; 151 | 152 | length += headerLength + contentLength 153 | 154 | return contentArray; 155 | }); 156 | 157 | length += footerLength; 158 | 159 | // Allocate the array 160 | const multipartArray = new Uint8Array(length); 161 | 162 | // Set the initial header 163 | multipartArray.set(headerArray, 0); 164 | 165 | // Write each dataset into the multipart array 166 | let position = 0; 167 | contentArrays.forEach(contentArray => { 168 | multipartArray.set(headerArray, position); 169 | multipartArray.set(contentArray, position + headerLength); 170 | 171 | position += headerLength + contentArray.length; 172 | }); 173 | 174 | multipartArray.set(footerArray, position); 175 | 176 | return { 177 | data: multipartArray.buffer, 178 | boundary, 179 | }; 180 | } 181 | 182 | /** 183 | * Decode a Multipart encoded ArrayBuffer and return the components as an Array. 184 | * 185 | * @param {ArrayBuffer} response Data encoded as a 'multipart/related' message 186 | * @returns {Array} The content 187 | */ 188 | function multipartDecode(response) { 189 | // Use the raw data if it is provided in an appropriate format 190 | const message = ArrayBuffer.isView(response) ? response : new Uint8Array(response); 191 | 192 | /* Set a maximum length to search for the header boundaries, otherwise 193 | findToken can run for a long time 194 | */ 195 | const maxSearchLength = 1000; 196 | 197 | // First look for the multipart mime header 198 | const separator = stringToUint8Array('\r\n\r\n'); 199 | const headerIndex = findToken(message, separator, 0, maxSearchLength); 200 | if (headerIndex === -1) { 201 | throw new Error('Response message has no multipart mime header'); 202 | } 203 | 204 | const header = uint8ArrayToString(message, 0, headerIndex); 205 | const boundaryString = identifyBoundary(header); 206 | if (!boundaryString) { 207 | throw new Error('Header of response message does not specify boundary'); 208 | } 209 | 210 | const boundary = stringToUint8Array(boundaryString); 211 | const boundaryLength = boundary.length; 212 | const components = []; 213 | 214 | let offset = boundaryLength; 215 | 216 | // Loop until we cannot find any more boundaries 217 | let boundaryIndex; 218 | 219 | while (boundaryIndex !== -1) { 220 | // Search for the next boundary in the message, starting 221 | // from the current offset position 222 | boundaryIndex = findToken(message, boundary, offset); 223 | 224 | // If no further boundaries are found, stop here. 225 | if (boundaryIndex === -1) { 226 | break; 227 | } 228 | 229 | const headerTokenIndex = findToken( 230 | message, 231 | separator, 232 | offset, 233 | maxSearchLength, 234 | ); 235 | if (headerTokenIndex === -1) { 236 | throw new Error('Response message part has no mime header'); 237 | } 238 | offset = headerTokenIndex + separator.length; 239 | 240 | // Extract data from response message, excluding "\r\n" 241 | const spacingLength = 2; 242 | const data = response.slice(offset, boundaryIndex - spacingLength); 243 | 244 | // Add the data to the array of results 245 | components.push(data); 246 | 247 | // Move the offset to the end of the current section, 248 | // plus the identified boundary 249 | offset = boundaryIndex + boundaryLength; 250 | } 251 | 252 | return components; 253 | } 254 | 255 | export { 256 | containsToken, 257 | findToken, 258 | identifyBoundary, 259 | uint8ArrayToString, 260 | stringToUint8Array, 261 | multipartEncode, 262 | multipartDecode, 263 | guid, 264 | }; 265 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | function findSubstring(str, before, after) { 2 | const beforeIndex = str.lastIndexOf(before) + before.length; 3 | if (beforeIndex < before.length) { 4 | return null; 5 | } 6 | if (after !== undefined) { 7 | const afterIndex = str.lastIndexOf(after); 8 | if (afterIndex < 0) { 9 | return null; 10 | } 11 | return str.substring(beforeIndex, afterIndex); 12 | } 13 | return str.substring(beforeIndex); 14 | } 15 | 16 | function getStudyInstanceUIDFromUri(uri) { 17 | let uid = findSubstring(uri, 'studies/', '/series'); 18 | if (!uid) { 19 | uid = findSubstring(uri, 'studies/'); 20 | } 21 | if (!uid) { 22 | console.debug( 23 | `Study Instance UID could not be dertermined from URI "${uri}"`, 24 | ); 25 | } 26 | return uid; 27 | } 28 | 29 | function getSeriesInstanceUIDFromUri(uri) { 30 | let uid = findSubstring(uri, 'series/', '/instances'); 31 | if (!uid) { 32 | uid = findSubstring(uri, 'series/'); 33 | } 34 | if (!uid) { 35 | console.debug( 36 | `Series Instance UID could not be dertermined from URI "${uri}"`, 37 | ); 38 | } 39 | return uid; 40 | } 41 | 42 | function getSOPInstanceUIDFromUri(uri) { 43 | let uid = findSubstring(uri, '/instances/', '/frames'); 44 | if (!uid) { 45 | uid = findSubstring(uri, '/instances/', '/metadata'); 46 | } 47 | if (!uid) { 48 | uid = findSubstring(uri, '/instances/'); 49 | } 50 | if (!uid) { 51 | console.debug(`SOP Instance UID could not be dertermined from URI"${uri}"`); 52 | } 53 | return uid; 54 | } 55 | 56 | 57 | function getFrameNumbersFromUri(uri) { 58 | let numbers = findSubstring(uri, '/frames/', '/rendered'); 59 | if (!numbers) { 60 | numbers = findSubstring(uri, '/frames/'); 61 | } 62 | if (numbers === undefined) { 63 | console.debug(`Frame Numbers could not be dertermined from URI"${uri}"`); 64 | } 65 | return numbers.split(','); 66 | } 67 | 68 | export { 69 | getStudyInstanceUIDFromUri, 70 | getSeriesInstanceUIDFromUri, 71 | getSOPInstanceUIDFromUri, 72 | getFrameNumbersFromUri, 73 | }; 74 | -------------------------------------------------------------------------------- /src/version.js: -------------------------------------------------------------------------------- 1 | export default '0.5.2'; 2 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | # Clear any previous data from the last test run 2 | rm -rf /tmp/dcm4chee-arc/db 3 | 4 | # now start dcm4chee archive and wait for it to startup 5 | echo 'Starting dcm4chee Docker container' 6 | docker-compose -f dcm4chee-docker-compose.yml up -d || { exit 1; } 7 | 8 | until curl localhost:8008/dcm4chee-arc/aets; do echo waiting for archive...; sleep 1; done 9 | echo "" 10 | echo "" 11 | echo "Archive started, ready to run tests..." 12 | echo "" 13 | 14 | # at this point DICOMweb server is running and ready for testing 15 | echo 'Installing and running tests' 16 | ./node_modules/karma/bin/karma start karma.conf.js --browsers Chrome_without_security --concurrency 1 17 | 18 | # Store the exit code from mochify 19 | exit_code=$? 20 | 21 | # now shut down the archive 22 | echo 'Shutting down Docker container' 23 | docker-compose -f dcm4chee-docker-compose.yml down 24 | 25 | # Exit with the exit code from Mochify 26 | exit "$exit_code" 27 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const { createSpy } = jasmine; 2 | jasmine.getEnv().configure({ random: false }); 3 | 4 | function getTestDataInstance(url) { 5 | return new Promise((resolve, reject) => { 6 | const xhr = new XMLHttpRequest(); 7 | xhr.open('GET', url, true); 8 | xhr.responseType = 'arraybuffer'; 9 | 10 | xhr.onload = function() { 11 | const arrayBuffer = this.response; 12 | if (arrayBuffer) { 13 | resolve(arrayBuffer); 14 | } else { 15 | reject(new Error('Something went wrong...')); 16 | } 17 | }; 18 | 19 | xhr.send(); 20 | }); 21 | } 22 | 23 | let dwc = new DICOMwebClient.api.DICOMwebClient({ 24 | url: 'http://localhost:8008/dcm4chee-arc/aets/DCM4CHEE/rs', 25 | retrieveRendered: false, 26 | }); 27 | describe('dicomweb.api.DICOMwebClient', function() { 28 | // 29 | // Note: you can add the following for debugging tests locally 30 | // 31 | // beforeAll(function(done) { 32 | // dwc = new DICOMwebClient.api.DICOMwebClient({ 33 | // url: 'http://localhost:8008/dcm4chee-arc/aets/DCM4CHEE/rs', 34 | // retrieveRendered: false, 35 | // }); 36 | 37 | // // Repeatedly call the reject endpoint until all studies are deleted 38 | // const deleteStudies = async () => { 39 | // const studies = await dwc.searchForStudies(); 40 | // console.log('studies', studies, 'studies.length', studies.length); 41 | 42 | // if (studies.length === 0) { 43 | // console.log('All studies deleted'); 44 | // done(); 45 | // return; 46 | // } 47 | 48 | // const promises = studies.map(study => { 49 | // return fetch( 50 | // `http://localhost:8008/dcm4chee-arc/aets/DCM4CHEE/rs/studies/${ 51 | // study['0020000D'].Value[0] 52 | // }/reject/113039^DCM`, 53 | // { 54 | // method: 'POST', 55 | // }, 56 | // ); 57 | // }); 58 | 59 | // await Promise.all(promises); 60 | // console.log('Deleted', promises.length, 'studies'); 61 | 62 | // setTimeout(deleteStudies, 1000); 63 | // }; 64 | 65 | // deleteStudies(); 66 | // }, 30000); 67 | 68 | it('should have correct constructor name', function() { 69 | expect(dwc.constructor.name).toEqual('DICOMwebClient'); 70 | }); 71 | 72 | it('should find zero studies', async function() { 73 | const studies = await dwc.searchForStudies({}); 74 | expect(studies.length).toBe(0); 75 | }); 76 | 77 | it('should store one instance', async function() { 78 | // This is the HTTP server run by the Karma test 79 | // runner 80 | const url = 'http://localhost:9876/base/testData/sample.dcm'; 81 | 82 | const arrayBuffer = await getTestDataInstance(url); 83 | const options = { 84 | datasets: [arrayBuffer], 85 | }; 86 | 87 | await dwc.storeInstances(options); 88 | 89 | const studies = await dwc.searchForStudies(); 90 | expect(studies.length).toBe(1); 91 | }, 5000); 92 | 93 | it('should store three more instances', async function() { 94 | // This is the HTTP server run by the Karma test 95 | // runner 96 | const url1 = 'http://localhost:9876/base/testData/sample2.dcm'; 97 | const url2 = 'http://localhost:9876/base/testData/sample3.dcm'; 98 | const url3 = 'http://localhost:9876/base/testData/US-PAL-8-10x-echo.dcm'; 99 | 100 | const instancePromises = [ 101 | getTestDataInstance(url1), 102 | getTestDataInstance(url2), 103 | getTestDataInstance(url3), 104 | ]; 105 | 106 | const datasets = await Promise.all(instancePromises); 107 | 108 | const promises = datasets.map(dataset => { 109 | return dwc.storeInstances({ datasets: [dataset] }); 110 | }); 111 | 112 | await Promise.all(promises); 113 | const studies = await dwc.searchForStudies(); 114 | expect(studies.length).toBe(4); 115 | }, 45000); 116 | 117 | it('should retrieve a single frame of an instance', async function() { 118 | // from sample.dcm 119 | const options = { 120 | studyInstanceUID: 121 | '1.3.6.1.4.1.14519.5.2.1.2744.7002.271803936741289691489150315969', 122 | seriesInstanceUID: 123 | '1.3.6.1.4.1.14519.5.2.1.2744.7002.117357550898198415937979788256', 124 | sopInstanceUID: 125 | '1.3.6.1.4.1.14519.5.2.1.2744.7002.325971588264730726076978589153', 126 | frameNumbers: '1', 127 | }; 128 | 129 | const frames = dwc.retrieveInstance(options); 130 | }); 131 | 132 | it('should retrieve a single instance', async function() { 133 | // from sample.dcm 134 | const options = { 135 | studyInstanceUID: 136 | '1.3.6.1.4.1.14519.5.2.1.2744.7002.271803936741289691489150315969', 137 | seriesInstanceUID: 138 | '1.3.6.1.4.1.14519.5.2.1.2744.7002.117357550898198415937979788256', 139 | sopInstanceUID: 140 | '1.3.6.1.4.1.14519.5.2.1.2744.7002.325971588264730726076978589153', 141 | }; 142 | 143 | const instance = await dwc.retrieveInstance(options); 144 | 145 | expect(instance instanceof ArrayBuffer).toBe(true); 146 | }); 147 | 148 | it('should retrieve an entire series as an array of instances', async function() { 149 | const options = { 150 | studyInstanceUID: 151 | '1.3.6.1.4.1.14519.5.2.1.2744.7002.271803936741289691489150315969', 152 | seriesInstanceUID: 153 | '1.3.6.1.4.1.14519.5.2.1.2744.7002.117357550898198415937979788256', 154 | }; 155 | 156 | const instances = await dwc.retrieveSeries(options); 157 | 158 | expect(instances.length).toBe(1); 159 | }); 160 | 161 | it('should retrieve an entire study as an array of instances', async function() { 162 | const options = { 163 | studyInstanceUID: 164 | '1.3.6.1.4.1.14519.5.2.1.2744.7002.271803936741289691489150315969', 165 | }; 166 | 167 | const instances = await dwc.retrieveStudy(options); 168 | 169 | expect(instances.length).toBe(1); 170 | }); 171 | 172 | it('should retrieve bulk data', async function() { 173 | const options = { 174 | studyInstanceUID: '999.999.3859744', 175 | seriesInstanceUID: '999.999.94827453', 176 | sopInstanceUID: '999.999.133.1996.1.1800.1.6.25', 177 | }; 178 | 179 | const metadata = await dwc.retrieveInstanceMetadata(options); 180 | 181 | // TODO: Check why metadata is an array of objects, not just an object 182 | const bulkDataOptions = { 183 | BulkDataURI: metadata[0]['00281201'].BulkDataURI, 184 | }; 185 | 186 | const bulkData = await dwc.retrieveBulkData(bulkDataOptions); 187 | 188 | expect(bulkData instanceof Array).toBe(true); 189 | expect(bulkData.length).toBe(1); 190 | expect(bulkData[0] instanceof ArrayBuffer).toBe(true); 191 | }, 15000); 192 | }); 193 | 194 | describe('Request hooks', function() { 195 | let requestHook1Spy, requestHook2Spy, url, metadataUrl, request; 196 | 197 | beforeEach(function() { 198 | request = new XMLHttpRequest(); 199 | url = 'http://localhost:8008/dcm4chee-arc/aets/DCM4CHEE/rs'; 200 | metadataUrl = 201 | 'http://localhost:8008/dcm4chee-arc/aets/DCM4CHEE/rs/studies/999.999.3859744/series/999.999.94827453/instances/999.999.133.1996.1.1800.1.6.25/metadata'; 202 | requestHook1Spy = createSpy('requestHook1Spy', function(request, metadata) { 203 | return request; 204 | }).and.callFake((request, metadata) => request); 205 | requestHook2Spy = createSpy('requestHook2Spy', function(request, metadata) { 206 | return request; 207 | }).and.callFake((request, metadata) => request); 208 | }); 209 | 210 | it('invalid request hooks should be notified and ignored', async function() { 211 | /** Spy with invalid request hook signature */ 212 | requestHook2Spy = createSpy('requestHook2Spy', function(request) { 213 | return request; 214 | }).and.callFake((request, metadata) => request); 215 | const dwc = new DICOMwebClient.api.DICOMwebClient({ 216 | url, 217 | requestHooks: [requestHook1Spy, requestHook2Spy], 218 | }); 219 | const metadata = { 220 | url: metadataUrl, 221 | method: 'get', 222 | headers: { Accept: 'application/dicom+json' }, 223 | }; 224 | request.open('GET', metadata.url); 225 | 226 | // Wrap the call to `dwc.retrieveInstanceMetadata()` in a try-catch block 227 | await dwc.retrieveInstanceMetadata({ 228 | studyInstanceUID: '999.999.3859744', 229 | seriesInstanceUID: '999.999.94827453', 230 | sopInstanceUID: '999.999.133.1996.1.1800.1.6.25', 231 | }); 232 | 233 | // The following line should not be reached if an error is thrown 234 | expect(requestHook1Spy).not.toHaveBeenCalledWith(request, metadata); 235 | expect(requestHook2Spy).not.toHaveBeenCalledWith(request, metadata); 236 | }); 237 | 238 | it('valid request hooks should be called', async function() { 239 | const dwc = new DICOMwebClient.api.DICOMwebClient({ 240 | url, 241 | requestHooks: [requestHook1Spy, requestHook2Spy], 242 | }); 243 | const metadata = { 244 | url: metadataUrl, 245 | method: 'get', 246 | headers: { Accept: 'application/dicom+json' }, 247 | }; 248 | request.open('GET', metadata.url); 249 | await dwc.retrieveInstanceMetadata({ 250 | studyInstanceUID: '999.999.3859744', 251 | seriesInstanceUID: '999.999.94827453', 252 | sopInstanceUID: '999.999.133.1996.1.1800.1.6.25', 253 | }); 254 | expect(requestHook1Spy).toHaveBeenCalledWith(request, metadata); 255 | expect(requestHook2Spy).toHaveBeenCalledWith(request, metadata); 256 | }); 257 | }); 258 | -------------------------------------------------------------------------------- /testData/US-PAL-8-10x-echo.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcmjs-org/dicomweb-client/9c3331fcc5b78db435bfc07a9d1ebc4253446f39/testData/US-PAL-8-10x-echo.dcm -------------------------------------------------------------------------------- /testData/sample.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcmjs-org/dicomweb-client/9c3331fcc5b78db435bfc07a9d1ebc4253446f39/testData/sample.dcm -------------------------------------------------------------------------------- /testData/sample2.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcmjs-org/dicomweb-client/9c3331fcc5b78db435bfc07a9d1ebc4253446f39/testData/sample2.dcm -------------------------------------------------------------------------------- /testData/sample3.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcmjs-org/dicomweb-client/9c3331fcc5b78db435bfc07a9d1ebc4253446f39/testData/sample3.dcm -------------------------------------------------------------------------------- /test_ci.sh: -------------------------------------------------------------------------------- 1 | # Clear any previous data from the last test run 2 | rm -rf /tmp/dcm4chee-arc/db 3 | 4 | # now start dcm4chee archive and wait for it to startup 5 | echo 'Starting dcm4chee Docker container' 6 | docker compose -f dcm4chee-docker-compose.yml up -d || { exit 1; } 7 | 8 | until curl localhost:8008/dcm4chee-arc/aets; do echo waiting for archive...; sleep 2; done 9 | echo "" 10 | echo "" 11 | echo "Archive started, ready to run tests..." 12 | echo "" 13 | 14 | # at this point DICOMweb server is running and ready for testing 15 | echo 'Installing and running tests' 16 | ./node_modules/karma/bin/karma start karma.conf.js --browsers ChromeHeadless_without_security --concurrency 1 --single-run 17 | 18 | # Store the exit code from mochify 19 | exit_code=$? 20 | 21 | # now shut down the archive 22 | echo 'Shutting down Docker container' 23 | docker compose -f dcm4chee-docker-compose.yml down 24 | 25 | # clean up temp database used by test 26 | sudo rm -rf ./tmp 27 | 28 | # Exit with the exit code from Mochify 29 | exit "$exit_code" 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "outDir": "types", 8 | "declarationMap": true 9 | } 10 | } 11 | --------------------------------------------------------------------------------