diff --git a/api-sql/dicom-web/controller/QIDO-RS/queryAllStudies.js b/api-sql/dicom-web/controller/QIDO-RS/queryAllStudies.js new file mode 100644 index 00000000..6fbd239a --- /dev/null +++ b/api-sql/dicom-web/controller/QIDO-RS/queryAllStudies.js @@ -0,0 +1,49 @@ +const { + SqlQidoRsService: QidoRsService +} = require("./service/QIDO-RS.service"); +const { ApiLogger } = require("@root/utils/logs/api-logger"); +const { Controller } = require("@root/api/controller.class"); + +class QueryAllStudiesController extends Controller { + constructor(req, res) { + super(req, res); + } + + async mainProcess() { + let apiLogger = new ApiLogger(this.request, "QIDO-RS"); + + apiLogger.addTokenValue(); + apiLogger.logger.info(`Query All Studies`); + + try { + + let qidoRsService = new QidoRsService(this.request, this.response, "study"); + + await qidoRsService.getAndResponseDicomJson(); + + } catch (e) { + let errorStr = JSON.stringify(e, Object.getOwnPropertyNames(e)); + apiLogger.logger.error(errorStr); + + this.response.writeHead(500, { + "Content-Type": "application/dicom+json" + }); + this.response.end(JSON.stringify({ + code: 500, + message: errorStr + })); + } + } +} + +/** + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +module.exports = async function (req, res) { + let controller = new QueryAllStudiesController(req, res); + + await controller.doPipeline(); +}; + diff --git a/api-sql/dicom-web/controller/QIDO-RS/service/QIDO-RS.service.js b/api-sql/dicom-web/controller/QIDO-RS/service/QIDO-RS.service.js new file mode 100644 index 00000000..fd88c3bb --- /dev/null +++ b/api-sql/dicom-web/controller/QIDO-RS/service/QIDO-RS.service.js @@ -0,0 +1,146 @@ +const _ = require("lodash"); + +const { QidoRsService } = require("@root/api/dicom-web/controller/QIDO-RS/service/QIDO-RS.service"); +const { DicomWebService } = require("@root/api/dicom-web/service/dicom-web.service"); +const { StudyQueryBuilder } = require("./querybuilder"); +const { dictionary } = require("@models/DICOM/dicom-tags-dic"); +const { + DicomWebServiceError, + DicomWebStatusCodes +} = require("@error/dicom-web-service"); +const { StudyModel } = require("@models/sql/models/study.model"); + + +class SqlQidoRsService extends QidoRsService { + /** + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {"study" | "series" | "instance"} level + */ + constructor(req, res, level = "instance") { + super(req, res, level); + } + + async getAndResponseDicomJson() { + try { + + let dicomWebService = new DicomWebService(this.request, this.response); + + let queryOptions = { + query: this.query, + skip: this.skip_, + limit: this.limit_, + includeFields: this.includeFields_, + retrieveBaseUrl: `${dicomWebService.getBasicURL()}/studies`, + requestParams: this.request.params + }; + + let qidoDicomJsonFactory = new QidoDicomJsonFactory(queryOptions, this.level); + + let dicomJson = await qidoDicomJsonFactory.getDicomJson(); + + let dicomJsonLength = _.get(dicomJson, "length", 0); + if (dicomJsonLength > 0) { + this.response.writeHead(200, { + "Content-Type": "application/dicom+json" + }); + this.response.end(JSON.stringify(dicomJson)); + } else { + this.response.writeHead(204); + this.response.end(); + } + + } catch (e) { + throw e; + } + } + + /** + * @private + */ + initQuery_() { + let query = _.cloneDeep(this.request.query); + let queryKeys = Object.keys(query).sort(); + for (let i = 0; i < queryKeys.length; i++) { + let queryKey = queryKeys[i]; + if (!query[queryKey]) delete query[queryKey]; + } + + this.query = convertAllQueryToDicomTag(query); + } +} + +class QidoDicomJsonFactory { + + /** + * + * @param {import("../../../../../utils/typeDef/dicom").DicomJsonMongoQueryOptions} queryOptions + * @param {string} level + */ + constructor(queryOptions, level = "instance") { + this.level = level; + + this.getDicomJsonByLevel = { + "patient": async () => { + // return await getPatientDicomJson(queryOptions); + }, + "study": async () => { + // return await getStudyDicomJson(queryOptions); + let queryBuilder = new StudyQueryBuilder(queryOptions); + queryBuilder.build(); + let studies = await StudyModel.findAll({ + where: queryBuilder.query + }); + console.log(studies); + }, + "series": async () => { + // return await getSeriesDicomJson(queryOptions); + }, + "instance": async () => { + // return await getInstanceDicomJson(queryOptions); + } + }; + } + + async getDicomJson() { + return await this.getDicomJsonByLevel[this.level](); + } +} + + +/** + * Convert All of name(tags, keyword) of queries to tags number + * @param {Object} iParam The request query. + * @returns + */ +function convertAllQueryToDicomTag(iParam) { + let keys = Object.keys(iParam); + let newQS = {}; + for (let i = 0; i < keys.length; i++) { + let keyName = keys[i]; + let keyNameSplit = keyName.split("."); + let newKeyNames = []; + for (let x = 0; x < keyNameSplit.length; x++) { + if (dictionary.keyword[keyNameSplit[x]]) { + newKeyNames.push(dictionary.keyword[keyNameSplit[x]]); + } else if (dictionary.tag[keyNameSplit[x]]) { + newKeyNames.push(keyNameSplit); + } + } + if (newKeyNames.length === 0) { + throw new DicomWebServiceError( + DicomWebStatusCodes.InvalidArgumentValue, + `Invalid request query: ${keyNameSplit}`, + 400 + ); + } else if (newKeyNames.length >= 2) { + newKeyNames.push("Value"); + } + let retKeyName = newKeyNames.join("."); + newQS[retKeyName] = iParam[keyName]; + } + return newQS; +} + +module.exports.SqlQidoRsService = SqlQidoRsService; \ No newline at end of file diff --git a/api-sql/dicom-web/controller/QIDO-RS/service/querybuilder.js b/api-sql/dicom-web/controller/QIDO-RS/service/querybuilder.js new file mode 100644 index 00000000..3c13fac9 --- /dev/null +++ b/api-sql/dicom-web/controller/QIDO-RS/service/querybuilder.js @@ -0,0 +1,170 @@ +const _ = require("lodash"); +const moment = require("moment"); +const { dictionary } = require("@models/DICOM/dicom-tags-dic"); +const { Op } = require("sequelize"); + +class BaseQueryBuilder { + constructor(queryOptions) { + this.queryOptions = queryOptions; + this.personQuery = []; + } + + comma(key, value) { + let $or = []; + let valueCommaSplit = value.split(","); + for (let i = 0; i < valueCommaSplit.length; i++) { + let obj = {}; + obj[key] = valueCommaSplit[i]; + $or.push(obj); + } + return $or; + } + + getStringQuery(tag, value) { + return { + [`x${tag}`]: value + }; + } + + getStringArrayQuery(tag, value) { + //TODO + } + + getNumberQuery(tag, value) { + return { + [`x${tag}`]: Number(value) + }; + } + + getNumberArrayQuery(tag, value) { + //TODO + } + + getPersonNameQuery(value) { + return { + [Op.or]: { + alphabetic: value, + ideographic: value, + phonetic: value + } + }; + } + + /** + * + * @param {string} tag + * @param {string} value + */ + getDateQuery(tag, value) { + let dashIndex = value.indexOf("-"); + if (dashIndex === 0) { // -YYYYMMDD + return { + [`x${tag}`]: { + [Op.lte]: this.dateStringToSqlDateOnly(value) + } + }; + } else if (dashIndex === value.length - 1) { // YYYYMMDD- + return { + [`x${tag}`]: { + [Op.gte]: this.dateStringToSqlDateOnly(value) + } + }; + } else if (dashIndex > 0) { // YYYYMMDD-YYYYMMDD + return { + [`x${tag}`]: { + [Op.and]: [ + { [Op.gte]: this.dateStringToSqlDateOnly(value.substring(0, dashIndex)) }, + { [Op.lte]: this.dateStringToSqlDateOnly(value.substring(dashIndex + 1)) } + ] + } + }; + } else { // YYYYMMDD + return { + [`x${tag}`]: this.dateStringToSqlDateOnly(value) + }; + } + } + + dateStringToSqlDateOnly(value) { + return moment(value, "YYYYMMDD").format("YYYY-MM-DD"); + } + + + /** + * + * @param {string} value + * @returns + */ + getWildCardQuery(value) { + let wildCardIndex = value.indexOf("*"); + let questionIndex = value.indexOf("?"); + + if (wildCardIndex >= 0 || questionIndex >= 0) { + value = value.replace(/\*/gm, "%"); + value = value.replace(/\?/gm, "_"); + } + + return value; + } + + /** + * + * @param {*} q + * @see {@link https://stackoverflow.com/questions/60598225/how-to-merge-javascript-object-containing-symbols "How to merge javascript object containing symbols?"} + */ + mergeQuery(q) { + _.mergeWith(this.query, q, (a ,b) => { + if (!_.isObject(b)) return b; + + return Array.isArray(a) ? [...a, ...b] : { ...a, ...b }; + }); + } +} + +class StudyQueryBuilder extends BaseQueryBuilder { + constructor(queryOptions) { + super(queryOptions); + this.query = {}; + } + + build() { + for (let key in this.queryOptions.query) { + let commaValue = this.comma(key, this.queryOptions.query[key]); + + for (let i = 0; i < commaValue.length; i++) { + let value = this.getWildCardQuery(commaValue[i][key]); + try { + this[`get${dictionary.tag[key]}`](value); + } catch (e) { + if (e.message.includes("not a function")) break; + } + } + } + console.log(this.query); + } + + getStudyInstanceUID(value) { + let q = this.getStringQuery(dictionary.keyword.StudyInstanceUID, value); + _.merge(this.query, q); + } + + getPatientName(value) { + let q = this.getPersonNameQuery(value); + this.personQuery.push(q); + } + + getPatientID(value) { + let q = this.getStringQuery(dictionary.keyword.PatientID, value); + _.merge(this.query, q); + } + + getStudyDate(value) { + let q = this.getDateQuery(dictionary.keyword.StudyDate, value); + this.mergeQuery(q); + } + +} + + +module.exports.BaseQueryBuilder = BaseQueryBuilder; +module.exports.StudyQueryBuilder = StudyQueryBuilder; \ No newline at end of file diff --git a/api-sql/dicom-web/qido-rs.route.js b/api-sql/dicom-web/qido-rs.route.js new file mode 100644 index 00000000..ea74e98c --- /dev/null +++ b/api-sql/dicom-web/qido-rs.route.js @@ -0,0 +1,311 @@ +const express = require("express"); +const Joi = require("joi"); +const { validateParams, intArrayJoi, stringArrayJoi } = require("@root/api/validator"); +const router = express(); +const { + dictionary +} = require("../../models/DICOM/dicom-tags-dic"); + +const KEYWORD_KEYS = Object.keys(dictionary.keyword); +const HEX_KEYS = Object.keys(dictionary.tag); + +//#region QIDO-RS +const queryValidation = { + limit: Joi.number().integer().min(1).max(100), + offset: Joi.number().integer().min(0), + includefield: stringArrayJoi.stringArray().items( + Joi.string().custom( + (attribute, helper) => { + if (!isValidAttribute(attribute)) { + return helper.message(`Invalid DICOM attribute: ${attribute}, please enter valid keyword or tag`); + } + return convertKeywordToHex(attribute); + } + ) + ).single() +}; + +/** + * + * @param {string} attribute + */ +function isValidAttribute(attribute) { + if (KEYWORD_KEYS.indexOf(attribute) >= 0 || + HEX_KEYS.indexOf(attribute) >= 0 || + attribute === "all") { + + return true; + } + + return false; +} + +function convertKeywordToHex(attribute) { + if (KEYWORD_KEYS.indexOf(attribute) >= 0) { + return dictionary.keyword[attribute]; + } + return attribute; +} + + +/** + * @openapi + * /dicom-web/studies: + * get: + * tags: + * - QIDO-RS + * description: Query for studies + * parameters: + * - $ref: "#/components/parameters/StudyDate" + * - $ref: "#/components/parameters/StudyTime" + * - $ref: "#/components/parameters/AccessionNumber" + * - $ref: "#/components/parameters/ModalitiesInStudy" + * - $ref: "#/components/parameters/ReferringPhysicianName" + * - $ref: "#/components/parameters/PatientName" + * - $ref: "#/components/parameters/PatientID" + * - $ref: "#/components/parameters/StudyID" + * responses: + * 200: + * description: Query successfully + * content: + * "application/dicom+json": + * schema: + * type: array + * items: + * allOf: + * - $ref: "#/components/schemas/StudyRequiredMatchingAttributes" + */ +router.get("/studies", validateParams(queryValidation, "query", { + allowUnknown: true +}), require("./controller/QIDO-RS/queryAllStudies")); + +/** + * @openapi + * /dicom-web/studies/{studyUID}/series: + * get: + * tags: + * - QIDO-RS + * description: Query for series from specific study's UID + * parameters: + * - $ref: "#/components/parameters/studyUID" + * - $ref: "#/components/parameters/StudyDate" + * - $ref: "#/components/parameters/StudyTime" + * - $ref: "#/components/parameters/AccessionNumber" + * - $ref: "#/components/parameters/ModalitiesInStudy" + * - $ref: "#/components/parameters/ReferringPhysicianName" + * - $ref: "#/components/parameters/PatientName" + * - $ref: "#/components/parameters/PatientID" + * - $ref: "#/components/parameters/StudyID" + * - $ref: "#/components/parameters/Modality" + * - $ref: "#/components/parameters/SeriesNumber" + * responses: + * 200: + * description: Query successfully + * content: + * "application/dicom+json": + * schema: + * type: array + * items: + * allOf: + * - $ref: "#/components/schemas/StudyRequiredMatchingAttributes" + * - $ref: "#/components/schemas/SeriesRequiredMatchingAttributes" + */ +// router.get( +// "/studies/:studyUID/series", validateParams(queryValidation, "query", { +// allowUnknown: true +// }), +// require("./controller/QIDO-RS/queryStudies-Series") +// ); + +/** + * @openapi + * /dicom-web/studies/{studyUID}/instances: + * get: + * tags: + * - QIDO-RS + * description: Query for studies + * parameters: + * - $ref: "#/components/parameters/studyUID" + * - $ref: "#/components/parameters/StudyDate" + * - $ref: "#/components/parameters/StudyTime" + * - $ref: "#/components/parameters/AccessionNumber" + * - $ref: "#/components/parameters/ModalitiesInStudy" + * - $ref: "#/components/parameters/ReferringPhysicianName" + * - $ref: "#/components/parameters/PatientName" + * - $ref: "#/components/parameters/PatientID" + * - $ref: "#/components/parameters/StudyID" + * - $ref: "#/components/parameters/Modality" + * - $ref: "#/components/parameters/SeriesNumber" + * - $ref: "#/components/parameters/SOPClassUID" + * - $ref: "#/components/parameters/InstanceNumber" + * responses: + * 200: + * description: Query successfully + * content: + * "application/dicom+json": + * schema: + * type: array + * items: + * allOf: + * - $ref: "#/components/schemas/StudyRequiredMatchingAttributes" + * - $ref: "#/components/schemas/SeriesRequiredMatchingAttributes" + * - $ref: "#/components/schemas/InstanceRequiredMatchingAttributes" + */ +// router.get( +// "/studies/:studyUID/instances", validateParams(queryValidation, "query", { +// allowUnknown: true +// }), +// require("./controller/QIDO-RS/queryStudies-Instances") +// ); + +/** + * @openapi + * /dicom-web/studies/{studyUID}/series/{seriesUID}/instances: + * get: + * tags: + * - QIDO-RS + * description: Query for studies + * parameters: + * - $ref: "#/components/parameters/studyUID" + * - $ref: "#/components/parameters/seriesUID" + * - $ref: "#/components/parameters/StudyDate" + * - $ref: "#/components/parameters/StudyTime" + * - $ref: "#/components/parameters/AccessionNumber" + * - $ref: "#/components/parameters/ModalitiesInStudy" + * - $ref: "#/components/parameters/ReferringPhysicianName" + * - $ref: "#/components/parameters/PatientName" + * - $ref: "#/components/parameters/PatientID" + * - $ref: "#/components/parameters/StudyID" + * - $ref: "#/components/parameters/Modality" + * - $ref: "#/components/parameters/SeriesNumber" + * - $ref: "#/components/parameters/SOPClassUID" + * - $ref: "#/components/parameters/InstanceNumber" + * responses: + * 200: + * description: Query successfully + * content: + * "application/dicom+json": + * schema: + * type: array + * items: + * allOf: + * - $ref: "#/components/schemas/StudyRequiredMatchingAttributes" + * - $ref: "#/components/schemas/SeriesRequiredMatchingAttributes" + * - $ref: "#/components/schemas/InstanceRequiredMatchingAttributes" + */ +// router.get( +// "/studies/:studyUID/series/:seriesUID/instances", validateParams(queryValidation, "query", { +// allowUnknown: true +// }), +// require("./controller/QIDO-RS/queryStudies-Series-Instance") +// ); + +/** + * @openapi + * /dicom-web/series: + * get: + * tags: + * - QIDO-RS + * description: Query all series in server + * parameters: + * - $ref: "#/components/parameters/StudyDate" + * - $ref: "#/components/parameters/StudyTime" + * - $ref: "#/components/parameters/AccessionNumber" + * - $ref: "#/components/parameters/ModalitiesInStudy" + * - $ref: "#/components/parameters/ReferringPhysicianName" + * - $ref: "#/components/parameters/PatientName" + * - $ref: "#/components/parameters/PatientID" + * - $ref: "#/components/parameters/StudyID" + * - $ref: "#/components/parameters/Modality" + * - $ref: "#/components/parameters/SeriesNumber" + * responses: + * 200: + * description: Query successfully + * content: + * "application/dicom+json": + * schema: + * type: array + * items: + * allOf: + * - $ref: "#/components/schemas/StudyRequiredMatchingAttributes" + * - $ref: "#/components/schemas/SeriesRequiredMatchingAttributes" + * + */ +// router.get( +// "/series", validateParams(queryValidation, "query", { +// allowUnknown: true +// }), +// require("./controller/QIDO-RS/queryAllSeries") +// ); + +/** + * @openapi + * /dicom-web/instances: + * get: + * tags: + * - QIDO-RS + * description: Query all instances in server + * parameters: + * - $ref: "#/components/parameters/StudyDate" + * - $ref: "#/components/parameters/StudyTime" + * - $ref: "#/components/parameters/AccessionNumber" + * - $ref: "#/components/parameters/ModalitiesInStudy" + * - $ref: "#/components/parameters/ReferringPhysicianName" + * - $ref: "#/components/parameters/PatientName" + * - $ref: "#/components/parameters/PatientID" + * - $ref: "#/components/parameters/StudyID" + * - $ref: "#/components/parameters/Modality" + * - $ref: "#/components/parameters/SeriesNumber" + * - $ref: "#/components/parameters/SOPClassUID" + * - $ref: "#/components/parameters/InstanceNumber" + * responses: + * 200: + * description: Query successfully + * content: + * "application/dicom+json": + * schema: + * type: array + * items: + * allOf: + * - $ref: "#/components/schemas/StudyRequiredMatchingAttributes" + * - $ref: "#/components/schemas/SeriesRequiredMatchingAttributes" + * - $ref: "#/components/schemas/InstanceRequiredMatchingAttributes" + */ +// router.get( +// "/instances", validateParams(queryValidation, "query", { +// allowUnknown: true +// }), +// require("./controller/QIDO-RS/queryAllInstances") +// ); + +/** + * @openapi + * /dicom-web/patients: + * get: + * tags: + * - QIDO-RS + * description: Query all patients in server + * parameters: + * - $ref: "#/components/parameters/PatientName" + * - $ref: "#/components/parameters/PatientID" + * - $ref: "#/components/parameters/PatientBirthDate" + * - $ref: "#/components/parameters/PatientBirthTime" + * responses: + * 200: + * description: Query successfully + * content: + * "application/dicom+json": + * schema: + * type: array + * items: + * allOf: + * - $ref: "#/components/schemas/PatientRequiredMatchingAttributes" + */ +// router.get( +// "/patients", +// require("./controller/QIDO-RS/allPatient") +// ); + +//#endregion + +module.exports = router; \ No newline at end of file diff --git a/models/sql/init.js b/models/sql/init.js index 850bdc39..0e707251 100644 --- a/models/sql/init.js +++ b/models/sql/init.js @@ -28,7 +28,7 @@ async function init() { }); //TODO: 設計完畢後要將 force 刪除 - await sequelizeInstance.sync({force: true}); + await sequelizeInstance.sync(); } catch (e) { console.error('Unable to connect to the database:', e); process.exit(1); diff --git a/routes.js b/routes.js index 70fa56ea..7328bc31 100644 --- a/routes.js +++ b/routes.js @@ -20,7 +20,7 @@ module.exports = function (app) { loadAllPlugin(); app.use("/dicom-web", require("./api-sql/dicom-web/stow-rs.route")); - app.use("/dicom-web", require("./api/dicom-web/qido-rs.route")); + app.use("/dicom-web", require("./api-sql/dicom-web/qido-rs.route")); app.use("/dicom-web", require("./api/dicom-web/wado-rs-instance.route")); app.use("/dicom-web", require("./api/dicom-web/wado-rs-metadata.route")); app.use("/dicom-web", require("./api/dicom-web/wado-rs-rendered.route"));