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'}
                 />
               );
             },