Compare commits

...

8 Commits

Author SHA1 Message Date
644d1132bb Bump terser from 5.12.1 to 5.14.2 in /admin
Bumps [terser](https://github.com/terser/terser) from 5.12.1 to 5.14.2.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-21 00:54:08 +00:00
f7def87d0f Add HTTP header support to string literal matching 2022-03-31 15:23:56 +02:00
aa9822854d Rename search package to filter package 2022-03-31 15:12:54 +02:00
2ce4218a30 Add filter support for HTTP headers 2022-03-31 14:53:40 +02:00
fd27955e11 Sort HTTP headers 2022-03-31 12:07:35 +02:00
426a7d5f96 Add "New request" button to Sender page 2022-03-31 11:23:17 +02:00
21b679dc91 Add new sponsorship options 2022-03-30 13:02:58 +02:00
e4f468d4d2 Publish Docker image to ghcr.io and docker.io 2022-03-30 11:50:16 +02:00
31 changed files with 704 additions and 402 deletions

4
.github/FUNDING.yml vendored
View File

@ -1,3 +1,3 @@
# These are supported funding model platforms
github: dstotijn
patreon: dstotijn
custom: "https://www.paypal.com/paypalme/dstotijn"

View File

@ -35,7 +35,7 @@ linters-settings:
godot:
capital: true
ireturn:
allow: "error,empty,anon,stdlib,.*(or|er)$,github.com/99designs/gqlgen/graphql.Marshaler,github.com/dstotijn/hetty/pkg/api.QueryResolver,github.com/dstotijn/hetty/pkg/search.Expression"
allow: "error,empty,anon,stdlib,.*(or|er)$,github.com/99designs/gqlgen/graphql.Marshaler,github.com/dstotijn/hetty/pkg/api.QueryResolver,github.com/dstotijn/hetty/pkg/filter.Expression"
issues:
exclude-rules:

View File

@ -69,6 +69,31 @@ scoop:
description: An HTTP toolkit for security research.
license: MIT
dockers:
- extra_files:
- go.mod
- go.sum
- pkg
- cmd
- admin
image_templates:
- "ghcr.io/dstotijn/hetty:{{ .Version }}"
- "ghcr.io/dstotijn/hetty:{{ .Major }}"
- "ghcr.io/dstotijn/hetty:{{ .Major }}.{{ .Minor }}"
- "ghcr.io/dstotijn/hetty:latest"
- "dstotijn/hetty:{{ .Version }}"
- "dstotijn/hetty:{{ .Major }}"
- "dstotijn/hetty:{{ .Major }}.{{ .Minor }}"
- "dstotijn/hetty:latest"
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.source=https://github.com/dstotijn/hetty"
- "--build-arg=HETTY_VERSION={{.Version}}"
checksum:
name_template: "checksums.txt"

View File

@ -64,8 +64,18 @@ Alternatively, you can [download the latest release from
GitHub](https://github.com/dstotijn/hetty/releases/latest) for your OS and
architecture, and move the binary to a directory in your `$PATH`. If your OS is
not available for one of the package managers or not listed in the GitHub
releases, you can compile from source _(link coming soon)_ or use a Docker image
_(link coming soon)_.
releases, you can compile from source _(link coming soon)_.
#### Docker
Docker images are distributed via [GitHub's Container registry](https://github.com/dstotijn/hetty/pkgs/container/hetty)
and [Docker Hub](https://hub.docker.com/r/dstotijn/hetty). To run Hetty via with a volume for database and certificate
storage, and port 8080 forwarded:
```
docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 \
ghcr.io/dstotijn/hetty:latest
```
### Usage
@ -136,9 +146,11 @@ Guidelines](CONTRIBUTING.md) for details.
## Sponsors
<a href="https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty">
<p><a href="https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty">
<img src="https://hetty.xyz/img/tines-sponsorship-badge.png" width="140" alt="Sponsored by Tines">
</a>
</a></p>
💖 Are you enjoying Hetty? You can [sponsor me](https://github.com/sponsors/dstotijn)!
## License

View File

@ -7,7 +7,7 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
import { KeyValuePair } from "lib/components/KeyValuePair";
import Link from "lib/components/Link";
import RequestTabs from "lib/components/RequestTabs";
import ResponseStatus from "lib/components/ResponseStatus";
@ -112,11 +112,11 @@ function EditRequest(): JSX.Element {
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
const newReqHeaders = sortKeyValuePairs(interceptedRequest.headers || []);
const newReqHeaders = interceptedRequest.headers || [];
setReqHeaders([...newReqHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
setResBody(interceptedRequest.response?.body || "");
const newResHeaders = sortKeyValuePairs(interceptedRequest.response?.headers || []);
const newResHeaders = interceptedRequest.response?.headers || [];
setResHeaders([...newResHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
},
});

View File

@ -76,6 +76,7 @@ function Search(): JSX.Element {
<ClickAwayListener onClickAway={handleClickAway}>
<Paper
component="form"
autoComplete="off"
onSubmit={handleSubmit}
ref={filterRef}
sx={{
@ -109,6 +110,8 @@ function Search(): JSX.Element {
value={searchExpr}
onChange={(e) => setSearchExpr(e.target.value)}
onFocus={() => setFilterOpen(true)}
autoCorrect="false"
spellCheck="false"
/>
<Tooltip title="Search">
<IconButton type="submit" sx={{ padding: 1.25 }}>

View File

@ -1,8 +1,9 @@
import { Alert, Box, Button, Typography } from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import { Alert, Box, Button, Fab, Tooltip, Typography, useTheme } from "@mui/material";
import { useRouter } from "next/router";
import React, { useState } from "react";
import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
import { KeyValuePair } from "lib/components/KeyValuePair";
import RequestTabs from "lib/components/RequestTabs";
import Response from "lib/components/Response";
import SplitPane from "lib/components/SplitPane";
@ -17,15 +18,21 @@ import { queryParamsFromURL } from "lib/queryParamsFromURL";
import updateKeyPairItem from "lib/updateKeyPairItem";
import updateURLQueryParams from "lib/updateURLQueryParams";
const defaultMethod = HttpMethod.Get;
const defaultProto = HttpProto.Http20;
const emptyKeyPair = [{ key: "", value: "" }];
function EditRequest(): JSX.Element {
const router = useRouter();
const reqId = router.query.id as string | undefined;
const [method, setMethod] = useState(HttpMethod.Get);
const theme = useTheme();
const [method, setMethod] = useState(defaultMethod);
const [url, setURL] = useState("");
const [proto, setProto] = useState(HttpProto.Http20);
const [queryParams, setQueryParams] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [headers, setHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [proto, setProto] = useState(defaultProto);
const [queryParams, setQueryParams] = useState<KeyValuePair[]>(emptyKeyPair);
const [headers, setHeaders] = useState<KeyValuePair[]>(emptyKeyPair);
const [body, setBody] = useState("");
const handleQueryParamChange = (key: string, value: string, idx: number) => {
@ -83,7 +90,7 @@ function EditRequest(): JSX.Element {
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
const newHeaders = sortKeyValuePairs(senderRequest.headers || []);
const newHeaders = senderRequest.headers || [];
setHeaders([...newHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
setResponse(senderRequest.response);
},
@ -131,8 +138,26 @@ function EditRequest(): JSX.Element {
createOrUpdateRequestAndSend();
};
const handleNewRequest = () => {
setURL("");
setMethod(defaultMethod);
setProto(defaultProto);
setQueryParams(emptyKeyPair);
setHeaders(emptyKeyPair);
setBody("");
setResponse(null);
router.push(`/sender`);
};
return (
<Box display="flex" flexDirection="column" height="100%" gap={2}>
<Box sx={{ position: "absolute", bottom: theme.spacing(2), right: theme.spacing(2) }}>
<Tooltip title="New request">
<Fab color="primary" onClick={handleNewRequest}>
<AddIcon />
</Fab>
</Tooltip>
</Box>
<Box component="form" autoComplete="off" onSubmit={handleFormSubmit}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<UrlBar

View File

@ -184,20 +184,4 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
);
}
export function sortKeyValuePairs(items: KeyValuePair[]): KeyValuePair[] {
const sorted = [...items];
sorted.sort((a, b) => {
if (a.key < b.key) {
return -1;
}
if (a.key > b.key) {
return 1;
}
return 0;
});
return sorted;
}
export default KeyValuePairTable;

View File

@ -1,6 +1,5 @@
import { Box, Typography } from "@mui/material";
import { sortKeyValuePairs } from "./KeyValuePair";
import ResponseTabs from "./ResponseTabs";
import ResponseStatus from "lib/components/ResponseStatus";
@ -29,7 +28,7 @@ function Response({ response }: ResponseProps): JSX.Element {
</Box>
<ResponseTabs
body={response?.body}
headers={sortKeyValuePairs(response?.headers || [])}
headers={response?.headers || []}
hasResponse={response !== undefined && response !== null}
/>
</Box>

View File

@ -1062,15 +1062,37 @@
resolved "https://registry.yarnpkg.com/@iarna/toml/-/toml-2.2.5.tgz#b32366c89b43c6f8cefbdefac778b9c828e3ba8c"
integrity sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==
"@jridgewell/gen-mapping@^0.3.0":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9"
integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==
dependencies:
"@jridgewell/set-array" "^1.0.1"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/trace-mapping" "^0.3.9"
"@jridgewell/resolve-uri@^3.0.3":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz#68eb521368db76d040a6315cdb24bf2483037b9c"
integrity sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew==
version "3.1.0"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
"@jridgewell/set-array@^1.0.1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
"@jridgewell/source-map@^0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb"
integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==
dependencies:
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@jridgewell/sourcemap-codec@^1.4.10":
version "1.4.11"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz#771a1d8d744eeb71b6adb35808e1a6c7b9b8c8ec"
integrity sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==
version "1.4.14"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
"@jridgewell/trace-mapping@^0.3.0":
version "0.3.4"
@ -1080,6 +1102,14 @@
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/trace-mapping@^0.3.9":
version "0.3.14"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed"
integrity sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==
dependencies:
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@juggle/resize-observer@^3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
@ -1721,9 +1751,9 @@ acorn-jsx@^5.3.1:
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^8.4.1, acorn@^8.5.0, acorn@^8.7.0:
version "8.7.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf"
integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==
version "8.7.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
agent-base@6:
version "6.0.2"
@ -5204,11 +5234,6 @@ source-map@^0.6.0, source-map@^0.6.1:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
source-map@~0.7.2:
version "0.7.3"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
sponge-case@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/sponge-case/-/sponge-case-1.0.1.tgz#260833b86453883d974f84854cdb63aecc5aef4c"
@ -5431,13 +5456,13 @@ terser-webpack-plugin@^5.1.3:
terser "^5.7.2"
terser@^5.7.2:
version "5.12.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.12.1.tgz#4cf2ebed1f5bceef5c83b9f60104ac4a78b49e9c"
integrity sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==
version "5.14.2"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.14.2.tgz#9ac9f22b06994d736174f4091aa368db896f1c10"
integrity sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==
dependencies:
"@jridgewell/source-map" "^0.3.2"
acorn "^8.5.0"
commander "^2.20.0"
source-map "~0.7.2"
source-map-support "~0.5.20"
text-table@^0.2.0:

View File

@ -49,3 +49,17 @@ func UnmarshalURL(v interface{}) (*url.URL, error) {
return u, nil
}
type HTTPHeaders []HTTPHeader
func (h HTTPHeaders) Len() int {
return len(h)
}
func (h HTTPHeaders) Less(i, j int) bool {
return h[i].Key < h[j].Key
}
func (h HTTPHeaders) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}

View File

@ -11,18 +11,19 @@ import (
"io/ioutil"
"net/http"
"regexp"
"sort"
"strings"
"github.com/99designs/gqlgen/graphql"
"github.com/oklog/ulid"
"github.com/vektah/gqlparser/v2/gqlerror"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/proxy/intercept"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
"github.com/dstotijn/hetty/pkg/sender"
)
@ -124,6 +125,8 @@ func parseRequestLog(reqLog reqlog.RequestLog) (HTTPRequestLog, error) {
})
}
}
sort.Sort(HTTPHeaders(log.Headers))
}
if reqLog.Response != nil {
@ -172,6 +175,8 @@ func parseResponseLog(resLog reqlog.ResponseLog) (HTTPResponseLog, error) {
})
}
}
sort.Sort(HTTPHeaders(httpResLog.Headers))
}
return httpResLog, nil
@ -634,7 +639,7 @@ func (r *mutationResolver) UpdateInterceptSettings(
}
if input.RequestFilter != nil && *input.RequestFilter != "" {
expr, err := search.ParseQuery(*input.RequestFilter)
expr, err := filter.ParseQuery(*input.RequestFilter)
if err != nil {
return nil, fmt.Errorf("could not parse request filter: %w", err)
}
@ -643,7 +648,7 @@ func (r *mutationResolver) UpdateInterceptSettings(
}
if input.ResponseFilter != nil && *input.ResponseFilter != "" {
expr, err := search.ParseQuery(*input.ResponseFilter)
expr, err := filter.ParseQuery(*input.ResponseFilter)
if err != nil {
return nil, fmt.Errorf("could not parse response filter: %w", err)
}
@ -710,6 +715,8 @@ func parseSenderRequest(req sender.Request) (SenderRequest, error) {
})
}
}
sort.Sort(HTTPHeaders(senderReq.Headers))
}
if len(req.Body) > 0 {
@ -765,6 +772,8 @@ func parseHTTPRequest(req *http.Request) (HTTPRequest, error) {
})
}
}
sort.Sort(HTTPHeaders(httpReq.Headers))
}
if req.Body != nil {
@ -815,6 +824,8 @@ func parseHTTPResponse(res *http.Response) (HTTPResponse, error) {
})
}
}
sort.Sort(HTTPHeaders(httpRes.Headers))
}
if res.Body != nil {
@ -905,43 +916,43 @@ func scopeToScopeRules(rules []scope.Rule) []ScopeRule {
return scopeRules
}
func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (filter reqlog.FindRequestsFilter, err error) {
func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (findFilter reqlog.FindRequestsFilter, err error) {
if input == nil {
return
}
if input.OnlyInScope != nil {
filter.OnlyInScope = *input.OnlyInScope
findFilter.OnlyInScope = *input.OnlyInScope
}
if input.SearchExpression != nil && *input.SearchExpression != "" {
expr, err := search.ParseQuery(*input.SearchExpression)
expr, err := filter.ParseQuery(*input.SearchExpression)
if err != nil {
return reqlog.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %w", err)
}
filter.SearchExpr = expr
findFilter.SearchExpr = expr
}
return
}
func findSenderRequestsFilterFromInput(input *SenderRequestFilterInput) (filter sender.FindRequestsFilter, err error) {
func findSenderRequestsFilterFromInput(input *SenderRequestFilterInput) (findFilter sender.FindRequestsFilter, err error) {
if input == nil {
return
}
if input.OnlyInScope != nil {
filter.OnlyInScope = *input.OnlyInScope
findFilter.OnlyInScope = *input.OnlyInScope
}
if input.SearchExpression != nil && *input.SearchExpression != "" {
expr, err := search.ParseQuery(*input.SearchExpression)
expr, err := filter.ParseQuery(*input.SearchExpression)
if err != nil {
return sender.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %w", err)
}
filter.SearchExpr = expr
findFilter.SearchExpr = expr
}
return

View File

@ -15,9 +15,9 @@ import (
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
)
//nolint:gosec
@ -45,7 +45,7 @@ func TestUpsertProject(t *testing.T) {
database := DatabaseFromBadgerDB(badgerDB)
defer database.Close()
searchExpr, err := search.ParseQuery("foo AND bar OR NOT baz")
searchExpr, err := filter.ParseQuery("foo AND bar OR NOT baz")
if err != nil {
t.Fatalf("unexpected error (expected: nil, got: %v)", err)
}

View File

@ -1,4 +1,4 @@
package search
package filter
import (
"encoding/gob"
@ -78,8 +78,10 @@ func (rl *RegexpLiteral) UnmarshalBinary(data []byte) error {
}
func init() {
gob.Register(PrefixExpression{})
gob.Register(InfixExpression{})
gob.Register(StringLiteral{})
gob.Register(RegexpLiteral{})
// The `filter` package was previously named `search`.
// We use the legacy names for backwards compatibility with existing database data.
gob.RegisterName("github.com/dstotijn/hetty/pkg/search.PrefixExpression", PrefixExpression{})
gob.RegisterName("github.com/dstotijn/hetty/pkg/search.InfixExpression", InfixExpression{})
gob.RegisterName("github.com/dstotijn/hetty/pkg/search.StringLiteral", StringLiteral{})
gob.RegisterName("github.com/dstotijn/hetty/pkg/search.RegexpLiteral", RegexpLiteral{})
}

212
pkg/filter/ast_test.go Normal file
View File

@ -0,0 +1,212 @@
package filter_test
import (
"regexp"
"testing"
"github.com/dstotijn/hetty/pkg/filter"
)
func TestExpressionString(t *testing.T) {
t.Parallel()
tests := []struct {
name string
expression filter.Expression
expected string
}{
{
name: "string literal expression",
expression: filter.StringLiteral{Value: "foobar"},
expected: `"foobar"`,
},
{
name: "boolean expression with equal operator",
expression: filter.InfixExpression{
Operator: filter.TokOpEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" = "bar")`,
},
{
name: "boolean expression with not equal operator",
expression: filter.InfixExpression{
Operator: filter.TokOpNotEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" != "bar")`,
},
{
name: "boolean expression with greater than operator",
expression: filter.InfixExpression{
Operator: filter.TokOpGt,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" > "bar")`,
},
{
name: "boolean expression with less than operator",
expression: filter.InfixExpression{
Operator: filter.TokOpLt,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" < "bar")`,
},
{
name: "boolean expression with greater than or equal operator",
expression: filter.InfixExpression{
Operator: filter.TokOpGtEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" >= "bar")`,
},
{
name: "boolean expression with less than or equal operator",
expression: filter.InfixExpression{
Operator: filter.TokOpLtEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" <= "bar")`,
},
{
name: "boolean expression with regular expression operator",
expression: filter.InfixExpression{
Operator: filter.TokOpRe,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.RegexpLiteral{regexp.MustCompile("bar")},
},
expected: `("foo" =~ "bar")`,
},
{
name: "boolean expression with not regular expression operator",
expression: filter.InfixExpression{
Operator: filter.TokOpNotRe,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.RegexpLiteral{regexp.MustCompile("bar")},
},
expected: `("foo" !~ "bar")`,
},
{
name: "boolean expression with AND, OR and NOT operators",
expression: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.InfixExpression{
Operator: filter.TokOpOr,
Left: filter.StringLiteral{Value: "bar"},
Right: filter.PrefixExpression{
Operator: filter.TokOpNot,
Right: filter.StringLiteral{Value: "baz"},
},
},
},
expected: `("foo" AND ("bar" OR (NOT "baz")))`,
},
{
name: "boolean expression with nested group",
expression: filter.InfixExpression{
Operator: filter.TokOpOr,
Left: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
Right: filter.PrefixExpression{
Operator: filter.TokOpNot,
Right: filter.StringLiteral{Value: "baz"},
},
},
expected: `(("foo" AND "bar") OR (NOT "baz"))`,
},
{
name: "implicit boolean expression with string literal operands",
expression: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
Right: filter.StringLiteral{Value: "baz"},
},
expected: `(("foo" AND "bar") AND "baz")`,
},
{
name: "implicit boolean expression nested in group",
expression: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" AND "bar")`,
},
{
name: "implicit and explicit boolean expression with string literal operands",
expression: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.InfixExpression{
Operator: filter.TokOpOr,
Left: filter.StringLiteral{Value: "bar"},
Right: filter.StringLiteral{Value: "baz"},
},
},
Right: filter.StringLiteral{Value: "yolo"},
},
expected: `(("foo" AND ("bar" OR "baz")) AND "yolo")`,
},
{
name: "implicit boolean expression with comparison operands",
expression: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.InfixExpression{
Operator: filter.TokOpEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
Right: filter.InfixExpression{
Operator: filter.TokOpRe,
Left: filter.StringLiteral{Value: "baz"},
Right: filter.RegexpLiteral{regexp.MustCompile("yolo")},
},
},
expected: `(("foo" = "bar") AND ("baz" =~ "yolo"))`,
},
{
name: "eq operator takes precedence over boolean ops",
expression: filter.InfixExpression{
Operator: filter.TokOpOr,
Left: filter.InfixExpression{
Operator: filter.TokOpEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
Right: filter.InfixExpression{
Operator: filter.TokOpEq,
Left: filter.StringLiteral{Value: "baz"},
Right: filter.StringLiteral{Value: "yolo"},
},
},
expected: `(("foo" = "bar") OR ("baz" = "yolo"))`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.expression.String()
if tt.expected != got {
t.Errorf("expected: %v, got: %v", tt.expected, got)
}
})
}
}

82
pkg/filter/http.go Normal file
View File

@ -0,0 +1,82 @@
package filter
import (
"errors"
"fmt"
"net/http"
)
func MatchHTTPHeaders(op TokenType, expr Expression, headers http.Header) (bool, error) {
if headers == nil {
return false, nil
}
switch op {
case TokOpEq:
strLiteral, ok := expr.(StringLiteral)
if !ok {
return false, errors.New("filter: expression must be a string literal")
}
// Return `true` if at least one header (<key>: <value>) is equal to the string literal.
for key, values := range headers {
for _, value := range values {
if strLiteral.Value == fmt.Sprintf("%v: %v", key, value) {
return true, nil
}
}
}
return false, nil
case TokOpNotEq:
strLiteral, ok := expr.(StringLiteral)
if !ok {
return false, errors.New("filter: expression must be a string literal")
}
// Return `true` if none of the headers (<key>: <value>) are equal to the string literal.
for key, values := range headers {
for _, value := range values {
if strLiteral.Value == fmt.Sprintf("%v: %v", key, value) {
return false, nil
}
}
}
return true, nil
case TokOpRe:
re, ok := expr.(RegexpLiteral)
if !ok {
return false, errors.New("filter: expression must be a regular expression")
}
// Return `true` if at least one header (<key>: <value>) matches the regular expression.
for key, values := range headers {
for _, value := range values {
if re.MatchString(fmt.Sprintf("%v: %v", key, value)) {
return true, nil
}
}
}
return false, nil
case TokOpNotRe:
re, ok := expr.(RegexpLiteral)
if !ok {
return false, errors.New("filter: expression must be a regular expression")
}
// Return `true` if none of the headers (<key>: <value>) match the regular expression.
for key, values := range headers {
for _, value := range values {
if re.MatchString(fmt.Sprintf("%v: %v", key, value)) {
return false, nil
}
}
}
return true, nil
default:
return false, fmt.Errorf("filter: unsupported operator %q", op.String())
}
}

View File

@ -1,4 +1,4 @@
package search
package filter
import (
"fmt"

View File

@ -1,4 +1,4 @@
package search
package filter
import "testing"

View File

@ -1,4 +1,4 @@
package search
package filter
import (
"fmt"
@ -88,7 +88,7 @@ func ParseQuery(input string) (expr Expression, err error) {
p.nextToken()
if p.curTokenIs(TokEOF) {
return nil, fmt.Errorf("search: unexpected EOF")
return nil, fmt.Errorf("filter: unexpected EOF")
}
for !p.curTokenIs(TokEOF) {
@ -96,7 +96,7 @@ func ParseQuery(input string) (expr Expression, err error) {
switch {
case err != nil:
return nil, fmt.Errorf("search: could not parse expression: %w", err)
return nil, fmt.Errorf("filter: could not parse expression: %w", err)
case expr == nil:
expr = right
default:

View File

@ -1,4 +1,4 @@
package search
package filter
import (
"errors"
@ -20,7 +20,7 @@ func TestParseQuery(t *testing.T) {
name: "empty query",
input: "",
expectedExpression: nil,
expectedError: errors.New("search: unexpected EOF"),
expectedError: errors.New("filter: unexpected EOF"),
},
{
name: "string literal expression",

View File

@ -11,10 +11,10 @@ import (
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/proxy/intercept"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
"github.com/dstotijn/hetty/pkg/sender"
)
@ -59,17 +59,17 @@ type Settings struct {
// Request log settings
ReqLogBypassOutOfScope bool
ReqLogOnlyFindInScope bool
ReqLogSearchExpr search.Expression
ReqLogSearchExpr filter.Expression
// Intercept settings
InterceptRequests bool
InterceptResponses bool
InterceptRequestFilter search.Expression
InterceptResponseFilter search.Expression
InterceptRequestFilter filter.Expression
InterceptResponseFilter filter.Expression
// Sender settings
SenderOnlyFindInScope bool
SenderSearchExpr search.Expression
SenderSearchExpr filter.Expression
// Scope settings
ScopeRules []scope.Rule

View File

@ -10,8 +10,8 @@ import (
"strconv"
"strings"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
)
//nolint:unparam
@ -68,22 +68,22 @@ var resFilterKeyFns = map[string]func(res *http.Response) (string, error){
}
// 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 filter.Expression) (bool, error) {
switch e := expr.(type) {
case search.PrefixExpression:
case filter.PrefixExpression:
return matchReqPrefixExpr(req, e)
case search.InfixExpression:
case filter.InfixExpression:
return matchReqInfixExpr(req, e)
case search.StringLiteral:
case filter.StringLiteral:
return matchReqStringLiteral(req, e)
default:
return false, fmt.Errorf("expression type (%T) not supported", expr)
}
}
func matchReqPrefixExpr(req *http.Request, expr search.PrefixExpression) (bool, error) {
func matchReqPrefixExpr(req *http.Request, expr filter.PrefixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpNot:
case filter.TokOpNot:
match, err := MatchRequestFilter(req, expr.Right)
if err != nil {
return false, err
@ -95,9 +95,9 @@ func matchReqPrefixExpr(req *http.Request, expr search.PrefixExpression) (bool,
}
}
func matchReqInfixExpr(req *http.Request, expr search.InfixExpression) (bool, error) {
func matchReqInfixExpr(req *http.Request, expr filter.InfixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpAnd:
case filter.TokOpAnd:
left, err := MatchRequestFilter(req, expr.Left)
if err != nil {
return false, err
@ -109,7 +109,7 @@ func matchReqInfixExpr(req *http.Request, expr search.InfixExpression) (bool, er
}
return left && right, nil
case search.TokOpOr:
case filter.TokOpOr:
left, err := MatchRequestFilter(req, expr.Left)
if err != nil {
return false, err
@ -123,7 +123,7 @@ func matchReqInfixExpr(req *http.Request, expr search.InfixExpression) (bool, er
return left || right, nil
}
left, ok := expr.Left.(search.StringLiteral)
left, ok := expr.Left.(filter.StringLiteral)
if !ok {
return false, errors.New("left operand must be a string literal")
}
@ -133,21 +133,30 @@ func matchReqInfixExpr(req *http.Request, expr search.InfixExpression) (bool, er
return false, fmt.Errorf("failed to get string literal from request for left operand: %w", err)
}
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
right, ok := expr.Right.(search.RegexpLiteral)
if leftVal == "headers" {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, req.Header)
if err != nil {
return false, fmt.Errorf("failed to match request HTTP headers: %w", err)
}
return match, nil
}
if expr.Operator == filter.TokOpRe || expr.Operator == filter.TokOpNotRe {
right, ok := expr.Right.(filter.RegexpLiteral)
if !ok {
return false, errors.New("right operand must be a regular expression")
}
switch expr.Operator {
case search.TokOpRe:
case filter.TokOpRe:
return right.MatchString(leftVal), nil
case search.TokOpNotRe:
case filter.TokOpNotRe:
return !right.MatchString(leftVal), nil
}
}
right, ok := expr.Right.(search.StringLiteral)
right, ok := expr.Right.(filter.StringLiteral)
if !ok {
return false, errors.New("right operand must be a string literal")
}
@ -158,20 +167,20 @@ func matchReqInfixExpr(req *http.Request, expr search.InfixExpression) (bool, er
}
switch expr.Operator {
case search.TokOpEq:
case filter.TokOpEq:
return leftVal == rightVal, nil
case search.TokOpNotEq:
case filter.TokOpNotEq:
return leftVal != rightVal, nil
case search.TokOpGt:
case filter.TokOpGt:
// TODO(?) attempt to parse as int.
return leftVal > rightVal, nil
case search.TokOpLt:
case filter.TokOpLt:
// TODO(?) attempt to parse as int.
return leftVal < rightVal, nil
case search.TokOpGtEq:
case filter.TokOpGtEq:
// TODO(?) attempt to parse as int.
return leftVal >= rightVal, nil
case search.TokOpLtEq:
case filter.TokOpLtEq:
// TODO(?) attempt to parse as int.
return leftVal <= rightVal, nil
default:
@ -188,7 +197,18 @@ func getMappedStringLiteralFromReq(req *http.Request, s string) (string, error)
return s, nil
}
func matchReqStringLiteral(req *http.Request, strLiteral search.StringLiteral) (bool, error) {
func matchReqStringLiteral(req *http.Request, strLiteral filter.StringLiteral) (bool, error) {
for key, values := range req.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range reqFilterKeyFns {
value, err := fn(req)
if err != nil {
@ -259,22 +279,22 @@ func MatchRequestScope(req *http.Request, s *scope.Scope) (bool, error) {
}
// MatchResponseFilter returns true if an HTTP response matches the response filter expression.
func MatchResponseFilter(res *http.Response, expr search.Expression) (bool, error) {
func MatchResponseFilter(res *http.Response, expr filter.Expression) (bool, error) {
switch e := expr.(type) {
case search.PrefixExpression:
case filter.PrefixExpression:
return matchResPrefixExpr(res, e)
case search.InfixExpression:
case filter.InfixExpression:
return matchResInfixExpr(res, e)
case search.StringLiteral:
case filter.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) {
func matchResPrefixExpr(res *http.Response, expr filter.PrefixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpNot:
case filter.TokOpNot:
match, err := MatchResponseFilter(res, expr.Right)
if err != nil {
return false, err
@ -286,9 +306,9 @@ func matchResPrefixExpr(res *http.Response, expr search.PrefixExpression) (bool,
}
}
func matchResInfixExpr(res *http.Response, expr search.InfixExpression) (bool, error) {
func matchResInfixExpr(res *http.Response, expr filter.InfixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpAnd:
case filter.TokOpAnd:
left, err := MatchResponseFilter(res, expr.Left)
if err != nil {
return false, err
@ -300,7 +320,7 @@ func matchResInfixExpr(res *http.Response, expr search.InfixExpression) (bool, e
}
return left && right, nil
case search.TokOpOr:
case filter.TokOpOr:
left, err := MatchResponseFilter(res, expr.Left)
if err != nil {
return false, err
@ -314,7 +334,7 @@ func matchResInfixExpr(res *http.Response, expr search.InfixExpression) (bool, e
return left || right, nil
}
left, ok := expr.Left.(search.StringLiteral)
left, ok := expr.Left.(filter.StringLiteral)
if !ok {
return false, errors.New("left operand must be a string literal")
}
@ -324,21 +344,30 @@ func matchResInfixExpr(res *http.Response, expr search.InfixExpression) (bool, e
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 leftVal == "headers" {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, res.Header)
if err != nil {
return false, fmt.Errorf("failed to match request HTTP headers: %w", err)
}
return match, nil
}
if expr.Operator == filter.TokOpRe || expr.Operator == filter.TokOpNotRe {
right, ok := expr.Right.(filter.RegexpLiteral)
if !ok {
return false, errors.New("right operand must be a regular expression")
}
switch expr.Operator {
case search.TokOpRe:
case filter.TokOpRe:
return right.MatchString(leftVal), nil
case search.TokOpNotRe:
case filter.TokOpNotRe:
return !right.MatchString(leftVal), nil
}
}
right, ok := expr.Right.(search.StringLiteral)
right, ok := expr.Right.(filter.StringLiteral)
if !ok {
return false, errors.New("right operand must be a string literal")
}
@ -349,20 +378,20 @@ func matchResInfixExpr(res *http.Response, expr search.InfixExpression) (bool, e
}
switch expr.Operator {
case search.TokOpEq:
case filter.TokOpEq:
return leftVal == rightVal, nil
case search.TokOpNotEq:
case filter.TokOpNotEq:
return leftVal != rightVal, nil
case search.TokOpGt:
case filter.TokOpGt:
// TODO(?) attempt to parse as int.
return leftVal > rightVal, nil
case search.TokOpLt:
case filter.TokOpLt:
// TODO(?) attempt to parse as int.
return leftVal < rightVal, nil
case search.TokOpGtEq:
case filter.TokOpGtEq:
// TODO(?) attempt to parse as int.
return leftVal >= rightVal, nil
case search.TokOpLtEq:
case filter.TokOpLtEq:
// TODO(?) attempt to parse as int.
return leftVal <= rightVal, nil
default:
@ -379,7 +408,18 @@ func getMappedStringLiteralFromRes(res *http.Response, s string) (string, error)
return s, nil
}
func matchResStringLiteral(res *http.Response, strLiteral search.StringLiteral) (bool, error) {
func matchResStringLiteral(res *http.Response, strLiteral filter.StringLiteral) (bool, error) {
for key, values := range res.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range resFilterKeyFns {
value, err := fn(res)
if err != nil {

View File

@ -10,9 +10,9 @@ import (
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/log"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/search"
)
var (
@ -56,16 +56,16 @@ type Service struct {
requestsEnabled bool
responsesEnabled bool
reqFilter search.Expression
resFilter search.Expression
reqFilter filter.Expression
resFilter filter.Expression
}
type Config struct {
Logger log.Logger
RequestsEnabled bool
ResponsesEnabled bool
RequestFilter search.Expression
ResponseFilter search.Expression
RequestFilter filter.Expression
ResponseFilter filter.Expression
}
// RequestIDs implements sort.Interface.

View File

@ -1,10 +1,10 @@
package intercept
import "github.com/dstotijn/hetty/pkg/search"
import "github.com/dstotijn/hetty/pkg/filter"
type Settings struct {
RequestsEnabled bool
ResponsesEnabled bool
RequestFilter search.Expression
ResponseFilter search.Expression
RequestFilter filter.Expression
ResponseFilter filter.Expression
}

View File

@ -12,10 +12,10 @@ import (
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/log"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
)
type contextKey int
@ -77,7 +77,7 @@ type service struct {
type FindRequestsFilter struct {
ProjectID ulid.ULID
OnlyInScope bool
SearchExpr search.Expression
SearchExpr filter.Expression
}
type Config struct {

View File

@ -8,8 +8,8 @@ import (
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
)
var reqLogSearchKeyFns = map[string]func(rl RequestLog) string{
@ -36,22 +36,22 @@ var ResLogSearchKeyFns = map[string]func(rl ResponseLog) string{
// TODO: Request and response headers search key functions.
// Matches returns true if the supplied search expression evaluates to true.
func (reqLog RequestLog) Matches(expr search.Expression) (bool, error) {
func (reqLog RequestLog) Matches(expr filter.Expression) (bool, error) {
switch e := expr.(type) {
case search.PrefixExpression:
case filter.PrefixExpression:
return reqLog.matchPrefixExpr(e)
case search.InfixExpression:
case filter.InfixExpression:
return reqLog.matchInfixExpr(e)
case search.StringLiteral:
case filter.StringLiteral:
return reqLog.matchStringLiteral(e)
default:
return false, fmt.Errorf("expression type (%T) not supported", expr)
}
}
func (reqLog RequestLog) matchPrefixExpr(expr search.PrefixExpression) (bool, error) {
func (reqLog RequestLog) matchPrefixExpr(expr filter.PrefixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpNot:
case filter.TokOpNot:
match, err := reqLog.Matches(expr.Right)
if err != nil {
return false, err
@ -63,9 +63,9 @@ func (reqLog RequestLog) matchPrefixExpr(expr search.PrefixExpression) (bool, er
}
}
func (reqLog RequestLog) matchInfixExpr(expr search.InfixExpression) (bool, error) {
func (reqLog RequestLog) matchInfixExpr(expr filter.InfixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpAnd:
case filter.TokOpAnd:
left, err := reqLog.Matches(expr.Left)
if err != nil {
return false, err
@ -77,7 +77,7 @@ func (reqLog RequestLog) matchInfixExpr(expr search.InfixExpression) (bool, erro
}
return left && right, nil
case search.TokOpOr:
case filter.TokOpOr:
left, err := reqLog.Matches(expr.Left)
if err != nil {
return false, err
@ -91,28 +91,46 @@ func (reqLog RequestLog) matchInfixExpr(expr search.InfixExpression) (bool, erro
return left || right, nil
}
left, ok := expr.Left.(search.StringLiteral)
left, ok := expr.Left.(filter.StringLiteral)
if !ok {
return false, errors.New("left operand must be a string literal")
}
leftVal := reqLog.getMappedStringLiteral(left.Value)
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
right, ok := expr.Right.(search.RegexpLiteral)
if leftVal == "req.headers" {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, reqLog.Header)
if err != nil {
return false, fmt.Errorf("failed to match request HTTP headers: %w", err)
}
return match, nil
}
if leftVal == "res.headers" && reqLog.Response != nil {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, reqLog.Response.Header)
if err != nil {
return false, fmt.Errorf("failed to match response HTTP headers: %w", err)
}
return match, nil
}
if expr.Operator == filter.TokOpRe || expr.Operator == filter.TokOpNotRe {
right, ok := expr.Right.(filter.RegexpLiteral)
if !ok {
return false, errors.New("right operand must be a regular expression")
}
switch expr.Operator {
case search.TokOpRe:
case filter.TokOpRe:
return right.MatchString(leftVal), nil
case search.TokOpNotRe:
case filter.TokOpNotRe:
return !right.MatchString(leftVal), nil
}
}
right, ok := expr.Right.(search.StringLiteral)
right, ok := expr.Right.(filter.StringLiteral)
if !ok {
return false, errors.New("right operand must be a string literal")
}
@ -120,20 +138,20 @@ func (reqLog RequestLog) matchInfixExpr(expr search.InfixExpression) (bool, erro
rightVal := reqLog.getMappedStringLiteral(right.Value)
switch expr.Operator {
case search.TokOpEq:
case filter.TokOpEq:
return leftVal == rightVal, nil
case search.TokOpNotEq:
case filter.TokOpNotEq:
return leftVal != rightVal, nil
case search.TokOpGt:
case filter.TokOpGt:
// TODO(?) attempt to parse as int.
return leftVal > rightVal, nil
case search.TokOpLt:
case filter.TokOpLt:
// TODO(?) attempt to parse as int.
return leftVal < rightVal, nil
case search.TokOpGtEq:
case filter.TokOpGtEq:
// TODO(?) attempt to parse as int.
return leftVal >= rightVal, nil
case search.TokOpLtEq:
case filter.TokOpLtEq:
// TODO(?) attempt to parse as int.
return leftVal <= rightVal, nil
default:
@ -162,7 +180,18 @@ func (reqLog RequestLog) getMappedStringLiteral(s string) string {
return s
}
func (reqLog RequestLog) matchStringLiteral(strLiteral search.StringLiteral) (bool, error) {
func (reqLog RequestLog) matchStringLiteral(strLiteral filter.StringLiteral) (bool, error) {
for key, values := range reqLog.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range reqLogSearchKeyFns {
if strings.Contains(
strings.ToLower(fn(reqLog)),
@ -173,6 +202,17 @@ func (reqLog RequestLog) matchStringLiteral(strLiteral search.StringLiteral) (bo
}
if reqLog.Response != nil {
for key, values := range reqLog.Response.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range ResLogSearchKeyFns {
if strings.Contains(
strings.ToLower(fn(*reqLog.Response)),

View File

@ -3,8 +3,8 @@ package reqlog_test
import (
"testing"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/search"
)
func TestRequestLogMatch(t *testing.T) {
@ -176,7 +176,7 @@ func TestRequestLogMatch(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
searchExpr, err := search.ParseQuery(tt.query)
searchExpr, err := filter.ParseQuery(tt.query)
assertError(t, nil, err)
got, err := tt.requestLog.Matches(searchExpr)

View File

@ -1,212 +0,0 @@
package search_test
import (
"regexp"
"testing"
"github.com/dstotijn/hetty/pkg/search"
)
func TestExpressionString(t *testing.T) {
t.Parallel()
tests := []struct {
name string
expression search.Expression
expected string
}{
{
name: "string literal expression",
expression: search.StringLiteral{Value: "foobar"},
expected: `"foobar"`,
},
{
name: "boolean expression with equal operator",
expression: search.InfixExpression{
Operator: search.TokOpEq,
Left: search.StringLiteral{Value: "foo"},
Right: search.StringLiteral{Value: "bar"},
},
expected: `("foo" = "bar")`,
},
{
name: "boolean expression with not equal operator",
expression: search.InfixExpression{
Operator: search.TokOpNotEq,
Left: search.StringLiteral{Value: "foo"},
Right: search.StringLiteral{Value: "bar"},
},
expected: `("foo" != "bar")`,
},
{
name: "boolean expression with greater than operator",
expression: search.InfixExpression{
Operator: search.TokOpGt,
Left: search.StringLiteral{Value: "foo"},
Right: search.StringLiteral{Value: "bar"},
},
expected: `("foo" > "bar")`,
},
{
name: "boolean expression with less than operator",
expression: search.InfixExpression{
Operator: search.TokOpLt,
Left: search.StringLiteral{Value: "foo"},
Right: search.StringLiteral{Value: "bar"},
},
expected: `("foo" < "bar")`,
},
{
name: "boolean expression with greater than or equal operator",
expression: search.InfixExpression{
Operator: search.TokOpGtEq,
Left: search.StringLiteral{Value: "foo"},
Right: search.StringLiteral{Value: "bar"},
},
expected: `("foo" >= "bar")`,
},
{
name: "boolean expression with less than or equal operator",
expression: search.InfixExpression{
Operator: search.TokOpLtEq,
Left: search.StringLiteral{Value: "foo"},
Right: search.StringLiteral{Value: "bar"},
},
expected: `("foo" <= "bar")`,
},
{
name: "boolean expression with regular expression operator",
expression: search.InfixExpression{
Operator: search.TokOpRe,
Left: search.StringLiteral{Value: "foo"},
Right: search.RegexpLiteral{regexp.MustCompile("bar")},
},
expected: `("foo" =~ "bar")`,
},
{
name: "boolean expression with not regular expression operator",
expression: search.InfixExpression{
Operator: search.TokOpNotRe,
Left: search.StringLiteral{Value: "foo"},
Right: search.RegexpLiteral{regexp.MustCompile("bar")},
},
expected: `("foo" !~ "bar")`,
},
{
name: "boolean expression with AND, OR and NOT operators",
expression: search.InfixExpression{
Operator: search.TokOpAnd,
Left: search.StringLiteral{Value: "foo"},
Right: search.InfixExpression{
Operator: search.TokOpOr,
Left: search.StringLiteral{Value: "bar"},
Right: search.PrefixExpression{
Operator: search.TokOpNot,
Right: search.StringLiteral{Value: "baz"},
},
},
},
expected: `("foo" AND ("bar" OR (NOT "baz")))`,
},
{
name: "boolean expression with nested group",
expression: search.InfixExpression{
Operator: search.TokOpOr,
Left: search.InfixExpression{
Operator: search.TokOpAnd,
Left: search.StringLiteral{Value: "foo"},
Right: search.StringLiteral{Value: "bar"},
},
Right: search.PrefixExpression{
Operator: search.TokOpNot,
Right: search.StringLiteral{Value: "baz"},
},
},
expected: `(("foo" AND "bar") OR (NOT "baz"))`,
},
{
name: "implicit boolean expression with string literal operands",
expression: search.InfixExpression{
Operator: search.TokOpAnd,
Left: search.InfixExpression{
Operator: search.TokOpAnd,
Left: search.StringLiteral{Value: "foo"},
Right: search.StringLiteral{Value: "bar"},
},
Right: search.StringLiteral{Value: "baz"},
},
expected: `(("foo" AND "bar") AND "baz")`,
},
{
name: "implicit boolean expression nested in group",
expression: search.InfixExpression{
Operator: search.TokOpAnd,
Left: search.StringLiteral{Value: "foo"},
Right: search.StringLiteral{Value: "bar"},
},
expected: `("foo" AND "bar")`,
},
{
name: "implicit and explicit boolean expression with string literal operands",
expression: search.InfixExpression{
Operator: search.TokOpAnd,
Left: search.InfixExpression{
Operator: search.TokOpAnd,
Left: search.StringLiteral{Value: "foo"},
Right: search.InfixExpression{
Operator: search.TokOpOr,
Left: search.StringLiteral{Value: "bar"},
Right: search.StringLiteral{Value: "baz"},
},
},
Right: search.StringLiteral{Value: "yolo"},
},
expected: `(("foo" AND ("bar" OR "baz")) AND "yolo")`,
},
{
name: "implicit boolean expression with comparison operands",
expression: search.InfixExpression{
Operator: search.TokOpAnd,
Left: search.InfixExpression{
Operator: search.TokOpEq,
Left: search.StringLiteral{Value: "foo"},
Right: search.StringLiteral{Value: "bar"},
},
Right: search.InfixExpression{
Operator: search.TokOpRe,
Left: search.StringLiteral{Value: "baz"},
Right: search.RegexpLiteral{regexp.MustCompile("yolo")},
},
},
expected: `(("foo" = "bar") AND ("baz" =~ "yolo"))`,
},
{
name: "eq operator takes precedence over boolean ops",
expression: search.InfixExpression{
Operator: search.TokOpOr,
Left: search.InfixExpression{
Operator: search.TokOpEq,
Left: search.StringLiteral{Value: "foo"},
Right: search.StringLiteral{Value: "bar"},
},
Right: search.InfixExpression{
Operator: search.TokOpEq,
Left: search.StringLiteral{Value: "baz"},
Right: search.StringLiteral{Value: "yolo"},
},
},
expected: `(("foo" = "bar") OR ("baz" = "yolo"))`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.expression.String()
if tt.expected != got {
t.Errorf("expected: %v, got: %v", tt.expected, got)
}
})
}
}

View File

@ -7,9 +7,9 @@ import (
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
)
var senderReqSearchKeyFns = map[string]func(req Request) string{
@ -29,22 +29,22 @@ var senderReqSearchKeyFns = map[string]func(req Request) string{
// TODO: Request and response headers search key functions.
// Matches returns true if the supplied search expression evaluates to true.
func (req Request) Matches(expr search.Expression) (bool, error) {
func (req Request) Matches(expr filter.Expression) (bool, error) {
switch e := expr.(type) {
case search.PrefixExpression:
case filter.PrefixExpression:
return req.matchPrefixExpr(e)
case search.InfixExpression:
case filter.InfixExpression:
return req.matchInfixExpr(e)
case search.StringLiteral:
case filter.StringLiteral:
return req.matchStringLiteral(e)
default:
return false, fmt.Errorf("expression type (%T) not supported", expr)
}
}
func (req Request) matchPrefixExpr(expr search.PrefixExpression) (bool, error) {
func (req Request) matchPrefixExpr(expr filter.PrefixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpNot:
case filter.TokOpNot:
match, err := req.Matches(expr.Right)
if err != nil {
return false, err
@ -56,9 +56,9 @@ func (req Request) matchPrefixExpr(expr search.PrefixExpression) (bool, error) {
}
}
func (req Request) matchInfixExpr(expr search.InfixExpression) (bool, error) {
func (req Request) matchInfixExpr(expr filter.InfixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpAnd:
case filter.TokOpAnd:
left, err := req.Matches(expr.Left)
if err != nil {
return false, err
@ -70,7 +70,7 @@ func (req Request) matchInfixExpr(expr search.InfixExpression) (bool, error) {
}
return left && right, nil
case search.TokOpOr:
case filter.TokOpOr:
left, err := req.Matches(expr.Left)
if err != nil {
return false, err
@ -84,28 +84,46 @@ func (req Request) matchInfixExpr(expr search.InfixExpression) (bool, error) {
return left || right, nil
}
left, ok := expr.Left.(search.StringLiteral)
left, ok := expr.Left.(filter.StringLiteral)
if !ok {
return false, errors.New("left operand must be a string literal")
}
leftVal := req.getMappedStringLiteral(left.Value)
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
right, ok := expr.Right.(search.RegexpLiteral)
if leftVal == "req.headers" {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, req.Header)
if err != nil {
return false, fmt.Errorf("failed to match request HTTP headers: %w", err)
}
return match, nil
}
if leftVal == "res.headers" && req.Response != nil {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, req.Response.Header)
if err != nil {
return false, fmt.Errorf("failed to match response HTTP headers: %w", err)
}
return match, nil
}
if expr.Operator == filter.TokOpRe || expr.Operator == filter.TokOpNotRe {
right, ok := expr.Right.(filter.RegexpLiteral)
if !ok {
return false, errors.New("right operand must be a regular expression")
}
switch expr.Operator {
case search.TokOpRe:
case filter.TokOpRe:
return right.MatchString(leftVal), nil
case search.TokOpNotRe:
case filter.TokOpNotRe:
return !right.MatchString(leftVal), nil
}
}
right, ok := expr.Right.(search.StringLiteral)
right, ok := expr.Right.(filter.StringLiteral)
if !ok {
return false, errors.New("right operand must be a string literal")
}
@ -113,20 +131,20 @@ func (req Request) matchInfixExpr(expr search.InfixExpression) (bool, error) {
rightVal := req.getMappedStringLiteral(right.Value)
switch expr.Operator {
case search.TokOpEq:
case filter.TokOpEq:
return leftVal == rightVal, nil
case search.TokOpNotEq:
case filter.TokOpNotEq:
return leftVal != rightVal, nil
case search.TokOpGt:
case filter.TokOpGt:
// TODO(?) attempt to parse as int.
return leftVal > rightVal, nil
case search.TokOpLt:
case filter.TokOpLt:
// TODO(?) attempt to parse as int.
return leftVal < rightVal, nil
case search.TokOpGtEq:
case filter.TokOpGtEq:
// TODO(?) attempt to parse as int.
return leftVal >= rightVal, nil
case search.TokOpLtEq:
case filter.TokOpLtEq:
// TODO(?) attempt to parse as int.
return leftVal <= rightVal, nil
default:
@ -155,7 +173,18 @@ func (req Request) getMappedStringLiteral(s string) string {
return s
}
func (req Request) matchStringLiteral(strLiteral search.StringLiteral) (bool, error) {
func (req Request) matchStringLiteral(strLiteral filter.StringLiteral) (bool, error) {
for key, values := range req.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range senderReqSearchKeyFns {
if strings.Contains(
strings.ToLower(fn(req)),
@ -166,6 +195,17 @@ func (req Request) matchStringLiteral(strLiteral search.StringLiteral) (bool, er
}
if req.Response != nil {
for key, values := range req.Response.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range reqlog.ResLogSearchKeyFns {
if strings.Contains(
strings.ToLower(fn(*req.Response)),

View File

@ -3,8 +3,8 @@ package sender_test
import (
"testing"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/search"
"github.com/dstotijn/hetty/pkg/sender"
)
@ -177,7 +177,7 @@ func TestRequestLogMatch(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
searchExpr, err := search.ParseQuery(tt.query)
searchExpr, err := filter.ParseQuery(tt.query)
assertError(t, nil, err)
got, err := tt.senderReq.Matches(searchExpr)

View File

@ -12,9 +12,9 @@ import (
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
)
//nolint:gosec
@ -54,7 +54,7 @@ type service struct {
type FindRequestsFilter struct {
ProjectID ulid.ULID
OnlyInScope bool
SearchExpr search.Expression
SearchExpr filter.Expression
}
type Config struct {