mirror of
https://github.com/dstotijn/hetty
synced 2024-11-21 19:23:02 +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"],
|
||||
"@next/next/no-css-tags": "off",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
|
||||
"import/default": "off",
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import AltRouteIcon from "@mui/icons-material/AltRoute";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import SendIcon from "@mui/icons-material/Send";
|
||||
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
|
||||
import {
|
||||
Theme,
|
||||
useTheme,
|
||||
|
@ -19,6 +20,7 @@ import {
|
|||
CSSObject,
|
||||
Box,
|
||||
ListItemText,
|
||||
Badge,
|
||||
} from "@mui/material";
|
||||
import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar";
|
||||
import MuiDrawer from "@mui/material/Drawer";
|
||||
|
@ -28,15 +30,18 @@ import Link from "next/link";
|
|||
import React, { useState } from "react";
|
||||
|
||||
import { useActiveProject } from "lib/ActiveProjectContext";
|
||||
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
|
||||
|
||||
export enum Page {
|
||||
Home,
|
||||
GetStarted,
|
||||
Intercept,
|
||||
Projects,
|
||||
ProxySetup,
|
||||
ProxyLogs,
|
||||
Sender,
|
||||
Scope,
|
||||
Settings,
|
||||
}
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
@ -135,6 +140,7 @@ interface Props {
|
|||
|
||||
export function Layout({ title, page, children }: Props): JSX.Element {
|
||||
const activeProject = useActiveProject();
|
||||
const interceptedRequests = useInterceptedRequests();
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
|
@ -204,12 +210,24 @@ export function Layout({ title, page, children }: Props): JSX.Element {
|
|||
</Link>
|
||||
<Link href="/proxy/logs" passHref>
|
||||
<ListItemButton key="proxyLogs" disabled={!activeProject} selected={page === Page.ProxyLogs}>
|
||||
<Tooltip title="Proxy">
|
||||
<Tooltip title="Proxy logs">
|
||||
<ListItemIcon>
|
||||
<SettingsEthernetIcon />
|
||||
<FormatListBulletedIcon />
|
||||
</ListItemIcon>
|
||||
</Tooltip>
|
||||
<ListItemText primary="Proxy" />
|
||||
<ListItemText primary="Logs" />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link href="/proxy/intercept" passHref>
|
||||
<ListItemButton key="proxyIntercept" disabled={!activeProject} selected={page === Page.Intercept}>
|
||||
<Tooltip title="Proxy intercept">
|
||||
<ListItemIcon>
|
||||
<Badge color="error" badgeContent={interceptedRequests?.length || 0}>
|
||||
<AltRouteIcon />
|
||||
</Badge>
|
||||
</ListItemIcon>
|
||||
</Tooltip>
|
||||
<ListItemText primary="Intercept" />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link href="/sender" passHref>
|
||||
|
|
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 DescriptionIcon from "@mui/icons-material/Description";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import { Alert } from "@mui/lab";
|
||||
import {
|
||||
Avatar,
|
||||
|
@ -29,6 +30,7 @@ import React, { useState } from "react";
|
|||
|
||||
import useOpenProjectMutation from "../hooks/useOpenProjectMutation";
|
||||
|
||||
import Link, { NextLinkComposed } from "lib/components/Link";
|
||||
import {
|
||||
ProjectsQuery,
|
||||
useCloseProjectMutation,
|
||||
|
@ -179,6 +181,11 @@ function ProjectList(): JSX.Element {
|
|||
{project.name} {project.isActive && <em>(Active)</em>}
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Project settings">
|
||||
<IconButton LinkComponent={Link} href="/settings" disabled={!project.isActive}>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{project.isActive && (
|
||||
<Tooltip title="Close project">
|
||||
<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 { useState } from "react";
|
||||
|
||||
import Actions from "./Actions";
|
||||
import LogDetail from "./LogDetail";
|
||||
import Search from "./Search";
|
||||
|
||||
|
@ -94,7 +95,14 @@ export function RequestLogs(): JSX.Element {
|
|||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" height="100%">
|
||||
<Search />
|
||||
<Box display="flex">
|
||||
<Box flex="1 auto">
|
||||
<Search />
|
||||
</Box>
|
||||
<Box pt={0.5}>
|
||||
<Actions />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flex: "1 auto", position: "relative" }}>
|
||||
<SplitPane split="horizontal" size={"40%"}>
|
||||
<Box sx={{ width: "100%", height: "100%", pb: 2 }}>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { Alert } from "@mui/lab";
|
||||
|
@ -17,11 +16,8 @@ import {
|
|||
import IconButton from "@mui/material/IconButton";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
|
||||
import {
|
||||
HttpRequestLogFilterDocument,
|
||||
HttpRequestLogsDocument,
|
||||
useClearHttpRequestLogMutation,
|
||||
useHttpRequestLogFilterQuery,
|
||||
useSetHttpRequestLogFilterMutation,
|
||||
} from "lib/graphql/generated";
|
||||
|
@ -49,11 +45,6 @@ function Search(): JSX.Element {
|
|||
},
|
||||
});
|
||||
|
||||
const [clearHTTPRequestLog, clearHTTPRequestLogResult] = useClearHttpRequestLogMutation({
|
||||
refetchQueries: [{ query: HttpRequestLogsDocument }],
|
||||
});
|
||||
const clearHTTPConfirmationDialog = useConfirmationDialog();
|
||||
|
||||
const filterRef = useRef<HTMLFormElement>(null);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
|
||||
|
@ -81,7 +72,6 @@ function Search(): JSX.Element {
|
|||
<Box>
|
||||
<Error prefix="Error fetching filter" error={filterResult.error} />
|
||||
<Error prefix="Error setting filter" error={setFilterResult.error} />
|
||||
<Error prefix="Error clearing all HTTP logs" error={clearHTTPRequestLogResult.error} />
|
||||
<Box style={{ display: "flex", flex: 1 }}>
|
||||
<ClickAwayListener onClickAway={handleClickAway}>
|
||||
<Paper
|
||||
|
@ -161,21 +151,7 @@ function Search(): JSX.Element {
|
|||
</Popper>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
<Box style={{ marginLeft: "auto" }}>
|
||||
<Tooltip title="Clear all">
|
||||
<IconButton onClick={clearHTTPConfirmationDialog.open}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<ConfirmationDialog
|
||||
isOpen={clearHTTPConfirmationDialog.isOpen}
|
||||
onClose={clearHTTPConfirmationDialog.close}
|
||||
onConfirm={clearHTTPRequestLog}
|
||||
>
|
||||
All proxy logs are going to be removed. This action cannot be undone.
|
||||
</ConfirmationDialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,15 +1,4 @@
|
|||
import {
|
||||
Alert,
|
||||
Box,
|
||||
BoxProps,
|
||||
Button,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Alert, Box, Button, Typography } from "@mui/material";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
|
||||
|
@ -17,76 +6,16 @@ import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
|
|||
import RequestTabs from "lib/components/RequestTabs";
|
||||
import Response from "lib/components/Response";
|
||||
import SplitPane from "lib/components/SplitPane";
|
||||
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
|
||||
import {
|
||||
GetSenderRequestQuery,
|
||||
useCreateOrUpdateSenderRequestMutation,
|
||||
HttpProtocol,
|
||||
useGetSenderRequestQuery,
|
||||
useSendRequestMutation,
|
||||
} from "lib/graphql/generated";
|
||||
import { queryParamsFromURL } from "lib/queryParamsFromURL";
|
||||
|
||||
enum HttpMethod {
|
||||
Get = "GET",
|
||||
Post = "POST",
|
||||
Put = "PUT",
|
||||
Patch = "PATCH",
|
||||
Delete = "DELETE",
|
||||
Head = "HEAD",
|
||||
Options = "OPTIONS",
|
||||
Connect = "CONNECT",
|
||||
Trace = "TRACE",
|
||||
}
|
||||
|
||||
enum HttpProto {
|
||||
Http10 = "HTTP/1.0",
|
||||
Http11 = "HTTP/1.1",
|
||||
Http20 = "HTTP/2.0",
|
||||
}
|
||||
|
||||
const httpProtoMap = new Map([
|
||||
[HttpProto.Http10, HttpProtocol.Http10],
|
||||
[HttpProto.Http11, HttpProtocol.Http11],
|
||||
[HttpProto.Http20, HttpProtocol.Http20],
|
||||
]);
|
||||
|
||||
function updateKeyPairItem(key: string, value: string, idx: number, items: KeyValuePair[]): KeyValuePair[] {
|
||||
const updated = [...items];
|
||||
updated[idx] = { key, value };
|
||||
|
||||
// Append an empty key-value pair if the last item in the array isn't blank
|
||||
// anymore.
|
||||
if (items.length - 1 === idx && items[idx].key === "" && items[idx].value === "") {
|
||||
updated.push({ key: "", value: "" });
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
function updateURLQueryParams(url: string, queryParams: KeyValuePair[]) {
|
||||
// Note: We don't use the `URL` interface, because we're potentially dealing
|
||||
// with malformed/incorrect URLs, which would yield TypeErrors when constructed
|
||||
// via `URL`.
|
||||
let newURL = url;
|
||||
|
||||
const questionMarkIndex = url.indexOf("?");
|
||||
if (questionMarkIndex !== -1) {
|
||||
newURL = newURL.slice(0, questionMarkIndex);
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const { key, value } of queryParams.filter(({ key }) => key !== "")) {
|
||||
searchParams.append(key, value);
|
||||
}
|
||||
|
||||
const rawQueryParams = decodeURI(searchParams.toString());
|
||||
|
||||
if (rawQueryParams == "") {
|
||||
return newURL;
|
||||
}
|
||||
|
||||
return newURL + "?" + rawQueryParams;
|
||||
}
|
||||
import updateKeyPairItem from "lib/updateKeyPairItem";
|
||||
import updateURLQueryParams from "lib/updateURLQueryParams";
|
||||
|
||||
function EditRequest(): JSX.Element {
|
||||
const router = useRouter();
|
||||
|
@ -263,94 +192,4 @@ function EditRequest(): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
interface UrlBarProps extends BoxProps {
|
||||
method: HttpMethod;
|
||||
onMethodChange: (method: HttpMethod) => void;
|
||||
url: string;
|
||||
onUrlChange: (url: string) => void;
|
||||
proto: HttpProto;
|
||||
onProtoChange: (proto: HttpProto) => void;
|
||||
}
|
||||
|
||||
function UrlBar(props: UrlBarProps) {
|
||||
const { method, onMethodChange, url, onUrlChange, proto, onProtoChange, ...other } = props;
|
||||
|
||||
return (
|
||||
<Box {...other} sx={{ ...other.sx, display: "flex" }}>
|
||||
<FormControl>
|
||||
<InputLabel id="req-method-label">Method</InputLabel>
|
||||
<Select
|
||||
labelId="req-method-label"
|
||||
id="req-method"
|
||||
value={method}
|
||||
label="Method"
|
||||
onChange={(e) => onMethodChange(e.target.value as HttpMethod)}
|
||||
sx={{
|
||||
width: "8rem",
|
||||
".MuiOutlinedInput-notchedOutline": {
|
||||
borderRightWidth: 0,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{Object.values(HttpMethod).map((method) => (
|
||||
<MenuItem key={method} value={method}>
|
||||
{method}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="URL"
|
||||
placeholder="E.g. “https://example.com/foobar”"
|
||||
value={url}
|
||||
onChange={(e) => onUrlChange(e.target.value)}
|
||||
required
|
||||
variant="outlined"
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
InputProps={{
|
||||
sx: {
|
||||
".MuiOutlinedInput-notchedOutline": {
|
||||
borderRadius: 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<FormControl>
|
||||
<InputLabel id="req-proto-label">Protocol</InputLabel>
|
||||
<Select
|
||||
labelId="req-proto-label"
|
||||
id="req-proto"
|
||||
value={proto}
|
||||
label="Protocol"
|
||||
onChange={(e) => onProtoChange(e.target.value as HttpProto)}
|
||||
sx={{
|
||||
".MuiOutlinedInput-notchedOutline": {
|
||||
borderLeftWidth: 0,
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderLeftWidth: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{Object.values(HttpProto).map((proto) => (
|
||||
<MenuItem key={proto} value={proto}>
|
||||
{proto}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditRequest;
|
||||
|
|
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 { Project, useProjectsQuery } from "./graphql/generated";
|
||||
import { Project, useActiveProjectQuery } from "./graphql/generated";
|
||||
|
||||
const ActiveProjectContext = createContext<Project | null>(null);
|
||||
|
||||
|
@ -9,8 +9,8 @@ interface Props {
|
|||
}
|
||||
|
||||
export function ActiveProjectProvider({ children }: Props): JSX.Element {
|
||||
const { data } = useProjectsQuery();
|
||||
const project = data?.projects.find((project) => project.isActive) || null;
|
||||
const { data } = useActiveProjectQuery();
|
||||
const project = data?.activeProject || null;
|
||||
|
||||
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 React, { useState } from "react";
|
||||
|
||||
import { KeyValuePairTable, KeyValuePair, KeyValuePairTableProps } from "./KeyValuePair";
|
||||
|
||||
import Editor from "lib/components/Editor";
|
||||
import { KeyValuePairTable } from "lib/components/KeyValuePair";
|
||||
import { HttpResponseLog } from "lib/graphql/generated";
|
||||
|
||||
interface ResponseTabsProps {
|
||||
headers: HttpResponseLog["headers"];
|
||||
body: HttpResponseLog["body"];
|
||||
headers: KeyValuePair[];
|
||||
onHeaderChange?: KeyValuePairTableProps["onChange"];
|
||||
onHeaderDelete?: KeyValuePairTableProps["onDelete"];
|
||||
body?: string | null;
|
||||
onBodyChange?: (value: string) => void;
|
||||
hasResponse: boolean;
|
||||
}
|
||||
|
||||
|
@ -24,7 +27,7 @@ const reqNotSent = (
|
|||
);
|
||||
|
||||
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 contentType = headers.find((header) => header.key.toLowerCase() === "content-type")?.value;
|
||||
|
@ -33,6 +36,8 @@ function ResponseTabs(props: ResponseTabsProps): JSX.Element {
|
|||
textTransform: "none",
|
||||
};
|
||||
|
||||
const headersLength = onHeaderChange ? headers.length - 1 : headers.length;
|
||||
|
||||
return (
|
||||
<Box height="100%" sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<TabContext value={tabValue}>
|
||||
|
@ -43,20 +48,25 @@ function ResponseTabs(props: ResponseTabsProps): JSX.Element {
|
|||
label={"Body" + (body?.length ? ` (${body.length} byte` + (body.length > 1 ? "s" : "") + ")" : "")}
|
||||
sx={tabSx}
|
||||
/>
|
||||
<Tab
|
||||
value={TabValue.Headers}
|
||||
label={"Headers" + (headers.length ? ` (${headers.length})` : "")}
|
||||
sx={tabSx}
|
||||
/>
|
||||
<Tab value={TabValue.Headers} label={"Headers" + (headersLength ? ` (${headersLength})` : "")} sx={tabSx} />
|
||||
</TabList>
|
||||
</Box>
|
||||
<Box flex="1 auto" overflow="hidden">
|
||||
<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}
|
||||
</TabPanel>
|
||||
<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}
|
||||
</TabPanel>
|
||||
</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;
|
||||
};
|
||||
|
||||
export type CancelRequestResult = {
|
||||
__typename?: 'CancelRequestResult';
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type CancelResponseResult = {
|
||||
__typename?: 'CancelResponseResult';
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type ClearHttpRequestLogResult = {
|
||||
__typename?: 'ClearHTTPRequestLogResult';
|
||||
success: Scalars['Boolean'];
|
||||
|
@ -67,6 +77,17 @@ export enum HttpProtocol {
|
|||
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 = {
|
||||
__typename?: 'HttpRequestLog';
|
||||
body?: Maybe<Scalars['String']>;
|
||||
|
@ -90,6 +111,17 @@ export type HttpRequestLogFilterInput = {
|
|||
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 = {
|
||||
__typename?: 'HttpResponseLog';
|
||||
body?: Maybe<Scalars['String']>;
|
||||
|
@ -101,8 +133,47 @@ export type HttpResponseLog = {
|
|||
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 = {
|
||||
__typename?: 'Mutation';
|
||||
cancelRequest: CancelRequestResult;
|
||||
cancelResponse: CancelResponseResult;
|
||||
clearHTTPRequestLog: ClearHttpRequestLogResult;
|
||||
closeProject: CloseProjectResult;
|
||||
createOrUpdateSenderRequest: SenderRequest;
|
||||
|
@ -110,11 +181,24 @@ export type Mutation = {
|
|||
createSenderRequestFromHttpRequestLog: SenderRequest;
|
||||
deleteProject: DeleteProjectResult;
|
||||
deleteSenderRequests: DeleteSenderRequestsResult;
|
||||
modifyRequest: ModifyRequestResult;
|
||||
modifyResponse: ModifyResponseResult;
|
||||
openProject?: Maybe<Project>;
|
||||
sendRequest: SenderRequest;
|
||||
setHttpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
|
||||
setScope: Array<ScopeRule>;
|
||||
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 = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
@ -162,11 +256,22 @@ export type MutationSetSenderRequestFilterArgs = {
|
|||
filter?: InputMaybe<SenderRequestFilterInput>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateInterceptSettingsArgs = {
|
||||
input: UpdateInterceptSettingsInput;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
__typename?: 'Project';
|
||||
id: Scalars['ID'];
|
||||
isActive: Scalars['Boolean'];
|
||||
name: Scalars['String'];
|
||||
settings: ProjectSettings;
|
||||
};
|
||||
|
||||
export type ProjectSettings = {
|
||||
__typename?: 'ProjectSettings';
|
||||
intercept: InterceptSettings;
|
||||
};
|
||||
|
||||
export type Query = {
|
||||
|
@ -175,6 +280,8 @@ export type Query = {
|
|||
httpRequestLog?: Maybe<HttpRequestLog>;
|
||||
httpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
|
||||
httpRequestLogs: Array<HttpRequestLog>;
|
||||
interceptedRequest?: Maybe<HttpRequest>;
|
||||
interceptedRequests: Array<HttpRequest>;
|
||||
projects: Array<Project>;
|
||||
scope: Array<ScopeRule>;
|
||||
senderRequest?: Maybe<SenderRequest>;
|
||||
|
@ -187,6 +294,11 @@ export type QueryHttpRequestLogArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type QueryInterceptedRequestArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type QuerySenderRequestArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
@ -248,6 +360,53 @@ export type SenderRequestInput = {
|
|||
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; }>;
|
||||
|
||||
|
||||
|
@ -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 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`
|
||||
mutation CloseProject {
|
||||
closeProject {
|
||||
|
@ -982,4 +1383,80 @@ export function useGetSenderRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHook
|
|||
}
|
||||
export type GetSenderRequestsQueryHookResult = ReturnType<typeof useGetSenderRequestsQuery>;
|
||||
export type GetSenderRequestsLazyQueryHookResult = ReturnType<typeof useGetSenderRequestsLazyQuery>;
|
||||
export type GetSenderRequestsQueryResult = Apollo.QueryResult<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>;
|
||||
export 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({
|
||||
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 { ActiveProjectProvider } from "lib/ActiveProjectContext";
|
||||
import { InterceptedRequestsProvider } from "lib/InterceptedRequestsContext";
|
||||
import { useApollo } from "lib/graphql/useApollo";
|
||||
import createEmotionCache from "lib/mui/createEmotionCache";
|
||||
import theme from "lib/mui/theme";
|
||||
|
@ -32,10 +33,12 @@ export default function MyApp(props: MyAppProps) {
|
|||
</Head>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<ActiveProjectProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
<InterceptedRequestsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</InterceptedRequestsProvider>
|
||||
</ActiveProjectProvider>
|
||||
</ApolloProvider>
|
||||
</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"
|
||||
|
||||
"@apollo/client@^3.2.0":
|
||||
version "3.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.5.8.tgz#7215b974c5988b6157530eb69369209210349fe0"
|
||||
integrity sha512-MAm05+I1ullr64VLpZwon/ISnkMuNLf6vDqgo9wiMhHYBGT4yOAbAIseRdjCHZwfSx/7AUuBgaTNOssZPIr6FQ==
|
||||
version "3.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.5.10.tgz#43463108a6e07ae602cca0afc420805a19339a71"
|
||||
integrity sha512-tL3iSpFe9Oldq7gYikZK1dcYxp1c01nlSwtsMz75382HcI6fvQXyFXUCJTTK3wgO2/ckaBvRGw7VqjFREdVoRw==
|
||||
dependencies:
|
||||
"@graphql-typed-document-node/core" "^3.0.0"
|
||||
"@wry/context" "^0.6.0"
|
||||
|
|
|
@ -28,6 +28,7 @@ import (
|
|||
"github.com/dstotijn/hetty/pkg/db/badger"
|
||||
"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/scope"
|
||||
"github.com/dstotijn/hetty/pkg/sender"
|
||||
|
@ -175,16 +176,21 @@ func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error {
|
|||
Logger: cmd.config.logger.Named("reqlog").Sugar(),
|
||||
})
|
||||
|
||||
interceptService := intercept.NewService(intercept.Config{
|
||||
Logger: cmd.config.logger.Named("intercept").Sugar(),
|
||||
})
|
||||
|
||||
senderService := sender.NewService(sender.Config{
|
||||
Repository: badger,
|
||||
ReqLogService: reqLogService,
|
||||
})
|
||||
|
||||
projService, err := proj.NewService(proj.Config{
|
||||
Repository: badger,
|
||||
ReqLogService: reqLogService,
|
||||
SenderService: senderService,
|
||||
Scope: scope,
|
||||
Repository: badger,
|
||||
InterceptService: interceptService,
|
||||
ReqLogService: reqLogService,
|
||||
SenderService: senderService,
|
||||
Scope: scope,
|
||||
})
|
||||
if err != nil {
|
||||
cmd.config.logger.Fatal("Failed to create new projects service.", zap.Error(err))
|
||||
|
@ -201,6 +207,8 @@ func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error {
|
|||
|
||||
proxy.UseRequestModifier(reqLogService.RequestModifier)
|
||||
proxy.UseResponseModifier(reqLogService.ResponseModifier)
|
||||
proxy.UseRequestModifier(interceptService.RequestModifier)
|
||||
proxy.UseResponseModifier(interceptService.ResponseModifier)
|
||||
|
||||
fsSub, err := fs.Sub(adminContent, "admin")
|
||||
if err != nil {
|
||||
|
@ -231,6 +239,7 @@ func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error {
|
|||
adminRouter.Path(gqlEndpoint).Handler(api.HTTPHandler(&api.Resolver{
|
||||
ProjectService: projService,
|
||||
RequestLogService: reqLogService,
|
||||
InterceptService: interceptService,
|
||||
SenderService: senderService,
|
||||
}, 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"
|
||||
)
|
||||
|
||||
type CancelRequestResult struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
type CancelResponseResult struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
type ClearHTTPRequestLogResult struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
@ -38,6 +46,16 @@ type HTTPHeaderInput struct {
|
|||
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 {
|
||||
ID ulid.ULID `json:"id"`
|
||||
URL string `json:"url"`
|
||||
|
@ -59,6 +77,16 @@ type HTTPRequestLogFilterInput struct {
|
|||
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 {
|
||||
// Will be the same ID as its related request ID.
|
||||
ID ulid.ULID `json:"id"`
|
||||
|
@ -69,10 +97,49 @@ type HTTPResponseLog struct {
|
|||
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 {
|
||||
ID ulid.ULID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"isActive"`
|
||||
ID ulid.ULID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"isActive"`
|
||||
Settings *ProjectSettings `json:"settings"`
|
||||
}
|
||||
|
||||
type ProjectSettings struct {
|
||||
Intercept *InterceptSettings `json:"intercept"`
|
||||
}
|
||||
|
||||
type ScopeHeader struct {
|
||||
|
@ -128,6 +195,13 @@ type SenderRequestInput struct {
|
|||
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
|
||||
|
||||
const (
|
||||
|
|
|
@ -3,9 +3,12 @@ package api
|
|||
//go:generate go run github.com/99designs/gqlgen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
@ -15,6 +18,8 @@ import (
|
|||
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||
|
||||
"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/scope"
|
||||
"github.com/dstotijn/hetty/pkg/search"
|
||||
|
@ -36,6 +41,7 @@ var revHTTPProtocolMap = map[HTTPProtocol]string{
|
|||
type Resolver struct {
|
||||
ProjectService proj.Service
|
||||
RequestLogService reqlog.Service
|
||||
InterceptService *intercept.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 &Project{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
IsActive: r.ProjectService.IsProjectActive(p.ID),
|
||||
}, nil
|
||||
project := parseProject(r.ProjectService, p)
|
||||
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
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 &Project{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
IsActive: r.ProjectService.IsProjectActive(p.ID),
|
||||
}, nil
|
||||
project := parseProject(r.ProjectService, p)
|
||||
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
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 &Project{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
IsActive: r.ProjectService.IsProjectActive(p.ID),
|
||||
}, nil
|
||||
project := parseProject(r.ProjectService, p)
|
||||
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
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))
|
||||
for i, proj := range p {
|
||||
projects[i] = Project{
|
||||
ID: proj.ID,
|
||||
Name: proj.Name,
|
||||
IsActive: r.ProjectService.IsProjectActive(proj.ID),
|
||||
}
|
||||
projects[i] = parseProject(r.ProjectService, proj)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
|
@ -520,6 +516,166 @@ func (r *mutationResolver) DeleteSenderRequests(ctx context.Context) (*DeleteSen
|
|||
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) {
|
||||
method := HTTPMethod(req.Method)
|
||||
if method != "" && !method.IsValid() {
|
||||
|
@ -575,6 +731,155 @@ func parseSenderRequest(req sender.Request) (SenderRequest, error) {
|
|||
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) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
|
|
|
@ -30,6 +30,11 @@ type Project {
|
|||
id: ID!
|
||||
name: String!
|
||||
isActive: Boolean!
|
||||
settings: ProjectSettings!
|
||||
}
|
||||
|
||||
type ProjectSettings {
|
||||
intercept: InterceptSettings!
|
||||
}
|
||||
|
||||
type ScopeRule {
|
||||
|
@ -116,6 +121,77 @@ type SenderRequestFilter {
|
|||
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 {
|
||||
httpRequestLog(id: ID!): HttpRequestLog
|
||||
httpRequestLogs: [HttpRequestLog!]!
|
||||
|
@ -125,6 +201,8 @@ type Query {
|
|||
scope: [ScopeRule!]!
|
||||
senderRequest(id: ID!): SenderRequest
|
||||
senderRequests: [SenderRequest!]!
|
||||
interceptedRequests: [HttpRequest!]!
|
||||
interceptedRequest(id: ID!): HttpRequest
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
|
@ -142,6 +220,13 @@ type Mutation {
|
|||
createSenderRequestFromHttpRequestLog(id: ID!): SenderRequest!
|
||||
sendRequest(id: ID!): SenderRequest!
|
||||
deleteSenderRequests: DeleteSenderRequestsResult!
|
||||
modifyRequest(request: ModifyRequestInput!): ModifyRequestResult!
|
||||
cancelRequest(id: ID!): CancelRequestResult!
|
||||
modifyResponse(response: ModifyResponseInput!): ModifyResponseResult!
|
||||
cancelResponse(requestID: ID!): CancelResponseResult!
|
||||
updateInterceptSettings(
|
||||
input: UpdateInterceptSettingsInput!
|
||||
): InterceptSettings!
|
||||
}
|
||||
|
||||
enum HttpMethod {
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
"github.com/oklog/ulid"
|
||||
|
||||
"github.com/dstotijn/hetty/pkg/proxy/intercept"
|
||||
"github.com/dstotijn/hetty/pkg/reqlog"
|
||||
"github.com/dstotijn/hetty/pkg/scope"
|
||||
"github.com/dstotijn/hetty/pkg/search"
|
||||
|
@ -33,10 +34,12 @@ type Service interface {
|
|||
SetScopeRules(ctx context.Context, rules []scope.Rule) error
|
||||
SetRequestLogFindFilter(ctx context.Context, filter reqlog.FindRequestsFilter) error
|
||||
SetSenderRequestFindFilter(ctx context.Context, filter sender.FindRequestsFilter) error
|
||||
UpdateInterceptSettings(ctx context.Context, settings intercept.Settings) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo Repository
|
||||
interceptSvc *intercept.Service
|
||||
reqLogSvc reqlog.Service
|
||||
senderSvc sender.Service
|
||||
scope *scope.Scope
|
||||
|
@ -53,13 +56,22 @@ type Project struct {
|
|||
}
|
||||
|
||||
type Settings struct {
|
||||
// Request log settings
|
||||
ReqLogBypassOutOfScope bool
|
||||
ReqLogOnlyFindInScope bool
|
||||
ReqLogSearchExpr search.Expression
|
||||
|
||||
// Intercept settings
|
||||
InterceptRequests bool
|
||||
InterceptResponses bool
|
||||
InterceptRequestFilter search.Expression
|
||||
InterceptResponseFilter search.Expression
|
||||
|
||||
// Sender settings
|
||||
SenderOnlyFindInScope bool
|
||||
SenderSearchExpr search.Expression
|
||||
|
||||
// Scope settings
|
||||
ScopeRules []scope.Rule
|
||||
}
|
||||
|
||||
|
@ -73,19 +85,21 @@ var (
|
|||
var nameRegexp = regexp.MustCompile(`^[\w\d\s]+$`)
|
||||
|
||||
type Config struct {
|
||||
Repository Repository
|
||||
ReqLogService reqlog.Service
|
||||
SenderService sender.Service
|
||||
Scope *scope.Scope
|
||||
Repository Repository
|
||||
InterceptService *intercept.Service
|
||||
ReqLogService reqlog.Service
|
||||
SenderService sender.Service
|
||||
Scope *scope.Scope
|
||||
}
|
||||
|
||||
// NewService returns a new Service.
|
||||
func NewService(cfg Config) (Service, error) {
|
||||
return &service{
|
||||
repo: cfg.Repository,
|
||||
reqLogSvc: cfg.ReqLogService,
|
||||
senderSvc: cfg.SenderService,
|
||||
scope: cfg.Scope,
|
||||
repo: cfg.Repository,
|
||||
interceptSvc: cfg.InterceptService,
|
||||
reqLogSvc: cfg.ReqLogService,
|
||||
senderSvc: cfg.SenderService,
|
||||
scope: cfg.Scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
@ -120,6 +134,12 @@ func (svc *service) CloseProject() error {
|
|||
svc.reqLogSvc.SetActiveProjectID(ulid.ULID{})
|
||||
svc.reqLogSvc.SetBypassOutOfScopeRequests(false)
|
||||
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.SetFindReqsFilter(sender.FindRequestsFilter{})
|
||||
svc.scope.SetRules(nil)
|
||||
|
@ -152,6 +172,7 @@ func (svc *service) OpenProject(ctx context.Context, projectID ulid.ULID) (Proje
|
|||
|
||||
svc.activeProjectID = project.ID
|
||||
|
||||
// Request log settings.
|
||||
svc.reqLogSvc.SetFindReqsFilter(reqlog.FindRequestsFilter{
|
||||
ProjectID: project.ID,
|
||||
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.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.SetFindReqsFilter(sender.FindRequestsFilter{
|
||||
ProjectID: project.ID,
|
||||
|
@ -167,6 +197,7 @@ func (svc *service) OpenProject(ctx context.Context, projectID ulid.ULID) (Proje
|
|||
SearchExpr: project.Settings.SenderSearchExpr,
|
||||
})
|
||||
|
||||
// Scope settings.
|
||||
svc.scope.SetRules(project.Settings.ScopeRules)
|
||||
|
||||
return project, nil
|
||||
|
@ -264,3 +295,24 @@ func (svc *service) SetSenderRequestFindFilter(ctx context.Context, filter sende
|
|||
func (svc *service) IsProjectActive(projectID ulid.ULID) bool {
|
||||
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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid"
|
||||
|
||||
"github.com/dstotijn/hetty/pkg/log"
|
||||
)
|
||||
|
||||
//nolint:gosec
|
||||
var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
type contextKey int
|
||||
|
||||
const ReqLogIDKey contextKey = 0
|
||||
const reqIDKey contextKey = 0
|
||||
|
||||
// Proxy implements http.Handler and offers MITM behaviour for modifying
|
||||
// HTTP requests and responses.
|
||||
|
@ -54,7 +62,25 @@ func NewProxy(cfg Config) (*Proxy, error) {
|
|||
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{
|
||||
Transport: transport,
|
||||
Director: p.modifyRequest,
|
||||
ModifyResponse: p.modifyResponse,
|
||||
ErrorHandler: p.errorHandler,
|
||||
|
@ -69,6 +95,10 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
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)
|
||||
}
|
||||
|
||||
|
@ -91,6 +121,25 @@ func (p *Proxy) modifyRequest(r *http.Request) {
|
|||
// set this header.
|
||||
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
|
||||
|
||||
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 {
|
||||
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-- {
|
||||
fn = p.resModifiers[i](fn)
|
||||
}
|
||||
|
@ -110,6 +164,15 @@ func (p *Proxy) modifyResponse(res *http.Response) error {
|
|||
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.
|
||||
// During the TLS handshake with the client, we use the proxy's CA config to
|
||||
// create a certificate on-the-fly.
|
||||
|
@ -170,13 +233,14 @@ func (p *Proxy) clientTLSConn(conn net.Conn) (*tls.Conn, error) {
|
|||
}
|
||||
|
||||
func (p *Proxy) errorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
switch {
|
||||
case !errors.Is(err, context.Canceled):
|
||||
p.logger.Errorw("Failed to proxy request.",
|
||||
"error", err)
|
||||
case errors.Is(err, context.Canceled):
|
||||
p.logger.Debugw("Proxy request was cancelled.")
|
||||
}
|
||||
|
||||
p.logger.Errorw("Failed to proxy request.",
|
||||
"error", err)
|
||||
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}
|
||||
|
||||
|
|
|
@ -2,16 +2,13 @@ package reqlog
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid"
|
||||
|
||||
|
@ -23,16 +20,16 @@ import (
|
|||
|
||||
type contextKey int
|
||||
|
||||
const LogBypassedKey contextKey = 0
|
||||
const (
|
||||
LogBypassedKey contextKey = iota
|
||||
ReqLogIDKey
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRequestNotFound = errors.New("reqlog: request not found")
|
||||
ErrProjectIDMustBeSet = errors.New("reqlog: project ID must be set")
|
||||
)
|
||||
|
||||
//nolint:gosec
|
||||
var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
type RequestLog struct {
|
||||
ID ulid.ULID
|
||||
ProjectID ulid.ULID
|
||||
|
@ -170,8 +167,14 @@ func (svc *service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestM
|
|||
return
|
||||
}
|
||||
|
||||
reqID, ok := proxy.RequestIDFromContext(req.Context())
|
||||
if !ok {
|
||||
svc.logger.Errorw("Bypassed logging: request doesn't have an ID.")
|
||||
return
|
||||
}
|
||||
|
||||
reqLog := RequestLog{
|
||||
ID: ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy),
|
||||
ID: reqID,
|
||||
ProjectID: svc.activeProjectID,
|
||||
Method: clone.Method,
|
||||
URL: clone.URL,
|
||||
|
@ -191,7 +194,7 @@ func (svc *service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestM
|
|||
"reqLogID", reqLog.ID.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)
|
||||
}
|
||||
}
|
||||
|
@ -206,21 +209,23 @@ func (svc *service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon
|
|||
return nil
|
||||
}
|
||||
|
||||
reqLogID, ok := res.Request.Context().Value(proxy.ReqLogIDKey).(ulid.ULID)
|
||||
reqLogID, ok := res.Request.Context().Value(ReqLogIDKey).(ulid.ULID)
|
||||
if !ok {
|
||||
return errors.New("reqlog: request is missing ID")
|
||||
}
|
||||
|
||||
clone := *res
|
||||
|
||||
// TODO: Use io.LimitReader.
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reqlog: could not read response body: %w", err)
|
||||
}
|
||||
if res.Body != nil {
|
||||
// TODO: Use io.LimitReader.
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reqlog: could not read response body: %w", err)
|
||||
}
|
||||
|
||||
res.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
clone.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
res.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
clone.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
}
|
||||
|
||||
go func() {
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
return ResponseLog{}, fmt.Errorf("reqlog: could not read body: %w", err)
|
||||
|
|
|
@ -41,6 +41,8 @@ func TestRequestModifier(t *testing.T) {
|
|||
}
|
||||
reqModFn := svc.RequestModifier(next)
|
||||
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)
|
||||
|
||||
|
@ -88,7 +90,7 @@ func TestResponseModifier(t *testing.T) {
|
|||
|
||||
req := httptest.NewRequest("GET", "https://example.com/", strings.NewReader("bar"))
|
||||
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{
|
||||
Request: req,
|
||||
|
|
|
@ -3,7 +3,6 @@ package reqlog
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
@ -100,7 +99,7 @@ func (reqLog RequestLog) matchInfixExpr(expr search.InfixExpression) (bool, erro
|
|||
leftVal := reqLog.getMappedStringLiteral(left.Value)
|
||||
|
||||
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
|
||||
right, ok := expr.Right.(*regexp.Regexp)
|
||||
right, ok := expr.Right.(search.RegexpLiteral)
|
||||
if !ok {
|
||||
return false, errors.New("right operand must be a regular expression")
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package search
|
|||
import (
|
||||
"encoding/gob"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
@ -50,13 +51,17 @@ type StringLiteral struct {
|
|||
}
|
||||
|
||||
func (sl StringLiteral) String() string {
|
||||
return sl.Value
|
||||
return strconv.Quote(sl.Value)
|
||||
}
|
||||
|
||||
type RegexpLiteral struct {
|
||||
*regexp.Regexp
|
||||
}
|
||||
|
||||
func (rl RegexpLiteral) String() string {
|
||||
return strconv.Quote(rl.Regexp.String())
|
||||
}
|
||||
|
||||
func (rl RegexpLiteral) MarshalBinary() ([]byte, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
right = re
|
||||
right = RegexpLiteral{re}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -94,7 +94,7 @@ func TestParseQuery(t *testing.T) {
|
|||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpRe,
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: regexp.MustCompile("bar"),
|
||||
Right: RegexpLiteral{regexp.MustCompile("bar")},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
|
@ -104,7 +104,7 @@ func TestParseQuery(t *testing.T) {
|
|||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpNotRe,
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: regexp.MustCompile("bar"),
|
||||
Right: RegexpLiteral{regexp.MustCompile("bar")},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
|
@ -197,7 +197,7 @@ func TestParseQuery(t *testing.T) {
|
|||
Right: InfixExpression{
|
||||
Operator: TokOpRe,
|
||||
Left: StringLiteral{Value: "baz"},
|
||||
Right: regexp.MustCompile("yolo"),
|
||||
Right: RegexpLiteral{regexp.MustCompile("yolo")},
|
||||
},
|
||||
},
|
||||
expectedError: nil,
|
||||
|
|
|
@ -3,7 +3,6 @@ package sender
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/oklog/ulid"
|
||||
|
@ -93,7 +92,7 @@ func (req Request) matchInfixExpr(expr search.InfixExpression) (bool, error) {
|
|||
leftVal := req.getMappedStringLiteral(left.Value)
|
||||
|
||||
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
|
||||
right, ok := expr.Right.(*regexp.Regexp)
|
||||
right, ok := expr.Right.(search.RegexpLiteral)
|
||||
if !ok {
|
||||
return false, errors.New("right operand must be a regular expression")
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue