Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow multiple CHEFS forms to be configured/displayed #1

Merged
merged 2 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ appRouter.get('/config', (_req: Request, res: Response, next: (err: unknown) =>
...config.get('frontend'),
gitRev: state.gitRev,
idpList: readIdpList(),
version: process.env.npm_package_version
version: appVersion
});
} catch (err) {
next(err);
Expand Down
14 changes: 14 additions & 0 deletions app/config/custom-environment-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@
"server": {
"apiPath": "SERVER_APIPATH",
"bodyLimit": "SERVER_BODYLIMIT",
"chefs": {
"forms": {
"form1": {
"name": "FORM_1_NAME",
"formId": "FORM_1_ID",
"formApiKey": "FORM_1_APIKEY"
},
"form2": {
"name": "FORM_2_NAME",
"formId": "FORM_2_ID",
"formApiKey": "FORM_2_APIKEY"
Comment on lines +17 to +23
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style: Since we know the values are nested, might be able to call it just "id" and "apiKey" without needing the form prefix for the attribute names.

}
}
},
"oidc": {
"enabled": "SERVER_OIDC_ENABLED",
"clientId": "SERVER_OIDC_CLIENTID",
Expand Down
28 changes: 28 additions & 0 deletions app/src/components/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import config from 'config';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';

import { getLogger } from './log';
import { ChefsFormConfig, ChefsFormConfigData } from '../types/ChefsFormConfig';
const log = getLogger(module.filename);

/**
* @function getChefsApiKey
* Search for a CHEFS form Api Key
* @returns {string | undefined} The CHEFS form Api Key if it exists
*/
export function getChefsApiKey(formId: string): string | undefined {
const cfg = config.get('server.chefs.forms') as ChefsFormConfig;
return Object.values<ChefsFormConfigData>(cfg).find((o: ChefsFormConfigData) => o.id === formId)?.apiKey;
}

/**
* @function getGitRevision
* Gets the current git revision hash
Expand Down Expand Up @@ -74,3 +86,19 @@ export function readIdpList(): object[] {

return idpList;
}

/**
* @function redactSecrets
* Sanitizes objects by replacing sensitive data with a REDACTED string value
* @param {object} data An arbitrary object
* @param {string[]} fields An array of field strings to sanitize on
* @returns {object} An arbitrary object with specified secret fields marked as redacted
*/
export function redactSecrets(data: { [key: string]: unknown }, fields: Array<string>): unknown {
if (fields && Array.isArray(fields) && fields.length) {
fields.forEach((field) => {
if (data[field]) data[field] = 'REDACTED';
});
}
return data;
}
98 changes: 35 additions & 63 deletions app/src/controllers/chefs.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,56 @@
import config from 'config';

import { chefsService } from '../services';
import { isTruthy } from '../components/utils';
import { IdentityProvider } from '../components/constants';

import type { NextFunction, Request, Response } from 'express';
import type { JwtPayload } from 'jsonwebtoken';
import type { ChefsFormConfig, ChefsFormConfigData } from '../types/ChefsFormConfig';
import type { ChefsSubmissionDataSource } from '../types/ChefsSubmissionDataSource';

const controller = {
exportSubmissions: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.exportSubmissions(req.params.formId);
res.status(200).send(response);
} catch (e: unknown) {
next(e);
}
},

getFormSubmissions: async (req: Request, res: Response, next: NextFunction) => {
getSubmissions: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getFormSubmissions(req.params.formId);

// IDIR users should be able to see all submissions
const filterToUser = (req.currentUser?.tokenPayload as JwtPayload).identity_provider !== IdentityProvider.IDIR;

if (isTruthy(filterToUser)) {
res
.status(200)
.send(
response.filter(
(x: { createdBy: string }) =>
x.createdBy.toUpperCase().substring(0, x.createdBy.indexOf('@idir')) ===
(req.currentUser?.tokenPayload as JwtPayload).idir_username.toUpperCase()
)
const cfg = config.get('server.chefs.forms') as ChefsFormConfig;
let formData = new Array<ChefsSubmissionDataSource>();

await Promise.all(
Object.values<ChefsFormConfigData>(cfg).map(async (x: ChefsFormConfigData) => {
const data = await chefsService.getFormSubmissions(x.id);
formData = formData.concat(data);
})
);

/*
* Filter Data source
* IDIR users should be able to see all submissions
* BCeID/Business should only see their own submissions
*/
const filterData = (data: Array<ChefsSubmissionDataSource>) => {
const tokenPayload = req.currentUser?.tokenPayload as JwtPayload;
const filterToUser = tokenPayload && tokenPayload.identity_provider !== IdentityProvider.IDIR;

if (isTruthy(filterToUser)) {
return data.filter(
(x: { createdBy: string }) =>
x.createdBy.toUpperCase().substring(0, x.createdBy.indexOf('@')) ===
(req.currentUser?.tokenPayload as JwtPayload).bceid_username.toUpperCase()
);
} else {
res.status(200).send(response);
}
} catch (e: unknown) {
next(e);
}
},
} else {
return data;
}
};

getPublishedVersion: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getPublishedVersion(req.params.formId);
res.status(200).send(response);
res.status(200).send(filterData(formData));
} catch (e: unknown) {
next(e);
}
},

getSubmission: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getSubmission(req.params.formSubmissionId);
res.status(200).send(response);
} catch (e: unknown) {
next(e);
}
},

getVersion: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getVersion(req.params.formId, req.params.versionId);
res.status(200).send(response);
} catch (e: unknown) {
next(e);
}
},

getVersionFields: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getVersionFields(req.params.formId, req.params.versionId);
res.status(200).send(response);
} catch (e: unknown) {
next(e);
}
},

getVersionSubmissions: async (req: Request, res: Response, next: NextFunction) => {
try {
const response = await chefsService.getVersionSubmissions(req.params.formId, req.params.versionId);
const response = await chefsService.getSubmission(req.query.formId as string, req.params.formSubmissionId);
res.status(200).send(response);
} catch (e: unknown) {
next(e);
Expand Down
36 changes: 36 additions & 0 deletions app/src/middleware/requireChefsFormConfigData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @ts-expect-error api-problem lacks a defined interface; code still works fine
import Problem from 'api-problem';

import { getChefsApiKey } from '../components/utils';

import type { NextFunction, Request, Response } from 'express';

/**
* @function requireChefsFormConfigData
* Rejects the request if there is no form ID present in the request
* or if the given Form ID/Api Key is not configured
* @param {object} req Express request object
* @param {object} _res Express response object
* @param {function} next The next callback function
* @returns {function} Express middleware function
* @throws The error encountered upon failure
*/
export const requireChefsFormConfigData = (req: Request, _res: Response, next: NextFunction) => {
const params = { ...req.query, ...req.params };

if (!params.formId) {
throw new Problem(400, {
detail: 'Form ID not present in request.',
instance: req.originalUrl
});
}

if (!getChefsApiKey(params.formId as string)) {
throw new Problem(501, {
detail: 'Form not present or misconfigured.',
instance: req.originalUrl
});
}

next();
};
37 changes: 7 additions & 30 deletions app/src/routes/v1/chefs.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,24 @@
import express from 'express';
import { chefsController } from '../../controllers';
import { requireChefsFormConfigData } from '../../middleware/requireChefsFormConfigData';
import { requireSomeAuth } from '../../middleware/requireSomeAuth';

import type { NextFunction, Request, Response } from 'express';

const router = express.Router();
router.use(requireSomeAuth);

// Export submissions endpoint
router.get('/forms/:formId/export', (req: Request, res: Response, next: NextFunction): void => {
chefsController.exportSubmissions(req, res, next);
});

// Form submissions endpoint
router.get('/forms/:formId/submissions', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getFormSubmissions(req, res, next);
});

// Published version endpoint
router.get('/forms/:formId/version', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getPublishedVersion(req, res, next);
});

// Submission endpoint
router.get('/submission/:formSubmissionId', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getSubmission(req, res, next);
router.get('/submissions', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getSubmissions(req, res, next);
});

// Version endpoint
router.get('/forms/:formId/versions/:versionId', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getVersion(req, res, next);
});

// Version fields endpoint
router.get('/forms/:formId/versions/:versionId/fields', (req: Request, res: Response, next: NextFunction): void => {
chefsController.getVersionFields(req, res, next);
});

// Version submissions endpoint
// Submission endpoint
router.get(
'/forms/:formId/versions/:versionId/submissions',
'/submission/:formSubmissionId',
requireChefsFormConfigData,
(req: Request, res: Response, next: NextFunction): void => {
chefsController.getVersionSubmissions(req, res, next);
chefsController.getSubmission(req, res, next);
}
);

Expand Down
72 changes: 9 additions & 63 deletions app/src/services/chefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,92 +2,38 @@
import axios from 'axios';
import config from 'config';

import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import { getChefsApiKey } from '../components/utils';

import type { AxiosInstance, AxiosRequestConfig } from 'axios';

/**
* @function chefsAxios
* Returns an Axios instance for the CHEFS API
* @param {AxiosRequestConfig} options Axios request config options
* @returns {AxiosInstance} An axios instance
*/
function chefsAxios(options: AxiosRequestConfig = {}): AxiosInstance {
const instance = axios.create({
function chefsAxios(formId: string, options: AxiosRequestConfig = {}): AxiosInstance {
return axios.create({
baseURL: config.get('frontend.chefs.apiPath'),
timeout: 10000,
auth: { username: formId, password: getChefsApiKey(formId) ?? '' },
...options
});

instance.interceptors.request.use(
async (cfg: InternalAxiosRequestConfig) => {
cfg.auth = { username: config.get('frontend.chefs.formId'), password: config.get('frontend.chefs.formApiKey') };
return Promise.resolve(cfg);
},
(error: Error) => {
return Promise.reject(error);
}
);

return instance;
}

const service = {
exportSubmissions: async (formId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/export`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getFormSubmissions: async (formId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/submissions`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getPublishedVersion: async (formId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/version`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getSubmission: async (formSubmissionId: string) => {
try {
const response = await chefsAxios().get(`submissions/${formSubmissionId}`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getVersion: async (formId: string, versionId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/versions/${versionId}`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getVersionFields: async (formId: string, versionId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/versions/${versionId}/fields`);
const response = await chefsAxios(formId).get(`forms/${formId}/submissions`);
return response.data;
} catch (e: unknown) {
throw e;
}
},

getVersionSubmissions: async (formId: string, versionId: string) => {
getSubmission: async (formId: string, formSubmissionId: string) => {
try {
const response = await chefsAxios().get(`forms/${formId}/versions/${versionId}/submissions`);
const response = await chefsAxios(formId).get(`submissions/${formSubmissionId}`);
return response.data;
} catch (e: unknown) {
throw e;
Expand Down
10 changes: 10 additions & 0 deletions app/src/types/ChefsFormConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type ChefsFormConfig = {
form1: ChefsFormConfigData;
form2: ChefsFormConfigData;
};

export type ChefsFormConfigData = {
name: string;
id: string;
apiKey: string;
};
Loading