From edc77c317ea869d744fbb31208dc635bdc9addb3 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin <aleh.zasypkin@gmail.com> Date: Mon, 9 Oct 2023 04:08:28 +0300 Subject: [PATCH] feat(webhooks): add support for "subdomain"-based webhook URLs --- src/app_container/app_container.tsx | 1 + src/model/index.ts | 2 +- src/model/ui_state.ts | 3 + .../auto_responder_requests_table.tsx | 6 +- .../workspace/utils/webhooks/responder.ts | 8 +- .../webhooks/save_auto_responder_flyout.tsx | 126 +++++++++++------- .../utils/webhooks/webhooks_responders.tsx | 64 +++++---- 7 files changed, 126 insertions(+), 84 deletions(-) diff --git a/src/app_container/app_container.tsx b/src/app_container/app_container.tsx index e2fbe1c..d2f7b32 100644 --- a/src/app_container/app_container.tsx +++ b/src/app_container/app_container.tsx @@ -27,6 +27,7 @@ export function AppContainer() { status: { level: 'available' }, license: { maxEndpoints: Infinity }, utils: [], + webhookUrlType: 'path', }); const refreshUiState = useCallback(() => { if (isUiStateRefreshInProgress) { diff --git a/src/model/index.ts b/src/model/index.ts index 4fb9009..baefb80 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -1,5 +1,5 @@ export type { ServerStatus } from './server_status'; -export type { UiState } from './ui_state'; +export type { UiState, WebhookUrlType } from './ui_state'; export type { AsyncData } from './async_data'; export { isAbortError } from './errors'; export { getUserData, setUserData } from './user'; diff --git a/src/model/ui_state.ts b/src/model/ui_state.ts index 0730632..aabb62e 100644 --- a/src/model/ui_state.ts +++ b/src/model/ui_state.ts @@ -14,6 +14,8 @@ export interface License { maxEndpoints: number; } +export type WebhookUrlType = 'path' | 'subdomain'; + export interface UiState { synced: boolean; status: ServerStatus; @@ -22,4 +24,5 @@ export interface UiState { userShare?: UserShare; settings?: UserSettings; utils: Util[]; + webhookUrlType: WebhookUrlType; } diff --git a/src/pages/workspace/utils/webhooks/auto_responder_requests_table.tsx b/src/pages/workspace/utils/webhooks/auto_responder_requests_table.tsx index 6a9a9e9..812226c 100644 --- a/src/pages/workspace/utils/webhooks/auto_responder_requests_table.tsx +++ b/src/pages/workspace/utils/webhooks/auto_responder_requests_table.tsx @@ -60,7 +60,7 @@ export function AutoResponderRequestsTable({ responder }: AutoResponderRequestsT .post<GetAutoRespondersRequestsResponse>(getApiUrl('/api/utils/action'), { action: { type: 'webhooks', - value: { type: 'getAutoRespondersRequests', value: { autoResponderName: responder.name } }, + value: { type: 'getAutoRespondersRequests', value: { responderPath: responder.path } }, }, }) .then( @@ -175,7 +175,7 @@ export function AutoResponderRequestsTable({ responder }: AutoResponderRequestsT ); if (requests.status === 'pending') { - return <PageLoadingState title={`Loading requests for "${responder.name}"…`} />; + return <PageLoadingState title={`Loading requests for "${responder.path}"…`} />; } if (requests.status === 'failed') { @@ -184,7 +184,7 @@ export function AutoResponderRequestsTable({ responder }: AutoResponderRequestsT title="Cannot load requests" content={ <p> - Cannot load recorded requests for <strong>{responder.name}</strong> auto responder. + Cannot load recorded requests for <strong>{responder.path}</strong> auto responder. </p> } /> diff --git a/src/pages/workspace/utils/webhooks/responder.ts b/src/pages/workspace/utils/webhooks/responder.ts index 97eb5ba..6c8aba4 100644 --- a/src/pages/workspace/utils/webhooks/responder.ts +++ b/src/pages/workspace/utils/webhooks/responder.ts @@ -3,7 +3,7 @@ export const RESPONDERS_USER_DATA_NAMESPACE = 'autoResponders'; export type SerializedResponders = Record<string, SerializedResponder>; export interface SerializedResponder { - n: string; + p: string; m: string; t: number; s: number; @@ -13,7 +13,7 @@ export interface SerializedResponder { } export interface Responder { - name: string; + path: string; method: string; trackingRequests: number; statusCode: number; @@ -24,7 +24,7 @@ export interface Responder { export function deserializeResponder(serializedResponder: SerializedResponder): Responder { const responder: Responder = { - name: serializedResponder.n, + path: serializedResponder.p, method: serializedResponder.m, statusCode: serializedResponder.s, trackingRequests: serializedResponder.t, @@ -59,7 +59,7 @@ export function deserializeResponders(serializedResponders: SerializedResponders export function serializeResponder(responder: Responder): SerializedResponder { const serializedResponder: SerializedResponder = { - n: responder.name, + p: responder.path, m: responder.method, s: responder.statusCode, h: responder.headers, diff --git a/src/pages/workspace/utils/webhooks/save_auto_responder_flyout.tsx b/src/pages/workspace/utils/webhooks/save_auto_responder_flyout.tsx index 15e163a..471abcf 100644 --- a/src/pages/workspace/utils/webhooks/save_auto_responder_flyout.tsx +++ b/src/pages/workspace/utils/webhooks/save_auto_responder_flyout.tsx @@ -11,6 +11,7 @@ import { EuiSelect, EuiTextArea, } from '@elastic/eui'; +import axios from 'axios'; import type { Responder, SerializedResponders } from './responder'; import { @@ -20,7 +21,7 @@ import { serializeResponder, } from './responder'; import type { AsyncData } from '../../../../model'; -import { setUserData } from '../../../../model'; +import { getApiUrl, getUserData } from '../../../../model'; import { EditorFlyout } from '../../components/editor_flyout'; import { useWorkspaceContext } from '../../hooks'; @@ -43,10 +44,11 @@ export function SaveAutoResponderFlyout({ onClose, autoResponder }: SaveAutoResp [], ); - const [name, setName] = useState<string>(autoResponder?.name ?? ''); - const onNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => { - setName(e.target.value); + const [path, setPath] = useState<string>(autoResponder?.path ?? ''); + const onPathChange = useCallback((e: ChangeEvent<HTMLInputElement>) => { + setPath(e.target.value); }, []); + const isPathValid = path.startsWith('/') && (path.length === 1 || !path.endsWith('/')); const [trackingRequests, setTrackingRequests] = useState<number>(autoResponder?.trackingRequests ?? 0); const onTrackingRequestsChange = useCallback((e: ChangeEvent<HTMLInputElement>) => { @@ -107,57 +109,69 @@ export function SaveAutoResponderFlyout({ onClose, autoResponder }: SaveAutoResp } setUpdatingStatus({ status: 'pending' }); - setUserData<SerializedResponders>(RESPONDERS_USER_DATA_NAMESPACE, { - [name]: serializeResponder({ - name: name, - method, - trackingRequests, - statusCode, - body: body && method !== serializeHttpMethod('HEAD') ? body : undefined, - headers: - headers.length > 0 - ? headers.map((headerValue) => { - const separatorIndex = headerValue.label.indexOf(':'); - return [ - headerValue.label.substring(0, separatorIndex).trim(), - headerValue.label.substring(separatorIndex + 1).trim(), - ] as [string, string]; - }) - : undefined, - delay, - }), - }).then( - (serializedResponders) => { - setUpdatingStatus({ status: 'succeeded', data: undefined }); - - addToast({ - id: `success-update-responder-${name}`, - iconType: 'check', - color: 'success', - title: `Successfully saved "${name}" responder`, - }); - - onClose(deserializeResponders(serializedResponders)); - }, - (err: Error) => { - setUpdatingStatus({ status: 'failed', error: err?.message ?? err }); - - addToast({ - id: `failed-update-responder-${name}`, - iconType: 'warning', - color: 'danger', - title: `Unable to save "${name}" responder, please try again later`, - }); - }, - ); - }, [method, name, trackingRequests, statusCode, body, headers, delay, autoResponder, updatingStatus]); + + axios + .post(getApiUrl('/api/utils/action'), { + action: { + type: 'webhooks', + value: { + type: 'saveAutoResponder', + value: { + responder: serializeResponder({ + path, + method, + trackingRequests, + statusCode, + body: body && method !== serializeHttpMethod('HEAD') ? body : undefined, + headers: + headers.length > 0 + ? headers.map((headerValue) => { + const separatorIndex = headerValue.label.indexOf(':'); + return [ + headerValue.label.substring(0, separatorIndex).trim(), + headerValue.label.substring(separatorIndex + 1).trim(), + ] as [string, string]; + }) + : undefined, + delay, + }), + }, + }, + }, + }) + .then(() => getUserData<SerializedResponders>(RESPONDERS_USER_DATA_NAMESPACE)) + .then( + (items) => { + setUpdatingStatus({ status: 'succeeded', data: undefined }); + + addToast({ + id: `success-update-responder-${path}`, + iconType: 'check', + color: 'success', + title: `Successfully saved "${path}" responder`, + }); + + onClose(deserializeResponders(items)); + }, + (err: Error) => { + setUpdatingStatus({ status: 'failed', error: err?.message ?? err }); + + addToast({ + id: `failed-update-responder-${path}`, + iconType: 'warning', + color: 'danger', + title: `Unable to save "${path}" responder, please try again later`, + }); + }, + ); + }, [method, path, trackingRequests, statusCode, body, headers, delay, autoResponder, updatingStatus]); return ( <EditorFlyout title={`${autoResponder ? 'Edit' : 'Add'} responder`} onClose={() => onClose()} onSave={onAddAutoResponder} - canSave={!areHeadersInvalid && name.trim().length > 0 && trackingRequests >= 0 && trackingRequests <= 100} + canSave={!areHeadersInvalid && isPathValid && trackingRequests >= 0 && trackingRequests <= 100} saveInProgress={updatingStatus?.status === 'pending'} > <EuiForm id="update-form" component="form" fullWidth> @@ -165,8 +179,18 @@ export function SaveAutoResponderFlyout({ onClose, autoResponder }: SaveAutoResp title={<h3>Request</h3>} description={'Properties of the responder related to the HTTP requests it handles'} > - <EuiFormRow label="Name" helpText="The last segment of the responder HTTP path" isDisabled={!!autoResponder}> - <EuiFieldText value={name} required type={'text'} onChange={onNameChange} /> + <EuiFormRow + label="Path" + helpText="The responder path should start with a '/', and should not end with a '/'" + isDisabled={!!autoResponder} + > + <EuiFieldText + value={path} + isInvalid={path.length > 0 && !isPathValid} + required + type={'text'} + onChange={onPathChange} + /> </EuiFormRow> <EuiFormRow label="Method" helpText="Responder will only respond to requests with the specified HTTP method"> <EuiSelect options={httpMethods} value={method} onChange={onMethodChange} /> diff --git a/src/pages/workspace/utils/webhooks/webhooks_responders.tsx b/src/pages/workspace/utils/webhooks/webhooks_responders.tsx index e09e426..49a444a 100644 --- a/src/pages/workspace/utils/webhooks/webhooks_responders.tsx +++ b/src/pages/workspace/utils/webhooks/webhooks_responders.tsx @@ -18,24 +18,31 @@ import { EuiText, EuiToolTip, } from '@elastic/eui'; +import axios from 'axios'; import { AutoResponderRequestsTable } from './auto_responder_requests_table'; import type { Responder, SerializedResponders } from './responder'; import { deserializeHttpMethod, deserializeResponders, RESPONDERS_USER_DATA_NAMESPACE } from './responder'; import { SaveAutoResponderFlyout } from './save_auto_responder_flyout'; import { PageLoadingState } from '../../../../components'; -import type { User } from '../../../../model'; -import { getUserData, setUserData } from '../../../../model'; +import { getApiUrl, getUserData } from '../../../../model'; import { useWorkspaceContext } from '../../hooks'; export default function WebhooksResponders() { const { uiState, setTitleActions } = useWorkspaceContext(); - const getResponderUrl = useCallback((autoResponder: Responder, user: User) => { - return `${location.origin}/api/webhooks/ar/${encodeURIComponent(user.handle)}/${encodeURIComponent( - autoResponder.name, - )}`; - }, []); + const getResponderUrl = useCallback( + (autoResponder: Responder) => { + if (!uiState.user) { + return '-'; + } + + return uiState.webhookUrlType === 'path' + ? `${location.origin}/api/webhooks/${uiState.user.handle}${autoResponder.path}` + : `${location.protocol}//${uiState.user.handle}.webhooks.${location.host}${autoResponder.path}`; + }, + [uiState], + ); const [autoResponders, setAutoResponders] = useState<Responder[] | null>(null); const updateResponders = useCallback((updatedResponders: Responder[]) => { @@ -101,18 +108,25 @@ export default function WebhooksResponders() { const [responderToRemove, setResponderToRemove] = useState<Responder | null>(null); const removeConfirmModal = responderToRemove ? ( <EuiConfirmModal - title={`Remove "${responderToRemove.name}"?`} + title={`Remove "${responderToRemove.path}"?`} onCancel={() => setResponderToRemove(null)} onConfirm={() => { setResponderToRemove(null); - setUserData<SerializedResponders>(RESPONDERS_USER_DATA_NAMESPACE, { - [responderToRemove.name]: null, - }).then( - (serializedResponders) => updateResponders(deserializeResponders(serializedResponders)), - (err: Error) => { - console.error(`Failed to remove auto responder: ${err?.message ?? err}`); - }, - ); + + axios + .post(getApiUrl('/api/utils/action'), { + action: { + type: 'webhooks', + value: { type: 'removeAutoResponder', value: { responderPath: responderToRemove.path } }, + }, + }) + .then(() => getUserData<SerializedResponders>(RESPONDERS_USER_DATA_NAMESPACE)) + .then( + (items) => updateResponders(deserializeResponders(items)), + (err: Error) => { + console.error(`Failed to remove auto responder: ${err?.message ?? err}`); + }, + ); }} cancelButtonText="Cancel" confirmButtonText="Remove" @@ -129,7 +143,7 @@ export default function WebhooksResponders() { pageSizeOptions: [10, 15, 25, 50, 100], totalItemCount: 0, }); - const [sorting, setSorting] = useState<{ sort: PropertySort }>({ sort: { field: 'name', direction: 'asc' } }); + const [sorting, setSorting] = useState<{ sort: PropertySort }>({ sort: { field: 'path', direction: 'asc' } }); const onTableChange = useCallback( ({ page, sort }: Criteria<Responder>) => { setPagination({ @@ -147,10 +161,10 @@ export default function WebhooksResponders() { const toggleResponderRequests = (responder: Responder) => { const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; - if (itemIdToExpandedRowMapValues[responder.name]) { - delete itemIdToExpandedRowMapValues[responder.name]; + if (itemIdToExpandedRowMapValues[responder.path]) { + delete itemIdToExpandedRowMapValues[responder.path]; } else { - itemIdToExpandedRowMapValues[responder.name] = <AutoResponderRequestsTable responder={responder} />; + itemIdToExpandedRowMapValues[responder.path] = <AutoResponderRequestsTable responder={responder} />; } setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); }; @@ -195,7 +209,7 @@ export default function WebhooksResponders() { sorting={sorting} onTableChange={onTableChange} items={autoResponders} - itemId={(autoResponder) => autoResponder.name} + itemId={(autoResponder) => autoResponder.path} isExpandable={true} itemIdToExpandedRowMap={itemIdToExpandedRowMap} tableLayout={'auto'} @@ -251,10 +265,10 @@ export default function WebhooksResponders() { </span> </EuiToolTip> ), - field: 'name', + field: 'path', sortable: true, render: (_, autoResponder: Responder) => { - const url = uiState.user ? getResponderUrl(autoResponder, uiState.user) : undefined; + const url = getResponderUrl(autoResponder); return url ? ( <EuiLink href={url} target="_blank"> {url} @@ -299,8 +313,8 @@ export default function WebhooksResponders() { return ( <EuiButtonIcon onClick={() => toggleResponderRequests(item)} - aria-label={itemIdToExpandedRowMapValues[item.name] ? 'Hide requests' : 'Show requests'} - iconType={itemIdToExpandedRowMapValues[item.name] ? 'arrowDown' : 'arrowRight'} + aria-label={itemIdToExpandedRowMapValues[item.path] ? 'Hide requests' : 'Show requests'} + iconType={itemIdToExpandedRowMapValues[item.path] ? 'arrowDown' : 'arrowRight'} /> ); },