Skip to content

Commit

Permalink
Read/write bundles to/from Azure Blob Storage (#2425)
Browse files Browse the repository at this point in the history
* wip. Add ability to read/write FHIR bundles to/from Azure blob storage

* move client inits inside function to see if it will remove errors

* add tests

* add note to make const global after Azure access

* place source consts in utils and import instead
  • Loading branch information
angelathe authored Aug 27, 2024
1 parent a4c93e6 commit 2e6d480
Show file tree
Hide file tree
Showing 11 changed files with 504 additions and 36 deletions.
2 changes: 2 additions & 0 deletions containers/ecr-viewer/.env
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
DATABASE_URL=postgres://postgres:pw@localhost:5432/ecr_viewer_db
AWS_REGION=us-east-1
ECR_BUCKET_NAME=ecr-viewer-files
AZURE_STORAGE_CONNECTION_STRING=idk
AZURE_CONTAINER_NAME=idk
SOURCE=postgres
NEXT_TELEMETRY_DISABLED=1
NEXT_PUBLIC_NON_INTEGRATED_VIEWER=true
2 changes: 1 addition & 1 deletion containers/ecr-viewer/environment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ namespace NodeJS {
NEXT_RUNTIME: string;
NEXT_PUBLIC_NON_INTEGRATED_VIEWER: "true" | "false";
NEXTAUTH_SECRET: string;
SOURCE: "s3" | "postgres";
SOURCE: "s3" | "azure" | "postgres";
}
}
1 change: 1 addition & 0 deletions containers/ecr-viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.621.0",
"@azure/storage-blob": "^12.24.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.48.0",
"@opentelemetry/exporter-jaeger": "^1.25.1",
Expand Down
46 changes: 46 additions & 0 deletions containers/ecr-viewer/src/app/api/fhir-data/fhir-data-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import pgPromise from "pg-promise";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import {
BlobClient,
BlobDownloadResponseParsed,
BlobServiceClient,
} from "@azure/storage-blob";
import { loadYamlConfig, streamToJson } from "../utils";
import { database } from "@/app/api/fhir-data/db";

Expand Down Expand Up @@ -70,3 +75,44 @@ export const get_s3 = async (request: NextRequest) => {
return NextResponse.json({ message: error.message }, { status: 500 });
}
};

/**
* Retrieves FHIR data from Azure Blob Storage based on eCR ID.
* @param request - The NextRequest object containing the request information.
* @returns A promise resolving to a NextResponse object.
*/
export const get_azure = async (request: NextRequest) => {
// TODO: Make this global after we get Azure access
const blobClient = BlobServiceClient.fromConnectionString(
process.env.AZURE_STORAGE_CONNECTION_STRING!,
);

const params = request.nextUrl.searchParams;
const ecr_id = params.get("id");

if (!process.env.AZURE_CONTAINER_NAME)
throw Error("Azure container name not found");

const containerName = process.env.AZURE_CONTAINER_NAME;
const blobName = `${ecr_id}.json`;

try {
const containerClient = blobClient.getContainerClient(containerName);
const blockBlobClient: BlobClient = containerClient.getBlobClient(blobName);

const downloadResponse: BlobDownloadResponseParsed =
await blockBlobClient.download();
const content = await streamToJson(downloadResponse.readableStreamBody);

return NextResponse.json(
{ fhirBundle: content, fhirPathMappings: loadYamlConfig() },
{ status: 200 },
);
} catch (error: any) {
console.error(
"Failed to download the FHIR data from Azure Blob Storage:",
error,
);
return NextResponse.json({ message: error.message }, { status: 500 });
}
};
8 changes: 4 additions & 4 deletions containers/ecr-viewer/src/app/api/fhir-data/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { get_s3, get_postgres } from "./fhir-data-service";

const S3_SOURCE = "s3";
const POSTGRES_SOURCE = "postgres";
import { get_s3, get_azure, get_postgres } from "./fhir-data-service";
import { S3_SOURCE, AZURE_SOURCE, POSTGRES_SOURCE } from "@/app/api/utils";

/**
* Handles GET requests by fetching data from different sources based on the environment configuration.
Expand All @@ -17,6 +15,8 @@ const POSTGRES_SOURCE = "postgres";
export async function GET(request: NextRequest) {
if (process.env.SOURCE === S3_SOURCE) {
return get_s3(request);
} else if (process.env.SOURCE === AZURE_SOURCE) {
return await get_azure(request);
} else if (process.env.SOURCE === POSTGRES_SOURCE) {
return await get_postgres(request);
} else {
Expand Down
12 changes: 8 additions & 4 deletions containers/ecr-viewer/src/app/api/save-fhir-data/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { saveToS3, saveToPostgres } from "./save-fhir-data-service";

const S3_SOURCE = "s3";
const POSTGRES_SOURCE = "postgres";
import {
saveToS3,
saveToAzure,
saveToPostgres,
} from "./save-fhir-data-service";
import { S3_SOURCE, AZURE_SOURCE, POSTGRES_SOURCE } from "@/app/api/utils";

/**
* Handles POST requests and saves the FHIR Bundle to the database.
Expand Down Expand Up @@ -50,6 +52,8 @@ export async function POST(request: NextRequest) {

if (saveSource === S3_SOURCE) {
return saveToS3(fhirBundle, ecrId);
} else if (saveSource === AZURE_SOURCE) {
return saveToAzure(fhirBundle, ecrId);
} else if (saveSource === POSTGRES_SOURCE) {
return await saveToPostgres(fhirBundle, ecrId);
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BlobServiceClient } from "@azure/storage-blob";
import { NextResponse } from "next/server";
import pgPromise from "pg-promise";
import {
Expand Down Expand Up @@ -85,3 +86,53 @@ export const saveToS3 = async (fhirBundle: Bundle, ecrId: string) => {
);
}
};

/**
* Saves a FHIR bundle to Azure Blob Storage.
* @async
* @function saveToAzure
* @param fhirBundle - The FHIR bundle to be saved.
* @param ecrId - The unique ID for the eCR associated with the FHIR bundle.
* @returns A promise that resolves when the FHIR bundle is successfully saved to Azure Blob Storage.
* @throws {Error} Throws an error if the FHIR bundle cannot be saved to Azure Blob Storage.
*/
export const saveToAzure = async (fhirBundle: Bundle, ecrId: string) => {
// TODO: Make this global after we get Azure access
const blobClient = BlobServiceClient.fromConnectionString(
process.env.AZURE_STORAGE_CONNECTION_STRING!,
);

if (!process.env.AZURE_CONTAINER_NAME)
throw Error("Azure container name not found");

const containerName = process.env.AZURE_CONTAINER_NAME;
const blobName = `${ecrId}.json`;
const body = JSON.stringify(fhirBundle);

try {
const containerClient = blobClient.getContainerClient(containerName);
const blockBlobClient = containerClient.getBlockBlobClient(blobName);

const response = await blockBlobClient.upload(body, body.length, {
blobHTTPHeaders: { blobContentType: "application/json" },
});

if (response._response.status !== 201) {
throw new Error(`HTTP Status Code: ${response._response.status}`);
}

return NextResponse.json(
{ message: "Success. Saved FHIR bundle to Azure Blob Storage: " + ecrId },
{ status: 200 },
);
} catch (error: any) {
return NextResponse.json(
{
message:
"Failed to insert FHIR bundle to Azure Blob Storage. " +
error.message,
},
{ status: 400 },
);
}
};
4 changes: 4 additions & 0 deletions containers/ecr-viewer/src/app/api/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import * as path from "path";
import yaml from "js-yaml";
import { PathMappings } from "@/app/view-data/utils/utils";

export const S3_SOURCE = "s3";
export const POSTGRES_SOURCE = "postgres";
export const AZURE_SOURCE = "azure";

/**
* Loads the YAML configuration for path mappings from a predefined file location.
* @returns An object representing the path mappings defined in the YAML configuration file.
Expand Down
68 changes: 51 additions & 17 deletions containers/ecr-viewer/src/app/tests/fhir-data.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,56 @@ import { mockClient } from "aws-sdk-client-mock";
import { GET } from "../api/fhir-data/route"; // Adjust the import path to your actual file path
import { sdkStreamMixin } from "@smithy/util-stream";
import { NextRequest } from "next/server";
import { Readable } from "stream";

const s3Mock = mockClient(S3Client);
const stream = sdkStreamMixin(
fs.createReadStream("src/app/tests/assets/BundleTravelHistory.json"),
);

beforeEach(() => {
s3Mock.reset();
});

const mockYamlConfig = {}; // Adjust this to match what loadYamlConfig() would return
const mockData = {
resourceType: "Bundle",
type: "batch",
entry: [
{
fullUrl: "urn:uuid:1dd10047-2207-4eac-a993-0f706c88be5d",
resource: {
resourceType: "Composition",
id: "1dd10047-2207-4eac-a993-0f706c88be5d",
},
},
],
};

jest.mock("../view-data/utils/utils", () => ({
loadYamlConfig: jest.fn().mockReturnValue(mockYamlConfig),
streamToJson: jest.fn().mockResolvedValue({
resourceType: "Bundle",
type: "batch",
entry: [
{
fullUrl: "urn:uuid:1dd10047-2207-4eac-a993-0f706c88be5d",
resource: {
resourceType: "Composition",
id: "1dd10047-2207-4eac-a993-0f706c88be5d",
},
},
],
}),
streamToJson: jest.fn().mockResolvedValue(mockData),
}));
jest.mock("@azure/storage-blob", () => ({
BlobServiceClient: {
fromConnectionString: jest.fn(() => mockBlobServiceClient),
},
}));

const mockStream = new Readable({
read() {
this.push(JSON.stringify(mockData));
this.push(null);
},
});
const mockBlobClient = {
download: jest.fn().mockResolvedValue({
readableStreamBody: mockStream,
}),
};
const mockContainerClient = {
getBlobClient: jest.fn(() => mockBlobClient),
};
const mockBlobServiceClient = {
getContainerClient: jest.fn(() => mockContainerClient),
};

describe("GET API Route", () => {
it("fetches data from S3 and returns a JSON response", async () => {
const fakeId = "test-id";
Expand All @@ -55,4 +77,16 @@ describe("GET API Route", () => {
const jsonResponse = await response.json();
expect(jsonResponse.fhirBundle).toBeDefined();
});

it("fetches data from Azure Blob Storage and returns a JSON response", async () => {
const fakeId = "test-id";
process.env.SOURCE = "azure";
const request = new NextRequest(`http://localhost?id=${fakeId}`);

const response = await GET(request);
expect(response.status).toBe(200);
const jsonResponse = await response.json();
expect(jsonResponse.fhirBundle).toBeDefined();
expect(jsonResponse.fhirBundle).toEqual(mockData);
});
});
Loading

0 comments on commit 2e6d480

Please sign in to comment.