Skip to content

Commit 359ac99

Browse files
committed
add custom header with variables
1 parent 76f39e9 commit 359ac99

File tree

12 files changed

+317
-48
lines changed

12 files changed

+317
-48
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: 50 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]
@@ -23,6 +28,7 @@ const action: IRawAction = {
2328
required: true,
2429
description: `The HTTP method we'll use to perform the request.`,
2530
value: 'GET',
31+
showOptionValue: false,
2632
options: [
2733
{ label: 'DELETE', value: 'DELETE' },
2834
{ label: 'GET', value: 'GET' },
@@ -40,6 +46,35 @@ const action: IRawAction = {
4046
'Any URL with a querystring will be re-encoded properly. Plumber URLs (e.g. https://plumber.gov.sg/webhooks/...) are prohibited.',
4147
variables: true,
4248
},
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+
customButtonText: 'Add',
57+
showDivider: false,
58+
subFields: [
59+
{
60+
placeholder: 'Key',
61+
key: 'key',
62+
type: 'string' as const,
63+
required: true,
64+
variables: false,
65+
customStyle: { flex: 0.5 },
66+
},
67+
{
68+
placeholder: 'Value',
69+
key: 'value',
70+
type: 'string' as const,
71+
required: true,
72+
variables: true,
73+
isSingleLine: true,
74+
customStyle: { flex: 1, minWidth: 0 },
75+
},
76+
],
77+
},
4378
{
4479
label: 'Data',
4580
key: 'data',
@@ -56,11 +91,15 @@ const action: IRawAction = {
5691
const url = $.step.parameters.url as string
5792

5893
try {
94+
const parsedS = requestSchema.parse($.step.parameters)
95+
const { customHeaders } = parsedS
96+
5997
let response = await $.http.request({
6098
url,
6199
method,
62100
data,
63101
maxRedirects: 0,
102+
headers: customHeaders,
64103
// overwriting this to allow redirects to resolve
65104
validateStatus: (status) =>
66105
(status >= 200 && status < 300) ||
@@ -97,6 +136,16 @@ const action: IRawAction = {
97136

98137
$.setActionItem({ raw: { data: responseData } })
99138
} catch (err) {
139+
if (err instanceof ZodError) {
140+
const firstError = fromZodError(err).details[0]
141+
throw new StepError(
142+
`${firstError.message} under set up action`,
143+
GenericSolution.ReconfigureInvalidField,
144+
$.step.position,
145+
$.app.name,
146+
)
147+
}
148+
100149
if (err.message === RECURSIVE_WEBHOOK_ERROR_NAME) {
101150
throw new StepError(
102151
RECURSIVE_WEBHOOK_ERROR_NAME,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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: z.string().trim().min(1, 'Key empty').nullish(),
10+
value: z.string().trim().min(1, 'Value empty').nullish(),
11+
}),
12+
)
13+
.transform((params, context) => {
14+
const result = Object.create(null)
15+
const seenFields = new Set<string>()
16+
for (const { key, value } of params) {
17+
// no null fields or values are allowed
18+
if (!key) {
19+
context.addIssue({
20+
code: z.ZodIssueCode.custom,
21+
message: 'Key empty',
22+
})
23+
return z.NEVER
24+
}
25+
if (!value) {
26+
context.addIssue({
27+
code: z.ZodIssueCode.custom,
28+
message: 'Value empty',
29+
})
30+
return z.NEVER
31+
}
32+
// catch duplicate fields
33+
if (seenFields.has(key)) {
34+
context.addIssue({
35+
code: z.ZodIssueCode.custom,
36+
message: `${key} key is repeated`,
37+
fatal: true,
38+
})
39+
return z.NEVER
40+
}
41+
seenFields.add(key)
42+
43+
const cleanV = value.replaceAll(/\r?\n|\r/g, ' ')
44+
result[key] = sanitizeMarkdown(cleanV)
45+
}
46+
return result
47+
})
48+
.nullish(),
49+
})

packages/backend/src/graphql/schema.graphql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,10 @@ type ActionSubstepArgument {
188188
value: JSONObject
189189
source: ActionSubstepArgumentSource
190190
hiddenIf: FieldVisibilityCondition
191+
customButtonText: String
192+
customStyle: JSONObject
193+
showDivider: Boolean
194+
isSingleLine: Boolean
191195

192196
# Only for dropdown
193197
addNewOption: DropdownAddNewOption

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ export default function InputCreator(props: InputCreatorProps): JSX.Element {
114114
description={description}
115115
disabled={disabled}
116116
placeholder={placeholder}
117+
isSingleLine={schema.isSingleLine}
118+
customStyle={schema.customStyle}
117119
variablesEnabled
118120
/>
119121
)
@@ -133,6 +135,7 @@ export default function InputCreator(props: InputCreatorProps): JSX.Element {
133135
description={description}
134136
clickToCopy={clickToCopy}
135137
autoComplete={schema.autoComplete}
138+
customStyle={schema.customStyle}
136139
/>
137140
)
138141
}
@@ -149,14 +152,17 @@ export default function InputCreator(props: InputCreatorProps): JSX.Element {
149152
)
150153
}
151154

152-
if (type === 'multirow') {
155+
if (type === 'multirow' || type === 'multirow-multicol') {
153156
return (
154157
<MultiRow
155158
name={computedName}
156159
label={label}
157160
description={description}
158161
subFields={schema.subFields}
159162
required={required}
163+
customButtonText={schema.customButtonText}
164+
showDivider={schema.showDivider}
165+
type={type}
160166
// These are InputCreatorProps which MultiRow will forward.
161167
stepId={stepId}
162168
disabled={disabled}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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+
{...forwardedInputCreatorProps}
37+
/>
38+
)
39+
})}
40+
{canRemoveRow && (
41+
<IconButton
42+
variant="clear"
43+
aria-label="Remove"
44+
icon={<BiTrash />}
45+
isDisabled={isEditorReadOnly}
46+
onClick={() => remove && remove(index)}
47+
/>
48+
)}
49+
</Flex>
50+
)
51+
}
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)