Skip to content

Commit

Permalink
feat: bundle transaction prototype
Browse files Browse the repository at this point in the history
  • Loading branch information
Chinlinlee committed Sep 30, 2023
1 parent 392f5d6 commit 918b469
Show file tree
Hide file tree
Showing 6 changed files with 336 additions and 21 deletions.
26 changes: 26 additions & 0 deletions api/FHIRApiService/root.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const _ = require("lodash");
const { FhirWebServiceError, handleError } = require("@root/models/FHIR/httpMessage");
const { BundleOpService } = require("./services/bundle-operations.service");
const { logger } = require("@root/utils/log");


/**
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
module.exports = async function (req, res) {
try {
let bundleOpService = new BundleOpService(req, res);
let bundleResponse = await bundleOpService.doOp();
return res.status(200).send(bundleResponse);
} catch(e) {
if (e instanceof FhirWebServiceError) {
return res.status(e.code).send(e.operationOutcome);
} else if (_.get(e, "name", "") === "ValidationError") {
return res.status(400).send(handleError.processing(e));
}
logger.error(e);
return res.status(500).send(handleError.processing(new Error("Server Error Occurred")));
}
}
268 changes: 268 additions & 0 deletions api/FHIRApiService/services/bundle-operations.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
const _ = require("lodash");
const mongoose = require("mongoose");
const jsonPath = require("jsonpath");

const {
getDeleteMessage,
handleError,
FhirWebServiceError
} = require("@models/FHIR/httpMessage");
const { BaseFhirApiService } = require("./base.service");
const { logger } = require("@root/utils/log");
const { CreateService } = require("./create.service");
const { UpdateService } = require("./update.service");
const { DeleteService } = require("./delete.service");
const { getUrlMatch, getResourceTypeInUrl, getIdInFullUrl } = require("@root/utils/fhir-param");
const { urlJoin } = require("@root/utils/url");
const uuid = require("uuid");
const resourceList = require("@models/FHIR/fhir.resourceList.json");

class BundleOpService extends BaseFhirApiService {
constructor(req, res) {
super(req, res, "Bundle");
this.resourcesInEntry = this.getResourcesInEntry();
this.bundleEntry = _.get(this.request.body, "entry");
this.checkBaseBundle();
this.checkFullUrl();
this.bundleResponse = [];
try {
this.sortedEntry = this.getSortedEntry();
} catch (e) {
throw new FhirWebServiceError(400, e.message, handleError.processing);
}
}

checkBaseBundle() {
if (this.bundleEntry.length === 0) {
throw new FhirWebServiceError(400, "Empty Bundle", handleError.processing);
} else if (this.request.body.type !== "transaction" && this.request.body.type !== "batch") {
throw new FhirWebServiceError(400, "Unsupported Operation", handleError.processing);
}
}

checkFullUrl() {
for (let entry of this.bundleEntry) {
let fullUrl = _.get(entry, "fullUrl", "");
let fullUrlSplit = _.compact(fullUrl.split("/"));
if (!fullUrl.length === 2 ||
!resourceList.includes(fullUrlSplit[0])
) {

if (!/urn:oid:[0-2](\.[1-9]\d*)+/i.test(fullUrl) &&
!uuid.validate(fullUrl.replace(/^urn:uuid:/, ""))
) {
throw new FhirWebServiceError(400, `Invalid fullUrl ${fullUrl}, only support {resourceType}/{id} now`, handleError.processing);
}

}
}
}

async doOp() {
if (_.get(this.request.body, "type", "") === "transaction") {
logger.info(`[Info: do bundle transaction] resource: ${JSON.stringify(this.request.body)}`);
return await this.doTransaction();
} else if (_.get(this.request.body, "type", "") === "batch") {
logger.info(`[Info: do batch] resource: ${JSON.stringify(this.request.body)}`);
return await this.doBatch();
}
}

async doTransaction() {
let transactionResponse;
const session = await mongoose.startSession();
session.startTransaction();

for (let item of this.sortedEntry) {
let request = _.get(item, "request");
let method = _.get(request, "method");
let fullUrl = _.get(item, "fullUrl");
let resourceType = _.get(item, "resource.resourceType") || getResourceTypeInUrl(request.url);

if (method === "POST") {
let createHandler = new TransactionCreateHandler(resourceType, item.resource, fullUrl, this.sortedEntry, session);
let createResource = await createHandler.create();
let reqBaseUrl = `${this.request.protocol}://${this.request.get('host')}/`;
let fullAbsoluteUrl = urlJoin(`/${process.env.FHIRSERVER_APIPATH}/${resourceType}/${createResource.id}/_history/1`, reqBaseUrl);
this.bundleResponse.push({
status: "201 Created",
location: fullAbsoluteUrl,
lastModified: (new Date()).toUTCString(),
});
} else if (method === "PUT") {
let updateHandler = new TransactionUpdateHandler(resourceType, item.resource, fullUrl, this.sortedEntry, session);
let updateResult = await updateHandler.update();
let reqBaseUrl = `${this.request.protocol}://${this.request.get('host')}/`;
let fullAbsoluteUrl = urlJoin(`/${process.env.FHIRSERVER_APIPATH}/${resourceType}/${getIdInFullUrl(fullUrl)}/_history/${updateResult.result.meta.versionId}`, reqBaseUrl);
this.bundleResponse.push({
status: updateResult.code.toString(),
location: fullAbsoluteUrl,
lastModified: (new Date()).toUTCString()
});
} else if (method === "DELETE") {
let deleteResult = await this.delete(resourceType, this.getIdInUrl(request.url));
if (_.isString(deleteResult.result) && deleteResult.result.includes("not found")) {
this.bundleResponse.push({
status: "404 NOT FOUND"
});
} else {
this.bundleResponse.push({
status: "200 DELETE"
});
}

} else {
await session.abortTransaction();
throw new FhirWebServiceError(400, "Unknown method, only support POST, PUT and DELETE", handleError.processing);
}
}

transactionResponse = new BundleTransactionResponse(this.sortedEntry, this.bundleEntry, this.bundleResponse).get();

try {
this.checkRefAfterOp();
} catch (e) {
await session.abortTransaction();
throw e;
}

await session.commitTransaction();
await session.endSession();

return transactionResponse;
}

checkRefAfterOp() {
let references = jsonPath.nodes(this.sortedEntry, "$.*.resource..reference");

for (let i = 0; i < references.length; i++) {
let reference = references[i];
if (/urn:oid:[0-2](\.[1-9]\d*)+/i.test(reference.value) ||
uuid.validate(reference.value.replace(/^urn:uuid:/, ""))) {
throw new FhirWebServiceError(400, `Unable to satisfy placeholder ID ${reference.value} found in path ${reference.path.slice(1).join(".")}`, handleError.processing);
}
}

}

async doBatch() {
// TODO: Implement batch
}

getResourcesInEntry() {
return jsonPath.query(this.request.body, "$.entry[*].resource");
}

getSortedEntry() {
let clonedEntry = _.cloneDeep(this.bundleEntry);
return clonedEntry.sort((a, b) => {
let secondFullUrl = _.get(b, "fullUrl");
let firstReferences = jsonPath.query(a, "$.resource..reference");
if (firstReferences.includes(secondFullUrl)) {
return 1;
}
return -1;
});
}

getIdInUrl(url) {
let urlMatch = getUrlMatch(url);
let id;
if (urlMatch) {
id = urlMatch[0];
} else {
id = url.split("/").pop();
}
return id;
}

async delete(resourceType, id) {
return await DeleteService.deleteResourceById(resourceType, id);
}
}

class BaseTransactionHandler {
constructor(resourceType, resource, fullUrl, entry, transaction) {
this.resourceType = resourceType;
this.resource = resource;
this.fullUrl = fullUrl;
this.entry = entry;
this.transaction = transaction;
}

async replaceIdInEntry(createdResource) {
let resourcesWithRef = jsonPath.nodes(this.entry, `$..*.reference`).filter(v => v.value === this.fullUrl);

for (let i = 0; i < resourcesWithRef.length; i++) {
let itemPath = resourcesWithRef[i].path.slice(1).join(".");
_.set(this.entry, itemPath, `${this.resourceType}/${createdResource.id}`);
}
}
}

class TransactionCreateHandler extends BaseTransactionHandler {
constructor(resourceType, resource, fullUrl, entry, transaction) {
super(resourceType, resource, fullUrl, entry, transaction);
}

async create() {
// Validate user request body
let validation = await BaseFhirApiService.validateRequestResource(this.resource);
if (!validation.status) return validation;

let { result } = await CreateService.insertResource(this.resourceType, this.resource, this.transaction);
this.replaceIdInEntry(result);

return result;
}
}

class TransactionUpdateHandler extends BaseTransactionHandler {
constructor(resourceType, resource, fullUrl, entry, transaction) {
super(resourceType, resource, fullUrl, entry, transaction);
}

async update() {
// Validate user request body
let validation = await BaseFhirApiService.validateRequestResource(this.resource);
if (!validation.status) return validation;

let { code, doc } = await UpdateService.insertOrUpdateResource(this.resourceType, this.resource, getIdInFullUrl(this.fullUrl), this.transaction);
this.replaceIdInEntry(doc);

return { code, result: doc };
}
}

class BundleTransactionResponse {
constructor(entry, sortedEntry, responses) {
this.entry = entry;
this.sortedEntry = sortedEntry;
this.responses = responses;
}

get() {
let entryMappingIndex = this.getEntryMappingIndex();
let bundle = {
resourceType: "Bundle",
type: "transaction-response",
entry: []
};

for (let i = 0; i < this.responses.length; i++) {
bundle.entry.push({
response: this.responses[entryMappingIndex[i]]
});
}

return new mongoose.model("Bundle")(bundle).getFHIRField();
}

getEntryMappingIndex() {
return this.sortedEntry.map((v, i) => {
return this.entry.findIndex(item => item.fullUrl === v.fullUrl);
});
}
}

module.exports.BundleOpService = BundleOpService;
4 changes: 2 additions & 2 deletions api/FHIRApiService/services/create.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ class CreateService extends BaseFhirApiService {
return this.doResourceChangeFailureResponse(err, code);
}

static async insertResource(resourceType, resource) {
static async insertResource(resourceType, resource, session=undefined) {
renameCollectionFieldName(resource);
resource.id = uuid.v4();
let insertDataObject = new mongoose.model(resourceType)(resource);
let doc = await insertDataObject.save();
let doc = await insertDataObject.save({session});
return {
status: true,
result: doc.getFHIRField()
Expand Down
15 changes: 8 additions & 7 deletions api/FHIRApiService/services/update.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,16 @@ class UpdateService extends BaseFhirApiService {
this.doResourceChangeFailureResponse(err, code);
}

static async insertOrUpdateResource(resourceType, resource, id) {
static async insertOrUpdateResource(resourceType, resource, id, session=undefined) {
let docExist = await UpdateService.isDocExist(resourceType, id);
if (docExist.status === 1) {
return await UpdateService.updateResource(resourceType, id, resource);
return await UpdateService.updateResource(resourceType, id, resource, session);
} else if (docExist.status === 2) {
return await UpdateService.insertResourceWithId(resourceType, id, resource);
return await UpdateService.insertResourceWithId(resourceType, id, resource, session);
}
}

static async updateResource(resourceType, id, resource) {
static async updateResource(resourceType, id, resource, session=undefined) {
delete resource.id;
renameCollectionFieldName(resource);
resource.id = id;
Expand All @@ -74,7 +74,8 @@ class UpdateService extends BaseFhirApiService {
},
{
new: true,
rawResult: true
rawResult: true,
session: session
}
);

Expand All @@ -85,11 +86,11 @@ class UpdateService extends BaseFhirApiService {
};
}

static async insertResourceWithId(resourceType, id, resource) {
static async insertResourceWithId(resourceType, id, resource, session=undefined) {
resource.id = id;
renameCollectionFieldName(resource);
let resourceInstance = new mongoose.model(resourceType)(resource);
let doc = await resourceInstance.save();
let doc = await resourceInstance.save({session});
return {
status: true,
code: 201,
Expand Down
26 changes: 14 additions & 12 deletions routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,18 @@ module.exports = function (app) {
_.get(req.headers, "accept")
? ""
: (() => {
_.get(req.headers, "content-type")
? _.set(
req.headers,
"accept",
_.get(req.headers, "content-type")
)
: _.set(
req.headers,
"accept",
"application/fhir+json"
);
})();
_.get(req.headers, "content-type")
? _.set(
req.headers,
"accept",
_.get(req.headers, "content-type")
)
: _.set(
req.headers,
"accept",
"application/fhir+json"
);
})();

let xmlAcceptList = [
"application/xml",
Expand All @@ -95,6 +95,8 @@ module.exports = function (app) {
);
}
}

app.post(`/${process.env.FHIRSERVER_APIPATH}`, require("./api/FHIRApiService/root"));
//#endregion

for (let pluginName in pluginsConfig) {
Expand Down
Loading

0 comments on commit 918b469

Please sign in to comment.