import { api } from 'dicomweb-client'; import { DicomMetadataStore, IWebApiDataSource, utils, errorHandler, classes } from '@ohif/core'; import { mapParams, search as qidoSearch, seriesInStudy, processResults, processSeriesResults, } from './qido.js'; import dcm4cheeReject from './dcm4cheeReject.js'; import getImageId from './utils/getImageId.js'; import dcmjs from 'dcmjs'; import { retrieveStudyMetadata, deleteStudyMetadataPromise } from './retrieveStudyMetadata.js'; import StaticWadoClient from './utils/StaticWadoClient'; import getDirectURL from '../utils/getDirectURL'; import { fixBulkDataURI } from './utils/fixBulkDataURI'; const { DicomMetaDictionary, DicomDict } = dcmjs.data; const { naturalizeDataset, denaturalizeDataset } = DicomMetaDictionary; const ImplementationClassUID = '2.25.270695996825855179949881587723571202391.2.0.0'; const ImplementationVersionName = 'OHIF-VIEWER-2.0.0'; const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1'; const metadataProvider = classes.MetadataProvider; export type DicomWebConfig = { /** Data source name */ name: string; // wadoUriRoot - Legacy? (potentially unused/replaced) /** Base URL to use for QIDO requests */ qidoRoot?: string; wadoRoot?: string; // - Base URL to use for WADO requests wadoUri?: string; // - Base URL to use for WADO URI requests qidoSupportsIncludeField?: boolean; // - Whether QIDO supports the "Include" option to request additional fields in response imageRendering?: string; // - wadors | ? (unsure of where/how this is used) thumbnailRendering?: string; // - wadors | ? (unsure of where/how this is used) /** Whether the server supports reject calls (i.e. DCM4CHEE) */ supportsReject?: boolean; /** Request series meta async instead of blocking */ lazyLoadStudy?: boolean; /** indicates if the retrieves can fetch singlepart. Options are bulkdata, video, image, or true */ singlepart?: boolean | string; /** Transfer syntax to request from the server */ requestTransferSyntaxUID?: string; acceptHeader?: string[]; // - Accept header to use for requests /** Whether to omit quotation marks for multipart requests */ omitQuotationForMultipartRequest?: boolean; /** Whether the server supports fuzzy matching */ supportsFuzzyMatching?: boolean; /** Whether the server supports wildcard matching */ supportsWildcard?: boolean; /** Whether the server supports the native DICOM model */ supportsNativeDICOMModel?: boolean; /** Whether to enable request tag */ enableRequestTag?: boolean; /** Whether to enable study lazy loading */ enableStudyLazyLoad?: boolean; /** Whether to enable bulkDataURI */ bulkDataURI?: BulkDataURIConfig; /** Function that is called after the configuration is initialized */ onConfiguration: (config: DicomWebConfig, params) => DicomWebConfig; /** Whether to use the static WADO client */ staticWado?: boolean; /** User authentication service */ userAuthenticationService: Record; }; export type BulkDataURIConfig = { /** Enable bulkdata uri configuration */ enabled?: boolean; /** * Remove the startsWith string. * This is used to correct reverse proxied URLs by removing the startsWith path */ startsWith?: string; /** * Adds this prefix path. Only used if the startsWith is defined and has * been removed. This allows replacing the base path. */ prefixWith?: string; /** Transform the bulkdata path. Used to replace a portion of the path */ transform?: (uri: string) => string; /** * Adds relative resolution to the path handling. * series is the default, as the metadata retrieved is series level. */ relativeResolution?: 'studies' | 'series'; }; /** * Creates a DICOM Web API based on the provided configuration. * * @param dicomWebConfig - Configuration for the DICOM Web API * @returns DICOM Web API object */ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { const { userAuthenticationService } = servicesManager.services; let dicomWebConfigCopy, qidoConfig, wadoConfig, qidoDicomWebClient, wadoDicomWebClient, getAuthorizationHeader, generateWadoHeader; // Default to enabling bulk data retrieves, with no other customization as // this is part of hte base standard. dicomWebConfig.bulkDataURI ||= { enabled: true }; const implementation = { initialize: ({ params, query }) => { if (dicomWebConfig.onConfiguration && typeof dicomWebConfig.onConfiguration === 'function') { dicomWebConfig = dicomWebConfig.onConfiguration(dicomWebConfig, { params, query, }); } dicomWebConfigCopy = JSON.parse(JSON.stringify(dicomWebConfig)); getAuthorizationHeader = () => { const xhrRequestHeaders = {}; const authHeaders = userAuthenticationService.getAuthorizationHeader(); if (authHeaders && authHeaders.Authorization) { xhrRequestHeaders.Authorization = authHeaders.Authorization; } return xhrRequestHeaders; }; generateWadoHeader = () => { const authorizationHeader = getAuthorizationHeader(); //Generate accept header depending on config params const formattedAcceptHeader = utils.generateAcceptHeader( dicomWebConfig.acceptHeader, dicomWebConfig.requestTransferSyntaxUID, dicomWebConfig.omitQuotationForMultipartRequest ); return { ...authorizationHeader, Accept: formattedAcceptHeader, }; }; qidoConfig = { url: dicomWebConfig.qidoRoot, staticWado: dicomWebConfig.staticWado, singlepart: dicomWebConfig.singlepart, headers: userAuthenticationService.getAuthorizationHeader(), errorInterceptor: errorHandler.getHTTPErrorHandler(), supportsFuzzyMatching: dicomWebConfig.supportsFuzzyMatching, }; wadoConfig = { url: dicomWebConfig.wadoRoot, staticWado: dicomWebConfig.staticWado, singlepart: dicomWebConfig.singlepart, headers: userAuthenticationService.getAuthorizationHeader(), errorInterceptor: errorHandler.getHTTPErrorHandler(), supportsFuzzyMatching: dicomWebConfig.supportsFuzzyMatching, }; // TODO -> Two clients sucks, but its better than 1000. // TODO -> We'll need to merge auth later. qidoDicomWebClient = dicomWebConfig.staticWado ? new StaticWadoClient(qidoConfig) : new api.DICOMwebClient(qidoConfig); wadoDicomWebClient = dicomWebConfig.staticWado ? new StaticWadoClient(wadoConfig) : new api.DICOMwebClient(wadoConfig); }, query: { studies: { mapParams: mapParams.bind(), search: async function (origParams) { qidoDicomWebClient.headers = getAuthorizationHeader(); const { studyInstanceUid, seriesInstanceUid, ...mappedParams } = mapParams(origParams, { supportsFuzzyMatching: dicomWebConfig.supportsFuzzyMatching, supportsWildcard: dicomWebConfig.supportsWildcard, }) || {}; const results = await qidoSearch(qidoDicomWebClient, undefined, undefined, mappedParams); return processResults(results); }, processResults: processResults.bind(), }, series: { // mapParams: mapParams.bind(), search: async function (studyInstanceUid) { qidoDicomWebClient.headers = getAuthorizationHeader(); const results = await seriesInStudy(qidoDicomWebClient, studyInstanceUid); return processSeriesResults(results); }, // processResults: processResults.bind(), }, instances: { search: (studyInstanceUid, queryParameters) => { qidoDicomWebClient.headers = getAuthorizationHeader(); return qidoSearch.call( undefined, qidoDicomWebClient, studyInstanceUid, null, queryParameters ); }, }, }, retrieve: { /** * Generates a URL that can be used for direct retrieve of the bulkdata * * @param {object} params * @param {string} params.tag is the tag name of the URL to retrieve * @param {object} params.instance is the instance object that the tag is in * @param {string} params.defaultType is the mime type of the response * @param {string} params.singlepart is the type of the part to retrieve * @returns an absolute URL to the resource, if the absolute URL can be retrieved as singlepart, * or is already retrieved, or a promise to a URL for such use if a BulkDataURI */ directURL: params => { return getDirectURL( { wadoRoot: dicomWebConfig.wadoRoot, singlepart: dicomWebConfig.singlepart, }, params ); }, /** * Provide direct access to the dicom web client for certain use cases * where the dicom web client is used by an external library such as the * microscopy viewer. * Note this instance only needs to support the wado queries, and may not * support any QIDO or STOW operations. */ getWadoDicomWebClient: () => wadoDicomWebClient, bulkDataURI: async ({ StudyInstanceUID, BulkDataURI }) => { qidoDicomWebClient.headers = getAuthorizationHeader(); const options = { multipart: false, BulkDataURI, StudyInstanceUID, }; return qidoDicomWebClient.retrieveBulkData(options).then(val => { const ret = (val && val[0]) || undefined; return ret; }); }, series: { metadata: async ({ StudyInstanceUID, filters, sortCriteria, sortFunction, madeInClient = false, returnPromises = false, } = {}) => { if (!StudyInstanceUID) { throw new Error('Unable to query for SeriesMetadata without StudyInstanceUID'); } if (dicomWebConfig.enableStudyLazyLoad) { return implementation._retrieveSeriesMetadataAsync( StudyInstanceUID, filters, sortCriteria, sortFunction, madeInClient, returnPromises ); } return implementation._retrieveSeriesMetadataSync( StudyInstanceUID, filters, sortCriteria, sortFunction, madeInClient ); }, }, }, store: { dicom: async (dataset, request, dicomDict) => { wadoDicomWebClient.headers = getAuthorizationHeader(); if (dataset instanceof ArrayBuffer) { const options = { datasets: [dataset], request, }; await wadoDicomWebClient.storeInstances(options); } else { let effectiveDicomDict = dicomDict; if (!dicomDict) { const meta = { FileMetaInformationVersion: dataset._meta?.FileMetaInformationVersion?.Value, MediaStorageSOPClassUID: dataset.SOPClassUID, MediaStorageSOPInstanceUID: dataset.SOPInstanceUID, TransferSyntaxUID: EXPLICIT_VR_LITTLE_ENDIAN, ImplementationClassUID, ImplementationVersionName, }; const denaturalized = denaturalizeDataset(meta); const defaultDicomDict = new DicomDict(denaturalized); defaultDicomDict.dict = denaturalizeDataset(dataset); effectiveDicomDict = defaultDicomDict; } const part10Buffer = effectiveDicomDict.write(); const options = { datasets: [part10Buffer], request, }; await wadoDicomWebClient.storeInstances(options); } }, }, _retrieveSeriesMetadataSync: async ( StudyInstanceUID, filters, sortCriteria, sortFunction, madeInClient ) => { const enableStudyLazyLoad = false; wadoDicomWebClient.headers = generateWadoHeader(); // data is all SOPInstanceUIDs const data = await retrieveStudyMetadata( wadoDicomWebClient, StudyInstanceUID, enableStudyLazyLoad, filters, sortCriteria, sortFunction, dicomWebConfig ); // first naturalize the data const naturalizedInstancesMetadata = data.map(naturalizeDataset); const seriesSummaryMetadata = {}; const instancesPerSeries = {}; naturalizedInstancesMetadata.forEach(instance => { if (!seriesSummaryMetadata[instance.SeriesInstanceUID]) { seriesSummaryMetadata[instance.SeriesInstanceUID] = { StudyInstanceUID: instance.StudyInstanceUID, StudyDescription: instance.StudyDescription, SeriesInstanceUID: instance.SeriesInstanceUID, SeriesDescription: instance.SeriesDescription, SeriesNumber: instance.SeriesNumber, SeriesTime: instance.SeriesTime, SOPClassUID: instance.SOPClassUID, ProtocolName: instance.ProtocolName, Modality: instance.Modality, }; } if (!instancesPerSeries[instance.SeriesInstanceUID]) { instancesPerSeries[instance.SeriesInstanceUID] = []; } const imageId = implementation.getImageIdsForInstance({ instance, }); instance.imageId = imageId; instance.wadoRoot = dicomWebConfig.wadoRoot; instance.wadoUri = dicomWebConfig.wadoUri; metadataProvider.addImageIdToUIDs(imageId, { StudyInstanceUID, SeriesInstanceUID: instance.SeriesInstanceUID, SOPInstanceUID: instance.SOPInstanceUID, }); instancesPerSeries[instance.SeriesInstanceUID].push(instance); }); // grab all the series metadata const seriesMetadata = Object.values(seriesSummaryMetadata); DicomMetadataStore.addSeriesMetadata(seriesMetadata, madeInClient); Object.keys(instancesPerSeries).forEach(seriesInstanceUID => DicomMetadataStore.addInstances(instancesPerSeries[seriesInstanceUID], madeInClient) ); return seriesSummaryMetadata; }, _retrieveSeriesMetadataAsync: async ( StudyInstanceUID, filters, sortCriteria, sortFunction, madeInClient = false, returnPromises = false ) => { const enableStudyLazyLoad = true; wadoDicomWebClient.headers = generateWadoHeader(); // Get Series const { preLoadData: seriesSummaryMetadata, promises: seriesPromises } = await retrieveStudyMetadata( wadoDicomWebClient, StudyInstanceUID, enableStudyLazyLoad, filters, sortCriteria, sortFunction, dicomWebConfig ); /** * Adds the retrieve bulkdata function to naturalized DICOM data. * This is done recursively, for sub-sequences. */ const addRetrieveBulkDataNaturalized = (naturalized, instance = naturalized) => { for (const key of Object.keys(naturalized)) { const value = naturalized[key]; if (Array.isArray(value) && typeof value[0] === 'object') { // Fix recursive values const validValues = value.filter(Boolean); validValues.forEach(child => addRetrieveBulkDataNaturalized(child, instance)); continue; } // The value.Value will be set with the bulkdata read value // in which case it isn't necessary to re-read this. if (value && value.BulkDataURI && !value.Value) { // handle the scenarios where bulkDataURI is relative path fixBulkDataURI(value, instance, dicomWebConfig); // Provide a method to fetch bulkdata value.retrieveBulkData = retrieveBulkData.bind(qidoDicomWebClient, value); } } return naturalized; }; /** * naturalizes the dataset, and adds a retrieve bulkdata method * to any values containing BulkDataURI. * @param {*} instance * @returns naturalized dataset, with retrieveBulkData methods */ const addRetrieveBulkData = instance => { const naturalized = naturalizeDataset(instance); // if we know the server doesn't use bulkDataURI, then don't if (!dicomWebConfig.bulkDataURI?.enabled) { return naturalized; } return addRetrieveBulkDataNaturalized(naturalized); }; // Async load series, store as retrieved function storeInstances(instances) { const naturalizedInstances = instances.map(addRetrieveBulkData); // Adding instanceMetadata to OHIF MetadataProvider naturalizedInstances.forEach(instance => { instance.wadoRoot = dicomWebConfig.wadoRoot; instance.wadoUri = dicomWebConfig.wadoUri; const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance; const numberOfFrames = instance.NumberOfFrames || 1; // Process all frames consistently, whether single or multiframe for (let i = 0; i < numberOfFrames; i++) { const frameNumber = i + 1; const frameImageId = implementation.getImageIdsForInstance({ instance, frame: frameNumber, }); // Add imageId specific mapping to this data as the URL isn't necessarily WADO-URI. metadataProvider.addImageIdToUIDs(frameImageId, { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID, frameNumber: numberOfFrames > 1 ? frameNumber : undefined, }); } // Adding imageId to each instance // Todo: This is not the best way I can think of to let external // metadata handlers know about the imageId that is stored in the store const imageId = implementation.getImageIdsForInstance({ instance, }); instance.imageId = imageId; }); DicomMetadataStore.addInstances(naturalizedInstances, madeInClient); } function setSuccessFlag() { const study = DicomMetadataStore.getStudy(StudyInstanceUID); if (!study) { return; } study.isLoaded = true; } // Google Cloud Healthcare doesn't return StudyInstanceUID, so we need to add // it manually here seriesSummaryMetadata.forEach(aSeries => { aSeries.StudyInstanceUID = StudyInstanceUID; }); DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient); const seriesDeliveredPromises = seriesPromises.map(promise => { if (!returnPromises) { promise?.start(); } return promise.then(instances => { storeInstances(instances); }); }); if (returnPromises) { Promise.all(seriesDeliveredPromises).then(() => setSuccessFlag()); return seriesPromises; } else { await Promise.all(seriesDeliveredPromises); setSuccessFlag(); } return seriesSummaryMetadata; }, deleteStudyMetadataPromise, getImageIdsForDisplaySet(displaySet) { const images = displaySet.images; const imageIds = []; if (!images) { return imageIds; } displaySet.images.forEach(instance => { const NumberOfFrames = instance.NumberOfFrames; if (NumberOfFrames > 1) { for (let frame = 1; frame <= NumberOfFrames; frame++) { const imageId = this.getImageIdsForInstance({ instance, frame, }); imageIds.push(imageId); } } else { const imageId = this.getImageIdsForInstance({ instance }); imageIds.push(imageId); } }); return imageIds; }, getImageIdsForInstance({ instance, frame = undefined }) { const imageIds = getImageId({ instance, frame, config: dicomWebConfig, }); return imageIds; }, getConfig() { return dicomWebConfigCopy; }, getStudyInstanceUIDs({ params, query }) { const paramsStudyInstanceUIDs = params.StudyInstanceUIDs || params.studyInstanceUIDs; const queryStudyInstanceUIDs = utils.splitComma( query.getAll('StudyInstanceUIDs').concat(query.getAll('studyInstanceUIDs')) ); const StudyInstanceUIDs = (queryStudyInstanceUIDs.length && queryStudyInstanceUIDs) || paramsStudyInstanceUIDs; const StudyInstanceUIDsAsArray = StudyInstanceUIDs && Array.isArray(StudyInstanceUIDs) ? StudyInstanceUIDs : [StudyInstanceUIDs]; return StudyInstanceUIDsAsArray; }, }; if (dicomWebConfig.supportsReject) { implementation.reject = dcm4cheeReject(dicomWebConfig.wadoRoot, getAuthorizationHeader); } return IWebApiDataSource.create(implementation); } /** * A bindable function that retrieves the bulk data against this as the * dicomweb client, and on the given value element. * * @param value - a bind value that stores the retrieve value to short circuit the * next retrieve instance. * @param options - to allow specifying the content type. */ function retrieveBulkData(value, options = {}) { const { mediaType } = options; const useOptions = { // The bulkdata fetches work with either multipart or // singlepart, so set multipart to false to let the server // decide which type to respond with. multipart: false, BulkDataURI: value.BulkDataURI, mediaTypes: mediaType ? [{ mediaType }, { mediaType: 'application/octet-stream' }] : undefined, ...options, }; return this.retrieveBulkData(useOptions).then(val => { // There are DICOM PDF cases where the first ArrayBuffer in the array is // the bulk data and DICOM video cases where the second ArrayBuffer is // the bulk data. Here we play it safe and do a find. const ret = (val instanceof Array && val.find(arrayBuffer => arrayBuffer?.byteLength)) || undefined; value.Value = ret; return ret; }); } export { createDicomWebApi };