Skip to content

Commit cc63e76

Browse files
authored
Merge pull request #243 from komen205/send-copy-as-code-snippet
feature: add copy-as-code-snippet for requests on the Send page
2 parents e8c6526 + 3be07ed commit cc63e76

11 files changed

Lines changed: 263 additions & 32 deletions

File tree

src/components/html-context-menu.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export class HtmlContextMenu<T> extends React.Component<{
3232
setTimeout(() => {
3333
contextMenu.show({
3434
id: 'menu',
35-
event: menuState.event
35+
event: menuState.event,
36+
position: menuState.position
3637
});
3738
}, 10);
3839
}));

src/components/send/request-pane.tsx

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,23 @@ import * as HarFormat from 'har-format';
77

88
import { RawHeaders } from '../../types';
99

10+
import { AccountStore } from '../../model/account/account-store';
1011
import { RulesStore } from '../../model/rules/rules-store';
1112
import { UiStore } from '../../model/ui/ui-store';
1213
import { RequestInput } from '../../model/send/send-request-model';
1314
import { EditableContentType } from '../../model/events/content-types';
15+
import { ContextMenuItem } from '../../model/ui/context-menu';
16+
import { generateCodeSnippetFromRequestInput } from '../../model/ui/export';
17+
import {
18+
getCodeSnippetFormatKey,
19+
getCodeSnippetFormatName,
20+
getCodeSnippetOptionFromKey,
21+
snippetExportOptions,
22+
SnippetOption
23+
} from '../../model/ui/snippet-formats';
1424

1525
import { ContainerSizedEditor } from '../editor/base-editor';
16-
import { useHotkeys } from '../../util/ui';
26+
import { useHotkeys, copyToClipboard } from '../../util/ui';
1727

1828
import { SendCardContainer } from './send-card-section';
1929
import { SendRequestLine } from './send-request-line';
@@ -49,10 +59,12 @@ const RequestPaneKeyboardShortcuts = (props: {
4959

5060
@inject('rulesStore')
5161
@inject('uiStore')
62+
@inject('accountStore')
5263
@observer
5364
export class RequestPane extends React.Component<{
5465
rulesStore?: RulesStore,
5566
uiStore?: UiStore,
67+
accountStore?: AccountStore,
5668

5769
editorNode: portals.HtmlPortalNode<typeof ContainerSizedEditor>,
5870

@@ -106,6 +118,7 @@ export class RequestPane extends React.Component<{
106118
isSending={isSending}
107119
sendRequest={sendRequest}
108120
updateFromHar={this.props.updateFromHar}
121+
showCopyAsSnippetMenu={this.showCopyAsSnippetMenu}
109122
/>
110123
<SendRequestHeadersCard
111124
{...this.cardProps.requestHeaders}
@@ -152,4 +165,57 @@ export class RequestPane extends React.Component<{
152165
requestInput.rawBody.updateDecodedBody(input);
153166
}
154167

168+
private copyRequestAsSnippet = async (snippetOption: SnippetOption) => {
169+
const { requestInput } = this.props;
170+
171+
try {
172+
const snippet = generateCodeSnippetFromRequestInput(requestInput, snippetOption);
173+
await copyToClipboard(snippet);
174+
} catch (e: any) {
175+
console.log(e);
176+
alert(`Could not copy this request as a code snippet:\n\n${e.message || e}`);
177+
}
178+
};
179+
180+
private showCopyAsSnippetMenu = (event: React.MouseEvent) => {
181+
const uiStore = this.props.uiStore!;
182+
const isPaidUser = this.props.accountStore!.user.isPaidUser();
183+
184+
const preferredFormat = uiStore.exportSnippetFormat
185+
? getCodeSnippetOptionFromKey(uiStore.exportSnippetFormat)
186+
: undefined;
187+
188+
const menuItems: Array<ContextMenuItem<void>> = [
189+
...(!isPaidUser ? [
190+
{ type: 'option', label: 'With Pro:', enabled: false, callback: () => {} }
191+
] as const : []),
192+
// If you have a preferred default format, we show that option at the top level:
193+
...(preferredFormat && isPaidUser ? [{
194+
type: 'option' as const,
195+
label: `Copy as ${getCodeSnippetFormatName(preferredFormat)} Snippet`,
196+
callback: () => this.copyRequestAsSnippet(preferredFormat)
197+
}] : []),
198+
{
199+
type: 'submenu',
200+
enabled: isPaidUser,
201+
label: `Copy as Code Snippet`,
202+
items: Object.keys(snippetExportOptions).map((snippetGroupName) => ({
203+
type: 'submenu' as const,
204+
label: snippetGroupName,
205+
items: snippetExportOptions[snippetGroupName].map((snippetOption) => ({
206+
type: 'option' as const,
207+
label: getCodeSnippetFormatName(snippetOption),
208+
callback: action(() => {
209+
// When you pick an option here, it updates your preferred default option
210+
uiStore.exportSnippetFormat = getCodeSnippetFormatKey(snippetOption);
211+
this.copyRequestAsSnippet(snippetOption);
212+
})
213+
}))
214+
}))
215+
}
216+
];
217+
218+
uiStore.handleContextMenuEvent(event, menuItems);
219+
};
220+
155221
}

src/components/send/send-request-line.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getMethodColor } from '../../model/events/categorization';
1010

1111
import { Ctrl } from '../../util/ui';
1212
import { Button, Select, TextInput } from '../common/inputs';
13+
import { IconButton } from '../common/icon-button';
1314

1415
type MethodName = keyof typeof Method;
1516
const validMethods = Object.values(Method)
@@ -95,6 +96,13 @@ const UrlInput = styled(TextInput)`
9596
}
9697
`;
9798

99+
const CopySnippetButton = styled(IconButton)`
100+
flex-shrink: 0;
101+
padding: 5px 12px;
102+
103+
font-size: ${p => p.theme.textSize};
104+
`;
105+
98106
const SendButton = styled(Button)`
99107
padding: 4px 18px 5px;
100108
border-radius: 0;
@@ -122,6 +130,8 @@ export const SendRequestLine = (props: {
122130

123131
isSending: boolean;
124132
sendRequest: () => void;
133+
134+
showCopyAsSnippetMenu: (event: React.MouseEvent) => void;
125135
}) => {
126136
const updateMethodFromEvent = React.useCallback((changeEvent: React.ChangeEvent<HTMLSelectElement>) => {
127137
props.updateMethod(changeEvent.target.value);
@@ -198,6 +208,12 @@ export const SendRequestLine = (props: {
198208
onChange={updateUrlFromEvent}
199209
onPaste={onPaste}
200210
/>
211+
<CopySnippetButton
212+
title='Copy this request as a code snippet'
213+
icon={['far', 'copy']}
214+
disabled={!props.url}
215+
onClick={props.showCopyAsSnippetMenu}
216+
/>
201217
<SendButton
202218
type='submit'
203219
disabled={props.isSending}

src/model/http/http-exchange.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ function tryParseUrl(url: string): ParsedUrl | undefined {
7676

7777
const unparseableUrl = Object.assign(new URL("unknown://unparseable.invalid/"), { parseable: false } as const);
7878

79-
function addRequestMetadata(request: InputRequest): HtkRequest {
79+
export function buildHtkRequest(request: InputRequest): HtkRequest {
8080
try {
8181
return Object.assign(request, {
8282
parsedUrl: request.url
@@ -112,7 +112,7 @@ export class HttpExchange extends HTKEventBase implements HttpExchangeView {
112112
) {
113113
super();
114114

115-
this.request = addRequestMetadata(request);
115+
this.request = buildHtkRequest(request);
116116

117117
this.timingEvents = request.timingEvents;
118118
this.tags = this.request.tags;

src/model/send/send-request-model.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
import * as _ from 'lodash';
12
import * as Mockttp from 'mockttp';
23
import * as serializr from 'serializr';
34
import { observable } from 'mobx';
45
import * as HarFormat from 'har-format';
56

6-
import { HttpExchange, RawHeaders, HttpExchangeView } from "../../types";
7+
import {
8+
HttpExchange,
9+
HttpExchangeView,
10+
RawHeaders,
11+
SentRequest,
12+
TimingEvents
13+
} from "../../types";
714
import { ObservablePromise } from '../../util/observable';
815

916
import { EditableContentType, getEditableContentType, getEditableContentTypeFromViewable } from "../events/content-types";
@@ -13,7 +20,7 @@ import {
1320
syncFormattingToContentType,
1421
syncUrlToHeaders
1522
} from '../http/editable-request-parts';
16-
import { getHeaderValue, h2HeadersToH1 } from '../http/headers';
23+
import { getHeaderValue, h2HeadersToH1, rawHeadersToHeaders } from '../http/headers';
1724
import { parseHarRequest } from '../http/har';
1825

1926
// This is our model of a Request for sending. Smilar to the API model,
@@ -154,6 +161,28 @@ export function buildRequestInputFromHarRequest(requestData: HarFormat.Request):
154161
});
155162
}
156163

164+
export function buildSentExchangeRequest(
165+
requestInput: RequestInput,
166+
body: SentRequest['body']
167+
): SentRequest {
168+
const url = new URL(requestInput.url);
169+
170+
return {
171+
id: crypto.randomUUID(),
172+
httpVersion: '1.1', // All sent requests are HTTP/1.1 for now
173+
matchedRuleId: false,
174+
method: requestInput.method,
175+
url: requestInput.url,
176+
protocol: url.protocol.slice(0, -1),
177+
path: url.pathname,
178+
headers: rawHeadersToHeaders(requestInput.headers),
179+
rawHeaders: _.cloneDeep(requestInput.headers),
180+
body,
181+
timingEvents: { startTime: Date.now() } as TimingEvents,
182+
tags: ['httptoolkit:manually-sent-request']
183+
};
184+
}
185+
157186
// These are the types that the sever client API expects. They are _not_ the same as
158187
// the Input type above, which is more flexible and includes various UI concerns that
159188
// we don't need to share with the server to actually send the request.

src/model/send/send-store.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { HttpExchange } from '../http/http-exchange';
2828
import { ResponseHeadEvent, ResponseStreamEvent } from './send-response-model';
2929
import {
3030
buildRequestInputFromExchange,
31+
buildSentExchangeRequest,
3132
ClientProxyConfig,
3233
RequestInput,
3334
SendRequest,
@@ -153,8 +154,6 @@ export class SendStore {
153154
sendRequest.pendingSend.promise.then(clearPending, clearPending);
154155
});
155156

156-
const exchangeId = crypto.randomUUID();
157-
158157
const passthroughOptions = this.rulesStore.activePassthroughOptions;
159158

160159
const url = new URL(requestInput.url);
@@ -192,22 +191,9 @@ export class SendStore {
192191
abortController.signal
193192
);
194193

195-
const exchange = this.eventStore.recordSentRequest({
196-
id: exchangeId,
197-
httpVersion: '1.1',
198-
matchedRuleId: false,
199-
method: requestInput.method,
200-
url: requestInput.url,
201-
protocol: url.protocol.slice(0, -1),
202-
path: url.pathname,
203-
headers: rawHeadersToHeaders(requestInput.headers),
204-
rawHeaders: _.cloneDeep(requestInput.headers),
205-
body: { buffer: encodedBody },
206-
timingEvents: {
207-
startTime: Date.now()
208-
} as TimingEvents,
209-
tags: ['httptoolkit:manually-sent-request']
210-
});
194+
const exchange = this.eventStore.recordSentRequest(
195+
buildSentExchangeRequest(requestInput, { buffer: encodedBody })
196+
);
211197

212198
// Keep the exchange up to date as response data arrives:
213199
trackResponseEvents(responseStream, exchange)

src/model/ui/context-menu.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { UnreachableCheck } from "../../util/error";
44
export interface ContextMenuState<T> {
55
data: T;
66
event: React.MouseEvent;
7+
position?: { x: number, y: number };
78
items: readonly ContextMenuItem<T>[];
89
}
910

src/model/ui/export.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import * as HTTPSnippet from "@httptoolkit/httpsnippet";
33
import { saveFile } from "../../util/ui";
44

55
import { HttpExchangeView } from "../../types";
6-
import { generateHarRequest, generateHar } from '../http/har';
6+
import {
7+
generateHarRequest,
8+
generateHar,
9+
ExtendedHarRequest
10+
} from '../http/har';
11+
import { buildHtkRequest } from '../http/http-exchange';
12+
import { RequestInput, buildSentExchangeRequest } from '../send/send-request-model';
713
import { simplifyHarRequestForSnippetExport } from './snippet-export-sanitization';
814
import { SnippetOption } from './snippet-formats';
915

@@ -45,16 +51,45 @@ export function generateCodeSnippet(
4551
}
4652

4753
// First, we need to get a HAR that appropriately represents this request as we
48-
// want to export it. All snippet-specific preprocessing (header filtering,
49-
// body placeholders) lives in snippet-export-sanitization.ts, so that this
50-
// export and the bulk ZIP export share identical behaviour:
54+
// want to export it:
5155
const harRequest = generateHarRequest(exchange.request, false, {
5256
bodySizeLimit: Infinity
5357
});
58+
59+
return generateCodeSnippetFromHarRequest(harRequest, snippetFormat);
60+
};
61+
62+
// Generates a code snippet for a not-yet-sent request input, e.g. while editing
63+
// a request on the Send page. We build the same HtkRequest a real send would produce,
64+
// so this goes through exactly the same HAR generation as exported captured requests.
65+
export function generateCodeSnippetFromRequestInput(
66+
requestInput: RequestInput,
67+
snippetFormat: SnippetOption
68+
): string {
69+
const decoded = requestInput.rawBody.decoded;
70+
const sentRequest = buildSentExchangeRequest(requestInput, {
71+
encodedLength: decoded.byteLength,
72+
decoded
73+
});
74+
75+
const harRequest = generateHarRequest(buildHtkRequest(sentRequest), false, {
76+
bodySizeLimit: Infinity
77+
});
78+
79+
return generateCodeSnippetFromHarRequest(harRequest, snippetFormat);
80+
};
81+
82+
function generateCodeSnippetFromHarRequest(
83+
harRequest: ExtendedHarRequest,
84+
snippetFormat: SnippetOption
85+
): string {
86+
// All snippet-specific preprocessing (header filtering, body placeholders) lives in
87+
// snippet-export-sanitization.ts, so that this export and the bulk ZIP export share
88+
// identical behaviour:
5489
const harSnippetBase = simplifyHarRequestForSnippetExport(harRequest);
5590

56-
// Then, we convert that HAR to code for the given target:
91+
// We convert the HAR to code for the given target:
5792
return new HTTPSnippet(harSnippetBase)
5893
.convert(snippetFormat.target, snippetFormat.client)
5994
.trim();
60-
};
95+
};

src/model/ui/ui-store.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,8 +583,17 @@ export class UiStore {
583583

584584
event.preventDefault();
585585

586+
// Right-click menus open at the cursor, other menus (e.g. button dropdowns) anchor
587+
// to the bottom of the triggering element instead:
588+
const anchorPosition = event.type === 'contextmenu'
589+
? undefined
590+
: (() => {
591+
const bounds = event.currentTarget.getBoundingClientRect();
592+
return { x: bounds.left, y: bounds.bottom };
593+
})();
594+
586595
if (DesktopApi.openContextMenu) {
587-
const position = { x: event.pageX, y: event.pageY };
596+
const position = anchorPosition ?? { x: event.pageX, y: event.pageY };
588597
this.contextMenuState = undefined; // Should be set already, but let's be explicit
589598

590599
DesktopApi.openContextMenu({
@@ -604,6 +613,7 @@ export class UiStore {
604613
this.contextMenuState = {
605614
data,
606615
event,
616+
position: anchorPosition,
607617
items
608618
};
609619
}

src/types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export type HarResponse = Omit<MockttpResponse, 'body' | 'timingEvents'> &
5858
{ body: HarBody; timingEvents: TimingEvents };
5959

6060
export type SentRequest = Omit<MockttpInitiatedRequest, 'matchedRuleId' | 'body' | 'destination'> &
61-
{ matchedRuleId: false, body: { buffer: Buffer } };
61+
{ matchedRuleId: false, body: { buffer: Buffer } | HarBody };
6262
export type SentRequestResponse = Omit<MockttpResponse, 'body'> &
6363
{ body: { buffer: Buffer } };
6464
export type SentRequestError = Pick<MockttpAbortedRequest, 'id' | 'timingEvents' | 'tags'> & {

0 commit comments

Comments
 (0)