|
1 | 1 | # Overriding forms-engine-plugin logic with custom services |
2 | 2 |
|
3 | | -## Customising where forms are loaded from |
| 3 | +The `services` plugin option accepts three service objects — `formsService`, `formSubmissionService`, and `outputService` — which together cover where forms come from, where submission data goes, and how submission notifications are sent. Replace any or all of them to integrate with your own infrastructure. |
4 | 4 |
|
5 | | -The engine plugin registers several [routes](https://hapi.dev/tutorials/routing/?lang=en_US) on the hapi server. |
| 5 | +## formsService |
6 | 6 |
|
7 | | -They look like this: |
| 7 | +Responsible for loading form metadata and definitions. Called on every page request to check for definition changes and load the full definition when needed. |
8 | 8 |
|
9 | | -``` |
10 | | -GET /{slug}/{path} |
11 | | -POST /{slug}/{path} |
12 | | -``` |
13 | | - |
14 | | -A unique `slug` is used to route the user to the correct form, and the `path` used to identify the correct page within the form to show. |
15 | | - |
16 | | -The [plugin registration options](/plugin-options) have a `services` setting to provide a `formsService` that is responsible for returning `form definition` data. |
17 | | - |
18 | | -WARNING: This below is subject to change |
19 | | - |
20 | | -A `formsService` has two methods, one for returning `formMetadata` and another to return `formDefinition`s. |
21 | | - |
22 | | -```javascript |
23 | | -const formsService = { |
24 | | - getFormMetadata: async function (slug) { |
25 | | - // Returns the metadata for the slug |
26 | | - }, |
27 | | - getFormDefinition: async function (id, state) { |
28 | | - // Returns the form definition for the given id |
29 | | - } |
| 9 | +```ts |
| 10 | +interface FormsService { |
| 11 | + getFormMetadata: (slug: string) => Promise<FormMetadata> |
| 12 | + getFormMetadataById: (id: string) => Promise<FormMetadata> |
| 13 | + getFormDefinition: (id: string, state: FormStatus) => Promise<FormDefinition | undefined> |
| 14 | + getFormSecret: (formId: string, secretName: string) => Promise<string> |
30 | 15 | } |
31 | 16 | ``` |
32 | 17 |
|
33 | | -The reason for the two separate methods is caching. |
34 | | -`formMetadata` is a lightweight record designed to give top level information about a form. |
35 | | -This method is invoked for every page request. |
| 18 | +`getFormMetadata` is called on every request and should be fast. `getFormDefinition` is only called when the metadata signals the definition has changed, so it can be slower. `getFormMetadataById` is called by the status page to retrieve the submitted form's name by its ID — this allows the confirmation panel to display the correct form name even when the current URL belongs to a different form (for example, a shared feedback form). |
36 | 19 |
|
37 | | -Only when the `formMetadata` indicates that the definition has changed is a call to `getFormDefinition` is made. |
38 | | -The response from this can be quite big as it contains the entire form definition. |
| 20 | +`getFormSecret` retrieves a secret that belongs to a specific form — for example, a payment API key scoped to that form. This is distinct from global secrets such as `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret`, which are passed once at plugin registration and apply across all forms. If your forms do not use any components that require per-form secrets, this method will never be called and you can safely return `undefined` (or leave it unimplemented). See the documentation for each component to find out whether it requires secrets and what names it requests. |
39 | 21 |
|
40 | | -## Loading forms from files |
| 22 | +### Loading forms from files |
41 | 23 |
|
42 | | -To create a `formsService` from form config files that live on disk, you can use the `FileFormService` class. |
43 | | -Form definition config files can be either `.json` or `.yaml`. |
| 24 | +For local or file-based forms, use the built-in `FileFormService`: |
44 | 25 |
|
45 | | -Once created and files have been loaded using the `addForm` method, |
46 | | -call the `toFormsService` method to return a `FormService` compliant interface which can be passed in to the `services` setting of the [plugin options](/plugin-options). |
47 | | - |
48 | | -```javascript |
| 26 | +```js |
49 | 27 | import { FileFormService } from '@defra/forms-engine-plugin/file-form-service.js' |
50 | 28 |
|
51 | | -// Create shared form metadata |
52 | 29 | const now = new Date() |
53 | 30 | const user = { id: 'user', displayName: 'Username' } |
54 | 31 | const author = { createdAt: now, createdBy: user, updatedAt: now, updatedBy: user } |
55 | | -const metadata = { |
| 32 | + |
| 33 | +const loader = new FileFormService() |
| 34 | + |
| 35 | +await loader.addForm('src/definitions/example-form.yaml', { |
| 36 | + id: '95e92559-968d-44ae-8666-2b1ad3dffd31', |
| 37 | + title: 'Example form', |
| 38 | + slug: 'example-form', |
56 | 39 | organisation: 'Defra', |
57 | 40 | teamName: 'Team name', |
58 | 41 | teamEmail: 'team@defra.gov.uk', |
59 | 42 | submissionGuidance: "Thanks for your submission, we'll be in touch", |
60 | | - notificationEmail: 'email@domain.com', |
| 43 | + notificationEmail: 'team@defra.gov.uk', |
61 | 44 | ...author, |
62 | 45 | live: author |
| 46 | +}) |
| 47 | + |
| 48 | +const formsService = loader.toFormsService() |
| 49 | +``` |
| 50 | + |
| 51 | +--- |
| 52 | + |
| 53 | +## formSubmissionService |
| 54 | + |
| 55 | +Called during form submission to persist the submitted data and any uploaded files. The default implementation calls the Defra Forms submission API (`SUBMISSION_URL`), which is part of the Defra Forms hosting infrastructure. Teams not using that infrastructure must provide their own implementation. |
| 56 | + |
| 57 | +```ts |
| 58 | +interface FormSubmissionService { |
| 59 | + persistFiles: ( |
| 60 | + files: { fileId: string; initiatedRetrievalKey: string }[], |
| 61 | + persistedRetrievalKey: string |
| 62 | + ) => Promise<object> |
| 63 | + submit: (data: SubmitPayload) => Promise<SubmitResponsePayload | undefined> |
63 | 64 | } |
| 65 | +``` |
64 | 66 |
|
65 | | -// Instantiate the file loader form service |
66 | | -const loader = new FileFormService() |
| 67 | +`submit` is called first with the structured form payload. The `SubmitResponsePayload` it returns (including CSV file IDs) is then passed to `outputService.submit`. `persistFiles` is called by `FileUploadField` during submission to move uploaded files from temporary to permanent storage. |
67 | 68 |
|
68 | | -// Add a Json form |
69 | | -await loader.addForm( |
70 | | - 'src/definitions/example-form.json', { |
71 | | - ...metadata, |
72 | | - id: '95e92559-968d-44ae-8666-2b1ad3dffd31', |
73 | | - title: 'Example Json', |
74 | | - slug: 'example-json' |
75 | | - } |
76 | | -) |
77 | | - |
78 | | -// Add a Yaml form |
79 | | -await loader.addForm( |
80 | | - 'src/definitions/example-form.yaml', { |
81 | | - ...metadata, |
82 | | - id: '641aeafd-13dd-40fa-9186-001703800efb', |
83 | | - title: 'Example Yaml', |
84 | | - slug: 'example-yaml' |
| 69 | +Override this service if you are not using the Defra Forms hosting infrastructure, or if you handle file persistence differently. |
| 70 | + |
| 71 | +--- |
| 72 | + |
| 73 | +## outputService |
| 74 | + |
| 75 | +Called after `formSubmissionService.submit` completes. Its job is to deliver the submission — by default, as a GOV.UK Notify email. |
| 76 | + |
| 77 | +```ts |
| 78 | +interface OutputService { |
| 79 | + submit: ( |
| 80 | + context: FormContext, |
| 81 | + request: FormRequestPayload, |
| 82 | + model: FormModel, |
| 83 | + emailAddress: string, |
| 84 | + items: DetailItem[], |
| 85 | + submitResponse: SubmitResponsePayload, |
| 86 | + formMetadata?: FormMetadata |
| 87 | + ) => Promise<void> |
| 88 | +} |
| 89 | +``` |
| 90 | + |
| 91 | +The default implementation (`notifyService`) formats the submission using the [output formatter](#output-format) configured on the form definition and sends it to `emailAddress` via GOV.UK Notify. |
| 92 | + |
| 93 | +Override this service to deliver submissions differently — for example, publishing to an SNS topic, calling a webhook, or writing to a database. Your implementation receives the full `FormContext`, `FormModel`, `DetailItem[]` array, and the `SubmitResponsePayload` from `formSubmissionService`, giving you everything needed to format and route the submission however you need. |
| 94 | + |
| 95 | +```js |
| 96 | +await server.register({ |
| 97 | + plugin, |
| 98 | + options: { |
| 99 | + services: { |
| 100 | + formsService, |
| 101 | + formSubmissionService, |
| 102 | + outputService: { |
| 103 | + async submit(context, request, model, emailAddress, items, submitResponse, formMetadata) { |
| 104 | + // publish to SNS, call a webhook, etc. |
| 105 | + } |
| 106 | + } |
| 107 | + } |
85 | 108 | } |
86 | | -) |
| 109 | +}) |
| 110 | +``` |
87 | 111 |
|
88 | | -// Get the forms service |
89 | | -const formsService = loader.toFormsService() |
| 112 | +### Output format |
| 113 | + |
| 114 | +If you use the default `notifyService`, the format of the email body is controlled by the `output` field in the form definition: |
| 115 | + |
| 116 | +```json |
| 117 | +{ |
| 118 | + "output": { |
| 119 | + "audience": "human", |
| 120 | + "version": "1" |
| 121 | + } |
| 122 | +} |
90 | 123 | ``` |
| 124 | + |
| 125 | +| Value | Description | |
| 126 | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | |
| 127 | +| `audience: "human"` | Formats the submission as readable Markdown for a GOV.UK Notify email template. Default. | |
| 128 | +| `audience: "machine"` | Formats the submission as a JSON payload, base64-encoded into the Notify email body. Useful when a downstream system reads the email programmatically. | |
| 129 | + |
| 130 | +`version` selects the formatter version within that audience. Currently `"1"` is the only stable version for `human`; `"1"` and `"2"` are available for `machine`. Defaults to `"1"` when omitted. |
| 131 | + |
| 132 | +If you provide a custom `outputService`, the `output` field has no effect — your service controls formatting entirely. |
| 133 | + |
| 134 | +> **Note:** Page events always use the `machine/v1` payload format regardless of the `output` setting. The `output` field only affects what the default `notifyService` sends. |
0 commit comments