Skip to content

Commit

Permalink
feat(webhooks): add support for "subdomain"-based webhook URLs
Browse files Browse the repository at this point in the history
  • Loading branch information
azasypkin committed Oct 9, 2023
1 parent 8192e7b commit edc77c3
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 84 deletions.
1 change: 1 addition & 0 deletions src/app_container/app_container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function AppContainer() {
status: { level: 'available' },
license: { maxEndpoints: Infinity },
utils: [],
webhookUrlType: 'path',
});
const refreshUiState = useCallback(() => {
if (isUiStateRefreshInProgress) {
Expand Down
2 changes: 1 addition & 1 deletion src/model/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 3 additions & 0 deletions src/model/ui_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface License {
maxEndpoints: number;
}

export type WebhookUrlType = 'path' | 'subdomain';

export interface UiState {
synced: boolean;
status: ServerStatus;
Expand All @@ -22,4 +24,5 @@ export interface UiState {
userShare?: UserShare;
settings?: UserSettings;
utils: Util[];
webhookUrlType: WebhookUrlType;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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') {
Expand All @@ -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>
}
/>
Expand Down
8 changes: 4 additions & 4 deletions src/pages/workspace/utils/webhooks/responder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,7 +13,7 @@ export interface SerializedResponder {
}

export interface Responder {
name: string;
path: string;
method: string;
trackingRequests: number;
statusCode: number;
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
126 changes: 75 additions & 51 deletions src/pages/workspace/utils/webhooks/save_auto_responder_flyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
EuiSelect,
EuiTextArea,
} from '@elastic/eui';
import axios from 'axios';

import type { Responder, SerializedResponders } from './responder';
import {
Expand All @@ -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';

Expand All @@ -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>) => {
Expand Down Expand Up @@ -107,66 +109,88 @@ 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>
<EuiDescribedFormGroup
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} />
Expand Down
64 changes: 39 additions & 25 deletions src/pages/workspace/utils/webhooks/webhooks_responders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) => {
Expand Down Expand Up @@ -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"
Expand All @@ -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({
Expand All @@ -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);
};
Expand Down Expand Up @@ -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'}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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'}
/>
);
},
Expand Down

0 comments on commit edc77c3

Please sign in to comment.