Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
};
}
8 changes: 7 additions & 1 deletion packages/static-wado-webserver/lib/routes/server/studies.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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".
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading