diff --git a/src/components/send/request-pane.tsx b/src/components/send/request-pane.tsx index a7bc14ff..9ccdefe9 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, + getCodeSnippetFormatKey, + getCodeSnippetFormatName, + getCodeSnippetOptionFromKey, + snippetExportOptions, + SnippetOption +} from '../../model/ui/export'; 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} /> + ({ + name: paramKey, + value: paramValue + }) + ), + headersSize: -1, + bodySize: request.decodedBody.byteLength + }; + + try { + requestEntry.postData = generateHarPostBody( + UTF8Decoder.decode(request.decodedBody), + getHeaderValue(request.rawHeaders, 'content-type') || 'application/octet-stream' + ); + } catch (e) { + if (e instanceof TypeError) { + requestEntry._requestBodyStatus = 'discarded:not-representable'; + requestEntry._content = { + text: request.decodedBody.toString('base64'), + size: request.decodedBody.byteLength, + encoding: 'base64' + }; + } else { + throw e; + } + } + + return requestEntry; +} + type TextBody = { mimeType: string, text: string diff --git a/src/model/ui/export.ts b/src/model/ui/export.ts index 69907e95..b563a89d 100644 --- a/src/model/ui/export.ts +++ b/src/model/ui/export.ts @@ -4,7 +4,13 @@ import * as HTTPSnippet from "@httptoolkit/httpsnippet"; import { saveFile } from "../../util/ui"; import { HttpExchangeView } from "../../types"; -import { generateHarRequest, generateHar, ExtendedHarRequest } from '../http/har'; +import { + generateHarRequest, + generateHarRequestFromRequestData, + generateHar, + ExtendedHarRequest +} from '../http/har'; +import { RequestInput } from '../send/send-request-model'; export const exportHar = async (exchange: HttpExchangeView) => { const harContent = JSON.stringify( @@ -48,9 +54,33 @@ export function generateCodeSnippet( 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. +export function generateCodeSnippetFromRequestInput( + requestInput: RequestInput, + snippetFormat: SnippetOption +): string { + const harRequest = generateHarRequestFromRequestData({ + method: requestInput.method, + url: requestInput.url, + rawHeaders: requestInput.headers, + decodedBody: requestInput.rawBody.decoded + }); + + return generateCodeSnippetFromHarRequest(harRequest, snippetFormat); +}; + +function generateCodeSnippetFromHarRequest( + harRequest: ExtendedHarRequest, + snippetFormat: SnippetOption +): string { const harSnippetBase = simplifyHarForSnippetExport(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(); diff --git a/test/unit/model/ui/export.spec.ts b/test/unit/model/ui/export.spec.ts new file mode 100644 index 00000000..bb4e9c25 --- /dev/null +++ b/test/unit/model/ui/export.spec.ts @@ -0,0 +1,89 @@ +import { expect } from "../../../test-setup"; + +import { RequestInput } from "../../../../src/model/send/send-request-model"; +import { + generateCodeSnippetFromRequestInput, + getCodeSnippetOptionFromKey +} from "../../../../src/model/ui/export"; + +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(); + }); + +});