mirror of
https://github.com/dstotijn/hetty
synced 2024-11-10 06:04:19 +00:00
Add intercept module
This commit is contained in:
parent
6ffc55cde3
commit
02408b5196
51 changed files with 5779 additions and 304 deletions
|
@ -17,7 +17,12 @@
|
||||||
"prettier/prettier": ["error"],
|
"prettier/prettier": ["error"],
|
||||||
"@next/next/no-css-tags": "off",
|
"@next/next/no-css-tags": "off",
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-unused-vars": "error",
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"ignoreRestSiblings": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
"import/default": "off",
|
"import/default": "off",
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
|
import AltRouteIcon from "@mui/icons-material/AltRoute";
|
||||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||||
import FolderIcon from "@mui/icons-material/Folder";
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
|
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
|
||||||
import HomeIcon from "@mui/icons-material/Home";
|
import HomeIcon from "@mui/icons-material/Home";
|
||||||
import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
|
import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
|
||||||
import MenuIcon from "@mui/icons-material/Menu";
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
import SendIcon from "@mui/icons-material/Send";
|
import SendIcon from "@mui/icons-material/Send";
|
||||||
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
|
|
||||||
import {
|
import {
|
||||||
Theme,
|
Theme,
|
||||||
useTheme,
|
useTheme,
|
||||||
|
@ -19,6 +20,7 @@ import {
|
||||||
CSSObject,
|
CSSObject,
|
||||||
Box,
|
Box,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
|
Badge,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar";
|
import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar";
|
||||||
import MuiDrawer from "@mui/material/Drawer";
|
import MuiDrawer from "@mui/material/Drawer";
|
||||||
|
@ -28,15 +30,18 @@ import Link from "next/link";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import { useActiveProject } from "lib/ActiveProjectContext";
|
import { useActiveProject } from "lib/ActiveProjectContext";
|
||||||
|
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
|
||||||
|
|
||||||
export enum Page {
|
export enum Page {
|
||||||
Home,
|
Home,
|
||||||
GetStarted,
|
GetStarted,
|
||||||
|
Intercept,
|
||||||
Projects,
|
Projects,
|
||||||
ProxySetup,
|
ProxySetup,
|
||||||
ProxyLogs,
|
ProxyLogs,
|
||||||
Sender,
|
Sender,
|
||||||
Scope,
|
Scope,
|
||||||
|
Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawerWidth = 240;
|
const drawerWidth = 240;
|
||||||
|
@ -135,6 +140,7 @@ interface Props {
|
||||||
|
|
||||||
export function Layout({ title, page, children }: Props): JSX.Element {
|
export function Layout({ title, page, children }: Props): JSX.Element {
|
||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
|
const interceptedRequests = useInterceptedRequests();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
@ -204,12 +210,24 @@ export function Layout({ title, page, children }: Props): JSX.Element {
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/proxy/logs" passHref>
|
<Link href="/proxy/logs" passHref>
|
||||||
<ListItemButton key="proxyLogs" disabled={!activeProject} selected={page === Page.ProxyLogs}>
|
<ListItemButton key="proxyLogs" disabled={!activeProject} selected={page === Page.ProxyLogs}>
|
||||||
<Tooltip title="Proxy">
|
<Tooltip title="Proxy logs">
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<SettingsEthernetIcon />
|
<FormatListBulletedIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
</Tooltip>
|
</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>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/sender" passHref>
|
<Link href="/sender" passHref>
|
||||||
|
|
366
admin/src/features/intercept/components/EditRequest.tsx
Normal file
366
admin/src/features/intercept/components/EditRequest.tsx
Normal file
|
@ -0,0 +1,366 @@
|
||||||
|
import CancelIcon from "@mui/icons-material/Cancel";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import SendIcon from "@mui/icons-material/Send";
|
||||||
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
|
import { Alert, Box, Button, CircularProgress, IconButton, Tooltip, 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 Link from "lib/components/Link";
|
||||||
|
import RequestTabs from "lib/components/RequestTabs";
|
||||||
|
import ResponseStatus from "lib/components/ResponseStatus";
|
||||||
|
import ResponseTabs from "lib/components/ResponseTabs";
|
||||||
|
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
|
||||||
|
import {
|
||||||
|
HttpProtocol,
|
||||||
|
HttpRequest,
|
||||||
|
useCancelRequestMutation,
|
||||||
|
useCancelResponseMutation,
|
||||||
|
useGetInterceptedRequestQuery,
|
||||||
|
useModifyRequestMutation,
|
||||||
|
useModifyResponseMutation,
|
||||||
|
} 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.
|
||||||
|
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 [reqHeaders, setReqHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
|
||||||
|
const [resHeaders, setResHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
|
||||||
|
const [reqBody, setReqBody] = useState("");
|
||||||
|
const [resBody, setResBody] = 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 handleReqHeaderChange = (key: string, value: string, idx: number) => {
|
||||||
|
setReqHeaders((prev) => updateKeyPairItem(key, value, idx, prev));
|
||||||
|
};
|
||||||
|
const handleReqHeaderDelete = (idx: number) => {
|
||||||
|
setReqHeaders((prev) => prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResHeaderChange = (key: string, value: string, idx: number) => {
|
||||||
|
setResHeaders((prev) => updateKeyPairItem(key, value, idx, prev));
|
||||||
|
};
|
||||||
|
const handleResHeaderDelete = (idx: number) => {
|
||||||
|
setResHeaders((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);
|
||||||
|
setReqBody(interceptedRequest.body || "");
|
||||||
|
|
||||||
|
const newQueryParams = queryParamsFromURL(interceptedRequest.url);
|
||||||
|
// Push empty row.
|
||||||
|
newQueryParams.push({ key: "", value: "" });
|
||||||
|
setQueryParams(newQueryParams);
|
||||||
|
|
||||||
|
const newReqHeaders = sortKeyValuePairs(interceptedRequest.headers || []);
|
||||||
|
setReqHeaders([...newReqHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
|
||||||
|
|
||||||
|
setResBody(interceptedRequest.response?.body || "");
|
||||||
|
const newResHeaders = sortKeyValuePairs(interceptedRequest.response?.headers || []);
|
||||||
|
setResHeaders([...newResHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const interceptedReq =
|
||||||
|
reqId && !getReqResult?.data?.interceptedRequest?.response ? getReqResult?.data?.interceptedRequest : undefined;
|
||||||
|
const interceptedRes = reqId ? getReqResult?.data?.interceptedRequest?.response : undefined;
|
||||||
|
|
||||||
|
const [modifyRequest, modifyReqResult] = useModifyRequestMutation();
|
||||||
|
const [cancelRequest, cancelReqResult] = useCancelRequestMutation();
|
||||||
|
|
||||||
|
const [modifyResponse, modifyResResult] = useModifyResponseMutation();
|
||||||
|
const [cancelResponse, cancelResResult] = useCancelResponseMutation();
|
||||||
|
|
||||||
|
const onActionCompleted = () => {
|
||||||
|
setURL("");
|
||||||
|
setMethod(HttpMethod.Get);
|
||||||
|
setReqBody("");
|
||||||
|
setQueryParams([]);
|
||||||
|
setReqHeaders([]);
|
||||||
|
router.replace(`/proxy/intercept`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit: React.FormEventHandler = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (interceptedReq) {
|
||||||
|
modifyRequest({
|
||||||
|
variables: {
|
||||||
|
request: {
|
||||||
|
id: interceptedReq.id,
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
proto: httpProtoMap.get(proto) || HttpProtocol.Http20,
|
||||||
|
headers: reqHeaders.filter((kv) => kv.key !== ""),
|
||||||
|
body: reqBody || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update(cache) {
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
interceptedRequests(existing: HttpRequest[], { readField }) {
|
||||||
|
return existing.filter((ref) => interceptedReq.id !== readField("id", ref));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCompleted: onActionCompleted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interceptedRes) {
|
||||||
|
modifyResponse({
|
||||||
|
variables: {
|
||||||
|
response: {
|
||||||
|
requestID: interceptedRes.id,
|
||||||
|
proto: interceptedRes.proto, // TODO: Allow modifying
|
||||||
|
statusCode: interceptedRes.statusCode, // TODO: Allow modifying
|
||||||
|
statusReason: interceptedRes.statusReason, // TODO: Allow modifying
|
||||||
|
headers: resHeaders.filter((kv) => kv.key !== ""),
|
||||||
|
body: resBody || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update(cache) {
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
interceptedRequests(existing: HttpRequest[], { readField }) {
|
||||||
|
return existing.filter((ref) => interceptedRes.id !== readField("id", ref));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCompleted: onActionCompleted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReqCancelClick = () => {
|
||||||
|
if (!interceptedReq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelRequest({
|
||||||
|
variables: {
|
||||||
|
id: interceptedReq.id,
|
||||||
|
},
|
||||||
|
update(cache) {
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
interceptedRequests(existing: HttpRequest[], { readField }) {
|
||||||
|
return existing.filter((ref) => interceptedReq.id !== readField("id", ref));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCompleted: onActionCompleted,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResCancelClick = () => {
|
||||||
|
if (!interceptedRes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelResponse({
|
||||||
|
variables: {
|
||||||
|
requestID: interceptedRes.id,
|
||||||
|
},
|
||||||
|
update(cache) {
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
interceptedRequests(existing: HttpRequest[], { readField }) {
|
||||||
|
return existing.filter((ref) => interceptedRes.id !== readField("id", ref));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCompleted: onActionCompleted,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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" }}
|
||||||
|
/>
|
||||||
|
{!interceptedRes && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disableElevation
|
||||||
|
type="submit"
|
||||||
|
disabled={!interceptedReq || modifyReqResult.loading || cancelReqResult.loading}
|
||||||
|
startIcon={modifyReqResult.loading ? <CircularProgress size={22} /> : <SendIcon />}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
disableElevation
|
||||||
|
onClick={handleReqCancelClick}
|
||||||
|
disabled={!interceptedReq || modifyReqResult.loading || cancelReqResult.loading}
|
||||||
|
startIcon={cancelReqResult.loading ? <CircularProgress size={22} /> : <CancelIcon />}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{interceptedRes && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disableElevation
|
||||||
|
type="submit"
|
||||||
|
disabled={modifyResResult.loading || cancelResResult.loading}
|
||||||
|
endIcon={modifyResResult.loading ? <CircularProgress size={22} /> : <DownloadIcon />}
|
||||||
|
>
|
||||||
|
Receive
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
disableElevation
|
||||||
|
onClick={handleResCancelClick}
|
||||||
|
disabled={modifyResResult.loading || cancelResResult.loading}
|
||||||
|
endIcon={cancelResResult.loading ? <CircularProgress size={22} /> : <CancelIcon />}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Tooltip title="Intercept settings">
|
||||||
|
<IconButton LinkComponent={Link} href="/settings#intercept">
|
||||||
|
<SettingsIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
{modifyReqResult.error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 1 }}>
|
||||||
|
{modifyReqResult.error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{cancelReqResult.error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 1 }}>
|
||||||
|
{cancelReqResult.error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flex="1 auto" overflow="scroll">
|
||||||
|
{interceptedReq && (
|
||||||
|
<Box sx={{ height: "100%", pb: 2 }}>
|
||||||
|
<Typography variant="overline" color="textSecondary" sx={{ position: "absolute", right: 0, mt: 1.2 }}>
|
||||||
|
Request
|
||||||
|
</Typography>
|
||||||
|
<RequestTabs
|
||||||
|
queryParams={interceptedReq ? queryParams : []}
|
||||||
|
headers={interceptedReq ? reqHeaders : []}
|
||||||
|
body={reqBody}
|
||||||
|
onQueryParamChange={interceptedReq ? handleQueryParamChange : undefined}
|
||||||
|
onQueryParamDelete={interceptedReq ? handleQueryParamDelete : undefined}
|
||||||
|
onHeaderChange={interceptedReq ? handleReqHeaderChange : undefined}
|
||||||
|
onHeaderDelete={interceptedReq ? handleReqHeaderDelete : undefined}
|
||||||
|
onBodyChange={interceptedReq ? setReqBody : undefined}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{interceptedRes && (
|
||||||
|
<Box sx={{ height: "100%", pb: 2 }}>
|
||||||
|
<Box sx={{ position: "absolute", right: 0, mt: 1.4 }}>
|
||||||
|
<Typography variant="overline" color="textSecondary" sx={{ float: "right", ml: 3 }}>
|
||||||
|
Response
|
||||||
|
</Typography>
|
||||||
|
{interceptedRes && (
|
||||||
|
<Box sx={{ float: "right", mt: 0.2 }}>
|
||||||
|
<ResponseStatus
|
||||||
|
proto={interceptedRes.proto}
|
||||||
|
statusCode={interceptedRes.statusCode}
|
||||||
|
statusReason={interceptedRes.statusReason}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<ResponseTabs
|
||||||
|
headers={interceptedRes ? resHeaders : []}
|
||||||
|
body={resBody}
|
||||||
|
onHeaderChange={interceptedRes ? handleResHeaderChange : undefined}
|
||||||
|
onHeaderDelete={interceptedRes ? handleResHeaderDelete : undefined}
|
||||||
|
onBodyChange={interceptedRes ? setResBody : undefined}
|
||||||
|
hasResponse={interceptedRes !== undefined && interceptedRes !== null}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditRequest;
|
21
admin/src/features/intercept/components/Intercept.tsx
Normal file
21
admin/src/features/intercept/components/Intercept.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
33
admin/src/features/intercept/components/Requests.tsx
Normal file
33
admin/src/features/intercept/components/Requests.tsx
Normal 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;
|
|
@ -0,0 +1,5 @@
|
||||||
|
mutation CancelRequest($id: ID!) {
|
||||||
|
cancelRequest(id: $id) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
mutation CancelResponse($requestID: ID!) {
|
||||||
|
cancelResponse(requestID: $requestID) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
query GetInterceptedRequest($id: ID!) {
|
||||||
|
interceptedRequest(id: $id) {
|
||||||
|
id
|
||||||
|
url
|
||||||
|
method
|
||||||
|
proto
|
||||||
|
headers {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
body
|
||||||
|
response {
|
||||||
|
id
|
||||||
|
proto
|
||||||
|
statusCode
|
||||||
|
statusReason
|
||||||
|
headers {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
mutation ModifyRequest($request: ModifyRequestInput!) {
|
||||||
|
modifyRequest(request: $request) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
mutation ModifyResponse($response: ModifyResponseInput!) {
|
||||||
|
modifyResponse(response: $response) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import CloseIcon from "@mui/icons-material/Close";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import DescriptionIcon from "@mui/icons-material/Description";
|
import DescriptionIcon from "@mui/icons-material/Description";
|
||||||
import LaunchIcon from "@mui/icons-material/Launch";
|
import LaunchIcon from "@mui/icons-material/Launch";
|
||||||
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
import { Alert } from "@mui/lab";
|
import { Alert } from "@mui/lab";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
@ -29,6 +30,7 @@ import React, { useState } from "react";
|
||||||
|
|
||||||
import useOpenProjectMutation from "../hooks/useOpenProjectMutation";
|
import useOpenProjectMutation from "../hooks/useOpenProjectMutation";
|
||||||
|
|
||||||
|
import Link, { NextLinkComposed } from "lib/components/Link";
|
||||||
import {
|
import {
|
||||||
ProjectsQuery,
|
ProjectsQuery,
|
||||||
useCloseProjectMutation,
|
useCloseProjectMutation,
|
||||||
|
@ -179,6 +181,11 @@ function ProjectList(): JSX.Element {
|
||||||
{project.name} {project.isActive && <em>(Active)</em>}
|
{project.name} {project.isActive && <em>(Active)</em>}
|
||||||
</ListItemText>
|
</ListItemText>
|
||||||
<ListItemSecondaryAction>
|
<ListItemSecondaryAction>
|
||||||
|
<Tooltip title="Project settings">
|
||||||
|
<IconButton LinkComponent={Link} href="/settings" disabled={!project.isActive}>
|
||||||
|
<SettingsIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
{project.isActive && (
|
{project.isActive && (
|
||||||
<Tooltip title="Close project">
|
<Tooltip title="Close project">
|
||||||
<IconButton onClick={() => closeProject()}>
|
<IconButton onClick={() => closeProject()}>
|
||||||
|
|
15
admin/src/features/projects/graphql/activeProject.graphql
Normal file
15
admin/src/features/projects/graphql/activeProject.graphql
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
query ActiveProject {
|
||||||
|
activeProject {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
settings {
|
||||||
|
intercept {
|
||||||
|
requestsEnabled
|
||||||
|
responsesEnabled
|
||||||
|
requestFilter
|
||||||
|
responseFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
61
admin/src/features/reqlog/components/Actions.tsx
Normal file
61
admin/src/features/reqlog/components/Actions.tsx
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
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 { useActiveProject } from "lib/ActiveProjectContext";
|
||||||
|
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
|
||||||
|
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
|
||||||
|
import { HttpRequestLogsDocument, useClearHttpRequestLogMutation } from "lib/graphql/generated";
|
||||||
|
|
||||||
|
function Actions(): JSX.Element {
|
||||||
|
const activeProject = useActiveProject();
|
||||||
|
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>}
|
||||||
|
|
||||||
|
{(activeProject?.settings.intercept.requestsEnabled || activeProject?.settings.intercept.responsesEnabled) && (
|
||||||
|
<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;
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import Actions from "./Actions";
|
||||||
import LogDetail from "./LogDetail";
|
import LogDetail from "./LogDetail";
|
||||||
import Search from "./Search";
|
import Search from "./Search";
|
||||||
|
|
||||||
|
@ -94,7 +95,14 @@ export function RequestLogs(): JSX.Element {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box display="flex" flexDirection="column" height="100%">
|
<Box display="flex" flexDirection="column" height="100%">
|
||||||
|
<Box display="flex">
|
||||||
|
<Box flex="1 auto">
|
||||||
<Search />
|
<Search />
|
||||||
|
</Box>
|
||||||
|
<Box pt={0.5}>
|
||||||
|
<Actions />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
<Box sx={{ display: "flex", flex: "1 auto", position: "relative" }}>
|
<Box sx={{ display: "flex", flex: "1 auto", position: "relative" }}>
|
||||||
<SplitPane split="horizontal" size={"40%"}>
|
<SplitPane split="horizontal" size={"40%"}>
|
||||||
<Box sx={{ width: "100%", height: "100%", pb: 2 }}>
|
<Box sx={{ width: "100%", height: "100%", pb: 2 }}>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
|
||||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import { Alert } from "@mui/lab";
|
import { Alert } from "@mui/lab";
|
||||||
|
@ -17,11 +16,8 @@ import {
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
|
|
||||||
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
|
|
||||||
import {
|
import {
|
||||||
HttpRequestLogFilterDocument,
|
HttpRequestLogFilterDocument,
|
||||||
HttpRequestLogsDocument,
|
|
||||||
useClearHttpRequestLogMutation,
|
|
||||||
useHttpRequestLogFilterQuery,
|
useHttpRequestLogFilterQuery,
|
||||||
useSetHttpRequestLogFilterMutation,
|
useSetHttpRequestLogFilterMutation,
|
||||||
} from "lib/graphql/generated";
|
} 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 filterRef = useRef<HTMLFormElement>(null);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
|
||||||
|
@ -81,7 +72,6 @@ function Search(): JSX.Element {
|
||||||
<Box>
|
<Box>
|
||||||
<Error prefix="Error fetching filter" error={filterResult.error} />
|
<Error prefix="Error fetching filter" error={filterResult.error} />
|
||||||
<Error prefix="Error setting filter" error={setFilterResult.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 }}>
|
<Box style={{ display: "flex", flex: 1 }}>
|
||||||
<ClickAwayListener onClickAway={handleClickAway}>
|
<ClickAwayListener onClickAway={handleClickAway}>
|
||||||
<Paper
|
<Paper
|
||||||
|
@ -161,22 +151,8 @@ function Search(): JSX.Element {
|
||||||
</Popper>
|
</Popper>
|
||||||
</Paper>
|
</Paper>
|
||||||
</ClickAwayListener>
|
</ClickAwayListener>
|
||||||
<Box style={{ marginLeft: "auto" }}>
|
|
||||||
<Tooltip title="Clear all">
|
|
||||||
<IconButton onClick={clearHTTPConfirmationDialog.open}>
|
|
||||||
<DeleteIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
</Box>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,4 @@
|
||||||
import {
|
import { Alert, Box, Button, Typography } from "@mui/material";
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
BoxProps,
|
|
||||||
Button,
|
|
||||||
InputLabel,
|
|
||||||
FormControl,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
@ -17,76 +6,16 @@ import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
|
||||||
import RequestTabs from "lib/components/RequestTabs";
|
import RequestTabs from "lib/components/RequestTabs";
|
||||||
import Response from "lib/components/Response";
|
import Response from "lib/components/Response";
|
||||||
import SplitPane from "lib/components/SplitPane";
|
import SplitPane from "lib/components/SplitPane";
|
||||||
|
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
|
||||||
import {
|
import {
|
||||||
GetSenderRequestQuery,
|
GetSenderRequestQuery,
|
||||||
useCreateOrUpdateSenderRequestMutation,
|
useCreateOrUpdateSenderRequestMutation,
|
||||||
HttpProtocol,
|
|
||||||
useGetSenderRequestQuery,
|
useGetSenderRequestQuery,
|
||||||
useSendRequestMutation,
|
useSendRequestMutation,
|
||||||
} from "lib/graphql/generated";
|
} from "lib/graphql/generated";
|
||||||
import { queryParamsFromURL } from "lib/queryParamsFromURL";
|
import { queryParamsFromURL } from "lib/queryParamsFromURL";
|
||||||
|
import updateKeyPairItem from "lib/updateKeyPairItem";
|
||||||
enum HttpMethod {
|
import updateURLQueryParams from "lib/updateURLQueryParams";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditRequest(): JSX.Element {
|
function EditRequest(): JSX.Element {
|
||||||
const router = useRouter();
|
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;
|
export default EditRequest;
|
||||||
|
|
294
admin/src/features/settings/components/Settings.tsx
Normal file
294
admin/src/features/settings/components/Settings.tsx
Normal file
|
@ -0,0 +1,294 @@
|
||||||
|
import { useApolloClient } from "@apollo/client";
|
||||||
|
import { TabContext, TabPanel } from "@mui/lab";
|
||||||
|
import TabList from "@mui/lab/TabList";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
FormHelperText,
|
||||||
|
Snackbar,
|
||||||
|
Switch,
|
||||||
|
Tab,
|
||||||
|
TextField,
|
||||||
|
TextFieldProps,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { SwitchBaseProps } from "@mui/material/internal/SwitchBase";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useActiveProject } from "lib/ActiveProjectContext";
|
||||||
|
import Link from "lib/components/Link";
|
||||||
|
import { ActiveProjectDocument, useUpdateInterceptSettingsMutation } from "lib/graphql/generated";
|
||||||
|
import { withoutTypename } from "lib/graphql/omitTypename";
|
||||||
|
|
||||||
|
enum TabValue {
|
||||||
|
Intercept = "intercept",
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterTextField(props: TextFieldProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
InputProps={{
|
||||||
|
sx: { fontFamily: "'JetBrains Mono', monospace" },
|
||||||
|
autoCorrect: "false",
|
||||||
|
spellCheck: "false",
|
||||||
|
}}
|
||||||
|
InputLabelProps={{
|
||||||
|
shrink: true,
|
||||||
|
}}
|
||||||
|
margin="normal"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings(): JSX.Element {
|
||||||
|
const client = useApolloClient();
|
||||||
|
const activeProject = useActiveProject();
|
||||||
|
const [updateInterceptSettings, updateIntercepSettingsResult] = useUpdateInterceptSettingsMutation({
|
||||||
|
onCompleted(data) {
|
||||||
|
client.cache.updateQuery({ query: ActiveProjectDocument }, (cachedData) => ({
|
||||||
|
activeProject: {
|
||||||
|
...cachedData.activeProject,
|
||||||
|
settings: {
|
||||||
|
...cachedData.activeProject.settings,
|
||||||
|
intercept: data.updateInterceptSettings,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
setInterceptReqFilter(data.updateInterceptSettings.requestFilter || "");
|
||||||
|
setInterceptResFilter(data.updateInterceptSettings.responseFilter || "");
|
||||||
|
setSettingsUpdatedOpen(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [interceptReqFilter, setInterceptReqFilter] = useState("");
|
||||||
|
const [interceptResFilter, setInterceptResFilter] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInterceptReqFilter(activeProject?.settings.intercept.requestFilter || "");
|
||||||
|
}, [activeProject?.settings.intercept.requestFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInterceptResFilter(activeProject?.settings.intercept.responseFilter || "");
|
||||||
|
}, [activeProject?.settings.intercept.responseFilter]);
|
||||||
|
|
||||||
|
const handleReqInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => {
|
||||||
|
if (!activeProject) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInterceptSettings({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
...withoutTypename(activeProject.settings.intercept),
|
||||||
|
requestsEnabled: checked,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => {
|
||||||
|
if (!activeProject) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInterceptSettings({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
...withoutTypename(activeProject.settings.intercept),
|
||||||
|
responsesEnabled: checked,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInterceptReqFilter = () => {
|
||||||
|
if (!activeProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInterceptSettings({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
...withoutTypename(activeProject.settings.intercept),
|
||||||
|
requestFilter: interceptReqFilter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInterceptResFilter = () => {
|
||||||
|
if (!activeProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInterceptSettings({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
...withoutTypename(activeProject.settings.intercept),
|
||||||
|
responseFilter: interceptResFilter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const [tabValue, setTabValue] = useState(TabValue.Intercept);
|
||||||
|
const [settingsUpdatedOpen, setSettingsUpdatedOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleSettingsUpdatedClose = (_: Event | React.SyntheticEvent, reason?: string) => {
|
||||||
|
if (reason === "clickaway") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettingsUpdatedOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabSx = {
|
||||||
|
textTransform: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p={4}>
|
||||||
|
<Snackbar open={settingsUpdatedOpen} autoHideDuration={3000} onClose={handleSettingsUpdatedClose}>
|
||||||
|
<Alert onClose={handleSettingsUpdatedClose} severity="info">
|
||||||
|
Intercept settings have been updated.
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
|
<Typography variant="h4" sx={{ mb: 2 }}>
|
||||||
|
Settings
|
||||||
|
</Typography>
|
||||||
|
<Typography paragraph sx={{ mb: 4 }}>
|
||||||
|
Settings allow you to tweak the behaviour of Hetty’s features.
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||||
|
Project settings
|
||||||
|
</Typography>
|
||||||
|
{!activeProject && (
|
||||||
|
<Typography paragraph>
|
||||||
|
There is no project active. To configure project settings, first <Link href="/projects">open a project</Link>.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{activeProject && (
|
||||||
|
<>
|
||||||
|
<TabContext value={tabValue}>
|
||||||
|
<TabList onChange={(_, value) => setTabValue(value)} sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||||
|
<Tab value={TabValue.Intercept} label="Intercept" sx={tabSx} />
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanel value={TabValue.Intercept} sx={{ px: 0 }}>
|
||||||
|
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||||
|
Requests
|
||||||
|
</Typography>
|
||||||
|
<FormControl sx={{ mb: 2 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
disabled={updateIntercepSettingsResult.loading}
|
||||||
|
onChange={handleReqInterceptEnabled}
|
||||||
|
checked={activeProject.settings.intercept.requestsEnabled}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enable request interception"
|
||||||
|
labelPlacement="start"
|
||||||
|
sx={{ display: "inline-block", m: 0 }}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
When enabled, incoming HTTP requests to the proxy are stalled for{" "}
|
||||||
|
<Link href="/proxy/intercept">manual review</Link>.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
<form>
|
||||||
|
<FormControl sx={{ width: "50%" }}>
|
||||||
|
<FilterTextField
|
||||||
|
label="Request filter"
|
||||||
|
placeholder={`Example: method = "GET" OR url =~ "/foobar"`}
|
||||||
|
value={interceptReqFilter}
|
||||||
|
onChange={(e) => setInterceptReqFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
Filter expression to match incoming requests on. When set, only matching requests are intercepted.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
py: 1.8,
|
||||||
|
}}
|
||||||
|
onClick={handleInterceptReqFilter}
|
||||||
|
disabled={updateIntercepSettingsResult.loading}
|
||||||
|
startIcon={updateIntercepSettingsResult.loading ? <CircularProgress size={22} /> : undefined}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<Typography variant="h6" sx={{ mt: 3 }}>
|
||||||
|
Responses
|
||||||
|
</Typography>
|
||||||
|
<FormControl sx={{ mb: 2 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
disabled={updateIntercepSettingsResult.loading}
|
||||||
|
onChange={handleResInterceptEnabled}
|
||||||
|
checked={activeProject.settings.intercept.responsesEnabled}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enable response interception"
|
||||||
|
labelPlacement="start"
|
||||||
|
sx={{ display: "inline-block", m: 0 }}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
When enabled, HTTP responses received by the proxy are stalled for{" "}
|
||||||
|
<Link href="/proxy/intercept">manual review</Link>.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
<form>
|
||||||
|
<FormControl sx={{ width: "50%" }}>
|
||||||
|
<FilterTextField
|
||||||
|
label="Response filter"
|
||||||
|
placeholder={`Example: statusCode =~ "^2" OR body =~ "foobar"`}
|
||||||
|
value={interceptResFilter}
|
||||||
|
onChange={(e) => setInterceptResFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
Filter expression to match received responses on. When set, only matching responses are intercepted.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
py: 1.8,
|
||||||
|
}}
|
||||||
|
onClick={handleInterceptResFilter}
|
||||||
|
disabled={updateIntercepSettingsResult.loading}
|
||||||
|
startIcon={updateIntercepSettingsResult.loading ? <CircularProgress size={22} /> : undefined}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</TabPanel>
|
||||||
|
</TabContext>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) {
|
||||||
|
updateInterceptSettings(input: $input) {
|
||||||
|
requestsEnabled
|
||||||
|
responsesEnabled
|
||||||
|
requestFilter
|
||||||
|
responseFilter
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { createContext, useContext } from "react";
|
import React, { createContext, useContext } from "react";
|
||||||
|
|
||||||
import { Project, useProjectsQuery } from "./graphql/generated";
|
import { Project, useActiveProjectQuery } from "./graphql/generated";
|
||||||
|
|
||||||
const ActiveProjectContext = createContext<Project | null>(null);
|
const ActiveProjectContext = createContext<Project | null>(null);
|
||||||
|
|
||||||
|
@ -9,8 +9,8 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveProjectProvider({ children }: Props): JSX.Element {
|
export function ActiveProjectProvider({ children }: Props): JSX.Element {
|
||||||
const { data } = useProjectsQuery();
|
const { data } = useActiveProjectQuery();
|
||||||
const project = data?.projects.find((project) => project.isActive) || null;
|
const project = data?.activeProject || null;
|
||||||
|
|
||||||
return <ActiveProjectContext.Provider value={project}>{children}</ActiveProjectContext.Provider>;
|
return <ActiveProjectContext.Provider value={project}>{children}</ActiveProjectContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
22
admin/src/lib/InterceptedRequestsContext.tsx
Normal file
22
admin/src/lib/InterceptedRequestsContext.tsx
Normal 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);
|
||||||
|
}
|
94
admin/src/lib/components/Link.tsx
Normal file
94
admin/src/lib/components/Link.tsx
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import MuiLink, { LinkProps as MuiLinkProps } from "@mui/material/Link";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import NextLink, { LinkProps as NextLinkProps } from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
// Add support for the sx prop for consistency with the other branches.
|
||||||
|
const Anchor = styled("a")({});
|
||||||
|
|
||||||
|
interface NextLinkComposedProps
|
||||||
|
extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">,
|
||||||
|
Omit<NextLinkProps, "href" | "as"> {
|
||||||
|
to: NextLinkProps["href"];
|
||||||
|
linkAs?: NextLinkProps["as"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NextLinkComposed = React.forwardRef<HTMLAnchorElement, NextLinkComposedProps>(function NextLinkComposed(
|
||||||
|
props,
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const { to, linkAs, replace, scroll, shallow, prefetch, locale, ...other } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextLink
|
||||||
|
href={to}
|
||||||
|
prefetch={prefetch}
|
||||||
|
as={linkAs}
|
||||||
|
replace={replace}
|
||||||
|
scroll={scroll}
|
||||||
|
shallow={shallow}
|
||||||
|
passHref
|
||||||
|
locale={locale}
|
||||||
|
>
|
||||||
|
<Anchor ref={ref} {...other} />
|
||||||
|
</NextLink>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LinkProps = {
|
||||||
|
activeClassName?: string;
|
||||||
|
as?: NextLinkProps["as"];
|
||||||
|
href: NextLinkProps["href"];
|
||||||
|
linkAs?: NextLinkProps["as"]; // Useful when the as prop is shallow by styled().
|
||||||
|
noLinkStyle?: boolean;
|
||||||
|
} & Omit<NextLinkComposedProps, "to" | "linkAs" | "href"> &
|
||||||
|
Omit<MuiLinkProps, "href">;
|
||||||
|
|
||||||
|
// A styled version of the Next.js Link component:
|
||||||
|
// https://nextjs.org/docs/api-reference/next/link
|
||||||
|
const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(function Link(props, ref) {
|
||||||
|
const {
|
||||||
|
activeClassName = "active",
|
||||||
|
as,
|
||||||
|
className: classNameProps,
|
||||||
|
href,
|
||||||
|
linkAs: linkAsProp,
|
||||||
|
locale,
|
||||||
|
noLinkStyle,
|
||||||
|
prefetch,
|
||||||
|
replace,
|
||||||
|
role, // Link don't have roles.
|
||||||
|
scroll,
|
||||||
|
shallow,
|
||||||
|
...other
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = typeof href === "string" ? href : href.pathname;
|
||||||
|
const className = clsx(classNameProps, {
|
||||||
|
[activeClassName]: router.pathname === pathname && activeClassName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isExternal = typeof href === "string" && (href.indexOf("http") === 0 || href.indexOf("mailto:") === 0);
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
|
if (noLinkStyle) {
|
||||||
|
return <Anchor className={className} href={href} ref={ref} {...other} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MuiLink className={className} href={href} ref={ref} {...other} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkAs = linkAsProp || as;
|
||||||
|
const nextjsProps = { to: href, linkAs, replace, scroll, shallow, prefetch, locale };
|
||||||
|
|
||||||
|
if (noLinkStyle) {
|
||||||
|
return <NextLinkComposed className={className} ref={ref} {...nextjsProps} {...other} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MuiLink component={NextLinkComposed} className={className} ref={ref} {...nextjsProps} {...other} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Link;
|
|
@ -2,13 +2,16 @@ import { TabContext, TabList, TabPanel } from "@mui/lab";
|
||||||
import { Box, Paper, Tab, Typography } from "@mui/material";
|
import { Box, Paper, Tab, Typography } from "@mui/material";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
import { KeyValuePairTable, KeyValuePair, KeyValuePairTableProps } from "./KeyValuePair";
|
||||||
|
|
||||||
import Editor from "lib/components/Editor";
|
import Editor from "lib/components/Editor";
|
||||||
import { KeyValuePairTable } from "lib/components/KeyValuePair";
|
|
||||||
import { HttpResponseLog } from "lib/graphql/generated";
|
|
||||||
|
|
||||||
interface ResponseTabsProps {
|
interface ResponseTabsProps {
|
||||||
headers: HttpResponseLog["headers"];
|
headers: KeyValuePair[];
|
||||||
body: HttpResponseLog["body"];
|
onHeaderChange?: KeyValuePairTableProps["onChange"];
|
||||||
|
onHeaderDelete?: KeyValuePairTableProps["onDelete"];
|
||||||
|
body?: string | null;
|
||||||
|
onBodyChange?: (value: string) => void;
|
||||||
hasResponse: boolean;
|
hasResponse: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,7 +27,7 @@ const reqNotSent = (
|
||||||
);
|
);
|
||||||
|
|
||||||
function ResponseTabs(props: ResponseTabsProps): JSX.Element {
|
function ResponseTabs(props: ResponseTabsProps): JSX.Element {
|
||||||
const { headers, body, hasResponse } = props;
|
const { headers, onHeaderChange, onHeaderDelete, body, onBodyChange, hasResponse } = props;
|
||||||
const [tabValue, setTabValue] = useState(TabValue.Body);
|
const [tabValue, setTabValue] = useState(TabValue.Body);
|
||||||
|
|
||||||
const contentType = headers.find((header) => header.key.toLowerCase() === "content-type")?.value;
|
const contentType = headers.find((header) => header.key.toLowerCase() === "content-type")?.value;
|
||||||
|
@ -33,6 +36,8 @@ function ResponseTabs(props: ResponseTabsProps): JSX.Element {
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headersLength = onHeaderChange ? headers.length - 1 : headers.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box height="100%" sx={{ display: "flex", flexDirection: "column" }}>
|
<Box height="100%" sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
<TabContext value={tabValue}>
|
<TabContext value={tabValue}>
|
||||||
|
@ -43,20 +48,25 @@ function ResponseTabs(props: ResponseTabsProps): JSX.Element {
|
||||||
label={"Body" + (body?.length ? ` (${body.length} byte` + (body.length > 1 ? "s" : "") + ")" : "")}
|
label={"Body" + (body?.length ? ` (${body.length} byte` + (body.length > 1 ? "s" : "") + ")" : "")}
|
||||||
sx={tabSx}
|
sx={tabSx}
|
||||||
/>
|
/>
|
||||||
<Tab
|
<Tab value={TabValue.Headers} label={"Headers" + (headersLength ? ` (${headersLength})` : "")} sx={tabSx} />
|
||||||
value={TabValue.Headers}
|
|
||||||
label={"Headers" + (headers.length ? ` (${headers.length})` : "")}
|
|
||||||
sx={tabSx}
|
|
||||||
/>
|
|
||||||
</TabList>
|
</TabList>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flex="1 auto" overflow="hidden">
|
<Box flex="1 auto" overflow="hidden">
|
||||||
<TabPanel value={TabValue.Body} sx={{ p: 0, height: "100%" }}>
|
<TabPanel value={TabValue.Body} sx={{ p: 0, height: "100%" }}>
|
||||||
{body && <Editor content={body} contentType={contentType} />}
|
{hasResponse && (
|
||||||
|
<Editor
|
||||||
|
content={body || ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
onBodyChange && onBodyChange(value || "");
|
||||||
|
}}
|
||||||
|
monacoOptions={{ readOnly: onBodyChange === undefined }}
|
||||||
|
contentType={contentType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!hasResponse && reqNotSent}
|
{!hasResponse && reqNotSent}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={TabValue.Headers} sx={{ p: 0, height: "100%", overflow: "scroll" }}>
|
<TabPanel value={TabValue.Headers} sx={{ p: 0, height: "100%", overflow: "scroll" }}>
|
||||||
{headers.length > 0 && <KeyValuePairTable items={headers} />}
|
{hasResponse && <KeyValuePairTable items={headers} onChange={onHeaderChange} onDelete={onHeaderDelete} />}
|
||||||
{!hasResponse && reqNotSent}
|
{!hasResponse && reqNotSent}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
122
admin/src/lib/components/UrlBar.tsx
Normal file
122
admin/src/lib/components/UrlBar.tsx
Normal 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;
|
|
@ -18,6 +18,16 @@ export type Scalars = {
|
||||||
URL: any;
|
URL: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CancelRequestResult = {
|
||||||
|
__typename?: 'CancelRequestResult';
|
||||||
|
success: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CancelResponseResult = {
|
||||||
|
__typename?: 'CancelResponseResult';
|
||||||
|
success: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
export type ClearHttpRequestLogResult = {
|
export type ClearHttpRequestLogResult = {
|
||||||
__typename?: 'ClearHTTPRequestLogResult';
|
__typename?: 'ClearHTTPRequestLogResult';
|
||||||
success: Scalars['Boolean'];
|
success: Scalars['Boolean'];
|
||||||
|
@ -67,6 +77,17 @@ export enum HttpProtocol {
|
||||||
Http20 = 'HTTP20'
|
Http20 = 'HTTP20'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HttpRequest = {
|
||||||
|
__typename?: 'HttpRequest';
|
||||||
|
body?: Maybe<Scalars['String']>;
|
||||||
|
headers: Array<HttpHeader>;
|
||||||
|
id: Scalars['ID'];
|
||||||
|
method: HttpMethod;
|
||||||
|
proto: HttpProtocol;
|
||||||
|
response?: Maybe<HttpResponse>;
|
||||||
|
url: Scalars['URL'];
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpRequestLog = {
|
export type HttpRequestLog = {
|
||||||
__typename?: 'HttpRequestLog';
|
__typename?: 'HttpRequestLog';
|
||||||
body?: Maybe<Scalars['String']>;
|
body?: Maybe<Scalars['String']>;
|
||||||
|
@ -90,6 +111,17 @@ export type HttpRequestLogFilterInput = {
|
||||||
searchExpression?: InputMaybe<Scalars['String']>;
|
searchExpression?: InputMaybe<Scalars['String']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HttpResponse = {
|
||||||
|
__typename?: 'HttpResponse';
|
||||||
|
body?: Maybe<Scalars['String']>;
|
||||||
|
headers: Array<HttpHeader>;
|
||||||
|
/** Will be the same ID as its related request ID. */
|
||||||
|
id: Scalars['ID'];
|
||||||
|
proto: HttpProtocol;
|
||||||
|
statusCode: Scalars['Int'];
|
||||||
|
statusReason: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpResponseLog = {
|
export type HttpResponseLog = {
|
||||||
__typename?: 'HttpResponseLog';
|
__typename?: 'HttpResponseLog';
|
||||||
body?: Maybe<Scalars['String']>;
|
body?: Maybe<Scalars['String']>;
|
||||||
|
@ -101,8 +133,47 @@ export type HttpResponseLog = {
|
||||||
statusReason: Scalars['String'];
|
statusReason: Scalars['String'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InterceptSettings = {
|
||||||
|
__typename?: 'InterceptSettings';
|
||||||
|
requestFilter?: Maybe<Scalars['String']>;
|
||||||
|
requestsEnabled: Scalars['Boolean'];
|
||||||
|
responseFilter?: Maybe<Scalars['String']>;
|
||||||
|
responsesEnabled: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModifyRequestInput = {
|
||||||
|
body?: InputMaybe<Scalars['String']>;
|
||||||
|
headers?: InputMaybe<Array<HttpHeaderInput>>;
|
||||||
|
id: Scalars['ID'];
|
||||||
|
method: HttpMethod;
|
||||||
|
modifyResponse?: InputMaybe<Scalars['Boolean']>;
|
||||||
|
proto: HttpProtocol;
|
||||||
|
url: Scalars['URL'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModifyRequestResult = {
|
||||||
|
__typename?: 'ModifyRequestResult';
|
||||||
|
success: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModifyResponseInput = {
|
||||||
|
body?: InputMaybe<Scalars['String']>;
|
||||||
|
headers?: InputMaybe<Array<HttpHeaderInput>>;
|
||||||
|
proto: HttpProtocol;
|
||||||
|
requestID: Scalars['ID'];
|
||||||
|
statusCode: Scalars['Int'];
|
||||||
|
statusReason: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModifyResponseResult = {
|
||||||
|
__typename?: 'ModifyResponseResult';
|
||||||
|
success: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
export type Mutation = {
|
export type Mutation = {
|
||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
|
cancelRequest: CancelRequestResult;
|
||||||
|
cancelResponse: CancelResponseResult;
|
||||||
clearHTTPRequestLog: ClearHttpRequestLogResult;
|
clearHTTPRequestLog: ClearHttpRequestLogResult;
|
||||||
closeProject: CloseProjectResult;
|
closeProject: CloseProjectResult;
|
||||||
createOrUpdateSenderRequest: SenderRequest;
|
createOrUpdateSenderRequest: SenderRequest;
|
||||||
|
@ -110,11 +181,24 @@ export type Mutation = {
|
||||||
createSenderRequestFromHttpRequestLog: SenderRequest;
|
createSenderRequestFromHttpRequestLog: SenderRequest;
|
||||||
deleteProject: DeleteProjectResult;
|
deleteProject: DeleteProjectResult;
|
||||||
deleteSenderRequests: DeleteSenderRequestsResult;
|
deleteSenderRequests: DeleteSenderRequestsResult;
|
||||||
|
modifyRequest: ModifyRequestResult;
|
||||||
|
modifyResponse: ModifyResponseResult;
|
||||||
openProject?: Maybe<Project>;
|
openProject?: Maybe<Project>;
|
||||||
sendRequest: SenderRequest;
|
sendRequest: SenderRequest;
|
||||||
setHttpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
|
setHttpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
|
||||||
setScope: Array<ScopeRule>;
|
setScope: Array<ScopeRule>;
|
||||||
setSenderRequestFilter?: Maybe<SenderRequestFilter>;
|
setSenderRequestFilter?: Maybe<SenderRequestFilter>;
|
||||||
|
updateInterceptSettings: InterceptSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationCancelRequestArgs = {
|
||||||
|
id: Scalars['ID'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationCancelResponseArgs = {
|
||||||
|
requestID: Scalars['ID'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -138,6 +222,16 @@ export type MutationDeleteProjectArgs = {
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationModifyRequestArgs = {
|
||||||
|
request: ModifyRequestInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationModifyResponseArgs = {
|
||||||
|
response: ModifyResponseInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationOpenProjectArgs = {
|
export type MutationOpenProjectArgs = {
|
||||||
id: Scalars['ID'];
|
id: Scalars['ID'];
|
||||||
};
|
};
|
||||||
|
@ -162,11 +256,22 @@ export type MutationSetSenderRequestFilterArgs = {
|
||||||
filter?: InputMaybe<SenderRequestFilterInput>;
|
filter?: InputMaybe<SenderRequestFilterInput>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationUpdateInterceptSettingsArgs = {
|
||||||
|
input: UpdateInterceptSettingsInput;
|
||||||
|
};
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
__typename?: 'Project';
|
__typename?: 'Project';
|
||||||
id: Scalars['ID'];
|
id: Scalars['ID'];
|
||||||
isActive: Scalars['Boolean'];
|
isActive: Scalars['Boolean'];
|
||||||
name: Scalars['String'];
|
name: Scalars['String'];
|
||||||
|
settings: ProjectSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectSettings = {
|
||||||
|
__typename?: 'ProjectSettings';
|
||||||
|
intercept: InterceptSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Query = {
|
export type Query = {
|
||||||
|
@ -175,6 +280,8 @@ export type Query = {
|
||||||
httpRequestLog?: Maybe<HttpRequestLog>;
|
httpRequestLog?: Maybe<HttpRequestLog>;
|
||||||
httpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
|
httpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
|
||||||
httpRequestLogs: Array<HttpRequestLog>;
|
httpRequestLogs: Array<HttpRequestLog>;
|
||||||
|
interceptedRequest?: Maybe<HttpRequest>;
|
||||||
|
interceptedRequests: Array<HttpRequest>;
|
||||||
projects: Array<Project>;
|
projects: Array<Project>;
|
||||||
scope: Array<ScopeRule>;
|
scope: Array<ScopeRule>;
|
||||||
senderRequest?: Maybe<SenderRequest>;
|
senderRequest?: Maybe<SenderRequest>;
|
||||||
|
@ -187,6 +294,11 @@ export type QueryHttpRequestLogArgs = {
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type QueryInterceptedRequestArgs = {
|
||||||
|
id: Scalars['ID'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type QuerySenderRequestArgs = {
|
export type QuerySenderRequestArgs = {
|
||||||
id: Scalars['ID'];
|
id: Scalars['ID'];
|
||||||
};
|
};
|
||||||
|
@ -248,6 +360,53 @@ export type SenderRequestInput = {
|
||||||
url: Scalars['URL'];
|
url: Scalars['URL'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateInterceptSettingsInput = {
|
||||||
|
requestFilter?: InputMaybe<Scalars['String']>;
|
||||||
|
requestsEnabled: Scalars['Boolean'];
|
||||||
|
responseFilter?: InputMaybe<Scalars['String']>;
|
||||||
|
responsesEnabled: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CancelRequestMutationVariables = Exact<{
|
||||||
|
id: Scalars['ID'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type CancelRequestMutation = { __typename?: 'Mutation', cancelRequest: { __typename?: 'CancelRequestResult', success: boolean } };
|
||||||
|
|
||||||
|
export type CancelResponseMutationVariables = Exact<{
|
||||||
|
requestID: Scalars['ID'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type CancelResponseMutation = { __typename?: 'Mutation', cancelResponse: { __typename?: 'CancelResponseResult', success: boolean } };
|
||||||
|
|
||||||
|
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 }>, response?: { __typename?: 'HttpResponse', id: string, proto: HttpProtocol, statusCode: number, statusReason: string, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }> } | null } | null };
|
||||||
|
|
||||||
|
export type ModifyRequestMutationVariables = Exact<{
|
||||||
|
request: ModifyRequestInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type ModifyRequestMutation = { __typename?: 'Mutation', modifyRequest: { __typename?: 'ModifyRequestResult', success: boolean } };
|
||||||
|
|
||||||
|
export type ModifyResponseMutationVariables = Exact<{
|
||||||
|
response: ModifyResponseInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type ModifyResponseMutation = { __typename?: 'Mutation', modifyResponse: { __typename?: 'ModifyResponseResult', success: boolean } };
|
||||||
|
|
||||||
|
export type ActiveProjectQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
|
export type ActiveProjectQuery = { __typename?: 'Query', activeProject?: { __typename?: 'Project', id: string, name: string, isActive: boolean, settings: { __typename?: 'ProjectSettings', intercept: { __typename?: 'InterceptSettings', requestsEnabled: boolean, responsesEnabled: boolean, requestFilter?: string | null, responseFilter?: string | null } } } | null };
|
||||||
|
|
||||||
export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>;
|
export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
|
@ -353,7 +512,249 @@ 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 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 UpdateInterceptSettingsMutationVariables = Exact<{
|
||||||
|
input: UpdateInterceptSettingsInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type UpdateInterceptSettingsMutation = { __typename?: 'Mutation', updateInterceptSettings: { __typename?: 'InterceptSettings', requestsEnabled: boolean, responsesEnabled: boolean, requestFilter?: string | null, responseFilter?: string | null } };
|
||||||
|
|
||||||
|
export type GetInterceptedRequestsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
|
export type GetInterceptedRequestsQuery = { __typename?: 'Query', interceptedRequests: Array<{ __typename?: 'HttpRequest', id: string, url: any, method: HttpMethod, response?: { __typename?: 'HttpResponse', statusCode: number, statusReason: string } | null }> };
|
||||||
|
|
||||||
|
|
||||||
|
export const CancelRequestDocument = gql`
|
||||||
|
mutation CancelRequest($id: ID!) {
|
||||||
|
cancelRequest(id: $id) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export type CancelRequestMutationFn = Apollo.MutationFunction<CancelRequestMutation, CancelRequestMutationVariables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useCancelRequestMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useCancelRequestMutation` within a React component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useCancelRequestMutation` 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 [cancelRequestMutation, { data, loading, error }] = useCancelRequestMutation({
|
||||||
|
* variables: {
|
||||||
|
* id: // value for 'id'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useCancelRequestMutation(baseOptions?: Apollo.MutationHookOptions<CancelRequestMutation, CancelRequestMutationVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useMutation<CancelRequestMutation, CancelRequestMutationVariables>(CancelRequestDocument, options);
|
||||||
|
}
|
||||||
|
export type CancelRequestMutationHookResult = ReturnType<typeof useCancelRequestMutation>;
|
||||||
|
export type CancelRequestMutationResult = Apollo.MutationResult<CancelRequestMutation>;
|
||||||
|
export type CancelRequestMutationOptions = Apollo.BaseMutationOptions<CancelRequestMutation, CancelRequestMutationVariables>;
|
||||||
|
export const CancelResponseDocument = gql`
|
||||||
|
mutation CancelResponse($requestID: ID!) {
|
||||||
|
cancelResponse(requestID: $requestID) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export type CancelResponseMutationFn = Apollo.MutationFunction<CancelResponseMutation, CancelResponseMutationVariables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useCancelResponseMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useCancelResponseMutation` within a React component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useCancelResponseMutation` 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 [cancelResponseMutation, { data, loading, error }] = useCancelResponseMutation({
|
||||||
|
* variables: {
|
||||||
|
* requestID: // value for 'requestID'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useCancelResponseMutation(baseOptions?: Apollo.MutationHookOptions<CancelResponseMutation, CancelResponseMutationVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useMutation<CancelResponseMutation, CancelResponseMutationVariables>(CancelResponseDocument, options);
|
||||||
|
}
|
||||||
|
export type CancelResponseMutationHookResult = ReturnType<typeof useCancelResponseMutation>;
|
||||||
|
export type CancelResponseMutationResult = Apollo.MutationResult<CancelResponseMutation>;
|
||||||
|
export type CancelResponseMutationOptions = Apollo.BaseMutationOptions<CancelResponseMutation, CancelResponseMutationVariables>;
|
||||||
|
export const GetInterceptedRequestDocument = gql`
|
||||||
|
query GetInterceptedRequest($id: ID!) {
|
||||||
|
interceptedRequest(id: $id) {
|
||||||
|
id
|
||||||
|
url
|
||||||
|
method
|
||||||
|
proto
|
||||||
|
headers {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
body
|
||||||
|
response {
|
||||||
|
id
|
||||||
|
proto
|
||||||
|
statusCode
|
||||||
|
statusReason
|
||||||
|
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 ModifyResponseDocument = gql`
|
||||||
|
mutation ModifyResponse($response: ModifyResponseInput!) {
|
||||||
|
modifyResponse(response: $response) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export type ModifyResponseMutationFn = Apollo.MutationFunction<ModifyResponseMutation, ModifyResponseMutationVariables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useModifyResponseMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useModifyResponseMutation` within a React component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useModifyResponseMutation` 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 [modifyResponseMutation, { data, loading, error }] = useModifyResponseMutation({
|
||||||
|
* variables: {
|
||||||
|
* response: // value for 'response'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useModifyResponseMutation(baseOptions?: Apollo.MutationHookOptions<ModifyResponseMutation, ModifyResponseMutationVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useMutation<ModifyResponseMutation, ModifyResponseMutationVariables>(ModifyResponseDocument, options);
|
||||||
|
}
|
||||||
|
export type ModifyResponseMutationHookResult = ReturnType<typeof useModifyResponseMutation>;
|
||||||
|
export type ModifyResponseMutationResult = Apollo.MutationResult<ModifyResponseMutation>;
|
||||||
|
export type ModifyResponseMutationOptions = Apollo.BaseMutationOptions<ModifyResponseMutation, ModifyResponseMutationVariables>;
|
||||||
|
export const ActiveProjectDocument = gql`
|
||||||
|
query ActiveProject {
|
||||||
|
activeProject {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
settings {
|
||||||
|
intercept {
|
||||||
|
requestsEnabled
|
||||||
|
responsesEnabled
|
||||||
|
requestFilter
|
||||||
|
responseFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useActiveProjectQuery__
|
||||||
|
*
|
||||||
|
* To run a query within a React component, call `useActiveProjectQuery` and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useActiveProjectQuery` 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 } = useActiveProjectQuery({
|
||||||
|
* variables: {
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useActiveProjectQuery(baseOptions?: Apollo.QueryHookOptions<ActiveProjectQuery, ActiveProjectQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useQuery<ActiveProjectQuery, ActiveProjectQueryVariables>(ActiveProjectDocument, options);
|
||||||
|
}
|
||||||
|
export function useActiveProjectLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ActiveProjectQuery, ActiveProjectQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useLazyQuery<ActiveProjectQuery, ActiveProjectQueryVariables>(ActiveProjectDocument, options);
|
||||||
|
}
|
||||||
|
export type ActiveProjectQueryHookResult = ReturnType<typeof useActiveProjectQuery>;
|
||||||
|
export type ActiveProjectLazyQueryHookResult = ReturnType<typeof useActiveProjectLazyQuery>;
|
||||||
|
export type ActiveProjectQueryResult = Apollo.QueryResult<ActiveProjectQuery, ActiveProjectQueryVariables>;
|
||||||
export const CloseProjectDocument = gql`
|
export const CloseProjectDocument = gql`
|
||||||
mutation CloseProject {
|
mutation CloseProject {
|
||||||
closeProject {
|
closeProject {
|
||||||
|
@ -983,3 +1384,79 @@ export function useGetSenderRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHook
|
||||||
export type GetSenderRequestsQueryHookResult = ReturnType<typeof useGetSenderRequestsQuery>;
|
export type GetSenderRequestsQueryHookResult = ReturnType<typeof useGetSenderRequestsQuery>;
|
||||||
export type GetSenderRequestsLazyQueryHookResult = ReturnType<typeof useGetSenderRequestsLazyQuery>;
|
export type GetSenderRequestsLazyQueryHookResult = ReturnType<typeof useGetSenderRequestsLazyQuery>;
|
||||||
export type GetSenderRequestsQueryResult = Apollo.QueryResult<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>;
|
export type GetSenderRequestsQueryResult = Apollo.QueryResult<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>;
|
||||||
|
export const UpdateInterceptSettingsDocument = gql`
|
||||||
|
mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) {
|
||||||
|
updateInterceptSettings(input: $input) {
|
||||||
|
requestsEnabled
|
||||||
|
responsesEnabled
|
||||||
|
requestFilter
|
||||||
|
responseFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export type UpdateInterceptSettingsMutationFn = Apollo.MutationFunction<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useUpdateInterceptSettingsMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useUpdateInterceptSettingsMutation` within a React component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useUpdateInterceptSettingsMutation` 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 [updateInterceptSettingsMutation, { data, loading, error }] = useUpdateInterceptSettingsMutation({
|
||||||
|
* variables: {
|
||||||
|
* input: // value for 'input'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useUpdateInterceptSettingsMutation(baseOptions?: Apollo.MutationHookOptions<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useMutation<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>(UpdateInterceptSettingsDocument, options);
|
||||||
|
}
|
||||||
|
export type UpdateInterceptSettingsMutationHookResult = ReturnType<typeof useUpdateInterceptSettingsMutation>;
|
||||||
|
export type UpdateInterceptSettingsMutationResult = Apollo.MutationResult<UpdateInterceptSettingsMutation>;
|
||||||
|
export type UpdateInterceptSettingsMutationOptions = Apollo.BaseMutationOptions<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>;
|
||||||
|
export const GetInterceptedRequestsDocument = gql`
|
||||||
|
query GetInterceptedRequests {
|
||||||
|
interceptedRequests {
|
||||||
|
id
|
||||||
|
url
|
||||||
|
method
|
||||||
|
response {
|
||||||
|
statusCode
|
||||||
|
statusReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __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>;
|
11
admin/src/lib/graphql/interceptedRequests.graphql
Normal file
11
admin/src/lib/graphql/interceptedRequests.graphql
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
query GetInterceptedRequests {
|
||||||
|
interceptedRequests {
|
||||||
|
id
|
||||||
|
url
|
||||||
|
method
|
||||||
|
response {
|
||||||
|
statusCode
|
||||||
|
statusReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,22 @@ function createApolloClient() {
|
||||||
link: new HttpLink({
|
link: new HttpLink({
|
||||||
uri: "/api/graphql/",
|
uri: "/api/graphql/",
|
||||||
}),
|
}),
|
||||||
cache: new InMemoryCache(),
|
cache: new InMemoryCache({
|
||||||
|
typePolicies: {
|
||||||
|
Query: {
|
||||||
|
fields: {
|
||||||
|
interceptedRequests: {
|
||||||
|
merge(_, incoming) {
|
||||||
|
return incoming;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ProjectSettings: {
|
||||||
|
merge: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
16
admin/src/lib/updateKeyPairItem.ts
Normal file
16
admin/src/lib/updateKeyPairItem.ts
Normal 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;
|
28
admin/src/lib/updateURLQueryParams.ts
Normal file
28
admin/src/lib/updateURLQueryParams.ts
Normal 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;
|
|
@ -7,6 +7,7 @@ import Head from "next/head";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { ActiveProjectProvider } from "lib/ActiveProjectContext";
|
import { ActiveProjectProvider } from "lib/ActiveProjectContext";
|
||||||
|
import { InterceptedRequestsProvider } from "lib/InterceptedRequestsContext";
|
||||||
import { useApollo } from "lib/graphql/useApollo";
|
import { useApollo } from "lib/graphql/useApollo";
|
||||||
import createEmotionCache from "lib/mui/createEmotionCache";
|
import createEmotionCache from "lib/mui/createEmotionCache";
|
||||||
import theme from "lib/mui/theme";
|
import theme from "lib/mui/theme";
|
||||||
|
@ -32,10 +33,12 @@ export default function MyApp(props: MyAppProps) {
|
||||||
</Head>
|
</Head>
|
||||||
<ApolloProvider client={apolloClient}>
|
<ApolloProvider client={apolloClient}>
|
||||||
<ActiveProjectProvider>
|
<ActiveProjectProvider>
|
||||||
|
<InterceptedRequestsProvider>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</InterceptedRequestsProvider>
|
||||||
</ActiveProjectProvider>
|
</ActiveProjectProvider>
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
</CacheProvider>
|
</CacheProvider>
|
||||||
|
|
12
admin/src/pages/proxy/intercept/index.tsx
Normal file
12
admin/src/pages/proxy/intercept/index.tsx
Normal 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;
|
12
admin/src/pages/settings/index.tsx
Normal file
12
admin/src/pages/settings/index.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { Layout, Page } from "features/Layout";
|
||||||
|
import Settings from "features/settings/components/Settings";
|
||||||
|
|
||||||
|
function Index(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Layout page={Page.Settings} title="Settings">
|
||||||
|
<Settings />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Index;
|
|
@ -10,9 +10,9 @@
|
||||||
"@jridgewell/trace-mapping" "^0.3.0"
|
"@jridgewell/trace-mapping" "^0.3.0"
|
||||||
|
|
||||||
"@apollo/client@^3.2.0":
|
"@apollo/client@^3.2.0":
|
||||||
version "3.5.8"
|
version "3.5.10"
|
||||||
resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.5.8.tgz#7215b974c5988b6157530eb69369209210349fe0"
|
resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.5.10.tgz#43463108a6e07ae602cca0afc420805a19339a71"
|
||||||
integrity sha512-MAm05+I1ullr64VLpZwon/ISnkMuNLf6vDqgo9wiMhHYBGT4yOAbAIseRdjCHZwfSx/7AUuBgaTNOssZPIr6FQ==
|
integrity sha512-tL3iSpFe9Oldq7gYikZK1dcYxp1c01nlSwtsMz75382HcI6fvQXyFXUCJTTK3wgO2/ckaBvRGw7VqjFREdVoRw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@graphql-typed-document-node/core" "^3.0.0"
|
"@graphql-typed-document-node/core" "^3.0.0"
|
||||||
"@wry/context" "^0.6.0"
|
"@wry/context" "^0.6.0"
|
||||||
|
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"github.com/dstotijn/hetty/pkg/db/badger"
|
"github.com/dstotijn/hetty/pkg/db/badger"
|
||||||
"github.com/dstotijn/hetty/pkg/proj"
|
"github.com/dstotijn/hetty/pkg/proj"
|
||||||
"github.com/dstotijn/hetty/pkg/proxy"
|
"github.com/dstotijn/hetty/pkg/proxy"
|
||||||
|
"github.com/dstotijn/hetty/pkg/proxy/intercept"
|
||||||
"github.com/dstotijn/hetty/pkg/reqlog"
|
"github.com/dstotijn/hetty/pkg/reqlog"
|
||||||
"github.com/dstotijn/hetty/pkg/scope"
|
"github.com/dstotijn/hetty/pkg/scope"
|
||||||
"github.com/dstotijn/hetty/pkg/sender"
|
"github.com/dstotijn/hetty/pkg/sender"
|
||||||
|
@ -175,6 +176,10 @@ func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error {
|
||||||
Logger: cmd.config.logger.Named("reqlog").Sugar(),
|
Logger: cmd.config.logger.Named("reqlog").Sugar(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interceptService := intercept.NewService(intercept.Config{
|
||||||
|
Logger: cmd.config.logger.Named("intercept").Sugar(),
|
||||||
|
})
|
||||||
|
|
||||||
senderService := sender.NewService(sender.Config{
|
senderService := sender.NewService(sender.Config{
|
||||||
Repository: badger,
|
Repository: badger,
|
||||||
ReqLogService: reqLogService,
|
ReqLogService: reqLogService,
|
||||||
|
@ -182,6 +187,7 @@ func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error {
|
||||||
|
|
||||||
projService, err := proj.NewService(proj.Config{
|
projService, err := proj.NewService(proj.Config{
|
||||||
Repository: badger,
|
Repository: badger,
|
||||||
|
InterceptService: interceptService,
|
||||||
ReqLogService: reqLogService,
|
ReqLogService: reqLogService,
|
||||||
SenderService: senderService,
|
SenderService: senderService,
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
|
@ -201,6 +207,8 @@ func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error {
|
||||||
|
|
||||||
proxy.UseRequestModifier(reqLogService.RequestModifier)
|
proxy.UseRequestModifier(reqLogService.RequestModifier)
|
||||||
proxy.UseResponseModifier(reqLogService.ResponseModifier)
|
proxy.UseResponseModifier(reqLogService.ResponseModifier)
|
||||||
|
proxy.UseRequestModifier(interceptService.RequestModifier)
|
||||||
|
proxy.UseResponseModifier(interceptService.ResponseModifier)
|
||||||
|
|
||||||
fsSub, err := fs.Sub(adminContent, "admin")
|
fsSub, err := fs.Sub(adminContent, "admin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -231,6 +239,7 @@ func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error {
|
||||||
adminRouter.Path(gqlEndpoint).Handler(api.HTTPHandler(&api.Resolver{
|
adminRouter.Path(gqlEndpoint).Handler(api.HTTPHandler(&api.Resolver{
|
||||||
ProjectService: projService,
|
ProjectService: projService,
|
||||||
RequestLogService: reqLogService,
|
RequestLogService: reqLogService,
|
||||||
|
InterceptService: interceptService,
|
||||||
SenderService: senderService,
|
SenderService: senderService,
|
||||||
}, gqlEndpoint))
|
}, gqlEndpoint))
|
||||||
|
|
||||||
|
|
2214
pkg/api/generated.go
2214
pkg/api/generated.go
File diff suppressed because it is too large
Load diff
|
@ -12,6 +12,14 @@ import (
|
||||||
"github.com/oklog/ulid"
|
"github.com/oklog/ulid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type CancelRequestResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CancelResponseResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
type ClearHTTPRequestLogResult struct {
|
type ClearHTTPRequestLogResult struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
}
|
}
|
||||||
|
@ -38,6 +46,16 @@ type HTTPHeaderInput struct {
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HTTPRequest struct {
|
||||||
|
ID ulid.ULID `json:"id"`
|
||||||
|
URL *url.URL `json:"url"`
|
||||||
|
Method HTTPMethod `json:"method"`
|
||||||
|
Proto HTTPProtocol `json:"proto"`
|
||||||
|
Headers []HTTPHeader `json:"headers"`
|
||||||
|
Body *string `json:"body"`
|
||||||
|
Response *HTTPResponse `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
type HTTPRequestLog struct {
|
type HTTPRequestLog struct {
|
||||||
ID ulid.ULID `json:"id"`
|
ID ulid.ULID `json:"id"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
@ -59,6 +77,16 @@ type HTTPRequestLogFilterInput struct {
|
||||||
SearchExpression *string `json:"searchExpression"`
|
SearchExpression *string `json:"searchExpression"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HTTPResponse struct {
|
||||||
|
// Will be the same ID as its related request ID.
|
||||||
|
ID ulid.ULID `json:"id"`
|
||||||
|
Proto HTTPProtocol `json:"proto"`
|
||||||
|
StatusCode int `json:"statusCode"`
|
||||||
|
StatusReason string `json:"statusReason"`
|
||||||
|
Body *string `json:"body"`
|
||||||
|
Headers []HTTPHeader `json:"headers"`
|
||||||
|
}
|
||||||
|
|
||||||
type HTTPResponseLog struct {
|
type HTTPResponseLog struct {
|
||||||
// Will be the same ID as its related request ID.
|
// Will be the same ID as its related request ID.
|
||||||
ID ulid.ULID `json:"id"`
|
ID ulid.ULID `json:"id"`
|
||||||
|
@ -69,10 +97,49 @@ type HTTPResponseLog struct {
|
||||||
Headers []HTTPHeader `json:"headers"`
|
Headers []HTTPHeader `json:"headers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InterceptSettings struct {
|
||||||
|
RequestsEnabled bool `json:"requestsEnabled"`
|
||||||
|
ResponsesEnabled bool `json:"responsesEnabled"`
|
||||||
|
RequestFilter *string `json:"requestFilter"`
|
||||||
|
ResponseFilter *string `json:"responseFilter"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModifyRequestInput struct {
|
||||||
|
ID ulid.ULID `json:"id"`
|
||||||
|
URL *url.URL `json:"url"`
|
||||||
|
Method HTTPMethod `json:"method"`
|
||||||
|
Proto HTTPProtocol `json:"proto"`
|
||||||
|
Headers []HTTPHeaderInput `json:"headers"`
|
||||||
|
Body *string `json:"body"`
|
||||||
|
ModifyResponse *bool `json:"modifyResponse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModifyRequestResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModifyResponseInput struct {
|
||||||
|
RequestID ulid.ULID `json:"requestID"`
|
||||||
|
Proto HTTPProtocol `json:"proto"`
|
||||||
|
Headers []HTTPHeaderInput `json:"headers"`
|
||||||
|
Body *string `json:"body"`
|
||||||
|
StatusCode int `json:"statusCode"`
|
||||||
|
StatusReason string `json:"statusReason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModifyResponseResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
type Project struct {
|
type Project struct {
|
||||||
ID ulid.ULID `json:"id"`
|
ID ulid.ULID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IsActive bool `json:"isActive"`
|
IsActive bool `json:"isActive"`
|
||||||
|
Settings *ProjectSettings `json:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectSettings struct {
|
||||||
|
Intercept *InterceptSettings `json:"intercept"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScopeHeader struct {
|
type ScopeHeader struct {
|
||||||
|
@ -128,6 +195,13 @@ type SenderRequestInput struct {
|
||||||
Body *string `json:"body"`
|
Body *string `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateInterceptSettingsInput struct {
|
||||||
|
RequestsEnabled bool `json:"requestsEnabled"`
|
||||||
|
ResponsesEnabled bool `json:"responsesEnabled"`
|
||||||
|
RequestFilter *string `json:"requestFilter"`
|
||||||
|
ResponseFilter *string `json:"responseFilter"`
|
||||||
|
}
|
||||||
|
|
||||||
type HTTPMethod string
|
type HTTPMethod string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -3,9 +3,12 @@ package api
|
||||||
//go:generate go run github.com/99designs/gqlgen
|
//go:generate go run github.com/99designs/gqlgen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -15,6 +18,8 @@ import (
|
||||||
"github.com/vektah/gqlparser/v2/gqlerror"
|
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||||
|
|
||||||
"github.com/dstotijn/hetty/pkg/proj"
|
"github.com/dstotijn/hetty/pkg/proj"
|
||||||
|
"github.com/dstotijn/hetty/pkg/proxy"
|
||||||
|
"github.com/dstotijn/hetty/pkg/proxy/intercept"
|
||||||
"github.com/dstotijn/hetty/pkg/reqlog"
|
"github.com/dstotijn/hetty/pkg/reqlog"
|
||||||
"github.com/dstotijn/hetty/pkg/scope"
|
"github.com/dstotijn/hetty/pkg/scope"
|
||||||
"github.com/dstotijn/hetty/pkg/search"
|
"github.com/dstotijn/hetty/pkg/search"
|
||||||
|
@ -36,6 +41,7 @@ var revHTTPProtocolMap = map[HTTPProtocol]string{
|
||||||
type Resolver struct {
|
type Resolver struct {
|
||||||
ProjectService proj.Service
|
ProjectService proj.Service
|
||||||
RequestLogService reqlog.Service
|
RequestLogService reqlog.Service
|
||||||
|
InterceptService *intercept.Service
|
||||||
SenderService sender.Service
|
SenderService sender.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -179,11 +185,9 @@ func (r *mutationResolver) CreateProject(ctx context.Context, name string) (*Pro
|
||||||
return nil, fmt.Errorf("could not open project: %w", err)
|
return nil, fmt.Errorf("could not open project: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Project{
|
project := parseProject(r.ProjectService, p)
|
||||||
ID: p.ID,
|
|
||||||
Name: p.Name,
|
return &project, nil
|
||||||
IsActive: r.ProjectService.IsProjectActive(p.ID),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) OpenProject(ctx context.Context, id ulid.ULID) (*Project, error) {
|
func (r *mutationResolver) OpenProject(ctx context.Context, id ulid.ULID) (*Project, error) {
|
||||||
|
@ -194,11 +198,9 @@ func (r *mutationResolver) OpenProject(ctx context.Context, id ulid.ULID) (*Proj
|
||||||
return nil, fmt.Errorf("could not open project: %w", err)
|
return nil, fmt.Errorf("could not open project: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Project{
|
project := parseProject(r.ProjectService, p)
|
||||||
ID: p.ID,
|
|
||||||
Name: p.Name,
|
return &project, nil
|
||||||
IsActive: r.ProjectService.IsProjectActive(p.ID),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) {
|
func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) {
|
||||||
|
@ -209,11 +211,9 @@ func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) {
|
||||||
return nil, fmt.Errorf("could not open project: %w", err)
|
return nil, fmt.Errorf("could not open project: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Project{
|
project := parseProject(r.ProjectService, p)
|
||||||
ID: p.ID,
|
|
||||||
Name: p.Name,
|
return &project, nil
|
||||||
IsActive: r.ProjectService.IsProjectActive(p.ID),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) {
|
func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) {
|
||||||
|
@ -224,11 +224,7 @@ func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) {
|
||||||
|
|
||||||
projects := make([]Project, len(p))
|
projects := make([]Project, len(p))
|
||||||
for i, proj := range p {
|
for i, proj := range p {
|
||||||
projects[i] = Project{
|
projects[i] = parseProject(r.ProjectService, proj)
|
||||||
ID: proj.ID,
|
|
||||||
Name: proj.Name,
|
|
||||||
IsActive: r.ProjectService.IsProjectActive(proj.ID),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return projects, nil
|
return projects, nil
|
||||||
|
@ -520,6 +516,166 @@ func (r *mutationResolver) DeleteSenderRequests(ctx context.Context) (*DeleteSen
|
||||||
return &DeleteSenderRequestsResult{true}, nil
|
return &DeleteSenderRequestsResult{true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) InterceptedRequests(ctx context.Context) (httpReqs []HTTPRequest, err error) {
|
||||||
|
items := r.InterceptService.Items()
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
req, err := parseInterceptItem(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReqs = append(httpReqs, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpReqs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) InterceptedRequest(ctx context.Context, id ulid.ULID) (*HTTPRequest, error) {
|
||||||
|
item, err := r.InterceptService.ItemByID(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := parseInterceptItem(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) ModifyRequest(ctx context.Context, input ModifyRequestInput) (*ModifyRequestResult, error) {
|
||||||
|
body := ""
|
||||||
|
if input.Body != nil {
|
||||||
|
body = *input.Body
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:noctx
|
||||||
|
req, err := http.NewRequest(input.Method.String(), input.URL.String(), strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to construct HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, header := range input.Headers {
|
||||||
|
req.Header.Add(header.Key, header.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = r.InterceptService.ModifyRequest(input.ID, req, input.ModifyResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not modify http request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ModifyRequestResult{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) CancelRequest(ctx context.Context, id ulid.ULID) (*CancelRequestResult, error) {
|
||||||
|
err := r.InterceptService.CancelRequest(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not cancel http request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CancelRequestResult{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) ModifyResponse(
|
||||||
|
ctx context.Context,
|
||||||
|
input ModifyResponseInput,
|
||||||
|
) (*ModifyResponseResult, error) {
|
||||||
|
res := &http.Response{
|
||||||
|
Header: make(http.Header),
|
||||||
|
Status: fmt.Sprintf("%v %v", input.StatusCode, input.StatusReason),
|
||||||
|
StatusCode: input.StatusCode,
|
||||||
|
Proto: revHTTPProtocolMap[input.Proto],
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
if res.ProtoMajor, res.ProtoMinor, ok = http.ParseHTTPVersion(res.Proto); !ok {
|
||||||
|
return nil, fmt.Errorf("malformed HTTP version: %q", res.Proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body string
|
||||||
|
if input.Body != nil {
|
||||||
|
body = *input.Body
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Body = io.NopCloser(strings.NewReader(body))
|
||||||
|
|
||||||
|
for _, header := range input.Headers {
|
||||||
|
res.Header.Add(header.Key, header.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.InterceptService.ModifyResponse(input.RequestID, res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not modify http request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ModifyResponseResult{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) CancelResponse(ctx context.Context, requestID ulid.ULID) (*CancelResponseResult, error) {
|
||||||
|
err := r.InterceptService.CancelResponse(requestID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not cancel http response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CancelResponseResult{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) UpdateInterceptSettings(
|
||||||
|
ctx context.Context,
|
||||||
|
input UpdateInterceptSettingsInput,
|
||||||
|
) (*InterceptSettings, error) {
|
||||||
|
settings := intercept.Settings{
|
||||||
|
RequestsEnabled: input.RequestsEnabled,
|
||||||
|
ResponsesEnabled: input.ResponsesEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.RequestFilter != nil && *input.RequestFilter != "" {
|
||||||
|
expr, err := search.ParseQuery(*input.RequestFilter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse request filter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.RequestFilter = expr
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.ResponseFilter != nil && *input.ResponseFilter != "" {
|
||||||
|
expr, err := search.ParseQuery(*input.ResponseFilter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse response filter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.ResponseFilter = expr
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.ProjectService.UpdateInterceptSettings(ctx, settings)
|
||||||
|
if errors.Is(err, proj.ErrNoProject) {
|
||||||
|
return nil, noActiveProjectErr(ctx)
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not update intercept settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := &InterceptSettings{
|
||||||
|
RequestsEnabled: settings.RequestsEnabled,
|
||||||
|
ResponsesEnabled: settings.ResponsesEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.RequestFilter != nil {
|
||||||
|
reqFilter := settings.RequestFilter.String()
|
||||||
|
updated.RequestFilter = &reqFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.ResponseFilter != nil {
|
||||||
|
resFilter := settings.ResponseFilter.String()
|
||||||
|
updated.ResponseFilter = &resFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseSenderRequest(req sender.Request) (SenderRequest, error) {
|
func parseSenderRequest(req sender.Request) (SenderRequest, error) {
|
||||||
method := HTTPMethod(req.Method)
|
method := HTTPMethod(req.Method)
|
||||||
if method != "" && !method.IsValid() {
|
if method != "" && !method.IsValid() {
|
||||||
|
@ -575,6 +731,155 @@ func parseSenderRequest(req sender.Request) (SenderRequest, error) {
|
||||||
return senderReq, nil
|
return senderReq, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseHTTPRequest(req *http.Request) (HTTPRequest, error) {
|
||||||
|
method := HTTPMethod(req.Method)
|
||||||
|
if method != "" && !method.IsValid() {
|
||||||
|
return HTTPRequest{}, fmt.Errorf("http request has invalid method: %v", method)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqProto := httpProtocolMap[req.Proto]
|
||||||
|
if !reqProto.IsValid() {
|
||||||
|
return HTTPRequest{}, fmt.Errorf("http request has invalid protocol: %v", req.Proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := proxy.RequestIDFromContext(req.Context())
|
||||||
|
if !ok {
|
||||||
|
return HTTPRequest{}, errors.New("http request has missing ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq := HTTPRequest{
|
||||||
|
ID: id,
|
||||||
|
URL: req.URL,
|
||||||
|
Method: method,
|
||||||
|
Proto: HTTPProtocol(req.Proto),
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Header != nil {
|
||||||
|
httpReq.Headers = make([]HTTPHeader, 0)
|
||||||
|
|
||||||
|
for key, values := range req.Header {
|
||||||
|
for _, value := range values {
|
||||||
|
httpReq.Headers = append(httpReq.Headers, HTTPHeader{
|
||||||
|
Key: key,
|
||||||
|
Value: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Body != nil {
|
||||||
|
body, err := ioutil.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return HTTPRequest{}, fmt.Errorf("failed to read request body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||||
|
bodyStr := string(body)
|
||||||
|
httpReq.Body = &bodyStr
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpReq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHTTPResponse(res *http.Response) (HTTPResponse, error) {
|
||||||
|
resProto := httpProtocolMap[res.Proto]
|
||||||
|
if !resProto.IsValid() {
|
||||||
|
return HTTPResponse{}, fmt.Errorf("http response has invalid protocol: %v", res.Proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := proxy.RequestIDFromContext(res.Request.Context())
|
||||||
|
if !ok {
|
||||||
|
return HTTPResponse{}, errors.New("http response has missing ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpRes := HTTPResponse{
|
||||||
|
ID: id,
|
||||||
|
Proto: resProto,
|
||||||
|
StatusCode: res.StatusCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
statusReasonSubs := strings.SplitN(res.Status, " ", 2)
|
||||||
|
|
||||||
|
if len(statusReasonSubs) == 2 {
|
||||||
|
httpRes.StatusReason = statusReasonSubs[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Header != nil {
|
||||||
|
httpRes.Headers = make([]HTTPHeader, 0)
|
||||||
|
|
||||||
|
for key, values := range res.Header {
|
||||||
|
for _, value := range values {
|
||||||
|
httpRes.Headers = append(httpRes.Headers, HTTPHeader{
|
||||||
|
Key: key,
|
||||||
|
Value: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Body != nil {
|
||||||
|
body, err := ioutil.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return HTTPResponse{}, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||||
|
bodyStr := string(body)
|
||||||
|
httpRes.Body = &bodyStr
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpRes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInterceptItem(item intercept.Item) (req HTTPRequest, err error) {
|
||||||
|
if item.Response != nil {
|
||||||
|
req, err = parseHTTPRequest(item.Response.Request)
|
||||||
|
if err != nil {
|
||||||
|
return HTTPRequest{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := parseHTTPResponse(item.Response)
|
||||||
|
if err != nil {
|
||||||
|
return HTTPRequest{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Response = &res
|
||||||
|
} else if item.Request != nil {
|
||||||
|
req, err = parseHTTPRequest(item.Request)
|
||||||
|
if err != nil {
|
||||||
|
return HTTPRequest{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProject(projSvc proj.Service, p proj.Project) Project {
|
||||||
|
project := Project{
|
||||||
|
ID: p.ID,
|
||||||
|
Name: p.Name,
|
||||||
|
IsActive: projSvc.IsProjectActive(p.ID),
|
||||||
|
Settings: &ProjectSettings{
|
||||||
|
Intercept: &InterceptSettings{
|
||||||
|
RequestsEnabled: p.Settings.InterceptRequests,
|
||||||
|
ResponsesEnabled: p.Settings.InterceptResponses,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Settings.InterceptRequestFilter != nil {
|
||||||
|
interceptReqFilter := p.Settings.InterceptRequestFilter.String()
|
||||||
|
project.Settings.Intercept.RequestFilter = &interceptReqFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Settings.InterceptResponseFilter != nil {
|
||||||
|
interceptResFilter := p.Settings.InterceptResponseFilter.String()
|
||||||
|
project.Settings.Intercept.ResponseFilter = &interceptResFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
func stringPtrToRegexp(s *string) (*regexp.Regexp, error) {
|
func stringPtrToRegexp(s *string) (*regexp.Regexp, error) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
|
@ -30,6 +30,11 @@ type Project {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
isActive: Boolean!
|
isActive: Boolean!
|
||||||
|
settings: ProjectSettings!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectSettings {
|
||||||
|
intercept: InterceptSettings!
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScopeRule {
|
type ScopeRule {
|
||||||
|
@ -116,6 +121,77 @@ type SenderRequestFilter {
|
||||||
searchExpression: String
|
searchExpression: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HttpRequest {
|
||||||
|
id: ID!
|
||||||
|
url: URL!
|
||||||
|
method: HttpMethod!
|
||||||
|
proto: HttpProtocol!
|
||||||
|
headers: [HttpHeader!]!
|
||||||
|
body: String
|
||||||
|
response: HttpResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
type HttpResponse {
|
||||||
|
"""
|
||||||
|
Will be the same ID as its related request ID.
|
||||||
|
"""
|
||||||
|
id: ID!
|
||||||
|
proto: HttpProtocol!
|
||||||
|
statusCode: Int!
|
||||||
|
statusReason: String!
|
||||||
|
body: String
|
||||||
|
headers: [HttpHeader!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
input ModifyRequestInput {
|
||||||
|
id: ID!
|
||||||
|
url: URL!
|
||||||
|
method: HttpMethod!
|
||||||
|
proto: HttpProtocol!
|
||||||
|
headers: [HttpHeaderInput!]
|
||||||
|
body: String
|
||||||
|
modifyResponse: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModifyRequestResult {
|
||||||
|
success: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CancelRequestResult {
|
||||||
|
success: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
input ModifyResponseInput {
|
||||||
|
requestID: ID!
|
||||||
|
proto: HttpProtocol!
|
||||||
|
headers: [HttpHeaderInput!]
|
||||||
|
body: String
|
||||||
|
statusCode: Int!
|
||||||
|
statusReason: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModifyResponseResult {
|
||||||
|
success: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CancelResponseResult {
|
||||||
|
success: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateInterceptSettingsInput {
|
||||||
|
requestsEnabled: Boolean!
|
||||||
|
responsesEnabled: Boolean!
|
||||||
|
requestFilter: String
|
||||||
|
responseFilter: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type InterceptSettings {
|
||||||
|
requestsEnabled: Boolean!
|
||||||
|
responsesEnabled: Boolean!
|
||||||
|
requestFilter: String
|
||||||
|
responseFilter: String
|
||||||
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
httpRequestLog(id: ID!): HttpRequestLog
|
httpRequestLog(id: ID!): HttpRequestLog
|
||||||
httpRequestLogs: [HttpRequestLog!]!
|
httpRequestLogs: [HttpRequestLog!]!
|
||||||
|
@ -125,6 +201,8 @@ type Query {
|
||||||
scope: [ScopeRule!]!
|
scope: [ScopeRule!]!
|
||||||
senderRequest(id: ID!): SenderRequest
|
senderRequest(id: ID!): SenderRequest
|
||||||
senderRequests: [SenderRequest!]!
|
senderRequests: [SenderRequest!]!
|
||||||
|
interceptedRequests: [HttpRequest!]!
|
||||||
|
interceptedRequest(id: ID!): HttpRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
@ -142,6 +220,13 @@ type Mutation {
|
||||||
createSenderRequestFromHttpRequestLog(id: ID!): SenderRequest!
|
createSenderRequestFromHttpRequestLog(id: ID!): SenderRequest!
|
||||||
sendRequest(id: ID!): SenderRequest!
|
sendRequest(id: ID!): SenderRequest!
|
||||||
deleteSenderRequests: DeleteSenderRequestsResult!
|
deleteSenderRequests: DeleteSenderRequestsResult!
|
||||||
|
modifyRequest(request: ModifyRequestInput!): ModifyRequestResult!
|
||||||
|
cancelRequest(id: ID!): CancelRequestResult!
|
||||||
|
modifyResponse(response: ModifyResponseInput!): ModifyResponseResult!
|
||||||
|
cancelResponse(requestID: ID!): CancelResponseResult!
|
||||||
|
updateInterceptSettings(
|
||||||
|
input: UpdateInterceptSettingsInput!
|
||||||
|
): InterceptSettings!
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HttpMethod {
|
enum HttpMethod {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
|
|
||||||
"github.com/oklog/ulid"
|
"github.com/oklog/ulid"
|
||||||
|
|
||||||
|
"github.com/dstotijn/hetty/pkg/proxy/intercept"
|
||||||
"github.com/dstotijn/hetty/pkg/reqlog"
|
"github.com/dstotijn/hetty/pkg/reqlog"
|
||||||
"github.com/dstotijn/hetty/pkg/scope"
|
"github.com/dstotijn/hetty/pkg/scope"
|
||||||
"github.com/dstotijn/hetty/pkg/search"
|
"github.com/dstotijn/hetty/pkg/search"
|
||||||
|
@ -33,10 +34,12 @@ type Service interface {
|
||||||
SetScopeRules(ctx context.Context, rules []scope.Rule) error
|
SetScopeRules(ctx context.Context, rules []scope.Rule) error
|
||||||
SetRequestLogFindFilter(ctx context.Context, filter reqlog.FindRequestsFilter) error
|
SetRequestLogFindFilter(ctx context.Context, filter reqlog.FindRequestsFilter) error
|
||||||
SetSenderRequestFindFilter(ctx context.Context, filter sender.FindRequestsFilter) error
|
SetSenderRequestFindFilter(ctx context.Context, filter sender.FindRequestsFilter) error
|
||||||
|
UpdateInterceptSettings(ctx context.Context, settings intercept.Settings) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type service struct {
|
type service struct {
|
||||||
repo Repository
|
repo Repository
|
||||||
|
interceptSvc *intercept.Service
|
||||||
reqLogSvc reqlog.Service
|
reqLogSvc reqlog.Service
|
||||||
senderSvc sender.Service
|
senderSvc sender.Service
|
||||||
scope *scope.Scope
|
scope *scope.Scope
|
||||||
|
@ -53,13 +56,22 @@ type Project struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
|
// Request log settings
|
||||||
ReqLogBypassOutOfScope bool
|
ReqLogBypassOutOfScope bool
|
||||||
ReqLogOnlyFindInScope bool
|
ReqLogOnlyFindInScope bool
|
||||||
ReqLogSearchExpr search.Expression
|
ReqLogSearchExpr search.Expression
|
||||||
|
|
||||||
|
// Intercept settings
|
||||||
|
InterceptRequests bool
|
||||||
|
InterceptResponses bool
|
||||||
|
InterceptRequestFilter search.Expression
|
||||||
|
InterceptResponseFilter search.Expression
|
||||||
|
|
||||||
|
// Sender settings
|
||||||
SenderOnlyFindInScope bool
|
SenderOnlyFindInScope bool
|
||||||
SenderSearchExpr search.Expression
|
SenderSearchExpr search.Expression
|
||||||
|
|
||||||
|
// Scope settings
|
||||||
ScopeRules []scope.Rule
|
ScopeRules []scope.Rule
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,6 +86,7 @@ var nameRegexp = regexp.MustCompile(`^[\w\d\s]+$`)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Repository Repository
|
Repository Repository
|
||||||
|
InterceptService *intercept.Service
|
||||||
ReqLogService reqlog.Service
|
ReqLogService reqlog.Service
|
||||||
SenderService sender.Service
|
SenderService sender.Service
|
||||||
Scope *scope.Scope
|
Scope *scope.Scope
|
||||||
|
@ -83,6 +96,7 @@ type Config struct {
|
||||||
func NewService(cfg Config) (Service, error) {
|
func NewService(cfg Config) (Service, error) {
|
||||||
return &service{
|
return &service{
|
||||||
repo: cfg.Repository,
|
repo: cfg.Repository,
|
||||||
|
interceptSvc: cfg.InterceptService,
|
||||||
reqLogSvc: cfg.ReqLogService,
|
reqLogSvc: cfg.ReqLogService,
|
||||||
senderSvc: cfg.SenderService,
|
senderSvc: cfg.SenderService,
|
||||||
scope: cfg.Scope,
|
scope: cfg.Scope,
|
||||||
|
@ -120,6 +134,12 @@ func (svc *service) CloseProject() error {
|
||||||
svc.reqLogSvc.SetActiveProjectID(ulid.ULID{})
|
svc.reqLogSvc.SetActiveProjectID(ulid.ULID{})
|
||||||
svc.reqLogSvc.SetBypassOutOfScopeRequests(false)
|
svc.reqLogSvc.SetBypassOutOfScopeRequests(false)
|
||||||
svc.reqLogSvc.SetFindReqsFilter(reqlog.FindRequestsFilter{})
|
svc.reqLogSvc.SetFindReqsFilter(reqlog.FindRequestsFilter{})
|
||||||
|
svc.interceptSvc.UpdateSettings(intercept.Settings{
|
||||||
|
RequestsEnabled: false,
|
||||||
|
ResponsesEnabled: false,
|
||||||
|
RequestFilter: nil,
|
||||||
|
ResponseFilter: nil,
|
||||||
|
})
|
||||||
svc.senderSvc.SetActiveProjectID(ulid.ULID{})
|
svc.senderSvc.SetActiveProjectID(ulid.ULID{})
|
||||||
svc.senderSvc.SetFindReqsFilter(sender.FindRequestsFilter{})
|
svc.senderSvc.SetFindReqsFilter(sender.FindRequestsFilter{})
|
||||||
svc.scope.SetRules(nil)
|
svc.scope.SetRules(nil)
|
||||||
|
@ -152,6 +172,7 @@ func (svc *service) OpenProject(ctx context.Context, projectID ulid.ULID) (Proje
|
||||||
|
|
||||||
svc.activeProjectID = project.ID
|
svc.activeProjectID = project.ID
|
||||||
|
|
||||||
|
// Request log settings.
|
||||||
svc.reqLogSvc.SetFindReqsFilter(reqlog.FindRequestsFilter{
|
svc.reqLogSvc.SetFindReqsFilter(reqlog.FindRequestsFilter{
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
OnlyInScope: project.Settings.ReqLogOnlyFindInScope,
|
OnlyInScope: project.Settings.ReqLogOnlyFindInScope,
|
||||||
|
@ -160,6 +181,15 @@ func (svc *service) OpenProject(ctx context.Context, projectID ulid.ULID) (Proje
|
||||||
svc.reqLogSvc.SetBypassOutOfScopeRequests(project.Settings.ReqLogBypassOutOfScope)
|
svc.reqLogSvc.SetBypassOutOfScopeRequests(project.Settings.ReqLogBypassOutOfScope)
|
||||||
svc.reqLogSvc.SetActiveProjectID(project.ID)
|
svc.reqLogSvc.SetActiveProjectID(project.ID)
|
||||||
|
|
||||||
|
// Intercept settings.
|
||||||
|
svc.interceptSvc.UpdateSettings(intercept.Settings{
|
||||||
|
RequestsEnabled: project.Settings.InterceptRequests,
|
||||||
|
ResponsesEnabled: project.Settings.InterceptResponses,
|
||||||
|
RequestFilter: project.Settings.InterceptRequestFilter,
|
||||||
|
ResponseFilter: project.Settings.InterceptResponseFilter,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sender settings.
|
||||||
svc.senderSvc.SetActiveProjectID(project.ID)
|
svc.senderSvc.SetActiveProjectID(project.ID)
|
||||||
svc.senderSvc.SetFindReqsFilter(sender.FindRequestsFilter{
|
svc.senderSvc.SetFindReqsFilter(sender.FindRequestsFilter{
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
|
@ -167,6 +197,7 @@ func (svc *service) OpenProject(ctx context.Context, projectID ulid.ULID) (Proje
|
||||||
SearchExpr: project.Settings.SenderSearchExpr,
|
SearchExpr: project.Settings.SenderSearchExpr,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Scope settings.
|
||||||
svc.scope.SetRules(project.Settings.ScopeRules)
|
svc.scope.SetRules(project.Settings.ScopeRules)
|
||||||
|
|
||||||
return project, nil
|
return project, nil
|
||||||
|
@ -264,3 +295,24 @@ func (svc *service) SetSenderRequestFindFilter(ctx context.Context, filter sende
|
||||||
func (svc *service) IsProjectActive(projectID ulid.ULID) bool {
|
func (svc *service) IsProjectActive(projectID ulid.ULID) bool {
|
||||||
return projectID.Compare(svc.activeProjectID) == 0
|
return projectID.Compare(svc.activeProjectID) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svc *service) UpdateInterceptSettings(ctx context.Context, settings intercept.Settings) error {
|
||||||
|
project, err := svc.ActiveProject(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
project.Settings.InterceptRequests = settings.RequestsEnabled
|
||||||
|
project.Settings.InterceptResponses = settings.ResponsesEnabled
|
||||||
|
project.Settings.InterceptRequestFilter = settings.RequestFilter
|
||||||
|
project.Settings.InterceptResponseFilter = settings.ResponseFilter
|
||||||
|
|
||||||
|
err = svc.repo.UpsertProject(ctx, project)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("proj: failed to update project: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.interceptSvc.UpdateSettings(settings)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
35
pkg/proxy/gzip.go
Normal file
35
pkg/proxy/gzip.go
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func gunzipResponseBody(res *http.Response) error {
|
||||||
|
if res.Header.Get("Content-Encoding") != "gzip" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
gzipReader, err := gzip.NewReader(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("proxy: could not create gzip reader: %w", err)
|
||||||
|
}
|
||||||
|
defer gzipReader.Close()
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
//nolint:gosec
|
||||||
|
if _, err := io.Copy(buf, gzipReader); err != nil {
|
||||||
|
return fmt.Errorf("proxy: could not read gzipped response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Body = io.NopCloser(buf)
|
||||||
|
res.Header.Del("Content-Encoding")
|
||||||
|
res.Header.Set("Content-Length", fmt.Sprint(buf.Len()))
|
||||||
|
res.ContentLength = int64(buf.Len())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
395
pkg/proxy/intercept/filter.go
Normal file
395
pkg/proxy/intercept/filter.go
Normal file
|
@ -0,0 +1,395 @@
|
||||||
|
package intercept
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dstotijn/hetty/pkg/scope"
|
||||||
|
"github.com/dstotijn/hetty/pkg/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:unparam
|
||||||
|
var reqFilterKeyFns = map[string]func(req *http.Request) (string, error){
|
||||||
|
"proto": func(req *http.Request) (string, error) { return req.Proto, nil },
|
||||||
|
"url": func(req *http.Request) (string, error) {
|
||||||
|
if req.URL == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return req.URL.String(), nil
|
||||||
|
},
|
||||||
|
"method": func(req *http.Request) (string, error) { return req.Method, nil },
|
||||||
|
"body": func(req *http.Request) (string, error) {
|
||||||
|
if req.Body == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||||
|
return string(body), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:unparam
|
||||||
|
var resFilterKeyFns = map[string]func(res *http.Response) (string, error){
|
||||||
|
"proto": func(res *http.Response) (string, error) { return res.Proto, nil },
|
||||||
|
"statusCode": func(res *http.Response) (string, error) { return strconv.Itoa(res.StatusCode), nil },
|
||||||
|
"statusReason": func(res *http.Response) (string, error) {
|
||||||
|
statusReasonSubs := strings.SplitN(res.Status, " ", 2)
|
||||||
|
|
||||||
|
if len(statusReasonSubs) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid response status %q", res.Status)
|
||||||
|
}
|
||||||
|
return statusReasonSubs[1], nil
|
||||||
|
},
|
||||||
|
"body": func(res *http.Response) (string, error) {
|
||||||
|
if res.Body == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||||
|
|
||||||
|
return string(body), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchRequestFilter returns true if an HTTP request matches the request filter expression.
|
||||||
|
func MatchRequestFilter(req *http.Request, expr search.Expression) (bool, error) {
|
||||||
|
switch e := expr.(type) {
|
||||||
|
case search.PrefixExpression:
|
||||||
|
return matchReqPrefixExpr(req, e)
|
||||||
|
case search.InfixExpression:
|
||||||
|
return matchReqInfixExpr(req, e)
|
||||||
|
case search.StringLiteral:
|
||||||
|
return matchReqStringLiteral(req, e)
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("expression type (%T) not supported", expr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchReqPrefixExpr(req *http.Request, expr search.PrefixExpression) (bool, error) {
|
||||||
|
switch expr.Operator {
|
||||||
|
case search.TokOpNot:
|
||||||
|
match, err := MatchRequestFilter(req, expr.Right)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return !match, nil
|
||||||
|
default:
|
||||||
|
return false, errors.New("operator is not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchReqInfixExpr(req *http.Request, expr search.InfixExpression) (bool, error) {
|
||||||
|
switch expr.Operator {
|
||||||
|
case search.TokOpAnd:
|
||||||
|
left, err := MatchRequestFilter(req, expr.Left)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
right, err := MatchRequestFilter(req, expr.Right)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return left && right, nil
|
||||||
|
case search.TokOpOr:
|
||||||
|
left, err := MatchRequestFilter(req, expr.Left)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
right, err := MatchRequestFilter(req, expr.Right)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return left || right, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
left, ok := expr.Left.(search.StringLiteral)
|
||||||
|
if !ok {
|
||||||
|
return false, errors.New("left operand must be a string literal")
|
||||||
|
}
|
||||||
|
|
||||||
|
leftVal, err := getMappedStringLiteralFromReq(req, left.Value)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get string literal from request for left operand: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
|
||||||
|
right, ok := expr.Right.(search.RegexpLiteral)
|
||||||
|
if !ok {
|
||||||
|
return false, errors.New("right operand must be a regular expression")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch expr.Operator {
|
||||||
|
case search.TokOpRe:
|
||||||
|
return right.MatchString(leftVal), nil
|
||||||
|
case search.TokOpNotRe:
|
||||||
|
return !right.MatchString(leftVal), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
right, ok := expr.Right.(search.StringLiteral)
|
||||||
|
if !ok {
|
||||||
|
return false, errors.New("right operand must be a string literal")
|
||||||
|
}
|
||||||
|
|
||||||
|
rightVal, err := getMappedStringLiteralFromReq(req, right.Value)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get string literal from request for right operand: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch expr.Operator {
|
||||||
|
case search.TokOpEq:
|
||||||
|
return leftVal == rightVal, nil
|
||||||
|
case search.TokOpNotEq:
|
||||||
|
return leftVal != rightVal, nil
|
||||||
|
case search.TokOpGt:
|
||||||
|
// TODO(?) attempt to parse as int.
|
||||||
|
return leftVal > rightVal, nil
|
||||||
|
case search.TokOpLt:
|
||||||
|
// TODO(?) attempt to parse as int.
|
||||||
|
return leftVal < rightVal, nil
|
||||||
|
case search.TokOpGtEq:
|
||||||
|
// TODO(?) attempt to parse as int.
|
||||||
|
return leftVal >= rightVal, nil
|
||||||
|
case search.TokOpLtEq:
|
||||||
|
// TODO(?) attempt to parse as int.
|
||||||
|
return leftVal <= rightVal, nil
|
||||||
|
default:
|
||||||
|
return false, errors.New("unsupported operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMappedStringLiteralFromReq(req *http.Request, s string) (string, error) {
|
||||||
|
fn, ok := reqFilterKeyFns[s]
|
||||||
|
if ok {
|
||||||
|
return fn(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchReqStringLiteral(req *http.Request, strLiteral search.StringLiteral) (bool, error) {
|
||||||
|
for _, fn := range reqFilterKeyFns {
|
||||||
|
value, err := fn(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(strings.ToLower(value), strings.ToLower(strLiteral.Value)) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchRequestScope(req *http.Request, s *scope.Scope) (bool, error) {
|
||||||
|
for _, rule := range s.Rules() {
|
||||||
|
if rule.URL != nil && req.URL != nil {
|
||||||
|
if matches := rule.URL.MatchString(req.URL.String()); matches {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, values := range req.Header {
|
||||||
|
var keyMatches, valueMatches bool
|
||||||
|
|
||||||
|
if rule.Header.Key != nil {
|
||||||
|
if matches := rule.Header.Key.MatchString(key); matches {
|
||||||
|
keyMatches = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.Header.Value != nil {
|
||||||
|
for _, value := range values {
|
||||||
|
if matches := rule.Header.Value.MatchString(value); matches {
|
||||||
|
valueMatches = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When only key or value is set, match on whatever is set.
|
||||||
|
// When both are set, both must match.
|
||||||
|
switch {
|
||||||
|
case rule.Header.Key != nil && rule.Header.Value == nil && keyMatches:
|
||||||
|
return true, nil
|
||||||
|
case rule.Header.Key == nil && rule.Header.Value != nil && valueMatches:
|
||||||
|
return true, nil
|
||||||
|
case rule.Header.Key != nil && rule.Header.Value != nil && keyMatches && valueMatches:
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.Body != nil {
|
||||||
|
body, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to read request body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||||
|
|
||||||
|
if matches := rule.Body.Match(body); matches {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchResponseFilter returns true if an HTTP response matches the response filter expression.
|
||||||
|
func MatchResponseFilter(res *http.Response, expr search.Expression) (bool, error) {
|
||||||
|
switch e := expr.(type) {
|
||||||
|
case search.PrefixExpression:
|
||||||
|
return matchResPrefixExpr(res, e)
|
||||||
|
case search.InfixExpression:
|
||||||
|
return matchResInfixExpr(res, e)
|
||||||
|
case search.StringLiteral:
|
||||||
|
return matchResStringLiteral(res, e)
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("expression type (%T) not supported", expr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchResPrefixExpr(res *http.Response, expr search.PrefixExpression) (bool, error) {
|
||||||
|
switch expr.Operator {
|
||||||
|
case search.TokOpNot:
|
||||||
|
match, err := MatchResponseFilter(res, expr.Right)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return !match, nil
|
||||||
|
default:
|
||||||
|
return false, errors.New("operator is not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchResInfixExpr(res *http.Response, expr search.InfixExpression) (bool, error) {
|
||||||
|
switch expr.Operator {
|
||||||
|
case search.TokOpAnd:
|
||||||
|
left, err := MatchResponseFilter(res, expr.Left)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
right, err := MatchResponseFilter(res, expr.Right)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return left && right, nil
|
||||||
|
case search.TokOpOr:
|
||||||
|
left, err := MatchResponseFilter(res, expr.Left)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
right, err := MatchResponseFilter(res, expr.Right)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return left || right, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
left, ok := expr.Left.(search.StringLiteral)
|
||||||
|
if !ok {
|
||||||
|
return false, errors.New("left operand must be a string literal")
|
||||||
|
}
|
||||||
|
|
||||||
|
leftVal, err := getMappedStringLiteralFromRes(res, left.Value)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get string literal from response for left operand: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
|
||||||
|
right, ok := expr.Right.(search.RegexpLiteral)
|
||||||
|
if !ok {
|
||||||
|
return false, errors.New("right operand must be a regular expression")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch expr.Operator {
|
||||||
|
case search.TokOpRe:
|
||||||
|
return right.MatchString(leftVal), nil
|
||||||
|
case search.TokOpNotRe:
|
||||||
|
return !right.MatchString(leftVal), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
right, ok := expr.Right.(search.StringLiteral)
|
||||||
|
if !ok {
|
||||||
|
return false, errors.New("right operand must be a string literal")
|
||||||
|
}
|
||||||
|
|
||||||
|
rightVal, err := getMappedStringLiteralFromRes(res, right.Value)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get string literal from response for right operand: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch expr.Operator {
|
||||||
|
case search.TokOpEq:
|
||||||
|
return leftVal == rightVal, nil
|
||||||
|
case search.TokOpNotEq:
|
||||||
|
return leftVal != rightVal, nil
|
||||||
|
case search.TokOpGt:
|
||||||
|
// TODO(?) attempt to parse as int.
|
||||||
|
return leftVal > rightVal, nil
|
||||||
|
case search.TokOpLt:
|
||||||
|
// TODO(?) attempt to parse as int.
|
||||||
|
return leftVal < rightVal, nil
|
||||||
|
case search.TokOpGtEq:
|
||||||
|
// TODO(?) attempt to parse as int.
|
||||||
|
return leftVal >= rightVal, nil
|
||||||
|
case search.TokOpLtEq:
|
||||||
|
// TODO(?) attempt to parse as int.
|
||||||
|
return leftVal <= rightVal, nil
|
||||||
|
default:
|
||||||
|
return false, errors.New("unsupported operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMappedStringLiteralFromRes(res *http.Response, s string) (string, error) {
|
||||||
|
fn, ok := resFilterKeyFns[s]
|
||||||
|
if ok {
|
||||||
|
return fn(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchResStringLiteral(res *http.Response, strLiteral search.StringLiteral) (bool, error) {
|
||||||
|
for _, fn := range resFilterKeyFns {
|
||||||
|
value, err := fn(res)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(strings.ToLower(value), strings.ToLower(strLiteral.Value)) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
452
pkg/proxy/intercept/intercept.go
Normal file
452
pkg/proxy/intercept/intercept.go
Normal file
|
@ -0,0 +1,452 @@
|
||||||
|
package intercept
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid"
|
||||||
|
|
||||||
|
"github.com/dstotijn/hetty/pkg/log"
|
||||||
|
"github.com/dstotijn/hetty/pkg/proxy"
|
||||||
|
"github.com/dstotijn/hetty/pkg/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrRequestAborted = errors.New("intercept: request was aborted")
|
||||||
|
ErrRequestNotFound = errors.New("intercept: request not found")
|
||||||
|
ErrRequestDone = errors.New("intercept: request is done")
|
||||||
|
ErrResponseNotFound = errors.New("intercept: response not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const interceptResponseKey contextKey = 0
|
||||||
|
|
||||||
|
// Request represents a server received HTTP request, alongside a channel for sending a modified version of it to the
|
||||||
|
// routine that's awaiting it. Also contains a channel for receiving a cancellation signal.
|
||||||
|
type Request struct {
|
||||||
|
req *http.Request
|
||||||
|
ch chan<- *http.Request
|
||||||
|
done <-chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response represents an HTTP response from a proxied request, alongside a channel for sending a modified version of it
|
||||||
|
// to the routine that's awaiting it. Also contains a channel for receiving a cancellation signal.
|
||||||
|
type Response struct {
|
||||||
|
res *http.Response
|
||||||
|
ch chan<- *http.Response
|
||||||
|
done <-chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Item struct {
|
||||||
|
Request *http.Request
|
||||||
|
Response *http.Response
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
reqMu *sync.RWMutex
|
||||||
|
resMu *sync.RWMutex
|
||||||
|
requests map[ulid.ULID]Request
|
||||||
|
responses map[ulid.ULID]Response
|
||||||
|
logger log.Logger
|
||||||
|
|
||||||
|
requestsEnabled bool
|
||||||
|
responsesEnabled bool
|
||||||
|
reqFilter search.Expression
|
||||||
|
resFilter search.Expression
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Logger log.Logger
|
||||||
|
RequestsEnabled bool
|
||||||
|
ResponsesEnabled bool
|
||||||
|
RequestFilter search.Expression
|
||||||
|
ResponseFilter search.Expression
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestIDs implements sort.Interface.
|
||||||
|
type RequestIDs []ulid.ULID
|
||||||
|
|
||||||
|
func NewService(cfg Config) *Service {
|
||||||
|
s := &Service{
|
||||||
|
reqMu: &sync.RWMutex{},
|
||||||
|
resMu: &sync.RWMutex{},
|
||||||
|
requests: make(map[ulid.ULID]Request),
|
||||||
|
responses: make(map[ulid.ULID]Response),
|
||||||
|
logger: cfg.Logger,
|
||||||
|
requestsEnabled: cfg.RequestsEnabled,
|
||||||
|
responsesEnabled: cfg.ResponsesEnabled,
|
||||||
|
reqFilter: cfg.RequestFilter,
|
||||||
|
resFilter: cfg.ResponseFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.logger == nil {
|
||||||
|
s.logger = log.NewNopLogger()
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestModifier is a proxy.RequestModifyMiddleware for intercepting HTTP requests.
|
||||||
|
func (svc *Service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestModifyFunc {
|
||||||
|
return func(req *http.Request) {
|
||||||
|
// This is a blocking operation, that gets unblocked when either a modified request is returned or an error
|
||||||
|
// (typically `context.Canceled`).
|
||||||
|
modifiedReq, err := svc.InterceptRequest(req.Context(), req)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrRequestAborted):
|
||||||
|
svc.logger.Debugw("Stopping intercept, request was aborted.")
|
||||||
|
// Prevent further processing by replacing req.Context with a cancelled context value.
|
||||||
|
// This will cause the http.Roundtripper in the `proxy` package to
|
||||||
|
// handle this request as an error.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
*req = *req.WithContext(ctx)
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
svc.logger.Debugw("Stopping intercept, context was cancelled.")
|
||||||
|
case err != nil:
|
||||||
|
svc.logger.Errorw("Failed to intercept request.",
|
||||||
|
"error", err)
|
||||||
|
default:
|
||||||
|
*req = *modifiedReq
|
||||||
|
next(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterceptRequest adds an HTTP request to an array of pending intercepted requests, alongside channels used for
|
||||||
|
// sending a cancellation signal and receiving a modified request. It's safe for concurrent use.
|
||||||
|
func (svc *Service) InterceptRequest(ctx context.Context, req *http.Request) (*http.Request, error) {
|
||||||
|
reqID, ok := proxy.RequestIDFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
svc.logger.Errorw("Failed to intercept: context doesn't have an ID.")
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !svc.requestsEnabled {
|
||||||
|
// If request intercept is disabled, return the incoming request as-is.
|
||||||
|
svc.logger.Debugw("Bypassed request interception: feature disabled.")
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.reqFilter != nil {
|
||||||
|
match, err := MatchRequestFilter(req, svc.reqFilter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("intercept: failed to match request rules for request (id: %v): %w",
|
||||||
|
reqID.String(), err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !match {
|
||||||
|
svc.logger.Debugw("Bypassed request interception: request rules don't match.")
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan *http.Request)
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
svc.reqMu.Lock()
|
||||||
|
svc.requests[reqID] = Request{
|
||||||
|
req: req,
|
||||||
|
ch: ch,
|
||||||
|
done: done,
|
||||||
|
}
|
||||||
|
svc.reqMu.Unlock()
|
||||||
|
|
||||||
|
// Whatever happens next (modified request returned, or a context cancelled error), any blocked channel senders
|
||||||
|
// should be unblocked, and the request should be removed from the requests queue.
|
||||||
|
defer func() {
|
||||||
|
close(done)
|
||||||
|
svc.reqMu.Lock()
|
||||||
|
defer svc.reqMu.Unlock()
|
||||||
|
delete(svc.requests, reqID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case modReq := <-ch:
|
||||||
|
if modReq == nil {
|
||||||
|
return nil, ErrRequestAborted
|
||||||
|
}
|
||||||
|
|
||||||
|
return modReq, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModifyRequest sends a modified HTTP request to the related channel, or returns ErrRequestDone when the request was
|
||||||
|
// cancelled. It's safe for concurrent use.
|
||||||
|
func (svc *Service) ModifyRequest(reqID ulid.ULID, modReq *http.Request, modifyResponse *bool) error {
|
||||||
|
svc.reqMu.RLock()
|
||||||
|
req, ok := svc.requests[reqID]
|
||||||
|
svc.reqMu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return ErrRequestNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
*modReq = *modReq.WithContext(req.req.Context())
|
||||||
|
if modifyResponse != nil {
|
||||||
|
*modReq = *modReq.WithContext(WithInterceptResponse(modReq.Context(), *modifyResponse))
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-req.done:
|
||||||
|
return ErrRequestDone
|
||||||
|
case req.ch <- modReq:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelRequest ensures an intercepted request is dropped.
|
||||||
|
func (svc *Service) CancelRequest(reqID ulid.ULID) error {
|
||||||
|
return svc.ModifyRequest(reqID, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) ClearRequests() {
|
||||||
|
svc.reqMu.Lock()
|
||||||
|
defer svc.reqMu.Unlock()
|
||||||
|
|
||||||
|
for _, req := range svc.requests {
|
||||||
|
select {
|
||||||
|
case <-req.done:
|
||||||
|
case req.ch <- nil:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) ClearResponses() {
|
||||||
|
svc.resMu.Lock()
|
||||||
|
defer svc.resMu.Unlock()
|
||||||
|
|
||||||
|
for _, res := range svc.responses {
|
||||||
|
select {
|
||||||
|
case <-res.done:
|
||||||
|
case res.ch <- nil:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items returns a list of pending items (requests and responses). It's safe for concurrent use.
|
||||||
|
func (svc *Service) Items() []Item {
|
||||||
|
svc.reqMu.RLock()
|
||||||
|
defer svc.reqMu.RUnlock()
|
||||||
|
|
||||||
|
svc.resMu.RLock()
|
||||||
|
defer svc.resMu.RUnlock()
|
||||||
|
|
||||||
|
reqIDs := make([]ulid.ULID, 0, len(svc.requests)+len(svc.responses))
|
||||||
|
|
||||||
|
for id := range svc.requests {
|
||||||
|
reqIDs = append(reqIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for id := range svc.responses {
|
||||||
|
reqIDs = append(reqIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Sort(RequestIDs(reqIDs))
|
||||||
|
|
||||||
|
items := make([]Item, len(reqIDs))
|
||||||
|
|
||||||
|
for i, id := range reqIDs {
|
||||||
|
item := Item{}
|
||||||
|
|
||||||
|
if req, ok := svc.requests[id]; ok {
|
||||||
|
item.Request = req.req
|
||||||
|
}
|
||||||
|
|
||||||
|
if res, ok := svc.responses[id]; ok {
|
||||||
|
item.Response = res.res
|
||||||
|
}
|
||||||
|
|
||||||
|
items[i] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) UpdateSettings(settings Settings) {
|
||||||
|
// When updating from requests `enabled` -> `disabled`, clear any pending reqs.
|
||||||
|
if svc.requestsEnabled && !settings.RequestsEnabled {
|
||||||
|
svc.ClearRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
// When updating from responses `enabled` -> `disabled`, clear any pending responses.
|
||||||
|
if svc.responsesEnabled && !settings.ResponsesEnabled {
|
||||||
|
svc.ClearResponses()
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.requestsEnabled = settings.RequestsEnabled
|
||||||
|
svc.responsesEnabled = settings.ResponsesEnabled
|
||||||
|
svc.reqFilter = settings.RequestFilter
|
||||||
|
svc.resFilter = settings.ResponseFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemByID returns an intercepted item (request and possible response) by ID. It's safe for concurrent use.
|
||||||
|
func (svc *Service) ItemByID(id ulid.ULID) (Item, error) {
|
||||||
|
svc.reqMu.RLock()
|
||||||
|
defer svc.reqMu.RUnlock()
|
||||||
|
|
||||||
|
svc.resMu.RLock()
|
||||||
|
defer svc.resMu.RUnlock()
|
||||||
|
|
||||||
|
item := Item{}
|
||||||
|
found := false
|
||||||
|
|
||||||
|
if req, ok := svc.requests[id]; ok {
|
||||||
|
item.Request = req.req
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if res, ok := svc.responses[id]; ok {
|
||||||
|
item.Response = res.res
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return Item{}, ErrRequestNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ids RequestIDs) Len() int {
|
||||||
|
return len(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ids RequestIDs) Less(i, j int) bool {
|
||||||
|
return ids[i].Compare(ids[j]) == -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ids RequestIDs) Swap(i, j int) {
|
||||||
|
ids[i], ids[j] = ids[j], ids[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithInterceptResponse(ctx context.Context, value bool) context.Context {
|
||||||
|
return context.WithValue(ctx, interceptResponseKey, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ShouldInterceptResponseFromContext(ctx context.Context) (bool, bool) {
|
||||||
|
shouldIntercept, ok := ctx.Value(interceptResponseKey).(bool)
|
||||||
|
return shouldIntercept, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseModifier is a proxy.ResponseModifyMiddleware for intercepting HTTP responses.
|
||||||
|
func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.ResponseModifyFunc {
|
||||||
|
return func(res *http.Response) error {
|
||||||
|
// This is a blocking operation, that gets unblocked when either a modified response is returned or an error.
|
||||||
|
//nolint:bodyclose
|
||||||
|
modifiedRes, err := svc.InterceptResponse(res.Request.Context(), res)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to intercept response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*res = *modifiedRes
|
||||||
|
|
||||||
|
return next(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterceptResponse adds an HTTP response to an array of pending intercepted responses, alongside channels used for
|
||||||
|
// sending a cancellation signal and receiving a modified response. It's safe for concurrent use.
|
||||||
|
func (svc *Service) InterceptResponse(ctx context.Context, res *http.Response) (*http.Response, error) {
|
||||||
|
reqID, ok := proxy.RequestIDFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
svc.logger.Errorw("Failed to intercept: context doesn't have an ID.")
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldIntercept, ok := ShouldInterceptResponseFromContext(ctx)
|
||||||
|
if ok && !shouldIntercept {
|
||||||
|
// If the related request explicitly disabled response intercept, return the response as-is.
|
||||||
|
svc.logger.Debugw("Bypassed response interception: related request explicitly disabled response intercept.")
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If global response intercept is disabled and interception is *not* explicitly enabled for this response: bypass.
|
||||||
|
if !svc.responsesEnabled && !(ok && shouldIntercept) {
|
||||||
|
svc.logger.Debugw("Bypassed response interception: feature disabled.")
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.resFilter != nil {
|
||||||
|
match, err := MatchResponseFilter(res, svc.resFilter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("intercept: failed to match response rules for response (id: %v): %w",
|
||||||
|
reqID.String(), err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !match {
|
||||||
|
svc.logger.Debugw("Bypassed response interception: response rules don't match.")
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan *http.Response)
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
svc.resMu.Lock()
|
||||||
|
svc.responses[reqID] = Response{
|
||||||
|
res: res,
|
||||||
|
ch: ch,
|
||||||
|
done: done,
|
||||||
|
}
|
||||||
|
svc.resMu.Unlock()
|
||||||
|
|
||||||
|
// Whatever happens next (modified response returned, or a context cancelled error), any blocked channel senders
|
||||||
|
// should be unblocked, and the response should be removed from the responses queue.
|
||||||
|
defer func() {
|
||||||
|
close(done)
|
||||||
|
svc.resMu.Lock()
|
||||||
|
defer svc.resMu.Unlock()
|
||||||
|
delete(svc.responses, reqID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case modRes := <-ch:
|
||||||
|
if modRes == nil {
|
||||||
|
return nil, ErrRequestAborted
|
||||||
|
}
|
||||||
|
|
||||||
|
return modRes, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModifyResponse sends a modified HTTP response to the related channel, or returns ErrRequestDone when the related
|
||||||
|
// request was cancelled. It's safe for concurrent use.
|
||||||
|
func (svc *Service) ModifyResponse(reqID ulid.ULID, modRes *http.Response) error {
|
||||||
|
svc.resMu.RLock()
|
||||||
|
res, ok := svc.responses[reqID]
|
||||||
|
svc.resMu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return ErrRequestNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if modRes != nil {
|
||||||
|
modRes.Request = res.res.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-res.done:
|
||||||
|
return ErrRequestDone
|
||||||
|
case res.ch <- modRes:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelResponse ensures an intercepted response is dropped.
|
||||||
|
func (svc *Service) CancelResponse(reqID ulid.ULID) error {
|
||||||
|
return svc.ModifyResponse(reqID, nil)
|
||||||
|
}
|
270
pkg/proxy/intercept/intercept_test.go
Normal file
270
pkg/proxy/intercept/intercept_test.go
Normal file
|
@ -0,0 +1,270 @@
|
||||||
|
package intercept_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/dstotijn/hetty/pkg/proxy"
|
||||||
|
"github.com/dstotijn/hetty/pkg/proxy/intercept"
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:gosec
|
||||||
|
var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
|
||||||
|
func TestRequestModifier(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("modify request that's not found", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger, _ := zap.NewDevelopment()
|
||||||
|
svc := intercept.NewService(intercept.Config{
|
||||||
|
Logger: logger.Sugar(),
|
||||||
|
RequestsEnabled: true,
|
||||||
|
ResponsesEnabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
|
||||||
|
|
||||||
|
err := svc.ModifyRequest(reqID, nil, nil)
|
||||||
|
if !errors.Is(err, intercept.ErrRequestNotFound) {
|
||||||
|
t.Fatalf("expected `intercept.ErrRequestNotFound`, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("modify request that's done", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger, _ := zap.NewDevelopment()
|
||||||
|
svc := intercept.NewService(intercept.Config{
|
||||||
|
Logger: logger.Sugar(),
|
||||||
|
RequestsEnabled: true,
|
||||||
|
ResponsesEnabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "https://example.com/foo", nil)
|
||||||
|
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
|
||||||
|
*req = *req.WithContext(ctx)
|
||||||
|
*req = *req.WithContext(proxy.WithRequestID(req.Context(), reqID))
|
||||||
|
|
||||||
|
next := func(req *http.Request) {}
|
||||||
|
go svc.RequestModifier(next)(req)
|
||||||
|
|
||||||
|
// Wait shortly, to allow the req modifier goroutine to add `req` to the
|
||||||
|
// array of intercepted reqs.
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
modReq := req.Clone(req.Context())
|
||||||
|
modReq.Header.Set("X-Foo", "bar")
|
||||||
|
|
||||||
|
err := svc.ModifyRequest(reqID, modReq, nil)
|
||||||
|
if !errors.Is(err, intercept.ErrRequestDone) {
|
||||||
|
t.Fatalf("expected `intercept.ErrRequestDone`, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("modify intercepted request", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "https://example.com/foo", nil)
|
||||||
|
req.Header.Set("X-Foo", "foo")
|
||||||
|
|
||||||
|
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
|
||||||
|
*req = *req.WithContext(proxy.WithRequestID(req.Context(), reqID))
|
||||||
|
|
||||||
|
modReq := req.Clone(context.Background())
|
||||||
|
modReq.Header.Set("X-Foo", "bar")
|
||||||
|
|
||||||
|
logger, _ := zap.NewDevelopment()
|
||||||
|
svc := intercept.NewService(intercept.Config{
|
||||||
|
Logger: logger.Sugar(),
|
||||||
|
RequestsEnabled: true,
|
||||||
|
ResponsesEnabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
var got *http.Request
|
||||||
|
|
||||||
|
next := func(req *http.Request) {
|
||||||
|
got = req.Clone(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
svc.RequestModifier(next)(req)
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait shortly, to allow the req modifier goroutine to add `req` to the
|
||||||
|
// array of intercepted reqs.
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
err := svc.ModifyRequest(reqID, modReq, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected `got` not to be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if exp := "bar"; exp != got.Header.Get("X-Foo") {
|
||||||
|
t.Fatalf("incorrect modified request header value (expected: %v, got: %v)", exp, got.Header.Get("X-Foo"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseModifier(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("modify response that's not found", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger, _ := zap.NewDevelopment()
|
||||||
|
svc := intercept.NewService(intercept.Config{
|
||||||
|
Logger: logger.Sugar(),
|
||||||
|
RequestsEnabled: false,
|
||||||
|
ResponsesEnabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
|
||||||
|
|
||||||
|
err := svc.ModifyResponse(reqID, nil)
|
||||||
|
if !errors.Is(err, intercept.ErrRequestNotFound) {
|
||||||
|
t.Fatalf("expected `intercept.ErrRequestNotFound`, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("modify response of request that's done", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger, _ := zap.NewDevelopment()
|
||||||
|
svc := intercept.NewService(intercept.Config{
|
||||||
|
Logger: logger.Sugar(),
|
||||||
|
RequestsEnabled: false,
|
||||||
|
ResponsesEnabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "https://example.com/foo", nil)
|
||||||
|
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
|
||||||
|
*req = *req.WithContext(ctx)
|
||||||
|
*req = *req.WithContext(proxy.WithRequestID(req.Context(), reqID))
|
||||||
|
|
||||||
|
res := &http.Response{
|
||||||
|
Request: req,
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
res.Header.Add("X-Foo", "foo")
|
||||||
|
|
||||||
|
var modErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
next := func(res *http.Response) error { return nil }
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
modErr = svc.ResponseModifier(next)(res)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait shortly, to allow the res modifier goroutine to add `res` to the
|
||||||
|
// array of intercepted responses.
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
modRes := *res
|
||||||
|
modRes.Header = make(http.Header)
|
||||||
|
modRes.Header.Set("X-Foo", "bar")
|
||||||
|
|
||||||
|
err := svc.ModifyResponse(reqID, &modRes)
|
||||||
|
if !errors.Is(err, intercept.ErrRequestDone) {
|
||||||
|
t.Fatalf("expected `intercept.ErrRequestDone`, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if !errors.Is(modErr, context.Canceled) {
|
||||||
|
t.Fatalf("expected `context.Canceled`, got: %v", modErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("modify intercepted response", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "https://example.com/foo", nil)
|
||||||
|
req.Header.Set("X-Foo", "foo")
|
||||||
|
|
||||||
|
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
|
||||||
|
*req = *req.WithContext(proxy.WithRequestID(req.Context(), reqID))
|
||||||
|
|
||||||
|
res := &http.Response{
|
||||||
|
Request: req,
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
res.Header.Add("X-Foo", "foo")
|
||||||
|
|
||||||
|
modRes := *res
|
||||||
|
modRes.Header = make(http.Header)
|
||||||
|
modRes.Header.Set("X-Foo", "bar")
|
||||||
|
|
||||||
|
logger, _ := zap.NewDevelopment()
|
||||||
|
svc := intercept.NewService(intercept.Config{
|
||||||
|
Logger: logger.Sugar(),
|
||||||
|
RequestsEnabled: false,
|
||||||
|
ResponsesEnabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
var gotHeader string
|
||||||
|
|
||||||
|
var next proxy.ResponseModifyFunc = func(res *http.Response) error {
|
||||||
|
gotHeader = res.Header.Get("X-Foo")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var modErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
modErr = svc.ResponseModifier(next)(res)
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait shortly, to allow the res modifier goroutine to add `req` to the
|
||||||
|
// array of intercepted reqs.
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
err := svc.ModifyResponse(reqID, &modRes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if modErr != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", modErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exp := "bar"; exp != gotHeader {
|
||||||
|
t.Fatalf("incorrect modified request header value (expected: %v, got: %v)", exp, gotHeader)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
10
pkg/proxy/intercept/settings.go
Normal file
10
pkg/proxy/intercept/settings.go
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
package intercept
|
||||||
|
|
||||||
|
import "github.com/dstotijn/hetty/pkg/search"
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
RequestsEnabled bool
|
||||||
|
ResponsesEnabled bool
|
||||||
|
RequestFilter search.Expression
|
||||||
|
ResponseFilter search.Expression
|
||||||
|
}
|
|
@ -7,16 +7,24 @@ import (
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid"
|
||||||
|
|
||||||
"github.com/dstotijn/hetty/pkg/log"
|
"github.com/dstotijn/hetty/pkg/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//nolint:gosec
|
||||||
|
var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
|
||||||
type contextKey int
|
type contextKey int
|
||||||
|
|
||||||
const ReqLogIDKey contextKey = 0
|
const reqIDKey contextKey = 0
|
||||||
|
|
||||||
// Proxy implements http.Handler and offers MITM behaviour for modifying
|
// Proxy implements http.Handler and offers MITM behaviour for modifying
|
||||||
// HTTP requests and responses.
|
// HTTP requests and responses.
|
||||||
|
@ -54,7 +62,25 @@ func NewProxy(cfg Config) (*Proxy, error) {
|
||||||
p.logger = log.NewNopLogger()
|
p.logger = log.NewNopLogger()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
// Values taken from `http.DefaultTransport`.
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
|
||||||
|
// Non-default transport values.
|
||||||
|
DisableCompression: true,
|
||||||
|
}
|
||||||
|
|
||||||
p.handler = &httputil.ReverseProxy{
|
p.handler = &httputil.ReverseProxy{
|
||||||
|
Transport: transport,
|
||||||
Director: p.modifyRequest,
|
Director: p.modifyRequest,
|
||||||
ModifyResponse: p.modifyResponse,
|
ModifyResponse: p.modifyResponse,
|
||||||
ErrorHandler: p.errorHandler,
|
ErrorHandler: p.errorHandler,
|
||||||
|
@ -69,6 +95,10 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
|
||||||
|
ctx := context.WithValue(r.Context(), reqIDKey, reqID)
|
||||||
|
*r = *r.WithContext(ctx)
|
||||||
|
|
||||||
p.handler.ServeHTTP(w, r)
|
p.handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,6 +121,25 @@ func (p *Proxy) modifyRequest(r *http.Request) {
|
||||||
// set this header.
|
// set this header.
|
||||||
r.Header["X-Forwarded-For"] = nil
|
r.Header["X-Forwarded-For"] = nil
|
||||||
|
|
||||||
|
// Strip unsupported encodings.
|
||||||
|
if acceptEncs := r.Header.Get("Accept-Encoding"); acceptEncs != "" {
|
||||||
|
directives := strings.Split(acceptEncs, ",")
|
||||||
|
updated := make([]string, 0, len(directives))
|
||||||
|
|
||||||
|
for _, directive := range directives {
|
||||||
|
stripped := strings.TrimSpace(directive)
|
||||||
|
if strings.HasPrefix(stripped, "*") || strings.HasPrefix(stripped, "gzip") {
|
||||||
|
updated = append(updated, stripped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updated) == 0 {
|
||||||
|
r.Header.Del("Accept-Encoding")
|
||||||
|
} else {
|
||||||
|
r.Header.Set("Accept-Encoding", strings.Join(updated, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn := nopReqModifier
|
fn := nopReqModifier
|
||||||
|
|
||||||
for i := len(p.reqModifiers) - 1; i >= 0; i-- {
|
for i := len(p.reqModifiers) - 1; i >= 0; i-- {
|
||||||
|
@ -103,6 +152,11 @@ func (p *Proxy) modifyRequest(r *http.Request) {
|
||||||
func (p *Proxy) modifyResponse(res *http.Response) error {
|
func (p *Proxy) modifyResponse(res *http.Response) error {
|
||||||
fn := nopResModifier
|
fn := nopResModifier
|
||||||
|
|
||||||
|
// TODO: Make decompressing gzip formatted response bodies a configurable project setting.
|
||||||
|
if err := gunzipResponseBody(res); err != nil {
|
||||||
|
return fmt.Errorf("proxy: failed to gunzip response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
for i := len(p.resModifiers) - 1; i >= 0; i-- {
|
for i := len(p.resModifiers) - 1; i >= 0; i-- {
|
||||||
fn = p.resModifiers[i](fn)
|
fn = p.resModifiers[i](fn)
|
||||||
}
|
}
|
||||||
|
@ -110,6 +164,15 @@ func (p *Proxy) modifyResponse(res *http.Response) error {
|
||||||
return fn(res)
|
return fn(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithRequestID(ctx context.Context, id ulid.ULID) context.Context {
|
||||||
|
return context.WithValue(ctx, reqIDKey, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequestIDFromContext(ctx context.Context) (ulid.ULID, bool) {
|
||||||
|
id, ok := ctx.Value(reqIDKey).(ulid.ULID)
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|
||||||
// handleConnect hijacks the incoming HTTP request and sets up an HTTP tunnel.
|
// handleConnect hijacks the incoming HTTP request and sets up an HTTP tunnel.
|
||||||
// During the TLS handshake with the client, we use the proxy's CA config to
|
// During the TLS handshake with the client, we use the proxy's CA config to
|
||||||
// create a certificate on-the-fly.
|
// create a certificate on-the-fly.
|
||||||
|
@ -170,12 +233,13 @@ func (p *Proxy) clientTLSConn(conn net.Conn) (*tls.Conn, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Proxy) errorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
func (p *Proxy) errorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
if errors.Is(err, context.Canceled) {
|
switch {
|
||||||
return
|
case !errors.Is(err, context.Canceled):
|
||||||
}
|
|
||||||
|
|
||||||
p.logger.Errorw("Failed to proxy request.",
|
p.logger.Errorw("Failed to proxy request.",
|
||||||
"error", err)
|
"error", err)
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
p.logger.Debugw("Proxy request was cancelled.")
|
||||||
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,16 +2,13 @@ package reqlog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/oklog/ulid"
|
"github.com/oklog/ulid"
|
||||||
|
|
||||||
|
@ -23,16 +20,16 @@ import (
|
||||||
|
|
||||||
type contextKey int
|
type contextKey int
|
||||||
|
|
||||||
const LogBypassedKey contextKey = 0
|
const (
|
||||||
|
LogBypassedKey contextKey = iota
|
||||||
|
ReqLogIDKey
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrRequestNotFound = errors.New("reqlog: request not found")
|
ErrRequestNotFound = errors.New("reqlog: request not found")
|
||||||
ErrProjectIDMustBeSet = errors.New("reqlog: project ID must be set")
|
ErrProjectIDMustBeSet = errors.New("reqlog: project ID must be set")
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:gosec
|
|
||||||
var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
||||||
|
|
||||||
type RequestLog struct {
|
type RequestLog struct {
|
||||||
ID ulid.ULID
|
ID ulid.ULID
|
||||||
ProjectID ulid.ULID
|
ProjectID ulid.ULID
|
||||||
|
@ -170,8 +167,14 @@ func (svc *service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestM
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reqID, ok := proxy.RequestIDFromContext(req.Context())
|
||||||
|
if !ok {
|
||||||
|
svc.logger.Errorw("Bypassed logging: request doesn't have an ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
reqLog := RequestLog{
|
reqLog := RequestLog{
|
||||||
ID: ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy),
|
ID: reqID,
|
||||||
ProjectID: svc.activeProjectID,
|
ProjectID: svc.activeProjectID,
|
||||||
Method: clone.Method,
|
Method: clone.Method,
|
||||||
URL: clone.URL,
|
URL: clone.URL,
|
||||||
|
@ -191,7 +194,7 @@ func (svc *service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestM
|
||||||
"reqLogID", reqLog.ID.String(),
|
"reqLogID", reqLog.ID.String(),
|
||||||
"url", reqLog.URL.String())
|
"url", reqLog.URL.String())
|
||||||
|
|
||||||
ctx := context.WithValue(req.Context(), proxy.ReqLogIDKey, reqLog.ID)
|
ctx := context.WithValue(req.Context(), ReqLogIDKey, reqLog.ID)
|
||||||
*req = *req.WithContext(ctx)
|
*req = *req.WithContext(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -206,21 +209,23 @@ func (svc *service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
reqLogID, ok := res.Request.Context().Value(proxy.ReqLogIDKey).(ulid.ULID)
|
reqLogID, ok := res.Request.Context().Value(ReqLogIDKey).(ulid.ULID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("reqlog: request is missing ID")
|
return errors.New("reqlog: request is missing ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
clone := *res
|
clone := *res
|
||||||
|
|
||||||
|
if res.Body != nil {
|
||||||
// TODO: Use io.LimitReader.
|
// TODO: Use io.LimitReader.
|
||||||
body, err := ioutil.ReadAll(res.Body)
|
body, err := io.ReadAll(res.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reqlog: could not read response body: %w", err)
|
return fmt.Errorf("reqlog: could not read response body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
res.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||||
clone.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
clone.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := svc.storeResponse(context.Background(), reqLogID, &clone); err != nil {
|
if err := svc.storeResponse(context.Background(), reqLogID, &clone); err != nil {
|
||||||
|
@ -261,23 +266,6 @@ func (svc *service) BypassOutOfScopeRequests() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseHTTPResponse(res *http.Response) (ResponseLog, error) {
|
func ParseHTTPResponse(res *http.Response) (ResponseLog, error) {
|
||||||
if res.Header.Get("Content-Encoding") == "gzip" {
|
|
||||||
gzipReader, err := gzip.NewReader(res.Body)
|
|
||||||
if err != nil {
|
|
||||||
return ResponseLog{}, fmt.Errorf("reqlog: could not create gzip reader: %w", err)
|
|
||||||
}
|
|
||||||
defer gzipReader.Close()
|
|
||||||
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
|
|
||||||
//nolint:gosec
|
|
||||||
if _, err := io.Copy(buf, gzipReader); err != nil {
|
|
||||||
return ResponseLog{}, fmt.Errorf("reqlog: could not read gzipped response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res.Body = io.NopCloser(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(res.Body)
|
body, err := io.ReadAll(res.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ResponseLog{}, fmt.Errorf("reqlog: could not read body: %w", err)
|
return ResponseLog{}, fmt.Errorf("reqlog: could not read body: %w", err)
|
||||||
|
|
|
@ -41,6 +41,8 @@ func TestRequestModifier(t *testing.T) {
|
||||||
}
|
}
|
||||||
reqModFn := svc.RequestModifier(next)
|
reqModFn := svc.RequestModifier(next)
|
||||||
req := httptest.NewRequest("GET", "https://example.com/", strings.NewReader("bar"))
|
req := httptest.NewRequest("GET", "https://example.com/", strings.NewReader("bar"))
|
||||||
|
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
|
||||||
|
req = req.WithContext(proxy.WithRequestID(req.Context(), reqID))
|
||||||
|
|
||||||
reqModFn(req)
|
reqModFn(req)
|
||||||
|
|
||||||
|
@ -88,7 +90,7 @@ func TestResponseModifier(t *testing.T) {
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "https://example.com/", strings.NewReader("bar"))
|
req := httptest.NewRequest("GET", "https://example.com/", strings.NewReader("bar"))
|
||||||
reqLogID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
|
reqLogID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
|
||||||
req = req.WithContext(context.WithValue(req.Context(), proxy.ReqLogIDKey, reqLogID))
|
req = req.WithContext(context.WithValue(req.Context(), reqlog.ReqLogIDKey, reqLogID))
|
||||||
|
|
||||||
res := &http.Response{
|
res := &http.Response{
|
||||||
Request: req,
|
Request: req,
|
||||||
|
|
|
@ -3,7 +3,6 @@ package reqlog
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
@ -100,7 +99,7 @@ func (reqLog RequestLog) matchInfixExpr(expr search.InfixExpression) (bool, erro
|
||||||
leftVal := reqLog.getMappedStringLiteral(left.Value)
|
leftVal := reqLog.getMappedStringLiteral(left.Value)
|
||||||
|
|
||||||
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
|
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
|
||||||
right, ok := expr.Right.(*regexp.Regexp)
|
right, ok := expr.Right.(search.RegexpLiteral)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, errors.New("right operand must be a regular expression")
|
return false, errors.New("right operand must be a regular expression")
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package search
|
||||||
import (
|
import (
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -50,13 +51,17 @@ type StringLiteral struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sl StringLiteral) String() string {
|
func (sl StringLiteral) String() string {
|
||||||
return sl.Value
|
return strconv.Quote(sl.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegexpLiteral struct {
|
type RegexpLiteral struct {
|
||||||
*regexp.Regexp
|
*regexp.Regexp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rl RegexpLiteral) String() string {
|
||||||
|
return strconv.Quote(rl.Regexp.String())
|
||||||
|
}
|
||||||
|
|
||||||
func (rl RegexpLiteral) MarshalBinary() ([]byte, error) {
|
func (rl RegexpLiteral) MarshalBinary() ([]byte, error) {
|
||||||
return []byte(rl.Regexp.String()), nil
|
return []byte(rl.Regexp.String()), nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -208,7 +208,7 @@ func parseInfixExpression(p *Parser, left Expression) (Expression, error) {
|
||||||
return nil, fmt.Errorf("could not compile regular expression %q: %w", rightStr.Value, err)
|
return nil, fmt.Errorf("could not compile regular expression %q: %w", rightStr.Value, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
right = re
|
right = RegexpLiteral{re}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -94,7 +94,7 @@ func TestParseQuery(t *testing.T) {
|
||||||
expectedExpression: InfixExpression{
|
expectedExpression: InfixExpression{
|
||||||
Operator: TokOpRe,
|
Operator: TokOpRe,
|
||||||
Left: StringLiteral{Value: "foo"},
|
Left: StringLiteral{Value: "foo"},
|
||||||
Right: regexp.MustCompile("bar"),
|
Right: RegexpLiteral{regexp.MustCompile("bar")},
|
||||||
},
|
},
|
||||||
expectedError: nil,
|
expectedError: nil,
|
||||||
},
|
},
|
||||||
|
@ -104,7 +104,7 @@ func TestParseQuery(t *testing.T) {
|
||||||
expectedExpression: InfixExpression{
|
expectedExpression: InfixExpression{
|
||||||
Operator: TokOpNotRe,
|
Operator: TokOpNotRe,
|
||||||
Left: StringLiteral{Value: "foo"},
|
Left: StringLiteral{Value: "foo"},
|
||||||
Right: regexp.MustCompile("bar"),
|
Right: RegexpLiteral{regexp.MustCompile("bar")},
|
||||||
},
|
},
|
||||||
expectedError: nil,
|
expectedError: nil,
|
||||||
},
|
},
|
||||||
|
@ -197,7 +197,7 @@ func TestParseQuery(t *testing.T) {
|
||||||
Right: InfixExpression{
|
Right: InfixExpression{
|
||||||
Operator: TokOpRe,
|
Operator: TokOpRe,
|
||||||
Left: StringLiteral{Value: "baz"},
|
Left: StringLiteral{Value: "baz"},
|
||||||
Right: regexp.MustCompile("yolo"),
|
Right: RegexpLiteral{regexp.MustCompile("yolo")},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedError: nil,
|
expectedError: nil,
|
||||||
|
|
|
@ -3,7 +3,6 @@ package sender
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/oklog/ulid"
|
"github.com/oklog/ulid"
|
||||||
|
@ -93,7 +92,7 @@ func (req Request) matchInfixExpr(expr search.InfixExpression) (bool, error) {
|
||||||
leftVal := req.getMappedStringLiteral(left.Value)
|
leftVal := req.getMappedStringLiteral(left.Value)
|
||||||
|
|
||||||
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
|
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
|
||||||
right, ok := expr.Right.(*regexp.Regexp)
|
right, ok := expr.Right.(search.RegexpLiteral)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, errors.New("right operand must be a regular expression")
|
return false, errors.New("right operand must be a regular expression")
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue