Skip to content

Commit 735f76f

Browse files
committed
Merge branch 'main' into CU-86c11rv16_automated-tests
2 parents 30ddd51 + e3e5994 commit 735f76f

File tree

6 files changed

+230
-23
lines changed

6 files changed

+230
-23
lines changed

.env.dev

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ MINIO_ACCESS_KEY=tCroZpZ3usDUcvPM3QT6
2222
MINIO_SECRET_KEY=suVjMHUpVIGyWx8fFJHTiZiT88dHhKgVpzvYTOKK
2323
MINIO_PREFIX=
2424
MINIO_SUFFIX=
25-
25+
MINIO_BUCKET_REGION=us-east-1
2626
# Clickhouse Configuration
2727
CLICKHOUSE_URL=http://localhost:8123
2828
CLICKHOUSE_USER=

src/config/config.ts

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const getConfig = () => {
2929
port: process.env.MINIO_PORT ? parseInt(process.env.MINIO_PORT) : 9000,
3030
useSSL: process.env.MINIO_USE_SSL === 'true' ? true : false,
3131
buckets: process.env.MINIO_BUCKETS || 'climate-mediator',
32+
bucket: process.env.MINIO_BUCKET || 'climate-mediator',
33+
bucketRegion: process.env.MINIO_BUCKET_REGION || 'us-east-1',
3234
accessKey: process.env.MINIO_ACCESS_KEY || 'tCroZpZ3usDUcvPM3QT6',
3335
secretKey: process.env.MINIO_SECRET_KEY || 'suVjMHUpVIGyWx8fFJHTiZiT88dHhKgVpzvYTOKK',
3436
prefix: process.env.MINIO_PREFIX || '',

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ app.listen(getConfig().port, () => {
2323
setupMediator(path.resolve(__dirname, './openhim/mediatorConfig.json'));
2424
}
2525
});
26+

src/routes/index.ts

+67-13
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import express from 'express';
22
import multer from 'multer';
33
import { getConfig } from '../config/config';
44
import { getCsvHeaders } from '../utils/file-validators';
5-
import { createTable } from '../utils/clickhouse';
65
import logger from '../logger';
7-
6+
import fs from 'fs';
7+
import path from 'path';
8+
import e from 'express';
9+
import { uploadToMinio } from '../utils/minio';
810
const routes = express.Router();
911

1012
const bodySizeLimit = getConfig().bodySizeLimit;
@@ -15,6 +17,35 @@ const jsonBodyParser = express.json({
1517

1618
const upload = multer({ storage: multer.memoryStorage() });
1719

20+
const saveCsvToTmp = (fileBuffer: Buffer, fileName: string): string => {
21+
const tmpDir = path.join(process.cwd(), 'tmp');
22+
23+
// Create tmp directory if it doesn't exist
24+
if (!fs.existsSync(tmpDir)) {
25+
fs.mkdirSync(tmpDir);
26+
}
27+
28+
const fileUrl = path.join(tmpDir, fileName);
29+
fs.writeFileSync(fileUrl, fileBuffer);
30+
logger.info(`fileUrl: ${fileUrl}`);
31+
32+
return fileUrl;
33+
};
34+
35+
const isValidFileType = (file: Express.Multer.File): boolean => {
36+
const validMimeTypes = ['text/csv', 'application/json'];
37+
return validMimeTypes.includes(file.mimetype);
38+
};
39+
40+
function validateJsonFile(buffer: Buffer): boolean {
41+
try {
42+
JSON.parse(buffer.toString());
43+
return true;
44+
} catch {
45+
return false;
46+
}
47+
}
48+
1849
routes.post('/upload', upload.single('file'), async (req, res) => {
1950
const file = req.file;
2051
const bucket = req.query.bucket;
@@ -29,21 +60,44 @@ routes.post('/upload', upload.single('file'), async (req, res) => {
2960
return res.status(400).send('No bucket provided');
3061
}
3162

32-
const headers = getCsvHeaders(file.buffer);
33-
34-
if (!headers) {
35-
return res.status(400).send('Invalid file type, please upload a valid CSV file');
63+
if (!isValidFileType(file)) {
64+
logger.error(`Invalid file type: ${file.mimetype}`);
65+
return res.status(400).send('Invalid file type. Please upload either a CSV or JSON file');
3666
}
3767

38-
const tableCreated = await createTable(headers, bucket as string);
68+
// For CSV files, validate headers
69+
if (file.mimetype === 'text/csv') {
70+
const headers = getCsvHeaders(file.buffer);
71+
if (!headers) {
72+
return res.status(400).send('Invalid CSV file format');
73+
}
74+
const fileUrl = saveCsvToTmp(file.buffer, file.originalname);
75+
try {
76+
const uploadResult = await uploadToMinio(fileUrl, file.originalname, bucket as string, file.mimetype);
77+
// Clean up the temporary file
78+
fs.unlinkSync(fileUrl);
3979

40-
if (!tableCreated) {
41-
return res
42-
.status(500)
43-
.send('Failed to create table, please check csv or use another name for the bucket');
44-
}
80+
if (uploadResult) {
81+
return res.status(201).send(`File ${file.originalname} uploaded in bucket ${bucket}`);
82+
} else {
83+
return res.status(400).send(`Object ${file.originalname} already exists in bucket ${bucket}`);
84+
}
85+
} catch (error) {
86+
// Clean up the temporary file in case of error
87+
fs.unlinkSync(fileUrl);
88+
logger.error('Error uploading file to Minio:', error);
89+
return res.status(500).send('Error uploading file');
90+
}
91+
} else if (file.mimetype === 'application/json') {
92+
if (!validateJsonFile(file.buffer)) {
93+
return res.status(400).send('Invalid JSON file format');
94+
}
4595

46-
return res.status(201).send('File uploaded successfully');
96+
return res.status(200).send('JSON file is valid - Future implementation');
97+
} else {
98+
return res.status(400).send('Invalid file type. Please upload either a CSV or JSON file');
99+
}
100+
47101
});
48102

49103
export default routes;

src/utils/clickhouse.ts

+41-4
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ export async function createTable(fields: string[], tableName: string) {
2727
}
2828

2929
try {
30-
console.debug(`Creating table ${normalizedTableName} with fields ${fields.join(', ')}`);
30+
logger.debug(`Creating table ${normalizedTableName} with fields ${fields.join(', ')}`);
3131
const result = await client.query({
3232
query: generateDDL(fields, normalizedTableName),
3333
});
34-
console.log('Table created successfully');
34+
logger.info(`Table ${normalizedTableName} created successfully`);
3535
} catch (error) {
36-
console.log('Error checking/creating table');
37-
console.error(error);
36+
logger.error(`Error checking/creating table ${normalizedTableName}`);
37+
logger.debug(JSON.stringify(error));
3838
return false;
3939
}
4040

@@ -68,3 +68,40 @@ export function flattenJson(json: any, prefix = ''): string[] {
6868
const fieldsSet = new Set(fields);
6969
return Array.from(fieldsSet);
7070
}
71+
72+
export async function insertFromS3(tableName: string, s3Path: string, s3Config: {
73+
accessKey: string,
74+
secretKey: string
75+
}) {
76+
logger.info(`Inside the insertFromS3 function`);
77+
const client = createClient({
78+
url,
79+
password,
80+
});
81+
logger.info(`s3Path: ${s3Path}`);
82+
const normalizedTableName = tableName.replace(/-/g, '_');
83+
84+
try {
85+
logger.debug(`Inserting data into ${normalizedTableName} from ${s3Path}`);
86+
const query = `
87+
INSERT INTO \`default\`.${normalizedTableName}
88+
SELECT * FROM s3(
89+
'${s3Path}',
90+
'${s3Config.accessKey}',
91+
'${s3Config.secretKey}',
92+
'CSVWithNames'
93+
)
94+
`;
95+
logger.debug(`Query: ${query}`);
96+
await client.query({ query });
97+
logger.info(`Successfully inserted data into ${normalizedTableName}`);
98+
return true;
99+
} catch (error) {
100+
logger.error('Error inserting data from S3');
101+
logger.error(error);
102+
return false;
103+
} finally {
104+
await client.close();
105+
}
106+
}
107+

src/utils/minio.ts

+118-5
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,111 @@ import * as Minio from 'minio';
22
import { getConfig } from '../config/config';
33
import logger from '../logger';
44
import { readFile, rm } from 'fs/promises';
5-
import { createTable } from './clickhouse';
5+
import { createTable, insertFromS3 } from './clickhouse';
66
import { validateJsonFile, getCsvHeaders } from './file-validators';
77

8-
export async function createMinioBucketListeners() {
9-
const { buckets, endPoint, port, useSSL, accessKey, secretKey, prefix, suffix } =
10-
getConfig().minio;
8+
const { endPoint, port, useSSL, bucketRegion, accessKey, secretKey, prefix, suffix, buckets } =
9+
getConfig().minio;
10+
11+
/**
12+
* Uploads a file to Minio storage
13+
* @param {string} sourceFile - Path to the file to upload
14+
* @param {string} destinationObject - Name for the uploaded object
15+
* @param {Object} [customMetadata={}] - Optional custom metadata
16+
* @returns {Promise<void>}
17+
*/
18+
export async function uploadToMinio(
19+
sourceFile: string,
20+
destinationObject: string,
21+
bucket: string,
22+
fileType: string,
23+
customMetadata = {}
24+
) {
25+
const minioClient = new Minio.Client({
26+
endPoint,
27+
port,
28+
useSSL,
29+
accessKey,
30+
secretKey,
31+
});
32+
// Check if bucket exists, create if it doesn't
33+
const exists = await minioClient.bucketExists(bucket);
34+
if (!exists) {
35+
await minioClient.makeBucket(bucket, bucketRegion);
36+
logger.info(`Bucket ${bucket} created in "${bucketRegion}".`);
37+
}
38+
39+
try {
40+
const fileExists = await checkFileExists(destinationObject, bucket, fileType);
41+
if (fileExists) {
42+
return false;
43+
} else {
44+
const metaData = {
45+
'Content-Type': fileType,
46+
'X-Upload-Id': crypto.randomUUID(),
47+
...customMetadata,
48+
};
49+
50+
// Upload the file
51+
await minioClient.fPutObject(bucket, destinationObject, sourceFile, metaData);
52+
logger.info(
53+
`File ${sourceFile} uploaded as object ${destinationObject} in bucket ${bucket}`
54+
);
55+
return true;
56+
}
57+
} catch (error) {
58+
console.error('Error checking file:', error);
59+
}
60+
}
61+
62+
/**
63+
* Checks if a CSV file exists in the specified Minio bucket
64+
* @param {string} fileName - Name of the CSV file to check
65+
* @param {string} bucket - Bucket name
66+
* @returns {Promise<boolean>} - Returns true if file exists, false otherwise
67+
*/
68+
export async function checkFileExists(
69+
fileName: string,
70+
bucket: string,
71+
fileType: string
72+
): Promise<boolean> {
73+
const minioClient = new Minio.Client({
74+
endPoint,
75+
port,
76+
useSSL,
77+
accessKey,
78+
secretKey,
79+
});
80+
81+
try {
82+
// Check if bucket exists first
83+
const bucketExists = await minioClient.bucketExists(bucket);
84+
if (!bucketExists) {
85+
logger.info(`Bucket ${bucket} does not exist`);
86+
return false;
87+
}
1188

89+
// Get object stats to check if file exists
90+
const stats = await minioClient.statObject(bucket, fileName); // Optionally verify it's a CSV file by checking Content-Type
91+
if (stats.metaData && stats.metaData['content-type'] === fileType) {
92+
logger.info(`File ${fileName} exists in bucket ${bucket}`);
93+
return true;
94+
} else {
95+
logger.info(`File ${fileName} does not exist in bucket ${bucket}`);
96+
return false;
97+
}
98+
} catch (err: any) {
99+
if (err.code === 'NotFound') {
100+
logger.debug(`File ${fileName} not found in bucket ${bucket}`);
101+
return false;
102+
}
103+
// For any other error, log it and rethrow
104+
logger.error(`Error checking file existence: ${err.message}`);
105+
throw err;
106+
}
107+
}
108+
109+
export async function createMinioBucketListeners() {
12110
const minioClient = new Minio.Client({
13111
endPoint,
14112
port,
@@ -36,12 +134,15 @@ export async function createMinioBucketListeners() {
36134
logger.debug(`Listening for notifications on bucket ${bucket}`);
37135

38136
listener.on('notification', async (notification) => {
137+
39138
//@ts-ignore
40139
const file = notification.s3.object.key;
41-
140+
42141
//@ts-ignore
43142
const tableName = notification.s3.bucket.name;
44143

144+
logger.info(`File received: ${file} from bucket ${tableName}`);
145+
45146
//@ts-ignore
46147
minioClient.fGetObject(bucket, file, `tmp/${file}`, async (err) => {
47148
if (err) {
@@ -63,10 +164,22 @@ export async function createMinioBucketListeners() {
63164
const fields = (await readFile(`tmp/${file}`, 'utf8')).split('\n')[0].split(',');
64165

65166
await createTable(fields, tableName);
167+
168+
// If running locally and using docker compose, the minio host is 'minio'. This is to allow clickhouse to connect to the minio server
169+
const host = getConfig().runningMode === 'testing' ? 'minio' : endPoint;
170+
// Construct the S3-style URL for the file
171+
const minioUrl = `http://${host}:${port}/${bucket}/${file}`;
172+
173+
// Insert data into clickhouse
174+
await insertFromS3(tableName, minioUrl, {
175+
accessKey,
176+
secretKey,
177+
});
66178
} else {
67179
logger.warn(`Unknown file type - ${extension}`);
68180
}
69181
await rm(`tmp/${file}`);
182+
logger.debug(`File ${file} deleted from tmp directory`);
70183
}
71184
});
72185
});

0 commit comments

Comments
 (0)