Skip to content

Commit 06aa505

Browse files
authored
[PLU-304]: feat: Allow headers to be set using variables for Custom API (#793)
## Problem Allow headers to be set using variables for Custom API. ## Solution Add option to set custom headers in the "Set up step" with ability to select variables. - Default <img width="853" alt="Screenshot 2024-11-08 at 5 31 09 PM" src="https://github.com/user-attachments/assets/54d0d598-b063-43cc-b845-bc541a10496f"> - With custom headers <img width="859" alt="Screenshot 2024-11-08 at 12 13 56 PM" src="https://github.com/user-attachments/assets/56c3720e-2d88-432e-9505-24c144cc6919"> **Features**: - Enable custom headers to be sent (on top of headers configured during "Add new connection") - Implement multi column input to adopt similar input style as Postman/Bruno <img width="851" alt="Screenshot 2024-11-08 at 1 33 24 PM" src="https://github.com/user-attachments/assets/c2194022-4457-4676-ab0d-25a297f10c26"> ## Before & After Screenshots **BEFORE**: - N/A. New feature **AFTER**: 1. Custom Headers set on Plumber <img width="850" alt="Screenshot 2024-11-08 at 1 41 21 PM" src="https://github.com/user-attachments/assets/f1e97f81-2c85-4e5a-9273-51d2ab45b4cd"> 2. Custom Headers received <img width="855" alt="Screenshot 2024-11-08 at 1 42 22 PM" src="https://github.com/user-attachments/assets/696e0305-aeeb-4183-bf93-eb43328a0120"> ## Tests - [x] Validate key-value pairs, i.e. key/value should not be null - [x] Custom headers should not override headers set up during the "Add new connection" stage - [x] API calls still execute without custom headers **New scripts**: - `packages/backend/src/apps/custom-api/actions/http-request/schema.ts` : validate custom headers - `packages/frontend/src/components/MultiCol.tsx/index.tsx` : component for multi column input - `packages/frontend/src/components/MultiRow/RowDivider.tsx` : move "And" divider to separate component
1 parent dcf2095 commit 06aa505

File tree

14 files changed

+307
-51
lines changed

14 files changed

+307
-51
lines changed

packages/backend/src/apps/custom-api/__tests__/actions/http-request.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,30 @@ describe('make http request', () => {
6565
)
6666
})
6767

68+
it('invokes the webhook with custom headers', async () => {
69+
$.step.parameters.method = 'POST'
70+
$.step.parameters.data = 'meep meep'
71+
$.step.parameters.url = 'http://test.local/endpoint?1234'
72+
$.step.parameters.customHeaders = [
73+
{ key: 'Key1', value: 'Value1' },
74+
{ key: 'Key2', value: 'Value2' },
75+
]
76+
mocks.httpRequest.mockReturnValue('mock response')
77+
78+
await makeRequestAction.run($).catch(() => null)
79+
expect(mocks.httpRequest).toHaveBeenCalledWith(
80+
expect.objectContaining({
81+
url: $.step.parameters.url,
82+
method: $.step.parameters.method,
83+
data: $.step.parameters.data,
84+
headers: {
85+
Key1: 'Value1',
86+
Key2: 'Value2',
87+
},
88+
}),
89+
)
90+
})
91+
6892
it('should throw an error for error with http request', async () => {
6993
$.step.parameters.method = 'POST'
7094
$.step.parameters.data = 'meep meep'
@@ -84,6 +108,22 @@ describe('make http request', () => {
84108
)
85109
})
86110

111+
it.each([[{ value: 'test' }], [{ key: 'test' }]])(
112+
'should throw error for invalid custom headers (no field)',
113+
async () => {
114+
$.step.parameters.customHeaders = [{ value: 'test' }]
115+
await expect(makeRequestAction.run($)).rejects.toThrowError()
116+
},
117+
)
118+
119+
it('should throw error for invalid custom headers (duplicate keys)', async () => {
120+
$.step.parameters.customHeaders = [
121+
{ key: 'test', value: 'value1' },
122+
{ key: 'test', value: 'value2' },
123+
]
124+
await expect(makeRequestAction.run($)).rejects.toThrowError()
125+
})
126+
87127
it('should follow redirect once', async () => {
88128
mocks.isUrlAllowed.mockResolvedValueOnce(false)
89129
$.step.parameters.method = 'POST'

packages/backend/src/apps/custom-api/actions/http-request/index.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { IRawAction } from '@plumber/types'
22

3-
import StepError from '@/errors/step'
3+
import { ZodError } from 'zod'
4+
import { fromZodError } from 'zod-validation-error'
5+
6+
import StepError, { GenericSolution } from '@/errors/step'
47

58
import {
69
DISALLOWED_IP_RESOLVED_ERROR,
710
RECURSIVE_WEBHOOK_ERROR_NAME,
811
} from '../../common/check-urls'
912

13+
import { requestSchema } from './schema'
14+
1015
type TMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
1116

1217
const REDIRECT_STATUS_CODES = [301, 302, 307, 308]
@@ -41,6 +46,31 @@ const action: IRawAction = {
4146
'Any URL with a querystring will be re-encoded properly. Plumber URLs (e.g. https://plumber.gov.sg/webhooks/...) are prohibited.',
4247
variables: true,
4348
},
49+
{
50+
label: 'Custom Headers',
51+
key: 'customHeaders',
52+
type: 'multirow-multicol' as const,
53+
required: false,
54+
description: 'Add custom headers here.',
55+
variables: true,
56+
addRowButtonText: 'Add',
57+
subFields: [
58+
{
59+
placeholder: 'Key',
60+
key: 'key',
61+
type: 'string' as const,
62+
required: true,
63+
variables: false,
64+
},
65+
{
66+
placeholder: 'Value',
67+
key: 'value',
68+
type: 'string' as const,
69+
required: true,
70+
variables: true,
71+
},
72+
],
73+
},
4474
{
4575
label: 'Data',
4676
key: 'data',
@@ -57,11 +87,15 @@ const action: IRawAction = {
5787
const url = $.step.parameters.url as string
5888

5989
try {
90+
const parsedS = requestSchema.parse($.step.parameters)
91+
const { customHeaders } = parsedS
92+
6093
let response = await $.http.request({
6194
url,
6295
method,
6396
data,
6497
maxRedirects: 0,
98+
headers: customHeaders,
6599
// overwriting this to allow redirects to resolve
66100
validateStatus: (status) =>
67101
(status >= 200 && status < 300) ||
@@ -98,6 +132,16 @@ const action: IRawAction = {
98132

99133
$.setActionItem({ raw: { data: responseData } })
100134
} catch (err) {
135+
if (err instanceof ZodError) {
136+
const firstError = fromZodError(err).details[0]
137+
throw new StepError(
138+
`${firstError.message} under set up action`,
139+
GenericSolution.ReconfigureInvalidField,
140+
$.step.position,
141+
$.app.name,
142+
)
143+
}
144+
101145
if (err.message === RECURSIVE_WEBHOOK_ERROR_NAME) {
102146
throw new StepError(
103147
RECURSIVE_WEBHOOK_ERROR_NAME,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { z } from 'zod'
2+
3+
import { sanitizeMarkdown } from '@/apps/telegram-bot/common/markdown-v1'
4+
5+
export const requestSchema = z.object({
6+
customHeaders: z
7+
.array(
8+
z.object({
9+
// key cannot be null or empty
10+
key: z
11+
.string({
12+
required_error: 'Key empty',
13+
invalid_type_error: 'Key empty',
14+
})
15+
.trim()
16+
.min(1, 'Key empty'),
17+
// value optional in the event the substituted variable is empty
18+
value: z.string().trim().nullish().optional(),
19+
}),
20+
)
21+
.transform((params, context) => {
22+
const result = Object.create(null)
23+
const seenFields = new Set<string>()
24+
for (const { key, value } of params) {
25+
// no null keys are allowed
26+
if (!key) {
27+
context.addIssue({
28+
code: z.ZodIssueCode.custom,
29+
message: 'Key empty',
30+
})
31+
return z.NEVER
32+
}
33+
// catch duplicate fields
34+
if (seenFields.has(key)) {
35+
context.addIssue({
36+
code: z.ZodIssueCode.custom,
37+
message: `${key} key is repeated`,
38+
fatal: true,
39+
})
40+
return z.NEVER
41+
}
42+
seenFields.add(key)
43+
44+
const cleanV = value?.replaceAll(/\r?\n|\r/g, ' ') || ''
45+
result[key] = sanitizeMarkdown(cleanV)
46+
}
47+
return result
48+
})
49+
.nullish(),
50+
})

packages/backend/src/apps/lettersg/actions/create-letter/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ const action: IRawAction = {
6969
label: 'Personalised fields',
7070
key: 'letterParams',
7171
type: 'multirow' as const,
72-
required: false,
72+
required: true,
7373
description:
7474
'Specify values for each personalised field in your template.',
7575

packages/backend/src/graphql/schema.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ type ActionSubstepArgument {
188188
value: JSONObject
189189
source: ActionSubstepArgumentSource
190190
hiddenIf: FieldVisibilityCondition
191+
addRowButtonText: String
191192

192193
# Only for dropdown
193194
addNewOption: DropdownAddNewOption

packages/frontend/src/components/InputCreator/index.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type InputCreatorProps = {
1515
namePrefix?: string
1616
stepId?: string
1717
disabled?: boolean
18+
parentType?: string
1819
}
1920

2021
type RawOption = {
@@ -26,7 +27,7 @@ const optionGenerator = (options: RawOption[]): IFieldDropdownOption[] =>
2627
options?.map(({ name, value }) => ({ label: name as string, value: value }))
2728

2829
export default function InputCreator(props: InputCreatorProps): JSX.Element {
29-
const { schema, namePrefix, stepId, disabled } = props
30+
const { schema, namePrefix, stepId, disabled, parentType } = props
3031

3132
const {
3233
key: name,
@@ -114,6 +115,10 @@ export default function InputCreator(props: InputCreatorProps): JSX.Element {
114115
description={description}
115116
disabled={disabled}
116117
placeholder={placeholder}
118+
isSingleLine={parentType === 'multicol'}
119+
customStyle={
120+
parentType === 'multicol' ? { flex: 1, minWidth: 0 } : {}
121+
}
117122
variablesEnabled
118123
/>
119124
)
@@ -133,6 +138,7 @@ export default function InputCreator(props: InputCreatorProps): JSX.Element {
133138
description={description}
134139
clickToCopy={clickToCopy}
135140
autoComplete={schema.autoComplete}
141+
customStyle={parentType === 'multicol' ? { flex: 0.5 } : {}}
136142
/>
137143
)
138144
}
@@ -149,14 +155,17 @@ export default function InputCreator(props: InputCreatorProps): JSX.Element {
149155
)
150156
}
151157

152-
if (type === 'multirow') {
158+
if (type === 'multirow' || type === 'multirow-multicol') {
153159
return (
154160
<MultiRow
155161
name={computedName}
156162
label={label}
157163
description={description}
158164
subFields={schema.subFields}
159165
required={required}
166+
addRowButtonText={schema.addRowButtonText}
167+
showDivider={type !== 'multirow-multicol'}
168+
type={type}
160169
// These are InputCreatorProps which MultiRow will forward.
161170
stepId={stepId}
162171
disabled={disabled}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { IField } from '@plumber/types'
2+
3+
import { BiTrash } from 'react-icons/bi'
4+
import { Flex } from '@chakra-ui/react'
5+
import { IconButton } from '@opengovsg/design-system-react'
6+
7+
import InputCreator from '@/components/InputCreator'
8+
9+
type MultiColProps = {
10+
name: string
11+
subFields: IField[]
12+
canRemoveRow?: boolean
13+
isEditorReadOnly?: boolean
14+
remove?: (index?: number | number[]) => void
15+
index?: number
16+
}
17+
18+
export default function MultiCol(props: MultiColProps) {
19+
const {
20+
name,
21+
subFields,
22+
canRemoveRow,
23+
isEditorReadOnly,
24+
remove,
25+
index,
26+
...forwardedInputCreatorProps
27+
} = props
28+
return (
29+
<Flex flexDir="row" gap={2}>
30+
{subFields.map((subF) => {
31+
return (
32+
<InputCreator
33+
key={`${name}.${subF.key}`}
34+
schema={subF}
35+
namePrefix={name}
36+
parentType="multicol"
37+
{...forwardedInputCreatorProps}
38+
/>
39+
)
40+
})}
41+
{canRemoveRow && (
42+
<IconButton
43+
variant="clear"
44+
aria-label="Remove"
45+
icon={<BiTrash />}
46+
isDisabled={isEditorReadOnly}
47+
onClick={() => remove?.(index)}
48+
/>
49+
)}
50+
</Flex>
51+
)
52+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Divider, Flex, Text } from '@chakra-ui/react'
2+
3+
export default function RowDivider() {
4+
return (
5+
<Flex alignItems="center">
6+
<Divider />
7+
<Text textStyle="subhead-3" mx={2.5}>
8+
And
9+
</Text>
10+
<Divider />
11+
</Flex>
12+
)
13+
}

0 commit comments

Comments
 (0)