├── .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 | [](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 |
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 |
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 |
--------------------------------------------------------------------------------