Skip to content
Merged
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
3 changes: 2 additions & 1 deletion src/components/html-context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export class HtmlContextMenu<T> extends React.Component<{
setTimeout(() => {
contextMenu.show({
id: 'menu',
event: menuState.event
event: menuState.event,
position: menuState.position
});
}, 10);
}));
Expand Down
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 } 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';
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
Contributor 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
4 changes: 2 additions & 2 deletions src/model/http/http-exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function tryParseUrl(url: string): ParsedUrl | undefined {

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

function addRequestMetadata(request: InputRequest): HtkRequest {
export function buildHtkRequest(request: InputRequest): HtkRequest {
try {
return Object.assign(request, {
parsedUrl: request.url
Expand Down Expand Up @@ -112,7 +112,7 @@ export class HttpExchange extends HTKEventBase implements HttpExchangeView {
) {
super();

this.request = addRequestMetadata(request);
this.request = buildHtkRequest(request);

this.timingEvents = request.timingEvents;
this.tags = this.request.tags;
Expand Down
33 changes: 31 additions & 2 deletions src/model/send/send-request-model.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import * as _ from 'lodash';
import * as Mockttp from 'mockttp';
import * as serializr from 'serializr';
import { observable } from 'mobx';
import * as HarFormat from 'har-format';

import { HttpExchange, RawHeaders, HttpExchangeView } from "../../types";
import {
HttpExchange,
HttpExchangeView,
RawHeaders,
SentRequest,
TimingEvents
} from "../../types";
import { ObservablePromise } from '../../util/observable';

import { EditableContentType, getEditableContentType, getEditableContentTypeFromViewable } from "../events/content-types";
Expand All @@ -13,7 +20,7 @@ import {
syncFormattingToContentType,
syncUrlToHeaders
} from '../http/editable-request-parts';
import { getHeaderValue, h2HeadersToH1 } from '../http/headers';
import { getHeaderValue, h2HeadersToH1, rawHeadersToHeaders } from '../http/headers';
import { parseHarRequest } from '../http/har';

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

export function buildSentExchangeRequest(
requestInput: RequestInput,
body: SentRequest['body']
): SentRequest {
const url = new URL(requestInput.url);

return {
id: crypto.randomUUID(),
httpVersion: '1.1', // All sent requests are HTTP/1.1 for now
matchedRuleId: false,
method: requestInput.method,
url: requestInput.url,
protocol: url.protocol.slice(0, -1),
path: url.pathname,
headers: rawHeadersToHeaders(requestInput.headers),
rawHeaders: _.cloneDeep(requestInput.headers),
body,
timingEvents: { startTime: Date.now() } as TimingEvents,
tags: ['httptoolkit:manually-sent-request']
};
}

// These are the types that the sever client API expects. They are _not_ the same as
// the Input type above, which is more flexible and includes various UI concerns that
// we don't need to share with the server to actually send the request.
Expand Down
22 changes: 4 additions & 18 deletions src/model/send/send-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { HttpExchange } from '../http/http-exchange';
import { ResponseHeadEvent, ResponseStreamEvent } from './send-response-model';
import {
buildRequestInputFromExchange,
buildSentExchangeRequest,
ClientProxyConfig,
RequestInput,
SendRequest,
Expand Down Expand Up @@ -153,8 +154,6 @@ export class SendStore {
sendRequest.pendingSend.promise.then(clearPending, clearPending);
});

const exchangeId = crypto.randomUUID();

const passthroughOptions = this.rulesStore.activePassthroughOptions;

const url = new URL(requestInput.url);
Expand Down Expand Up @@ -192,22 +191,9 @@ export class SendStore {
abortController.signal
);

const exchange = this.eventStore.recordSentRequest({
id: exchangeId,
httpVersion: '1.1',
matchedRuleId: false,
method: requestInput.method,
url: requestInput.url,
protocol: url.protocol.slice(0, -1),
path: url.pathname,
headers: rawHeadersToHeaders(requestInput.headers),
rawHeaders: _.cloneDeep(requestInput.headers),
body: { buffer: encodedBody },
timingEvents: {
startTime: Date.now()
} as TimingEvents,
tags: ['httptoolkit:manually-sent-request']
});
const exchange = this.eventStore.recordSentRequest(
buildSentExchangeRequest(requestInput, { buffer: encodedBody })
);

// Keep the exchange up to date as response data arrives:
trackResponseEvents(responseStream, exchange)
Expand Down
1 change: 1 addition & 0 deletions src/model/ui/context-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { UnreachableCheck } from "../../util/error";
export interface ContextMenuState<T> {
data: T;
event: React.MouseEvent;
position?: { x: number, y: number };
items: readonly ContextMenuItem<T>[];
}

Expand Down
47 changes: 41 additions & 6 deletions src/model/ui/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
};
};
12 changes: 11 additions & 1 deletion src/model/ui/ui-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -604,6 +613,7 @@ export class UiStore {
this.contextMenuState = {
data,
event,
position: anchorPosition,
items
};
}
Expand Down
2 changes: 1 addition & 1 deletion src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export type HarResponse = Omit<MockttpResponse, 'body' | 'timingEvents'> &
{ body: HarBody; timingEvents: TimingEvents };

export type SentRequest = Omit<MockttpInitiatedRequest, 'matchedRuleId' | 'body' | 'destination'> &
{ matchedRuleId: false, body: { buffer: Buffer } };
{ matchedRuleId: false, body: { buffer: Buffer } | HarBody };
export type SentRequestResponse = Omit<MockttpResponse, 'body'> &
{ body: { buffer: Buffer } };
export type SentRequestError = Pick<MockttpAbortedRequest, 'id' | 'timingEvents' | 'tags'> & {
Expand Down
Loading
Loading