Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion src/components/send/request-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof ContainerSizedEditor>,

Expand Down Expand Up @@ -106,6 +118,7 @@ export class RequestPane extends React.Component<{
isSending={isSending}
sendRequest={sendRequest}
updateFromHar={this.props.updateFromHar}
showCopyAsSnippetMenu={this.showCopyAsSnippetMenu}
/>
<SendRequestHeadersCard
{...this.cardProps.requestHeaders}
Expand Down Expand Up @@ -152,4 +165,57 @@ export class RequestPane extends React.Component<{
requestInput.rawBody.updateDecodedBody(input);
}

private copyRequestAsSnippet = async (snippetOption: SnippetOption) => {
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<ContextMenuItem<void>> = [
...(!isPaidUser ? [

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is worth to keep, the whole feature is already gated anyway.

{ 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);
};

}
16 changes: 16 additions & 0 deletions src/components/send/send-request-line.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -122,6 +130,8 @@ export const SendRequestLine = (props: {

isSending: boolean;
sendRequest: () => void;

showCopyAsSnippetMenu: (event: React.MouseEvent) => void;
}) => {
const updateMethodFromEvent = React.useCallback((changeEvent: React.ChangeEvent<HTMLSelectElement>) => {
props.updateMethod(changeEvent.target.value);
Expand Down Expand Up @@ -198,6 +208,12 @@ export const SendRequestLine = (props: {
onChange={updateUrlFromEvent}
onPaste={onPaste}
/>
<CopySnippetButton
title='Copy this request as a code snippet'
icon={['far', 'copy']}
disabled={!props.url}
onClick={props.showCopyAsSnippetMenu}
/>
<SendButton
type='submit'
disabled={props.isSending}
Expand Down
49 changes: 49 additions & 0 deletions src/model/http/har.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,55 @@ export function generateHarRequest(
return requestEntry;
}

// Generates a HAR request for request data that hasn't (yet) been sent, e.g. an
// in-progress request input on the Send page. Unlike generateHarRequest above, the
// body here is always already decoded & available synchronously.
export function generateHarRequestFromRequestData(request: {
method: string,
url: string,
rawHeaders: RawHeaders,
decodedBody: Buffer
}): ExtendedHarRequest {
const parsedUrl = new URL(request.url);
const headers = asHtkHeaders(asHarHeaders(request.rawHeaders));

const requestEntry: ExtendedHarRequest = {
method: request.method,
url: parsedUrl.toString(),
httpVersion: 'HTTP/1.1', // All sent requests are HTTP/1.1 for now
cookies: asHarRequestCookies(headers),
headers: asHarHeaders(request.rawHeaders),
queryString: Array.from(parsedUrl.searchParams.entries()).map(
([paramKey, paramValue]) => ({
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
Expand Down
34 changes: 32 additions & 2 deletions src/model/ui/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down
89 changes: 89 additions & 0 deletions test/unit/model/ui/export.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});

});