diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index bb4953329..4e428874e 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -111,7 +111,7 @@ const paths = [join(config.get('appDir'), 'views')] await server.register({ plugin, options: { - cacheName: 'session', // must match a session you've instantiated in your hapi server config + cache: 'session', // must match a session you've instantiated in your hapi server config. Also accepts a CacheService instance for advanced use-cases. /** * Options that DXT uses to render Nunjucks templates */ diff --git a/docs/PLUGIN_OPTIONS.md b/docs/PLUGIN_OPTIONS.md index 42d74ca20..4a5421ff7 100644 --- a/docs/PLUGIN_OPTIONS.md +++ b/docs/PLUGIN_OPTIONS.md @@ -16,7 +16,10 @@ The forms plugin is configured with [registration options](https://hapi.dev/api/ - `controllers` (optional) - Object map of custom page controllers used to override the default. See [custom controllers](#custom-controllers) - `globals` (optional) - A map of custom template globals to include - `filters` (optional) - A map of custom template filters to include -- `cacheName` (optional) - The cache name to use. Defaults to hapi's [default server cache]. Recommended for production. See [here](#custom-cache) for more details +- `cache` (optional) - Caching options + - `cache` (optional) - Caching options. Recommended for production. This can be either: + - a string representing the cache name to use (e.g. hapi's default server cache). See [here](#custom-cache) for more details. + - a custom `CacheService` instance implementing your own caching logic - `pluginPath` (optional) - The location of the plugin (defaults to `node_modules/@defra/forms-engine-plugin`) - `preparePageEventRequestOptions` (optional) - A function that will be invoked for http-based [page events](./features/configuration-based/PAGE_EVENTS.md). See [here](./features/configuration-based/PAGE_EVENTS.md#authenticating-a-http-page-event-request-from-dxt-in-your-api) for details - `saveAndExit` (optional) - Configuration for custom session management including key generation, session hydration, and persistence. See [save and exit documentation](./features/code-based/SAVE_AND_EXIT.md) for details diff --git a/package.json b/package.json index f6e673813..ab12825bc 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "./helpers.js": "./.server/server/plugins/engine/components/helpers.js", "./schema.js": "./.server/server/schemas/index.js", "./templates/*": "./.server/server/plugins/engine/views/*", + "./cache-service.js": "./.server/server/services/cacheService.js", "./package.json": "./package.json" }, "scripts": { diff --git a/src/server/index.ts b/src/server/index.ts index 9af3e79ad..f4a3aa7f4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -82,8 +82,11 @@ export async function createServer(routeConfig?: RouteConfig) { prepareSecureContext(server) } + const cacheService = routeConfig?.cacheServiceCreator + ? routeConfig.cacheServiceCreator(server) + : undefined const pluginCrumb = configureCrumbPlugin(routeConfig) - const pluginEngine = await configureEnginePlugin(routeConfig) + const pluginEngine = await configureEnginePlugin(routeConfig, cacheService) await server.register(pluginSession) await server.register(pluginPulse) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 3501f8fdd..dba5fa230 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -10,17 +10,21 @@ import { formsService } from '~/src/server/plugins/engine/services/localFormsSer import { type PluginOptions } from '~/src/server/plugins/engine/types.js' import { findPackageRoot } from '~/src/server/plugins/engine/vision.js' import { devtoolContext } from '~/src/server/plugins/nunjucks/context.js' +import { type CacheService } from '~/src/server/services/cacheService.js' import { type RouteConfig } from '~/src/server/types.js' -export const configureEnginePlugin = async ({ - formFileName, - formFilePath, - services, - controllers, - preparePageEventRequestOptions, - onRequest, - saveAndExit -}: RouteConfig = {}): Promise<{ +export const configureEnginePlugin = async ( + { + formFileName, + formFilePath, + services, + controllers, + preparePageEventRequestOptions, + onRequest, + saveAndExit + }: RouteConfig = {}, + cache?: CacheService +): Promise<{ plugin: typeof plugin options: PluginOptions }> => { @@ -50,7 +54,7 @@ export const configureEnginePlugin = async ({ formsService: await formsService() }, controllers, - cacheName: 'session', + cache: cache ?? 'session', nunjucks: { baseLayoutPath: 'dxt-devtool-baselayout.html', paths: [join(findPackageRoot(), 'src/server/devserver')] // custom layout to make it really clear this is not the same as the runner diff --git a/src/server/plugins/engine/options.js b/src/server/plugins/engine/options.js index bd620753e..39f04daca 100644 --- a/src/server/plugins/engine/options.js +++ b/src/server/plugins/engine/options.js @@ -1,6 +1,7 @@ import Joi from 'joi' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' +import { CacheService } from '~/src/server/services/index.js' const logger = createLogger() @@ -8,7 +9,10 @@ const pluginRegistrationOptionsSchema = Joi.object({ model: Joi.object().optional(), services: Joi.object().optional(), controllers: Joi.object().pattern(Joi.string(), Joi.any()).optional(), - cacheName: Joi.string().optional(), + cache: Joi.alternatives().try( + Joi.object().instance(CacheService), + Joi.string() + ), globals: Joi.object().pattern(Joi.string(), Joi.any()).optional(), filters: Joi.object().pattern(Joi.string(), Joi.any()).optional(), pluginPath: Joi.string().optional(), diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 85dff2e7d..2c783d38d 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -30,17 +30,17 @@ export const plugin = { const { model, - cacheName, + cache, saveAndExit, nunjucks: nunjucksOptions, viewContext, preparePageEventRequestOptions } = options - const cacheService = new CacheService({ - server, - cacheName - }) + const cacheService = + typeof cache === 'string' + ? new CacheService({ server, cacheName: cache }) + : cache await registerVision(server, options) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 05480d632..7576545d7 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -43,6 +43,7 @@ import { type FormResponseToolkit, type FormStatus } from '~/src/server/routes/types.js' +import { type CacheService } from '~/src/server/services/cacheService.js' import { type RequestOptions } from '~/src/server/services/httpService.js' import { type Services } from '~/src/server/types.js' @@ -379,7 +380,7 @@ export interface PluginOptions { model?: FormModel services?: Services controllers?: Record - cacheName?: string + cache?: CacheService | string globals?: Record filters?: Record saveAndExit?: SaveAndExitHandler diff --git a/src/server/types.ts b/src/server/types.ts index b6bca3d00..b5179ae00 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -4,6 +4,7 @@ import { type SubmitPayload, type SubmitResponsePayload } from '@defra/forms-model' +import { type Server } from '@hapi/hapi' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' @@ -18,6 +19,7 @@ import { type FormRequestPayload, type FormStatus } from '~/src/server/routes/types.js' +import { type CacheService } from '~/src/server/services/cacheService.js' export interface FormsService { getFormMetadata: (slug: string) => Promise @@ -50,6 +52,7 @@ export interface RouteConfig { preparePageEventRequestOptions?: PreparePageEventRequestOptions onRequest?: OnRequestCallback saveAndExit?: PluginOptions['saveAndExit'] + cacheServiceCreator?: (server: Server) => CacheService } export interface OutputService { diff --git a/test/form/cacheService.test.js b/test/form/cacheService.test.js new file mode 100644 index 000000000..d71e01f4d --- /dev/null +++ b/test/form/cacheService.test.js @@ -0,0 +1,130 @@ +import { join } from 'path' + +import { Engine as CatboxMemory } from '@hapi/catbox-memory' + +import { FORM_PREFIX } from '~/src/server/constants.js' +import { createServer } from '~/src/server/index.js' +import { CacheService } from '~/src/server/services/cacheService.js' +import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' + +const basePath = `${FORM_PREFIX}/minimal` + +class NewCacheService extends CacheService { + /** + * + * @param {AnyRequest} _request + * @param {ADDITIONAL_IDENTIFIER} [_additionalIdentifier] + * @returns + */ + Key(_request, _additionalIdentifier) { + return { + segment: 'irrelevant', + id: 'my-custom-identifier' + } + } +} + +describe('CacheService', () => { + /** @type {Server} */ + let server + + afterEach(async () => { + await server.stop() + }) + + test('the new cache service is utilised', async () => { + // Spy on CatboxMemory.prototype.set globally + const setStateSpy = jest.spyOn(NewCacheService.prototype, 'setState') + const catboxSetSpy = jest.spyOn(CatboxMemory.prototype, 'set') + + server = await createServer({ + formFileName: 'minimal.js', + formFilePath: join(import.meta.dirname, 'definitions'), + cacheServiceCreator: (server) => + new NewCacheService({ server, cacheName: 'session' }) + }) + + await server.initialize() + + // Navigate to start + const headers = undefined + const response = await server.inject({ + url: `${basePath}/start`, + headers + }) + + // Extract the session cookie + const csrfToken = getCookie(response, 'crumb') + const newHeaders = getCookieHeader(response, ['session', 'crumb']) + + // Submit answers + await server.inject({ + url: `${basePath}/start`, + method: 'POST', + headers: newHeaders, + payload: { + crumb: csrfToken, + field: 'value' + } + }) + + // assert our new custom cache is used + expect(setStateSpy).toHaveBeenCalled() + setStateSpy.mockRestore() + + // Assert the custom ID 'my-custom-identifier' is used + expect(catboxSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'my-custom-identifier', + segment: 'formSubmission' + }), + expect.any(Object), + expect.any(Number) + ) + catboxSetSpy.mockRestore() + }) + + test('the default cache service is utilised', async () => { + // Spy on CatboxMemory.prototype.set globally + const setStateSpy = jest.spyOn(CacheService.prototype, 'setState') + + server = await createServer({ + formFileName: 'minimal.js', + formFilePath: join(import.meta.dirname, 'definitions') + }) + + await server.initialize() + + // Navigate to start + const headers = undefined + const response = await server.inject({ + url: `${basePath}/start`, + headers + }) + + // Extract the session cookie + const csrfToken = getCookie(response, 'crumb') + const newHeaders = getCookieHeader(response, ['session', 'crumb']) + + // Submit answers + await server.inject({ + url: `${basePath}/start`, + method: 'POST', + headers: newHeaders, + payload: { + crumb: csrfToken, + field: 'value' + } + }) + + // assert our new custom cache is used + expect(setStateSpy).toHaveBeenCalled() + setStateSpy.mockRestore() + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + * @import { AnyFormRequest, AnyRequest, FormSubmissionState } from '~/src/server/plugins/engine/types.js' + * @import { ADDITIONAL_IDENTIFIER } from '~/src/server/services/cacheService.js' + */