diff --git a/src/app.ts b/src/app.ts index be4d706..068a29c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,9 +8,11 @@ import Backend from 'i18next-fs-backend'; import i18nextMiddleware from 'i18next-http-middleware'; import multer from 'multer'; +// eslint-disable-next-line import/no-cycle import { API } from './controllers/api'; import { healthcheck } from './route/healthcheck'; import { FileList } from './dtos/filelist'; +import { ViewErrDTO } from './dtos/view-dto'; i18next .use(Backend) @@ -30,13 +32,18 @@ i18next debug: false }); +export const i18n = i18next; +export const t = i18next.t; +export const ENGLISH = 'en-GB'; +export const WELSH = 'cy-GB'; + export const logger = pino({ name: 'StatsWales-Alpha-App', level: 'debug' }); const app: Application = express(); -const APIInstance = new API(logger); +const APIInstance = new API(); const storage = multer.memoryStorage(); const upload = multer({ storage }); @@ -74,18 +81,28 @@ app.get('/:lang/publish/name', (req: Request, res: Response) => { app.post('/:lang/publish/name', upload.none(), (req: Request, res: Response) => { if (!req.body?.internal_name) { logger.debug('Internal name was missing on request'); - res.status(400); - res.render('publish/name', { + const err: ViewErrDTO = { success: false, - headers: undefined, - data: undefined, + status: 400, + dataset_id: undefined, errors: [ { field: 'internal_name', - message: 'No dataset name provided' + message: [ + { + lang: req.i18n.language, + message: t('errors.name_missing') + } + ], + tag: { + name: 'errors.name_missing', + params: {} + } } ] - }); + }; + res.status(400); + res.render('publish/name', err); return; } const internalName: string = req.body.internal_name; @@ -96,37 +113,56 @@ app.post('/:lang/publish/upload', upload.single('csv'), async (req: Request, res const lang = req.params.lang; if (!req.body?.internal_name) { logger.debug('Internal name was missing on request'); - res.status(400); - res.render('publish/name', { + const err: ViewErrDTO = { success: false, - headers: undefined, - data: undefined, + status: 400, + dataset_id: undefined, errors: [ { field: 'internal_name', - message: 'No dataset name provided' + message: [ + { + lang: req.i18n.language, + message: t('errors.name_missing') + } + ], + tag: { + name: 'errors.name_missing', + params: {} + } } ] - }); + }; + res.status(400); + res.render('publish/name', err); return; } logger.debug(`Internal name: ${req.body.internal_name}`); const internalName: string = req.body.internal_name; if (!req.file) { logger.debug('Attached file was missing on this request'); - res.status(400); - res.render('publish/upload', { + const err: ViewErrDTO = { success: false, - headers: undefined, - data: undefined, - internal_name: internalName, + status: 400, + dataset_id: undefined, errors: [ { field: 'csv', - message: 'No CSV data available' + message: [ + { + lang: req.i18n.language, + message: t('errors.upload.no-csv-data') + } + ], + tag: { + name: 'errors.upload.no-csv-data', + params: {} + } } ] - }); + }; + res.status(400); + res.render('publish/upload', err); return; } @@ -147,29 +183,42 @@ app.get('/:lang/list', async (req: Request, res: Response) => { res.render('list', fileList); }); -app.get('/:lang/data', async (req: Request, res: Response) => { +app.get('/:lang/data/:file', async (req: Request, res: Response) => { const lang = req.params.lang; const page_number: number = Number.parseInt(req.query.page_number as string, 10) || 1; const page_size: number = Number.parseInt(req.query.page_size as string, 10) || 100; - if (!req.query.file) { - res.status(400); - res.render('data', { + if (!req.params.file) { + const err: ViewErrDTO = { success: false, - headers: undefined, - data: undefined, + status: 404, + dataset_id: undefined, errors: [ { field: 'file', - message: 'No filename provided' + message: [ + { + lang: req.i18n.language, + message: t('errors.dataset_missing') + } + ], + tag: { + name: 'errors.dataset_missing', + params: {} + } } ] - }); + }; + res.status(404); + res.render('data', err); return; } - const file_id = req.query.file.toString(); + const file_id = req.params.file; const file = await APIInstance.getFileData(lang, file_id, page_number, page_size); + if (!file.success) { + res.status((file as ViewErrDTO).status); + } res.render('data', file); }); diff --git a/src/controllers/api.ts b/src/controllers/api.ts index ee60625..7186462 100644 --- a/src/controllers/api.ts +++ b/src/controllers/api.ts @@ -1,18 +1,32 @@ import { env } from 'process'; -import { Logger } from 'pino'; - -import { FileList } from '../dtos/filelist'; -import { ProcessedCSV } from '../dtos/processedcsv-dto'; +// eslint-disable-next-line import/no-cycle +import { logger } from '../app'; +import { FileListError, FileList } from '../dtos/filelist'; +import { ViewDTO, ViewErrDTO } from '../dtos/view-dto'; import { Healthcheck } from '../dtos/healthcehck'; +import { UploadDTO, UploadErrDTO } from '../dtos/upload-dto'; + +class HttpError extends Error { + public status: number; + + constructor(status: number) { + super(''); + this.status = status; + } + + async handleMessage(message: Promise) { + const msg = await message; + this.message = msg; + } +} export class API { private readonly backend_server: string; private readonly backend_port: string; private readonly backend_protocol: string; - private readonly logger: Logger; - constructor(logger: Logger) { + constructor() { this.backend_server = env.BACKEND_SERVER || 'localhost'; this.backend_port = env.BACKEND_PORT || '3001'; if (env.BACKEND_PROTOCOL === 'https') { @@ -20,16 +34,26 @@ export class API { } else { this.backend_protocol = 'http'; } - this.logger = logger; } public async getFileList(lang: string) { const filelist: FileList = await fetch( `${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset` ) - .then((api_res) => api_res.json()) + .then((response) => { + if (response.ok) { + return response.json(); + } + const err = new HttpError(response.status); + err.handleMessage(response.text()); + throw err; + }) .then((api_res) => { return api_res as FileList; + }) + .catch((error) => { + logger.error(`An HTTP error occured with status ${error.status} and message "${error.message}"`); + return { status: error.status, files: [], error: error.message } as FileListError; }); return filelist; } @@ -38,9 +62,39 @@ export class API { const file = await fetch( `${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset/${file_id}/view?page_number=${page_number}&page_size=${page_size}` ) - .then((api_res) => api_res.json()) + .then((response) => { + if (response.ok) { + return response.json(); + } + const err = new HttpError(response.status); + err.handleMessage(response.text()); + throw err; + }) .then((api_res) => { - return api_res as ProcessedCSV; + return api_res as ViewDTO; + }) + .catch((error) => { + logger.error(`An HTTP error occured with status ${error.status} and message "${error.message}"`); + return { + success: false, + status: error.status, + errors: [ + { + field: 'file', + message: [ + { + lang, + message: 'errors.dataset_missing' + } + ], + tag: { + name: 'errors.dataset_missing', + params: {} + } + } + ], + dataset_id: file_id + } as ViewErrDTO; }); return file; } @@ -50,16 +104,46 @@ export class API { formData.append('csv', file, filename); formData.append('internal_name', filename); - const processedCSV: ProcessedCSV = await fetch( + const processedCSV = await fetch( `${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset/`, { method: 'POST', body: formData } ) - .then((api_res) => api_res.json()) + .then((response) => { + if (response.ok) { + return response.json(); + } + const err = new HttpError(response.status); + err.handleMessage(response.text()); + throw err; + }) .then((api_res) => { - return api_res as ProcessedCSV; + return api_res as UploadDTO; + }) + .catch((error) => { + logger.error(`An HTTP error occured with status ${error.status} and message "${error.message}"`); + return { + success: false, + status: error.status, + errors: [ + { + field: 'csv', + message: [ + { + lang, + message: 'errors.upload.no-csv-data' + } + ], + tag: { + name: 'errors.upload.no-csv-data', + params: {} + } + } + ], + dataset: undefined + } as UploadErrDTO; }); return processedCSV; } diff --git a/src/dtos/error.ts b/src/dtos/error.ts index d6f0a0c..966457c 100644 --- a/src/dtos/error.ts +++ b/src/dtos/error.ts @@ -1,4 +1,13 @@ +export interface ErrorMessage { + lang: string; + message: string; +} + export interface Error { field: string; - message: string; + message: ErrorMessage[]; + tag: { + name: string; + params: object; + }; } diff --git a/src/dtos/filelist.ts b/src/dtos/filelist.ts index 522c4ba..9de26bd 100644 --- a/src/dtos/filelist.ts +++ b/src/dtos/filelist.ts @@ -6,3 +6,9 @@ export interface FileDescription { export interface FileList { files: FileDescription[]; } + +export interface FileListError { + status: number; + files: FileDescription[]; + error: string; +} diff --git a/src/dtos/processedcsv-dto.ts b/src/dtos/processedcsv-dto.ts deleted file mode 100644 index 6000f2b..0000000 --- a/src/dtos/processedcsv-dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { DatasetDTO } from './dataset-dto'; -import { Error } from './error'; - -export interface PageInfo { - total_records: number | undefined; - start_record: number | undefined; - end_record: number | undefined; -} - -export interface ProcessedCSV { - success: boolean; - dataset: DatasetDTO | undefined; - current_page: number | undefined; - page_info: PageInfo | undefined; - pages: Array | undefined; - page_size: number | undefined; - total_pages: number | undefined; - headers: Array | undefined; - data: Array> | undefined; - errors: Array | undefined; -} diff --git a/src/dtos/upload-dto.ts b/src/dtos/upload-dto.ts index 0ef4e4c..adaf09c 100644 --- a/src/dtos/upload-dto.ts +++ b/src/dtos/upload-dto.ts @@ -8,6 +8,7 @@ export interface UploadDTO { export interface UploadErrDTO { success: boolean; - dataset: DatasetDTO; + status: number; + dataset: DatasetDTO | undefined; errors: Error[]; } diff --git a/src/dtos/view-dto.ts b/src/dtos/view-dto.ts index 24ef285..d9e6a21 100644 --- a/src/dtos/view-dto.ts +++ b/src/dtos/view-dto.ts @@ -9,8 +9,9 @@ export interface PageInfo { export interface ViewErrDTO { success: boolean; + status: number; errors: Error[]; - dataset_id: string; + dataset_id: string | undefined; } export interface ViewDTO { diff --git a/src/resources/locales/en-GB.json b/src/resources/locales/en-GB.json index 51cb6b3..f38c99e 100644 --- a/src/resources/locales/en-GB.json +++ b/src/resources/locales/en-GB.json @@ -55,9 +55,13 @@ }, "errors": { "missing": "Filename Missing", - "description_missing": "Dataset Description Missing", + "problem": "There is a problem", "name_missing": "Dataset Name Missing", - "problem": "There is a problem" + "dataset_missing": "No dataset found with this ID", + "upload": { + "no-csv": "No CSV data provided", + "no-csv-data": "No CSV data available" + } }, "list": { "title": "Display Data" diff --git a/src/route/healthcheck.ts b/src/route/healthcheck.ts index 5dd8aad..8309fb7 100644 --- a/src/route/healthcheck.ts +++ b/src/route/healthcheck.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import pino from 'pino'; +// eslint-disable-next-line import/no-cycle import { API } from '../controllers/api'; export const logger = pino({ @@ -8,7 +9,7 @@ export const logger = pino({ level: 'debug' }); -const APIInstance = new API(logger); +const APIInstance = new API(); export const healthcheck = Router(); diff --git a/src/views/data.ejs b/src/views/data.ejs index 5bbd157..ef1f0d4 100644 --- a/src/views/data.ejs +++ b/src/views/data.ejs @@ -15,7 +15,7 @@ <% errors.forEach(function(error) { %>
  • - <%= error.message %> + <%= t(error.tag.name) %>
  • <% }); %> diff --git a/src/views/publish/name.ejs b/src/views/publish/name.ejs index 6e6df0e..3ff8cc0 100644 --- a/src/views/publish/name.ejs +++ b/src/views/publish/name.ejs @@ -9,15 +9,13 @@

    - There is a problem + <%= t('errors.problem') %>

    diff --git a/src/views/publish/upload.ejs b/src/views/publish/upload.ejs index e8fb0b5..0c76873 100644 --- a/src/views/publish/upload.ejs +++ b/src/views/publish/upload.ejs @@ -8,13 +8,13 @@

    - There is a problem + <%= t('errors.problem') %>

    diff --git a/test/app.test.ts b/test/app.test.ts index 0b30e74..1cad3b9 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -4,7 +4,7 @@ import request from 'supertest'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; -import app from '../src/app'; +import app, { ENGLISH, WELSH, t } from '../src/app'; const server = setupServer( http.get('http://somehost.com:3001/en-GB/dataset', () => { @@ -12,6 +12,12 @@ const server = setupServer( filelist: [{ internal_name: 'test-data-1.csv', id: 'bdc40218-af89-424b-b86e-d21710bc92f1' }] }); }), + http.get('http://somehost.com:3001/en-GB/dataset/missing-id/view', () => { + return new HttpResponse(null, { + status: 404, + statusText: '{}' + }); + }), http.get('http://somehost.com:3001/en-GB/dataset/fa07be9d-3495-432d-8c1f-d0fc6daae359/view', () => { return HttpResponse.json({ success: true, @@ -56,7 +62,11 @@ const server = setupServer( errors: [ { field: 'csv', - message: 'There was a problem with the upload' + message: [ + { lang: ENGLISH, message: t('errors.upload.no-csv-data', { lng: ENGLISH }) }, + { lang: WELSH, message: t('errors.upload.no-csv-data', { lng: WELSH }) } + ], + tag: { name: 'errors.upload.no-csv-data', params: {} } } ] }); @@ -142,7 +152,7 @@ describe('Test app.ts', () => { test('Publish upload page returns 400 if no internal name provided', async () => { const res = await request(app).post('/en-GB/publish/name').set('User-Agent', 'supertest'); expect(res.status).toBe(400); - expect(res.text).toContain('No dataset name provided'); + expect(res.text).toContain(t('errors.name_missing')); }); test('Set name returns 200 with internal name', async () => { @@ -174,7 +184,7 @@ describe('Test app.ts', () => { .set('User-Agent', 'supertest') .attach('csv', csvfile); expect(res.status).toBe(400); - expect(res.text).toContain('No dataset name provided'); + expect(res.text).toContain(t('errors.name_missing')); }); test('Upload returns 400 and an error if no file attached', async () => { @@ -195,7 +205,7 @@ describe('Test app.ts', () => { .attach('csv', csvfile) .field('internal_name', 'test-data-3.csv fail test'); expect(res.status).toBe(400); - expect(res.text).toContain('There was a problem with the upload'); + expect(res.text).toContain(t('errors.upload.no-csv-data')); }); test('Check inital healthcheck endpoint works', async () => { @@ -218,7 +228,7 @@ describe('Test app.ts', () => { test('Data is rendered in the frontend', async () => { const res = await request(app) - .get('/en-GB/data?file=fa07be9d-3495-432d-8c1f-d0fc6daae359') + .get('/en-GB/data/fa07be9d-3495-432d-8c1f-d0fc6daae359') .set('User-Agent', 'supertest'); expect(res.status).toBe(200); // Header @@ -255,9 +265,9 @@ describe('Test app.ts', () => { }); test('Data display returns 404 if no file available', async () => { - const res = await request(app).get('/en-GB/data').set('User-Agent', 'supertest'); - expect(res.status).toBe(400); - expect(res.text).toContain('There is a problem'); - expect(res.text).toContain('No filename provided'); + const res = await request(app).get('/en-GB/data/missing-id').set('User-Agent', 'supertest'); + expect(res.status).toBe(404); + expect(res.text).toContain(t('errors.problem')); + expect(res.text).toContain(t('errors.dataset_missing')); }); });