Add search expression support to admin interface

This commit is contained in:
David Stotijn 2020-12-21 12:50:09 +01:00
parent 8ab65fb55f
commit 194d727f4f
11 changed files with 469 additions and 19 deletions

View file

@ -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 {
<InputBase
className={classes.input}
placeholder="Search proxy logs…"
value={searchExpr}
onChange={(e) => setSearchExpr(e.target.value)}
onFocus={() => setFilterOpen(true)}
/>
<Tooltip title="Search">

View file

@ -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))
}

View file

@ -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 {

View file

@ -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
}

View file

@ -64,10 +64,12 @@ type ClearHTTPRequestLogResult {
input HttpRequestLogFilterInput {
onlyInScope: Boolean
searchExpression: String
}
type HttpRequestLogFilter {
onlyInScope: Boolean!
searchExpression: String
}
type Query {

108
pkg/db/sqlite/search.go Normal file
View file

@ -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")
}
}

View file

@ -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())
}
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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) {

View file

@ -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 {