diff --git a/src/components/html-context-menu.tsx b/src/components/html-context-menu.tsx index a7807bb0..a016307f 100644 --- a/src/components/html-context-menu.tsx +++ b/src/components/html-context-menu.tsx @@ -32,7 +32,8 @@ export class HtmlContextMenu extends React.Component<{ setTimeout(() => { contextMenu.show({ id: 'menu', - event: menuState.event + event: menuState.event, + position: menuState.position }); }, 10); })); diff --git a/src/components/send/request-pane.tsx b/src/components/send/request-pane.tsx index a7bc14ff..ad4b3049 100644 --- a/src/components/send/request-pane.tsx +++ b/src/components/send/request-pane.tsx @@ -7,13 +7,23 @@ import * as HarFormat from 'har-format'; import { RawHeaders } from '../../types'; +import { AccountStore } from '../../model/account/account-store'; import { RulesStore } from '../../model/rules/rules-store'; import { UiStore } from '../../model/ui/ui-store'; import { RequestInput } from '../../model/send/send-request-model'; import { EditableContentType } from '../../model/events/content-types'; +import { ContextMenuItem } from '../../model/ui/context-menu'; +import { generateCodeSnippetFromRequestInput } from '../../model/ui/export'; +import { + getCodeSnippetFormatKey, + getCodeSnippetFormatName, + getCodeSnippetOptionFromKey, + snippetExportOptions, + SnippetOption +} from '../../model/ui/snippet-formats'; import { ContainerSizedEditor } from '../editor/base-editor'; -import { useHotkeys } from '../../util/ui'; +import { useHotkeys, copyToClipboard } from '../../util/ui'; import { SendCardContainer } from './send-card-section'; import { SendRequestLine } from './send-request-line'; @@ -49,10 +59,12 @@ const RequestPaneKeyboardShortcuts = (props: { @inject('rulesStore') @inject('uiStore') +@inject('accountStore') @observer export class RequestPane extends React.Component<{ rulesStore?: RulesStore, uiStore?: UiStore, + accountStore?: AccountStore, editorNode: portals.HtmlPortalNode, @@ -106,6 +118,7 @@ export class RequestPane extends React.Component<{ isSending={isSending} sendRequest={sendRequest} updateFromHar={this.props.updateFromHar} + showCopyAsSnippetMenu={this.showCopyAsSnippetMenu} /> { + const { requestInput } = this.props; + + try { + const snippet = generateCodeSnippetFromRequestInput(requestInput, snippetOption); + await copyToClipboard(snippet); + } catch (e: any) { + console.log(e); + alert(`Could not copy this request as a code snippet:\n\n${e.message || e}`); + } + }; + + private showCopyAsSnippetMenu = (event: React.MouseEvent) => { + const uiStore = this.props.uiStore!; + const isPaidUser = this.props.accountStore!.user.isPaidUser(); + + const preferredFormat = uiStore.exportSnippetFormat + ? getCodeSnippetOptionFromKey(uiStore.exportSnippetFormat) + : undefined; + + const menuItems: Array> = [ + ...(!isPaidUser ? [ + { type: 'option', label: 'With Pro:', enabled: false, callback: () => {} } + ] as const : []), + // If you have a preferred default format, we show that option at the top level: + ...(preferredFormat && isPaidUser ? [{ + type: 'option' as const, + label: `Copy as ${getCodeSnippetFormatName(preferredFormat)} Snippet`, + callback: () => this.copyRequestAsSnippet(preferredFormat) + }] : []), + { + type: 'submenu', + enabled: isPaidUser, + label: `Copy as Code Snippet`, + items: Object.keys(snippetExportOptions).map((snippetGroupName) => ({ + type: 'submenu' as const, + label: snippetGroupName, + items: snippetExportOptions[snippetGroupName].map((snippetOption) => ({ + type: 'option' as const, + label: getCodeSnippetFormatName(snippetOption), + callback: action(() => { + // When you pick an option here, it updates your preferred default option + uiStore.exportSnippetFormat = getCodeSnippetFormatKey(snippetOption); + this.copyRequestAsSnippet(snippetOption); + }) + })) + })) + } + ]; + + uiStore.handleContextMenuEvent(event, menuItems); + }; + } \ No newline at end of file diff --git a/src/components/send/send-request-line.tsx b/src/components/send/send-request-line.tsx index 664595f8..93790d1a 100644 --- a/src/components/send/send-request-line.tsx +++ b/src/components/send/send-request-line.tsx @@ -10,6 +10,7 @@ import { getMethodColor } from '../../model/events/categorization'; import { Ctrl } from '../../util/ui'; import { Button, Select, TextInput } from '../common/inputs'; +import { IconButton } from '../common/icon-button'; type MethodName = keyof typeof Method; const validMethods = Object.values(Method) @@ -95,6 +96,13 @@ const UrlInput = styled(TextInput)` } `; +const CopySnippetButton = styled(IconButton)` + flex-shrink: 0; + padding: 5px 12px; + + font-size: ${p => p.theme.textSize}; +`; + const SendButton = styled(Button)` padding: 4px 18px 5px; border-radius: 0; @@ -122,6 +130,8 @@ export const SendRequestLine = (props: { isSending: boolean; sendRequest: () => void; + + showCopyAsSnippetMenu: (event: React.MouseEvent) => void; }) => { const updateMethodFromEvent = React.useCallback((changeEvent: React.ChangeEvent) => { props.updateMethod(changeEvent.target.value); @@ -198,6 +208,12 @@ export const SendRequestLine = (props: { onChange={updateUrlFromEvent} onPaste={onPaste} /> + { data: T; event: React.MouseEvent; + position?: { x: number, y: number }; items: readonly ContextMenuItem[]; } diff --git a/src/model/ui/export.ts b/src/model/ui/export.ts index 2e30910c..91c2a1d0 100644 --- a/src/model/ui/export.ts +++ b/src/model/ui/export.ts @@ -3,7 +3,13 @@ import * as HTTPSnippet from "@httptoolkit/httpsnippet"; import { saveFile } from "../../util/ui"; import { HttpExchangeView } from "../../types"; -import { generateHarRequest, generateHar } from '../http/har'; +import { + generateHarRequest, + generateHar, + ExtendedHarRequest +} from '../http/har'; +import { buildHtkRequest } from '../http/http-exchange'; +import { RequestInput, buildSentExchangeRequest } from '../send/send-request-model'; import { simplifyHarRequestForSnippetExport } from './snippet-export-sanitization'; import { SnippetOption } from './snippet-formats'; @@ -45,16 +51,45 @@ export function generateCodeSnippet( } // First, we need to get a HAR that appropriately represents this request as we - // want to export it. All snippet-specific preprocessing (header filtering, - // body placeholders) lives in snippet-export-sanitization.ts, so that this - // export and the bulk ZIP export share identical behaviour: + // want to export it: const harRequest = generateHarRequest(exchange.request, false, { bodySizeLimit: Infinity }); + + return generateCodeSnippetFromHarRequest(harRequest, snippetFormat); +}; + +// Generates a code snippet for a not-yet-sent request input, e.g. while editing +// a request on the Send page. We build the same HtkRequest a real send would produce, +// so this goes through exactly the same HAR generation as exported captured requests. +export function generateCodeSnippetFromRequestInput( + requestInput: RequestInput, + snippetFormat: SnippetOption +): string { + const decoded = requestInput.rawBody.decoded; + const sentRequest = buildSentExchangeRequest(requestInput, { + encodedLength: decoded.byteLength, + decoded + }); + + const harRequest = generateHarRequest(buildHtkRequest(sentRequest), false, { + bodySizeLimit: Infinity + }); + + return generateCodeSnippetFromHarRequest(harRequest, snippetFormat); +}; + +function generateCodeSnippetFromHarRequest( + harRequest: ExtendedHarRequest, + snippetFormat: SnippetOption +): string { + // All snippet-specific preprocessing (header filtering, body placeholders) lives in + // snippet-export-sanitization.ts, so that this export and the bulk ZIP export share + // identical behaviour: const harSnippetBase = simplifyHarRequestForSnippetExport(harRequest); - // Then, we convert that HAR to code for the given target: + // We convert the HAR to code for the given target: return new HTTPSnippet(harSnippetBase) .convert(snippetFormat.target, snippetFormat.client) .trim(); -}; \ No newline at end of file +}; diff --git a/src/model/ui/ui-store.ts b/src/model/ui/ui-store.ts index 7788cadc..29d170df 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -583,8 +583,17 @@ export class UiStore { event.preventDefault(); + // Right-click menus open at the cursor, other menus (e.g. button dropdowns) anchor + // to the bottom of the triggering element instead: + const anchorPosition = event.type === 'contextmenu' + ? undefined + : (() => { + const bounds = event.currentTarget.getBoundingClientRect(); + return { x: bounds.left, y: bounds.bottom }; + })(); + if (DesktopApi.openContextMenu) { - const position = { x: event.pageX, y: event.pageY }; + const position = anchorPosition ?? { x: event.pageX, y: event.pageY }; this.contextMenuState = undefined; // Should be set already, but let's be explicit DesktopApi.openContextMenu({ @@ -604,6 +613,7 @@ export class UiStore { this.contextMenuState = { data, event, + position: anchorPosition, items }; } diff --git a/src/types.d.ts b/src/types.d.ts index d4077d98..a2b81871 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -58,7 +58,7 @@ export type HarResponse = Omit & { body: HarBody; timingEvents: TimingEvents }; export type SentRequest = Omit & - { matchedRuleId: false, body: { buffer: Buffer } }; + { matchedRuleId: false, body: { buffer: Buffer } | HarBody }; export type SentRequestResponse = Omit & { body: { buffer: Buffer } }; export type SentRequestError = Pick & { diff --git a/test/unit/model/ui/export.spec.ts b/test/unit/model/ui/export.spec.ts new file mode 100644 index 00000000..b131ee57 --- /dev/null +++ b/test/unit/model/ui/export.spec.ts @@ -0,0 +1,87 @@ +import { expect } from "../../../test-setup"; + +import { RequestInput } from "../../../../src/model/send/send-request-model"; +import { generateCodeSnippetFromRequestInput } from "../../../../src/model/ui/export"; +import { getCodeSnippetOptionFromKey } from "../../../../src/model/ui/snippet-formats"; + +const curlFormat = getCodeSnippetOptionFromKey('shell~~curl'); + +describe("Code snippet generation from Send request inputs", () => { + + it("should generate a curl snippet for a simple GET request", () => { + const requestInput = new RequestInput({ + method: 'GET', + url: 'https://example.com/path?a=b', + headers: [ + ['host', 'example.com'], + ['accept', 'application/json'] + ], + requestContentType: 'text', + rawBody: Buffer.from([]) + }); + + const snippet = generateCodeSnippetFromRequestInput(requestInput, curlFormat); + + expect(snippet).to.include('curl'); + expect(snippet).to.include('https://example.com/path?a=b'); + expect(snippet).to.include("--header 'accept: application/json'"); + }); + + it("should generate a curl snippet including the body for a POST request", () => { + const requestInput = new RequestInput({ + method: 'POST', + url: 'https://example.com/upload', + headers: [ + ['host', 'example.com'], + ['content-type', 'application/json'], + ['content-length', '18'] + ], + requestContentType: 'json', + rawBody: Buffer.from('{"hello":"world"}') + }); + + const snippet = generateCodeSnippetFromRequestInput(requestInput, curlFormat); + + expect(snippet).to.include('--request POST'); + expect(snippet).to.include('https://example.com/upload'); + expect(snippet).to.include("--header 'content-type: application/json'"); + expect(snippet).to.include('{"hello":"world"}'); + + // Content-length is dropped, as clients can calculate it themselves: + expect(snippet).to.not.include('content-length'); + }); + + it("should drop content-encoding headers and use the decoded body", () => { + const requestInput = new RequestInput({ + method: 'POST', + url: 'https://example.com/', + headers: [ + ['host', 'example.com'], + ['content-type', 'text/plain'], + ['content-encoding', 'gzip'] + ], + requestContentType: 'text', + rawBody: Buffer.from('plain text body') + }); + + const snippet = generateCodeSnippetFromRequestInput(requestInput, curlFormat); + + expect(snippet).to.include('plain text body'); + expect(snippet).to.not.include('content-encoding'); + }); + + it("should fail clearly given an invalid URL", () => { + const requestInput = new RequestInput({ + method: 'GET', + url: 'not-a-real-url', + headers: [], + requestContentType: 'text', + rawBody: Buffer.from([]) + }); + + expect(() => + generateCodeSnippetFromRequestInput(requestInput, curlFormat) + ).to.throw(); + }); + +});