Skip to content
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 docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
5 changes: 4 additions & 1 deletion docs/PLUGIN_OPTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 4 additions & 1 deletion src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 14 additions & 10 deletions src/server/plugins/engine/configureEnginePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}> => {
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/server/plugins/engine/options.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
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()

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(),
Expand Down
10 changes: 5 additions & 5 deletions src/server/plugins/engine/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion src/server/plugins/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -379,7 +380,7 @@ export interface PluginOptions {
model?: FormModel
services?: Services
controllers?: Record<string, typeof PageController>
cacheName?: string
cache?: CacheService | string
globals?: Record<string, GlobalFunction>
filters?: Record<string, FilterFunction>
saveAndExit?: SaveAndExitHandler
Expand Down
3 changes: 3 additions & 0 deletions src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<FormMetadata>
Expand Down Expand Up @@ -50,6 +52,7 @@ export interface RouteConfig {
preparePageEventRequestOptions?: PreparePageEventRequestOptions
onRequest?: OnRequestCallback
saveAndExit?: PluginOptions['saveAndExit']
cacheServiceCreator?: (server: Server) => CacheService
}

export interface OutputService {
Expand Down
130 changes: 130 additions & 0 deletions test/form/cacheService.test.js
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just wondering why this is pascal?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Legacy reasons, it's always been like that for some reason. Worth us changing soon, I'll leave it for now until the dust has settled on these PRs.

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'
*/
Loading