diff --git a/admin/src/components/reqlog/Search.tsx b/admin/src/components/reqlog/Search.tsx index 76a2d72..7c9c502 100644 --- a/admin/src/components/reqlog/Search.tsx +++ b/admin/src/components/reqlog/Search.tsx @@ -31,6 +31,7 @@ const FILTER = gql` query HttpRequestLogFilter { httpRequestLogFilter { onlyInScope + searchExpression } } `; @@ -39,6 +40,7 @@ const SET_FILTER = gql` mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) { setHttpRequestLogFilter(filter: $filter) { onlyInScope + searchExpression } } `; @@ -75,14 +77,21 @@ const useStyles = makeStyles((theme: Theme) => export interface SearchFilter { onlyInScope: boolean; + searchExpression: string; } function Search(): JSX.Element { const classes = useStyles(); const theme = useTheme(); + const [searchExpr, setSearchExpr] = useState(""); const { loading: filterLoading, error: filterErr, data: filter } = useQuery( - FILTER + FILTER, + { + onCompleted: (data) => { + setSearchExpr(data.httpRequestLogFilter.searchExpression || ""); + }, + } ); const [ @@ -111,6 +120,15 @@ function Search(): JSX.Element { const [filterOpen, setFilterOpen] = useState(false); const handleSubmit = (e: React.SyntheticEvent) => { + setFilterMutate({ + variables: { + filter: { + ...withoutTypename(filter?.httpRequestLogFilter), + searchExpression: searchExpr, + }, + }, + }); + setFilterOpen(false); e.preventDefault(); }; @@ -142,10 +160,9 @@ function Search(): JSX.Element { className={classes.iconButton} onClick={() => setFilterOpen(!filterOpen)} style={{ - color: - filter?.httpRequestLogFilter !== null - ? theme.palette.secondary.main - : "inherit", + color: filter?.httpRequestLogFilter?.onlyInScope + ? theme.palette.secondary.main + : "inherit", }} > {filterLoading || setFilterLoading ? ( @@ -161,6 +178,8 @@ function Search(): JSX.Element { setSearchExpr(e.target.value)} onFocus={() => setFilterOpen(true)} /> diff --git a/pkg/api/generated.go b/pkg/api/generated.go index 6da8e9d..803a532 100644 --- a/pkg/api/generated.go +++ b/pkg/api/generated.go @@ -72,7 +72,8 @@ type ComplexityRoot struct { } HTTPRequestLogFilter struct { - OnlyInScope func(childComplexity int) int + OnlyInScope func(childComplexity int) int + SearchExpression func(childComplexity int) int } HTTPResponseLog struct { @@ -249,6 +250,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.HTTPRequestLogFilter.OnlyInScope(childComplexity), true + case "HttpRequestLogFilter.searchExpression": + if e.complexity.HTTPRequestLogFilter.SearchExpression == nil { + break + } + + return e.complexity.HTTPRequestLogFilter.SearchExpression(childComplexity), true + case "HttpResponseLog.body": if e.complexity.HTTPResponseLog.Body == nil { break @@ -579,10 +587,12 @@ type ClearHTTPRequestLogResult { input HttpRequestLogFilterInput { onlyInScope: Boolean + searchExpression: String } type HttpRequestLogFilter { onlyInScope: Boolean! + searchExpression: String } type Query { @@ -1239,6 +1249,38 @@ func (ec *executionContext) _HttpRequestLogFilter_onlyInScope(ctx context.Contex return ec.marshalNBoolean2bool(ctx, field.Selections, res) } +func (ec *executionContext) _HttpRequestLogFilter_searchExpression(ctx context.Context, field graphql.CollectedField, obj *HTTPRequestLogFilter) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "HttpRequestLogFilter", + 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.SearchExpression, 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) _HttpResponseLog_requestId(ctx context.Context, field graphql.CollectedField, obj *HTTPResponseLog) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -3288,6 +3330,14 @@ func (ec *executionContext) unmarshalInputHttpRequestLogFilterInput(ctx context. if err != nil { return it, err } + case "searchExpression": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("searchExpression")) + it.SearchExpression, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } } } @@ -3551,6 +3601,8 @@ func (ec *executionContext) _HttpRequestLogFilter(ctx context.Context, sel ast.S if out.Values[i] == graphql.Null { invalids++ } + case "searchExpression": + out.Values[i] = ec._HttpRequestLogFilter_searchExpression(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/pkg/api/models_gen.go b/pkg/api/models_gen.go index 61a695b..05ccc3c 100644 --- a/pkg/api/models_gen.go +++ b/pkg/api/models_gen.go @@ -38,11 +38,13 @@ type HTTPRequestLog struct { } type HTTPRequestLogFilter struct { - OnlyInScope bool `json:"onlyInScope"` + OnlyInScope bool `json:"onlyInScope"` + SearchExpression *string `json:"searchExpression"` } type HTTPRequestLogFilterInput struct { - OnlyInScope *bool `json:"onlyInScope"` + OnlyInScope *bool `json:"onlyInScope"` + SearchExpression *string `json:"searchExpression"` } type HTTPResponseLog struct { diff --git a/pkg/api/resolvers.go b/pkg/api/resolvers.go index eeec37b..ad04c3d 100644 --- a/pkg/api/resolvers.go +++ b/pkg/api/resolvers.go @@ -12,6 +12,7 @@ import ( "github.com/dstotijn/hetty/pkg/proj" "github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/scope" + "github.com/dstotijn/hetty/pkg/search" "github.com/vektah/gqlparser/v2/gqlerror" ) @@ -263,15 +264,14 @@ func (r *mutationResolver) SetHTTPRequestLogFilter( ctx context.Context, input *HTTPRequestLogFilterInput, ) (*HTTPRequestLogFilter, error) { - filter := findRequestsFilterFromInput(input) + filter, err := findRequestsFilterFromInput(input) + if err != nil { + return nil, fmt.Errorf("could not parse request log filter: %v", err) + } if err := r.RequestLogService.SetRequestLogFilter(ctx, filter); err != nil { return nil, fmt.Errorf("could not set request log filter: %v", err) } - empty := reqlog.FindRequestsFilter{} - if filter == empty { - return nil, nil - } return findReqFilterToHTTPReqLogFilter(filter), nil } @@ -297,13 +297,21 @@ func scopeToScopeRules(rules []scope.Rule) []ScopeRule { return scopeRules } -func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (filter reqlog.FindRequestsFilter) { +func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (filter reqlog.FindRequestsFilter, err error) { if input == nil { return } if input.OnlyInScope != nil { filter.OnlyInScope = *input.OnlyInScope } + if input.SearchExpression != nil && *input.SearchExpression != "" { + expr, err := search.ParseQuery(*input.SearchExpression) + if err != nil { + return reqlog.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %v", err) + } + filter.RawSearchExpr = *input.SearchExpression + filter.SearchExpr = expr + } return } @@ -317,5 +325,9 @@ func findReqFilterToHTTPReqLogFilter(findReqFilter reqlog.FindRequestsFilter) *H OnlyInScope: findReqFilter.OnlyInScope, } + if findReqFilter.RawSearchExpr != "" { + httpReqLogFilter.SearchExpression = &findReqFilter.RawSearchExpr + } + return httpReqLogFilter } diff --git a/pkg/api/schema.graphql b/pkg/api/schema.graphql index 53a3757..0911194 100644 --- a/pkg/api/schema.graphql +++ b/pkg/api/schema.graphql @@ -64,10 +64,12 @@ type ClearHTTPRequestLogResult { input HttpRequestLogFilterInput { onlyInScope: Boolean + searchExpression: String } type HttpRequestLogFilter { onlyInScope: Boolean! + searchExpression: String } type Query { diff --git a/pkg/db/sqlite/search.go b/pkg/db/sqlite/search.go new file mode 100644 index 0000000..e1ca4f1 --- /dev/null +++ b/pkg/db/sqlite/search.go @@ -0,0 +1,108 @@ +package sqlite + +import ( + "errors" + "fmt" + + sq "github.com/Masterminds/squirrel" + "github.com/dstotijn/hetty/pkg/search" +) + +var stringLiteralMap = map[string]string{ + // http_requests + "req.id": "req.id", + "req.proto": "req.proto", + "req.url": "req.url", + "req.method": "req.method", + "req.body": "req.body", + "req.timestamp": "req.timestamp", + // http_responses + "res.id": "res.id", + "res.proto": "res.proto", + "res.statusCode": "res.status_code", + "res.statusReason": "res.status_reason", + "res.body": "res.body", + "res.timestamp": "res.timestamp", + // TODO: http_headers +} + +func parseSearchExpr(expr search.Expression) (sq.Sqlizer, error) { + switch e := expr.(type) { + case *search.PrefixExpression: + return parsePrefixExpr(e) + case *search.InfixExpression: + return parseInfixExpr(e) + default: + return nil, fmt.Errorf("expression type (%v) not supported", expr) + } +} + +func parsePrefixExpr(expr *search.PrefixExpression) (sq.Sqlizer, error) { + switch expr.Operator { + case search.TokOpNot: + // TODO: Find a way to prefix an `sq.Sqlizer` with "NOT". + return nil, errors.New("not implemented") + default: + return nil, errors.New("operator is not supported") + } +} + +func parseInfixExpr(expr *search.InfixExpression) (sq.Sqlizer, error) { + switch expr.Operator { + case search.TokOpAnd: + left, err := parseSearchExpr(expr.Left) + if err != nil { + return nil, err + } + right, err := parseSearchExpr(expr.Right) + if err != nil { + return nil, err + } + return sq.And{left, right}, nil + case search.TokOpOr: + left, err := parseSearchExpr(expr.Left) + if err != nil { + return nil, err + } + right, err := parseSearchExpr(expr.Right) + if err != nil { + return nil, err + } + return sq.Or{left, right}, nil + } + + left, ok := expr.Left.(*search.StringLiteral) + if !ok { + return nil, errors.New("left operand must be a string literal") + } + right, ok := expr.Right.(*search.StringLiteral) + if !ok { + return nil, errors.New("right operand must be a string literal") + } + + mappedLeft, ok := stringLiteralMap[left.Value] + if !ok { + return nil, fmt.Errorf("invalid string literal: %v", left) + } + + switch expr.Operator { + case search.TokOpEq: + return sq.Eq{mappedLeft: right.Value}, nil + case search.TokOpNotEq: + return sq.NotEq{mappedLeft: right.Value}, nil + case search.TokOpGt: + return sq.Gt{mappedLeft: right.Value}, nil + case search.TokOpLt: + return sq.Lt{mappedLeft: right.Value}, nil + case search.TokOpGtEq: + return sq.GtOrEq{mappedLeft: right.Value}, nil + case search.TokOpLtEq: + return sq.LtOrEq{mappedLeft: right.Value}, nil + case search.TokOpRe: + return sq.Expr(fmt.Sprintf("regexp(?, %v)", mappedLeft), right.Value), nil + case search.TokOpNotRe: + return sq.Expr(fmt.Sprintf("NOT regexp(?, %v)", mappedLeft), right.Value), nil + default: + return nil, errors.New("unsupported operator") + } +} diff --git a/pkg/db/sqlite/search_test.go b/pkg/db/sqlite/search_test.go new file mode 100644 index 0000000..1770b0c --- /dev/null +++ b/pkg/db/sqlite/search_test.go @@ -0,0 +1,197 @@ +package sqlite + +import ( + "reflect" + "testing" + + sq "github.com/Masterminds/squirrel" + + "github.com/dstotijn/hetty/pkg/search" +) + +func TestParseSearchExpr(t *testing.T) { + tests := []struct { + name string + searchExpr search.Expression + expectedSqlizer sq.Sqlizer + expectedError error + }{ + { + name: "req.body = bar", + searchExpr: &search.InfixExpression{ + Operator: search.TokOpEq, + Left: &search.StringLiteral{Value: "req.body"}, + Right: &search.StringLiteral{Value: "bar"}, + }, + expectedSqlizer: sq.Eq{"req.body": "bar"}, + expectedError: nil, + }, + { + name: "req.body != bar", + searchExpr: &search.InfixExpression{ + Operator: search.TokOpNotEq, + Left: &search.StringLiteral{Value: "req.body"}, + Right: &search.StringLiteral{Value: "bar"}, + }, + expectedSqlizer: sq.NotEq{"req.body": "bar"}, + expectedError: nil, + }, + { + name: "req.body > bar", + searchExpr: &search.InfixExpression{ + Operator: search.TokOpGt, + Left: &search.StringLiteral{Value: "req.body"}, + Right: &search.StringLiteral{Value: "bar"}, + }, + expectedSqlizer: sq.Gt{"req.body": "bar"}, + expectedError: nil, + }, + { + name: "req.body < bar", + searchExpr: &search.InfixExpression{ + Operator: search.TokOpLt, + Left: &search.StringLiteral{Value: "req.body"}, + Right: &search.StringLiteral{Value: "bar"}, + }, + expectedSqlizer: sq.Lt{"req.body": "bar"}, + expectedError: nil, + }, + { + name: "req.body >= bar", + searchExpr: &search.InfixExpression{ + Operator: search.TokOpGtEq, + Left: &search.StringLiteral{Value: "req.body"}, + Right: &search.StringLiteral{Value: "bar"}, + }, + expectedSqlizer: sq.GtOrEq{"req.body": "bar"}, + expectedError: nil, + }, + { + name: "req.body <= bar", + searchExpr: &search.InfixExpression{ + Operator: search.TokOpLtEq, + Left: &search.StringLiteral{Value: "req.body"}, + Right: &search.StringLiteral{Value: "bar"}, + }, + expectedSqlizer: sq.LtOrEq{"req.body": "bar"}, + expectedError: nil, + }, + { + name: "req.body =~ bar", + searchExpr: &search.InfixExpression{ + Operator: search.TokOpRe, + Left: &search.StringLiteral{Value: "req.body"}, + Right: &search.StringLiteral{Value: "bar"}, + }, + expectedSqlizer: sq.Expr("regexp(?, req.body)", "bar"), + expectedError: nil, + }, + { + name: "req.body !~ bar", + searchExpr: &search.InfixExpression{ + Operator: search.TokOpNotRe, + Left: &search.StringLiteral{Value: "req.body"}, + Right: &search.StringLiteral{Value: "bar"}, + }, + expectedSqlizer: sq.Expr("NOT regexp(?, req.body)", "bar"), + expectedError: nil, + }, + { + name: "req.body = bar AND res.body = yolo", + searchExpr: &search.InfixExpression{ + Operator: search.TokOpAnd, + Left: &search.InfixExpression{ + Operator: search.TokOpEq, + Left: &search.StringLiteral{Value: "req.body"}, + Right: &search.StringLiteral{Value: "bar"}, + }, + Right: &search.InfixExpression{ + Operator: search.TokOpEq, + Left: &search.StringLiteral{Value: "res.body"}, + Right: &search.StringLiteral{Value: "yolo"}, + }, + }, + expectedSqlizer: sq.And{ + sq.Eq{"req.body": "bar"}, + sq.Eq{"res.body": "yolo"}, + }, + expectedError: nil, + }, + { + name: "req.body = bar AND res.body = yolo AND req.method = POST", + searchExpr: &search.InfixExpression{ + Operator: search.TokOpAnd, + Left: &search.InfixExpression{ + Operator: search.TokOpEq, + Left: &search.StringLiteral{Value: "req.body"}, + Right: &search.StringLiteral{Value: "bar"}, + }, + Right: &search.InfixExpression{ + Operator: search.TokOpAnd, + Left: &search.InfixExpression{ + Operator: search.TokOpEq, + Left: &search.StringLiteral{Value: "res.body"}, + Right: &search.StringLiteral{Value: "yolo"}, + }, + Right: &search.InfixExpression{ + Operator: search.TokOpEq, + Left: &search.StringLiteral{Value: "req.method"}, + Right: &search.StringLiteral{Value: "POST"}, + }, + }, + }, + expectedSqlizer: sq.And{ + sq.Eq{"req.body": "bar"}, + sq.And{ + sq.Eq{"res.body": "yolo"}, + sq.Eq{"req.method": "POST"}, + }, + }, + expectedError: nil, + }, + { + name: "req.body = bar OR res.body = yolo", + searchExpr: &search.InfixExpression{ + Operator: search.TokOpOr, + Left: &search.InfixExpression{ + Operator: search.TokOpEq, + Left: &search.StringLiteral{Value: "req.body"}, + Right: &search.StringLiteral{Value: "bar"}, + }, + Right: &search.InfixExpression{ + Operator: search.TokOpEq, + Left: &search.StringLiteral{Value: "res.body"}, + Right: &search.StringLiteral{Value: "yolo"}, + }, + }, + expectedSqlizer: sq.Or{ + sq.Eq{"req.body": "bar"}, + sq.Eq{"res.body": "yolo"}, + }, + expectedError: nil, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parseSearchExpr(tt.searchExpr) + assertError(t, tt.expectedError, err) + if !reflect.DeepEqual(tt.expectedSqlizer, got) { + t.Errorf("expected: %#v, got: %#v", tt.expectedSqlizer, got) + } + }) + } +} + +func assertError(t *testing.T, exp, got error) { + switch { + case exp == nil && got != nil: + t.Fatalf("expected: nil, got: %v", got) + case exp != nil && got == nil: + t.Fatalf("expected: %v, got: nil", exp.Error()) + case exp != nil && got != nil && exp.Error() != got.Error(): + t.Fatalf("expected: %v, got: %v", exp.Error(), got.Error()) + } +} diff --git a/pkg/db/sqlite/sqlite.go b/pkg/db/sqlite/sqlite.go index d3fa3ef..b6a7827 100644 --- a/pkg/db/sqlite/sqlite.go +++ b/pkg/db/sqlite/sqlite.go @@ -30,7 +30,7 @@ var regexpFn = func(pattern string, value interface{}) (bool, error) { case string: return regexp.MatchString(pattern, v) case int64: - return regexp.MatchString(pattern, string(v)) + return regexp.MatchString(pattern, fmt.Sprintf("%v", v)) case []byte: return regexp.Match(pattern, v) default: @@ -257,6 +257,14 @@ func (c *Client) FindRequestLogs( } } + if filter.SearchExpr != nil { + sqlizer, err := parseSearchExpr(filter.SearchExpr) + if err != nil { + return nil, fmt.Errorf("sqlite: could not parse search expression: %v", err) + } + reqQuery = reqQuery.Where(sqlizer) + } + sql, args, err := reqQuery.ToSql() if err != nil { return nil, fmt.Errorf("sqlite: could not parse query: %v", err) diff --git a/pkg/reqlog/reqlog.go b/pkg/reqlog/reqlog.go index 8866e15..7d933a7 100644 --- a/pkg/reqlog/reqlog.go +++ b/pkg/reqlog/reqlog.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/gzip" "context" + "encoding/json" "errors" "fmt" "io/ioutil" @@ -14,6 +15,7 @@ import ( "github.com/dstotijn/hetty/pkg/proj" "github.com/dstotijn/hetty/pkg/proxy" "github.com/dstotijn/hetty/pkg/scope" + "github.com/dstotijn/hetty/pkg/search" ) type contextKey int @@ -51,7 +53,9 @@ type Service struct { } type FindRequestsFilter struct { - OnlyInScope bool + OnlyInScope bool + SearchExpr search.Expression `json:"-"` + RawSearchExpr string } type Config struct { @@ -210,6 +214,34 @@ func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon } } +// UnmarshalJSON implements json.Unmarshaler. +func (f *FindRequestsFilter) UnmarshalJSON(b []byte) error { + var dto struct { + OnlyInScope bool + RawSearchExpr string + } + if err := json.Unmarshal(b, &dto); err != nil { + return err + } + + filter := FindRequestsFilter{ + OnlyInScope: dto.OnlyInScope, + RawSearchExpr: dto.RawSearchExpr, + } + + if dto.RawSearchExpr != "" { + expr, err := search.ParseQuery(dto.RawSearchExpr) + if err != nil { + return err + } + filter.SearchExpr = expr + } + + *f = filter + + return nil +} + func (svc *Service) loadSettings() error { return svc.repo.FindSettingsByModule(context.Background(), moduleName, svc) } diff --git a/pkg/search/parser.go b/pkg/search/parser.go index 92f4d29..96e73f5 100644 --- a/pkg/search/parser.go +++ b/pkg/search/parser.go @@ -9,10 +9,10 @@ type precedence int const ( _ precedence = iota precLowest - precEq precAnd precOr precNot + precEq precLessGreater precPrefix precGroup @@ -86,7 +86,7 @@ func ParseQuery(input string) (expr Expression, err error) { p.nextToken() if p.curTokenIs(TokEOF) { - return nil, fmt.Errorf("unexpected EOF") + return nil, fmt.Errorf("search: unexpected EOF") } for !p.curTokenIs(TokEOF) { diff --git a/pkg/search/parser_test.go b/pkg/search/parser_test.go index 3915c5e..0a8eab2 100644 --- a/pkg/search/parser_test.go +++ b/pkg/search/parser_test.go @@ -17,7 +17,7 @@ func TestParseQuery(t *testing.T) { name: "empty query", input: "", expectedExpression: nil, - expectedError: errors.New("unexpected EOF"), + expectedError: errors.New("search: unexpected EOF"), }, { name: "string literal expression", @@ -199,6 +199,24 @@ func TestParseQuery(t *testing.T) { }, expectedError: nil, }, + { + name: "eq operator takes precedence over boolean ops", + input: "foo=bar OR baz=yolo", + expectedExpression: &InfixExpression{ + Operator: TokOpOr, + Left: &InfixExpression{ + Operator: TokOpEq, + Left: &StringLiteral{Value: "foo"}, + Right: &StringLiteral{Value: "bar"}, + }, + Right: &InfixExpression{ + Operator: TokOpEq, + Left: &StringLiteral{Value: "baz"}, + Right: &StringLiteral{Value: "yolo"}, + }, + }, + expectedError: nil, + }, } for _, tt := range tests {