diff --git a/.gitignore b/.gitignore index cc4fd1cd..5f5a1d5e 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ typings/ .vscode .config + +# ignore any cached templates +/questionnaire/template-versions diff --git a/Dockerfile b/Dockerfile index dc0e3898..d479f546 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,9 +5,17 @@ RUN groupadd -g 1014 dc_user \ && useradd -rm -d /usr/src/app -u 1015 -g dc_user dc_user USER dc_user +# Install git (needed for npm to install from GitHub) +RUN apt-get update && \ + apt-get install -y git && \ + rm -rf /var/lib/apt/lists/* + # Essentially running mkdir inside the current working # directory, and then cd WORKDIR /usr/src/app +RUN mkdir -p /usr/src/app/questionnaire/template-versions \ + && chown -R dc_user:dc_user /usr/src/app/questionnaire + #no chnage made # Install app dependencies # A wildcard is used to ensure both package.json AND package-lock.json are copied @@ -55,6 +63,8 @@ USER dc_user # Essentially running mkdir inside the current working # directory, and then cd WORKDIR /usr/src/app +RUN mkdir -p /usr/src/app/questionnaire/template-versions \ + && chown -R dc_user:dc_user /usr/src/app/questionnaire #no chnage made # Install app dependencies # A wildcard is used to ensure both package.json AND package-lock.json are copied diff --git a/nodemon.json b/nodemon.json index 374a32b6..7cb5acec 100644 --- a/nodemon.json +++ b/nodemon.json @@ -5,7 +5,8 @@ ".sass-cache", "bower_components", "coverage", - "./node_modules/!(q-templates-application)/dist/template.json" + "./node_modules/!(q-templates-application)/dist/template.json", + "./questionnaire/template-versions" ], "watch": [ "node_modules/q-templates-application/dist/template.json", @@ -14,7 +15,7 @@ "middleware", "openapi", "public", - "questionnaire", + "questionnaire/!(template-versions)", "services", "app.js" ] diff --git a/openapi/openapi.json b/openapi/openapi.json index 2e0c360d..9794a11c 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -132,6 +132,11 @@ "pattern": "^urn:uuid:" } } + }, + "templateVersion": { + "title": "semver", + "type": "string", + "pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$" } } } @@ -153,7 +158,8 @@ }, "external": { "id": "urn:uuid:f81d4fae-7dec-11d0-a765-123456781234" - } + }, + "templateVersion": "1.0.0" } } } diff --git a/openapi/src/json-schemas/api/_questionnaires/post/req/201.json b/openapi/src/json-schemas/api/_questionnaires/post/req/201.json index 37d19a14..09b43312 100644 --- a/openapi/src/json-schemas/api/_questionnaires/post/req/201.json +++ b/openapi/src/json-schemas/api/_questionnaires/post/req/201.json @@ -53,6 +53,9 @@ "$ref": "../../../../models/definitions/external-id.json" } } + }, + "templateVersion": { + "$ref": "../../../../models/definitions/semver.json" } } } diff --git a/openapi/src/openapi-src.json b/openapi/src/openapi-src.json index 057863e8..cdbab3a6 100644 --- a/openapi/src/openapi-src.json +++ b/openapi/src/openapi-src.json @@ -76,7 +76,8 @@ }, "external": { "id": "urn:uuid:f81d4fae-7dec-11d0-a765-123456781234" - } + }, + "templateVersion": "1.0.0" } } } diff --git a/questionnaire/questionnaire-service.js b/questionnaire/questionnaire-service.js index b668d538..f38a52cc 100644 --- a/questionnaire/questionnaire-service.js +++ b/questionnaire/questionnaire-service.js @@ -8,7 +8,7 @@ const VError = require('verror'); const router = require('q-router'); const uuidv4 = require('uuid/v4'); const ajvFormatsMobileUk = require('ajv-formats-mobile-uk'); -const templates = require('./templates'); +const getTemplate = require('./templates'); const questionnaireResource = require('./resources/questionnaire-resource'); const createQuestionnaireHelper = require('./questionnaire/questionnaire'); const isQuestionnaireCompatible = require('./utils/isQuestionnaireVersionCompatible'); @@ -62,18 +62,16 @@ function createQuestionnaireService({ return false; } - async function createQuestionnaire(templateName, ownerData, originData, externalData) { - if (!(templateName in templates)) { - throw new VError( - { - name: 'ResourceNotFound' - }, - `Template "${templateName}" does not exist` - ); - } - + async function createQuestionnaire( + templateName, + ownerData, + originData, + externalData, + templateVersion + ) { const uuidV4 = uuidv4(); - const questionnaire = templates[templateName](uuidV4); + const initiateQuestionnaire = getTemplate(templateName, templateVersion); + const questionnaire = initiateQuestionnaire(uuidV4); if (!ownerData) { throw new VError( diff --git a/questionnaire/questionnaire-service.test.js b/questionnaire/questionnaire-service.test.js index 747a6451..1fadfcd9 100644 --- a/questionnaire/questionnaire-service.test.js +++ b/questionnaire/questionnaire-service.test.js @@ -207,6 +207,20 @@ jest.doMock('./utils/isQuestionnaireVersionCompatible', () => questionnaireVersi return questionnaireVersion !== incompatibleQuestionnaireFixture.version; }); +jest.doMock('./templates', () => { + const getTemplateMock = jest.fn(templateName => { + return id => ({ + id, + templateName, + routes: { + type: 'mockTemplate' + } + }); + }); + + return getTemplateMock; +}); + const mockDalService = require('./questionnaire-dal')(); const createQuestionnaireService = require('./questionnaire-service'); @@ -245,14 +259,6 @@ describe('Questionnaire Service', () => { }); }); - it('Should error if templateName not found', async () => { - const templatename = 'not-a-template'; - - await expect( - questionnaireService.createQuestionnaire(templatename, ownerData) - ).rejects.toThrow('Template "not-a-template" does not exist'); - }); - it('Should set owner data in the answers', async () => { await questionnaireService.createQuestionnaire(templatename, ownerData); diff --git a/questionnaire/routes.js b/questionnaire/routes.js index 5a29fc0f..cdd2744e 100644 --- a/questionnaire/routes.js +++ b/questionnaire/routes.js @@ -28,7 +28,7 @@ router.route('/').post(permissions('create:questionnaires'), async (req, res, ne throw err; } - const {templateName, owner, origin, external} = req.body.data.attributes; + const {templateName, owner, origin, external, templateVersion} = req.body.data.attributes; const questionnaireService = createQuestionnaireService({ logger: req.log, @@ -39,7 +39,8 @@ router.route('/').post(permissions('create:questionnaires'), async (req, res, ne templateName, owner, origin, - external + external, + templateVersion ); res.status(201).json(response); diff --git a/questionnaire/templates.js b/questionnaire/templates.js index 2ae48f86..e7b96711 100644 --- a/questionnaire/templates.js +++ b/questionnaire/templates.js @@ -1,16 +1,71 @@ +/* eslint-disable import/no-dynamic-require */ +/* eslint-disable global-require */ + 'use strict'; -const applicationTemplate = require('q-templates-application'); +const path = require('path'); +const fs = require('fs'); +const {execSync} = require('child_process'); +const {createRequire} = require('module'); +const VError = require('verror'); + +const templatesDir = path.resolve(__dirname, 'template-versions'); +const registry = require('../supported-templates'); + +const moduleCache = {}; + +function getTemplate(templateName, version) { + const templateConfig = registry[templateName]; + + if (!templateConfig) { + throw new VError({name: 'ResourceNotFound'}, `Template "${templateName}" does not exist`); + } + + const {moduleName, moduleUrl, supportedVersions} = templateConfig; + const cacheKey = `${templateName}@${version || 'latest'}`; + + if (!moduleCache[cacheKey]) { + if (!version) { + moduleCache[cacheKey] = require(moduleName); + } else { + if (supportedVersions && !supportedVersions.includes(version)) { + throw new VError( + {name: 'ResourceNotFound'}, + `Version "${version}" not supported for template "${templateName}"` + ); + } + + const installDir = path.join(templatesDir, templateName, `v${version}`); + const modulePath = path.join(installDir, 'node_modules', moduleName); + + if (!fs.existsSync(modulePath)) { + console.log(`Installing ${moduleName}#${version} for ${templateName}...`); + fs.mkdirSync(installDir, {recursive: true}); -const applicationTemplateAsJson = JSON.stringify(applicationTemplate); + if (!fs.existsSync(path.join(installDir, 'package.json'))) { + execSync('npm init -y', { + cwd: installDir, + stdio: 'inherit' + }); + } + execSync(`npm install ${moduleUrl}#v${version} --no-save`, { + cwd: installDir, + stdio: 'inherit' + }); + } + const requireFrom = createRequire(installDir); + moduleCache[cacheKey] = requireFrom(moduleName); + } + } -function getApplicationTemplateCopy() { - return JSON.parse(applicationTemplateAsJson); + const loadedModule = moduleCache[cacheKey]; + return id => { + const templateInstance = JSON.parse(JSON.stringify(loadedModule)); + return { + id, + ...templateInstance + }; + }; } -module.exports = { - 'sexual-assault': id => ({ - id, - ...getApplicationTemplateCopy() - }) -}; +module.exports = getTemplate; diff --git a/questionnaire/templates.test.js b/questionnaire/templates.test.js new file mode 100644 index 00000000..b5ff51f2 --- /dev/null +++ b/questionnaire/templates.test.js @@ -0,0 +1,72 @@ +'use strict'; + +const fs = require('fs'); +const {execSync} = require('child_process'); + +jest.mock('fs'); +jest.mock('child_process'); +jest.mock('module', () => { + return { + createRequire: jest.fn(() => { + return () => ({ + type: 'loaded-requested' + }); + }) + }; +}); +jest.mock('q-templates-application', () => ({ + type: 'loaded-latest' +})); + +fs.existsSync.mockImplementation(() => true); +fs.mkdirSync.mockImplementation(() => {}); +execSync.mockImplementation(() => {}); + +jest.doMock('../supported-templates.js', () => ({ + 'sexual-assault': { + moduleName: 'q-templates-application', + moduleUrl: 'github:CriminalInjuriesCompensationAuthority/q-templates-application', + supportedVersions: ['12.3.9', '12.0.0', '11.0.0'] + } +})); + +const getTemplate = require('./templates'); + +const testId = '123ImAnId'; + +beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); +}); + +describe('Templates.js', () => { + it('should use installed template when no version is specified', () => { + const questionnaire = getTemplate('sexual-assault')(testId); + + expect(questionnaire).toMatchObject({ + id: '123ImAnId', + type: 'loaded-latest' + }); + }); + + it('should dynamically load a specific version', () => { + const questionnaire = getTemplate('sexual-assault', '12.0.0')(testId); + + expect(questionnaire).toMatchObject({ + id: '123ImAnId', + type: 'loaded-requested' + }); + }); + + it('should throw if template name is unknown', () => { + expect(() => { + getTemplate('non-existent-template')(testId); + }).toThrow('Template "non-existent-template" does not exist'); + }); + + it('should throw if version is unsupported', () => { + expect(() => { + getTemplate('sexual-assault', '99.9.9')(testId); + }).toThrow('Version "99.9.9" not supported for template "sexual-assault"'); + }); +}); diff --git a/supported-templates.js b/supported-templates.js new file mode 100644 index 00000000..945ffa06 --- /dev/null +++ b/supported-templates.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = { + 'sexual-assault': { + moduleName: 'q-templates-application', + moduleUrl: 'github:CriminalInjuriesCompensationAuthority/q-templates-application', + supportedVersions: ['12.3.9', '12.0.0', '11.0.0'] + } +};