Add initial UI/UX for intecepting requests

This commit is contained in:
David Stotijn
2022-03-10 20:30:40 +01:00
parent 71e550f0cd
commit 9dd8464af7
24 changed files with 882 additions and 203 deletions

View File

@ -1,11 +1,12 @@
import AltRouteIcon from "@mui/icons-material/AltRoute";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import FolderIcon from "@mui/icons-material/Folder";
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
import HomeIcon from "@mui/icons-material/Home";
import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
import MenuIcon from "@mui/icons-material/Menu";
import SendIcon from "@mui/icons-material/Send";
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
import {
Theme,
useTheme,
@ -19,6 +20,7 @@ import {
CSSObject,
Box,
ListItemText,
Badge,
} from "@mui/material";
import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar";
import MuiDrawer from "@mui/material/Drawer";
@ -28,10 +30,12 @@ import Link from "next/link";
import React, { useState } from "react";
import { useActiveProject } from "lib/ActiveProjectContext";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
export enum Page {
Home,
GetStarted,
Intercept,
Projects,
ProxySetup,
ProxyLogs,
@ -135,6 +139,7 @@ interface Props {
export function Layout({ title, page, children }: Props): JSX.Element {
const activeProject = useActiveProject();
const interceptedRequests = useInterceptedRequests();
const theme = useTheme();
const [open, setOpen] = useState(false);
@ -204,12 +209,24 @@ export function Layout({ title, page, children }: Props): JSX.Element {
</Link>
<Link href="/proxy/logs" passHref>
<ListItemButton key="proxyLogs" disabled={!activeProject} selected={page === Page.ProxyLogs}>
<Tooltip title="Proxy">
<Tooltip title="Proxy logs">
<ListItemIcon>
<SettingsEthernetIcon />
<FormatListBulletedIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Proxy" />
<ListItemText primary="Logs" />
</ListItemButton>
</Link>
<Link href="/proxy/intercept" passHref>
<ListItemButton key="proxyIntercept" disabled={!activeProject} selected={page === Page.Intercept}>
<Tooltip title="Proxy intercept">
<ListItemIcon>
<Badge color="error" badgeContent={interceptedRequests?.length || 0}>
<AltRouteIcon />
</Badge>
</ListItemIcon>
</Tooltip>
<ListItemText primary="Intercept" />
</ListItemButton>
</Link>
<Link href="/sender" passHref>

View File

@ -0,0 +1,203 @@
import SendIcon from "@mui/icons-material/Send";
import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
import RequestTabs from "lib/components/RequestTabs";
import Response from "lib/components/Response";
import SplitPane from "lib/components/SplitPane";
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
import {
HttpProtocol,
HttpRequest,
useGetInterceptedRequestQuery,
useModifyRequestMutation,
} from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL";
import updateKeyPairItem from "lib/updateKeyPairItem";
import updateURLQueryParams from "lib/updateURLQueryParams";
function EditRequest(): JSX.Element {
const router = useRouter();
const interceptedRequests = useInterceptedRequests();
useEffect(() => {
// If there's no request selected and there are pending reqs, navigate to
// the first one in the list. This helps you quickly review/handle reqs
// without having to manually select the next one in the requests table.
console.log(router.isReady, router.query.id, interceptedRequests?.length);
if (router.isReady && !router.query.id && interceptedRequests?.length) {
const req = interceptedRequests[0];
router.replace(`/proxy/intercept?id=${req.id}`);
}
}, [router, interceptedRequests]);
const reqId = router.query.id as string | undefined;
const [method, setMethod] = useState(HttpMethod.Get);
const [url, setURL] = useState("");
const [proto, setProto] = useState(HttpProto.Http20);
const [queryParams, setQueryParams] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [headers, setHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [body, setBody] = useState("");
const handleQueryParamChange = (key: string, value: string, idx: number) => {
setQueryParams((prev) => {
const updated = updateKeyPairItem(key, value, idx, prev);
setURL((prev) => updateURLQueryParams(prev, updated));
return updated;
});
};
const handleQueryParamDelete = (idx: number) => {
setQueryParams((prev) => {
const updated = prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length));
setURL((prev) => updateURLQueryParams(prev, updated));
return updated;
});
};
const handleHeaderChange = (key: string, value: string, idx: number) => {
setHeaders((prev) => updateKeyPairItem(key, value, idx, prev));
};
const handleHeaderDelete = (idx: number) => {
setHeaders((prev) => prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length)));
};
const handleURLChange = (url: string) => {
setURL(url);
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex === -1) {
setQueryParams([{ key: "", value: "" }]);
return;
}
const newQueryParams = queryParamsFromURL(url);
// Push empty row.
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
};
const getReqResult = useGetInterceptedRequestQuery({
variables: { id: reqId as string },
skip: reqId === undefined,
onCompleted: ({ interceptedRequest }) => {
if (!interceptedRequest) {
return;
}
setURL(interceptedRequest.url);
setMethod(interceptedRequest.method);
setBody(interceptedRequest.body || "");
const newQueryParams = queryParamsFromURL(interceptedRequest.url);
// Push empty row.
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
const newHeaders = sortKeyValuePairs(interceptedRequest.headers || []);
setHeaders([...newHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
},
});
const interceptedReq = reqId ? getReqResult?.data?.interceptedRequest : undefined;
const [modifyRequest, modifyResult] = useModifyRequestMutation();
const handleFormSubmit: React.FormEventHandler = (e) => {
e.preventDefault();
if (!interceptedReq) {
return;
}
modifyRequest({
variables: {
request: {
id: interceptedReq.id,
url,
method,
proto: httpProtoMap.get(proto) || HttpProtocol.Http20,
headers: headers.filter((kv) => kv.key !== ""),
body: body || undefined,
},
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedReq.id !== readField("id", ref));
},
},
});
},
onCompleted: () => {
setURL("");
setMethod(HttpMethod.Get);
setBody("");
setQueryParams([]);
setHeaders([]);
console.log("done!");
router.replace(`/proxy/intercept`);
},
});
};
return (
<Box display="flex" flexDirection="column" height="100%" gap={2}>
<Box component="form" autoComplete="off" onSubmit={handleFormSubmit}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<UrlBar
method={method}
onMethodChange={interceptedReq ? setMethod : undefined}
url={url.toString()}
onUrlChange={interceptedReq ? handleURLChange : undefined}
proto={proto}
onProtoChange={interceptedReq ? setProto : undefined}
sx={{ flex: "1 auto" }}
/>
<Button
variant="contained"
disableElevation
type="submit"
disabled={!interceptedReq || modifyResult.loading}
startIcon={modifyResult.loading ? <CircularProgress size={22} /> : <SendIcon />}
>
Send
</Button>
</Box>
{modifyResult.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{modifyResult.error.message}
</Alert>
)}
</Box>
<Box flex="1 auto" position="relative">
<SplitPane split="vertical" size={"50%"}>
<Box sx={{ height: "100%", mr: 2, pb: 2, position: "relative" }}>
<Typography variant="overline" color="textSecondary" sx={{ position: "absolute", right: 0, mt: 1.2 }}>
Request
</Typography>
<RequestTabs
queryParams={interceptedReq ? queryParams : []}
headers={interceptedReq ? headers : []}
body={body}
onQueryParamChange={interceptedReq ? handleQueryParamChange : undefined}
onQueryParamDelete={interceptedReq ? handleQueryParamDelete : undefined}
onHeaderChange={interceptedReq ? handleHeaderChange : undefined}
onHeaderDelete={interceptedReq ? handleHeaderDelete : undefined}
onBodyChange={interceptedReq ? setBody : undefined}
/>
</Box>
<Box sx={{ height: "100%", position: "relative", ml: 2, pb: 2 }}>
<Response response={null} />
</Box>
</SplitPane>
</Box>
</Box>
);
}
export default EditRequest;

View File

@ -0,0 +1,21 @@
import { Box } from "@mui/material";
import EditRequest from "./EditRequest";
import Requests from "./Requests";
import SplitPane from "lib/components/SplitPane";
export default function Sender(): JSX.Element {
return (
<Box sx={{ height: "100%", position: "relative" }}>
<SplitPane split="horizontal" size="70%">
<Box sx={{ width: "100%", pt: "0.75rem" }}>
<EditRequest />
</Box>
<Box sx={{ height: "100%", overflow: "scroll" }}>
<Requests />
</Box>
</SplitPane>
</Box>
);
}

View File

@ -0,0 +1,33 @@
import { Box, Paper, Typography } from "@mui/material";
import { useRouter } from "next/router";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import RequestsTable from "lib/components/RequestsTable";
function Requests(): JSX.Element {
const interceptedRequests = useInterceptedRequests();
const router = useRouter();
const activeId = router.query.id as string | undefined;
const handleRowClick = (id: string) => {
router.push(`/proxy/intercept?id=${id}`);
};
return (
<Box>
{interceptedRequests && interceptedRequests.length > 0 && (
<RequestsTable requests={interceptedRequests} onRowClick={handleRowClick} activeRowId={activeId} />
)}
<Box sx={{ mt: 2, height: "100%" }}>
{interceptedRequests?.length === 0 && (
<Paper variant="centered">
<Typography>No pending intercepted requests.</Typography>
</Paper>
)}
</Box>
</Box>
);
}
export default Requests;

View File

@ -0,0 +1,13 @@
query GetInterceptedRequest($id: ID!) {
interceptedRequest(id: $id) {
id
url
method
proto
headers {
key
value
}
body
}
}

View File

@ -0,0 +1,5 @@
mutation ModifyRequest($request: ModifyRequestInput!) {
modifyRequest(request: $request) {
success
}
}

View File

@ -0,0 +1,57 @@
import AltRouteIcon from "@mui/icons-material/AltRoute";
import DeleteIcon from "@mui/icons-material/Delete";
import { Alert } from "@mui/lab";
import { Badge, Button, IconButton, Tooltip } from "@mui/material";
import Link from "next/link";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
import { HttpRequestLogsDocument, useClearHttpRequestLogMutation } from "lib/graphql/generated";
function Actions(): JSX.Element {
const interceptedRequests = useInterceptedRequests();
const [clearHTTPRequestLog, clearLogsResult] = useClearHttpRequestLogMutation({
refetchQueries: [{ query: HttpRequestLogsDocument }],
});
const clearHTTPConfirmationDialog = useConfirmationDialog();
return (
<div>
<ConfirmationDialog
isOpen={clearHTTPConfirmationDialog.isOpen}
onClose={clearHTTPConfirmationDialog.close}
onConfirm={clearHTTPRequestLog}
>
All proxy logs are going to be removed. This action cannot be undone.
</ConfirmationDialog>
{clearLogsResult.error && <Alert severity="error">Failed to clear HTTP logs: {clearLogsResult.error}</Alert>}
<Link href="/proxy/intercept/?id=" passHref>
<Button
variant="contained"
disabled={interceptedRequests === null || interceptedRequests.length === 0}
color="primary"
component="a"
size="large"
startIcon={
<Badge color="error" badgeContent={interceptedRequests?.length || 0}>
<AltRouteIcon />
</Badge>
}
sx={{ mr: 1 }}
>
Review Intercepted
</Button>
</Link>
<Tooltip title="Clear all">
<IconButton onClick={clearHTTPConfirmationDialog.open}>
<DeleteIcon />
</IconButton>
</Tooltip>
</div>
);
}
export default Actions;

View File

@ -14,6 +14,7 @@ import {
import { useRouter } from "next/router";
import { useState } from "react";
import Actions from "./Actions";
import LogDetail from "./LogDetail";
import Search from "./Search";
@ -94,7 +95,14 @@ export function RequestLogs(): JSX.Element {
return (
<Box display="flex" flexDirection="column" height="100%">
<Box display="flex">
<Box flex="1 auto">
<Search />
</Box>
<Box pt={0.5}>
<Actions />
</Box>
</Box>
<Box sx={{ display: "flex", flex: "1 auto", position: "relative" }}>
<SplitPane split="horizontal" size={"40%"}>
<Box sx={{ width: "100%", height: "100%", pb: 2 }}>

View File

@ -1,4 +1,3 @@
import DeleteIcon from "@mui/icons-material/Delete";
import FilterListIcon from "@mui/icons-material/FilterList";
import SearchIcon from "@mui/icons-material/Search";
import { Alert } from "@mui/lab";
@ -17,11 +16,8 @@ import {
import IconButton from "@mui/material/IconButton";
import React, { useRef, useState } from "react";
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
import {
HttpRequestLogFilterDocument,
HttpRequestLogsDocument,
useClearHttpRequestLogMutation,
useHttpRequestLogFilterQuery,
useSetHttpRequestLogFilterMutation,
} from "lib/graphql/generated";
@ -49,11 +45,6 @@ function Search(): JSX.Element {
},
});
const [clearHTTPRequestLog, clearHTTPRequestLogResult] = useClearHttpRequestLogMutation({
refetchQueries: [{ query: HttpRequestLogsDocument }],
});
const clearHTTPConfirmationDialog = useConfirmationDialog();
const filterRef = useRef<HTMLFormElement>(null);
const [filterOpen, setFilterOpen] = useState(false);
@ -81,7 +72,6 @@ function Search(): JSX.Element {
<Box>
<Error prefix="Error fetching filter" error={filterResult.error} />
<Error prefix="Error setting filter" error={setFilterResult.error} />
<Error prefix="Error clearing all HTTP logs" error={clearHTTPRequestLogResult.error} />
<Box style={{ display: "flex", flex: 1 }}>
<ClickAwayListener onClickAway={handleClickAway}>
<Paper
@ -161,22 +151,8 @@ function Search(): JSX.Element {
</Popper>
</Paper>
</ClickAwayListener>
<Box style={{ marginLeft: "auto" }}>
<Tooltip title="Clear all">
<IconButton onClick={clearHTTPConfirmationDialog.open}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
<ConfirmationDialog
isOpen={clearHTTPConfirmationDialog.isOpen}
onClose={clearHTTPConfirmationDialog.close}
onConfirm={clearHTTPRequestLog}
>
All proxy logs are going to be removed. This action cannot be undone.
</ConfirmationDialog>
</Box>
);
}

View File

@ -1,15 +1,4 @@
import {
Alert,
Box,
BoxProps,
Button,
InputLabel,
FormControl,
MenuItem,
Select,
TextField,
Typography,
} from "@mui/material";
import { Alert, Box, Button, Typography } from "@mui/material";
import { useRouter } from "next/router";
import React, { useState } from "react";
@ -17,76 +6,16 @@ import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
import RequestTabs from "lib/components/RequestTabs";
import Response from "lib/components/Response";
import SplitPane from "lib/components/SplitPane";
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
import {
GetSenderRequestQuery,
useCreateOrUpdateSenderRequestMutation,
HttpProtocol,
useGetSenderRequestQuery,
useSendRequestMutation,
} from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL";
enum HttpMethod {
Get = "GET",
Post = "POST",
Put = "PUT",
Patch = "PATCH",
Delete = "DELETE",
Head = "HEAD",
Options = "OPTIONS",
Connect = "CONNECT",
Trace = "TRACE",
}
enum HttpProto {
Http10 = "HTTP/1.0",
Http11 = "HTTP/1.1",
Http20 = "HTTP/2.0",
}
const httpProtoMap = new Map([
[HttpProto.Http10, HttpProtocol.Http10],
[HttpProto.Http11, HttpProtocol.Http11],
[HttpProto.Http20, HttpProtocol.Http20],
]);
function updateKeyPairItem(key: string, value: string, idx: number, items: KeyValuePair[]): KeyValuePair[] {
const updated = [...items];
updated[idx] = { key, value };
// Append an empty key-value pair if the last item in the array isn't blank
// anymore.
if (items.length - 1 === idx && items[idx].key === "" && items[idx].value === "") {
updated.push({ key: "", value: "" });
}
return updated;
}
function updateURLQueryParams(url: string, queryParams: KeyValuePair[]) {
// Note: We don't use the `URL` interface, because we're potentially dealing
// with malformed/incorrect URLs, which would yield TypeErrors when constructed
// via `URL`.
let newURL = url;
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex !== -1) {
newURL = newURL.slice(0, questionMarkIndex);
}
const searchParams = new URLSearchParams();
for (const { key, value } of queryParams.filter(({ key }) => key !== "")) {
searchParams.append(key, value);
}
const rawQueryParams = decodeURI(searchParams.toString());
if (rawQueryParams == "") {
return newURL;
}
return newURL + "?" + rawQueryParams;
}
import updateKeyPairItem from "lib/updateKeyPairItem";
import updateURLQueryParams from "lib/updateURLQueryParams";
function EditRequest(): JSX.Element {
const router = useRouter();
@ -263,94 +192,4 @@ function EditRequest(): JSX.Element {
);
}
interface UrlBarProps extends BoxProps {
method: HttpMethod;
onMethodChange: (method: HttpMethod) => void;
url: string;
onUrlChange: (url: string) => void;
proto: HttpProto;
onProtoChange: (proto: HttpProto) => void;
}
function UrlBar(props: UrlBarProps) {
const { method, onMethodChange, url, onUrlChange, proto, onProtoChange, ...other } = props;
return (
<Box {...other} sx={{ ...other.sx, display: "flex" }}>
<FormControl>
<InputLabel id="req-method-label">Method</InputLabel>
<Select
labelId="req-method-label"
id="req-method"
value={method}
label="Method"
onChange={(e) => onMethodChange(e.target.value as HttpMethod)}
sx={{
width: "8rem",
".MuiOutlinedInput-notchedOutline": {
borderRightWidth: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderRightWidth: 1,
},
}}
>
{Object.values(HttpMethod).map((method) => (
<MenuItem key={method} value={method}>
{method}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="URL"
placeholder="E.g. “https://example.com/foobar”"
value={url}
onChange={(e) => onUrlChange(e.target.value)}
required
variant="outlined"
InputLabelProps={{
shrink: true,
}}
InputProps={{
sx: {
".MuiOutlinedInput-notchedOutline": {
borderRadius: 0,
},
},
}}
sx={{ flexGrow: 1 }}
/>
<FormControl>
<InputLabel id="req-proto-label">Protocol</InputLabel>
<Select
labelId="req-proto-label"
id="req-proto"
value={proto}
label="Protocol"
onChange={(e) => onProtoChange(e.target.value as HttpProto)}
sx={{
".MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 0,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 1,
},
}}
>
{Object.values(HttpProto).map((proto) => (
<MenuItem key={proto} value={proto}>
{proto}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
}
export default EditRequest;

View File

@ -0,0 +1,22 @@
import React, { createContext, useContext } from "react";
import { GetInterceptedRequestsQuery, useGetInterceptedRequestsQuery } from "./graphql/generated";
const InterceptedRequestsContext = createContext<GetInterceptedRequestsQuery["interceptedRequests"] | null>(null);
interface Props {
children?: React.ReactNode | undefined;
}
export function InterceptedRequestsProvider({ children }: Props): JSX.Element {
const { data } = useGetInterceptedRequestsQuery({
pollInterval: 1000,
});
const reqs = data?.interceptedRequests || null;
return <InterceptedRequestsContext.Provider value={reqs}>{children}</InterceptedRequestsContext.Provider>;
}
export function useInterceptedRequests() {
return useContext(InterceptedRequestsContext);
}

View File

@ -0,0 +1,122 @@
import { Box, BoxProps, FormControl, InputLabel, MenuItem, Select, TextField } from "@mui/material";
import { HttpProtocol } from "lib/graphql/generated";
export enum HttpMethod {
Get = "GET",
Post = "POST",
Put = "PUT",
Patch = "PATCH",
Delete = "DELETE",
Head = "HEAD",
Options = "OPTIONS",
Connect = "CONNECT",
Trace = "TRACE",
}
export enum HttpProto {
Http10 = "HTTP/1.0",
Http11 = "HTTP/1.1",
Http20 = "HTTP/2.0",
}
export const httpProtoMap = new Map([
[HttpProto.Http10, HttpProtocol.Http10],
[HttpProto.Http11, HttpProtocol.Http11],
[HttpProto.Http20, HttpProtocol.Http20],
]);
interface UrlBarProps extends BoxProps {
method: HttpMethod;
onMethodChange?: (method: HttpMethod) => void;
url: string;
onUrlChange?: (url: string) => void;
proto: HttpProto;
onProtoChange?: (proto: HttpProto) => void;
}
function UrlBar(props: UrlBarProps) {
const { method, onMethodChange, url, onUrlChange, proto, onProtoChange, ...other } = props;
return (
<Box {...other} sx={{ ...other.sx, display: "flex" }}>
<FormControl>
<InputLabel id="req-method-label">Method</InputLabel>
<Select
labelId="req-method-label"
id="req-method"
value={method}
label="Method"
disabled={!onMethodChange}
onChange={(e) => onMethodChange && onMethodChange(e.target.value as HttpMethod)}
sx={{
width: "8rem",
".MuiOutlinedInput-notchedOutline": {
borderRightWidth: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderRightWidth: 1,
},
}}
>
{Object.values(HttpMethod).map((method) => (
<MenuItem key={method} value={method}>
{method}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="URL"
placeholder="E.g. “https://example.com/foobar”"
value={url}
disabled={!onUrlChange}
onChange={(e) => onUrlChange && onUrlChange(e.target.value)}
required
variant="outlined"
InputLabelProps={{
shrink: true,
}}
InputProps={{
sx: {
".MuiOutlinedInput-notchedOutline": {
borderRadius: 0,
},
},
}}
sx={{ flexGrow: 1 }}
/>
<FormControl>
<InputLabel id="req-proto-label">Protocol</InputLabel>
<Select
labelId="req-proto-label"
id="req-proto"
value={proto}
label="Protocol"
disabled={!onProtoChange}
onChange={(e) => onProtoChange && onProtoChange(e.target.value as HttpProto)}
sx={{
".MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 0,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 1,
},
}}
>
{Object.values(HttpProto).map((proto) => (
<MenuItem key={proto} value={proto}>
{proto}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
}
export default UrlBar;

View File

@ -67,6 +67,16 @@ export enum HttpProtocol {
Http20 = 'HTTP20'
}
export type HttpRequest = {
__typename?: 'HttpRequest';
body?: Maybe<Scalars['String']>;
headers: Array<HttpHeader>;
id: Scalars['ID'];
method: HttpMethod;
proto: HttpProtocol;
url: Scalars['URL'];
};
export type HttpRequestLog = {
__typename?: 'HttpRequestLog';
body?: Maybe<Scalars['String']>;
@ -101,6 +111,20 @@ export type HttpResponseLog = {
statusReason: Scalars['String'];
};
export type ModifyRequestInput = {
body?: InputMaybe<Scalars['String']>;
headers?: InputMaybe<Array<HttpHeaderInput>>;
id: Scalars['ID'];
method: HttpMethod;
proto: HttpProtocol;
url: Scalars['URL'];
};
export type ModifyRequestResult = {
__typename?: 'ModifyRequestResult';
success: Scalars['Boolean'];
};
export type Mutation = {
__typename?: 'Mutation';
clearHTTPRequestLog: ClearHttpRequestLogResult;
@ -110,6 +134,7 @@ export type Mutation = {
createSenderRequestFromHttpRequestLog: SenderRequest;
deleteProject: DeleteProjectResult;
deleteSenderRequests: DeleteSenderRequestsResult;
modifyRequest: ModifyRequestResult;
openProject?: Maybe<Project>;
sendRequest: SenderRequest;
setHttpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
@ -138,6 +163,11 @@ export type MutationDeleteProjectArgs = {
};
export type MutationModifyRequestArgs = {
request: ModifyRequestInput;
};
export type MutationOpenProjectArgs = {
id: Scalars['ID'];
};
@ -175,6 +205,8 @@ export type Query = {
httpRequestLog?: Maybe<HttpRequestLog>;
httpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
httpRequestLogs: Array<HttpRequestLog>;
interceptedRequest?: Maybe<HttpRequest>;
interceptedRequests: Array<HttpRequest>;
projects: Array<Project>;
scope: Array<ScopeRule>;
senderRequest?: Maybe<SenderRequest>;
@ -187,6 +219,11 @@ export type QueryHttpRequestLogArgs = {
};
export type QueryInterceptedRequestArgs = {
id: Scalars['ID'];
};
export type QuerySenderRequestArgs = {
id: Scalars['ID'];
};
@ -248,6 +285,20 @@ export type SenderRequestInput = {
url: Scalars['URL'];
};
export type GetInterceptedRequestQueryVariables = Exact<{
id: Scalars['ID'];
}>;
export type GetInterceptedRequestQuery = { __typename?: 'Query', interceptedRequest?: { __typename?: 'HttpRequest', id: string, url: any, method: HttpMethod, proto: HttpProtocol, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }> } | null };
export type ModifyRequestMutationVariables = Exact<{
request: ModifyRequestInput;
}>;
export type ModifyRequestMutation = { __typename?: 'Mutation', modifyRequest: { __typename?: 'ModifyRequestResult', success: boolean } };
export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>;
@ -353,7 +404,88 @@ export type GetSenderRequestsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetSenderRequestsQuery = { __typename?: 'Query', senderRequests: Array<{ __typename?: 'SenderRequest', id: string, url: any, method: HttpMethod, response?: { __typename?: 'HttpResponseLog', id: string, statusCode: number, statusReason: string } | null }> };
export type GetInterceptedRequestsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetInterceptedRequestsQuery = { __typename?: 'Query', interceptedRequests: Array<{ __typename?: 'HttpRequest', id: string, url: any, method: HttpMethod }> };
export const GetInterceptedRequestDocument = gql`
query GetInterceptedRequest($id: ID!) {
interceptedRequest(id: $id) {
id
url
method
proto
headers {
key
value
}
body
}
}
`;
/**
* __useGetInterceptedRequestQuery__
*
* To run a query within a React component, call `useGetInterceptedRequestQuery` and pass it any options that fit your needs.
* When your component renders, `useGetInterceptedRequestQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetInterceptedRequestQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useGetInterceptedRequestQuery(baseOptions: Apollo.QueryHookOptions<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>(GetInterceptedRequestDocument, options);
}
export function useGetInterceptedRequestLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>(GetInterceptedRequestDocument, options);
}
export type GetInterceptedRequestQueryHookResult = ReturnType<typeof useGetInterceptedRequestQuery>;
export type GetInterceptedRequestLazyQueryHookResult = ReturnType<typeof useGetInterceptedRequestLazyQuery>;
export type GetInterceptedRequestQueryResult = Apollo.QueryResult<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>;
export const ModifyRequestDocument = gql`
mutation ModifyRequest($request: ModifyRequestInput!) {
modifyRequest(request: $request) {
success
}
}
`;
export type ModifyRequestMutationFn = Apollo.MutationFunction<ModifyRequestMutation, ModifyRequestMutationVariables>;
/**
* __useModifyRequestMutation__
*
* To run a mutation, you first call `useModifyRequestMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useModifyRequestMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [modifyRequestMutation, { data, loading, error }] = useModifyRequestMutation({
* variables: {
* request: // value for 'request'
* },
* });
*/
export function useModifyRequestMutation(baseOptions?: Apollo.MutationHookOptions<ModifyRequestMutation, ModifyRequestMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ModifyRequestMutation, ModifyRequestMutationVariables>(ModifyRequestDocument, options);
}
export type ModifyRequestMutationHookResult = ReturnType<typeof useModifyRequestMutation>;
export type ModifyRequestMutationResult = Apollo.MutationResult<ModifyRequestMutation>;
export type ModifyRequestMutationOptions = Apollo.BaseMutationOptions<ModifyRequestMutation, ModifyRequestMutationVariables>;
export const CloseProjectDocument = gql`
mutation CloseProject {
closeProject {
@ -983,3 +1115,39 @@ export function useGetSenderRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHook
export type GetSenderRequestsQueryHookResult = ReturnType<typeof useGetSenderRequestsQuery>;
export type GetSenderRequestsLazyQueryHookResult = ReturnType<typeof useGetSenderRequestsLazyQuery>;
export type GetSenderRequestsQueryResult = Apollo.QueryResult<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>;
export const GetInterceptedRequestsDocument = gql`
query GetInterceptedRequests {
interceptedRequests {
id
url
method
}
}
`;
/**
* __useGetInterceptedRequestsQuery__
*
* To run a query within a React component, call `useGetInterceptedRequestsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetInterceptedRequestsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetInterceptedRequestsQuery({
* variables: {
* },
* });
*/
export function useGetInterceptedRequestsQuery(baseOptions?: Apollo.QueryHookOptions<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>(GetInterceptedRequestsDocument, options);
}
export function useGetInterceptedRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>(GetInterceptedRequestsDocument, options);
}
export type GetInterceptedRequestsQueryHookResult = ReturnType<typeof useGetInterceptedRequestsQuery>;
export type GetInterceptedRequestsLazyQueryHookResult = ReturnType<typeof useGetInterceptedRequestsLazyQuery>;
export type GetInterceptedRequestsQueryResult = Apollo.QueryResult<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>;

View File

@ -0,0 +1,7 @@
query GetInterceptedRequests {
interceptedRequests {
id
url
method
}
}

View File

@ -8,7 +8,19 @@ function createApolloClient() {
link: new HttpLink({
uri: "/api/graphql/",
}),
cache: new InMemoryCache(),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
interceptedRequests: {
merge(_, incoming) {
return incoming;
},
},
},
},
},
}),
});
}

View File

@ -0,0 +1,16 @@
import { KeyValuePair } from "./components/KeyValuePair";
function updateKeyPairItem(key: string, value: string, idx: number, items: KeyValuePair[]): KeyValuePair[] {
const updated = [...items];
updated[idx] = { key, value };
// Append an empty key-value pair if the last item in the array isn't blank
// anymore.
if (items.length - 1 === idx && items[idx].key === "" && items[idx].value === "") {
updated.push({ key: "", value: "" });
}
return updated;
}
export default updateKeyPairItem;

View File

@ -0,0 +1,28 @@
import { KeyValuePair } from "./components/KeyValuePair";
function updateURLQueryParams(url: string, queryParams: KeyValuePair[]) {
// Note: We don't use the `URL` interface, because we're potentially dealing
// with malformed/incorrect URLs, which would yield TypeErrors when constructed
// via `URL`.
let newURL = url;
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex !== -1) {
newURL = newURL.slice(0, questionMarkIndex);
}
const searchParams = new URLSearchParams();
for (const { key, value } of queryParams.filter(({ key }) => key !== "")) {
searchParams.append(key, value);
}
const rawQueryParams = decodeURI(searchParams.toString());
if (rawQueryParams == "") {
return newURL;
}
return newURL + "?" + rawQueryParams;
}
export default updateURLQueryParams;

View File

@ -7,6 +7,7 @@ import Head from "next/head";
import React from "react";
import { ActiveProjectProvider } from "lib/ActiveProjectContext";
import { InterceptedRequestsProvider } from "lib/InterceptedRequestsContext";
import { useApollo } from "lib/graphql/useApollo";
import createEmotionCache from "lib/mui/createEmotionCache";
import theme from "lib/mui/theme";
@ -32,10 +33,12 @@ export default function MyApp(props: MyAppProps) {
</Head>
<ApolloProvider client={apolloClient}>
<ActiveProjectProvider>
<InterceptedRequestsProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
</InterceptedRequestsProvider>
</ActiveProjectProvider>
</ApolloProvider>
</CacheProvider>

View File

@ -0,0 +1,12 @@
import { Layout, Page } from "features/Layout";
import Intercept from "features/intercept/components/Intercept";
function ProxyIntercept(): JSX.Element {
return (
<Layout page={Page.Intercept} title="Proxy intercept">
<Intercept />
</Layout>
);
}
export default ProxyIntercept;

View File

@ -10,9 +10,9 @@
"@jridgewell/trace-mapping" "^0.3.0"
"@apollo/client@^3.2.0":
version "3.5.8"
resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.5.8.tgz#7215b974c5988b6157530eb69369209210349fe0"
integrity sha512-MAm05+I1ullr64VLpZwon/ISnkMuNLf6vDqgo9wiMhHYBGT4yOAbAIseRdjCHZwfSx/7AUuBgaTNOssZPIr6FQ==
version "3.5.10"
resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.5.10.tgz#43463108a6e07ae602cca0afc420805a19339a71"
integrity sha512-tL3iSpFe9Oldq7gYikZK1dcYxp1c01nlSwtsMz75382HcI6fvQXyFXUCJTTK3wgO2/ckaBvRGw7VqjFREdVoRw==
dependencies:
"@graphql-typed-document-node/core" "^3.0.0"
"@wry/context" "^0.6.0"

View File

@ -131,6 +131,7 @@ type ComplexityRoot struct {
HTTPRequestLog func(childComplexity int, id ulid.ULID) int
HTTPRequestLogFilter func(childComplexity int) int
HTTPRequestLogs func(childComplexity int) int
InterceptedRequest func(childComplexity int, id ulid.ULID) int
InterceptedRequests func(childComplexity int) int
Projects func(childComplexity int) int
Scope func(childComplexity int) int
@ -192,6 +193,7 @@ type QueryResolver interface {
SenderRequest(ctx context.Context, id ulid.ULID) (*SenderRequest, error)
SenderRequests(ctx context.Context) ([]SenderRequest, error)
InterceptedRequests(ctx context.Context) ([]HTTPRequest, error)
InterceptedRequest(ctx context.Context, id ulid.ULID) (*HTTPRequest, error)
}
type executableSchema struct {
@ -607,6 +609,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Query.HTTPRequestLogs(childComplexity), true
case "Query.interceptedRequest":
if e.complexity.Query.InterceptedRequest == nil {
break
}
args, err := ec.field_Query_interceptedRequest_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Query.InterceptedRequest(childComplexity, args["id"].(ulid.ULID)), true
case "Query.interceptedRequests":
if e.complexity.Query.InterceptedRequests == nil {
break
@ -973,6 +987,7 @@ type Query {
senderRequest(id: ID!): SenderRequest
senderRequests: [SenderRequest!]!
interceptedRequests: [HttpRequest!]!
interceptedRequest(id: ID!): HttpRequest
}
type Mutation {
@ -1202,6 +1217,21 @@ func (ec *executionContext) field_Query_httpRequestLog_args(ctx context.Context,
return args, nil
}
func (ec *executionContext) field_Query_interceptedRequest_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
var arg0 ulid.ULID
if tmp, ok := rawArgs["id"]; ok {
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id"))
arg0, err = ec.unmarshalNID2githubᚗcomᚋoklogᚋulidᚐULID(ctx, tmp)
if err != nil {
return nil, err
}
}
args["id"] = arg0
return args, nil
}
func (ec *executionContext) field_Query_senderRequest_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
@ -3190,6 +3220,45 @@ func (ec *executionContext) _Query_interceptedRequests(ctx context.Context, fiel
return ec.marshalNHttpRequest2ᚕgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPRequestᚄ(ctx, field.Selections, res)
}
func (ec *executionContext) _Query_interceptedRequest(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Query",
Field: field,
Args: nil,
IsMethod: true,
IsResolver: true,
}
ctx = graphql.WithFieldContext(ctx, fc)
rawArgs := field.ArgumentMap(ec.Variables)
args, err := ec.field_Query_interceptedRequest_args(ctx, rawArgs)
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
fc.Args = args
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Query().InterceptedRequest(rctx, args["id"].(ulid.ULID))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*HTTPRequest)
fc.Result = res
return ec.marshalOHttpRequest2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPRequest(ctx, field.Selections, res)
}
func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -5805,6 +5874,17 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
}
return res
})
case "interceptedRequest":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
}
}()
res = ec._Query_interceptedRequest(ctx, field)
return res
})
case "__type":
out.Values[i] = ec._Query___type(ctx, field)
case "__schema":
@ -7117,6 +7197,13 @@ func (ec *executionContext) marshalOHttpProtocol2ᚖgithubᚗcomᚋdstotijnᚋhe
return v
}
func (ec *executionContext) marshalOHttpRequest2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPRequest(ctx context.Context, sel ast.SelectionSet, v *HTTPRequest) graphql.Marshaler {
if v == nil {
return graphql.Null
}
return ec._HttpRequest(ctx, sel, v)
}
func (ec *executionContext) marshalOHttpRequestLog2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPRequestLog(ctx context.Context, sel ast.SelectionSet, v *HTTPRequestLog) graphql.Marshaler {
if v == nil {
return graphql.Null

View File

@ -541,6 +541,22 @@ func (r *queryResolver) InterceptedRequests(ctx context.Context) ([]HTTPRequest,
return httpReqs, nil
}
func (r *queryResolver) InterceptedRequest(ctx context.Context, id ulid.ULID) (*HTTPRequest, error) {
req, err := r.InterceptService.RequestByID(id)
if errors.Is(err, intercept.ErrRequestNotFound) {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("could not get request by ID: %w", err)
}
httpReq, err := parseHTTPRequest(req)
if err != nil {
return nil, err
}
return &httpReq, nil
}
func (r *mutationResolver) ModifyRequest(ctx context.Context, input ModifyRequestInput) (*ModifyRequestResult, error) {
body := ""
if input.Body != nil {

View File

@ -148,6 +148,7 @@ type Query {
senderRequest(id: ID!): SenderRequest
senderRequests: [SenderRequest!]!
interceptedRequests: [HttpRequest!]!
interceptedRequest(id: ID!): HttpRequest
}
type Mutation {

View File

@ -176,6 +176,19 @@ func (svc *Service) Requests() []*http.Request {
return reqs
}
// Request returns an intercepted request by ID. It's safe for concurrent use.
func (svc *Service) RequestByID(id ulid.ULID) (*http.Request, error) {
svc.mu.RLock()
defer svc.mu.RUnlock()
req, ok := svc.requests[id]
if !ok {
return nil, ErrRequestNotFound
}
return req.req, nil
}
func (ids RequestIDs) Len() int {
return len(ids)
}