From 9dd8464af77955f3f085ee649c511384f8270e2d Mon Sep 17 00:00:00 2001 From: David Stotijn Date: Thu, 10 Mar 2022 20:30:40 +0100 Subject: [PATCH] Add initial UI/UX for intecepting requests --- admin/src/features/Layout.tsx | 25 ++- .../intercept/components/EditRequest.tsx | 203 ++++++++++++++++++ .../intercept/components/Intercept.tsx | 21 ++ .../intercept/components/Requests.tsx | 33 +++ .../graphql/interceptedRequest.graphql | 13 ++ .../intercept/graphql/modifyRequest.graphql | 5 + .../features/reqlog/components/Actions.tsx | 57 +++++ .../reqlog/components/RequestLogs.tsx | 10 +- .../src/features/reqlog/components/Search.tsx | 24 --- .../sender/components/EditRequest.tsx | 169 +-------------- admin/src/lib/InterceptedRequestsContext.tsx | 22 ++ admin/src/lib/components/UrlBar.tsx | 122 +++++++++++ admin/src/lib/graphql/generated.tsx | 170 ++++++++++++++- .../lib/graphql/interceptedRequests.graphql | 7 + admin/src/lib/graphql/useApollo.ts | 14 +- admin/src/lib/updateKeyPairItem.ts | 16 ++ admin/src/lib/updateURLQueryParams.ts | 28 +++ admin/src/pages/_app.tsx | 11 +- admin/src/pages/proxy/intercept/index.tsx | 12 ++ admin/yarn.lock | 6 +- pkg/api/generated.go | 87 ++++++++ pkg/api/resolvers.go | 16 ++ pkg/api/schema.graphql | 1 + pkg/proxy/intercept/intercept.go | 13 ++ 24 files changed, 882 insertions(+), 203 deletions(-) create mode 100644 admin/src/features/intercept/components/EditRequest.tsx create mode 100644 admin/src/features/intercept/components/Intercept.tsx create mode 100644 admin/src/features/intercept/components/Requests.tsx create mode 100644 admin/src/features/intercept/graphql/interceptedRequest.graphql create mode 100644 admin/src/features/intercept/graphql/modifyRequest.graphql create mode 100644 admin/src/features/reqlog/components/Actions.tsx create mode 100644 admin/src/lib/InterceptedRequestsContext.tsx create mode 100644 admin/src/lib/components/UrlBar.tsx create mode 100644 admin/src/lib/graphql/interceptedRequests.graphql create mode 100644 admin/src/lib/updateKeyPairItem.ts create mode 100644 admin/src/lib/updateURLQueryParams.ts create mode 100644 admin/src/pages/proxy/intercept/index.tsx diff --git a/admin/src/features/Layout.tsx b/admin/src/features/Layout.tsx index d4fd924..9a76d5a 100644 --- a/admin/src/features/Layout.tsx +++ b/admin/src/features/Layout.tsx @@ -1,11 +1,12 @@ +import AltRouteIcon from "@mui/icons-material/AltRoute"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import FolderIcon from "@mui/icons-material/Folder"; +import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted"; import HomeIcon from "@mui/icons-material/Home"; import LocationSearchingIcon from "@mui/icons-material/LocationSearching"; import MenuIcon from "@mui/icons-material/Menu"; import SendIcon from "@mui/icons-material/Send"; -import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet"; import { Theme, useTheme, @@ -19,6 +20,7 @@ import { CSSObject, Box, ListItemText, + Badge, } from "@mui/material"; import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar"; import MuiDrawer from "@mui/material/Drawer"; @@ -28,10 +30,12 @@ import Link from "next/link"; import React, { useState } from "react"; import { useActiveProject } from "lib/ActiveProjectContext"; +import { useInterceptedRequests } from "lib/InterceptedRequestsContext"; export enum Page { Home, GetStarted, + Intercept, Projects, ProxySetup, ProxyLogs, @@ -135,6 +139,7 @@ interface Props { export function Layout({ title, page, children }: Props): JSX.Element { const activeProject = useActiveProject(); + const interceptedRequests = useInterceptedRequests(); const theme = useTheme(); const [open, setOpen] = useState(false); @@ -204,12 +209,24 @@ export function Layout({ title, page, children }: Props): JSX.Element { - + - + - + + + + + + + + + + + + + diff --git a/admin/src/features/intercept/components/EditRequest.tsx b/admin/src/features/intercept/components/EditRequest.tsx new file mode 100644 index 0000000..b9e3140 --- /dev/null +++ b/admin/src/features/intercept/components/EditRequest.tsx @@ -0,0 +1,203 @@ +import SendIcon from "@mui/icons-material/Send"; +import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material"; +import { useRouter } from "next/router"; +import React, { useEffect, useState } from "react"; + +import { useInterceptedRequests } from "lib/InterceptedRequestsContext"; +import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair"; +import RequestTabs from "lib/components/RequestTabs"; +import Response from "lib/components/Response"; +import SplitPane from "lib/components/SplitPane"; +import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar"; +import { + HttpProtocol, + HttpRequest, + useGetInterceptedRequestQuery, + useModifyRequestMutation, +} from "lib/graphql/generated"; +import { queryParamsFromURL } from "lib/queryParamsFromURL"; +import updateKeyPairItem from "lib/updateKeyPairItem"; +import updateURLQueryParams from "lib/updateURLQueryParams"; + +function EditRequest(): JSX.Element { + const router = useRouter(); + const interceptedRequests = useInterceptedRequests(); + + useEffect(() => { + // If there's no request selected and there are pending reqs, navigate to + // the first one in the list. This helps you quickly review/handle reqs + // without having to manually select the next one in the requests table. + console.log(router.isReady, router.query.id, interceptedRequests?.length); + if (router.isReady && !router.query.id && interceptedRequests?.length) { + const req = interceptedRequests[0]; + router.replace(`/proxy/intercept?id=${req.id}`); + } + }, [router, interceptedRequests]); + + const reqId = router.query.id as string | undefined; + + const [method, setMethod] = useState(HttpMethod.Get); + const [url, setURL] = useState(""); + const [proto, setProto] = useState(HttpProto.Http20); + const [queryParams, setQueryParams] = useState([{ key: "", value: "" }]); + const [headers, setHeaders] = useState([{ key: "", value: "" }]); + const [body, setBody] = useState(""); + + const handleQueryParamChange = (key: string, value: string, idx: number) => { + setQueryParams((prev) => { + const updated = updateKeyPairItem(key, value, idx, prev); + setURL((prev) => updateURLQueryParams(prev, updated)); + return updated; + }); + }; + const handleQueryParamDelete = (idx: number) => { + setQueryParams((prev) => { + const updated = prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length)); + setURL((prev) => updateURLQueryParams(prev, updated)); + return updated; + }); + }; + + const handleHeaderChange = (key: string, value: string, idx: number) => { + setHeaders((prev) => updateKeyPairItem(key, value, idx, prev)); + }; + const handleHeaderDelete = (idx: number) => { + setHeaders((prev) => prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length))); + }; + + const handleURLChange = (url: string) => { + setURL(url); + + const questionMarkIndex = url.indexOf("?"); + if (questionMarkIndex === -1) { + setQueryParams([{ key: "", value: "" }]); + return; + } + + const newQueryParams = queryParamsFromURL(url); + // Push empty row. + newQueryParams.push({ key: "", value: "" }); + setQueryParams(newQueryParams); + }; + + const getReqResult = useGetInterceptedRequestQuery({ + variables: { id: reqId as string }, + skip: reqId === undefined, + onCompleted: ({ interceptedRequest }) => { + if (!interceptedRequest) { + return; + } + + setURL(interceptedRequest.url); + setMethod(interceptedRequest.method); + setBody(interceptedRequest.body || ""); + + const newQueryParams = queryParamsFromURL(interceptedRequest.url); + // Push empty row. + newQueryParams.push({ key: "", value: "" }); + setQueryParams(newQueryParams); + + const newHeaders = sortKeyValuePairs(interceptedRequest.headers || []); + setHeaders([...newHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]); + }, + }); + const interceptedReq = reqId ? getReqResult?.data?.interceptedRequest : undefined; + + const [modifyRequest, modifyResult] = useModifyRequestMutation(); + + const handleFormSubmit: React.FormEventHandler = (e) => { + e.preventDefault(); + + if (!interceptedReq) { + return; + } + + modifyRequest({ + variables: { + request: { + id: interceptedReq.id, + url, + method, + proto: httpProtoMap.get(proto) || HttpProtocol.Http20, + headers: headers.filter((kv) => kv.key !== ""), + body: body || undefined, + }, + }, + update(cache) { + cache.modify({ + fields: { + interceptedRequests(existing: HttpRequest[], { readField }) { + return existing.filter((ref) => interceptedReq.id !== readField("id", ref)); + }, + }, + }); + }, + onCompleted: () => { + setURL(""); + setMethod(HttpMethod.Get); + setBody(""); + setQueryParams([]); + setHeaders([]); + console.log("done!"); + router.replace(`/proxy/intercept`); + }, + }); + }; + + return ( + + + + + + + {modifyResult.error && ( + + {modifyResult.error.message} + + )} + + + + + + + Request + + + + + + + + + + ); +} + +export default EditRequest; diff --git a/admin/src/features/intercept/components/Intercept.tsx b/admin/src/features/intercept/components/Intercept.tsx new file mode 100644 index 0000000..ccc7f68 --- /dev/null +++ b/admin/src/features/intercept/components/Intercept.tsx @@ -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 ( + + + + + + + + + + + ); +} diff --git a/admin/src/features/intercept/components/Requests.tsx b/admin/src/features/intercept/components/Requests.tsx new file mode 100644 index 0000000..f6d06a8 --- /dev/null +++ b/admin/src/features/intercept/components/Requests.tsx @@ -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 ( + + {interceptedRequests && interceptedRequests.length > 0 && ( + + )} + + {interceptedRequests?.length === 0 && ( + + No pending intercepted requests. + + )} + + + ); +} + +export default Requests; diff --git a/admin/src/features/intercept/graphql/interceptedRequest.graphql b/admin/src/features/intercept/graphql/interceptedRequest.graphql new file mode 100644 index 0000000..ec2749b --- /dev/null +++ b/admin/src/features/intercept/graphql/interceptedRequest.graphql @@ -0,0 +1,13 @@ +query GetInterceptedRequest($id: ID!) { + interceptedRequest(id: $id) { + id + url + method + proto + headers { + key + value + } + body + } +} diff --git a/admin/src/features/intercept/graphql/modifyRequest.graphql b/admin/src/features/intercept/graphql/modifyRequest.graphql new file mode 100644 index 0000000..ed1f06a --- /dev/null +++ b/admin/src/features/intercept/graphql/modifyRequest.graphql @@ -0,0 +1,5 @@ +mutation ModifyRequest($request: ModifyRequestInput!) { + modifyRequest(request: $request) { + success + } +} diff --git a/admin/src/features/reqlog/components/Actions.tsx b/admin/src/features/reqlog/components/Actions.tsx new file mode 100644 index 0000000..0e755b2 --- /dev/null +++ b/admin/src/features/reqlog/components/Actions.tsx @@ -0,0 +1,57 @@ +import AltRouteIcon from "@mui/icons-material/AltRoute"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { Alert } from "@mui/lab"; +import { Badge, Button, IconButton, Tooltip } from "@mui/material"; +import Link from "next/link"; + +import { useInterceptedRequests } from "lib/InterceptedRequestsContext"; +import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog"; +import { HttpRequestLogsDocument, useClearHttpRequestLogMutation } from "lib/graphql/generated"; + +function Actions(): JSX.Element { + const interceptedRequests = useInterceptedRequests(); + const [clearHTTPRequestLog, clearLogsResult] = useClearHttpRequestLogMutation({ + refetchQueries: [{ query: HttpRequestLogsDocument }], + }); + const clearHTTPConfirmationDialog = useConfirmationDialog(); + + return ( +
+ + All proxy logs are going to be removed. This action cannot be undone. + + + {clearLogsResult.error && Failed to clear HTTP logs: {clearLogsResult.error}} + + + + + + + + + + +
+ ); +} + +export default Actions; diff --git a/admin/src/features/reqlog/components/RequestLogs.tsx b/admin/src/features/reqlog/components/RequestLogs.tsx index daf17a3..787d529 100644 --- a/admin/src/features/reqlog/components/RequestLogs.tsx +++ b/admin/src/features/reqlog/components/RequestLogs.tsx @@ -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 ( - + + + + + + + + diff --git a/admin/src/features/reqlog/components/Search.tsx b/admin/src/features/reqlog/components/Search.tsx index 93406d4..99e0760 100644 --- a/admin/src/features/reqlog/components/Search.tsx +++ b/admin/src/features/reqlog/components/Search.tsx @@ -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(null); const [filterOpen, setFilterOpen] = useState(false); @@ -81,7 +72,6 @@ function Search(): JSX.Element { - - - - - - - - - - All proxy logs are going to be removed. This action cannot be undone. - ); } diff --git a/admin/src/features/sender/components/EditRequest.tsx b/admin/src/features/sender/components/EditRequest.tsx index 04f3402..df24bd8 100644 --- a/admin/src/features/sender/components/EditRequest.tsx +++ b/admin/src/features/sender/components/EditRequest.tsx @@ -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 ( - - - Method - - - onUrlChange(e.target.value)} - required - variant="outlined" - InputLabelProps={{ - shrink: true, - }} - InputProps={{ - sx: { - ".MuiOutlinedInput-notchedOutline": { - borderRadius: 0, - }, - }, - }} - sx={{ flexGrow: 1 }} - /> - - Protocol - - - - ); -} - export default EditRequest; diff --git a/admin/src/lib/InterceptedRequestsContext.tsx b/admin/src/lib/InterceptedRequestsContext.tsx new file mode 100644 index 0000000..f2f81dc --- /dev/null +++ b/admin/src/lib/InterceptedRequestsContext.tsx @@ -0,0 +1,22 @@ +import React, { createContext, useContext } from "react"; + +import { GetInterceptedRequestsQuery, useGetInterceptedRequestsQuery } from "./graphql/generated"; + +const InterceptedRequestsContext = createContext(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 {children}; +} + +export function useInterceptedRequests() { + return useContext(InterceptedRequestsContext); +} diff --git a/admin/src/lib/components/UrlBar.tsx b/admin/src/lib/components/UrlBar.tsx new file mode 100644 index 0000000..b4c6d5a --- /dev/null +++ b/admin/src/lib/components/UrlBar.tsx @@ -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 ( + + + Method + + + onUrlChange && onUrlChange(e.target.value)} + required + variant="outlined" + InputLabelProps={{ + shrink: true, + }} + InputProps={{ + sx: { + ".MuiOutlinedInput-notchedOutline": { + borderRadius: 0, + }, + }, + }} + sx={{ flexGrow: 1 }} + /> + + Protocol + + + + ); +} + +export default UrlBar; diff --git a/admin/src/lib/graphql/generated.tsx b/admin/src/lib/graphql/generated.tsx index 8677011..14ff524 100644 --- a/admin/src/lib/graphql/generated.tsx +++ b/admin/src/lib/graphql/generated.tsx @@ -67,6 +67,16 @@ export enum HttpProtocol { Http20 = 'HTTP20' } +export type HttpRequest = { + __typename?: 'HttpRequest'; + body?: Maybe; + headers: Array; + id: Scalars['ID']; + method: HttpMethod; + proto: HttpProtocol; + url: Scalars['URL']; +}; + export type HttpRequestLog = { __typename?: 'HttpRequestLog'; body?: Maybe; @@ -101,6 +111,20 @@ export type HttpResponseLog = { statusReason: Scalars['String']; }; +export type ModifyRequestInput = { + body?: InputMaybe; + headers?: InputMaybe>; + id: Scalars['ID']; + method: HttpMethod; + proto: HttpProtocol; + url: Scalars['URL']; +}; + +export type ModifyRequestResult = { + __typename?: 'ModifyRequestResult'; + success: Scalars['Boolean']; +}; + export type Mutation = { __typename?: 'Mutation'; clearHTTPRequestLog: ClearHttpRequestLogResult; @@ -110,6 +134,7 @@ export type Mutation = { createSenderRequestFromHttpRequestLog: SenderRequest; deleteProject: DeleteProjectResult; deleteSenderRequests: DeleteSenderRequestsResult; + modifyRequest: ModifyRequestResult; openProject?: Maybe; sendRequest: SenderRequest; setHttpRequestLogFilter?: Maybe; @@ -138,6 +163,11 @@ export type MutationDeleteProjectArgs = { }; +export type MutationModifyRequestArgs = { + request: ModifyRequestInput; +}; + + export type MutationOpenProjectArgs = { id: Scalars['ID']; }; @@ -175,6 +205,8 @@ export type Query = { httpRequestLog?: Maybe; httpRequestLogFilter?: Maybe; httpRequestLogs: Array; + interceptedRequest?: Maybe; + interceptedRequests: Array; projects: Array; scope: Array; senderRequest?: Maybe; @@ -187,6 +219,11 @@ export type QueryHttpRequestLogArgs = { }; +export type QueryInterceptedRequestArgs = { + id: Scalars['ID']; +}; + + export type QuerySenderRequestArgs = { id: Scalars['ID']; }; @@ -248,6 +285,20 @@ export type SenderRequestInput = { url: Scalars['URL']; }; +export type GetInterceptedRequestQueryVariables = Exact<{ + id: Scalars['ID']; +}>; + + +export type GetInterceptedRequestQuery = { __typename?: 'Query', interceptedRequest?: { __typename?: 'HttpRequest', id: string, url: any, method: HttpMethod, proto: HttpProtocol, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }> } | null }; + +export type ModifyRequestMutationVariables = Exact<{ + request: ModifyRequestInput; +}>; + + +export type ModifyRequestMutation = { __typename?: 'Mutation', modifyRequest: { __typename?: 'ModifyRequestResult', success: boolean } }; + export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>; @@ -353,7 +404,88 @@ export type GetSenderRequestsQueryVariables = Exact<{ [key: string]: never; }>; export type GetSenderRequestsQuery = { __typename?: 'Query', senderRequests: Array<{ __typename?: 'SenderRequest', id: string, url: any, method: HttpMethod, response?: { __typename?: 'HttpResponseLog', id: string, statusCode: number, statusReason: string } | null }> }; +export type GetInterceptedRequestsQueryVariables = Exact<{ [key: string]: never; }>; + +export type GetInterceptedRequestsQuery = { __typename?: 'Query', interceptedRequests: Array<{ __typename?: 'HttpRequest', id: string, url: any, method: HttpMethod }> }; + + +export const GetInterceptedRequestDocument = gql` + query GetInterceptedRequest($id: ID!) { + interceptedRequest(id: $id) { + id + url + method + proto + headers { + key + value + } + body + } +} + `; + +/** + * __useGetInterceptedRequestQuery__ + * + * To run a query within a React component, call `useGetInterceptedRequestQuery` and pass it any options that fit your needs. + * When your component renders, `useGetInterceptedRequestQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetInterceptedRequestQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useGetInterceptedRequestQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetInterceptedRequestDocument, options); + } +export function useGetInterceptedRequestLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetInterceptedRequestDocument, options); + } +export type GetInterceptedRequestQueryHookResult = ReturnType; +export type GetInterceptedRequestLazyQueryHookResult = ReturnType; +export type GetInterceptedRequestQueryResult = Apollo.QueryResult; +export const ModifyRequestDocument = gql` + mutation ModifyRequest($request: ModifyRequestInput!) { + modifyRequest(request: $request) { + success + } +} + `; +export type ModifyRequestMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ModifyRequestDocument, options); + } +export type ModifyRequestMutationHookResult = ReturnType; +export type ModifyRequestMutationResult = Apollo.MutationResult; +export type ModifyRequestMutationOptions = Apollo.BaseMutationOptions; export const CloseProjectDocument = gql` mutation CloseProject { closeProject { @@ -982,4 +1114,40 @@ export function useGetSenderRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHook } export type GetSenderRequestsQueryHookResult = ReturnType; export type GetSenderRequestsLazyQueryHookResult = ReturnType; -export type GetSenderRequestsQueryResult = Apollo.QueryResult; \ No newline at end of file +export type GetSenderRequestsQueryResult = Apollo.QueryResult; +export const GetInterceptedRequestsDocument = gql` + query GetInterceptedRequests { + interceptedRequests { + id + url + method + } +} + `; + +/** + * __useGetInterceptedRequestsQuery__ + * + * To run a query within a React component, call `useGetInterceptedRequestsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetInterceptedRequestsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetInterceptedRequestsQuery({ + * variables: { + * }, + * }); + */ +export function useGetInterceptedRequestsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetInterceptedRequestsDocument, options); + } +export function useGetInterceptedRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetInterceptedRequestsDocument, options); + } +export type GetInterceptedRequestsQueryHookResult = ReturnType; +export type GetInterceptedRequestsLazyQueryHookResult = ReturnType; +export type GetInterceptedRequestsQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/admin/src/lib/graphql/interceptedRequests.graphql b/admin/src/lib/graphql/interceptedRequests.graphql new file mode 100644 index 0000000..8616a40 --- /dev/null +++ b/admin/src/lib/graphql/interceptedRequests.graphql @@ -0,0 +1,7 @@ +query GetInterceptedRequests { + interceptedRequests { + id + url + method + } +} diff --git a/admin/src/lib/graphql/useApollo.ts b/admin/src/lib/graphql/useApollo.ts index 9f2782a..7576624 100644 --- a/admin/src/lib/graphql/useApollo.ts +++ b/admin/src/lib/graphql/useApollo.ts @@ -8,7 +8,19 @@ function createApolloClient() { link: new HttpLink({ uri: "/api/graphql/", }), - cache: new InMemoryCache(), + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + interceptedRequests: { + merge(_, incoming) { + return incoming; + }, + }, + }, + }, + }, + }), }); } diff --git a/admin/src/lib/updateKeyPairItem.ts b/admin/src/lib/updateKeyPairItem.ts new file mode 100644 index 0000000..e1d531b --- /dev/null +++ b/admin/src/lib/updateKeyPairItem.ts @@ -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; diff --git a/admin/src/lib/updateURLQueryParams.ts b/admin/src/lib/updateURLQueryParams.ts new file mode 100644 index 0000000..17bdbce --- /dev/null +++ b/admin/src/lib/updateURLQueryParams.ts @@ -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; diff --git a/admin/src/pages/_app.tsx b/admin/src/pages/_app.tsx index e7eda29..c69ad84 100644 --- a/admin/src/pages/_app.tsx +++ b/admin/src/pages/_app.tsx @@ -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) { - - - - + + + + + + diff --git a/admin/src/pages/proxy/intercept/index.tsx b/admin/src/pages/proxy/intercept/index.tsx new file mode 100644 index 0000000..635bc3d --- /dev/null +++ b/admin/src/pages/proxy/intercept/index.tsx @@ -0,0 +1,12 @@ +import { Layout, Page } from "features/Layout"; +import Intercept from "features/intercept/components/Intercept"; + +function ProxyIntercept(): JSX.Element { + return ( + + + + ); +} + +export default ProxyIntercept; diff --git a/admin/yarn.lock b/admin/yarn.lock index f527df5..2ef2927 100644 --- a/admin/yarn.lock +++ b/admin/yarn.lock @@ -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" diff --git a/pkg/api/generated.go b/pkg/api/generated.go index 2d5040c..b671d3d 100644 --- a/pkg/api/generated.go +++ b/pkg/api/generated.go @@ -131,6 +131,7 @@ type ComplexityRoot struct { HTTPRequestLog func(childComplexity int, id ulid.ULID) int HTTPRequestLogFilter func(childComplexity int) int HTTPRequestLogs func(childComplexity int) int + InterceptedRequest func(childComplexity int, id ulid.ULID) int InterceptedRequests func(childComplexity int) int Projects func(childComplexity int) int Scope func(childComplexity int) int @@ -192,6 +193,7 @@ type QueryResolver interface { SenderRequest(ctx context.Context, id ulid.ULID) (*SenderRequest, error) SenderRequests(ctx context.Context) ([]SenderRequest, error) InterceptedRequests(ctx context.Context) ([]HTTPRequest, error) + InterceptedRequest(ctx context.Context, id ulid.ULID) (*HTTPRequest, error) } type executableSchema struct { @@ -607,6 +609,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.HTTPRequestLogs(childComplexity), true + case "Query.interceptedRequest": + if e.complexity.Query.InterceptedRequest == nil { + break + } + + args, err := ec.field_Query_interceptedRequest_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.InterceptedRequest(childComplexity, args["id"].(ulid.ULID)), true + case "Query.interceptedRequests": if e.complexity.Query.InterceptedRequests == nil { break @@ -973,6 +987,7 @@ type Query { senderRequest(id: ID!): SenderRequest senderRequests: [SenderRequest!]! interceptedRequests: [HttpRequest!]! + interceptedRequest(id: ID!): HttpRequest } type Mutation { @@ -1202,6 +1217,21 @@ func (ec *executionContext) field_Query_httpRequestLog_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Query_interceptedRequest_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 ulid.ULID + if tmp, ok := rawArgs["id"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + arg0, err = ec.unmarshalNID2githubᚗcomᚋoklogᚋulidᚐULID(ctx, tmp) + if err != nil { + return nil, err + } + } + args["id"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query_senderRequest_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -3190,6 +3220,45 @@ func (ec *executionContext) _Query_interceptedRequests(ctx context.Context, fiel return ec.marshalNHttpRequest2ᚕgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPRequestᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _Query_interceptedRequest(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Query_interceptedRequest_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().InterceptedRequest(rctx, args["id"].(ulid.ULID)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*HTTPRequest) + fc.Result = res + return ec.marshalOHttpRequest2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPRequest(ctx, field.Selections, res) +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -5805,6 +5874,17 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } return res }) + case "interceptedRequest": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_interceptedRequest(ctx, field) + return res + }) case "__type": out.Values[i] = ec._Query___type(ctx, field) case "__schema": @@ -7117,6 +7197,13 @@ func (ec *executionContext) marshalOHttpProtocol2ᚖgithubᚗcomᚋdstotijnᚋhe return v } +func (ec *executionContext) marshalOHttpRequest2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPRequest(ctx context.Context, sel ast.SelectionSet, v *HTTPRequest) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._HttpRequest(ctx, sel, v) +} + func (ec *executionContext) marshalOHttpRequestLog2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPRequestLog(ctx context.Context, sel ast.SelectionSet, v *HTTPRequestLog) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/pkg/api/resolvers.go b/pkg/api/resolvers.go index d611e9b..81cb17f 100644 --- a/pkg/api/resolvers.go +++ b/pkg/api/resolvers.go @@ -541,6 +541,22 @@ func (r *queryResolver) InterceptedRequests(ctx context.Context) ([]HTTPRequest, return httpReqs, nil } +func (r *queryResolver) InterceptedRequest(ctx context.Context, id ulid.ULID) (*HTTPRequest, error) { + req, err := r.InterceptService.RequestByID(id) + if errors.Is(err, intercept.ErrRequestNotFound) { + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("could not get request by ID: %w", err) + } + + httpReq, err := parseHTTPRequest(req) + if err != nil { + return nil, err + } + + return &httpReq, nil +} + func (r *mutationResolver) ModifyRequest(ctx context.Context, input ModifyRequestInput) (*ModifyRequestResult, error) { body := "" if input.Body != nil { diff --git a/pkg/api/schema.graphql b/pkg/api/schema.graphql index 797a8bf..0f001ca 100644 --- a/pkg/api/schema.graphql +++ b/pkg/api/schema.graphql @@ -148,6 +148,7 @@ type Query { senderRequest(id: ID!): SenderRequest senderRequests: [SenderRequest!]! interceptedRequests: [HttpRequest!]! + interceptedRequest(id: ID!): HttpRequest } type Mutation { diff --git a/pkg/proxy/intercept/intercept.go b/pkg/proxy/intercept/intercept.go index 9b29205..7fd50fa 100644 --- a/pkg/proxy/intercept/intercept.go +++ b/pkg/proxy/intercept/intercept.go @@ -176,6 +176,19 @@ func (svc *Service) Requests() []*http.Request { return reqs } +// Request returns an intercepted request by ID. It's safe for concurrent use. +func (svc *Service) RequestByID(id ulid.ULID) (*http.Request, error) { + svc.mu.RLock() + defer svc.mu.RUnlock() + + req, ok := svc.requests[id] + if !ok { + return nil, ErrRequestNotFound + } + + return req.req, nil +} + func (ids RequestIDs) Len() int { return len(ids) }