From d051d489415b25c8b045d25a5c838affb03c8db8 Mon Sep 17 00:00:00 2001 From: David Stotijn Date: Mon, 14 Mar 2022 15:42:43 +0100 Subject: [PATCH] Add project settings for intercept --- admin/.eslintrc.json | 7 +- admin/src/features/Layout.tsx | 1 + .../intercept/components/EditRequest.tsx | 9 +- .../projects/components/ProjectList.tsx | 7 + .../projects/graphql/activeProject.graphql | 12 + .../features/reqlog/components/Actions.tsx | 38 +- .../features/settings/components/Settings.tsx | 95 +++++ .../graphql/updateInterceptSettings.graphql | 5 + admin/src/lib/ActiveProjectContext.tsx | 6 +- admin/src/lib/components/Link.tsx | 94 +++++ admin/src/lib/graphql/generated.tsx | 107 ++++++ admin/src/pages/settings/index.tsx | 12 + pkg/api/generated.go | 338 ++++++++++++++++++ pkg/api/models_gen.go | 19 +- pkg/api/resolvers.go | 25 ++ pkg/api/schema.graphql | 16 + pkg/proj/proj.go | 37 +- pkg/proxy/intercept/intercept.go | 20 +- pkg/proxy/intercept/intercept_test.go | 11 +- pkg/proxy/intercept/settings.go | 5 + 20 files changed, 833 insertions(+), 31 deletions(-) create mode 100644 admin/src/features/projects/graphql/activeProject.graphql create mode 100644 admin/src/features/settings/components/Settings.tsx create mode 100644 admin/src/features/settings/graphql/updateInterceptSettings.graphql create mode 100644 admin/src/lib/components/Link.tsx create mode 100644 admin/src/pages/settings/index.tsx create mode 100644 pkg/proxy/intercept/settings.go diff --git a/admin/.eslintrc.json b/admin/.eslintrc.json index a901afe..c1f671b 100644 --- a/admin/.eslintrc.json +++ b/admin/.eslintrc.json @@ -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", diff --git a/admin/src/features/Layout.tsx b/admin/src/features/Layout.tsx index 9a76d5a..6495d0f 100644 --- a/admin/src/features/Layout.tsx +++ b/admin/src/features/Layout.tsx @@ -41,6 +41,7 @@ export enum Page { ProxyLogs, Sender, Scope, + Settings, } const drawerWidth = 240; diff --git a/admin/src/features/intercept/components/EditRequest.tsx b/admin/src/features/intercept/components/EditRequest.tsx index a15086e..e939531 100644 --- a/admin/src/features/intercept/components/EditRequest.tsx +++ b/admin/src/features/intercept/components/EditRequest.tsx @@ -1,11 +1,13 @@ import CancelIcon from "@mui/icons-material/Cancel"; import SendIcon from "@mui/icons-material/Send"; -import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material"; +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 Response from "lib/components/Response"; import SplitPane from "lib/components/SplitPane"; @@ -201,6 +203,11 @@ function EditRequest(): JSX.Element { > Cancel + + + + + {modifyResult.error && ( diff --git a/admin/src/features/projects/components/ProjectList.tsx b/admin/src/features/projects/components/ProjectList.tsx index 3ad2dd0..7e156c5 100644 --- a/admin/src/features/projects/components/ProjectList.tsx +++ b/admin/src/features/projects/components/ProjectList.tsx @@ -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 && (Active)} + + + + + {project.isActive && ( closeProject()}> diff --git a/admin/src/features/projects/graphql/activeProject.graphql b/admin/src/features/projects/graphql/activeProject.graphql new file mode 100644 index 0000000..7350999 --- /dev/null +++ b/admin/src/features/projects/graphql/activeProject.graphql @@ -0,0 +1,12 @@ +query ActiveProject { + activeProject { + id + name + isActive + settings { + intercept { + enabled + } + } + } +} diff --git a/admin/src/features/reqlog/components/Actions.tsx b/admin/src/features/reqlog/components/Actions.tsx index 0e755b2..bc16d7b 100644 --- a/admin/src/features/reqlog/components/Actions.tsx +++ b/admin/src/features/reqlog/components/Actions.tsx @@ -4,11 +4,13 @@ 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 }], @@ -27,23 +29,25 @@ function Actions(): JSX.Element { {clearLogsResult.error && Failed to clear HTTP logs: {clearLogsResult.error}} - - - + {activeProject?.settings.intercept.enabled && ( + + + + )} diff --git a/admin/src/features/settings/components/Settings.tsx b/admin/src/features/settings/components/Settings.tsx new file mode 100644 index 0000000..f9210e5 --- /dev/null +++ b/admin/src/features/settings/components/Settings.tsx @@ -0,0 +1,95 @@ +import { useApolloClient } from "@apollo/client"; +import { TabContext, TabPanel } from "@mui/lab"; +import TabList from "@mui/lab/TabList"; +import { Box, FormControl, FormControlLabel, FormHelperText, Switch, Tab, Typography } from "@mui/material"; +import { SwitchBaseProps } from "@mui/material/internal/SwitchBase"; +import { useState } from "react"; + +import { useActiveProject } from "lib/ActiveProjectContext"; +import Link from "lib/components/Link"; +import { ActiveProjectDocument, useUpdateInterceptSettingsMutation } from "lib/graphql/generated"; + +enum TabValue { + Intercept = "intercept", +} + +export default function Settings(): JSX.Element { + const client = useApolloClient(); + const activeProject = useActiveProject(); + const [updateInterceptSettings, updateIntercepSettingsResult] = useUpdateInterceptSettingsMutation(); + + const handleInterceptEnabled: SwitchBaseProps["onChange"] = (_, checked) => { + updateInterceptSettings({ + variables: { + input: { + enabled: checked, + }, + }, + onCompleted(data) { + client.cache.updateQuery({ query: ActiveProjectDocument }, (cachedData) => ({ + activeProject: { + ...cachedData.activeProject, + settings: { + intercept: data.updateInterceptSettings, + }, + }, + })); + }, + }); + }; + + const [tabValue, setTabValue] = useState(TabValue.Intercept); + + const tabSx = { + textTransform: "none", + }; + + return ( + + + Settings + + + Settings allow you to tweak the behaviour of Hetty’s features. + + + Project settings + + {!activeProject && ( + + There is no project active. To configure project settings, first open a project. + + )} + {activeProject && ( + <> + + setTabValue(value)} sx={{ borderBottom: 1, borderColor: "divider" }}> + + + + + + + } + label="Enable proxy interception" + labelPlacement="start" + sx={{ display: "inline-block", m: 0 }} + /> + + When enabled, incoming HTTP requests to the proxy are stalled for{" "} + manual review. + + + + + + )} + + ); +} diff --git a/admin/src/features/settings/graphql/updateInterceptSettings.graphql b/admin/src/features/settings/graphql/updateInterceptSettings.graphql new file mode 100644 index 0000000..0896ade --- /dev/null +++ b/admin/src/features/settings/graphql/updateInterceptSettings.graphql @@ -0,0 +1,5 @@ +mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) { + updateInterceptSettings(input: $input) { + enabled + } +} diff --git a/admin/src/lib/ActiveProjectContext.tsx b/admin/src/lib/ActiveProjectContext.tsx index 3bc35bc..a28eca6 100644 --- a/admin/src/lib/ActiveProjectContext.tsx +++ b/admin/src/lib/ActiveProjectContext.tsx @@ -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(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 {children}; } diff --git a/admin/src/lib/components/Link.tsx b/admin/src/lib/components/Link.tsx new file mode 100644 index 0000000..a9d8a51 --- /dev/null +++ b/admin/src/lib/components/Link.tsx @@ -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, "href">, + Omit { + to: NextLinkProps["href"]; + linkAs?: NextLinkProps["as"]; +} + +export const NextLinkComposed = React.forwardRef(function NextLinkComposed( + props, + ref +) { + const { to, linkAs, replace, scroll, shallow, prefetch, locale, ...other } = props; + + return ( + + + + ); +}); + +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 & + Omit; + +// A styled version of the Next.js Link component: +// https://nextjs.org/docs/api-reference/next/link +const Link = React.forwardRef(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 ; + } + + return ; + } + + const linkAs = linkAsProp || as; + const nextjsProps = { to: href, linkAs, replace, scroll, shallow, prefetch, locale }; + + if (noLinkStyle) { + return ; + } + + return ; +}); + +export default Link; diff --git a/admin/src/lib/graphql/generated.tsx b/admin/src/lib/graphql/generated.tsx index 79d9461..29865f4 100644 --- a/admin/src/lib/graphql/generated.tsx +++ b/admin/src/lib/graphql/generated.tsx @@ -116,6 +116,11 @@ export type HttpResponseLog = { statusReason: Scalars['String']; }; +export type InterceptSettings = { + __typename?: 'InterceptSettings'; + enabled: Scalars['Boolean']; +}; + export type ModifyRequestInput = { body?: InputMaybe; headers?: InputMaybe>; @@ -146,6 +151,7 @@ export type Mutation = { setHttpRequestLogFilter?: Maybe; setScope: Array; setSenderRequestFilter?: Maybe; + updateInterceptSettings: InterceptSettings; }; @@ -203,11 +209,22 @@ export type MutationSetSenderRequestFilterArgs = { filter?: InputMaybe; }; + +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 = { @@ -296,6 +313,10 @@ export type SenderRequestInput = { url: Scalars['URL']; }; +export type UpdateInterceptSettingsInput = { + enabled: Scalars['Boolean']; +}; + export type CancelRequestMutationVariables = Exact<{ id: Scalars['ID']; }>; @@ -317,6 +338,11 @@ export type ModifyRequestMutationVariables = Exact<{ export type ModifyRequestMutation = { __typename?: 'Mutation', modifyRequest: { __typename?: 'ModifyRequestResult', 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', enabled: boolean } } } | null }; + export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>; @@ -422,6 +448,13 @@ 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', enabled: boolean } }; + export type GetInterceptedRequestsQueryVariables = Exact<{ [key: string]: never; }>; @@ -537,6 +570,47 @@ export function useModifyRequestMutation(baseOptions?: Apollo.MutationHookOption export type ModifyRequestMutationHookResult = ReturnType; export type ModifyRequestMutationResult = Apollo.MutationResult; export type ModifyRequestMutationOptions = Apollo.BaseMutationOptions; +export const ActiveProjectDocument = gql` + query ActiveProject { + activeProject { + id + name + isActive + settings { + intercept { + enabled + } + } + } +} + `; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ActiveProjectDocument, options); + } +export function useActiveProjectLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ActiveProjectDocument, options); + } +export type ActiveProjectQueryHookResult = ReturnType; +export type ActiveProjectLazyQueryHookResult = ReturnType; +export type ActiveProjectQueryResult = Apollo.QueryResult; export const CloseProjectDocument = gql` mutation CloseProject { closeProject { @@ -1166,6 +1240,39 @@ export function useGetSenderRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHook export type GetSenderRequestsQueryHookResult = ReturnType; export type GetSenderRequestsLazyQueryHookResult = ReturnType; export type GetSenderRequestsQueryResult = Apollo.QueryResult; +export const UpdateInterceptSettingsDocument = gql` + mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) { + updateInterceptSettings(input: $input) { + enabled + } +} + `; +export type UpdateInterceptSettingsMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateInterceptSettingsDocument, options); + } +export type UpdateInterceptSettingsMutationHookResult = ReturnType; +export type UpdateInterceptSettingsMutationResult = Apollo.MutationResult; +export type UpdateInterceptSettingsMutationOptions = Apollo.BaseMutationOptions; export const GetInterceptedRequestsDocument = gql` query GetInterceptedRequests { interceptedRequests { diff --git a/admin/src/pages/settings/index.tsx b/admin/src/pages/settings/index.tsx new file mode 100644 index 0000000..64150dd --- /dev/null +++ b/admin/src/pages/settings/index.tsx @@ -0,0 +1,12 @@ +import { Layout, Page } from "features/Layout"; +import Settings from "features/settings/components/Settings"; + +function Index(): JSX.Element { + return ( + + + + ); +} + +export default Index; diff --git a/pkg/api/generated.go b/pkg/api/generated.go index 5ad3515..bb693df 100644 --- a/pkg/api/generated.go +++ b/pkg/api/generated.go @@ -104,6 +104,10 @@ type ComplexityRoot struct { StatusReason func(childComplexity int) int } + InterceptSettings struct { + Enabled func(childComplexity int) int + } + ModifyRequestResult struct { Success func(childComplexity int) int } @@ -123,12 +127,18 @@ type ComplexityRoot struct { SetHTTPRequestLogFilter func(childComplexity int, filter *HTTPRequestLogFilterInput) int SetScope func(childComplexity int, scope []ScopeRuleInput) int SetSenderRequestFilter func(childComplexity int, filter *SenderRequestFilterInput) int + UpdateInterceptSettings func(childComplexity int, input UpdateInterceptSettingsInput) int } Project struct { ID func(childComplexity int) int IsActive func(childComplexity int) int Name func(childComplexity int) int + Settings func(childComplexity int) int + } + + ProjectSettings struct { + Intercept func(childComplexity int) int } Query struct { @@ -188,6 +198,7 @@ type MutationResolver interface { DeleteSenderRequests(ctx context.Context) (*DeleteSenderRequestsResult, error) ModifyRequest(ctx context.Context, request ModifyRequestInput) (*ModifyRequestResult, error) CancelRequest(ctx context.Context, id ulid.ULID) (*CancelRequestResult, error) + UpdateInterceptSettings(ctx context.Context, input UpdateInterceptSettingsInput) (*InterceptSettings, error) } type QueryResolver interface { HTTPRequestLog(ctx context.Context, id ulid.ULID) (*HTTPRequestLog, error) @@ -420,6 +431,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.HTTPResponseLog.StatusReason(childComplexity), true + case "InterceptSettings.enabled": + if e.complexity.InterceptSettings.Enabled == nil { + break + } + + return e.complexity.InterceptSettings.Enabled(childComplexity), true + case "ModifyRequestResult.success": if e.complexity.ModifyRequestResult.Success == nil { break @@ -580,6 +598,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.SetSenderRequestFilter(childComplexity, args["filter"].(*SenderRequestFilterInput)), true + case "Mutation.updateInterceptSettings": + if e.complexity.Mutation.UpdateInterceptSettings == nil { + break + } + + args, err := ec.field_Mutation_updateInterceptSettings_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UpdateInterceptSettings(childComplexity, args["input"].(UpdateInterceptSettingsInput)), true + case "Project.id": if e.complexity.Project.ID == nil { break @@ -601,6 +631,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Project.Name(childComplexity), true + case "Project.settings": + if e.complexity.Project.Settings == nil { + break + } + + return e.complexity.Project.Settings(childComplexity), true + + case "ProjectSettings.intercept": + if e.complexity.ProjectSettings.Intercept == nil { + break + } + + return e.complexity.ProjectSettings.Intercept(childComplexity), true + case "Query.activeProject": if e.complexity.Query.ActiveProject == nil { break @@ -894,6 +938,11 @@ type Project { id: ID! name: String! isActive: Boolean! + settings: ProjectSettings! +} + +type ProjectSettings { + intercept: InterceptSettings! } type ScopeRule { @@ -1006,6 +1055,14 @@ type CancelRequestResult { success: Boolean! } +input UpdateInterceptSettingsInput { + enabled: Boolean! +} + +type InterceptSettings { + enabled: Boolean! +} + type Query { httpRequestLog(id: ID!): HttpRequestLog httpRequestLogs: [HttpRequestLog!]! @@ -1036,6 +1093,9 @@ type Mutation { deleteSenderRequests: DeleteSenderRequestsResult! modifyRequest(request: ModifyRequestInput!): ModifyRequestResult! cancelRequest(id: ID!): CancelRequestResult! + updateInterceptSettings( + input: UpdateInterceptSettingsInput! + ): InterceptSettings! } enum HttpMethod { @@ -1232,6 +1292,21 @@ func (ec *executionContext) field_Mutation_setSenderRequestFilter_args(ctx conte return args, nil } +func (ec *executionContext) field_Mutation_updateInterceptSettings_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 UpdateInterceptSettingsInput + if tmp, ok := rawArgs["input"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + arg0, err = ec.unmarshalNUpdateInterceptSettingsInput2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐUpdateInterceptSettingsInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -2330,6 +2405,41 @@ func (ec *executionContext) _HttpResponseLog_headers(ctx context.Context, field return ec.marshalNHttpHeader2ᚕgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPHeaderᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _InterceptSettings_enabled(ctx context.Context, field graphql.CollectedField, obj *InterceptSettings) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "InterceptSettings", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Enabled, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _ModifyRequestResult_success(ctx context.Context, field graphql.CollectedField, obj *ModifyRequestResult) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -2920,6 +3030,48 @@ func (ec *executionContext) _Mutation_cancelRequest(ctx context.Context, field g return ec.marshalNCancelRequestResult2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐCancelRequestResult(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_updateInterceptSettings(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: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_updateInterceptSettings_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.Mutation().UpdateInterceptSettings(rctx, args["input"].(UpdateInterceptSettingsInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*InterceptSettings) + fc.Result = res + return ec.marshalNInterceptSettings2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐInterceptSettings(ctx, field.Selections, res) +} + func (ec *executionContext) _Project_id(ctx context.Context, field graphql.CollectedField, obj *Project) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -3025,6 +3177,76 @@ func (ec *executionContext) _Project_isActive(ctx context.Context, field graphql return ec.marshalNBoolean2bool(ctx, field.Selections, res) } +func (ec *executionContext) _Project_settings(ctx context.Context, field graphql.CollectedField, obj *Project) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Project", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Settings, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*ProjectSettings) + fc.Result = res + return ec.marshalNProjectSettings2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐProjectSettings(ctx, field.Selections, res) +} + +func (ec *executionContext) _ProjectSettings_intercept(ctx context.Context, field graphql.CollectedField, obj *ProjectSettings) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "ProjectSettings", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Intercept, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*InterceptSettings) + fc.Result = res + return ec.marshalNInterceptSettings2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐInterceptSettings(ctx, field.Selections, res) +} + func (ec *executionContext) _Query_httpRequestLog(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -5393,6 +5615,29 @@ func (ec *executionContext) unmarshalInputSenderRequestInput(ctx context.Context return it, nil } +func (ec *executionContext) unmarshalInputUpdateInterceptSettingsInput(ctx context.Context, obj interface{}) (UpdateInterceptSettingsInput, error) { + var it UpdateInterceptSettingsInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + for k, v := range asMap { + switch k { + case "enabled": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("enabled")) + it.Enabled, err = ec.unmarshalNBoolean2bool(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + // endregion **************************** input.gotpl ***************************** // region ************************** interface.gotpl *************************** @@ -5751,6 +5996,33 @@ func (ec *executionContext) _HttpResponseLog(ctx context.Context, sel ast.Select return out } +var interceptSettingsImplementors = []string{"InterceptSettings"} + +func (ec *executionContext) _InterceptSettings(ctx context.Context, sel ast.SelectionSet, obj *InterceptSettings) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, interceptSettingsImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("InterceptSettings") + case "enabled": + out.Values[i] = ec._InterceptSettings_enabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var modifyRequestResultImplementors = []string{"ModifyRequestResult"} func (ec *executionContext) _ModifyRequestResult(ctx context.Context, sel ast.SelectionSet, obj *ModifyRequestResult) graphql.Marshaler { @@ -5851,6 +6123,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "updateInterceptSettings": + out.Values[i] = ec._Mutation_updateInterceptSettings(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -5888,6 +6165,38 @@ func (ec *executionContext) _Project(ctx context.Context, sel ast.SelectionSet, if out.Values[i] == graphql.Null { invalids++ } + case "settings": + out.Values[i] = ec._Project_settings(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + +var projectSettingsImplementors = []string{"ProjectSettings"} + +func (ec *executionContext) _ProjectSettings(ctx context.Context, sel ast.SelectionSet, obj *ProjectSettings) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, projectSettingsImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ProjectSettings") + case "intercept": + out.Values[i] = ec._ProjectSettings_intercept(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -6726,6 +7035,20 @@ func (ec *executionContext) marshalNInt2int(ctx context.Context, sel ast.Selecti return res } +func (ec *executionContext) marshalNInterceptSettings2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐInterceptSettings(ctx context.Context, sel ast.SelectionSet, v InterceptSettings) graphql.Marshaler { + return ec._InterceptSettings(ctx, sel, &v) +} + +func (ec *executionContext) marshalNInterceptSettings2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐInterceptSettings(ctx context.Context, sel ast.SelectionSet, v *InterceptSettings) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._InterceptSettings(ctx, sel, v) +} + func (ec *executionContext) unmarshalNModifyRequestInput2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐModifyRequestInput(ctx context.Context, v interface{}) (ModifyRequestInput, error) { res, err := ec.unmarshalInputModifyRequestInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -6793,6 +7116,16 @@ func (ec *executionContext) marshalNProject2ᚕgithubᚗcomᚋdstotijnᚋhetty return ret } +func (ec *executionContext) marshalNProjectSettings2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐProjectSettings(ctx context.Context, sel ast.SelectionSet, v *ProjectSettings) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._ProjectSettings(ctx, sel, v) +} + func (ec *executionContext) marshalNScopeRule2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐScopeRule(ctx context.Context, sel ast.SelectionSet, v ScopeRule) graphql.Marshaler { return ec._ScopeRule(ctx, sel, &v) } @@ -6981,6 +7314,11 @@ func (ec *executionContext) marshalNURL2ᚖnetᚋurlᚐURL(ctx context.Context, return res } +func (ec *executionContext) unmarshalNUpdateInterceptSettingsInput2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐUpdateInterceptSettingsInput(ctx context.Context, v interface{}) (UpdateInterceptSettingsInput, error) { + res, err := ec.unmarshalInputUpdateInterceptSettingsInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalN__Directive2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirective(ctx context.Context, sel ast.SelectionSet, v introspection.Directive) graphql.Marshaler { return ec.___Directive(ctx, sel, &v) } diff --git a/pkg/api/models_gen.go b/pkg/api/models_gen.go index 09b8749..8e5c0fb 100644 --- a/pkg/api/models_gen.go +++ b/pkg/api/models_gen.go @@ -82,6 +82,10 @@ type HTTPResponseLog struct { Headers []HTTPHeader `json:"headers"` } +type InterceptSettings struct { + Enabled bool `json:"enabled"` +} + type ModifyRequestInput struct { ID ulid.ULID `json:"id"` URL *url.URL `json:"url"` @@ -96,9 +100,14 @@ type ModifyRequestResult struct { } 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 { @@ -154,6 +163,10 @@ type SenderRequestInput struct { Body *string `json:"body"` } +type UpdateInterceptSettingsInput struct { + Enabled bool `json:"enabled"` +} + type HTTPMethod string const ( diff --git a/pkg/api/resolvers.go b/pkg/api/resolvers.go index 588c188..06a9d8b 100644 --- a/pkg/api/resolvers.go +++ b/pkg/api/resolvers.go @@ -218,6 +218,11 @@ func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) { ID: p.ID, Name: p.Name, IsActive: r.ProjectService.IsProjectActive(p.ID), + Settings: &ProjectSettings{ + Intercept: &InterceptSettings{ + Enabled: p.Settings.InterceptEnabled, + }, + }, }, nil } @@ -590,6 +595,26 @@ func (r *mutationResolver) CancelRequest(ctx context.Context, id ulid.ULID) (*Ca return &CancelRequestResult{Success: true}, nil } +func (r *mutationResolver) UpdateInterceptSettings( + ctx context.Context, + input UpdateInterceptSettingsInput, +) (*InterceptSettings, error) { + settings := intercept.Settings{ + Enabled: input.Enabled, + } + + 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) + } + + return &InterceptSettings{ + Enabled: settings.Enabled, + }, nil +} + func parseSenderRequest(req sender.Request) (SenderRequest, error) { method := HTTPMethod(req.Method) if method != "" && !method.IsValid() { diff --git a/pkg/api/schema.graphql b/pkg/api/schema.graphql index 9c4aa48..046e42d 100644 --- a/pkg/api/schema.graphql +++ b/pkg/api/schema.graphql @@ -30,6 +30,11 @@ type Project { id: ID! name: String! isActive: Boolean! + settings: ProjectSettings! +} + +type ProjectSettings { + intercept: InterceptSettings! } type ScopeRule { @@ -142,6 +147,14 @@ type CancelRequestResult { success: Boolean! } +input UpdateInterceptSettingsInput { + enabled: Boolean! +} + +type InterceptSettings { + enabled: Boolean! +} + type Query { httpRequestLog(id: ID!): HttpRequestLog httpRequestLogs: [HttpRequestLog!]! @@ -172,6 +185,9 @@ type Mutation { deleteSenderRequests: DeleteSenderRequestsResult! modifyRequest(request: ModifyRequestInput!): ModifyRequestResult! cancelRequest(id: ID!): CancelRequestResult! + updateInterceptSettings( + input: UpdateInterceptSettingsInput! + ): InterceptSettings! } enum HttpMethod { diff --git a/pkg/proj/proj.go b/pkg/proj/proj.go index e26443c..e6ba850 100644 --- a/pkg/proj/proj.go +++ b/pkg/proj/proj.go @@ -34,6 +34,7 @@ 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 { @@ -55,13 +56,19 @@ type Project struct { } type Settings struct { + // Request log settings ReqLogBypassOutOfScope bool ReqLogOnlyFindInScope bool ReqLogSearchExpr search.Expression + // Intercept settings + InterceptEnabled bool + + // Sender settings SenderOnlyFindInScope bool SenderSearchExpr search.Expression + // Scope settings ScopeRules []scope.Rule } @@ -121,10 +128,12 @@ func (svc *service) CloseProject() error { } svc.activeProjectID = ulid.ULID{} - svc.interceptSvc.ClearRequests() svc.reqLogSvc.SetActiveProjectID(ulid.ULID{}) svc.reqLogSvc.SetBypassOutOfScopeRequests(false) svc.reqLogSvc.SetFindReqsFilter(reqlog.FindRequestsFilter{}) + svc.interceptSvc.UpdateSettings(intercept.Settings{ + Enabled: false, + }) svc.senderSvc.SetActiveProjectID(ulid.ULID{}) svc.senderSvc.SetFindReqsFilter(sender.FindRequestsFilter{}) svc.scope.SetRules(nil) @@ -157,6 +166,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, @@ -165,6 +175,12 @@ 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{ + Enabled: project.Settings.InterceptEnabled, + }) + + // Sender settings. svc.senderSvc.SetActiveProjectID(project.ID) svc.senderSvc.SetFindReqsFilter(sender.FindRequestsFilter{ ProjectID: project.ID, @@ -172,6 +188,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 @@ -269,3 +286,21 @@ 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.InterceptEnabled = settings.Enabled + + 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 +} diff --git a/pkg/proxy/intercept/intercept.go b/pkg/proxy/intercept/intercept.go index 201b3f5..89e82e5 100644 --- a/pkg/proxy/intercept/intercept.go +++ b/pkg/proxy/intercept/intercept.go @@ -31,10 +31,12 @@ type Service struct { mu *sync.RWMutex requests map[ulid.ULID]Request logger log.Logger + enabled bool } type Config struct { - Logger log.Logger + Logger log.Logger + Enabled bool } // RequestIDs implements sort.Interface. @@ -45,6 +47,7 @@ func NewService(cfg Config) *Service { mu: &sync.RWMutex{}, requests: make(map[ulid.ULID]Request), logger: cfg.Logger, + enabled: cfg.Enabled, } if s.logger == nil { @@ -93,6 +96,12 @@ func (svc *Service) Intercept(ctx context.Context, req *http.Request) (*http.Req return req, nil } + if !svc.enabled { + // If intercept is disabled, return the incoming request as-is. + svc.logger.Debugw("Bypassed interception: module disabled.") + return req, nil + } + ch := make(chan *http.Request) done := make(chan struct{}) @@ -181,6 +190,15 @@ func (svc *Service) Requests() []*http.Request { return reqs } +func (svc *Service) UpdateSettings(settings Settings) { + // When updating from `enabled` -> `disabled`, clear any pending reqs. + if svc.enabled && !settings.Enabled { + svc.ClearRequests() + } + + svc.enabled = settings.Enabled +} + // 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() diff --git a/pkg/proxy/intercept/intercept_test.go b/pkg/proxy/intercept/intercept_test.go index 77b794c..fe683bd 100644 --- a/pkg/proxy/intercept/intercept_test.go +++ b/pkg/proxy/intercept/intercept_test.go @@ -28,7 +28,8 @@ func TestRequestModifier(t *testing.T) { logger, _ := zap.NewDevelopment() svc := intercept.NewService(intercept.Config{ - Logger: logger.Sugar(), + Logger: logger.Sugar(), + Enabled: true, }) reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy) @@ -44,7 +45,8 @@ func TestRequestModifier(t *testing.T) { logger, _ := zap.NewDevelopment() svc := intercept.NewService(intercept.Config{ - Logger: logger.Sugar(), + Logger: logger.Sugar(), + Enabled: true, }) ctx, cancel := context.WithCancel(context.Background()) @@ -65,7 +67,7 @@ func TestRequestModifier(t *testing.T) { err := svc.ModifyRequest(reqID, nil) if !errors.Is(err, intercept.ErrRequestDone) { - t.Fatalf("expected `interept.ErrRequestDone`, got: %v", err) + t.Fatalf("expected `intercept.ErrRequestDone`, got: %v", err) } }) @@ -83,7 +85,8 @@ func TestRequestModifier(t *testing.T) { logger, _ := zap.NewDevelopment() svc := intercept.NewService(intercept.Config{ - Logger: logger.Sugar(), + Logger: logger.Sugar(), + Enabled: true, }) var got *http.Request diff --git a/pkg/proxy/intercept/settings.go b/pkg/proxy/intercept/settings.go new file mode 100644 index 0000000..35ac2fc --- /dev/null +++ b/pkg/proxy/intercept/settings.go @@ -0,0 +1,5 @@ +package intercept + +type Settings struct { + Enabled bool +}