diff --git a/.changeset/modern-laws-type.md b/.changeset/modern-laws-type.md
new file mode 100644
index 0000000..3501e2f
--- /dev/null
+++ b/.changeset/modern-laws-type.md
@@ -0,0 +1,5 @@
+---
+'@envyjs/webui': minor
+---
+
+Added ability to copy request details as a curl command
diff --git a/packages/webui/package.json b/packages/webui/package.json
index 452e308..35d490c 100644
--- a/packages/webui/package.json
+++ b/packages/webui/package.json
@@ -43,9 +43,11 @@
"@envyjs/client": "0.2.2",
"@envyjs/core": "0.3.2",
"chalk": "^4.1.2",
+ "curl-generator": "^0.3.1",
"dayjs": "^1.11.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-hot-toast": "^2.4.1",
"react-icons": "^4.11.0",
"react-json-view": "^1.21.3",
"serve-handler": "^6.1.5",
diff --git a/packages/webui/src/components/ui/CopyAsCurlButton.test.tsx b/packages/webui/src/components/ui/CopyAsCurlButton.test.tsx
new file mode 100644
index 0000000..4281e28
--- /dev/null
+++ b/packages/webui/src/components/ui/CopyAsCurlButton.test.tsx
@@ -0,0 +1,140 @@
+import { act, cleanup, render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { toast } from 'react-hot-toast';
+
+import { Trace } from '@/types';
+
+import CopyAsCurlButton from './CopyAsCurlButton';
+
+jest.mock('react-hot-toast');
+
+describe('CopyAsCurlButton', () => {
+ const originalClipboard = { ...global.navigator.clipboard };
+
+ let trace: Trace;
+ let writeTextFn: jest.Mock;
+ let toastFn: jest.Mock;
+
+ beforeEach(() => {
+ trace = {
+ http: {
+ method: 'POST',
+ url: 'https://www.foo.com/bar/baz',
+ },
+ } as any;
+
+ writeTextFn = jest.fn();
+ Object.assign(window.navigator, {
+ clipboard: { writeText: writeTextFn },
+ });
+
+ toastFn = jest.fn();
+ jest.mocked(toast.success).mockImplementation(toastFn);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+
+ Object.assign(window.navigator, {
+ clipboard: originalClipboard,
+ });
+
+ cleanup();
+ });
+
+ it('should render without error', () => {
+ render();
+ });
+
+ it('should render a button with the expected label', () => {
+ const { getByRole } = render();
+
+ const button = getByRole('button');
+ expect(button).toHaveTextContent('Copy as cURL snippet');
+ });
+
+ describe('cURL snippet', () => {
+ it('should copy cURL snippet to clipboard when clicked', async () => {
+ const { getByRole } = render();
+
+ await act(async () => {
+ const button = getByRole('button');
+ expect(button).toHaveTextContent('Copy as cURL snippet');
+
+ await userEvent.click(button);
+ });
+
+ const expectedCurl = `curl https://www.foo.com/bar/baz \\
+ -X POST`;
+
+ expect(writeTextFn).toHaveBeenCalledWith(expectedCurl);
+ });
+
+ it('should include headers in snippet if headers are present', async () => {
+ const traceWithHeaders = {
+ http: {
+ ...trace.http,
+ requestHeaders: {
+ foo: 'bar',
+ baz: 'qux',
+ },
+ },
+ } as any;
+
+ const { getByRole } = render();
+
+ await act(async () => {
+ const button = getByRole('button');
+ expect(button).toHaveTextContent('Copy as cURL snippet');
+
+ await userEvent.click(button);
+ });
+
+ const expectedCurl = `curl https://www.foo.com/bar/baz \\
+ -X POST \\
+-H "foo: bar" \\
+-H "baz: qux"`;
+
+ expect(writeTextFn).toHaveBeenCalledWith(expectedCurl);
+ });
+
+ it('should include body in snippet if body is present', async () => {
+ const traceWithHeaders = {
+ http: {
+ ...trace.http,
+ requestBody: JSON.stringify({ foo: 'bar', baz: 'qux' }),
+ },
+ } as any;
+
+ const { getByRole } = render();
+
+ await act(async () => {
+ const button = getByRole('button');
+ expect(button).toHaveTextContent('Copy as cURL snippet');
+
+ await userEvent.click(button);
+ });
+
+ const expectedCurl = `curl https://www.foo.com/bar/baz \\
+ -X POST \\
+-d "{\\"foo\\":\\"bar\\",\\"baz\\":\\"qux\\"}"`;
+
+ expect(writeTextFn).toHaveBeenCalledWith(expectedCurl);
+ });
+ });
+
+ it('should display toast notification when copied', async () => {
+ const { getByRole } = render();
+
+ await act(async () => {
+ const button = getByRole('button');
+ expect(button).toHaveTextContent('Copy as cURL snippet');
+
+ await userEvent.click(button);
+ });
+
+ expect(toastFn).toHaveBeenCalledWith('cURL snippet written to clipboard', {
+ position: 'top-right',
+ });
+ });
+});
diff --git a/packages/webui/src/components/ui/CopyAsCurlButton.tsx b/packages/webui/src/components/ui/CopyAsCurlButton.tsx
new file mode 100644
index 0000000..c1895a3
--- /dev/null
+++ b/packages/webui/src/components/ui/CopyAsCurlButton.tsx
@@ -0,0 +1,37 @@
+import { CurlGenerator } from 'curl-generator';
+import { toast } from 'react-hot-toast';
+import { HiOutlineClipboardCopy } from 'react-icons/hi';
+
+import { IconButton } from '@/components';
+import { Trace } from '@/types';
+import { cloneHeaders, flatMapHeaders, safeParseJson } from '@/utils';
+
+type CopyAsCurlButtonProps = {
+ trace: Trace;
+};
+
+export default function CopyAsCurlButton({ trace, ...props }: CopyAsCurlButtonProps) {
+ async function copyAsCurl() {
+ const headers = flatMapHeaders(cloneHeaders(trace.http!.requestHeaders, true));
+ const body = safeParseJson(trace.http!.requestBody) ?? null;
+
+ const curlSnippet = CurlGenerator({
+ method: trace.http!.method as any,
+ url: trace.http!.url,
+ headers,
+ body,
+ });
+
+ await navigator.clipboard.writeText(curlSnippet);
+
+ toast.success('cURL snippet written to clipboard', {
+ position: 'top-right',
+ });
+ }
+
+ return (
+ await copyAsCurl()}>
+ Copy as cURL snippet
+
+ );
+}
diff --git a/packages/webui/src/components/ui/MainDisplay.test.tsx b/packages/webui/src/components/ui/MainDisplay.test.tsx
index ea83143..569eeeb 100644
--- a/packages/webui/src/components/ui/MainDisplay.test.tsx
+++ b/packages/webui/src/components/ui/MainDisplay.test.tsx
@@ -4,6 +4,11 @@ import { setUseApplicationData } from '@/testing/mockUseApplication';
import MainDisplay from './MainDisplay';
+jest.mock('react-hot-toast', () => ({
+ Toaster: function MockToaster() {
+ return
Mock Toaster component
;
+ },
+}));
jest.mock(
'@/components/ui/TraceDetail',
() =>
@@ -52,4 +57,14 @@ describe('MainDisplay', () => {
const connectionStatus = queryByTestId('mock-trace-detail');
expect(connectionStatus).toBeVisible();
});
+
+ it('should render Toaster component ', () => {
+ setUseApplicationData({ selectedTraceId: '1' });
+
+ const { queryByTestId } = render();
+
+ const toaster = queryByTestId('mock-toaster');
+ expect(toaster).toBeVisible();
+ expect(toaster).toHaveTextContent('Mock Toaster component');
+ });
});
diff --git a/packages/webui/src/components/ui/MainDisplay.tsx b/packages/webui/src/components/ui/MainDisplay.tsx
index 1bb0785..97277f9 100644
--- a/packages/webui/src/components/ui/MainDisplay.tsx
+++ b/packages/webui/src/components/ui/MainDisplay.tsx
@@ -1,3 +1,5 @@
+import { Toaster } from 'react-hot-toast';
+
import TraceDetail from '@/components/ui/TraceDetail';
import TraceList from '@/components/ui/TraceList';
import useApplication from '@/hooks/useApplication';
@@ -9,6 +11,7 @@ export default function MainDisplay() {
{traceId && }
+
);
}
diff --git a/packages/webui/src/components/ui/TraceDetail.test.tsx b/packages/webui/src/components/ui/TraceDetail.test.tsx
index 04c5734..37c85d3 100644
--- a/packages/webui/src/components/ui/TraceDetail.test.tsx
+++ b/packages/webui/src/components/ui/TraceDetail.test.tsx
@@ -41,6 +41,13 @@ jest.mock('@/components', () => ({
},
}));
+jest.mock(
+ '@/components/ui/CopyAsCurlButton',
+ () =>
+ function CopyAsCurlButton({ trace, ...props }: any) {
+ return Mock CopyAsCurlButton component: {trace.id}
;
+ },
+);
jest.mock(
'@/components/ui/QueryParams',
() =>
@@ -218,6 +225,30 @@ describe('TraceDetail', () => {
expect(service).toHaveTextContent('Sent from my-service');
});
+ it('should display button to copy as cURL snippet', () => {
+ getSelectedTraceFn.mockReturnValue({
+ ...mockTrace,
+ serviceName: 'my-service',
+ http: {
+ ...mockTrace.http,
+ method: 'PATCH',
+ statusCode: 204,
+ statusMessage: 'No Content',
+ responseHeaders: {},
+ responseBody: '',
+ duration: 1234,
+ },
+ });
+
+ const { getByTestId } = render();
+
+ const summary = getByTestId('summary');
+ const copyAsCurl = within(summary).getByTestId('copy-as-curl');
+
+ expect(copyAsCurl).toBeVisible();
+ expect(copyAsCurl).toHaveTextContent('Mock CopyAsCurlButton component: 1');
+ });
+
it.each([
{ statusCode: 500, color: 'purple-500' },
{ statusCode: 400, color: 'red-500' },
diff --git a/packages/webui/src/components/ui/TraceDetail.tsx b/packages/webui/src/components/ui/TraceDetail.tsx
index 8442a18..ce6367e 100644
--- a/packages/webui/src/components/ui/TraceDetail.tsx
+++ b/packages/webui/src/components/ui/TraceDetail.tsx
@@ -11,13 +11,14 @@ import {
} from '@/systems';
import { getHeader, numberFormat, pathAndQuery } from '@/utils';
+import CopyAsCurlButton from './CopyAsCurlButton';
import QueryParams from './QueryParams';
import RequestHeaders from './RequestHeaders';
import ResponseHeaders from './ResponseHeaders';
import TimingsDiagram from './TimingsDiagram';
type CodeDisplayProps = {
- contentType: string | null;
+ contentType: string | string[] | null;
children: any;
};
type DetailProps = React.HTMLAttributes;
@@ -25,8 +26,8 @@ type DetailProps = React.HTMLAttributes;
function CodeDisplay({ contentType, children, ...props }: CodeDisplayProps) {
if (!children) return null;
- const isJson =
- contentType?.includes('application/json') || contentType?.includes('application/graphql-response+json');
+ const type = Array.isArray(contentType) ? contentType[0] : contentType;
+ const isJson = type?.includes('application/json') || contentType?.includes('application/graphql-response+json');
const isXml = contentType?.includes('application/xml');
return (
@@ -122,8 +123,11 @@ export default function TraceDetail({ className }: DetailProps) {
{url}
-
- Sent from
{serviceName}
+
+
+ Sent from {serviceName}
+
+
diff --git a/packages/webui/src/utils/utils.test.ts b/packages/webui/src/utils/utils.test.ts
index 71036b7..7c414c6 100644
--- a/packages/webui/src/utils/utils.test.ts
+++ b/packages/webui/src/utils/utils.test.ts
@@ -2,7 +2,16 @@ import { twMerge } from 'tailwind-merge';
import { Trace } from '@/types';
-import { cloneHeaders, getHeader, numberFormat, pathAndQuery, prettyFormat, safeParseJson, tw } from './utils';
+import {
+ cloneHeaders,
+ flatMapHeaders,
+ getHeader,
+ numberFormat,
+ pathAndQuery,
+ prettyFormat,
+ safeParseJson,
+ tw,
+} from './utils';
jest.mock('tailwind-merge');
@@ -180,6 +189,54 @@ describe('utils', () => {
});
});
+ describe('flatMapHeaders', () => {
+ it('should return an empty object if no headers supplied', () => {
+ const result = flatMapHeaders(undefined);
+
+ expect(result).toEqual({});
+ });
+
+ it('should return identical headers in a new object', () => {
+ const originalHeaders = {
+ foo: 'bar',
+ baz: 'qux',
+ };
+
+ const result = flatMapHeaders(originalHeaders);
+
+ expect(result).toEqual(originalHeaders);
+ expect(result).not.toBe(originalHeaders);
+ });
+
+ it('should transform header array values to comma delimited strings', () => {
+ const originalHeaders = {
+ foo: ['bar', 'baz'],
+ bar: ['baz', 'qux'],
+ };
+
+ const result = flatMapHeaders(originalHeaders);
+
+ expect(result).toEqual({
+ foo: 'bar,baz',
+ bar: 'baz,qux',
+ });
+ });
+
+ it('should retain header string values', () => {
+ const originalHeaders = {
+ foo: 'bar',
+ baz: 'qux',
+ };
+
+ const result = flatMapHeaders(originalHeaders);
+
+ expect(result).toEqual({
+ foo: 'bar',
+ baz: 'qux',
+ });
+ });
+ });
+
describe('getHeader', () => {
const headers = {
Foo: 'BAR',
@@ -209,10 +266,10 @@ describe('utils', () => {
expect(result).toEqual('BAR');
});
- it('should return undefined if header not found', () => {
+ it('should return null if header not found', () => {
const result = getHeader(headers, 'banana');
- expect(result).toBeUndefined();
+ expect(result).toBeNull();
});
});
diff --git a/packages/webui/src/utils/utils.ts b/packages/webui/src/utils/utils.ts
index 5862ac0..7414e7e 100644
--- a/packages/webui/src/utils/utils.ts
+++ b/packages/webui/src/utils/utils.ts
@@ -3,7 +3,8 @@ import { twMerge } from 'tailwind-merge';
import { Trace } from '@/types';
-type Headers = HttpRequest['requestHeaders'] | HttpRequest['responseHeaders'] | undefined;
+type Headers = HttpRequest['requestHeaders'];
+type HeadersOrUndefined = Headers | undefined;
export function pathAndQuery(trace: Trace, decodeQs = false): [string, string] {
const [path, qs] = (trace.http?.path ?? '').split('?');
@@ -14,21 +15,32 @@ export function numberFormat(num: number): string {
return Intl.NumberFormat('en-US').format(num);
}
-export function cloneHeaders(headers: Headers, lowercase = true): Record {
+export function cloneHeaders(headers: HeadersOrUndefined, lowercase = true): Headers {
if (!headers) return {};
- return Object.entries(headers).reduce>((a, [k, v]) => {
+ return Object.entries(headers).reduce((a, [k, v]) => {
const newKey = lowercase ? k.toLowerCase() : k;
a[newKey] = v;
return a;
}, {});
}
-export function getHeader(headers: Headers, name: string): string | null {
+export function flatMapHeaders(headers: HeadersOrUndefined): Record {
+ if (!headers) return {};
+
+ return Object.entries(headers).reduce>((a, [k, v]) => {
+ if (!!v) {
+ a[k] = Array.isArray(v) ? v.join(',') : v;
+ }
+ return a;
+ }, {});
+}
+
+export function getHeader(headers: HeadersOrUndefined, name: string): string | string[] | null {
if (!headers) return null;
const allLowercaseHeaders = cloneHeaders(headers, true);
- return allLowercaseHeaders[name.toLowerCase()];
+ return allLowercaseHeaders[name.toLowerCase()] ?? null;
}
export function safeParseJson(data: string | null | undefined): T | null {
diff --git a/yarn.lock b/yarn.lock
index 64cd53b..de9fb1e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4120,6 +4120,13 @@ csv@^5.5.3:
csv-stringify "^5.6.5"
stream-transform "^2.1.3"
+curl-generator@^0.3.1:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/curl-generator/-/curl-generator-0.3.1.tgz#b426276261207be495ae3b91a31a64bf5b13cf21"
+ integrity sha512-AAbB3mMkn2l2b0gzLQVNRenJtxvq7iaEuCi5pXMelpGpUjy3Fpy8zQG/fbbUHK3Zl80bjlroVemlj5dkuvTEqg==
+ dependencies:
+ ms "^2.0.0"
+
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@@ -5537,6 +5544,11 @@ globby@^11.0.0, globby@^11.1.0:
merge2 "^1.4.1"
slash "^3.0.0"
+goober@^2.1.10:
+ version "2.1.13"
+ resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.13.tgz#e3c06d5578486212a76c9eba860cbc3232ff6d7c"
+ integrity sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==
+
gopd@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c"
@@ -7229,7 +7241,7 @@ ms@2.1.2:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
-ms@2.1.3, ms@^2.1.1:
+ms@2.1.3, ms@^2.0.0, ms@^2.1.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
@@ -8112,6 +8124,13 @@ react-error-overlay@6.0.9:
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a"
integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==
+react-hot-toast@^2.4.1:
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994"
+ integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==
+ dependencies:
+ goober "^2.1.10"
+
react-icons@^4.11.0:
version "4.11.0"
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.11.0.tgz#4b0e31c9bfc919608095cc429c4f1846f4d66c65"