Add intercept response filter

This commit is contained in:
David Stotijn
2022-03-21 10:33:11 +01:00
parent cf55456c42
commit 89141afd3b
15 changed files with 556 additions and 113 deletions

View File

@ -5,8 +5,10 @@ query ActiveProject {
isActive isActive
settings { settings {
intercept { intercept {
enabled requestsEnabled
responsesEnabled
requestFilter requestFilter
responseFilter
} }
} }
} }

View File

@ -29,7 +29,7 @@ function Actions(): JSX.Element {
{clearLogsResult.error && <Alert severity="error">Failed to clear HTTP logs: {clearLogsResult.error}</Alert>} {clearLogsResult.error && <Alert severity="error">Failed to clear HTTP logs: {clearLogsResult.error}</Alert>}
{activeProject?.settings.intercept.enabled && ( {(activeProject?.settings.intercept.requestsEnabled || activeProject?.settings.intercept.responsesEnabled) && (
<Link href="/proxy/intercept/?id=" passHref> <Link href="/proxy/intercept/?id=" passHref>
<Button <Button
variant="contained" variant="contained"

View File

@ -11,6 +11,7 @@ import {
Switch, Switch,
Tab, Tab,
TextField, TextField,
TextFieldProps,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { SwitchBaseProps } from "@mui/material/internal/SwitchBase"; import { SwitchBaseProps } from "@mui/material/internal/SwitchBase";
@ -25,6 +26,26 @@ enum TabValue {
Intercept = "intercept", 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 { export default function Settings(): JSX.Element {
const client = useApolloClient(); const client = useApolloClient();
const activeProject = useActiveProject(); const activeProject = useActiveProject();
@ -41,16 +62,22 @@ export default function Settings(): JSX.Element {
})); }));
setInterceptReqFilter(data.updateInterceptSettings.requestFilter || ""); setInterceptReqFilter(data.updateInterceptSettings.requestFilter || "");
setInterceptResFilter(data.updateInterceptSettings.responseFilter || "");
}, },
}); });
const [interceptReqFilter, setInterceptReqFilter] = useState(""); const [interceptReqFilter, setInterceptReqFilter] = useState("");
const [interceptResFilter, setInterceptResFilter] = useState("");
useEffect(() => { useEffect(() => {
setInterceptReqFilter(activeProject?.settings.intercept.requestFilter || ""); setInterceptReqFilter(activeProject?.settings.intercept.requestFilter || "");
}, [activeProject?.settings.intercept.requestFilter]); }, [activeProject?.settings.intercept.requestFilter]);
const handleInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => { useEffect(() => {
setInterceptResFilter(activeProject?.settings.intercept.responseFilter || "");
}, [activeProject?.settings.intercept.responseFilter]);
const handleReqInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => {
if (!activeProject) { if (!activeProject) {
e.preventDefault(); e.preventDefault();
return; return;
@ -60,7 +87,23 @@ export default function Settings(): JSX.Element {
variables: { variables: {
input: { input: {
...withoutTypename(activeProject.settings.intercept), ...withoutTypename(activeProject.settings.intercept),
enabled: checked, requestsEnabled: checked,
},
},
});
};
const handleResInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => {
if (!activeProject) {
e.preventDefault();
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
responsesEnabled: checked,
}, },
}, },
}); });
@ -81,6 +124,21 @@ export default function Settings(): JSX.Element {
}); });
}; };
const handleInterceptResFilter = () => {
if (!activeProject) {
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
responseFilter: interceptResFilter,
},
},
});
};
const [tabValue, setTabValue] = useState(TabValue.Intercept); const [tabValue, setTabValue] = useState(TabValue.Intercept);
const tabSx = { const tabSx = {
@ -111,16 +169,19 @@ export default function Settings(): JSX.Element {
</TabList> </TabList>
<TabPanel value={TabValue.Intercept} sx={{ px: 0 }}> <TabPanel value={TabValue.Intercept} sx={{ px: 0 }}>
<FormControl> <Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
Requests
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
disabled={updateIntercepSettingsResult.loading} disabled={updateIntercepSettingsResult.loading}
onChange={handleInterceptEnabled} onChange={handleReqInterceptEnabled}
checked={activeProject.settings.intercept.enabled} checked={activeProject.settings.intercept.requestsEnabled}
/> />
} }
label="Enable proxy interception" label="Enable request interception"
labelPlacement="start" labelPlacement="start"
sx={{ display: "inline-block", m: 0 }} sx={{ display: "inline-block", m: 0 }}
/> />
@ -129,28 +190,13 @@ export default function Settings(): JSX.Element {
<Link href="/proxy/intercept">manual review</Link>. <Link href="/proxy/intercept">manual review</Link>.
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>
<Typography variant="h6" sx={{ mt: 3 }}>
Rules
</Typography>
<form> <form>
<FormControl sx={{ width: "50%" }}> <FormControl sx={{ width: "50%" }}>
<TextField <FilterTextField
label="Request filter" label="Request filter"
placeholder={`method = "GET" OR url =~ "/foobar"`} placeholder={`Example: method = "GET" OR url =~ "/foobar"`}
color="primary"
variant="outlined"
value={interceptReqFilter} value={interceptReqFilter}
onChange={(e) => setInterceptReqFilter(e.target.value)} onChange={(e) => setInterceptReqFilter(e.target.value)}
InputProps={{
sx: { fontFamily: "'JetBrains Mono', monospace" },
autoCorrect: "false",
spellCheck: "false",
}}
InputLabelProps={{
shrink: true,
}}
margin="normal"
sx={{ mr: 1 }}
/> />
<FormHelperText> <FormHelperText>
Filter expression to match incoming requests on. When set, only matching requests are intercepted. Filter expression to match incoming requests on. When set, only matching requests are intercepted.
@ -172,6 +218,55 @@ export default function Settings(): JSX.Element {
Update Update
</Button> </Button>
</form> </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> </TabPanel>
</TabContext> </TabContext>
</> </>

View File

@ -1,6 +1,8 @@
mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) { mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) {
updateInterceptSettings(input: $input) { updateInterceptSettings(input: $input) {
enabled requestsEnabled
responsesEnabled
requestFilter requestFilter
responseFilter
} }
} }

View File

@ -135,8 +135,10 @@ export type HttpResponseLog = {
export type InterceptSettings = { export type InterceptSettings = {
__typename?: 'InterceptSettings'; __typename?: 'InterceptSettings';
enabled: Scalars['Boolean'];
requestFilter?: Maybe<Scalars['String']>; requestFilter?: Maybe<Scalars['String']>;
requestsEnabled: Scalars['Boolean'];
responseFilter?: Maybe<Scalars['String']>;
responsesEnabled: Scalars['Boolean'];
}; };
export type ModifyRequestInput = { export type ModifyRequestInput = {
@ -359,8 +361,10 @@ export type SenderRequestInput = {
}; };
export type UpdateInterceptSettingsInput = { export type UpdateInterceptSettingsInput = {
enabled: Scalars['Boolean'];
requestFilter?: InputMaybe<Scalars['String']>; requestFilter?: InputMaybe<Scalars['String']>;
requestsEnabled: Scalars['Boolean'];
responseFilter?: InputMaybe<Scalars['String']>;
responsesEnabled: Scalars['Boolean'];
}; };
export type CancelRequestMutationVariables = Exact<{ export type CancelRequestMutationVariables = Exact<{
@ -401,7 +405,7 @@ export type ModifyResponseMutation = { __typename?: 'Mutation', modifyResponse:
export type ActiveProjectQueryVariables = Exact<{ [key: string]: never; }>; 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, requestFilter?: string | null } } } | null }; export type ActiveProjectQuery = { __typename?: 'Query', activeProject?: { __typename?: 'Project', id: string, name: string, isActive: boolean, settings: { __typename?: 'ProjectSettings', intercept: { __typename?: 'InterceptSettings', requestsEnabled: boolean, responsesEnabled: boolean, requestFilter?: string | null, responseFilter?: string | null } } } | null };
export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>; export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>;
@ -513,7 +517,7 @@ export type UpdateInterceptSettingsMutationVariables = Exact<{
}>; }>;
export type UpdateInterceptSettingsMutation = { __typename?: 'Mutation', updateInterceptSettings: { __typename?: 'InterceptSettings', enabled: boolean, requestFilter?: string | null } }; 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 GetInterceptedRequestsQueryVariables = Exact<{ [key: string]: never; }>;
@ -715,8 +719,10 @@ export const ActiveProjectDocument = gql`
isActive isActive
settings { settings {
intercept { intercept {
enabled requestsEnabled
responsesEnabled
requestFilter requestFilter
responseFilter
} }
} }
} }
@ -1381,8 +1387,10 @@ export type GetSenderRequestsQueryResult = Apollo.QueryResult<GetSenderRequestsQ
export const UpdateInterceptSettingsDocument = gql` export const UpdateInterceptSettingsDocument = gql`
mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) { mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) {
updateInterceptSettings(input: $input) { updateInterceptSettings(input: $input) {
enabled requestsEnabled
responsesEnabled
requestFilter requestFilter
responseFilter
} }
} }
`; `;

View File

@ -119,8 +119,10 @@ type ComplexityRoot struct {
} }
InterceptSettings struct { InterceptSettings struct {
Enabled func(childComplexity int) int
RequestFilter func(childComplexity int) int RequestFilter func(childComplexity int) int
RequestsEnabled func(childComplexity int) int
ResponseFilter func(childComplexity int) int
ResponsesEnabled func(childComplexity int) int
} }
ModifyRequestResult struct { ModifyRequestResult struct {
@ -510,13 +512,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.HTTPResponseLog.StatusReason(childComplexity), true 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 "InterceptSettings.requestFilter": case "InterceptSettings.requestFilter":
if e.complexity.InterceptSettings.RequestFilter == nil { if e.complexity.InterceptSettings.RequestFilter == nil {
break break
@ -524,6 +519,27 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.InterceptSettings.RequestFilter(childComplexity), true return e.complexity.InterceptSettings.RequestFilter(childComplexity), true
case "InterceptSettings.requestsEnabled":
if e.complexity.InterceptSettings.RequestsEnabled == nil {
break
}
return e.complexity.InterceptSettings.RequestsEnabled(childComplexity), true
case "InterceptSettings.responseFilter":
if e.complexity.InterceptSettings.ResponseFilter == nil {
break
}
return e.complexity.InterceptSettings.ResponseFilter(childComplexity), true
case "InterceptSettings.responsesEnabled":
if e.complexity.InterceptSettings.ResponsesEnabled == nil {
break
}
return e.complexity.InterceptSettings.ResponsesEnabled(childComplexity), true
case "ModifyRequestResult.success": case "ModifyRequestResult.success":
if e.complexity.ModifyRequestResult.Success == nil { if e.complexity.ModifyRequestResult.Success == nil {
break break
@ -1204,13 +1220,17 @@ type CancelResponseResult {
} }
input UpdateInterceptSettingsInput { input UpdateInterceptSettingsInput {
enabled: Boolean! requestsEnabled: Boolean!
responsesEnabled: Boolean!
requestFilter: String requestFilter: String
responseFilter: String
} }
type InterceptSettings { type InterceptSettings {
enabled: Boolean! requestsEnabled: Boolean!
responsesEnabled: Boolean!
requestFilter: String requestFilter: String
responseFilter: String
} }
type Query { type Query {
@ -2861,7 +2881,7 @@ func (ec *executionContext) _HttpResponseLog_headers(ctx context.Context, field
return ec.marshalNHttpHeader2ᚕgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPHeaderᚄ(ctx, field.Selections, res) 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) { func (ec *executionContext) _InterceptSettings_requestsEnabled(ctx context.Context, field graphql.CollectedField, obj *InterceptSettings) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r)) ec.Error(ctx, ec.Recover(ctx, r))
@ -2879,7 +2899,42 @@ func (ec *executionContext) _InterceptSettings_enabled(ctx context.Context, fiel
ctx = graphql.WithFieldContext(ctx, fc) ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children ctx = rctx // use context from middleware stack in children
return obj.Enabled, nil return obj.RequestsEnabled, 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) _InterceptSettings_responsesEnabled(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.ResponsesEnabled, nil
}) })
if err != nil { if err != nil {
ec.Error(ctx, err) ec.Error(ctx, err)
@ -2928,6 +2983,38 @@ func (ec *executionContext) _InterceptSettings_requestFilter(ctx context.Context
return ec.marshalOString2ᚖstring(ctx, field.Selections, res) return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
} }
func (ec *executionContext) _InterceptSettings_responseFilter(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.ResponseFilter, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*string)
fc.Result = res
return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
}
func (ec *executionContext) _ModifyRequestResult_success(ctx context.Context, field graphql.CollectedField, obj *ModifyRequestResult) (ret graphql.Marshaler) { func (ec *executionContext) _ModifyRequestResult_success(ctx context.Context, field graphql.CollectedField, obj *ModifyRequestResult) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -6302,11 +6389,19 @@ func (ec *executionContext) unmarshalInputUpdateInterceptSettingsInput(ctx conte
for k, v := range asMap { for k, v := range asMap {
switch k { switch k {
case "enabled": case "requestsEnabled":
var err error var err error
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("enabled")) ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("requestsEnabled"))
it.Enabled, err = ec.unmarshalNBoolean2bool(ctx, v) it.RequestsEnabled, err = ec.unmarshalNBoolean2bool(ctx, v)
if err != nil {
return it, err
}
case "responsesEnabled":
var err error
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("responsesEnabled"))
it.ResponsesEnabled, err = ec.unmarshalNBoolean2bool(ctx, v)
if err != nil { if err != nil {
return it, err return it, err
} }
@ -6318,6 +6413,14 @@ func (ec *executionContext) unmarshalInputUpdateInterceptSettingsInput(ctx conte
if err != nil { if err != nil {
return it, err return it, err
} }
case "responseFilter":
var err error
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("responseFilter"))
it.ResponseFilter, err = ec.unmarshalOString2ᚖstring(ctx, v)
if err != nil {
return it, err
}
} }
} }
@ -6771,13 +6874,20 @@ func (ec *executionContext) _InterceptSettings(ctx context.Context, sel ast.Sele
switch field.Name { switch field.Name {
case "__typename": case "__typename":
out.Values[i] = graphql.MarshalString("InterceptSettings") out.Values[i] = graphql.MarshalString("InterceptSettings")
case "enabled": case "requestsEnabled":
out.Values[i] = ec._InterceptSettings_enabled(ctx, field, obj) out.Values[i] = ec._InterceptSettings_requestsEnabled(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
case "responsesEnabled":
out.Values[i] = ec._InterceptSettings_responsesEnabled(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
invalids++ invalids++
} }
case "requestFilter": case "requestFilter":
out.Values[i] = ec._InterceptSettings_requestFilter(ctx, field, obj) out.Values[i] = ec._InterceptSettings_requestFilter(ctx, field, obj)
case "responseFilter":
out.Values[i] = ec._InterceptSettings_responseFilter(ctx, field, obj)
default: default:
panic("unknown field " + strconv.Quote(field.Name)) panic("unknown field " + strconv.Quote(field.Name))
} }

View File

@ -98,8 +98,10 @@ type HTTPResponseLog struct {
} }
type InterceptSettings struct { type InterceptSettings struct {
Enabled bool `json:"enabled"` RequestsEnabled bool `json:"requestsEnabled"`
ResponsesEnabled bool `json:"responsesEnabled"`
RequestFilter *string `json:"requestFilter"` RequestFilter *string `json:"requestFilter"`
ResponseFilter *string `json:"responseFilter"`
} }
type ModifyRequestInput struct { type ModifyRequestInput struct {
@ -194,8 +196,10 @@ type SenderRequestInput struct {
} }
type UpdateInterceptSettingsInput struct { type UpdateInterceptSettingsInput struct {
Enabled bool `json:"enabled"` RequestsEnabled bool `json:"requestsEnabled"`
ResponsesEnabled bool `json:"responsesEnabled"`
RequestFilter *string `json:"requestFilter"` RequestFilter *string `json:"requestFilter"`
ResponseFilter *string `json:"responseFilter"`
} }
type HTTPMethod string type HTTPMethod string

View File

@ -596,10 +596,13 @@ func (r *mutationResolver) ModifyResponse(
return nil, fmt.Errorf("malformed HTTP version: %q", res.Proto) return nil, fmt.Errorf("malformed HTTP version: %q", res.Proto)
} }
var body string
if input.Body != nil { if input.Body != nil {
res.Body = io.NopCloser(strings.NewReader(*input.Body)) body = *input.Body
} }
res.Body = io.NopCloser(strings.NewReader(body))
for _, header := range input.Headers { for _, header := range input.Headers {
res.Header.Add(header.Key, header.Value) res.Header.Add(header.Key, header.Value)
} }
@ -626,18 +629,28 @@ func (r *mutationResolver) UpdateInterceptSettings(
input UpdateInterceptSettingsInput, input UpdateInterceptSettingsInput,
) (*InterceptSettings, error) { ) (*InterceptSettings, error) {
settings := intercept.Settings{ settings := intercept.Settings{
Enabled: input.Enabled, RequestsEnabled: input.RequestsEnabled,
ResponsesEnabled: input.ResponsesEnabled,
} }
if input.RequestFilter != nil && *input.RequestFilter != "" { if input.RequestFilter != nil && *input.RequestFilter != "" {
expr, err := search.ParseQuery(*input.RequestFilter) expr, err := search.ParseQuery(*input.RequestFilter)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse search query: %w", err) return nil, fmt.Errorf("could not parse request filter: %w", err)
} }
settings.RequestFilter = expr 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) err := r.ProjectService.UpdateInterceptSettings(ctx, settings)
if errors.Is(err, proj.ErrNoProject) { if errors.Is(err, proj.ErrNoProject) {
return nil, noActiveProjectErr(ctx) return nil, noActiveProjectErr(ctx)
@ -646,7 +659,8 @@ func (r *mutationResolver) UpdateInterceptSettings(
} }
updated := &InterceptSettings{ updated := &InterceptSettings{
Enabled: settings.Enabled, RequestsEnabled: settings.RequestsEnabled,
ResponsesEnabled: settings.ResponsesEnabled,
} }
if settings.RequestFilter != nil { if settings.RequestFilter != nil {
@ -654,6 +668,11 @@ func (r *mutationResolver) UpdateInterceptSettings(
updated.RequestFilter = &reqFilter updated.RequestFilter = &reqFilter
} }
if settings.ResponseFilter != nil {
resFilter := settings.ResponseFilter.String()
updated.ResponseFilter = &resFilter
}
return updated, nil return updated, nil
} }
@ -842,7 +861,8 @@ func parseProject(projSvc proj.Service, p proj.Project) Project {
IsActive: projSvc.IsProjectActive(p.ID), IsActive: projSvc.IsProjectActive(p.ID),
Settings: &ProjectSettings{ Settings: &ProjectSettings{
Intercept: &InterceptSettings{ Intercept: &InterceptSettings{
Enabled: p.Settings.InterceptEnabled, RequestsEnabled: p.Settings.InterceptRequests,
ResponsesEnabled: p.Settings.InterceptResponses,
}, },
}, },
} }
@ -852,6 +872,11 @@ func parseProject(projSvc proj.Service, p proj.Project) Project {
project.Settings.Intercept.RequestFilter = &interceptReqFilter project.Settings.Intercept.RequestFilter = &interceptReqFilter
} }
if p.Settings.InterceptResponseFilter != nil {
interceptResFilter := p.Settings.InterceptResponseFilter.String()
project.Settings.Intercept.ResponseFilter = &interceptResFilter
}
return project return project
} }

View File

@ -179,13 +179,17 @@ type CancelResponseResult {
} }
input UpdateInterceptSettingsInput { input UpdateInterceptSettingsInput {
enabled: Boolean! requestsEnabled: Boolean!
responsesEnabled: Boolean!
requestFilter: String requestFilter: String
responseFilter: String
} }
type InterceptSettings { type InterceptSettings {
enabled: Boolean! requestsEnabled: Boolean!
responsesEnabled: Boolean!
requestFilter: String requestFilter: String
responseFilter: String
} }
type Query { type Query {

View File

@ -62,8 +62,10 @@ type Settings struct {
ReqLogSearchExpr search.Expression ReqLogSearchExpr search.Expression
// Intercept settings // Intercept settings
InterceptEnabled bool InterceptRequests bool
InterceptResponses bool
InterceptRequestFilter search.Expression InterceptRequestFilter search.Expression
InterceptResponseFilter search.Expression
// Sender settings // Sender settings
SenderOnlyFindInScope bool SenderOnlyFindInScope bool
@ -133,8 +135,10 @@ func (svc *service) CloseProject() error {
svc.reqLogSvc.SetBypassOutOfScopeRequests(false) svc.reqLogSvc.SetBypassOutOfScopeRequests(false)
svc.reqLogSvc.SetFindReqsFilter(reqlog.FindRequestsFilter{}) svc.reqLogSvc.SetFindReqsFilter(reqlog.FindRequestsFilter{})
svc.interceptSvc.UpdateSettings(intercept.Settings{ svc.interceptSvc.UpdateSettings(intercept.Settings{
Enabled: false, RequestsEnabled: false,
ResponsesEnabled: false,
RequestFilter: nil, RequestFilter: nil,
ResponseFilter: nil,
}) })
svc.senderSvc.SetActiveProjectID(ulid.ULID{}) svc.senderSvc.SetActiveProjectID(ulid.ULID{})
svc.senderSvc.SetFindReqsFilter(sender.FindRequestsFilter{}) svc.senderSvc.SetFindReqsFilter(sender.FindRequestsFilter{})
@ -179,8 +183,10 @@ func (svc *service) OpenProject(ctx context.Context, projectID ulid.ULID) (Proje
// Intercept settings. // Intercept settings.
svc.interceptSvc.UpdateSettings(intercept.Settings{ svc.interceptSvc.UpdateSettings(intercept.Settings{
Enabled: project.Settings.InterceptEnabled, RequestsEnabled: project.Settings.InterceptRequests,
ResponsesEnabled: project.Settings.InterceptResponses,
RequestFilter: project.Settings.InterceptRequestFilter, RequestFilter: project.Settings.InterceptRequestFilter,
ResponseFilter: project.Settings.InterceptResponseFilter,
}) })
// Sender settings. // Sender settings.
@ -296,8 +302,10 @@ func (svc *service) UpdateInterceptSettings(ctx context.Context, settings interc
return err return err
} }
project.Settings.InterceptEnabled = settings.Enabled project.Settings.InterceptRequests = settings.RequestsEnabled
project.Settings.InterceptResponses = settings.ResponsesEnabled
project.Settings.InterceptRequestFilter = settings.RequestFilter project.Settings.InterceptRequestFilter = settings.RequestFilter
project.Settings.InterceptResponseFilter = settings.ResponseFilter
err = svc.repo.UpsertProject(ctx, project) err = svc.repo.UpsertProject(ctx, project)
if err != nil { if err != nil {

View File

@ -7,6 +7,7 @@ import (
"io" "io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
@ -38,6 +39,34 @@ var reqFilterKeyFns = map[string]func(req *http.Request) (string, error){
}, },
} }
//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. // MatchRequestFilter returns true if an HTTP request matches the request filter expression.
func MatchRequestFilter(req *http.Request, expr search.Expression) (bool, error) { func MatchRequestFilter(req *http.Request, expr search.Expression) (bool, error) {
switch e := expr.(type) { switch e := expr.(type) {
@ -228,3 +257,139 @@ func MatchRequestScope(req *http.Request, s *scope.Scope) (bool, error) {
return false, 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
}

View File

@ -53,14 +53,19 @@ type Service struct {
requests map[ulid.ULID]Request requests map[ulid.ULID]Request
responses map[ulid.ULID]Response responses map[ulid.ULID]Response
logger log.Logger logger log.Logger
enabled bool
requestsEnabled bool
responsesEnabled bool
reqFilter search.Expression reqFilter search.Expression
resFilter search.Expression
} }
type Config struct { type Config struct {
Logger log.Logger Logger log.Logger
Enabled bool RequestsEnabled bool
ResponsesEnabled bool
RequestFilter search.Expression RequestFilter search.Expression
ResponseFilter search.Expression
} }
// RequestIDs implements sort.Interface. // RequestIDs implements sort.Interface.
@ -73,8 +78,10 @@ func NewService(cfg Config) *Service {
requests: make(map[ulid.ULID]Request), requests: make(map[ulid.ULID]Request),
responses: make(map[ulid.ULID]Response), responses: make(map[ulid.ULID]Response),
logger: cfg.Logger, logger: cfg.Logger,
enabled: cfg.Enabled, requestsEnabled: cfg.RequestsEnabled,
responsesEnabled: cfg.ResponsesEnabled,
reqFilter: cfg.RequestFilter, reqFilter: cfg.RequestFilter,
resFilter: cfg.ResponseFilter,
} }
if s.logger == nil { if s.logger == nil {
@ -122,7 +129,7 @@ func (svc *Service) InterceptRequest(ctx context.Context, req *http.Request) (*h
return req, nil return req, nil
} }
if !svc.enabled { if !svc.requestsEnabled {
// If request intercept is disabled, return the incoming request as-is. // If request intercept is disabled, return the incoming request as-is.
svc.logger.Debugw("Bypassed request interception: feature disabled.") svc.logger.Debugw("Bypassed request interception: feature disabled.")
return req, nil return req, nil
@ -267,14 +274,20 @@ func (svc *Service) Items() []Item {
} }
func (svc *Service) UpdateSettings(settings Settings) { func (svc *Service) UpdateSettings(settings Settings) {
// When updating from `enabled` -> `disabled`, clear any pending reqs. // When updating from requests `enabled` -> `disabled`, clear any pending reqs.
if svc.enabled && !settings.Enabled { if svc.requestsEnabled && !settings.RequestsEnabled {
svc.ClearRequests() svc.ClearRequests()
}
// When updating from responses `enabled` -> `disabled`, clear any pending responses.
if svc.responsesEnabled && !settings.ResponsesEnabled {
svc.ClearResponses() svc.ClearResponses()
} }
svc.enabled = settings.Enabled svc.requestsEnabled = settings.RequestsEnabled
svc.responsesEnabled = settings.ResponsesEnabled
svc.reqFilter = settings.RequestFilter 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. // ItemByID returns an intercepted item (request and possible response) by ID. It's safe for concurrent use.
@ -358,25 +371,25 @@ func (svc *Service) InterceptResponse(ctx context.Context, res *http.Response) (
return res, nil return res, nil
} }
if !svc.enabled { // If global response intercept is disabled and interception is *not* explicitly enabled for this response: bypass.
// If the feature is disabled, return the response as-is. if !svc.responsesEnabled && !(ok && shouldIntercept) {
svc.logger.Debugw("Bypassed response interception: feature disabled.") svc.logger.Debugw("Bypassed response interception: feature disabled.")
return res, nil return res, nil
} }
// if svc.reqFilter != nil { if svc.resFilter != nil {
// match, err := MatchRequestFilter(req, svc.reqFilter) match, err := MatchResponseFilter(res, svc.resFilter)
// if err != nil { if err != nil {
// return nil, fmt.Errorf("intercept: failed to match request rules for request (id: %v): %w", return nil, fmt.Errorf("intercept: failed to match response rules for response (id: %v): %w",
// reqID.String(), err, reqID.String(), err,
// ) )
// } }
// if !match { if !match {
// svc.logger.Debugw("Bypassed interception: request rules don't match.") svc.logger.Debugw("Bypassed response interception: response rules don't match.")
// return req, nil return res, nil
// } }
// } }
ch := make(chan *http.Response) ch := make(chan *http.Response)
done := make(chan struct{}) done := make(chan struct{})

View File

@ -29,7 +29,8 @@ func TestRequestModifier(t *testing.T) {
logger, _ := zap.NewDevelopment() logger, _ := zap.NewDevelopment()
svc := intercept.NewService(intercept.Config{ svc := intercept.NewService(intercept.Config{
Logger: logger.Sugar(), Logger: logger.Sugar(),
Enabled: true, RequestsEnabled: true,
ResponsesEnabled: false,
}) })
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy) reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
@ -46,7 +47,8 @@ func TestRequestModifier(t *testing.T) {
logger, _ := zap.NewDevelopment() logger, _ := zap.NewDevelopment()
svc := intercept.NewService(intercept.Config{ svc := intercept.NewService(intercept.Config{
Logger: logger.Sugar(), Logger: logger.Sugar(),
Enabled: true, RequestsEnabled: true,
ResponsesEnabled: false,
}) })
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@ -89,7 +91,8 @@ func TestRequestModifier(t *testing.T) {
logger, _ := zap.NewDevelopment() logger, _ := zap.NewDevelopment()
svc := intercept.NewService(intercept.Config{ svc := intercept.NewService(intercept.Config{
Logger: logger.Sugar(), Logger: logger.Sugar(),
Enabled: true, RequestsEnabled: true,
ResponsesEnabled: false,
}) })
var got *http.Request var got *http.Request

View File

@ -3,6 +3,8 @@ package intercept
import "github.com/dstotijn/hetty/pkg/search" import "github.com/dstotijn/hetty/pkg/search"
type Settings struct { type Settings struct {
Enabled bool RequestsEnabled bool
ResponsesEnabled bool
RequestFilter search.Expression RequestFilter search.Expression
ResponseFilter search.Expression
} }

View File

@ -217,14 +217,16 @@ func (svc *service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon
clone := *res clone := *res
if res.Body != nil {
// TODO: Use io.LimitReader. // TODO: Use io.LimitReader.
body, err := ioutil.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return fmt.Errorf("reqlog: could not read response body: %w", err) return fmt.Errorf("reqlog: could not read response body: %w", err)
} }
res.Body = ioutil.NopCloser(bytes.NewBuffer(body)) res.Body = io.NopCloser(bytes.NewBuffer(body))
clone.Body = ioutil.NopCloser(bytes.NewBuffer(body)) clone.Body = io.NopCloser(bytes.NewBuffer(body))
}
go func() { go func() {
if err := svc.storeResponse(context.Background(), reqLogID, &clone); err != nil { if err := svc.storeResponse(context.Background(), reqLogID, &clone); err != nil {