diff --git a/packages/static-wado-webserver/lib/controllers/server/studyMetadataQueryController.mjs b/packages/static-wado-webserver/lib/controllers/server/studyMetadataQueryController.mjs new file mode 100644 index 0000000..1994c1c --- /dev/null +++ b/packages/static-wado-webserver/lib/controllers/server/studyMetadataQueryController.mjs @@ -0,0 +1,75 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import zlib from 'node:zlib'; +import { handleHomeRelative } from '@radicalimaging/static-wado-util'; +import { seriesMain } from '@radicalimaging/create-dicomweb'; + +const gunzip = promisify(zlib.gunzip); + +async function readSeriesMetadata(seriesDirectory) { + const gzipPath = path.join(seriesDirectory, 'metadata.gz'); + + try { + const compressed = await fs.readFile(gzipPath); + return JSON.parse((await gunzip(compressed)).toString('utf8')); + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error; + } + } + + return JSON.parse(await fs.readFile(path.join(seriesDirectory, 'metadata'), 'utf8')); +} + +/** + * Serves standard WADO-RS study metadata by combining the generated metadata + * arrays for every series in the study. + * + * @param {string} dir Static DICOMweb root directory. + * @param {object} params Server parameters. + * @returns {function} Express middleware. + */ +export function studyMetadataQueryController(dir, params = {}) { + return async function studyMetadataQueryControllerHandler(req, res, next) { + const root = handleHomeRelative(dir ?? params.rootDir); + const studyUID = req.params.studyUID; + const studyPath = req.staticWadoPath.replace(/\/metadata\/?$/, '').replace(/^\/+/, ''); + const seriesRoot = path.join(root, studyPath, 'series'); + const createOnDemand = params.createIndexOnDemand !== false; + + try { + const seriesEntries = (await fs.readdir(seriesRoot, { withFileTypes: true })) + .filter(entry => entry.isDirectory()) + .sort((left, right) => left.name.localeCompare(right.name)); + + const metadataBySeries = await Promise.all( + seriesEntries.map(async entry => { + const seriesDirectory = path.join(seriesRoot, entry.name); + + try { + return await readSeriesMetadata(seriesDirectory); + } catch (error) { + if (!createOnDemand || error?.code !== 'ENOENT') { + throw error; + } + await seriesMain(studyUID, { dicomdir: root, seriesUid: entry.name }); + return readSeriesMetadata(seriesDirectory); + } + }) + ); + + const metadata = metadataBySeries.flat(); + if (!metadata.length) { + return res.status(404).json([]); + } + + return res.type('application/dicom+json').send(metadata); + } catch (error) { + if (error?.code === 'ENOENT') { + return res.status(404).json([]); + } + return next(error); + } + }; +} diff --git a/packages/static-wado-webserver/lib/routes/server/studies.mjs b/packages/static-wado-webserver/lib/routes/server/studies.mjs index cd44726..8c349c6 100644 --- a/packages/static-wado-webserver/lib/routes/server/studies.mjs +++ b/packages/static-wado-webserver/lib/routes/server/studies.mjs @@ -17,6 +17,7 @@ import { import { studyQueryController } from '../../controllers/server/indexOnDemandController.mjs'; import { seriesQueryController } from '../../controllers/server/seriesQueryController.mjs'; import { seriesMetadataQueryController } from '../../controllers/server/seriesMetadataQueryController.mjs'; +import { studyMetadataQueryController } from '../../controllers/server/studyMetadataQueryController.mjs'; import { defaultNotFoundController as notFoundController } from '../../controllers/server/notFoundControllers.mjs'; import { defaultGetProxyController } from '../../controllers/server/proxyControllers.mjs'; import { @@ -131,7 +132,11 @@ export default function setRoutes(routerExpress, params, dir, hashStudyUidPath) multipartIndexMap ); - routerExpress.get(['/studies/:studyUID/series/:seriesUID/instances'], qidoMap); + routerExpress.get( + ['/studies/:studyUID/series/:seriesUID/instances'], + qidoMap, + indexingStaticController(dir) + ); // Part 10 controller: handles accept negotiation for application/dicom, // application/zip, and multipart/related; type="application/dicom". @@ -146,6 +151,7 @@ export default function setRoutes(routerExpress, params, dir, hashStudyUidPath) '/studies/:studyUID/series/:seriesUID/metadata', seriesMetadataQueryController(dir, params) ); + routerExpress.get('/studies/:studyUID/metadata', studyMetadataQueryController(dir, params)); routerExpress.get( [ '/studies/:studyUID/series/metadata', diff --git a/packages/static-wado-webserver/test/unit/studyMetadataQueryController.jest.mjs b/packages/static-wado-webserver/test/unit/studyMetadataQueryController.jest.mjs new file mode 100644 index 0000000..c0425df --- /dev/null +++ b/packages/static-wado-webserver/test/unit/studyMetadataQueryController.jest.mjs @@ -0,0 +1,72 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import zlib from 'node:zlib'; +import { studyMetadataQueryController } from '../../lib/controllers/server/studyMetadataQueryController.mjs'; + +const gzip = promisify(zlib.gzip); + +describe('studyMetadataQueryController', () => { + let root; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'static-wado-study-metadata-')); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + it('combines series metadata arrays for the standard study metadata endpoint', async () => { + const studyUID = '1.2.3'; + const seriesRoot = path.join(root, 'studies', studyUID, 'series'); + const expected = [ + { '00080018': { vr: 'UI', Value: ['1.2.3.1.1'] } }, + { '00080018': { vr: 'UI', Value: ['1.2.3.2.1'] } }, + ]; + + for (const [index, metadata] of expected.entries()) { + const seriesDirectory = path.join(seriesRoot, `1.2.3.${index + 1}`); + await fs.mkdir(seriesDirectory, { recursive: true }); + await fs.writeFile( + path.join(seriesDirectory, 'metadata.gz'), + await gzip(JSON.stringify([metadata])) + ); + } + + const req = { + params: { studyUID }, + staticWadoPath: `/studies/${studyUID}/metadata`, + }; + const res = { + statusCode: 200, + contentType: undefined, + body: undefined, + status(value) { + this.statusCode = value; + return this; + }, + type(value) { + this.contentType = value; + return this; + }, + send(value) { + this.body = value; + return this; + }, + json(value) { + this.body = value; + return this; + }, + }; + const next = jest.fn(); + + await studyMetadataQueryController(root, { createIndexOnDemand: false })(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(res.contentType).toBe('application/dicom+json'); + expect(res.body).toEqual(expected); + }); +});