Compare commits

..

22 Commits

Author SHA1 Message Date
565c370bb8 Fix GoReleaser config, update moq dep 2022-01-31 15:48:44 +01:00
2dc6538a3b Add build-test Github Action 2022-01-31 14:37:56 +01:00
aa8ddf4122 Update Next.js, Material UI 2022-01-28 20:20:15 +01:00
73ebb89863 Update Dockerfile 2022-01-26 11:35:47 +01:00
1489cb16bf Update GoReleaser config 2022-01-25 13:20:16 +01:00
d84d2d0905 Replace SQLite with BadgerDB 2022-01-21 11:45:54 +01:00
8a3b3cbf02 Bump copyright year 2022-01-01 16:15:03 +01:00
b3225bfb99 Add initial tests for reqlog package 2022-01-01 16:11:49 +01:00
4e2eaea499 Add sponsor 2021-12-30 11:40:14 +01:00
8122b2552d Remove dead code 2021-05-13 13:35:11 +02:00
569f7bc76f Use embed instead of rice 2021-04-26 09:24:45 +02:00
ca3a729c36 Add linter, fix linting issue 2021-04-25 16:23:53 +02:00
ad3dc0da70 Bump ini from 1.3.5 to 1.3.8 in /docs (#57)
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.8.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.8)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:36 +02:00
49547f535f Bump prismjs from 1.22.0 to 1.23.0 in /docs (#64)
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.22.0 to 1.23.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.22.0...v1.23.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:23 +02:00
e42e1c212b Bump elliptic from 6.5.3 to 6.5.4 in /admin (#65)
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.3 to 6.5.4.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.3...v6.5.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:17 +02:00
6e38b16cf2 Bump elliptic from 6.5.3 to 6.5.4 in /docs (#66)
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.3 to 6.5.4.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.3...v6.5.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:10 +02:00
078bf303be Bump ssri from 6.0.1 to 6.0.2 in /docs (#71)
Bumps [ssri](https://github.com/npm/ssri) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/npm/ssri/releases)
- [Changelog](https://github.com/npm/ssri/blob/v6.0.2/CHANGELOG.md)
- [Commits](https://github.com/npm/ssri/compare/v6.0.1...v6.0.2)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:17:30 +02:00
a42f003919 Bump y18n from 4.0.0 to 4.0.1 in /admin (#68)
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:16:52 +02:00
50c2eac42d Bump y18n from 4.0.0 to 4.0.1 in /docs (#69)
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:16:39 +02:00
4ead501f53 Bump ssri from 6.0.1 to 6.0.2 in /admin (#70)
Bumps [ssri](https://github.com/npm/ssri) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/npm/ssri/releases)
- [Changelog](https://github.com/npm/ssri/blob/v6.0.2/CHANGELOG.md)
- [Commits](https://github.com/npm/ssri/compare/v6.0.1...v6.0.2)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:16:11 +02:00
d2e97f2acc Fix typo "is" > "if" in CLI cert help text (#63)
* Fix typo "is" > "if" in main.go

* Fix typo "is" > "if" in documentation

Update to match the code.
2021-04-23 20:01:50 +02:00
ad3fa7d379 Handle req log filter I/O when no project is set 2020-12-29 20:46:42 +01:00
92 changed files with 6019 additions and 8276 deletions

View File

@ -1,4 +1,8 @@
/admin/.env /admin/.env
/admin/.next /admin/.next
/admin/dist /admin/dist
/admin/node_modules /admin/node_modules
/dist
/docs
/hetty
/cmd/hetty/admin

52
.github/workflows/build-test.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.17", "1.16"]
name: Go ${{ matrix.go }} - Build
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}
- uses: actions/setup-node@v2
with:
node-version: "14"
- uses: actions/cache@v2
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- uses: actions/cache@v2
with:
path: ${{ github.workspace }}/admin/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: make build
test:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.17", "1.16"]
name: Go ${{ matrix.go }} - Test
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}
- uses: actions/cache@v2
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test ./pkg/...

11
.gitignore vendored
View File

@ -1,7 +1,6 @@
.release-env /.vscode
.vscode /dist
**/rice-box.go /hetty
dist /cmd/hetty/admin
hetty
*.pem *.pem
*.test *.test

48
.golangci.yml Normal file
View File

@ -0,0 +1,48 @@
linters:
presets:
- bugs
- comment
- error
- format
- import
- metalinter
- module
- performance
- style
- test
- unused
disable:
- exhaustive
- exhaustivestruct
- gochecknoglobals
- gochecknoinits
- godox
- goerr113
- gomnd
- interfacer
- maligned
- nlreturn
- scopelint
- testpackage
- wrapcheck
linters-settings:
gci:
local-prefixes: github.com/dstotijn/hetty
godot:
capital: true
issues:
exclude-rules:
- linters:
- gosec
# Ignore SHA1 usage.
text: "G(401|505):"
- linters:
- wsl
# Ignore cuddled defer statements.
text: "only one cuddle assignment allowed before defer statement"
- linters:
- nlreturn
# Ignore `break` without leading blank line.
text: "break with no blank line before"

View File

@ -1,60 +1,38 @@
env: before:
- GO111MODULE=on hooks:
- CGO_ENABLED=1 - make clean
- sh -c "NEXT_PUBLIC_VERSION={{ .Version}} make build-admin"
- go mod tidy
builds: builds:
- id: hetty-darwin-amd64 - env:
- CGO_ENABLED=0
main: ./cmd/hetty main: ./cmd/hetty
goarch: ldflags:
- amd64 - -s -w -X main.version={{.Version}}
goos:
- darwin
env:
- CC=o64-clang
- CXX=o64-clang++
flags:
- -mod=readonly
- id: hetty-linux-amd64
main: ./cmd/hetty
goarch:
- amd64
goos: goos:
- linux - linux
flags: - windows
- -mod=readonly - darwin
- id: hetty-windows-amd64
main: ./cmd/hetty
goarch: goarch:
- amd64 - amd64
goos: - arm64
- windows
env:
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
flags:
- -mod=readonly
ldflags:
- -buildmode=exe
archives: archives:
- - replacements:
replacements: darwin: macOS
darwin: macOS linux: Linux
linux: Linux windows: Windows
windows: Windows amd64: x86_64
386: i386
amd64: x86_64
format_overrides: format_overrides:
- goos: windows - goos: windows
format: zip format: zip
checksum: checksum:
name_template: "checksums.txt" name_template: "checksums.txt"
snapshot: snapshot:
name_template: "{{ .Tag }}-next" name_template: "{{ incpatch .Version }}-next"
changelog: changelog:
sort: asc sort: asc

View File

@ -1,16 +1,6 @@
ARG GO_VERSION=1.15 ARG GO_VERSION=1.17
ARG CGO_ENABLED=1 ARG NODE_VERSION=16.13
ARG NODE_VERSION=14.11 ARG ALPINE_VERSION=3.15
FROM golang:${GO_VERSION}-alpine AS go-builder
WORKDIR /app
RUN apk add --no-cache build-base
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY pkg ./pkg
RUN rm -f cmd/hetty/rice-box.go
RUN go build ./cmd/hetty
FROM node:${NODE_VERSION}-alpine AS node-builder FROM node:${NODE_VERSION}-alpine AS node-builder
WORKDIR /app WORKDIR /app
@ -20,11 +10,21 @@ COPY admin/ .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN yarn run export RUN yarn run export
FROM alpine:3.12 FROM golang:${GO_VERSION}-alpine AS go-builder
ARG HETTY_VERSION=0.0.0
ENV CGO_ENABLED=0
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY pkg ./pkg
COPY --from=node-builder /app/dist ./cmd/hetty/admin
RUN go build -ldflags="-s -w -X main.version=${HETTY_VERSION}" ./cmd/hetty
FROM alpine:${ALPINE_VERSION}
WORKDIR /app WORKDIR /app
COPY --from=go-builder /app/hetty . COPY --from=go-builder /app/hetty .
COPY --from=node-builder /app/dist admin
ENTRYPOINT ["./hetty", "-adminPath=./admin"] ENTRYPOINT ["./hetty"]
EXPOSE 8080 EXPOSE 8080

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2020 David Stotijn Copyright (c) 2021 David Stotijn
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.

View File

@ -1,34 +1,21 @@
PACKAGE_NAME := github.com/dstotijn/hetty export CGO_ENABLED = 0
GOLANG_CROSS_VERSION ?= v1.15.2 export NEXT_TELEMETRY_DISABLED = 1
.PHONY: embed .PHONY: clean
embed: clean:
NEXT_TELEMETRY_DISABLED=1 cd admin && yarn install && yarn run export rm -f hetty
cd cmd/hetty && rice embed-go rm -rf ./cmd/hetty/admin
rm -rf ./admin/node_modules
rm -rf ./admin/dist
rm -rf ./admin/.next
.PHONY: build-admin
build-admin:
cd admin && \
yarn install --frozen-lockfile && \
yarn run export && \
mv dist ../cmd/hetty/admin
.PHONY: build .PHONY: build
build: embed build: build-admin
CGO_ENABLED=1 go build ./cmd/hetty go build ./cmd/hetty
.PHONY: release-dry-run
release-dry-run: embed
@docker run \
--rm \
-v `pwd`:/go/src/$(PACKAGE_NAME) \
-w /go/src/$(PACKAGE_NAME) \
troian/golang-cross:${GOLANG_CROSS_VERSION} \
--rm-dist --skip-validate --skip-publish
.PHONY: release
release: embed
@if [ ! -f ".release-env" ]; then \
echo "\033[91mFile \`.release-env\` is missing.\033[0m";\
exit 1;\
fi
@docker run \
--rm \
-v `pwd`:/go/src/$(PACKAGE_NAME) \
-w /go/src/$(PACKAGE_NAME) \
--env-file .release-env \
troian/golang-cross:${GOLANG_CROSS_VERSION} \
release --rm-dist

View File

@ -5,6 +5,7 @@
</h1> </h1>
[![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/releases/latest) [![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/releases/latest)
[![Build Status](https://img.shields.io/endpoint.svg?url=https://actions-badge.atrox.dev/dstotijn/hetty/badge&style=flat-square&label=build+%26+test&logo=none&color=18BA91)](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
![GitHub download count](https://img.shields.io/github/downloads/dstotijn/hetty/total?color=18BA91&style=flat-square) ![GitHub download count](https://img.shields.io/github/downloads/dstotijn/hetty/total?color=18BA91&style=flat-square)
[![GitHub](https://img.shields.io/github/license/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/blob/master/LICENSE) [![GitHub](https://img.shields.io/github/license/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/blob/master/LICENSE)
[![Documentation](https://img.shields.io/badge/hetty-docs-18BA91?style=flat-square)](https://hetty.xyz/) [![Documentation](https://img.shields.io/badge/hetty-docs-18BA91?style=flat-square)](https://hetty.xyz/)
@ -33,7 +34,7 @@ for details.
## Installation ## Installation
Hetty compiles to a self-contained binary, with an embedded SQLite database Hetty compiles to a self-contained binary, with an embedded BadgerDB database
and web based admin interface. and web based admin interface.
### Install pre-built release (recommended) ### Install pre-built release (recommended)
@ -44,14 +45,13 @@ and web based admin interface.
#### Prerequisites #### Prerequisites
- [Go](https://golang.org/) - [Go 1.16](https://golang.org/)
- [Yarn](https://yarnpkg.com/) - [Yarn](https://yarnpkg.com/)
- [go.rice](https://github.com/GeertJohan/go.rice)
Hetty depends on SQLite (via [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3)) When building from source, the static resources for the admin interface
and needs `cgo` to compile. Additionally, the static resources for the admin interface (Next.js) need to be generated via [Yarn](https://yarnpkg.com/). The generated
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/) and embedded in files will be embedded (using the [embed](https://golang.org/pkg/embed/)
a `.go` file with [go.rice](https://github.com/GeertJohan/go.rice) beforehand. package) when you use the `build` Makefile target.
Clone the repository and use the `build` make target to create a binary: Clone the repository and use the `build` make target to create a binary:
@ -64,7 +64,7 @@ $ make build
### Docker ### Docker
A Docker image is available on Docker Hub: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty). A Docker image is available on Docker Hub: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty).
For persistent storage of CA certificates and project databases, mount a volume: For persistent storage of CA certificates and projects database, mount a volume:
``` ```
$ mkdir -p $HOME/.hetty $ mkdir -p $HOME/.hetty
@ -77,7 +77,7 @@ When Hetty is run, by default it listens on `:8080` and is accessible via
http://localhost:8080. Depending on incoming HTTP requests, it either acts as a http://localhost:8080. Depending on incoming HTTP requests, it either acts as a
MITM proxy, or it serves the API and web interface. MITM proxy, or it serves the API and web interface.
By default, project database files and CA certificates are stored in a `.hetty` By default, the projects database files and CA certificates are stored in a `.hetty`
directory under the user's home directory (`$HOME` on Linux/macOS, `%USERPROFILE%` directory under the user's home directory (`$HOME` on Linux/macOS, `%USERPROFILE%`
on Windows). on Windows).
@ -98,17 +98,17 @@ Usage of ./hetty:
-adminPath string -adminPath string
File path to admin build File path to admin build
-cert string -cert string
CA certificate filepath. Creates a new CA certificate is file doesn't exist (default "~/.hetty/hetty_cert.pem") CA certificate filepath. Creates a new CA certificate if file doesn't exist (default "~/.hetty/hetty_cert.pem")
-key string -key string
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem") CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
-projects string -db string
Projects directory path (default "~/.hetty/projects") Database directory path (default "~/.hetty/db")
``` ```
You should see: You should see:
``` ```
2020/11/01 14:47:10 [INFO] Running server on :8080 ... 2022/01/26 10:34:24 [INFO] Hetty (v0.3.2) is running on :8080 ...
``` ```
Then, visit [http://localhost:8080](http://localhost:8080) to get started. Then, visit [http://localhost:8080](http://localhost:8080) to get started.
@ -228,10 +228,16 @@ for details.
for all the encouragement and feedback. for all the encouragement and feedback.
- The font used in the logo and admin interface is [JetBrains Mono](https://www.jetbrains.com/lp/mono/). - The font used in the logo and admin interface is [JetBrains Mono](https://www.jetbrains.com/lp/mono/).
## Sponsors
<a href="https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty">
<img src="https://hetty.xyz/assets/tines-sponsorship-badge.png" width="140" alt="Sponsored by Tines">
</a>
## License ## License
[MIT License](LICENSE) [MIT License](LICENSE)
--- ---
© 2020 David Stotijn — [Twitter](https://twitter.com/dstotijn), [Email](mailto:dstotijn@gmail.com) © 2021 David Stotijn — [Twitter](https://twitter.com/dstotijn), [Email](mailto:dstotijn@gmail.com)

6
admin/.eslintrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-css-tags": "off"
}
}

4
admin/.prettierignore Normal file
View File

@ -0,0 +1,4 @@
/.next/
/out/
/build
/coverage

3
admin/.prettierrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"printWidth": 120
}

5
admin/next-env.d.ts vendored
View File

@ -1,2 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -1,7 +1,10 @@
const withCSS = require("@zeit/next-css"); // @ts-check
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
module.exports = withCSS({ /**
* @type {import('next').NextConfig}
**/
const nextConfig = {
reactStrictMode: true,
trailingSlash: true, trailingSlash: true,
async rewrites() { async rewrites() {
return [ return [
@ -11,24 +14,6 @@ module.exports = withCSS({
}, },
]; ];
}, },
webpack: (config) => { };
config.module.rules.push({
test: /\.(png|jpg|gif|svg|eot|ttf|woff|woff2)$/,
use: {
loader: "url-loader",
options: {
limit: 100000,
},
},
});
config.plugins.push( module.exports = nextConfig;
new MonacoWebpackPlugin({
languages: ["html", "json", "javascript"],
filename: "static/[name].worker.js",
})
);
return config;
},
});

View File

@ -6,31 +6,38 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"export": "rm -rf .next && next build && next export -o dist" "lint": "next lint",
"export": "next build && next export -o dist"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.2.0", "@apollo/client": "^3.2.0",
"@material-ui/core": "^4.11.0", "@emotion/react": "^11.7.1",
"@material-ui/icons": "^4.9.1", "@emotion/server": "^11.4.0",
"@material-ui/lab": "^4.0.0-alpha.56", "@emotion/styled": "^11.6.0",
"@zeit/next-css": "^1.0.1", "@monaco-editor/react": "^4.3.1",
"graphql": "^15.3.0", "@mui/icons-material": "^5.3.1",
"monaco-editor": "^0.20.0", "@mui/lab": "^5.0.0-alpha.66",
"monaco-editor-webpack-plugin": "^1.9.0", "@mui/material": "^5.3.1",
"next": "^9.5.4", "deepmerge": "^4.2.2",
"graphql": "^16.2.0",
"lodash": "^4.17.21",
"monaco-editor": "^0.31.1",
"next": "^12.0.8",
"next-fonts": "^1.0.3", "next-fonts": "^1.0.3",
"react": "^16.13.1", "react": "^17.0.2",
"react-dom": "^16.13.1", "react-dom": "^17.0.2",
"react-monaco-editor": "^0.34.0",
"react-syntax-highlighter": "^13.5.3",
"typescript": "^4.0.3" "typescript": "^4.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^14.11.1", "@babel/core": "^7.0.0",
"@types/react": "^16.9.49", "@types/lodash": "^4.14.178",
"eslint": "^7.9.0", "@types/node": "^17.0.12",
"eslint-config-prettier": "^6.11.0", "@types/react": "^17.0.38",
"eslint-plugin-prettier": "^3.1.4", "eslint": "^8.7.0",
"prettier": "^2.1.2" "eslint-config-next": "12.0.8",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"prettier": "^2.1.2",
"webpack": "^5.67.0"
} }
} }

View File

@ -1,10 +1,6 @@
import { Paper } from "@material-ui/core"; import { Paper } from "@mui/material";
function CenteredPaper({ function CenteredPaper({ children }: { children: React.ReactNode }): JSX.Element {
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return ( return (
<div> <div>
<Paper <Paper

View File

@ -1,31 +1,31 @@
import React from "react"; import React from "react";
import { import {
makeStyles,
Theme, Theme,
createStyles,
useTheme, useTheme,
AppBar,
Toolbar, Toolbar,
IconButton, IconButton,
Typography, Typography,
Drawer,
Divider, Divider,
List, List,
ListItem,
ListItemIcon,
ListItemText,
Tooltip, Tooltip,
} from "@material-ui/core"; styled,
CSSObject,
Box,
ListItemText,
} from "@mui/material";
import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar";
import MuiDrawer from "@mui/material/Drawer";
import MuiListItemButton, { ListItemButtonProps } from "@mui/material/ListItemButton";
import MuiListItemIcon, { ListItemIconProps } from "@mui/material/ListItemIcon";
import Link from "next/link"; import Link from "next/link";
import MenuIcon from "@material-ui/icons/Menu"; import MenuIcon from "@mui/icons-material/Menu";
import HomeIcon from "@material-ui/icons/Home"; import HomeIcon from "@mui/icons-material/Home";
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet"; import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
import SendIcon from "@material-ui/icons/Send"; import SendIcon from "@mui/icons-material/Send";
import FolderIcon from "@material-ui/icons/Folder"; import FolderIcon from "@mui/icons-material/Folder";
import LocationSearchingIcon from "@material-ui/icons/LocationSearching"; import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@material-ui/icons/ChevronRight"; import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import clsx from "clsx";
export enum Page { export enum Page {
Home, Home,
@ -39,85 +39,91 @@ export enum Page {
const drawerWidth = 240; const drawerWidth = 240;
const useStyles = makeStyles((theme: Theme) => const openedMixin = (theme: Theme): CSSObject => ({
createStyles({ width: drawerWidth,
root: { transition: theme.transitions.create("width", {
display: "flex", easing: theme.transitions.easing.sharp,
width: "100%", duration: theme.transitions.duration.enteringScreen,
}, }),
appBar: { overflowX: "hidden",
zIndex: theme.zIndex.drawer + 1, });
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp, const closedMixin = (theme: Theme): CSSObject => ({
duration: theme.transitions.duration.leavingScreen, transition: theme.transitions.create("width", {
}), easing: theme.transitions.easing.sharp,
}, duration: theme.transitions.duration.leavingScreen,
appBarShift: { }),
marginLeft: drawerWidth, overflowX: "hidden",
width: `calc(100% - ${drawerWidth}px)`, width: 56,
transition: theme.transitions.create(["width", "margin"], { });
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen, const DrawerHeader = styled("div")(({ theme }) => ({
}), display: "flex",
}, alignItems: "center",
menuButton: { justifyContent: "flex-start",
marginRight: 28, padding: theme.spacing(0, 1),
}, // necessary for content to be below app bar
hide: { ...theme.mixins.toolbar,
display: "none", }));
},
drawer: { interface AppBarProps extends MuiAppBarProps {
width: drawerWidth, open?: boolean;
flexShrink: 0, }
whiteSpace: "nowrap",
}, const AppBar = styled(MuiAppBar, {
drawerOpen: { shouldForwardProp: (prop) => prop !== "open",
width: drawerWidth, })<AppBarProps>(({ theme, open }) => ({
transition: theme.transitions.create("width", { backgroundColor: theme.palette.secondary.dark,
easing: theme.transitions.easing.sharp, zIndex: theme.zIndex.drawer + 1,
duration: theme.transitions.duration.enteringScreen, transition: theme.transitions.create(["width", "margin"], {
}), easing: theme.transitions.easing.sharp,
}, duration: theme.transitions.duration.leavingScreen,
drawerClose: { }),
transition: theme.transitions.create("width", { ...(open && {
easing: theme.transitions.easing.sharp, marginLeft: drawerWidth,
duration: theme.transitions.duration.leavingScreen, width: `calc(100% - ${drawerWidth}px)`,
}), transition: theme.transitions.create(["width", "margin"], {
overflowX: "hidden", easing: theme.transitions.easing.sharp,
width: theme.spacing(7) + 1, duration: theme.transitions.duration.enteringScreen,
[theme.breakpoints.up("sm")]: { }),
width: theme.spacing(7) + 8, }),
}));
const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== "open" })(({ theme, open }) => ({
width: drawerWidth,
flexShrink: 0,
whiteSpace: "nowrap",
boxSizing: "border-box",
...(open && {
...openedMixin(theme),
"& .MuiDrawer-paper": openedMixin(theme),
}),
...(!open && {
...closedMixin(theme),
"& .MuiDrawer-paper": closedMixin(theme),
}),
}));
const ListItemButton = styled(MuiListItemButton)<ListItemButtonProps>(({ theme }) => ({
[theme.breakpoints.up("sm")]: {
px: 1,
},
"&.MuiListItemButton-root": {
"&.Mui-selected": {
backgroundColor: theme.palette.primary.main,
"& .MuiListItemIcon-root": {
color: theme.palette.secondary.dark,
},
"& .MuiListItemText-root": {
color: theme.palette.secondary.dark,
}, },
}, },
toolbar: { },
display: "flex", }));
alignItems: "center",
justifyContent: "flex-end", const ListItemIcon = styled(MuiListItemIcon)<ListItemIconProps>(() => ({
padding: theme.spacing(0, 1), minWidth: 42,
// necessary for content to be below app bar }));
...theme.mixins.toolbar,
},
content: {
flexGrow: 1,
padding: theme.spacing(3),
},
listItem: {
paddingLeft: 16,
paddingRight: 16,
[theme.breakpoints.up("sm")]: {
paddingLeft: 20,
paddingRight: 20,
},
},
listItemIcon: {
minWidth: 42,
},
titleHighlight: {
color: theme.palette.secondary.main,
marginRight: 4,
},
})
);
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
@ -126,7 +132,6 @@ interface Props {
} }
export function Layout({ title, page, children }: Props): JSX.Element { export function Layout({ title, page, children }: Props): JSX.Element {
const classes = useStyles();
const theme = useTheme(); const theme = useTheme();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
@ -138,145 +143,109 @@ export function Layout({ title, page, children }: Props): JSX.Element {
setOpen(false); setOpen(false);
}; };
const SiteTitle = styled("span")({
...(title !== "" && {
color: theme.palette.primary.main,
marginRight: 4,
}),
});
return ( return (
<div className={classes.root}> <Box sx={{ display: "flex" }}>
<AppBar <AppBar position="fixed" open={open}>
position="fixed"
className={clsx(classes.appBar, {
[classes.appBarShift]: open,
})}
>
<Toolbar> <Toolbar>
<IconButton <IconButton
color="inherit" color="inherit"
aria-label="open drawer" aria-label="Open drawer"
onClick={handleDrawerOpen} onClick={handleDrawerOpen}
edge="start" edge="start"
className={clsx(classes.menuButton, { sx={{
[classes.hide]: open, mr: 5,
})} ...(open && { display: "none" }),
}}
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Typography variant="h5" noWrap> <Box
<span className={title !== "" ? classes.titleHighlight : ""}> sx={{
Hetty:// display: "flex",
</span> justifyContent: "space-around",
{title} width: "100%",
</Typography> }}
>
<Typography variant="h5" noWrap sx={{ width: "100%" }}>
<SiteTitle>Hetty://</SiteTitle>
{title}
</Typography>
<Box sx={{ flexShrink: 0, pt: 0.75 }}>v{process.env.NEXT_PUBLIC_VERSION || "0.0"}</Box>
</Box>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Drawer <Drawer variant="permanent" open={open}>
variant="permanent" <DrawerHeader>
className={clsx(classes.drawer, {
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
})}
classes={{
paper: clsx({
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
}),
}}
>
<div className={classes.toolbar}>
<IconButton onClick={handleDrawerClose}> <IconButton onClick={handleDrawerClose}>
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? <ChevronRightIcon /> : <ChevronLeftIcon />}
<ChevronRightIcon />
) : (
<ChevronLeftIcon />
)}
</IconButton> </IconButton>
</div> </DrawerHeader>
<Divider /> <Divider />
<List> <List sx={{ p: 0 }}>
<Link href="/" passHref> <Link href="/" passHref>
<ListItem <ListItemButton key="home" selected={page === Page.Home}>
button
component="a"
key="home"
selected={page === Page.Home}
className={classes.listItem}
>
<Tooltip title="Home"> <Tooltip title="Home">
<ListItemIcon className={classes.listItemIcon}> <ListItemIcon>
<HomeIcon /> <HomeIcon />
</ListItemIcon> </ListItemIcon>
</Tooltip> </Tooltip>
<ListItemText primary="Home" /> <ListItemText primary="Home" />
</ListItem> </ListItemButton>
</Link> </Link>
<Link href="/proxy/logs" passHref> <Link href="/proxy/logs" passHref>
<ListItem <ListItemButton key="proxyLogs" selected={page === Page.ProxyLogs}>
button
component="a"
key="proxyLogs"
selected={page === Page.ProxyLogs}
className={classes.listItem}
>
<Tooltip title="Proxy"> <Tooltip title="Proxy">
<ListItemIcon className={classes.listItemIcon}> <ListItemIcon>
<SettingsEthernetIcon /> <SettingsEthernetIcon />
</ListItemIcon> </ListItemIcon>
</Tooltip> </Tooltip>
<ListItemText primary="Proxy" /> <ListItemText primary="Proxy" />
</ListItem> </ListItemButton>
</Link> </Link>
<Link href="/sender" passHref> <Link href="/sender" passHref>
<ListItem <ListItemButton key="sender" selected={page === Page.Sender}>
button
component="a"
key="sender"
selected={page === Page.Sender}
className={classes.listItem}
>
<Tooltip title="Sender"> <Tooltip title="Sender">
<ListItemIcon className={classes.listItemIcon}> <ListItemIcon>
<SendIcon /> <SendIcon />
</ListItemIcon> </ListItemIcon>
</Tooltip> </Tooltip>
<ListItemText primary="Sender" /> <ListItemText primary="Sender" />
</ListItem> </ListItemButton>
</Link> </Link>
<Link href="/scope" passHref> <Link href="/scope" passHref>
<ListItem <ListItemButton key="scope" selected={page === Page.Scope}>
button
component="a"
key="scope"
selected={page === Page.Scope}
className={classes.listItem}
>
<Tooltip title="Scope"> <Tooltip title="Scope">
<ListItemIcon className={classes.listItemIcon}> <ListItemIcon>
<LocationSearchingIcon /> <LocationSearchingIcon />
</ListItemIcon> </ListItemIcon>
</Tooltip> </Tooltip>
<ListItemText primary="Scope" /> <ListItemText primary="Scope" />
</ListItem> </ListItemButton>
</Link> </Link>
<Link href="/projects" passHref> <Link href="/projects" passHref>
<ListItem <ListItemButton key="projects" selected={page === Page.Projects}>
button
component="a"
key="projects"
selected={page === Page.Projects}
className={classes.listItem}
>
<Tooltip title="Projects"> <Tooltip title="Projects">
<ListItemIcon className={classes.listItemIcon}> <ListItemIcon>
<FolderIcon /> <FolderIcon />
</ListItemIcon> </ListItemIcon>
</Tooltip> </Tooltip>
<ListItemText primary="Projects" /> <ListItemText primary="Projects" />
</ListItem> </ListItemButton>
</Link> </Link>
</List> </List>
</Drawer> </Drawer>
<main className={classes.content}> <Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<div className={classes.toolbar} /> <DrawerHeader />
{children} {children}
</main> </Box>
</div> </Box>
); );
} }

View File

@ -1,32 +1,21 @@
import { gql, useMutation } from "@apollo/client"; import { gql, useMutation } from "@apollo/client";
import { import { Box, Button, CircularProgress, TextField, Typography } from "@mui/material";
Box, import AddIcon from "@mui/icons-material/Add";
Button,
CircularProgress,
createStyles,
makeStyles,
TextField,
Theme,
Typography,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import React, { useState } from "react"; import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) => const CREATE_PROJECT = gql`
createStyles({ mutation CreateProject($name: String!) {
projectName: { createProject(name: $name) {
marginTop: -6, id
marginRight: theme.spacing(2), name
}, }
button: { }
marginRight: theme.spacing(2), `;
},
})
);
const OPEN_PROJECT = gql` const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) { mutation OpenProject($id: ID!) {
openProject(name: $name) { openProject(id: $id) {
id
name name
isActive isActive
} }
@ -34,23 +23,27 @@ const OPEN_PROJECT = gql`
`; `;
function NewProject(): JSX.Element { function NewProject(): JSX.Element {
const classes = useStyles(); const [name, setName] = useState("");
const [input, setInput] = useState(null);
const [openProject, { error, loading }] = useMutation(OPEN_PROJECT, { const [createProject, { error: createProjErr, loading: createProjLoading }] = useMutation(CREATE_PROJECT, {
onError: () => {}, onError: () => {},
onCompleted() { onCompleted(data) {
input.value = ""; setName("");
openProject({ variables: { id: data.createProject.id } });
}, },
});
const [openProject, { error: openProjErr, loading: openProjLoading }] = useMutation(OPEN_PROJECT, {
onError: () => {},
update(cache, { data: { openProject } }) { update(cache, { data: { openProject } }) {
cache.modify({ cache.modify({
fields: { fields: {
activeProject() { activeProject() {
const activeProjRef = cache.writeFragment({ const activeProjRef = cache.writeFragment({
id: openProject.name, id: openProject.id,
data: openProject, data: openProject,
fragment: gql` fragment: gql`
fragment ActiveProject on Project { fragment ActiveProject on Project {
id
name name
isActive isActive
type type
@ -61,10 +54,11 @@ function NewProject(): JSX.Element {
}, },
projects(_, { DELETE }) { projects(_, { DELETE }) {
cache.writeFragment({ cache.writeFragment({
id: openProject.name, id: openProject.id,
data: openProject, data: openProject,
fragment: gql` fragment: gql`
fragment OpenProject on Project { fragment OpenProject on Project {
id
name name
isActive isActive
type type
@ -78,9 +72,9 @@ function NewProject(): JSX.Element {
}, },
}); });
const handleNewProjectForm = (e: React.SyntheticEvent) => { const handleCreateAndOpenProjectForm = (e: React.SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
openProject({ variables: { name: input.value } }); createProject({ variables: { name } });
}; };
return ( return (
@ -88,29 +82,30 @@ function NewProject(): JSX.Element {
<Box mb={3}> <Box mb={3}>
<Typography variant="h6">New project</Typography> <Typography variant="h6">New project</Typography>
</Box> </Box>
<form onSubmit={handleNewProjectForm} autoComplete="off"> <form onSubmit={handleCreateAndOpenProjectForm} autoComplete="off">
<TextField <TextField
className={classes.projectName} sx={{
color="secondary" mr: 2,
inputProps={{
id: "projectName",
ref: (node) => {
setInput(node);
},
}} }}
color="primary"
size="small"
label="Project name" label="Project name"
placeholder="Project name…" placeholder="Project name…"
error={Boolean(error)} onChange={(e) => setName(e.target.value)}
helperText={error && error.message} error={Boolean(createProjErr || openProjErr)}
helperText={(createProjErr && createProjErr.message) || (openProjErr && openProjErr.message)}
/> />
<Button <Button
className={classes.button}
type="submit" type="submit"
variant="contained" variant="contained"
color="secondary" color="primary"
size="large" size="large"
disabled={loading} sx={{
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />} pt: 0.9,
pb: 0.7,
}}
disabled={createProjLoading || openProjLoading}
startIcon={createProjLoading || openProjLoading ? <CircularProgress size={22} /> : <AddIcon />}
> >
Create & open project Create & open project
</Button> </Button>

View File

@ -4,7 +4,6 @@ import {
Box, Box,
Button, Button,
CircularProgress, CircularProgress,
createStyles,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
@ -16,38 +15,25 @@ import {
ListItemAvatar, ListItemAvatar,
ListItemSecondaryAction, ListItemSecondaryAction,
ListItemText, ListItemText,
makeStyles, Paper,
Snackbar, Snackbar,
Theme,
Tooltip, Tooltip,
Typography, Typography,
} from "@material-ui/core"; useTheme,
import CloseIcon from "@material-ui/icons/Close"; } from "@mui/material";
import DescriptionIcon from "@material-ui/icons/Description"; import CloseIcon from "@mui/icons-material/Close";
import DeleteIcon from "@material-ui/icons/Delete"; import DescriptionIcon from "@mui/icons-material/Description";
import LaunchIcon from "@material-ui/icons/Launch"; import DeleteIcon from "@mui/icons-material/Delete";
import { Alert } from "@material-ui/lab"; import LaunchIcon from "@mui/icons-material/Launch";
import { Alert } from "@mui/lab";
import React, { useState } from "react"; import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) => import { Project } from "../../lib/Project";
createStyles({
projectsList: {
backgroundColor: theme.palette.background.paper,
},
activeProject: {
color: theme.palette.getContrastText(theme.palette.secondary.main),
backgroundColor: theme.palette.secondary.main,
},
deleteProjectButton: {
color: theme.palette.error.main,
},
})
);
const PROJECTS = gql` const PROJECTS = gql`
query Projects { query Projects {
projects { projects {
id
name name
isActive isActive
} }
@ -55,8 +41,9 @@ const PROJECTS = gql`
`; `;
const OPEN_PROJECT = gql` const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) { mutation OpenProject($id: ID!) {
openProject(name: $name) { openProject(id: $id) {
id
name name
isActive isActive
} }
@ -72,61 +59,61 @@ const CLOSE_PROJECT = gql`
`; `;
const DELETE_PROJECT = gql` const DELETE_PROJECT = gql`
mutation DeleteProject($name: String!) { mutation DeleteProject($id: ID!) {
deleteProject(name: $name) { deleteProject(id: $id) {
success success
} }
} }
`; `;
function ProjectList(): JSX.Element { function ProjectList(): JSX.Element {
const classes = useStyles(); const theme = useTheme();
const { loading: projLoading, error: projErr, data: projData } = useQuery( const { loading: projLoading, error: projErr, data: projData } = useQuery<{ projects: Project[] }>(PROJECTS);
PROJECTS const [openProject, { error: openProjErr, loading: openProjLoading }] = useMutation<{ openProject: Project }>(
OPEN_PROJECT,
{
errorPolicy: "all",
onError: () => {},
update(cache, { data }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
data: data?.openProject,
fragment: gql`
fragment ActiveProject on Project {
id
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: data?.openProject.id,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
id
name
isActive
type
}
`,
});
return DELETE;
},
httpRequestLogFilter(_, { DELETE }) {
return DELETE;
},
},
});
},
}
); );
const [
openProject,
{ error: openProjErr, loading: openProjLoading },
] = useMutation(OPEN_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
name
isActive
type
}
`,
});
return DELETE;
},
httpRequestLogFilter(_, { DELETE }) {
return DELETE;
},
},
});
},
});
const [closeProject, { error: closeProjErr }] = useMutation(CLOSE_PROJECT, { const [closeProject, { error: closeProjErr }] = useMutation(CLOSE_PROJECT, {
errorPolicy: "all", errorPolicy: "all",
onError: () => {}, onError: () => {},
@ -146,10 +133,7 @@ function ProjectList(): JSX.Element {
}); });
}, },
}); });
const [ const [deleteProject, { loading: deleteProjLoading, error: deleteProjErr }] = useMutation(DELETE_PROJECT, {
deleteProject,
{ loading: deleteProjLoading, error: deleteProjErr },
] = useMutation(DELETE_PROJECT, {
errorPolicy: "all", errorPolicy: "all",
onError: () => {}, onError: () => {},
update(cache) { update(cache) {
@ -165,21 +149,21 @@ function ProjectList(): JSX.Element {
}, },
}); });
const [deleteProjName, setDeleteProjName] = useState(null); const [deleteProj, setDeleteProj] = useState<Project>();
const [deleteDiagOpen, setDeleteDiagOpen] = useState(false); const [deleteDiagOpen, setDeleteDiagOpen] = useState(false);
const handleDeleteButtonClick = (name: string) => { const handleDeleteButtonClick = (project: any) => {
setDeleteProjName(name); setDeleteProj(project);
setDeleteDiagOpen(true); setDeleteDiagOpen(true);
}; };
const handleDeleteConfirm = () => { const handleDeleteConfirm = () => {
deleteProject({ variables: { name: deleteProjName } }); deleteProject({ variables: { id: deleteProj?.id } });
}; };
const handleDeleteCancel = () => { const handleDeleteCancel = () => {
setDeleteDiagOpen(false); setDeleteDiagOpen(false);
}; };
const [deleteNotifOpen, setDeleteNotifOpen] = useState(false); const [deleteNotifOpen, setDeleteNotifOpen] = useState(false);
const handleCloseDeleteNotif = (_, reason?: string) => { const handleCloseDeleteNotif = (_: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") { if (reason === "clickaway") {
return; return;
} }
@ -190,27 +174,29 @@ function ProjectList(): JSX.Element {
<div> <div>
<Dialog open={deleteDiagOpen} onClose={handleDeleteCancel}> <Dialog open={deleteDiagOpen} onClose={handleDeleteCancel}>
<DialogTitle> <DialogTitle>
Delete project <strong>{deleteProjName}</strong>? Delete project <strong>{deleteProj?.name}</strong>?
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
Deleting a project permanently removes its database file from disk. Deleting a project permanently removes all its data from the database. This action is irreversible.
This action is irreversible.
</DialogContentText> </DialogContentText>
{deleteProjErr && ( {deleteProjErr && <Alert severity="error">Error closing project: {deleteProjErr.message}</Alert>}
<Alert severity="error">
Error closing project: {deleteProjErr.message}
</Alert>
)}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleDeleteCancel} autoFocus> <Button onClick={handleDeleteCancel} autoFocus color="secondary" variant="contained">
Cancel Cancel
</Button> </Button>
<Button <Button
className={classes.deleteProjectButton} sx={{
color: "white",
backgroundColor: "error.main",
"&:hover": {
backgroundColor: "error.dark",
},
}}
onClick={handleDeleteConfirm} onClick={handleDeleteConfirm}
disabled={deleteProjLoading} disabled={deleteProjLoading}
variant="contained"
> >
Delete Delete
</Button> </Button>
@ -221,9 +207,10 @@ function ProjectList(): JSX.Element {
open={deleteNotifOpen} open={deleteNotifOpen}
autoHideDuration={3000} autoHideDuration={3000}
onClose={handleCloseDeleteNotif} onClose={handleCloseDeleteNotif}
anchorOrigin={{ horizontal: "center", vertical: "bottom" }}
> >
<Alert onClose={handleCloseDeleteNotif} severity="info"> <Alert onClose={handleCloseDeleteNotif} severity="info">
Project <strong>{deleteProjName}</strong> was deleted. Project <strong>{deleteProj?.name}</strong> was deleted.
</Alert> </Alert>
</Snackbar> </Snackbar>
@ -233,82 +220,70 @@ function ProjectList(): JSX.Element {
<Box mb={4}> <Box mb={4}>
{projLoading && <CircularProgress />} {projLoading && <CircularProgress />}
{projErr && ( {projErr && <Alert severity="error">Error fetching projects: {projErr.message}</Alert>}
<Alert severity="error"> {openProjErr && <Alert severity="error">Error opening project: {openProjErr.message}</Alert>}
Error fetching projects: {projErr.message} {closeProjErr && <Alert severity="error">Error closing project: {closeProjErr.message}</Alert>}
</Alert>
)}
{openProjErr && (
<Alert severity="error">
Error opening project: {openProjErr.message}
</Alert>
)}
{closeProjErr && (
<Alert severity="error">
Error closing project: {closeProjErr.message}
</Alert>
)}
</Box> </Box>
{projData?.projects.length > 0 && ( {projData && projData.projects.length > 0 && (
<List className={classes.projectsList}> <Paper>
{projData.projects.map((project) => ( <List>
<ListItem key={project.name}> {projData.projects.map((project) => (
<ListItemAvatar> <ListItem key={project.id}>
<Avatar <ListItemAvatar>
className={ <Avatar
project.isActive ? classes.activeProject : undefined sx={{
} ...(project.isActive && {
> color: theme.palette.secondary.dark,
<DescriptionIcon /> backgroundColor: theme.palette.primary.main,
</Avatar> }),
</ListItemAvatar> }}
<ListItemText> >
{project.name} {project.isActive && <em>(Active)</em>} <DescriptionIcon />
</ListItemText> </Avatar>
<ListItemSecondaryAction> </ListItemAvatar>
{project.isActive && ( <ListItemText>
<Tooltip title="Close project"> {project.name} {project.isActive && <em>(Active)</em>}
<IconButton onClick={() => closeProject()}> </ListItemText>
<CloseIcon /> <ListItemSecondaryAction>
</IconButton> {project.isActive && (
</Tooltip> <Tooltip title="Close project">
)} <IconButton onClick={() => closeProject()}>
{!project.isActive && ( <CloseIcon />
<Tooltip title="Open project"> </IconButton>
</Tooltip>
)}
{!project.isActive && (
<Tooltip title="Open project">
<span>
<IconButton
disabled={openProjLoading || projLoading}
onClick={() =>
openProject({
variables: { id: project.id },
})
}
>
<LaunchIcon />
</IconButton>
</span>
</Tooltip>
)}
<Tooltip title="Delete project">
<span> <span>
<IconButton <IconButton onClick={() => handleDeleteButtonClick(project)} disabled={project.isActive}>
disabled={openProjLoading || projLoading} <DeleteIcon />
onClick={() =>
openProject({
variables: { name: project.name },
})
}
>
<LaunchIcon />
</IconButton> </IconButton>
</span> </span>
</Tooltip> </Tooltip>
)} </ListItemSecondaryAction>
<Tooltip title="Delete project"> </ListItem>
<span> ))}
<IconButton </List>
onClick={() => handleDeleteButtonClick(project.name)} </Paper>
disabled={project.isActive}
>
<DeleteIcon />
</IconButton>
</span>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)} )}
{projData?.projects.length === 0 && ( {projData?.projects.length === 0 && (
<Alert severity="info"> <Alert severity="info">There are no projects. Create one to get started.</Alert>
There are no projects. Create one to get started.
</Alert>
)} )}
</div> </div>
); );

View File

@ -1,10 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Button from "@material-ui/core/Button"; import Button from "@mui/material/Button";
import Dialog from "@material-ui/core/Dialog"; import Dialog from "@mui/material/Dialog";
import DialogActions from "@material-ui/core/DialogActions"; import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@material-ui/core/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText"; import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@material-ui/core/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
export function useConfirmationDialog() { export function useConfirmationDialog() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -38,12 +38,10 @@ export function ConfirmationDialog(props: ConfirmationDialog) {
> >
<DialogTitle id="alert-dialog-title">Are you sure?</DialogTitle> <DialogTitle id="alert-dialog-title">Are you sure?</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText id="alert-dialog-description"> <DialogContentText id="alert-dialog-description">{children}</DialogContentText>
{children}
</DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose}>Abort</Button> <Button onClick={onClose}>Cancel</Button>
<Button onClick={confirm} autoFocus> <Button onClick={confirm} autoFocus>
Confirm Confirm
</Button> </Button>

View File

@ -1,7 +1,7 @@
import dynamic from "next/dynamic"; import MonacoEditor from "@monaco-editor/react";
const MonacoEditor = dynamic(import("react-monaco-editor"), { ssr: false }); import monaco from "monaco-editor/esm/vs/editor/editor.api";
const monacoOptions = { const monacoOptions: monaco.editor.IEditorOptions = {
readOnly: true, readOnly: true,
wordWrap: "on", wordWrap: "on",
minimap: { minimap: {
@ -11,20 +11,7 @@ const monacoOptions = {
type language = "html" | "typescript" | "json"; type language = "html" | "typescript" | "json";
function editorDidMount() { function languageForContentType(contentType?: string): language | undefined {
return ((window as any).MonacoEnvironment.getWorkerUrl = (
moduleId,
label
) => {
if (label === "json") return "/_next/static/json.worker.js";
if (label === "html") return "/_next/static/html.worker.js";
if (label === "javascript") return "/_next/static/ts.worker.js";
return "/_next/static/editor.worker.js";
});
}
function languageForContentType(contentType: string): language {
switch (contentType) { switch (contentType) {
case "text/html": case "text/html":
return "html"; return "html";
@ -41,7 +28,7 @@ function languageForContentType(contentType: string): language {
interface Props { interface Props {
content: string; content: string;
contentType: string; contentType?: string;
} }
function Editor({ content, contentType }: Props): JSX.Element { function Editor({ content, contentType }: Props): JSX.Element {
@ -50,8 +37,7 @@ function Editor({ content, contentType }: Props): JSX.Element {
height={"600px"} height={"600px"}
language={languageForContentType(contentType)} language={languageForContentType(contentType)}
theme="vs-dark" theme="vs-dark"
editorDidMount={editorDidMount} options={monacoOptions}
options={monacoOptions as any}
value={content} value={content}
/> />
); );

View File

@ -1,83 +1,66 @@
import { import { Table, TableBody, TableCell, TableContainer, TableRow, Snackbar } from "@mui/material";
makeStyles, import { Alert } from "@mui/lab";
Theme,
createStyles,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Snackbar,
} from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import React, { useState } from "react"; import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) => { const baseCellStyle = {
const paddingX = 0; px: 0,
const paddingY = theme.spacing(1) / 3; py: 0.33,
const tableCell = { verticalAlign: "top",
paddingLeft: paddingX, border: "none",
paddingRight: paddingX, whiteSpace: "nowrap" as any,
paddingTop: paddingY, overflow: "hidden",
paddingBottom: paddingY, textOverflow: "ellipsis",
verticalAlign: "top", "&:hover": {
border: "none", color: "primary.main",
whiteSpace: "nowrap" as any, whiteSpace: "inherit" as any,
overflow: "hidden", overflow: "inherit",
textOverflow: "ellipsis", textOverflow: "inherit",
"&:hover": { cursor: "copy",
color: theme.palette.secondary.main, },
whiteSpace: "inherit" as any, };
overflow: "inherit",
textOverflow: "inherit", const keyCellStyle = {
cursor: "copy", ...baseCellStyle,
}, pr: 1,
}; width: "40%",
return createStyles({ fontWeight: "bold",
root: {}, fontSize: ".75rem",
table: { };
tableLayout: "fixed",
width: "100%", const valueCellStyle = {
}, ...baseCellStyle,
keyCell: { width: "60%",
...tableCell, border: "none",
paddingRight: theme.spacing(1), fontSize: ".75rem",
width: "40%", };
fontWeight: "bold",
fontSize: ".75rem",
},
valueCell: {
...tableCell,
width: "60%",
border: "none",
fontSize: ".75rem",
},
});
});
interface Props { interface Props {
headers: Array<{ key: string; value: string }>; headers: Array<{ key: string; value: string }>;
} }
function HttpHeadersTable({ headers }: Props): JSX.Element { function HttpHeadersTable({ headers }: Props): JSX.Element {
const classes = useStyles();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
const windowSel = window.getSelection();
if (!windowSel || !document) {
return;
}
const r = document.createRange(); const r = document.createRange();
r.selectNode(e.currentTarget); r.selectNode(e.currentTarget);
window.getSelection().removeAllRanges(); windowSel.removeAllRanges();
window.getSelection().addRange(r); windowSel.addRange(r);
document.execCommand("copy"); document.execCommand("copy");
window.getSelection().removeAllRanges(); windowSel.removeAllRanges();
setOpen(true); setOpen(true);
}; };
const handleClose = (event?: React.SyntheticEvent, reason?: string) => { const handleClose = (event: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") { if (reason === "clickaway") {
return; return;
} }
@ -92,20 +75,21 @@ function HttpHeadersTable({ headers }: Props): JSX.Element {
Copied to clipboard. Copied to clipboard.
</Alert> </Alert>
</Snackbar> </Snackbar>
<TableContainer className={classes.root}> <TableContainer>
<Table className={classes.table} size="small"> <Table
sx={{
tableLayout: "fixed",
width: "100%",
}}
size="small"
>
<TableBody> <TableBody>
{headers.map(({ key, value }, index) => ( {headers.map(({ key, value }, index) => (
<TableRow key={index}> <TableRow key={index}>
<TableCell <TableCell component="th" scope="row" sx={keyCellStyle} onClick={handleClick}>
component="th"
scope="row"
className={classes.keyCell}
onClick={handleClick}
>
<code>{key}:</code> <code>{key}:</code>
</TableCell> </TableCell>
<TableCell className={classes.valueCell} onClick={handleClick}> <TableCell sx={valueCellStyle} onClick={handleClick}>
<code>{value}</code> <code>{value}</code>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@ -1,31 +1,25 @@
import { Theme, withTheme } from "@material-ui/core"; import { SvgIconTypeMap } from "@mui/material";
import { orange, red } from "@material-ui/core/colors"; import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import FiberManualRecordIcon from "@material-ui/icons/FiberManualRecord";
interface Props { interface Props {
status: number; status: number;
theme: Theme;
} }
function HttpStatusIcon({ status, theme }: Props): JSX.Element { export default function HttpStatusIcon({ status }: Props): JSX.Element {
const style = { marginTop: "-.25rem", verticalAlign: "middle" }; let color: SvgIconTypeMap["props"]["color"] = "inherit";
switch (Math.floor(status / 100)) { switch (Math.floor(status / 100)) {
case 2: case 2:
case 3: case 3:
return ( color = "primary";
<FiberManualRecordIcon break;
style={{ ...style, color: theme.palette.secondary.main }}
/>
);
case 4: case 4:
return ( color = "warning";
<FiberManualRecordIcon style={{ ...style, color: orange["A400"] }} /> break;
);
case 5: case 5:
return <FiberManualRecordIcon style={{ ...style, color: red["A400"] }} />; color = "error";
default: break;
return <FiberManualRecordIcon style={style} />;
} }
}
export default withTheme(HttpStatusIcon); return <FiberManualRecordIcon sx={{ marginTop: "-.25rem", verticalAlign: "middle" }} color={color} />;
}

View File

@ -1,9 +1,9 @@
import { gql, useQuery } from "@apollo/client"; import { gql, useQuery } from "@apollo/client";
import { Box, Grid, Paper, CircularProgress } from "@material-ui/core"; import { Box, Grid, Paper, CircularProgress } from "@mui/material";
import ResponseDetail from "./ResponseDetail"; import ResponseDetail from "./ResponseDetail";
import RequestDetail from "./RequestDetail"; import RequestDetail from "./RequestDetail";
import Alert from "@material-ui/lab/Alert"; import Alert from "@mui/lab/Alert";
const HTTP_REQUEST_LOG = gql` const HTTP_REQUEST_LOG = gql`
query HttpRequestLog($id: ID!) { query HttpRequestLog($id: ID!) {
@ -32,7 +32,7 @@ const HTTP_REQUEST_LOG = gql`
`; `;
interface Props { interface Props {
requestId: number; requestId: string;
} }
function LogDetail({ requestId: id }: Props): JSX.Element { function LogDetail({ requestId: id }: Props): JSX.Element {
@ -44,11 +44,7 @@ function LogDetail({ requestId: id }: Props): JSX.Element {
return <CircularProgress />; return <CircularProgress />;
} }
if (error) { if (error) {
return ( return <Alert severity="error">Error fetching logs details: {error.message}</Alert>;
<Alert severity="error">
Error fetching logs details: {error.message}
</Alert>
);
} }
if (!data.httpRequestLog) { if (!data.httpRequestLog) {

View File

@ -1,12 +1,7 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
import { import { Box, CircularProgress, Link as MaterialLink, Typography } from "@mui/material";
Box, import Alert from "@mui/lab/Alert";
CircularProgress,
Link as MaterialLink,
Typography,
} from "@material-ui/core";
import Alert from "@material-ui/lab/Alert";
import RequestList from "./RequestList"; import RequestList from "./RequestList";
import LogDetail from "./LogDetail"; import LogDetail from "./LogDetail";
@ -15,12 +10,10 @@ import { useHttpRequestLogs } from "./hooks/useHttpRequestLogs";
function LogsOverview(): JSX.Element { function LogsOverview(): JSX.Element {
const router = useRouter(); const router = useRouter();
const detailReqLogId = const detailReqLogId = router.query.id as string | undefined;
router.query.id && parseInt(router.query.id as string, 10);
const { loading, error, data } = useHttpRequestLogs(); const { loading, error, data } = useHttpRequestLogs();
const handleLogClick = (reqId: number) => { const handleLogClick = (reqId: string) => {
router.push("/proxy/logs?id=" + reqId, undefined, { router.push("/proxy/logs?id=" + reqId, undefined, {
shallow: false, shallow: false,
}); });
@ -35,7 +28,7 @@ function LogsOverview(): JSX.Element {
<Alert severity="info"> <Alert severity="info">
There is no project active.{" "} There is no project active.{" "}
<Link href="/projects" passHref> <Link href="/projects" passHref>
<MaterialLink color="secondary">Create or open</MaterialLink> <MaterialLink color="primary">Create or open</MaterialLink>
</Link>{" "} </Link>{" "}
one first. one first.
</Alert> </Alert>
@ -49,11 +42,7 @@ function LogsOverview(): JSX.Element {
return ( return (
<div> <div>
<Box mb={2}> <Box mb={2}>
<RequestList <RequestList logs={logs || []} selectedReqLogId={detailReqLogId} onLogClick={handleLogClick} />
logs={logs}
selectedReqLogId={detailReqLogId}
onLogClick={handleLogClick}
/>
</Box> </Box>
<Box> <Box>
{detailReqLogId && <LogDetail requestId={detailReqLogId} />} {detailReqLogId && <LogDetail requestId={detailReqLogId} />}

View File

@ -1,42 +1,9 @@
import React from "react"; import React from "react";
import { import { Typography, Box, Divider } from "@mui/material";
Typography,
Box,
createStyles,
makeStyles,
Theme,
Divider,
} from "@material-ui/core";
import HttpHeadersTable from "./HttpHeadersTable"; import HttpHeadersTable from "./HttpHeadersTable";
import Editor from "./Editor"; import Editor from "./Editor";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
requestTitle: {
width: "calc(100% - 80px)",
fontSize: "1rem",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
},
headersTable: {
tableLayout: "fixed",
width: "100%",
},
headerKeyCell: {
verticalAlign: "top",
width: "30%",
fontWeight: "bold",
},
headerValueCell: {
width: "70%",
verticalAlign: "top",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
},
})
);
interface Props { interface Props {
request: { request: {
method: string; method: string;
@ -49,29 +16,27 @@ interface Props {
function RequestDetail({ request }: Props): JSX.Element { function RequestDetail({ request }: Props): JSX.Element {
const { method, url, proto, headers, body } = request; const { method, url, proto, headers, body } = request;
const classes = useStyles();
const contentType = headers.find((header) => header.key === "Content-Type") const contentType = headers.find((header) => header.key === "Content-Type")?.value;
?.value;
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
return ( return (
<div> <div>
<Box p={2}> <Box p={2}>
<Typography <Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
variant="overline"
color="textSecondary"
style={{ float: "right" }}
>
Request Request
</Typography> </Typography>
<Typography className={classes.requestTitle} variant="h6"> <Typography
sx={{
width: "calc(100% - 80px)",
fontSize: "1rem",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
}}
variant="h6"
>
{method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}{" "} {method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}{" "}
<Typography <Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
component="span"
color="textSecondary"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{proto} {proto}
</Typography> </Typography>
</Typography> </Typography>

View File

@ -8,48 +8,23 @@ import {
TableBody, TableBody,
Typography, Typography,
Box, Box,
createStyles, useTheme,
makeStyles, } from "@mui/material";
Theme,
withTheme,
} from "@material-ui/core";
import HttpStatusIcon from "./HttpStatusCode"; import HttpStatusIcon from "./HttpStatusCode";
import CenteredPaper from "../CenteredPaper"; import CenteredPaper from "../CenteredPaper";
import { RequestLog } from "../../lib/requestLogs";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
row: {
"&:hover": {
cursor: "pointer",
},
},
/* Pseudo-class applied to the root element if `hover={true}`. */
hover: {},
})
);
interface Props { interface Props {
logs: Array<any>; logs: RequestLog[];
selectedReqLogId?: number; selectedReqLogId?: string;
onLogClick(requestId: number): void; onLogClick(requestId: string): void;
theme: Theme;
} }
function RequestList({ export default function RequestList({ logs, onLogClick, selectedReqLogId }: Props): JSX.Element {
logs,
onLogClick,
selectedReqLogId,
theme,
}: Props): JSX.Element {
return ( return (
<div> <div>
<RequestListTable <RequestListTable onLogClick={onLogClick} logs={logs} selectedReqLogId={selectedReqLogId} />
onLogClick={onLogClick}
logs={logs}
selectedReqLogId={selectedReqLogId}
theme={theme}
/>
{logs.length === 0 && ( {logs.length === 0 && (
<Box my={1}> <Box my={1}>
<CenteredPaper> <CenteredPaper>
@ -62,19 +37,14 @@ function RequestList({
} }
interface RequestListTableProps { interface RequestListTableProps {
logs?: any; logs: RequestLog[];
selectedReqLogId?: number; selectedReqLogId?: string;
onLogClick(requestId: number): void; onLogClick(requestId: string): void;
theme: Theme;
} }
function RequestListTable({ function RequestListTable({ logs, selectedReqLogId, onLogClick }: RequestListTableProps): JSX.Element {
logs, const theme = useTheme();
selectedReqLogId,
onLogClick,
theme,
}: RequestListTableProps): JSX.Element {
const classes = useStyles();
return ( return (
<TableContainer <TableContainer
component={Paper} component={Paper}
@ -102,26 +72,25 @@ function RequestListTable({
textOverflow: "ellipsis", textOverflow: "ellipsis",
} as any; } as any;
const rowStyle = {
backgroundColor:
id === selectedReqLogId && theme.palette.action.selected,
};
return ( return (
<TableRow <TableRow
key={id} key={id}
className={classes.row} sx={{
style={rowStyle} "&:hover": {
cursor: "pointer",
},
...(id === selectedReqLogId && {
bgcolor: theme.palette.action.selected,
}),
}}
hover hover
onClick={() => onLogClick(id)} onClick={() => onLogClick(id)}
> >
<TableCell style={{ ...cellStyle, width: "100px" }}> <TableCell style={{ ...cellStyle, width: "100px" }}>
<code>{method}</code> <code>{method}</code>
</TableCell> </TableCell>
<TableCell style={{ ...cellStyle, maxWidth: "100px" }}> <TableCell sx={{ ...cellStyle, maxWidth: "100px" }}>{origin}</TableCell>
{origin} <TableCell sx={{ ...cellStyle, maxWidth: "200px" }}>
</TableCell>
<TableCell style={{ ...cellStyle, maxWidth: "200px" }}>
{decodeURIComponent(pathname + search + hash)} {decodeURIComponent(pathname + search + hash)}
</TableCell> </TableCell>
<TableCell style={{ maxWidth: "100px" }}> <TableCell style={{ maxWidth: "100px" }}>
@ -142,5 +111,3 @@ function RequestListTable({
</TableContainer> </TableContainer>
); );
} }
export default withTheme(RequestList);

View File

@ -1,4 +1,4 @@
import { Typography, Box, Divider } from "@material-ui/core"; import { Typography, Box, Divider } from "@mui/material";
import HttpStatusIcon from "./HttpStatusCode"; import HttpStatusIcon from "./HttpStatusCode";
import Editor from "./Editor"; import Editor from "./Editor";
@ -15,30 +15,17 @@ interface Props {
} }
function ResponseDetail({ response }: Props): JSX.Element { function ResponseDetail({ response }: Props): JSX.Element {
const contentType = response.headers.find( const contentType = response.headers.find((header) => header.key === "Content-Type")?.value;
(header) => header.key === "Content-Type"
)?.value;
return ( return (
<div> <div>
<Box p={2}> <Box p={2}>
<Typography <Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
variant="overline"
color="textSecondary"
style={{ float: "right" }}
>
Response Response
</Typography> </Typography>
<Typography <Typography variant="h6" style={{ fontSize: "1rem", whiteSpace: "nowrap" }}>
variant="h6"
style={{ fontSize: "1rem", whiteSpace: "nowrap" }}
>
<HttpStatusIcon status={response.statusCode} />{" "} <HttpStatusIcon status={response.statusCode} />{" "}
<Typography component="span" color="textSecondary"> <Typography component="span" color="textSecondary">
<Typography <Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
component="span"
color="textSecondary"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{response.proto} {response.proto}
</Typography> </Typography>
</Typography>{" "} </Typography>{" "}
@ -52,9 +39,7 @@ function ResponseDetail({ response }: Props): JSX.Element {
<HttpHeadersTable headers={response.headers} /> <HttpHeadersTable headers={response.headers} />
</Box> </Box>
{response.body && ( {response.body && <Editor content={response.body} contentType={contentType} />}
<Editor content={response.body} contentType={contentType} />
)}
</div> </div>
); );
} }

View File

@ -3,29 +3,23 @@ import {
Checkbox, Checkbox,
CircularProgress, CircularProgress,
ClickAwayListener, ClickAwayListener,
createStyles,
FormControlLabel, FormControlLabel,
InputBase, InputBase,
makeStyles,
Paper, Paper,
Popper, Popper,
Theme,
Tooltip, Tooltip,
useTheme, useTheme,
} from "@material-ui/core"; } from "@mui/material";
import IconButton from "@material-ui/core/IconButton"; import IconButton from "@mui/material/IconButton";
import SearchIcon from "@material-ui/icons/Search"; import SearchIcon from "@mui/icons-material/Search";
import FilterListIcon from "@material-ui/icons/FilterList"; import FilterListIcon from "@mui/icons-material/FilterList";
import DeleteIcon from "@material-ui/icons/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { gql, useMutation, useQuery } from "@apollo/client"; import { gql, useMutation, useQuery } from "@apollo/client";
import { withoutTypename } from "../../lib/omitTypename"; import { withoutTypename } from "../../lib/omitTypename";
import { Alert } from "@material-ui/lab"; import { Alert } from "@mui/lab";
import { useClearHTTPRequestLog } from "./hooks/useClearHTTPRequestLog"; import { useClearHTTPRequestLog } from "./hooks/useClearHTTPRequestLog";
import { import { ConfirmationDialog, useConfirmationDialog } from "./ConfirmationDialog";
ConfirmationDialog,
useConfirmationDialog,
} from "./ConfirmationDialog";
const FILTER = gql` const FILTER = gql`
query HttpRequestLogFilter { query HttpRequestLogFilter {
@ -45,78 +39,43 @@ const SET_FILTER = gql`
} }
`; `;
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
padding: "2px 4px",
display: "flex",
alignItems: "center",
width: 400,
},
input: {
marginLeft: theme.spacing(1),
flex: 1,
},
iconButton: {
padding: 10,
},
filterPopper: {
width: 400,
marginTop: 6,
zIndex: 99,
},
filterOptions: {
padding: theme.spacing(2),
},
filterLoading: {
marginRight: 1,
color: theme.palette.text.primary,
},
})
);
export interface SearchFilter { export interface SearchFilter {
onlyInScope: boolean; onlyInScope: boolean;
searchExpression: string; searchExpression: string;
} }
function Search(): JSX.Element { function Search(): JSX.Element {
const classes = useStyles();
const theme = useTheme(); const theme = useTheme();
const [searchExpr, setSearchExpr] = useState(""); const [searchExpr, setSearchExpr] = useState("");
const { loading: filterLoading, error: filterErr, data: filter } = useQuery( const {
FILTER, loading: filterLoading,
{ error: filterErr,
onCompleted: (data) => { data: filter,
setSearchExpr(data.httpRequestLogFilter.searchExpression || ""); } = useQuery(FILTER, {
}, onCompleted: (data) => {
} setSearchExpr(data.httpRequestLogFilter?.searchExpression || "");
);
const [
setFilterMutate,
{ error: setFilterErr, loading: setFilterLoading },
] = useMutation<{
setHttpRequestLogFilter: SearchFilter | null;
}>(SET_FILTER, {
update(cache, { data: { setHttpRequestLogFilter } }) {
cache.writeQuery({
query: FILTER,
data: {
httpRequestLogFilter: setHttpRequestLogFilter,
},
});
}, },
}); });
const [ const [setFilterMutate, { error: setFilterErr, loading: setFilterLoading }] = useMutation<{
clearHTTPRequestLog, setHttpRequestLogFilter: SearchFilter | null;
clearHTTPRequestLogResult, }>(SET_FILTER, {
] = useClearHTTPRequestLog(); update(cache, { data }) {
cache.writeQuery({
query: FILTER,
data: {
httpRequestLogFilter: data?.setHttpRequestLogFilter,
},
});
},
onError: () => {},
});
const [clearHTTPRequestLog, clearHTTPRequestLogResult] = useClearHTTPRequestLog();
const clearHTTPConfirmationDialog = useConfirmationDialog(); const clearHTTPConfirmationDialog = useConfirmationDialog();
const filterRef = useRef<HTMLElement | null>(); const filterRef = useRef<HTMLFormElement>(null);
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const handleSubmit = (e: React.SyntheticEvent) => { const handleSubmit = (e: React.SyntheticEvent) => {
@ -132,8 +91,8 @@ function Search(): JSX.Element {
e.preventDefault(); e.preventDefault();
}; };
const handleClickAway = (event: React.MouseEvent<EventTarget>) => { const handleClickAway = (event: MouseEvent | TouchEvent) => {
if (filterRef.current.contains(event.target as HTMLElement)) { if (filterRef?.current && filterRef.current.contains(event.target as HTMLElement)) {
return; return;
} }
setFilterOpen(false); setFilterOpen(false);
@ -143,63 +102,67 @@ function Search(): JSX.Element {
<Box> <Box>
<Error prefix="Error fetching filter" error={filterErr} /> <Error prefix="Error fetching filter" error={filterErr} />
<Error prefix="Error setting filter" error={setFilterErr} /> <Error prefix="Error setting filter" error={setFilterErr} />
<Error <Error prefix="Error clearing all HTTP logs" error={clearHTTPRequestLogResult.error} />
prefix="Error clearing all HTTP logs"
error={clearHTTPRequestLogResult.error}
/>
<Box style={{ display: "flex", flex: 1 }}> <Box style={{ display: "flex", flex: 1 }}>
<ClickAwayListener onClickAway={handleClickAway}> <ClickAwayListener onClickAway={handleClickAway}>
<Paper <Paper
component="form" component="form"
onSubmit={handleSubmit} onSubmit={handleSubmit}
ref={filterRef} ref={filterRef}
className={classes.root} sx={{
padding: "2px 4px",
display: "flex",
alignItems: "center",
width: 400,
}}
> >
<Tooltip title="Toggle filter options"> <Tooltip title="Toggle filter options">
<IconButton <IconButton
className={classes.iconButton}
onClick={() => setFilterOpen(!filterOpen)} onClick={() => setFilterOpen(!filterOpen)}
style={{ sx={{
color: filter?.httpRequestLogFilter?.onlyInScope p: 1,
? theme.palette.secondary.main color: filter?.httpRequestLogFilter?.onlyInScope ? "primary.main" : "inherit",
: "inherit",
}} }}
> >
{filterLoading || setFilterLoading ? ( {filterLoading || setFilterLoading ? (
<CircularProgress <CircularProgress sx={{ color: theme.palette.text.primary }} size={23} />
className={classes.filterLoading}
size={23}
/>
) : ( ) : (
<FilterListIcon /> <FilterListIcon />
)} )}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<InputBase <InputBase
className={classes.input} sx={{
ml: 1,
flex: 1,
}}
placeholder="Search proxy logs…" placeholder="Search proxy logs…"
value={searchExpr} value={searchExpr}
onChange={(e) => setSearchExpr(e.target.value)} onChange={(e) => setSearchExpr(e.target.value)}
onFocus={() => setFilterOpen(true)} onFocus={() => setFilterOpen(true)}
/> />
<Tooltip title="Search"> <Tooltip title="Search">
<IconButton type="submit" className={classes.iconButton}> <IconButton type="submit" sx={{ padding: 1.25 }}>
<SearchIcon /> <SearchIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Popper <Popper
className={classes.filterPopper}
open={filterOpen} open={filterOpen}
anchorEl={filterRef.current} anchorEl={filterRef.current}
placement="bottom-start" placement="bottom"
style={{ zIndex: theme.zIndex.appBar }}
> >
<Paper className={classes.filterOptions}> <Paper
sx={{
width: 400,
marginTop: 0.5,
p: 1.5,
}}
>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
checked={ checked={filter?.httpRequestLogFilter?.onlyInScope ? true : false}
filter?.httpRequestLogFilter?.onlyInScope ? true : false
}
disabled={filterLoading || setFilterLoading} disabled={filterLoading || setFilterLoading}
onChange={(e) => onChange={(e) =>
setFilterMutate({ setFilterMutate({

View File

@ -3,20 +3,18 @@ import {
Box, Box,
Button, Button,
CircularProgress, CircularProgress,
createStyles,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
FormLabel, FormLabel,
makeStyles,
Radio, Radio,
RadioGroup, RadioGroup,
TextField, TextField,
Theme, } from "@mui/material";
} from "@material-ui/core"; import AddIcon from "@mui/icons-material/Add";
import AddIcon from "@material-ui/icons/Add"; import { Alert } from "@mui/lab";
import { Alert } from "@material-ui/lab";
import React from "react"; import React from "react";
import { SCOPE } from "./Rules"; import { SCOPE } from "./Rules";
import { ScopeRule } from "../../lib/scope";
const SET_SCOPE = gql` const SET_SCOPE = gql`
mutation SetScope($scope: [ScopeRuleInput!]!) { mutation SetScope($scope: [ScopeRuleInput!]!) {
@ -26,25 +24,15 @@ const SET_SCOPE = gql`
} }
`; `;
const useStyles = makeStyles((theme: Theme) =>
createStyles({
ruleExpression: {
fontFamily: "'JetBrains Mono', monospace",
},
})
);
function AddRule(): JSX.Element { function AddRule(): JSX.Element {
const classes = useStyles();
const [ruleType, setRuleType] = React.useState("url"); const [ruleType, setRuleType] = React.useState("url");
const [expression, setExpression] = React.useState(null); const [expression, setExpression] = React.useState("");
const client = useApolloClient(); const client = useApolloClient();
const [setScope, { error, loading }] = useMutation(SET_SCOPE, { const [setScope, { error, loading }] = useMutation(SET_SCOPE, {
onError() {}, onError() {},
onCompleted() { onCompleted() {
expression.value = ""; setExpression("");
}, },
update(_, { data: { setScope } }) { update(_, { data: { setScope } }) {
client.writeQuery({ client.writeQuery({
@ -59,21 +47,20 @@ function AddRule(): JSX.Element {
}; };
const handleSubmit = (e: React.SyntheticEvent) => { const handleSubmit = (e: React.SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
let scope = []; let scope: ScopeRule[] = [];
try { try {
const data = client.readQuery({ const data = client.readQuery<{ scope: ScopeRule[] }>({
query: SCOPE, query: SCOPE,
}); });
scope = data.scope; if (data) {
scope = data.scope;
}
} catch (e) {} } catch (e) {}
setScope({ setScope({
variables: { variables: {
scope: [ scope: [...scope.map(({ url }) => ({ url })), { url: expression }],
...scope.map(({ url }) => ({ url })),
{ url: expression.value },
],
}, },
}); });
}; };
@ -87,15 +74,10 @@ function AddRule(): JSX.Element {
)} )}
<form onSubmit={handleSubmit} autoComplete="off"> <form onSubmit={handleSubmit} autoComplete="off">
<FormControl fullWidth> <FormControl fullWidth>
<FormLabel color="secondary" component="legend"> <FormLabel color="primary" component="legend">
Rule Type Rule Type
</FormLabel> </FormLabel>
<RadioGroup <RadioGroup row name="ruleType" value={ruleType} onChange={handleTypeChange}>
row
name="ruleType"
value={ruleType}
onChange={handleTypeChange}
>
<FormControlLabel value="url" control={<Radio />} label="URL" /> <FormControlLabel value="url" control={<Radio />} label="URL" />
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
@ -104,20 +86,17 @@ function AddRule(): JSX.Element {
label="Expression" label="Expression"
placeholder="^https:\/\/(.*)example.com(.*)" placeholder="^https:\/\/(.*)example.com(.*)"
helperText="Regular expression to match on." helperText="Regular expression to match on."
color="secondary" color="primary"
variant="outlined" variant="outlined"
required required
value={expression}
onChange={(e) => setExpression(e.target.value)}
InputProps={{ InputProps={{
className: classes.ruleExpression, sx: { fontFamily: "'JetBrains Mono', monospace" },
}} }}
InputLabelProps={{ InputLabelProps={{
shrink: true, shrink: true,
}} }}
inputProps={{
ref: (node) => {
setExpression(node);
},
}}
margin="normal" margin="normal"
/> />
</FormControl> </FormControl>
@ -125,7 +104,7 @@ function AddRule(): JSX.Element {
<Button <Button
type="submit" type="submit"
variant="contained" variant="contained"
color="secondary" color="primary"
disabled={loading} disabled={loading}
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />} startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
> >

View File

@ -8,11 +8,12 @@ import {
ListItemSecondaryAction, ListItemSecondaryAction,
ListItemText, ListItemText,
Tooltip, Tooltip,
} from "@material-ui/core"; } from "@mui/material";
import CodeIcon from "@material-ui/icons/Code"; import CodeIcon from "@mui/icons-material/Code";
import DeleteIcon from "@material-ui/icons/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import React from "react"; import React from "react";
import { SCOPE } from "./Rules"; import { SCOPE } from "./Rules";
import { ScopeRule } from "../../lib/scope";
const SET_SCOPE = gql` const SET_SCOPE = gql`
mutation SetScope($scope: [ScopeRuleInput!]!) { mutation SetScope($scope: [ScopeRuleInput!]!) {
@ -22,7 +23,13 @@ const SET_SCOPE = gql`
} }
`; `;
function RuleListItem({ scope, rule, index }): JSX.Element { type RuleListItemProps = {
scope: ScopeRule[];
rule: ScopeRule;
index: number;
};
function RuleListItem({ scope, rule, index }: RuleListItemProps): JSX.Element {
const client = useApolloClient(); const client = useApolloClient();
const [setScope, { loading }] = useMutation(SET_SCOPE, { const [setScope, { loading }] = useMutation(SET_SCOPE, {
update(_, { data: { setScope } }) { update(_, { data: { setScope } }) {
@ -65,8 +72,8 @@ function RuleListItem({ scope, rule, index }): JSX.Element {
); );
} }
function RuleListItemText({ rule }): JSX.Element { function RuleListItemText({ rule }: { rule: ScopeRule }): JSX.Element {
let text: JSX.Element; let text: JSX.Element = <div></div>;
if (rule.url) { if (rule.url) {
text = <code>{rule.url}</code>; text = <code>{rule.url}</code>;
@ -77,10 +84,14 @@ function RuleListItemText({ rule }): JSX.Element {
return <ListItemText>{text}</ListItemText>; return <ListItemText>{text}</ListItemText>;
} }
function RuleTypeChip({ rule }): JSX.Element { function RuleTypeChip({ rule }: { rule: ScopeRule }): JSX.Element {
let label = "Unknown";
if (rule.url) { if (rule.url) {
return <Chip label="URL" variant="outlined" />; label = "URL";
} }
return <Chip label={label} variant="outlined" />;
} }
export default RuleListItem; export default RuleListItem;

View File

@ -1,22 +1,9 @@
import { gql, useQuery } from "@apollo/client"; import { gql, useQuery } from "@apollo/client";
import { import { CircularProgress, List } from "@mui/material";
CircularProgress, import { Alert } from "@mui/lab";
createStyles,
List,
makeStyles,
Theme,
} from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import React from "react"; import React from "react";
import RuleListItem from "./RuleListItem"; import RuleListItem from "./RuleListItem";
import { ScopeRule } from "../../lib/scope";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
rulesList: {
backgroundColor: theme.palette.background.paper,
},
})
);
export const SCOPE = gql` export const SCOPE = gql`
query Scope { query Scope {
@ -27,24 +14,20 @@ export const SCOPE = gql`
`; `;
function Rules(): JSX.Element { function Rules(): JSX.Element {
const classes = useStyles(); const { loading, error, data } = useQuery<{ scope: ScopeRule[] }>(SCOPE);
const { loading, error, data } = useQuery(SCOPE);
return ( return (
<div> <div>
{loading && <CircularProgress />} {loading && <CircularProgress />}
{error && ( {error && <Alert severity="error">Error fetching scope: {error.message}</Alert>}
<Alert severity="error">Error fetching scope: {error.message}</Alert> {data && data.scope.length > 0 && (
)} <List
{data?.scope.length > 0 && ( sx={{
<List className={classes.rulesList}> bgcolor: "background.paper",
}}
>
{data.scope.map((rule, index) => ( {data.scope.map((rule, index) => (
<RuleListItem <RuleListItem key={index} rule={rule} scope={data.scope} index={index} />
key={index}
rule={rule}
scope={data.scope}
index={index}
/>
))} ))}
</List> </List>
)} )}

5
admin/src/lib/Project.ts Normal file
View File

@ -0,0 +1,5 @@
export type Project = {
id: string
name: string
isActive: boolean
}

View File

@ -0,0 +1,7 @@
import createCache from "@emotion/cache";
// prepend: true moves MUI styles to the top of the <head> so they're loaded first.
// It allows developers to easily override MUI styles with other styling solutions, like CSS modules.
export default function createEmotionCache() {
return createCache({ key: "css", prepend: true });
}

View File

@ -1,7 +1,11 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; import { ApolloClient, HttpLink, InMemoryCache, NormalizedCacheObject } from "@apollo/client";
import merge from "deepmerge";
import isEqual from "lodash/isEqual";
let apolloClient; export const APOLLO_STATE_PROP_NAME = "__APOLLO_STATE__";
let apolloClient: ApolloClient<NormalizedCacheObject>;
function createApolloClient() { function createApolloClient() {
return new ApolloClient({ return new ApolloClient({
@ -9,13 +13,7 @@ function createApolloClient() {
link: new HttpLink({ link: new HttpLink({
uri: "/api/graphql/", uri: "/api/graphql/",
}), }),
cache: new InMemoryCache({ cache: new InMemoryCache(),
typePolicies: {
Project: {
keyFields: ["name"],
},
},
}),
}); });
} }
@ -27,9 +25,18 @@ export function initializeApollo(initialState = null) {
if (initialState) { if (initialState) {
// Get existing cache, loaded during client side data fetching // Get existing cache, loaded during client side data fetching
const existingCache = _apolloClient.extract(); const existingCache = _apolloClient.extract();
// Restore the cache using the data passed from getStaticProps/getServerSideProps
// combined with the existing cached data // Merge the existing cache into data passed from getStaticProps/getServerSideProps
_apolloClient.cache.restore({ ...existingCache, ...initialState }); const data = merge(initialState, existingCache, {
// combine arrays using object equality (like in sets)
arrayMerge: (destinationArray, sourceArray) => [
...sourceArray,
...destinationArray.filter((d) => sourceArray.every((s) => !isEqual(d, s))),
],
});
// Restore the cache with the merged data
_apolloClient.cache.restore(data);
} }
// For SSG and SSR always create a new Apollo Client // For SSG and SSR always create a new Apollo Client
if (typeof window === "undefined") return _apolloClient; if (typeof window === "undefined") return _apolloClient;
@ -39,7 +46,16 @@ export function initializeApollo(initialState = null) {
return _apolloClient; return _apolloClient;
} }
export function useApollo(initialState) { export function addApolloState(client: ApolloClient<NormalizedCacheObject>, pageProps: any) {
const store = useMemo(() => initializeApollo(initialState), [initialState]); if (pageProps?.props) {
pageProps.props[APOLLO_STATE_PROP_NAME] = client.cache.extract();
}
return pageProps;
}
export function useApollo(pageProps: any) {
const state = pageProps[APOLLO_STATE_PROP_NAME];
const store = useMemo(() => initializeApollo(state), [state]);
return store; return store;
} }

View File

@ -1,4 +1,4 @@
const omitTypename = (key, value) => (key === "__typename" ? undefined : value); const omitTypename = (key: string, value: any) => (key === "__typename" ? undefined : value);
export function withoutTypename(input: any): any { export function withoutTypename(input: any): any {
return JSON.parse(JSON.stringify(input), omitTypename); return JSON.parse(JSON.stringify(input), omitTypename);

View File

@ -0,0 +1,24 @@
export type RequestLog = {
id: string
url: string
method: string
proto: string
headers: HTTPHeader[]
body?: string
timestamp: string
response?: ResponseLog
}
export type ResponseLog = {
proto: string
statusCode: number
statusReason: string
body?: string
headers: HTTPHeader[]
}
export type HTTPHeader = {
key: string
value: string
}

3
admin/src/lib/scope.ts Normal file
View File

@ -0,0 +1,3 @@
export type ScopeRule = {
url?: string
}

View File

@ -1,49 +1,51 @@
import { createMuiTheme } from "@material-ui/core/styles"; import { createTheme } from "@mui/material/styles";
import grey from "@material-ui/core/colors/grey"; import * as colors from "@mui/material/colors";
import teal from "@material-ui/core/colors/teal";
const theme = createMuiTheme({ const heading = {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
};
let theme = createTheme({
palette: { palette: {
type: "dark", mode: "dark",
primary: { primary: {
main: grey[900], main: colors.teal["A400"],
}, },
secondary: { secondary: {
main: teal["A400"], main: colors.grey[900],
}, light: "#333",
info: { dark: colors.common.black,
main: teal["A400"],
},
success: {
main: teal["A400"],
}, },
}, },
typography: { typography: {
h2: { h2: heading,
fontFamily: "'JetBrains Mono', monospace", h3: heading,
fontWeight: 600, h4: heading,
h5: heading,
h6: heading,
},
});
theme = createTheme(theme, {
palette: {
background: {
default: theme.palette.secondary.main,
paper: theme.palette.secondary.light,
}, },
h3: { info: {
fontFamily: "'JetBrains Mono', monospace", main: theme.palette.primary.main,
fontWeight: 600,
}, },
h4: { success: {
fontFamily: "'JetBrains Mono', monospace", main: theme.palette.primary.main,
fontWeight: 600,
},
h5: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
},
h6: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
}, },
}, },
overrides: { components: {
MuiTableCell: { MuiTableCell: {
stickyHeader: { styleOverrides: {
backgroundColor: grey[900], stickyHeader: {
backgroundColor: theme.palette.secondary.dark,
},
}, },
}, },
}, },

View File

@ -1,32 +1,31 @@
import React from "react"; import * as React from "react";
import Head from "next/head";
import { AppProps } from "next/app"; import { AppProps } from "next/app";
import { ApolloProvider } from "@apollo/client"; import { ApolloProvider } from "@apollo/client";
import Head from "next/head"; import { ThemeProvider } from "@mui/material/styles";
import { ThemeProvider } from "@material-ui/core/styles"; import CssBaseline from "@mui/material/CssBaseline";
import CssBaseline from "@material-ui/core/CssBaseline"; import { CacheProvider, EmotionCache } from "@emotion/react";
import createEmotionCache from "../lib/createEmotionCache";
import theme from "../lib/theme"; import theme from "../lib/theme";
import { useApollo } from "../lib/graphql"; import { useApollo } from "../lib/graphql";
function App({ Component, pageProps }: AppProps): JSX.Element { // Client-side cache, shared for the whole session of the user in the browser.
const apolloClient = useApollo(pageProps.initialApolloState); const clientSideEmotionCache = createEmotionCache();
React.useEffect(() => { interface MyAppProps extends AppProps {
// Remove the server-side injected CSS. emotionCache?: EmotionCache;
const jssStyles = document.querySelector("#jss-server-side"); }
if (jssStyles) {
jssStyles.parentElement.removeChild(jssStyles); export default function MyApp(props: MyAppProps) {
} const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
}, []); const apolloClient = useApollo(pageProps);
return ( return (
<React.Fragment> <CacheProvider value={emotionCache}>
<Head> <Head>
<title>Hetty://</title> <title>Hetty://</title>
<meta <meta name="viewport" content="initial-scale=1, width=device-width" />
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width"
/>
</Head> </Head>
<ApolloProvider client={apolloClient}> <ApolloProvider client={apolloClient}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
@ -34,8 +33,6 @@ function App({ Component, pageProps }: AppProps): JSX.Element {
<Component {...pageProps} /> <Component {...pageProps} />
</ThemeProvider> </ThemeProvider>
</ApolloProvider> </ApolloProvider>
</React.Fragment> </CacheProvider>
); );
} }
export default App;

View File

@ -1,7 +1,8 @@
import React from "react"; import * as React from "react";
import Document, { Html, Head, Main, NextScript } from "next/document"; import Document, { Html, Head, Main, NextScript } from "next/document";
import { ServerStyleSheets } from "@material-ui/core/styles"; import createEmotionServer from "@emotion/server/create-instance";
import createEmotionCache from "../lib/createEmotionCache";
import theme from "../lib/theme"; import theme from "../lib/theme";
export default class MyDocument extends Document { export default class MyDocument extends Document {
@ -11,14 +12,9 @@ export default class MyDocument extends Document {
<Head> <Head>
<meta name="theme-color" content={theme.palette.primary.main} /> <meta name="theme-color" content={theme.palette.primary.main} />
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css" />
<link <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
rel="stylesheet" <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" />
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" {(this.props as any).emotionStyleTags}
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"
/>
</Head> </Head>
<body> <body>
<Main /> <Main />
@ -30,25 +26,60 @@ export default class MyDocument extends Document {
} }
// `getInitialProps` belongs to `_document` (instead of `_app`), // `getInitialProps` belongs to `_document` (instead of `_app`),
// it's compatible with server-side generation (SSG). // it's compatible with static-site generation (SSG).
MyDocument.getInitialProps = async (ctx) => { MyDocument.getInitialProps = async (ctx) => {
// Render app and page and get the context of the page with collected side effects. // Resolution order
const sheets = new ServerStyleSheets(); //
// On the server:
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. document.getInitialProps
// 4. app.render
// 5. page.render
// 6. document.render
//
// On the server with error:
// 1. document.getInitialProps
// 2. app.render
// 3. page.render
// 4. document.render
//
// On the client
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. app.render
// 4. page.render
const originalRenderPage = ctx.renderPage; const originalRenderPage = ctx.renderPage;
// You can consider sharing the same emotion cache between all the SSR requests to speed up performance.
// However, be aware that it can have global side effects.
const cache = createEmotionCache();
const { extractCriticalToChunks } = createEmotionServer(cache);
ctx.renderPage = () => ctx.renderPage = () =>
originalRenderPage({ originalRenderPage({
enhanceApp: (App) => (props) => sheets.collect(<App {...props} />), enhanceApp: (App: any) =>
function EnhanceApp(props) {
return <App emotionCache={cache} {...props} />;
},
}); });
const initialProps = await Document.getInitialProps(ctx); const initialProps = await Document.getInitialProps(ctx);
// This is important. It prevents emotion to render invalid HTML.
// See https://github.com/mui-org/material-ui/issues/26561#issuecomment-855286153
const emotionStyles = extractCriticalToChunks(initialProps.html);
const emotionStyleTags = emotionStyles.styles.map((style) => (
<style
data-emotion={`${style.key} ${style.ids.join(" ")}`}
key={style.key}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: style.css }}
/>
));
return { return {
...initialProps, ...initialProps,
// Styles fragment is rendered after the app and page rendering finish. emotionStyleTags,
styles: [
...React.Children.toArray(initialProps.styles),
sheets.getStyleElement(),
],
}; };
}; };

View File

@ -1,4 +1,4 @@
import { Box, Link as MaterialLink, Typography } from "@material-ui/core"; import { Box, Link as MaterialLink, Typography } from "@mui/material";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
@ -13,17 +13,13 @@ function Index(): JSX.Element {
<Typography variant="h4">Get started</Typography> <Typography variant="h4">Get started</Typography>
</Box> </Box>
<Typography paragraph> <Typography paragraph>
Youve loaded a (new) project. Whats next? You can now use the MITM Youve loaded a (new) project. Whats next? You can now use the MITM proxy and review HTTP requests and
proxy and review HTTP requests and responses via the{" "} responses via the{" "}
<Link href="/proxy/logs" passHref> <Link href="/proxy/logs" passHref>
<MaterialLink color="secondary">Proxy logs</MaterialLink> <MaterialLink color="primary">Proxy logs</MaterialLink>
</Link> </Link>
. Stuck? Ask for help on the{" "} . Stuck? Ask for help on the{" "}
<MaterialLink <MaterialLink href="https://github.com/dstotijn/hetty/discussions" color="primary" target="_blank">
href="https://github.com/dstotijn/hetty/discussions"
color="secondary"
target="_blank"
>
Discussions forum Discussions forum
</MaterialLink> </MaterialLink>
. .

View File

@ -1,253 +1,53 @@
import { import { Box, Button, Typography } from "@mui/material";
Avatar, import FolderIcon from "@mui/icons-material/Folder";
Box,
Button,
CircularProgress,
createStyles,
List,
ListItem,
ListItemAvatar,
ListItemText,
makeStyles,
TextField,
Theme,
Typography,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import FolderIcon from "@material-ui/icons/Folder";
import DescriptionIcon from "@material-ui/icons/Description";
import PlayArrowIcon from "@material-ui/icons/PlayArrow";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react";
import { gql, useMutation, useQuery } from "@apollo/client";
import { useRouter } from "next/router";
import Layout, { Page } from "../components/Layout"; import Layout, { Page } from "../components/Layout";
import { Alert } from "@material-ui/lab";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
titleHighlight: {
color: theme.palette.secondary.main,
},
subtitle: {
fontSize: "1.6rem",
width: "60%",
lineHeight: 2,
marginBottom: theme.spacing(5),
},
projectName: {
marginTop: -6,
marginRight: theme.spacing(2),
},
button: {
marginRight: theme.spacing(2),
},
activeProject: {
color: theme.palette.getContrastText(theme.palette.secondary.main),
backgroundColor: theme.palette.secondary.main,
},
})
);
const ACTIVE_PROJECT = gql`
query ActiveProject {
activeProject {
name
}
}
`;
const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) {
openProject(name: $name) {
name
isActive
}
}
`;
function Index(): JSX.Element { function Index(): JSX.Element {
const classes = useStyles(); const highlightSx = { color: "primary.main" };
const router = useRouter();
const [input, setInput] = useState(null);
const { error: activeProjErr, data: activeProjData } = useQuery(
ACTIVE_PROJECT,
{
pollInterval: 1000,
}
);
const [
openProject,
{ error: openProjErr, data: openProjData, loading: openProjLoading },
] = useMutation(OPEN_PROJECT, {
onError: () => {},
onCompleted({ openProject }) {
if (openProject) {
router.push("/get-started");
}
},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
name
isActive
type
}
`,
});
return DELETE;
},
},
});
},
});
const handleForm = (e: React.SyntheticEvent) => {
e.preventDefault();
openProject({ variables: { name: input.value } });
};
if (activeProjErr) {
return (
<Layout page={Page.Home} title="">
<Alert severity="error">
Error fetching active project: {activeProjErr.message}
</Alert>
</Layout>
);
}
return ( return (
<Layout page={Page.Home} title=""> <Layout page={Page.Home} title="">
<Box p={4}> <Box p={4}>
<Box mb={4} width="60%"> <Box mb={4} width="60%">
<Typography variant="h2"> <Typography variant="h2">
<span className={classes.titleHighlight}>Hetty://</span> <Box component="span" sx={highlightSx}>
Hetty://
</Box>
<br /> <br />
The simple HTTP toolkit for security research. The simple HTTP toolkit for security research.
</Typography> </Typography>
</Box> </Box>
<Typography className={classes.subtitle} paragraph> <Typography
What if security testing was intuitive, powerful, and good looking? paragraph
What if it was <strong>free</strong>, instead of $400 per year?{" "} sx={{
<span className={classes.titleHighlight}>Hetty</span> is listening on{" "} fontSize: "1.6rem",
<code>:8080</code> width: "60%",
lineHeight: 2,
mb: 5,
}}
>
Welcome to{" "}
<Box component="span" sx={highlightSx}>
Hetty
</Box>
. Get started by creating a project.
</Typography> </Typography>
{activeProjData?.activeProject?.name ? ( <Link href="/projects" passHref>
<div> <Button
<Box mb={1}> sx={{ mr: 2 }}
<Typography variant="h6">Active project:</Typography> variant="contained"
</Box> color="primary"
<Box ml={-2} mb={2}> component="a"
<List> size="large"
<ListItem> startIcon={<FolderIcon />}
<ListItemAvatar> >
<Avatar className={classes.activeProject}> Manage projects
<DescriptionIcon /> </Button>
</Avatar> </Link>
</ListItemAvatar>
<ListItemText primary={activeProjData.activeProject.name} />
</ListItem>
</List>
</Box>
<div>
<Link href="/get-started" passHref>
<Button
className={classes.button}
variant="outlined"
component="a"
color="secondary"
size="large"
startIcon={<PlayArrowIcon />}
>
Get started
</Button>
</Link>
<Link href="/projects" passHref>
<Button
className={classes.button}
variant="outlined"
component="a"
size="large"
startIcon={<FolderIcon />}
>
Manage projects
</Button>
</Link>
</div>
</div>
) : (
<form onSubmit={handleForm} autoComplete="off">
<TextField
className={classes.projectName}
color="secondary"
inputProps={{
id: "projectName",
ref: (node) => {
setInput(node);
},
}}
label="Project name"
placeholder="Project name…"
error={Boolean(openProjErr)}
helperText={openProjErr && openProjErr.message}
/>
<Button
className={classes.button}
type="submit"
variant="contained"
color="secondary"
size="large"
disabled={
openProjLoading || Boolean(openProjData?.openProject?.name)
}
startIcon={
openProjLoading || openProjData?.openProject ? (
<CircularProgress size={22} />
) : (
<AddIcon />
)
}
>
Create project
</Button>
<Link href="/projects" passHref>
<Button
className={classes.button}
variant="outlined"
component="a"
size="large"
startIcon={<FolderIcon />}
>
Open project
</Button>
</Link>
</form>
)}
</Box> </Box>
</Layout> </Layout>
); );

View File

@ -1,4 +1,4 @@
import { Box, Divider, Grid, Typography } from "@material-ui/core"; import { Box, Divider, Grid, Typography } from "@mui/material";
import Layout, { Page } from "../../components/Layout"; import Layout, { Page } from "../../components/Layout";
import NewProject from "../../components/projects/NewProject"; import NewProject from "../../components/projects/NewProject";
import ProjectList from "../../components/projects/ProjectList"; import ProjectList from "../../components/projects/ProjectList";
@ -11,8 +11,7 @@ function Index(): JSX.Element {
<Typography variant="h4">Projects</Typography> <Typography variant="h4">Projects</Typography>
</Box> </Box>
<Typography paragraph> <Typography paragraph>
Projects contain settings and data generated/processed by Hetty. They Projects contain settings and data generated/processed by Hetty. They are stored in a single database on disk.
are stored as SQLite database files on disk.
</Typography> </Typography>
<Box my={4}> <Box my={4}>
<Divider /> <Divider />

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { Button, Typography } from "@material-ui/core"; import { Button, Typography } from "@mui/material";
import ListIcon from "@material-ui/icons/List"; import ListIcon from "@mui/icons-material/List";
import Link from "next/link"; import Link from "next/link";
import Layout, { Page } from "../../components/Layout"; import Layout, { Page } from "../../components/Layout";
@ -10,13 +10,7 @@ function Index(): JSX.Element {
<Layout page={Page.ProxySetup} title="Proxy setup"> <Layout page={Page.ProxySetup} title="Proxy setup">
<Typography paragraph>Coming soon</Typography> <Typography paragraph>Coming soon</Typography>
<Link href="/proxy/logs" passHref> <Link href="/proxy/logs" passHref>
<Button <Button variant="contained" color="primary" component="a" size="large" startIcon={<ListIcon />}>
variant="contained"
color="secondary"
component="a"
size="large"
startIcon={<ListIcon />}
>
View logs View logs
</Button> </Button>
</Link> </Link>

View File

@ -1,4 +1,4 @@
import { Box } from "@material-ui/core"; import { Box } from "@mui/material";
import LogsOverview from "../../../components/reqlog/LogsOverview"; import LogsOverview from "../../../components/reqlog/LogsOverview";
import Layout, { Page } from "../../../components/Layout"; import Layout, { Page } from "../../../components/Layout";

View File

@ -1,4 +1,4 @@
import { Box, Divider, Grid, Typography } from "@material-ui/core"; import { Box, Divider, Grid, Typography } from "@mui/material";
import React from "react"; import React from "react";
import Layout, { Page } from "../../components/Layout"; import Layout, { Page } from "../../components/Layout";
@ -13,11 +13,9 @@ function Index(): JSX.Element {
<Typography variant="h4">Scope</Typography> <Typography variant="h4">Scope</Typography>
</Box> </Box>
<Typography paragraph> <Typography paragraph>
Scope rules are used by various modules in Hetty and can influence Scope rules are used by various modules in Hetty and can influence their behavior. For example: the Proxy logs
their behavior. For example: the Proxy logs module can match incoming module can match incoming requests against scope rules and decide its behavior (e.g. log or bypass) based on
requests against scope rules and decide its behavior (e.g. log or the outcome of the match. All scope configuration is stored per project.
bypass) based on the outcome of the match. All scope configuration is
stored per project.
</Typography> </Typography>
<Box my={4}> <Box my={4}>
<Divider /> <Divider />

View File

@ -1,4 +1,4 @@
import { Box, Typography } from "@material-ui/core"; import { Box, Typography } from "@mui/material";
import Layout, { Page } from "../../components/Layout"; import Layout, { Page } from "../../components/Layout";

View File

@ -8,7 +8,7 @@
], ],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": false, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
@ -16,7 +16,8 @@
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve" "jsx": "preserve",
"incremental": true
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",

File diff suppressed because it is too large Load Diff

View File

@ -2,105 +2,122 @@ package main
import ( import (
"crypto/tls" "crypto/tls"
"embed"
"errors"
"flag" "flag"
"fmt"
"io/fs"
"log" "log"
"net" "net"
"net/http" "net/http"
"os" "os"
"strings" "strings"
rice "github.com/GeertJohan/go.rice" "github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
badgerdb "github.com/dgraph-io/badger/v3"
"github.com/gorilla/mux"
"github.com/mitchellh/go-homedir"
"github.com/dstotijn/hetty/pkg/api" "github.com/dstotijn/hetty/pkg/api"
"github.com/dstotijn/hetty/pkg/db/sqlite" "github.com/dstotijn/hetty/pkg/db/badger"
"github.com/dstotijn/hetty/pkg/proj" "github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/proxy" "github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/gorilla/mux"
"github.com/mitchellh/go-homedir"
) )
var version = "0.0.0"
// Flag variables.
var ( var (
caCertFile string caCertFile string
caKeyFile string caKeyFile string
projPath string dbPath string
addr string addr string
adminPath string
) )
//go:embed admin
//go:embed admin/_next/static
//go:embed admin/_next/static/chunks/pages/*.js
//go:embed admin/_next/static/*/*.js
var adminContent embed.FS
func main() { func main() {
flag.StringVar(&caCertFile, "cert", "~/.hetty/hetty_cert.pem", "CA certificate filepath. Creates a new CA certificate is file doesn't exist") if err := run(); err != nil {
flag.StringVar(&caKeyFile, "key", "~/.hetty/hetty_key.pem", "CA private key filepath. Creates a new CA private key if file doesn't exist") log.Fatalf("[ERROR]: %v", err)
flag.StringVar(&projPath, "projects", "~/.hetty/projects", "Projects directory path") }
}
func run() error {
flag.StringVar(&caCertFile, "cert", "~/.hetty/hetty_cert.pem",
"CA certificate filepath. Creates a new CA certificate if file doesn't exist")
flag.StringVar(&caKeyFile, "key", "~/.hetty/hetty_key.pem",
"CA private key filepath. Creates a new CA private key if file doesn't exist")
flag.StringVar(&dbPath, "db", "~/.hetty/db", "Database directory path")
flag.StringVar(&addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\"") flag.StringVar(&addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\"")
flag.StringVar(&adminPath, "adminPath", "", "File path to admin build")
flag.Parse() flag.Parse()
// Expand `~` in filepaths. // Expand `~` in filepaths.
caCertFile, err := homedir.Expand(caCertFile) caCertFile, err := homedir.Expand(caCertFile)
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not parse CA certificate filepath: %v", err) return fmt.Errorf("could not parse CA certificate filepath: %w", err)
} }
caKeyFile, err := homedir.Expand(caKeyFile) caKeyFile, err := homedir.Expand(caKeyFile)
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not parse CA private key filepath: %v", err) return fmt.Errorf("could not parse CA private key filepath: %w", err)
} }
projPath, err := homedir.Expand(projPath)
dbPath, err := homedir.Expand(dbPath)
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not parse projects filepath: %v", err) return fmt.Errorf("could not parse projects filepath: %w", err)
} }
// Load existing CA certificate and key from disk, or generate and write // Load existing CA certificate and key from disk, or generate and write
// to disk if no files exist yet. // to disk if no files exist yet.
caCert, caKey, err := proxy.LoadOrCreateCA(caKeyFile, caCertFile) caCert, caKey, err := proxy.LoadOrCreateCA(caKeyFile, caCertFile)
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not create/load CA key pair: %v", err) return fmt.Errorf("could not create/load CA key pair: %w", err)
} }
db, err := sqlite.New(projPath) badger, err := badger.OpenDatabase(badgerdb.DefaultOptions(dbPath))
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not initialize database client: %v", err) return fmt.Errorf("could not open badger database: %w", err)
} }
defer badger.Close()
projService, err := proj.NewService(db) scope := &scope.Scope{}
if err != nil {
log.Fatalf("[FATAL] Could not create new project service: %v", err)
}
defer projService.Close()
scope := scope.New(db, projService)
reqLogService := reqlog.NewService(reqlog.Config{ reqLogService := reqlog.NewService(reqlog.Config{
Scope: scope, Scope: scope,
ProjectService: projService, Repository: badger,
Repository: db,
}) })
projService, err := proj.NewService(proj.Config{
Repository: badger,
ReqLogService: reqLogService,
Scope: scope,
})
if err != nil {
return fmt.Errorf("could not create new project service: %w", err)
}
p, err := proxy.NewProxy(caCert, caKey) p, err := proxy.NewProxy(caCert, caKey)
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not create Proxy: %v", err) return fmt.Errorf("could not create proxy: %w", err)
} }
p.UseRequestModifier(reqLogService.RequestModifier) p.UseRequestModifier(reqLogService.RequestModifier)
p.UseResponseModifier(reqLogService.ResponseModifier) p.UseResponseModifier(reqLogService.ResponseModifier)
var adminHandler http.Handler fsSub, err := fs.Sub(adminContent, "admin")
if adminPath == "" { if err != nil {
// Used for embedding with `rice`. return fmt.Errorf("could not prepare subtree file system: %w", err)
box, err := rice.FindBox("../../admin/dist")
if err != nil {
log.Fatalf("[FATAL] Could not find embedded admin resources: %v", err)
}
adminHandler = http.FileServer(box.HTTPBox())
} else {
adminHandler = http.FileServer(http.Dir(adminPath))
} }
adminHandler := http.FileServer(http.FS(fsSub))
router := mux.NewRouter().SkipClean(true) router := mux.NewRouter().SkipClean(true)
adminRouter := router.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool { adminRouter := router.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool {
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
host, _, _ := net.SplitHostPort(req.Host) host, _, _ := net.SplitHostPort(req.Host)
@ -109,11 +126,11 @@ func main() {
// GraphQL server. // GraphQL server.
adminRouter.Path("/api/playground/").Handler(playground.Handler("GraphQL Playground", "/api/graphql/")) adminRouter.Path("/api/playground/").Handler(playground.Handler("GraphQL Playground", "/api/graphql/"))
adminRouter.Path("/api/graphql/").Handler(handler.NewDefaultServer(api.NewExecutableSchema(api.Config{Resolvers: &api.Resolver{ adminRouter.Path("/api/graphql/").Handler(
RequestLogService: reqLogService, handler.NewDefaultServer(api.NewExecutableSchema(api.Config{Resolvers: &api.Resolver{
ProjectService: projService, RequestLogService: reqLogService,
ScopeService: scope, ProjectService: projService,
}}))) }})))
// Admin interface. // Admin interface.
adminRouter.PathPrefix("").Handler(adminHandler) adminRouter.PathPrefix("").Handler(adminHandler)
@ -127,9 +144,12 @@ func main() {
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, // Disable HTTP/2 TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, // Disable HTTP/2
} }
log.Printf("[INFO] Running server on %v ...", addr) log.Printf("[INFO] Hetty (v%v) is running on %v ...", version, addr)
err = s.ListenAndServe() err = s.ListenAndServe()
if err != nil && err != http.ErrServerClosed { if err != nil && errors.Is(err, http.ErrServerClosed) {
log.Fatalf("[FATAL] HTTP server closed: %v", err) return fmt.Errorf("http server closed unexpected: %w", err)
} }
return nil
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -24,7 +24,7 @@ Source: [pkg/api/schema.graphql](https://github.com/dstotijn/hetty/blob/master/p
MIT License MIT License
Copyright (c) 2020 David Stotijn Copyright (c) 2021 David Stotijn
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -2,7 +2,7 @@
## Installation ## Installation
Hetty compiles to a static binary, with an embedded SQLite database and web Hetty compiles to a static binary, with an embedded BadgerDB database and web
admin interface. admin interface.
### Install pre-built release (recommended) ### Install pre-built release (recommended)
@ -13,14 +13,13 @@ admin interface.
#### Prerequisites #### Prerequisites
- [Go](https://golang.org/) - [Go 1.16](https://golang.org/)
- [Yarn](https://yarnpkg.com/) - [Yarn](https://yarnpkg.com/)
- [go.rice](https://github.com/GeertJohan/go.rice)
Hetty depends on SQLite (via [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3)) When building from source, the static resources for the admin interface
and needs `cgo` to compile. Additionally, the static resources for the web admin interface (Next.js) need to be generated via [Yarn](https://yarnpkg.com/). The generated
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/) and embedded in files will be embedded (using the [embed](https://golang.org/pkg/embed/)
a `.go` file with [go.rice](https://github.com/GeertJohan/go.rice) beforehand. package) when you use the `build` Makefile target.
Clone the repository and use the `build` make target to create a binary: Clone the repository and use the `build` make target to create a binary:
@ -46,7 +45,7 @@ When Hetty is started, by default it listens on `:8080` and is accessible via
[http://localhost:8080](http://localhost:8080). Depending on incoming HTTP [http://localhost:8080](http://localhost:8080). Depending on incoming HTTP
requests, it either acts as a MITM proxy, or it serves the API and web interface. requests, it either acts as a MITM proxy, or it serves the API and web interface.
By default, project database files and CA certificates are stored in a `.hetty` By default, the projects database files and CA certificates are stored in a `.hetty`
directory under the user's home directory (`$HOME` on Linux/macOS, `%USERPROFILE%` directory under the user's home directory (`$HOME` on Linux/macOS, `%USERPROFILE%`
on Windows). on Windows).
@ -60,7 +59,7 @@ $ hetty
You should see: You should see:
``` ```
2020/11/01 14:47:10 [INFO] Running server on :8080 ... 2022/01/26 10:34:24 [INFO] Hetty (v0.3.2) is running on :8080 ...
``` ```
Then, visit [http://localhost:8080](http://localhost:8080) to get started. Then, visit [http://localhost:8080](http://localhost:8080) to get started.
@ -77,9 +76,9 @@ Usage of ./hetty:
-adminPath string -adminPath string
File path to admin build File path to admin build
-cert string -cert string
CA certificate filepath. Creates a new CA certificate is file doesn't exist (default "~/.hetty/hetty_cert.pem") CA certificate filepath. Creates a new CA certificate if file doesn't exist (default "~/.hetty/hetty_cert.pem")
-key string -key string
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem") CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
-projects string -db string
Projects directory path (default "~/.hetty/projects") Database directory path (default "~/.hetty/db")
``` ```

View File

@ -17,7 +17,7 @@ features tailored to the needs of the infosec and bug bounty community.
## Features ## Features
- Machine-in-the-middle (MITM) HTTP/1.1 proxy with logs - Machine-in-the-middle (MITM) HTTP/1.1 proxy with logs
- Project based database storage (SQLite) - Project based database storage (BadgerDB)
- Scope support - Scope support
- Headless management API using GraphQL - Headless management API using GraphQL
- Embedded web admin interface (Next.js) - Embedded web admin interface (Next.js)
@ -27,3 +27,7 @@ Hetty is in early development. Additional features are planned
for a `v1.0` release. Please see the <a href="https://github.com/dstotijn/hetty/projects/1">backlog</a> for a `v1.0` release. Please see the <a href="https://github.com/dstotijn/hetty/projects/1">backlog</a>
for details. for details.
::: :::
## Sponsors
[![Sponsored by Tines](/assets/tines-sponsorship-badge.png =140x)](https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty)

View File

@ -14,7 +14,7 @@ The available modules:
## Projects ## Projects
Projects are self-contained (SQLite) database files that contain module data. Projects are stored in a single BadgerDB database on disk.
They allow you organize your work, for example to split your work between research They allow you organize your work, for example to split your work between research
targets. targets.
@ -25,19 +25,15 @@ typically the first thing you do when you start using Hetty.
### Creating a new project ### Creating a new project
When you open the Hetty admin interface after starting the program, youll be prompted When you open the Hetty admin interface after starting the program, youll be prompted
on the homepage to create a new project. Give it a name (alphanumeric and space character) on the homepage to “Manage projects”, which leads to the “Projects” page where
and click the create button: you can open an existing project or create a new one:
![Creating a project](./create_project.png =417x) ![Creating a project](./create_project.png =417x)
The project name will become the base for the database file on disk. For example,
if you name your project `My first project`, the file on disk will be
`My first project.db`.
::: tip INFO ::: tip INFO
Project database files by default are stored in `$HOME/.hetty/projects` on Linux Projects are stored in a single BadgerDB database, stored in `$HOME/.hetty/db` on Linux
and macOS, and `%USERPROFILE%/.hetty` on Windows. You can override this path with and macOS, and `%USERPROFILE%/.hetty/db` on Windows. You can override this path with
the `-projects` flag. See: [Usage](/guide/getting-started.md#usage). the `-db` flag. See: [Usage](/guide/getting-started.md#usage).
::: :::
### Managing projects ### Managing projects

View File

@ -1705,10 +1705,10 @@ bluebird@^3.1.1, bluebird@^3.5.5:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0: bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9:
version "4.11.9" version "4.12.0"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
bn.js@^5.1.1: bn.js@^5.1.1:
version "5.1.3" version "5.1.3"
@ -1793,7 +1793,7 @@ braces@~3.0.2:
dependencies: dependencies:
fill-range "^7.0.1" fill-range "^7.0.1"
brorand@^1.0.1: brorand@^1.0.1, brorand@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=
@ -3010,17 +3010,17 @@ electron-to-chromium@^1.3.585:
integrity sha512-xoeqjMQhgHDZM7FiglJAb2aeOxHZWFruUc3MbAGTgE7GB8rr5fTn1Sdh5THGuQtndU3GuXlu91ZKqRivxoCZ/A== integrity sha512-xoeqjMQhgHDZM7FiglJAb2aeOxHZWFruUc3MbAGTgE7GB8rr5fTn1Sdh5THGuQtndU3GuXlu91ZKqRivxoCZ/A==
elliptic@^6.5.3: elliptic@^6.5.3:
version "6.5.3" version "6.5.4"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw== integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==
dependencies: dependencies:
bn.js "^4.4.0" bn.js "^4.11.9"
brorand "^1.0.1" brorand "^1.1.0"
hash.js "^1.0.0" hash.js "^1.0.0"
hmac-drbg "^1.0.0" hmac-drbg "^1.0.1"
inherits "^2.0.1" inherits "^2.0.4"
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.1"
minimalistic-crypto-utils "^1.0.0" minimalistic-crypto-utils "^1.0.1"
emoji-regex@^7.0.1: emoji-regex@^7.0.1:
version "7.0.3" version "7.0.3"
@ -3844,7 +3844,7 @@ hex-color-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==
hmac-drbg@^1.0.0: hmac-drbg@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=
@ -4124,9 +4124,9 @@ inherits@2.0.3:
integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
ini@^1.3.5, ini@~1.3.0: ini@^1.3.5, ini@~1.3.0:
version "1.3.5" version "1.3.8"
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
internal-ip@^4.3.0: internal-ip@^4.3.0:
version "4.3.0" version "4.3.0"
@ -4979,7 +4979,7 @@ minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1:
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: minimalistic-crypto-utils@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=
@ -5945,9 +5945,9 @@ pretty-time@^1.1.0:
integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA== integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==
prismjs@^1.13.0: prismjs@^1.13.0:
version "1.22.0" version "1.23.0"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.22.0.tgz#73c3400afc58a823dd7eed023f8e1ce9fd8977fa" resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33"
integrity sha512-lLJ/Wt9yy0AiSYBf212kK3mM5L8ycwlyTlSxHBAneXLR0nzFMlZ5y7riFPF3E33zXOF2IH95xdY5jIyZbM9z/w== integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA==
optionalDependencies: optionalDependencies:
clipboard "^2.0.0" clipboard "^2.0.0"
@ -6779,9 +6779,9 @@ sshpk@^1.7.0:
tweetnacl "~0.14.0" tweetnacl "~0.14.0"
ssri@^6.0.1: ssri@^6.0.1:
version "6.0.1" version "6.0.2"
resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8" resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.2.tgz#157939134f20464e7301ddba3e90ffa8f7728ac5"
integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA== integrity sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==
dependencies: dependencies:
figgy-pudding "^3.5.1" figgy-pudding "^3.5.1"
@ -7825,9 +7825,9 @@ xtend@^4.0.0, xtend@~4.0.1:
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
y18n@^4.0.0: y18n@^4.0.0:
version "4.0.0" version "4.0.1"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4"
integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== integrity sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==
yallist@^2.1.2: yallist@^2.1.2:
version "2.1.2" version "2.1.2"

48
go.mod
View File

@ -1,17 +1,45 @@
module github.com/dstotijn/hetty module github.com/dstotijn/hetty
go 1.15 go 1.17
require ( require (
github.com/99designs/gqlgen v0.13.0 github.com/99designs/gqlgen v0.14.0
github.com/GeertJohan/go.rice v1.0.0 github.com/dgraph-io/badger/v3 v3.2103.2
github.com/Masterminds/squirrel v1.4.0 github.com/google/go-cmp v0.5.6
github.com/gorilla/mux v1.7.4 github.com/gorilla/mux v1.7.4
github.com/hashicorp/golang-lru v0.5.1 // indirect github.com/matryer/moq v0.2.5
github.com/jmoiron/sqlx v1.2.0
github.com/mattn/go-sqlite3 v1.14.4
github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-homedir v1.1.0
github.com/mitchellh/mapstructure v1.1.2 // indirect github.com/oklog/ulid v1.3.1
github.com/vektah/gqlparser/v2 v2.1.0 github.com/vektah/gqlparser/v2 v2.2.0
google.golang.org/appengine v1.6.6 // indirect )
require (
github.com/agnivade/levenshtein v1.1.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 // indirect
github.com/golang/protobuf v1.3.1 // indirect
github.com/golang/snappy v0.0.3 // indirect
github.com/google/flatbuffers v1.12.1 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/hashicorp/golang-lru v0.5.1 // indirect
github.com/klauspost/compress v1.12.3 // indirect
github.com/mitchellh/mapstructure v1.1.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/urfave/cli/v2 v2.1.1 // indirect
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e // indirect
go.opencensus.io v0.22.5 // indirect
golang.org/x/mod v0.3.0 // indirect
golang.org/x/net v0.0.0-20201021035429-f5854403a974 // indirect
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
golang.org/x/tools v0.0.0-20210106214847-113979e3529a // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
gopkg.in/yaml.v2 v2.2.4 // indirect
) )

192
go.sum
View File

@ -1,85 +1,107 @@
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk= github.com/99designs/gqlgen v0.14.0 h1:Wg8aNYQUjMR/4v+W3xD+7SizOy6lSvVeQ06AobNQAXI=
github.com/99designs/gqlgen v0.14.0/go.mod h1:S7z4boV+Nx4VvzMUpVrY/YuHjFX4n7rDyuTqvAkuoRE=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GeertJohan/go.incremental v1.0.0 h1:7AH+pY1XUgQE4Y1HcXYaMqAI0m9yrFqo/jt0CW30vsg= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/GeertJohan/go.rice v1.0.0 h1:KkI6O9uMaQU3VEKaj01ulavtF7o1fWT7+pk/4voiMLQ=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
github.com/Masterminds/squirrel v1.4.0 h1:he5i/EXixZxrBUWcxzDYMiju9WZ3ld/l7QBNuo/eN3w=
github.com/Masterminds/squirrel v1.4.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/agnivade/levenshtein v1.0.1 h1:3oJU7J3FGFmyhn8KHjmVaZCN5hxTr7GxgRue+sxIXdQ=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.0.3 h1:M5ZnqLOoZR8ygVq0FfkXsNOKzMCk0xRiow0R5+5VkQ0= github.com/agnivade/levenshtein v1.1.0 h1:n6qGwyHG61v3ABce1rPVZklEYRT8NFpCMrpZdBUbYGM=
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs= github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/akavel/rsrc v0.8.0 h1:zjWn7ukO9Kc5Q62DOJCcxGpXC18RawVtYAGdz2aLlfw=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/daaku/go.zipexe v1.0.0 h1:VSOgZtH418pH9L16hC/JrgSNJbbAL26pj7lmD1+CGdY=
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c h1:TUuUh0Xgj97tLMNtWtNvI9mIV6isjEb9lBMNv+77IGM= github.com/dgraph-io/badger/v3 v3.2103.2 h1:dpyM5eCJAtQCBcMCZcT4UBZchuTJgCywerHHgmxfxM8=
github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M=
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk= github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 h1:ZgQEtGgCBiWRM39fZuwSd1LwSqqSW0hOdXCYYDX0R3I=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/matryer/moq v0.2.5 h1:BGQISyhl7Gc9W/gMYmAJONh9mT6AYeyeTjNupNPknMs=
github.com/matryer/moq v0.2.5/go.mod h1:9RtPYjTnH1bSBIkpvtHkFN7nbWAnO7oRpdJkEIn6UtE=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.4 h1:4rQjbDxdu9fSgI/r3KN72G3c2goxknAqHHgPWWs8UlI=
github.com/mattn/go-sqlite3 v1.14.4/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047 h1:zCoDWFD5nrJJVjbXiDZcVhOBSzKn3o9LgRLLMRNuru8=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229 h1:E2B8qYyeSgv5MXpmzZXRNp8IAQ4vjxIjhpAf5hv/tAg= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
@ -88,55 +110,103 @@ github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJ
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k= github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg= github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns= github.com/vektah/gqlparser/v2 v2.2.0 h1:bAc3slekAAJW6sZTi07aGq0OrfaCjj4jxARAaC7g2EM=
github.com/vektah/gqlparser/v2 v2.1.0/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms= github.com/vektah/gqlparser/v2 v2.2.0/go.mod h1:i3mQIGIrbK2PD1RrCeMTlVbkF2FJ6WkU1KJlJlC+3F4=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd h1:oMEQDWVXVNpceQoVd1JN3CQ7LYJJzs5qWqZIUcxXHHw= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589 h1:rjUrONFu4kLchcZTfp3/96bR8bW8dIa8uz3cR5n0cgM= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200815165600-90abf76919f3/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a h1:CB3a9Nez8M13wwlr/E2YtwoU+qYHKfC+JrDa45RXXoQ=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k= sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k=

View File

@ -45,7 +45,7 @@ omit_slice_element_pointers: true
models: models:
ID: ID:
model: model:
- github.com/99designs/gqlgen/graphql.Int64 - github.com/dstotijn/hetty/pkg/api.ULID
# Int: # Int:
# model: # model:
# - github.com/99designs/gqlgen/graphql.Int # - github.com/99designs/gqlgen/graphql.Int

View File

@ -80,7 +80,6 @@ type ComplexityRoot struct {
Body func(childComplexity int) int Body func(childComplexity int) int
Headers func(childComplexity int) int Headers func(childComplexity int) int
Proto func(childComplexity int) int Proto func(childComplexity int) int
RequestID func(childComplexity int) int
StatusCode func(childComplexity int) int StatusCode func(childComplexity int) int
StatusReason func(childComplexity int) int StatusReason func(childComplexity int) int
} }
@ -88,20 +87,22 @@ type ComplexityRoot struct {
Mutation struct { Mutation struct {
ClearHTTPRequestLog func(childComplexity int) int ClearHTTPRequestLog func(childComplexity int) int
CloseProject func(childComplexity int) int CloseProject func(childComplexity int) int
DeleteProject func(childComplexity int, name string) int CreateProject func(childComplexity int, name string) int
OpenProject func(childComplexity int, name string) int DeleteProject func(childComplexity int, id ULID) int
OpenProject func(childComplexity int, id ULID) int
SetHTTPRequestLogFilter func(childComplexity int, filter *HTTPRequestLogFilterInput) int SetHTTPRequestLogFilter func(childComplexity int, filter *HTTPRequestLogFilterInput) int
SetScope func(childComplexity int, scope []ScopeRuleInput) int SetScope func(childComplexity int, scope []ScopeRuleInput) int
} }
Project struct { Project struct {
ID func(childComplexity int) int
IsActive func(childComplexity int) int IsActive func(childComplexity int) int
Name func(childComplexity int) int Name func(childComplexity int) int
} }
Query struct { Query struct {
ActiveProject func(childComplexity int) int ActiveProject func(childComplexity int) int
HTTPRequestLog func(childComplexity int, id int64) int HTTPRequestLog func(childComplexity int, id ULID) int
HTTPRequestLogFilter func(childComplexity int) int HTTPRequestLogFilter func(childComplexity int) int
HTTPRequestLogs func(childComplexity int) int HTTPRequestLogs func(childComplexity int) int
Projects func(childComplexity int) int Projects func(childComplexity int) int
@ -121,15 +122,16 @@ type ComplexityRoot struct {
} }
type MutationResolver interface { type MutationResolver interface {
OpenProject(ctx context.Context, name string) (*Project, error) CreateProject(ctx context.Context, name string) (*Project, error)
OpenProject(ctx context.Context, id ULID) (*Project, error)
CloseProject(ctx context.Context) (*CloseProjectResult, error) CloseProject(ctx context.Context) (*CloseProjectResult, error)
DeleteProject(ctx context.Context, name string) (*DeleteProjectResult, error) DeleteProject(ctx context.Context, id ULID) (*DeleteProjectResult, error)
ClearHTTPRequestLog(ctx context.Context) (*ClearHTTPRequestLogResult, error) ClearHTTPRequestLog(ctx context.Context) (*ClearHTTPRequestLogResult, error)
SetScope(ctx context.Context, scope []ScopeRuleInput) ([]ScopeRule, error) SetScope(ctx context.Context, scope []ScopeRuleInput) ([]ScopeRule, error)
SetHTTPRequestLogFilter(ctx context.Context, filter *HTTPRequestLogFilterInput) (*HTTPRequestLogFilter, error) SetHTTPRequestLogFilter(ctx context.Context, filter *HTTPRequestLogFilterInput) (*HTTPRequestLogFilter, error)
} }
type QueryResolver interface { type QueryResolver interface {
HTTPRequestLog(ctx context.Context, id int64) (*HTTPRequestLog, error) HTTPRequestLog(ctx context.Context, id ULID) (*HTTPRequestLog, error)
HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error)
HTTPRequestLogFilter(ctx context.Context) (*HTTPRequestLogFilter, error) HTTPRequestLogFilter(ctx context.Context) (*HTTPRequestLogFilter, error)
ActiveProject(ctx context.Context) (*Project, error) ActiveProject(ctx context.Context) (*Project, error)
@ -278,13 +280,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.HTTPResponseLog.Proto(childComplexity), true return e.complexity.HTTPResponseLog.Proto(childComplexity), true
case "HttpResponseLog.requestId":
if e.complexity.HTTPResponseLog.RequestID == nil {
break
}
return e.complexity.HTTPResponseLog.RequestID(childComplexity), true
case "HttpResponseLog.statusCode": case "HttpResponseLog.statusCode":
if e.complexity.HTTPResponseLog.StatusCode == nil { if e.complexity.HTTPResponseLog.StatusCode == nil {
break break
@ -313,6 +308,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.CloseProject(childComplexity), true return e.complexity.Mutation.CloseProject(childComplexity), true
case "Mutation.createProject":
if e.complexity.Mutation.CreateProject == nil {
break
}
args, err := ec.field_Mutation_createProject_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.CreateProject(childComplexity, args["name"].(string)), true
case "Mutation.deleteProject": case "Mutation.deleteProject":
if e.complexity.Mutation.DeleteProject == nil { if e.complexity.Mutation.DeleteProject == nil {
break break
@ -323,7 +330,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return 0, false return 0, false
} }
return e.complexity.Mutation.DeleteProject(childComplexity, args["name"].(string)), true return e.complexity.Mutation.DeleteProject(childComplexity, args["id"].(ULID)), true
case "Mutation.openProject": case "Mutation.openProject":
if e.complexity.Mutation.OpenProject == nil { if e.complexity.Mutation.OpenProject == nil {
@ -335,7 +342,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return 0, false return 0, false
} }
return e.complexity.Mutation.OpenProject(childComplexity, args["name"].(string)), true return e.complexity.Mutation.OpenProject(childComplexity, args["id"].(ULID)), true
case "Mutation.setHttpRequestLogFilter": case "Mutation.setHttpRequestLogFilter":
if e.complexity.Mutation.SetHTTPRequestLogFilter == nil { if e.complexity.Mutation.SetHTTPRequestLogFilter == nil {
@ -361,6 +368,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.SetScope(childComplexity, args["scope"].([]ScopeRuleInput)), true return e.complexity.Mutation.SetScope(childComplexity, args["scope"].([]ScopeRuleInput)), true
case "Project.id":
if e.complexity.Project.ID == nil {
break
}
return e.complexity.Project.ID(childComplexity), true
case "Project.isActive": case "Project.isActive":
if e.complexity.Project.IsActive == nil { if e.complexity.Project.IsActive == nil {
break break
@ -392,7 +406,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return 0, false return 0, false
} }
return e.complexity.Query.HTTPRequestLog(childComplexity, args["id"].(int64)), true return e.complexity.Query.HTTPRequestLog(childComplexity, args["id"].(ULID)), true
case "Query.httpRequestLogFilter": case "Query.httpRequestLogFilter":
if e.complexity.Query.HTTPRequestLogFilter == nil { if e.complexity.Query.HTTPRequestLogFilter == nil {
@ -533,7 +547,6 @@ var sources = []*ast.Source{
} }
type HttpResponseLog { type HttpResponseLog {
requestId: ID!
proto: String! proto: String!
statusCode: Int! statusCode: Int!
statusReason: String! statusReason: String!
@ -547,6 +560,7 @@ type HttpHeader {
} }
type Project { type Project {
id: ID!
name: String! name: String!
isActive: Boolean! isActive: Boolean!
} }
@ -605,9 +619,10 @@ type Query {
} }
type Mutation { type Mutation {
openProject(name: String!): Project createProject(name: String!): Project
openProject(id: ID!): Project
closeProject: CloseProjectResult! closeProject: CloseProjectResult!
deleteProject(name: String!): DeleteProjectResult! deleteProject(id: ID!): DeleteProjectResult!
clearHTTPRequestLog: ClearHTTPRequestLogResult! clearHTTPRequestLog: ClearHTTPRequestLogResult!
setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]! setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]!
setHttpRequestLogFilter( setHttpRequestLogFilter(
@ -637,7 +652,7 @@ var parsedSchema = gqlparser.MustLoadSchema(sources...)
// region ***************************** args.gotpl ***************************** // region ***************************** args.gotpl *****************************
func (ec *executionContext) field_Mutation_deleteProject_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { func (ec *executionContext) field_Mutation_createProject_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error var err error
args := map[string]interface{}{} args := map[string]interface{}{}
var arg0 string var arg0 string
@ -652,18 +667,33 @@ func (ec *executionContext) field_Mutation_deleteProject_args(ctx context.Contex
return args, nil return args, nil
} }
func (ec *executionContext) field_Mutation_openProject_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { func (ec *executionContext) field_Mutation_deleteProject_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error var err error
args := map[string]interface{}{} args := map[string]interface{}{}
var arg0 string var arg0 ULID
if tmp, ok := rawArgs["name"]; ok { if tmp, ok := rawArgs["id"]; ok {
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id"))
arg0, err = ec.unmarshalNString2string(ctx, tmp) arg0, err = ec.unmarshalNID2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐULID(ctx, tmp)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
args["name"] = arg0 args["id"] = arg0
return args, nil
}
func (ec *executionContext) field_Mutation_openProject_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
var arg0 ULID
if tmp, ok := rawArgs["id"]; ok {
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id"))
arg0, err = ec.unmarshalNID2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐULID(ctx, tmp)
if err != nil {
return nil, err
}
}
args["id"] = arg0
return args, nil return args, nil
} }
@ -715,10 +745,10 @@ func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs
func (ec *executionContext) field_Query_httpRequestLog_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { func (ec *executionContext) field_Query_httpRequestLog_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error var err error
args := map[string]interface{}{} args := map[string]interface{}{}
var arg0 int64 var arg0 ULID
if tmp, ok := rawArgs["id"]; ok { if tmp, ok := rawArgs["id"]; ok {
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id"))
arg0, err = ec.unmarshalNID2int64(ctx, tmp) arg0, err = ec.unmarshalNID2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐULID(ctx, tmp)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -970,9 +1000,9 @@ func (ec *executionContext) _HttpRequestLog_id(ctx context.Context, field graphq
} }
return graphql.Null return graphql.Null
} }
res := resTmp.(int64) res := resTmp.(ULID)
fc.Result = res fc.Result = res
return ec.marshalNID2int64(ctx, field.Selections, res) return ec.marshalNID2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐULID(ctx, field.Selections, res)
} }
func (ec *executionContext) _HttpRequestLog_url(ctx context.Context, field graphql.CollectedField, obj *HTTPRequestLog) (ret graphql.Marshaler) { func (ec *executionContext) _HttpRequestLog_url(ctx context.Context, field graphql.CollectedField, obj *HTTPRequestLog) (ret graphql.Marshaler) {
@ -1281,41 +1311,6 @@ func (ec *executionContext) _HttpRequestLogFilter_searchExpression(ctx context.C
return ec.marshalOString2ᚖstring(ctx, field.Selections, 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 {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "HttpResponseLog",
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.RequestID, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(int64)
fc.Result = res
return ec.marshalNID2int64(ctx, field.Selections, res)
}
func (ec *executionContext) _HttpResponseLog_proto(ctx context.Context, field graphql.CollectedField, obj *HTTPResponseLog) (ret graphql.Marshaler) { func (ec *executionContext) _HttpResponseLog_proto(ctx context.Context, field graphql.CollectedField, obj *HTTPResponseLog) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -1488,6 +1483,45 @@ func (ec *executionContext) _HttpResponseLog_headers(ctx context.Context, field
return ec.marshalNHttpHeader2ᚕgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPHeaderᚄ(ctx, field.Selections, res) return ec.marshalNHttpHeader2ᚕgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPHeaderᚄ(ctx, field.Selections, res)
} }
func (ec *executionContext) _Mutation_createProject(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Mutation",
Field: field,
Args: nil,
IsMethod: true,
IsResolver: true,
}
ctx = graphql.WithFieldContext(ctx, fc)
rawArgs := field.ArgumentMap(ec.Variables)
args, err := ec.field_Mutation_createProject_args(ctx, rawArgs)
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
fc.Args = args
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().CreateProject(rctx, args["name"].(string))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*Project)
fc.Result = res
return ec.marshalOProject2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐProject(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_openProject(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Mutation_openProject(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -1513,7 +1547,7 @@ func (ec *executionContext) _Mutation_openProject(ctx context.Context, field gra
fc.Args = args fc.Args = args
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().OpenProject(rctx, args["name"].(string)) return ec.resolvers.Mutation().OpenProject(rctx, args["id"].(ULID))
}) })
if err != nil { if err != nil {
ec.Error(ctx, err) ec.Error(ctx, err)
@ -1587,7 +1621,7 @@ func (ec *executionContext) _Mutation_deleteProject(ctx context.Context, field g
fc.Args = args fc.Args = args
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().DeleteProject(rctx, args["name"].(string)) return ec.resolvers.Mutation().DeleteProject(rctx, args["id"].(ULID))
}) })
if err != nil { if err != nil {
ec.Error(ctx, err) ec.Error(ctx, err)
@ -1720,6 +1754,41 @@ func (ec *executionContext) _Mutation_setHttpRequestLogFilter(ctx context.Contex
return ec.marshalOHttpRequestLogFilter2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPRequestLogFilter(ctx, field.Selections, res) return ec.marshalOHttpRequestLogFilter2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐHTTPRequestLogFilter(ctx, field.Selections, res)
} }
func (ec *executionContext) _Project_id(ctx context.Context, field graphql.CollectedField, obj *Project) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Project",
Field: field,
Args: nil,
IsMethod: false,
IsResolver: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.ID, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(ULID)
fc.Result = res
return ec.marshalNID2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐULID(ctx, field.Selections, res)
}
func (ec *executionContext) _Project_name(ctx context.Context, field graphql.CollectedField, obj *Project) (ret graphql.Marshaler) { func (ec *executionContext) _Project_name(ctx context.Context, field graphql.CollectedField, obj *Project) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -1815,7 +1884,7 @@ func (ec *executionContext) _Query_httpRequestLog(ctx context.Context, field gra
fc.Args = args fc.Args = args
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children ctx = rctx // use context from middleware stack in children
return ec.resolvers.Query().HTTPRequestLog(rctx, args["id"].(int64)) return ec.resolvers.Query().HTTPRequestLog(rctx, args["id"].(ULID))
}) })
if err != nil { if err != nil {
ec.Error(ctx, err) ec.Error(ctx, err)
@ -2366,6 +2435,41 @@ func (ec *executionContext) ___Directive_args(ctx context.Context, field graphql
return ec.marshalN__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ(ctx, field.Selections, res) return ec.marshalN__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ(ctx, field.Selections, res)
} }
func (ec *executionContext) ___Directive_isRepeatable(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "__Directive",
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.IsRepeatable, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(bool)
fc.Result = res
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) ___EnumValue_name(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) (ret graphql.Marshaler) { func (ec *executionContext) ___EnumValue_name(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -3318,7 +3422,10 @@ func (ec *executionContext) ___Type_ofType(ctx context.Context, field graphql.Co
func (ec *executionContext) unmarshalInputHttpRequestLogFilterInput(ctx context.Context, obj interface{}) (HTTPRequestLogFilterInput, error) { func (ec *executionContext) unmarshalInputHttpRequestLogFilterInput(ctx context.Context, obj interface{}) (HTTPRequestLogFilterInput, error) {
var it HTTPRequestLogFilterInput var it HTTPRequestLogFilterInput
var asMap = obj.(map[string]interface{}) asMap := map[string]interface{}{}
for k, v := range obj.(map[string]interface{}) {
asMap[k] = v
}
for k, v := range asMap { for k, v := range asMap {
switch k { switch k {
@ -3346,7 +3453,10 @@ func (ec *executionContext) unmarshalInputHttpRequestLogFilterInput(ctx context.
func (ec *executionContext) unmarshalInputScopeHeaderInput(ctx context.Context, obj interface{}) (ScopeHeaderInput, error) { func (ec *executionContext) unmarshalInputScopeHeaderInput(ctx context.Context, obj interface{}) (ScopeHeaderInput, error) {
var it ScopeHeaderInput var it ScopeHeaderInput
var asMap = obj.(map[string]interface{}) asMap := map[string]interface{}{}
for k, v := range obj.(map[string]interface{}) {
asMap[k] = v
}
for k, v := range asMap { for k, v := range asMap {
switch k { switch k {
@ -3374,7 +3484,10 @@ func (ec *executionContext) unmarshalInputScopeHeaderInput(ctx context.Context,
func (ec *executionContext) unmarshalInputScopeRuleInput(ctx context.Context, obj interface{}) (ScopeRuleInput, error) { func (ec *executionContext) unmarshalInputScopeRuleInput(ctx context.Context, obj interface{}) (ScopeRuleInput, error) {
var it ScopeRuleInput var it ScopeRuleInput
var asMap = obj.(map[string]interface{}) asMap := map[string]interface{}{}
for k, v := range obj.(map[string]interface{}) {
asMap[k] = v
}
for k, v := range asMap { for k, v := range asMap {
switch k { switch k {
@ -3625,11 +3738,6 @@ func (ec *executionContext) _HttpResponseLog(ctx context.Context, sel ast.Select
switch field.Name { switch field.Name {
case "__typename": case "__typename":
out.Values[i] = graphql.MarshalString("HttpResponseLog") out.Values[i] = graphql.MarshalString("HttpResponseLog")
case "requestId":
out.Values[i] = ec._HttpResponseLog_requestId(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
case "proto": case "proto":
out.Values[i] = ec._HttpResponseLog_proto(ctx, field, obj) out.Values[i] = ec._HttpResponseLog_proto(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
@ -3678,6 +3786,8 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
switch field.Name { switch field.Name {
case "__typename": case "__typename":
out.Values[i] = graphql.MarshalString("Mutation") out.Values[i] = graphql.MarshalString("Mutation")
case "createProject":
out.Values[i] = ec._Mutation_createProject(ctx, field)
case "openProject": case "openProject":
out.Values[i] = ec._Mutation_openProject(ctx, field) out.Values[i] = ec._Mutation_openProject(ctx, field)
case "closeProject": case "closeProject":
@ -3724,6 +3834,11 @@ func (ec *executionContext) _Project(ctx context.Context, sel ast.SelectionSet,
switch field.Name { switch field.Name {
case "__typename": case "__typename":
out.Values[i] = graphql.MarshalString("Project") out.Values[i] = graphql.MarshalString("Project")
case "id":
out.Values[i] = ec._Project_id(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
case "name": case "name":
out.Values[i] = ec._Project_name(ctx, field, obj) out.Values[i] = ec._Project_name(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
@ -3932,6 +4047,11 @@ func (ec *executionContext) ___Directive(ctx context.Context, sel ast.SelectionS
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
invalids++ invalids++
} }
case "isRepeatable":
out.Values[i] = ec.___Directive_isRepeatable(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
default: default:
panic("unknown field " + strconv.Quote(field.Name)) panic("unknown field " + strconv.Quote(field.Name))
} }
@ -4244,6 +4364,13 @@ func (ec *executionContext) marshalNHttpHeader2ᚕgithubᚗcomᚋdstotijnᚋhett
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }
@ -4295,22 +4422,24 @@ func (ec *executionContext) marshalNHttpRequestLog2ᚕgithubᚗcomᚋdstotijnᚋ
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }
func (ec *executionContext) unmarshalNID2int64(ctx context.Context, v interface{}) (int64, error) { func (ec *executionContext) unmarshalNID2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐULID(ctx context.Context, v interface{}) (ULID, error) {
res, err := graphql.UnmarshalInt64(v) var res ULID
err := res.UnmarshalGQL(v)
return res, graphql.ErrorOnPath(ctx, err) return res, graphql.ErrorOnPath(ctx, err)
} }
func (ec *executionContext) marshalNID2int64(ctx context.Context, sel ast.SelectionSet, v int64) graphql.Marshaler { func (ec *executionContext) marshalNID2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐULID(ctx context.Context, sel ast.SelectionSet, v ULID) graphql.Marshaler {
res := graphql.MarshalInt64(v) return v
if res == graphql.Null {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "must not be null")
}
}
return res
} }
func (ec *executionContext) unmarshalNInt2int(ctx context.Context, v interface{}) (int, error) { func (ec *executionContext) unmarshalNInt2int(ctx context.Context, v interface{}) (int, error) {
@ -4366,6 +4495,13 @@ func (ec *executionContext) marshalNProject2ᚕgithubᚗcomᚋdstotijnᚋhetty
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }
@ -4407,6 +4543,13 @@ func (ec *executionContext) marshalNScopeRule2ᚕgithubᚗcomᚋdstotijnᚋhetty
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }
@ -4504,6 +4647,13 @@ func (ec *executionContext) marshalN__Directive2ᚕgithubᚗcomᚋ99designsᚋgq
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }
@ -4577,6 +4727,13 @@ func (ec *executionContext) marshalN__DirectiveLocation2ᚕstringᚄ(ctx context
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }
@ -4626,6 +4783,13 @@ func (ec *executionContext) marshalN__InputValue2ᚕgithubᚗcomᚋ99designsᚋg
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }
@ -4667,6 +4831,13 @@ func (ec *executionContext) marshalN__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgen
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }
@ -4846,6 +5017,13 @@ func (ec *executionContext) marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgq
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }
@ -4886,6 +5064,13 @@ func (ec *executionContext) marshalO__Field2ᚕgithubᚗcomᚋ99designsᚋgqlgen
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }
@ -4926,6 +5111,13 @@ func (ec *executionContext) marshalO__InputValue2ᚕgithubᚗcomᚋ99designsᚋg
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }
@ -4973,6 +5165,13 @@ func (ec *executionContext) marshalO__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgen
} }
wg.Wait() wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret return ret
} }

31
pkg/api/models.go Normal file
View File

@ -0,0 +1,31 @@
package api
import (
"fmt"
"io"
"strconv"
"github.com/oklog/ulid"
)
type ULID ulid.ULID
func (u *ULID) UnmarshalGQL(v interface{}) (err error) {
str, ok := v.(string)
if !ok {
return fmt.Errorf("ulid must be a string")
}
id, err := ulid.Parse(str)
if err != nil {
return fmt.Errorf("failed to parse ULID: %w", err)
}
*u = ULID(id)
return nil
}
func (u ULID) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(ulid.ULID(u).String()))
}

View File

@ -27,7 +27,7 @@ type HTTPHeader struct {
} }
type HTTPRequestLog struct { type HTTPRequestLog struct {
ID int64 `json:"id"` ID ULID `json:"id"`
URL string `json:"url"` URL string `json:"url"`
Method HTTPMethod `json:"method"` Method HTTPMethod `json:"method"`
Proto string `json:"proto"` Proto string `json:"proto"`
@ -48,7 +48,6 @@ type HTTPRequestLogFilterInput struct {
} }
type HTTPResponseLog struct { type HTTPResponseLog struct {
RequestID int64 `json:"requestId"`
Proto string `json:"proto"` Proto string `json:"proto"`
StatusCode int `json:"statusCode"` StatusCode int `json:"statusCode"`
StatusReason string `json:"statusReason"` StatusReason string `json:"statusReason"`
@ -57,6 +56,7 @@ type HTTPResponseLog struct {
} }
type Project struct { type Project struct {
ID ULID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
IsActive bool `json:"isActive"` IsActive bool `json:"isActive"`
} }

View File

@ -4,44 +4,42 @@ package api
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
"github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql"
"github.com/oklog/ulid"
"github.com/vektah/gqlparser/v2/gqlerror"
"github.com/dstotijn/hetty/pkg/proj" "github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search" "github.com/dstotijn/hetty/pkg/search"
"github.com/vektah/gqlparser/v2/gqlerror"
) )
type Resolver struct { type Resolver struct {
ProjectService proj.Service
RequestLogService *reqlog.Service RequestLogService *reqlog.Service
ProjectService *proj.Service
ScopeService *scope.Scope
} }
type queryResolver struct{ *Resolver } type (
type mutationResolver struct{ *Resolver } queryResolver struct{ *Resolver }
mutationResolver struct{ *Resolver }
)
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error) { func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error) {
reqs, err := r.RequestLogService.FindRequests(ctx) reqs, err := r.RequestLogService.FindRequests(ctx)
if err == proj.ErrNoProject { if errors.Is(err, proj.ErrNoProject) {
return nil, &gqlerror.Error{ return nil, noActiveProjectErr(ctx)
Path: graphql.GetPath(ctx), } else if err != nil {
Message: "No active project.", return nil, fmt.Errorf("could not query repository for requests: %w", err)
Extensions: map[string]interface{}{
"code": "no_active_project",
},
}
}
if err != nil {
return nil, fmt.Errorf("could not query repository for requests: %v", err)
} }
logs := make([]HTTPRequestLog, len(reqs)) logs := make([]HTTPRequestLog, len(reqs))
for i, req := range reqs { for i, req := range reqs {
@ -49,20 +47,21 @@ func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog,
if err != nil { if err != nil {
return nil, err return nil, err
} }
logs[i] = req logs[i] = req
} }
return logs, nil return logs, nil
} }
func (r *queryResolver) HTTPRequestLog(ctx context.Context, id int64) (*HTTPRequestLog, error) { func (r *queryResolver) HTTPRequestLog(ctx context.Context, id ULID) (*HTTPRequestLog, error) {
log, err := r.RequestLogService.FindRequestLogByID(ctx, id) log, err := r.RequestLogService.FindRequestLogByID(ctx, ulid.ULID(id))
if err == reqlog.ErrRequestNotFound { if errors.Is(err, reqlog.ErrRequestNotFound) {
return nil, nil return nil, nil
} else if err != nil {
return nil, fmt.Errorf("could not get request by ID: %w", err)
} }
if err != nil {
return nil, fmt.Errorf("could not get request by ID: %v", err)
}
req, err := parseRequestLog(log) req, err := parseRequestLog(log)
if err != nil { if err != nil {
return nil, err return nil, err
@ -71,31 +70,32 @@ func (r *queryResolver) HTTPRequestLog(ctx context.Context, id int64) (*HTTPRequ
return &req, nil return &req, nil
} }
func parseRequestLog(req reqlog.Request) (HTTPRequestLog, error) { func parseRequestLog(reqLog reqlog.RequestLog) (HTTPRequestLog, error) {
method := HTTPMethod(req.Request.Method) method := HTTPMethod(reqLog.Method)
if method != "" && !method.IsValid() { if method != "" && !method.IsValid() {
return HTTPRequestLog{}, fmt.Errorf("request has invalid method: %v", method) return HTTPRequestLog{}, fmt.Errorf("request has invalid method: %v", method)
} }
log := HTTPRequestLog{ log := HTTPRequestLog{
ID: req.ID, ID: ULID(reqLog.ID),
Proto: req.Request.Proto, Proto: reqLog.Proto,
Method: method, Method: method,
Timestamp: req.Timestamp, Timestamp: ulid.Time(reqLog.ID.Time()),
} }
if req.Request.URL != nil { if reqLog.URL != nil {
log.URL = req.Request.URL.String() log.URL = reqLog.URL.String()
} }
if len(req.Body) > 0 { if len(reqLog.Body) > 0 {
reqBody := string(req.Body) bodyStr := string(reqLog.Body)
log.Body = &reqBody log.Body = &bodyStr
} }
if req.Request.Header != nil { if reqLog.Header != nil {
log.Headers = make([]HTTPHeader, 0) log.Headers = make([]HTTPHeader, 0)
for key, values := range req.Request.Header {
for key, values := range reqLog.Header {
for _, value := range values { for _, value := range values {
log.Headers = append(log.Headers, HTTPHeader{ log.Headers = append(log.Headers, HTTPHeader{
Key: key, Key: key,
@ -105,23 +105,26 @@ func parseRequestLog(req reqlog.Request) (HTTPRequestLog, error) {
} }
} }
if req.Response != nil { if reqLog.Response != nil {
log.Response = &HTTPResponseLog{ log.Response = &HTTPResponseLog{
RequestID: req.Response.RequestID, Proto: reqLog.Response.Proto,
Proto: req.Response.Response.Proto, StatusCode: reqLog.Response.StatusCode,
StatusCode: req.Response.Response.StatusCode,
} }
statusReasonSubs := strings.SplitN(req.Response.Response.Status, " ", 2) statusReasonSubs := strings.SplitN(reqLog.Response.Status, " ", 2)
if len(statusReasonSubs) == 2 { if len(statusReasonSubs) == 2 {
log.Response.StatusReason = statusReasonSubs[1] log.Response.StatusReason = statusReasonSubs[1]
} }
if len(req.Response.Body) > 0 {
resBody := string(req.Response.Body) if len(reqLog.Response.Body) > 0 {
log.Response.Body = &resBody bodyStr := string(reqLog.Response.Body)
log.Response.Body = &bodyStr
} }
if req.Response.Response.Header != nil {
if reqLog.Response.Header != nil {
log.Response.Headers = make([]HTTPHeader, 0) log.Response.Headers = make([]HTTPHeader, 0)
for key, values := range req.Response.Response.Header {
for key, values := range reqLog.Response.Header {
for _, value := range values { for _, value := range values {
log.Response.Headers = append(log.Response.Headers, HTTPHeader{ log.Response.Headers = append(log.Response.Headers, HTTPHeader{
Key: key, Key: key,
@ -135,46 +138,63 @@ func parseRequestLog(req reqlog.Request) (HTTPRequestLog, error) {
return log, nil return log, nil
} }
func (r *mutationResolver) OpenProject(ctx context.Context, name string) (*Project, error) { func (r *mutationResolver) CreateProject(ctx context.Context, name string) (*Project, error) {
p, err := r.ProjectService.Open(ctx, name) p, err := r.ProjectService.CreateProject(ctx, name)
if err == proj.ErrInvalidName { if errors.Is(err, proj.ErrInvalidName) {
return nil, gqlerror.Errorf("Project name must only contain alphanumeric or space chars.") return nil, gqlerror.Errorf("Project name must only contain alphanumeric or space chars.")
} else if err != nil {
return nil, fmt.Errorf("could not open project: %w", err)
} }
if err != nil {
return nil, fmt.Errorf("could not open project: %v", err)
}
return &Project{ return &Project{
ID: ULID(p.ID),
Name: p.Name, Name: p.Name,
IsActive: p.IsActive, IsActive: r.ProjectService.IsProjectActive(p.ID),
}, nil
}
func (r *mutationResolver) OpenProject(ctx context.Context, id ULID) (*Project, error) {
p, err := r.ProjectService.OpenProject(ctx, ulid.ULID(id))
if errors.Is(err, proj.ErrInvalidName) {
return nil, gqlerror.Errorf("Project name must only contain alphanumeric or space chars.")
} else if err != nil {
return nil, fmt.Errorf("could not open project: %w", err)
}
return &Project{
ID: ULID(p.ID),
Name: p.Name,
IsActive: r.ProjectService.IsProjectActive(p.ID),
}, nil }, nil
} }
func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) { func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) {
p, err := r.ProjectService.ActiveProject() p, err := r.ProjectService.ActiveProject(ctx)
if err == proj.ErrNoProject { if errors.Is(err, proj.ErrNoProject) {
return nil, nil return nil, nil
} } else if err != nil {
if err != nil { return nil, fmt.Errorf("could not open project: %w", err)
return nil, fmt.Errorf("could not open project: %v", err)
} }
return &Project{ return &Project{
ID: ULID(p.ID),
Name: p.Name, Name: p.Name,
IsActive: p.IsActive, IsActive: r.ProjectService.IsProjectActive(p.ID),
}, nil }, nil
} }
func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) { func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) {
p, err := r.ProjectService.Projects() p, err := r.ProjectService.Projects(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not get projects: %v", err) return nil, fmt.Errorf("could not get projects: %w", err)
} }
projects := make([]Project, len(p)) projects := make([]Project, len(p))
for i, proj := range p { for i, proj := range p {
projects[i] = Project{ projects[i] = Project{
ID: ULID(proj.ID),
Name: proj.Name, Name: proj.Name,
IsActive: proj.IsActive, IsActive: r.ProjectService.IsProjectActive(proj.ID),
} }
} }
@ -182,7 +202,7 @@ func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) {
} }
func (r *queryResolver) Scope(ctx context.Context) ([]ScopeRule, error) { func (r *queryResolver) Scope(ctx context.Context) ([]ScopeRule, error) {
rules := r.ScopeService.Rules() rules := r.ProjectService.Scope().Rules()
return scopeToScopeRules(rules), nil return scopeToScopeRules(rules), nil
} }
@ -190,55 +210,73 @@ func regexpToStringPtr(r *regexp.Regexp) *string {
if r == nil { if r == nil {
return nil return nil
} }
s := r.String() s := r.String()
return &s return &s
} }
func (r *mutationResolver) CloseProject(ctx context.Context) (*CloseProjectResult, error) { func (r *mutationResolver) CloseProject(ctx context.Context) (*CloseProjectResult, error) {
if err := r.ProjectService.Close(); err != nil { if err := r.ProjectService.CloseProject(); err != nil {
return nil, fmt.Errorf("could not close project: %v", err) return nil, fmt.Errorf("could not close project: %w", err)
} }
return &CloseProjectResult{true}, nil return &CloseProjectResult{true}, nil
} }
func (r *mutationResolver) DeleteProject(ctx context.Context, name string) (*DeleteProjectResult, error) { func (r *mutationResolver) DeleteProject(ctx context.Context, id ULID) (*DeleteProjectResult, error) {
if err := r.ProjectService.Delete(name); err != nil { if err := r.ProjectService.DeleteProject(ctx, ulid.ULID(id)); err != nil {
return nil, fmt.Errorf("could not delete project: %v", err) return nil, fmt.Errorf("could not delete project: %w", err)
} }
return &DeleteProjectResult{ return &DeleteProjectResult{
Success: true, Success: true,
}, nil }, nil
} }
func (r *mutationResolver) ClearHTTPRequestLog(ctx context.Context) (*ClearHTTPRequestLogResult, error) { func (r *mutationResolver) ClearHTTPRequestLog(ctx context.Context) (*ClearHTTPRequestLogResult, error) {
if err := r.RequestLogService.ClearRequests(ctx); err != nil { project, err := r.ProjectService.ActiveProject(ctx)
return nil, fmt.Errorf("could not clear request log: %v", err) if errors.Is(err, proj.ErrNoProject) {
return nil, noActiveProjectErr(ctx)
} else if err != nil {
return nil, fmt.Errorf("could not get active project: %w", err)
} }
if err := r.RequestLogService.ClearRequests(ctx, project.ID); err != nil {
return nil, fmt.Errorf("could not clear request log: %w", err)
}
return &ClearHTTPRequestLogResult{true}, nil return &ClearHTTPRequestLogResult{true}, nil
} }
func (r *mutationResolver) SetScope(ctx context.Context, input []ScopeRuleInput) ([]ScopeRule, error) { func (r *mutationResolver) SetScope(ctx context.Context, input []ScopeRuleInput) ([]ScopeRule, error) {
rules := make([]scope.Rule, len(input)) rules := make([]scope.Rule, len(input))
for i, rule := range input { for i, rule := range input {
u, err := stringPtrToRegexp(rule.URL) u, err := stringPtrToRegexp(rule.URL)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid URL in scope rule: %v", err) return nil, fmt.Errorf("invalid URL in scope rule: %w", err)
} }
var headerKey, headerValue *regexp.Regexp var headerKey, headerValue *regexp.Regexp
if rule.Header != nil { if rule.Header != nil {
headerKey, err = stringPtrToRegexp(rule.Header.Key) headerKey, err = stringPtrToRegexp(rule.Header.Key)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid header key in scope rule: %v", err) return nil, fmt.Errorf("invalid header key in scope rule: %w", err)
} }
headerValue, err = stringPtrToRegexp(rule.Header.Key) headerValue, err = stringPtrToRegexp(rule.Header.Key)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid header value in scope rule: %v", err) return nil, fmt.Errorf("invalid header value in scope rule: %w", err)
} }
} }
body, err := stringPtrToRegexp(rule.Body) body, err := stringPtrToRegexp(rule.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid body in scope rule: %v", err) return nil, fmt.Errorf("invalid body in scope rule: %w", err)
} }
rules[i] = scope.Rule{ rules[i] = scope.Rule{
URL: u, URL: u,
Header: scope.Header{ Header: scope.Header{
@ -249,8 +287,9 @@ func (r *mutationResolver) SetScope(ctx context.Context, input []ScopeRuleInput)
} }
} }
if err := r.ScopeService.SetRules(ctx, rules); err != nil { err := r.ProjectService.SetScopeRules(ctx, rules)
return nil, fmt.Errorf("could not set scope: %v", err) if err != nil {
return nil, fmt.Errorf("could not set scope rules: %w", err)
} }
return scopeToScopeRules(rules), nil return scopeToScopeRules(rules), nil
@ -266,10 +305,14 @@ func (r *mutationResolver) SetHTTPRequestLogFilter(
) (*HTTPRequestLogFilter, error) { ) (*HTTPRequestLogFilter, error) {
filter, err := findRequestsFilterFromInput(input) filter, err := findRequestsFilterFromInput(input)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse request log filter: %v", err) return nil, fmt.Errorf("could not parse request log filter: %w", err)
} }
if err := r.RequestLogService.SetRequestLogFilter(ctx, filter); err != nil {
return nil, fmt.Errorf("could not set request log filter: %v", err) err = r.ProjectService.SetRequestLogFindFilter(ctx, filter)
if errors.Is(err, proj.ErrNoProject) {
return nil, noActiveProjectErr(ctx)
} else if err != nil {
return nil, fmt.Errorf("could not set request log filter: %w", err)
} }
return findReqFilterToHTTPReqLogFilter(filter), nil return findReqFilterToHTTPReqLogFilter(filter), nil
@ -279,6 +322,7 @@ func stringPtrToRegexp(s *string) (*regexp.Regexp, error) {
if s == nil { if s == nil {
return nil, nil return nil, nil
} }
return regexp.Compile(*s) return regexp.Compile(*s)
} }
@ -292,8 +336,10 @@ func scopeToScopeRules(rules []scope.Rule) []ScopeRule {
Value: regexpToStringPtr(rule.Header.Value), Value: regexpToStringPtr(rule.Header.Value),
} }
} }
scopeRules[i].Body = regexpToStringPtr(rule.Body) scopeRules[i].Body = regexpToStringPtr(rule.Body)
} }
return scopeRules return scopeRules
} }
@ -301,15 +347,17 @@ func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (filter reqlo
if input == nil { if input == nil {
return return
} }
if input.OnlyInScope != nil { if input.OnlyInScope != nil {
filter.OnlyInScope = *input.OnlyInScope filter.OnlyInScope = *input.OnlyInScope
} }
if input.SearchExpression != nil && *input.SearchExpression != "" { if input.SearchExpression != nil && *input.SearchExpression != "" {
expr, err := search.ParseQuery(*input.SearchExpression) expr, err := search.ParseQuery(*input.SearchExpression)
if err != nil { if err != nil {
return reqlog.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %v", err) return reqlog.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %w", err)
} }
filter.RawSearchExpr = *input.SearchExpression
filter.SearchExpr = expr filter.SearchExpr = expr
} }
@ -321,13 +369,25 @@ func findReqFilterToHTTPReqLogFilter(findReqFilter reqlog.FindRequestsFilter) *H
if findReqFilter == empty { if findReqFilter == empty {
return nil return nil
} }
httpReqLogFilter := &HTTPRequestLogFilter{ httpReqLogFilter := &HTTPRequestLogFilter{
OnlyInScope: findReqFilter.OnlyInScope, OnlyInScope: findReqFilter.OnlyInScope,
} }
if findReqFilter.RawSearchExpr != "" { if findReqFilter.SearchExpr != nil {
httpReqLogFilter.SearchExpression = &findReqFilter.RawSearchExpr searchExpr := findReqFilter.SearchExpr.String()
httpReqLogFilter.SearchExpression = &searchExpr
} }
return httpReqLogFilter return httpReqLogFilter
} }
func noActiveProjectErr(ctx context.Context) error {
return &gqlerror.Error{
Path: graphql.GetPath(ctx),
Message: "No active project.",
Extensions: map[string]interface{}{
"code": "no_active_project",
},
}
}

View File

@ -10,7 +10,6 @@ type HttpRequestLog {
} }
type HttpResponseLog { type HttpResponseLog {
requestId: ID!
proto: String! proto: String!
statusCode: Int! statusCode: Int!
statusReason: String! statusReason: String!
@ -24,6 +23,7 @@ type HttpHeader {
} }
type Project { type Project {
id: ID!
name: String! name: String!
isActive: Boolean! isActive: Boolean!
} }
@ -82,9 +82,10 @@ type Query {
} }
type Mutation { type Mutation {
openProject(name: String!): Project createProject(name: String!): Project
openProject(id: ID!): Project
closeProject: CloseProjectResult! closeProject: CloseProjectResult!
deleteProject(name: String!): DeleteProjectResult! deleteProject(id: ID!): DeleteProjectResult!
clearHTTPRequestLog: ClearHTTPRequestLogResult! clearHTTPRequestLog: ClearHTTPRequestLogResult!
setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]! setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]!
setHttpRequestLogFilter( setHttpRequestLogFilter(

53
pkg/db/badger/badger.go Normal file
View File

@ -0,0 +1,53 @@
package badger
import (
"fmt"
"github.com/dgraph-io/badger/v3"
)
const (
// Key prefixes. Each prefix value should be unique.
projectPrefix = 0x00
reqLogPrefix = 0x01
resLogPrefix = 0x02
// Request log indices.
reqLogProjectIDIndex = 0x00
)
// Database is used to store and retrieve data from an underlying Badger database.
type Database struct {
badger *badger.DB
}
// OpenDatabase opens a new Badger database.
func OpenDatabase(opts badger.Options) (*Database, error) {
db, err := badger.Open(opts)
if err != nil {
return nil, fmt.Errorf("badger: failed to open database: %w", err)
}
return &Database{badger: db}, nil
}
// Close closes the underlying Badger database.
func (db *Database) Close() error {
return db.badger.Close()
}
// DatabaseFromBadgerDB returns a Database with `db` set as the underlying
// Badger database.
func DatabaseFromBadgerDB(db *badger.DB) *Database {
return &Database{badger: db}
}
func entryKey(prefix, index byte, value []byte) []byte {
// Key consists of: | prefix (byte) | index (byte) | value
key := make([]byte, 2+len(value))
key[0] = prefix
key[1] = index
copy(key[2:len(value)+2], value)
return key
}

110
pkg/db/badger/proj.go Normal file
View File

@ -0,0 +1,110 @@
package badger
import (
"bytes"
"context"
"encoding/gob"
"errors"
"fmt"
"github.com/dgraph-io/badger/v3"
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/proj"
)
func (db *Database) UpsertProject(ctx context.Context, project proj.Project) error {
buf := bytes.Buffer{}
err := gob.NewEncoder(&buf).Encode(project)
if err != nil {
return fmt.Errorf("badger: failed to encode project: %w", err)
}
err = db.badger.Update(func(txn *badger.Txn) error {
return txn.Set(entryKey(projectPrefix, 0, project.ID[:]), buf.Bytes())
})
if err != nil {
return fmt.Errorf("badger: failed to commit transaction: %w", err)
}
return nil
}
func (db *Database) FindProjectByID(ctx context.Context, projectID ulid.ULID) (project proj.Project, err error) {
err = db.badger.View(func(txn *badger.Txn) error {
item, err := txn.Get(entryKey(projectPrefix, 0, projectID[:]))
if err != nil {
return err
}
err = item.Value(func(rawProject []byte) error {
return gob.NewDecoder(bytes.NewReader(rawProject)).Decode(&project)
})
if err != nil {
return fmt.Errorf("failed to retrieve or parse project: %w", err)
}
return nil
})
if errors.Is(err, badger.ErrKeyNotFound) {
return proj.Project{}, proj.ErrProjectNotFound
}
if err != nil {
return proj.Project{}, fmt.Errorf("badger: failed to commit transaction: %w", err)
}
return project, nil
}
func (db *Database) DeleteProject(ctx context.Context, projectID ulid.ULID) error {
err := db.ClearRequestLogs(ctx, projectID)
if err != nil {
return fmt.Errorf("badger: failed to delete project request logs: %w", err)
}
err = db.badger.Update(func(txn *badger.Txn) error {
return txn.Delete(entryKey(projectPrefix, 0, projectID[:]))
})
if err != nil {
return fmt.Errorf("badger: failed to delete project item: %w", err)
}
return nil
}
func (db *Database) Projects(ctx context.Context) ([]proj.Project, error) {
projects := make([]proj.Project, 0)
err := db.badger.View(func(txn *badger.Txn) error {
var rawProject []byte
prefix := entryKey(projectPrefix, 0, nil)
iterator := txn.NewIterator(badger.DefaultIteratorOptions)
defer iterator.Close()
for iterator.Seek(prefix); iterator.ValidForPrefix(prefix); iterator.Next() {
rawProject, err := iterator.Item().ValueCopy(rawProject)
if err != nil {
return fmt.Errorf("failed to copy value: %w", err)
}
var project proj.Project
err = gob.NewDecoder(bytes.NewReader(rawProject)).Decode(&project)
if err != nil {
return fmt.Errorf("failed to decode project: %w", err)
}
projects = append(projects, project)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("badger: failed to commit transaction: %w", err)
}
return projects, nil
}

284
pkg/db/badger/proj_test.go Normal file
View File

@ -0,0 +1,284 @@
package badger
import (
"bytes"
"context"
"encoding/gob"
"errors"
"math/rand"
"regexp"
"testing"
"time"
badgerdb "github.com/dgraph-io/badger/v3"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
)
//nolint:gosec
var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
var regexpCompareOpt = cmp.Comparer(func(x, y *regexp.Regexp) bool {
switch {
case x == nil && y == nil:
return true
case x == nil || y == nil:
return false
default:
return x.String() == y.String()
}
})
func TestUpsertProject(t *testing.T) {
t.Parallel()
badgerDB, err := badgerdb.Open(badgerdb.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatalf("failed to open badger database: %v", err)
}
database := DatabaseFromBadgerDB(badgerDB)
defer database.Close()
searchExpr, err := search.ParseQuery("foo AND bar OR NOT baz")
if err != nil {
t.Fatalf("unexpected error (expected: nil, got: %v)", err)
}
exp := proj.Project{
ID: ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy),
Name: "foobar",
Settings: proj.Settings{
ReqLogBypassOutOfScope: true,
ReqLogOnlyFindInScope: true,
ScopeRules: []scope.Rule{
{
URL: regexp.MustCompile("^https://(.*)example.com(.*)$"),
Header: scope.Header{
Key: regexp.MustCompile("^X-Foo(.*)$"),
Value: regexp.MustCompile("^foo(.*)$"),
},
Body: regexp.MustCompile("^foo(.*)"),
},
},
SearchExpr: searchExpr,
},
}
err = database.UpsertProject(context.Background(), exp)
if err != nil {
t.Fatalf("unexpected error storing project: %v", err)
}
var rawProject []byte
err = badgerDB.View(func(txn *badgerdb.Txn) error {
item, err := txn.Get(entryKey(projectPrefix, 0, exp.ID[:]))
if err != nil {
return err
}
rawProject, err = item.ValueCopy(nil)
return err
})
if err != nil {
t.Fatalf("unexpected error retrieving project from database: %v", err)
}
got := proj.Project{}
err = gob.NewDecoder(bytes.NewReader(rawProject)).Decode(&got)
if err != nil {
t.Fatalf("unexpected error decoding project: %v", err)
}
if diff := cmp.Diff(exp, got, regexpCompareOpt, cmpopts.IgnoreUnexported(proj.Project{})); diff != "" {
t.Fatalf("project not equal (-exp, +got):\n%v", diff)
}
}
func TestFindProjectByID(t *testing.T) {
t.Parallel()
t.Run("existing project", func(t *testing.T) {
t.Parallel()
badgerDB, err := badgerdb.Open(badgerdb.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatalf("failed to open badger database: %v", err)
}
database := DatabaseFromBadgerDB(badgerDB)
defer database.Close()
exp := proj.Project{
ID: ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy),
Name: "foobar",
Settings: proj.Settings{},
}
buf := bytes.Buffer{}
err = gob.NewEncoder(&buf).Encode(exp)
if err != nil {
t.Fatalf("unexpected error encoding project: %v", err)
}
err = badgerDB.Update(func(txn *badgerdb.Txn) error {
return txn.Set(entryKey(projectPrefix, 0, exp.ID[:]), buf.Bytes())
})
if err != nil {
t.Fatalf("unexpected error setting project: %v", err)
}
got, err := database.FindProjectByID(context.Background(), exp.ID)
if err != nil {
t.Fatalf("unexpected error finding project: %v", err)
}
if diff := cmp.Diff(exp, got, cmpopts.IgnoreUnexported(proj.Project{})); diff != "" {
t.Fatalf("project not equal (-exp, +got):\n%v", diff)
}
})
t.Run("project not found", func(t *testing.T) {
t.Parallel()
database, err := OpenDatabase(badgerdb.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatalf("failed to open badger database: %v", err)
}
defer database.Close()
projectID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
_, err = database.FindProjectByID(context.Background(), projectID)
if !errors.Is(err, proj.ErrProjectNotFound) {
t.Fatalf("expected `proj.ErrProjectNotFound`, got: %v", err)
}
})
}
func TestDeleteProject(t *testing.T) {
t.Parallel()
badgerDB, err := badgerdb.Open(badgerdb.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatalf("failed to open badger database: %v", err)
}
database := DatabaseFromBadgerDB(badgerDB)
defer database.Close()
// Store fixtures.
projectID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
reqLogID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
err = badgerDB.Update(func(txn *badgerdb.Txn) error {
if err := txn.Set(entryKey(projectPrefix, 0, projectID[:]), nil); err != nil {
return err
}
if err := txn.Set(entryKey(reqLogPrefix, 0, reqLogID[:]), nil); err != nil {
return err
}
if err := txn.Set(entryKey(resLogPrefix, 0, reqLogID[:]), nil); err != nil {
return err
}
err := txn.Set(entryKey(reqLogPrefix, reqLogProjectIDIndex, append(projectID[:], reqLogID[:]...)), nil)
if err != nil {
return err
}
return nil
})
if err != nil {
t.Fatalf("unexpected error creating fixtures: %v", err)
}
err = database.DeleteProject(context.Background(), projectID)
if err != nil {
t.Fatalf("unexpected error deleting project: %v", err)
}
// Assert project key was deleted.
err = badgerDB.View(func(txn *badgerdb.Txn) error {
_, err := txn.Get(entryKey(projectPrefix, 0, projectID[:]))
return err
})
if !errors.Is(err, badgerdb.ErrKeyNotFound) {
t.Fatalf("expected `badger.ErrKeyNotFound`, got: %v", err)
}
// Assert request log item was deleted.
err = badgerDB.View(func(txn *badgerdb.Txn) error {
_, err := txn.Get(entryKey(reqLogPrefix, 0, reqLogID[:]))
return err
})
if !errors.Is(err, badgerdb.ErrKeyNotFound) {
t.Fatalf("expected `badger.ErrKeyNotFound`, got: %v", err)
}
// Assert response log item was deleted.
err = badgerDB.View(func(txn *badgerdb.Txn) error {
_, err := txn.Get(entryKey(resLogPrefix, 0, reqLogID[:]))
return err
})
if !errors.Is(err, badgerdb.ErrKeyNotFound) {
t.Fatalf("expected `badger.ErrKeyNotFound`, got: %v", err)
}
// Assert request log project ID index key was deleted.
err = badgerDB.View(func(txn *badgerdb.Txn) error {
_, err := txn.Get(entryKey(reqLogPrefix, reqLogProjectIDIndex, append(projectID[:], reqLogID[:]...)))
return err
})
if !errors.Is(err, badgerdb.ErrKeyNotFound) {
t.Fatalf("expected `badger.ErrKeyNotFound`, got: %v", err)
}
}
func TestProjects(t *testing.T) {
t.Parallel()
database, err := OpenDatabase(badgerdb.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatalf("failed to open badger database: %v", err)
}
defer database.Close()
exp := []proj.Project{
{
ID: ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy),
Name: "one",
},
{
ID: ulid.MustNew(ulid.Timestamp(time.Now())+100, ulidEntropy),
Name: "two",
},
}
// Store fixtures.
for _, project := range exp {
err = database.UpsertProject(context.Background(), project)
if err != nil {
t.Fatalf("unexpected error creating project fixture: %v", err)
}
}
got, err := database.Projects(context.Background())
if err != nil {
t.Fatalf("unexpected error finding projects: %v", err)
}
if len(exp) != len(got) {
t.Fatalf("expected %v projects, got: %v", len(exp), len(got))
}
if diff := cmp.Diff(exp, got, cmpopts.IgnoreUnexported(proj.Project{})); diff != "" {
t.Fatalf("projects not equal (-exp, +got):\n%v", diff)
}
}

251
pkg/db/badger/reqlog.go Normal file
View File

@ -0,0 +1,251 @@
package badger
import (
"bytes"
"context"
"encoding/gob"
"errors"
"fmt"
"github.com/dgraph-io/badger/v3"
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
)
func (db *Database) FindRequestLogs(ctx context.Context, filter reqlog.FindRequestsFilter, scope *scope.Scope) ([]reqlog.RequestLog, error) {
if filter.ProjectID.Compare(ulid.ULID{}) == 0 {
return nil, reqlog.ErrProjectIDMustBeSet
}
txn := db.badger.NewTransaction(false)
defer txn.Discard()
reqLogIDs, err := findRequestLogIDsByProjectID(txn, filter.ProjectID)
if err != nil {
return nil, fmt.Errorf("badger: failed to find request log IDs: %w", err)
}
reqLogs := make([]reqlog.RequestLog, 0, len(reqLogIDs))
for _, reqLogID := range reqLogIDs {
reqLog, err := getRequestLogWithResponse(txn, reqLogID)
if err != nil {
return nil, fmt.Errorf("badger: failed to get request log (id: %v): %w", reqLogID.String(), err)
}
if filter.OnlyInScope {
if !reqLog.MatchScope(scope) {
continue
}
}
// Filter by search expression.
// TODO: Once pagination is introduced, this filter logic should be done
// as items are retrieved (e.g. when using a `badger.Iterator`).
if filter.SearchExpr != nil {
match, err := reqLog.Matches(filter.SearchExpr)
if err != nil {
return nil, fmt.Errorf(
"badger: failed to match search expression for request log (id: %v): %w",
reqLogID.String(), err,
)
}
if !match {
continue
}
}
reqLogs = append(reqLogs, reqLog)
}
return reqLogs, nil
}
func getRequestLogWithResponse(txn *badger.Txn, reqLogID ulid.ULID) (reqlog.RequestLog, error) {
item, err := txn.Get(entryKey(reqLogPrefix, 0, reqLogID[:]))
if err != nil {
return reqlog.RequestLog{}, fmt.Errorf("failed to lookup request log item: %w", err)
}
reqLog := reqlog.RequestLog{
ID: reqLogID,
}
err = item.Value(func(rawReqLog []byte) error {
err = gob.NewDecoder(bytes.NewReader(rawReqLog)).Decode(&reqLog)
if err != nil {
return fmt.Errorf("failed to decode request log: %w", err)
}
return nil
})
if err != nil {
return reqlog.RequestLog{}, fmt.Errorf("failed to retrieve or parse request log value: %w", err)
}
item, err = txn.Get(entryKey(resLogPrefix, 0, reqLogID[:]))
if errors.Is(err, badger.ErrKeyNotFound) {
return reqLog, nil
}
if err != nil {
return reqlog.RequestLog{}, fmt.Errorf("failed to get response log: %w", err)
}
err = item.Value(func(rawReslog []byte) error {
var resLog reqlog.ResponseLog
err = gob.NewDecoder(bytes.NewReader(rawReslog)).Decode(&resLog)
if err != nil {
return fmt.Errorf("failed to decode response log: %w", err)
}
reqLog.Response = &resLog
return nil
})
if err != nil {
return reqlog.RequestLog{}, fmt.Errorf("failed to retrieve or parse response log value: %w", err)
}
return reqLog, nil
}
func (db *Database) FindRequestLogByID(ctx context.Context, reqLogID ulid.ULID) (reqLog reqlog.RequestLog, err error) {
txn := db.badger.NewTransaction(false)
defer txn.Discard()
reqLog, err = getRequestLogWithResponse(txn, reqLogID)
if err != nil {
return reqlog.RequestLog{}, fmt.Errorf("badger: failed to get request log: %w", err)
}
return reqLog, nil
}
func (db *Database) StoreRequestLog(ctx context.Context, reqLog reqlog.RequestLog) error {
buf := bytes.Buffer{}
err := gob.NewEncoder(&buf).Encode(reqLog)
if err != nil {
return fmt.Errorf("badger: failed to encode request log: %w", err)
}
entries := []*badger.Entry{
// Request log itself.
{
Key: entryKey(reqLogPrefix, 0, reqLog.ID[:]),
Value: buf.Bytes(),
},
// Index by project ID.
{
Key: entryKey(reqLogPrefix, reqLogProjectIDIndex, append(reqLog.ProjectID[:], reqLog.ID[:]...)),
},
}
err = db.badger.Update(func(txn *badger.Txn) error {
for i := range entries {
err := txn.SetEntry(entries[i])
if err != nil {
return err
}
}
return nil
})
if err != nil {
return fmt.Errorf("badger: failed to commit transaction: %w", err)
}
return nil
}
func (db *Database) StoreResponseLog(ctx context.Context, reqLogID ulid.ULID, resLog reqlog.ResponseLog) error {
buf := bytes.Buffer{}
err := gob.NewEncoder(&buf).Encode(resLog)
if err != nil {
return fmt.Errorf("badger: failed to encode response log: %w", err)
}
err = db.badger.Update(func(txn *badger.Txn) error {
return txn.SetEntry(&badger.Entry{
Key: entryKey(resLogPrefix, 0, reqLogID[:]),
Value: buf.Bytes(),
})
})
if err != nil {
return fmt.Errorf("badger: failed to commit transaction: %w", err)
}
return nil
}
func (db *Database) ClearRequestLogs(ctx context.Context, projectID ulid.ULID) error {
// Note: this transaction is used just for reading; we use the `badger.WriteBatch`
// API to bulk delete items.
txn := db.badger.NewTransaction(false)
defer txn.Discard()
reqLogIDs, err := findRequestLogIDsByProjectID(txn, projectID)
if err != nil {
return fmt.Errorf("badger: failed to find request log IDs: %w", err)
}
writeBatch := db.badger.NewWriteBatch()
defer writeBatch.Cancel()
for _, reqLogID := range reqLogIDs {
// Delete request logs.
err := writeBatch.Delete(entryKey(reqLogPrefix, 0, reqLogID[:]))
if err != nil {
return fmt.Errorf("badger: failed to delete request log: %w", err)
}
// Delete related response log.
err = writeBatch.Delete(entryKey(resLogPrefix, 0, reqLogID[:]))
if err != nil {
return fmt.Errorf("badger: failed to delete request log: %w", err)
}
}
if err := writeBatch.Flush(); err != nil {
return fmt.Errorf("badger: failed to commit batch write: %w", err)
}
err = db.badger.DropPrefix(entryKey(reqLogPrefix, reqLogProjectIDIndex, projectID[:]))
if err != nil {
return fmt.Errorf("badger: failed to drop request log project ID index items: %w", err)
}
return nil
}
func findRequestLogIDsByProjectID(txn *badger.Txn, projectID ulid.ULID) ([]ulid.ULID, error) {
reqLogIDs := make([]ulid.ULID, 0)
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false
iterator := txn.NewIterator(opts)
defer iterator.Close()
var projectIndexKey []byte
prefix := entryKey(reqLogPrefix, reqLogProjectIDIndex, projectID[:])
for iterator.Seek(prefix); iterator.ValidForPrefix(prefix); iterator.Next() {
projectIndexKey = iterator.Item().KeyCopy(projectIndexKey)
var id ulid.ULID
// The request log ID starts *after* the first 2 prefix and index bytes
// and the 16 byte project ID.
if err := id.UnmarshalBinary(projectIndexKey[18:]); err != nil {
return nil, fmt.Errorf("failed to parse request log ID: %w", err)
}
reqLogIDs = append(reqLogIDs, id)
}
return reqLogIDs, nil
}

View File

@ -0,0 +1,121 @@
package badger
import (
"context"
"errors"
"net/http"
"net/url"
"testing"
"time"
badgerdb "github.com/dgraph-io/badger/v3"
"github.com/google/go-cmp/cmp"
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/reqlog"
)
func TestFindRequestLogs(t *testing.T) {
t.Parallel()
t.Run("without project ID in filter", func(t *testing.T) {
t.Parallel()
database, err := OpenDatabase(badgerdb.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatalf("failed to open badger database: %v", err)
}
defer database.Close()
filter := reqlog.FindRequestsFilter{}
_, err = database.FindRequestLogs(context.Background(), filter, nil)
if !errors.Is(err, reqlog.ErrProjectIDMustBeSet) {
t.Fatalf("expected `reqlog.ErrProjectIDMustBeSet`, got: %v", err)
}
})
t.Run("returns request logs and related response logs", func(t *testing.T) {
t.Parallel()
database, err := OpenDatabase(badgerdb.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatalf("failed to open badger database: %v", err)
}
defer database.Close()
projectID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
exp := []reqlog.RequestLog{
{
ID: ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy),
ProjectID: projectID,
URL: mustParseURL(t, "https://example.com/foobar"),
Method: http.MethodPost,
Proto: "HTTP/1.1",
Header: http.Header{
"X-Foo": []string{"baz"},
},
Body: []byte("foo"),
Response: &reqlog.ResponseLog{
Proto: "HTTP/1.1",
Status: "200 OK",
StatusCode: 200,
Header: http.Header{
"X-Yolo": []string{"swag"},
},
Body: []byte("bar"),
},
},
{
ID: ulid.MustNew(ulid.Timestamp(time.Now())+100, ulidEntropy),
ProjectID: projectID,
URL: mustParseURL(t, "https://example.com/foo?bar=baz"),
Method: http.MethodGet,
Proto: "HTTP/1.1",
Header: http.Header{
"X-Foo": []string{"baz"},
},
},
}
// Store fixtures.
for _, reqLog := range exp {
err = database.StoreRequestLog(context.Background(), reqLog)
if err != nil {
t.Fatalf("unexpected error creating request log fixture: %v", err)
}
if reqLog.Response != nil {
err = database.StoreResponseLog(context.Background(), reqLog.ID, *reqLog.Response)
if err != nil {
t.Fatalf("unexpected error creating response log fixture: %v", err)
}
}
}
filter := reqlog.FindRequestsFilter{
ProjectID: projectID,
}
got, err := database.FindRequestLogs(context.Background(), filter, nil)
if err != nil {
t.Fatalf("unexpected error finding request logs: %v", err)
}
if diff := cmp.Diff(exp, got); diff != "" {
t.Fatalf("request logs not equal (-exp, +got):\n%v", diff)
}
})
}
func mustParseURL(t *testing.T, s string) *url.URL {
t.Helper()
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return u
}

View File

@ -1,82 +0,0 @@
package sqlite
import (
"database/sql"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/dstotijn/hetty/pkg/reqlog"
)
type reqURL url.URL
type httpRequest struct {
ID int64 `db:"req_id"`
Proto string `db:"req_proto"`
URL reqURL `db:"url"`
Method string `db:"method"`
Body []byte `db:"req_body"`
Timestamp time.Time `db:"req_timestamp"`
httpResponse
}
type httpResponse struct {
ID sql.NullInt64 `db:"res_id"`
RequestID sql.NullInt64 `db:"res_req_id"`
Proto sql.NullString `db:"res_proto"`
StatusCode sql.NullInt64 `db:"status_code"`
StatusReason sql.NullString `db:"status_reason"`
Body []byte `db:"res_body"`
Timestamp sql.NullTime `db:"res_timestamp"`
}
// Value implements driver.Valuer.
func (u *reqURL) Scan(value interface{}) error {
rawURL, ok := value.(string)
if !ok {
return errors.New("sqlite: cannot scan non-string value")
}
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("sqlite: could not parse URL: %v", err)
}
*u = reqURL(*parsed)
return nil
}
func (dto httpRequest) toRequestLog() reqlog.Request {
u := url.URL(dto.URL)
reqLog := reqlog.Request{
ID: dto.ID,
Request: http.Request{
Proto: dto.Proto,
Method: dto.Method,
URL: &u,
},
Body: dto.Body,
Timestamp: dto.Timestamp,
}
if dto.httpResponse.ID.Valid {
reqLog.Response = &reqlog.Response{
ID: dto.httpResponse.ID.Int64,
RequestID: dto.httpResponse.RequestID.Int64,
Response: http.Response{
Status: strconv.FormatInt(dto.StatusCode.Int64, 10) + " " + dto.StatusReason.String,
StatusCode: int(dto.StatusCode.Int64),
Proto: dto.httpResponse.Proto.String,
},
Body: dto.httpResponse.Body,
Timestamp: dto.httpResponse.Timestamp.Time,
}
}
return reqLog
}

View File

@ -1,126 +0,0 @@
package sqlite
import (
"errors"
"fmt"
"sort"
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)
case *search.StringLiteral:
return parseStringLiteral(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")
}
}
func parseStringLiteral(strLiteral *search.StringLiteral) (sq.Sqlizer, error) {
// Sorting is not necessary, but makes it easier to do assertions in tests.
sortedKeys := make([]string, 0, len(stringLiteralMap))
for _, v := range stringLiteralMap {
sortedKeys = append(sortedKeys, v)
}
sort.Strings(sortedKeys)
or := make(sq.Or, len(stringLiteralMap))
for i, value := range sortedKeys {
or[i] = sq.Like{value: "%" + strLiteral.Value + "%"}
}
return or, nil
}

View File

@ -1,217 +0,0 @@
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,
},
{
name: "foo",
searchExpr: &search.StringLiteral{
Value: "foo",
},
expectedSqlizer: sq.Or{
sq.Like{"req.body": "%foo%"},
sq.Like{"req.id": "%foo%"},
sq.Like{"req.method": "%foo%"},
sq.Like{"req.proto": "%foo%"},
sq.Like{"req.timestamp": "%foo%"},
sq.Like{"req.url": "%foo%"},
sq.Like{"res.body": "%foo%"},
sq.Like{"res.id": "%foo%"},
sq.Like{"res.proto": "%foo%"},
sq.Like{"res.status_code": "%foo%"},
sq.Like{"res.status_reason": "%foo%"},
sq.Like{"res.timestamp": "%foo%"},
},
},
}
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

@ -1,676 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/99designs/gqlgen/graphql"
sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
"github.com/mattn/go-sqlite3"
)
var regexpFn = func(pattern string, value interface{}) (bool, error) {
switch v := value.(type) {
case string:
return regexp.MatchString(pattern, v)
case int64:
return regexp.MatchString(pattern, fmt.Sprintf("%v", v))
case []byte:
return regexp.Match(pattern, v)
default:
return false, fmt.Errorf("unsupported type %T", v)
}
}
// Client implements reqlog.Repository.
type Client struct {
db *sqlx.DB
dbPath string
activeProject string
}
type httpRequestLogsQuery struct {
requestCols []string
requestHeaderCols []string
responseHeaderCols []string
joinResponse bool
}
func init() {
sql.Register("sqlite3_with_regexp", &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
if err := conn.RegisterFunc("regexp", regexpFn, false); err != nil {
return err
}
return nil
},
})
}
func New(dbPath string) (*Client, error) {
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
if err := os.MkdirAll(dbPath, 0755); err != nil {
return nil, fmt.Errorf("proj: could not create project directory: %v", err)
}
}
return &Client{
dbPath: dbPath,
}, nil
}
// OpenProject opens a project database.
func (c *Client) OpenProject(name string) error {
if c.db != nil {
return errors.New("sqlite: there is already a project open")
}
opts := make(url.Values)
opts.Set("_foreign_keys", "1")
dbPath := filepath.Join(c.dbPath, name+".db")
dsn := fmt.Sprintf("file:%v?%v", dbPath, opts.Encode())
db, err := sqlx.Open("sqlite3_with_regexp", dsn)
if err != nil {
return fmt.Errorf("sqlite: could not open database: %v", err)
}
if err := db.Ping(); err != nil {
return fmt.Errorf("sqlite: could not ping database: %v", err)
}
if err := prepareSchema(db); err != nil {
return fmt.Errorf("sqlite: could not prepare schema: %v", err)
}
c.db = db
c.activeProject = name
return nil
}
func (c *Client) Projects() ([]proj.Project, error) {
files, err := ioutil.ReadDir(c.dbPath)
if err != nil {
return nil, fmt.Errorf("sqlite: could not read projects directory: %v", err)
}
projects := make([]proj.Project, len(files))
for i, file := range files {
projName := strings.TrimSuffix(file.Name(), ".db")
projects[i] = proj.Project{
Name: projName,
IsActive: c.activeProject == projName,
}
}
return projects, nil
}
func prepareSchema(db *sqlx.DB) error {
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS http_requests (
id INTEGER PRIMARY KEY,
proto TEXT,
url TEXT,
method TEXT,
body BLOB,
timestamp DATETIME
)`)
if err != nil {
return fmt.Errorf("could not create http_requests table: %v", err)
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS http_responses (
id INTEGER PRIMARY KEY,
req_id INTEGER REFERENCES http_requests(id) ON DELETE CASCADE,
proto TEXT,
status_code INTEGER,
status_reason TEXT,
body BLOB,
timestamp DATETIME
)`)
if err != nil {
return fmt.Errorf("could not create http_responses table: %v", err)
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS http_headers (
id INTEGER PRIMARY KEY,
req_id INTEGER REFERENCES http_requests(id) ON DELETE CASCADE,
res_id INTEGER REFERENCES http_responses(id) ON DELETE CASCADE,
key TEXT,
value TEXT
)`)
if err != nil {
return fmt.Errorf("could not create http_headers table: %v", err)
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS settings (
module TEXT PRIMARY KEY,
settings TEXT
)`)
if err != nil {
return fmt.Errorf("could not create settings table: %v", err)
}
return nil
}
// Close uses the underlying database if it's open.
func (c *Client) Close() error {
if c.db == nil {
return nil
}
if err := c.db.Close(); err != nil {
return fmt.Errorf("sqlite: could not close database: %v", err)
}
c.db = nil
c.activeProject = ""
return nil
}
func (c *Client) DeleteProject(name string) error {
if err := os.Remove(filepath.Join(c.dbPath, name+".db")); err != nil {
return fmt.Errorf("sqlite: could not remove database file: %v", err)
}
return nil
}
var reqFieldToColumnMap = map[string]string{
"proto": "proto AS req_proto",
"url": "url",
"method": "method",
"body": "body AS req_body",
"timestamp": "timestamp AS req_timestamp",
}
var resFieldToColumnMap = map[string]string{
"requestId": "req_id AS res_req_id",
"proto": "proto AS res_proto",
"statusCode": "status_code",
"statusReason": "status_reason",
"body": "body AS res_body",
"timestamp": "timestamp AS res_timestamp",
}
var headerFieldToColumnMap = map[string]string{
"key": "key",
"value": "value",
}
func (c *Client) ClearRequestLogs(ctx context.Context) error {
if c.db == nil {
return proj.ErrNoProject
}
_, err := c.db.Exec("DELETE FROM http_requests")
if err != nil {
return fmt.Errorf("sqlite: could not delete requests: %v", err)
}
return nil
}
func (c *Client) FindRequestLogs(
ctx context.Context,
filter reqlog.FindRequestsFilter,
scope *scope.Scope,
) (reqLogs []reqlog.Request, err error) {
if c.db == nil {
return nil, proj.ErrNoProject
}
httpReqLogsQuery := parseHTTPRequestLogsQuery(ctx)
reqQuery := sq.
Select(httpReqLogsQuery.requestCols...).
From("http_requests req").
OrderBy("req.id DESC")
if httpReqLogsQuery.joinResponse {
reqQuery = reqQuery.LeftJoin("http_responses res ON req.id = res.req_id")
}
if filter.OnlyInScope && scope != nil {
var ruleExpr []sq.Sqlizer
for _, rule := range scope.Rules() {
if rule.URL != nil {
ruleExpr = append(ruleExpr, sq.Expr("regexp(?, req.url)", rule.URL.String()))
}
}
if len(ruleExpr) > 0 {
reqQuery = reqQuery.Where(sq.Or(ruleExpr))
}
}
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)
}
rows, err := c.db.QueryxContext(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("sqlite: could not execute query: %v", err)
}
defer rows.Close()
for rows.Next() {
var dto httpRequest
err = rows.StructScan(&dto)
if err != nil {
return nil, fmt.Errorf("sqlite: could not scan row: %v", err)
}
reqLogs = append(reqLogs, dto.toRequestLog())
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("sqlite: could not iterate over rows: %v", err)
}
rows.Close()
if err := c.queryHeaders(ctx, httpReqLogsQuery, reqLogs); err != nil {
return nil, fmt.Errorf("sqlite: could not query headers: %v", err)
}
return reqLogs, nil
}
func (c *Client) FindRequestLogByID(ctx context.Context, id int64) (reqlog.Request, error) {
if c.db == nil {
return reqlog.Request{}, proj.ErrNoProject
}
httpReqLogsQuery := parseHTTPRequestLogsQuery(ctx)
reqQuery := sq.
Select(httpReqLogsQuery.requestCols...).
From("http_requests req").
Where("req.id = ?")
if httpReqLogsQuery.joinResponse {
reqQuery = reqQuery.LeftJoin("http_responses res ON req.id = res.req_id")
}
reqSQL, _, err := reqQuery.ToSql()
if err != nil {
return reqlog.Request{}, fmt.Errorf("sqlite: could not parse query: %v", err)
}
row := c.db.QueryRowxContext(ctx, reqSQL, id)
var dto httpRequest
err = row.StructScan(&dto)
if err == sql.ErrNoRows {
return reqlog.Request{}, reqlog.ErrRequestNotFound
}
if err != nil {
return reqlog.Request{}, fmt.Errorf("sqlite: could not scan row: %v", err)
}
reqLog := dto.toRequestLog()
reqLogs := []reqlog.Request{reqLog}
if err := c.queryHeaders(ctx, httpReqLogsQuery, reqLogs); err != nil {
return reqlog.Request{}, fmt.Errorf("sqlite: could not query headers: %v", err)
}
return reqLogs[0], nil
}
func (c *Client) AddRequestLog(
ctx context.Context,
req http.Request,
body []byte,
timestamp time.Time,
) (*reqlog.Request, error) {
if c.db == nil {
return nil, proj.ErrNoProject
}
reqLog := &reqlog.Request{
Request: req,
Body: body,
Timestamp: timestamp,
}
tx, err := c.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("sqlite: could not start transaction: %v", err)
}
defer tx.Rollback()
reqStmt, err := tx.PrepareContext(ctx, `INSERT INTO http_requests (
proto,
url,
method,
body,
timestamp
) VALUES (?, ?, ?, ?, ?)`)
if err != nil {
return nil, fmt.Errorf("sqlite: could not prepare statement: %v", err)
}
defer reqStmt.Close()
result, err := reqStmt.ExecContext(ctx,
reqLog.Request.Proto,
reqLog.Request.URL.String(),
reqLog.Request.Method,
reqLog.Body,
reqLog.Timestamp,
)
if err != nil {
return nil, fmt.Errorf("sqlite: could not execute statement: %v", err)
}
reqID, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("sqlite: could not get last insert ID: %v", err)
}
reqLog.ID = reqID
headerStmt, err := tx.PrepareContext(ctx, `INSERT INTO http_headers (
req_id,
key,
value
) VALUES (?, ?, ?)`)
if err != nil {
return nil, fmt.Errorf("sqlite: could not prepare statement: %v", err)
}
defer headerStmt.Close()
err = insertHeaders(ctx, headerStmt, reqID, reqLog.Request.Header)
if err != nil {
return nil, fmt.Errorf("sqlite: could not insert http headers: %v", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("sqlite: could not commit transaction: %v", err)
}
return reqLog, nil
}
func (c *Client) AddResponseLog(
ctx context.Context,
reqID int64,
res http.Response,
body []byte,
timestamp time.Time,
) (*reqlog.Response, error) {
if c.db == nil {
return nil, proj.ErrNoProject
}
resLog := &reqlog.Response{
RequestID: reqID,
Response: res,
Body: body,
Timestamp: timestamp,
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("sqlite: could not start transaction: %v", err)
}
defer tx.Rollback()
resStmt, err := tx.PrepareContext(ctx, `INSERT INTO http_responses (
req_id,
proto,
status_code,
status_reason,
body,
timestamp
) VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return nil, fmt.Errorf("sqlite: could not prepare statement: %v", err)
}
defer resStmt.Close()
var statusReason string
if len(resLog.Response.Status) > 4 {
statusReason = resLog.Response.Status[4:]
}
result, err := resStmt.ExecContext(ctx,
resLog.RequestID,
resLog.Response.Proto,
resLog.Response.StatusCode,
statusReason,
resLog.Body,
resLog.Timestamp,
)
if err != nil {
return nil, fmt.Errorf("sqlite: could not execute statement: %v", err)
}
resID, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("sqlite: could not get last insert ID: %v", err)
}
resLog.ID = resID
headerStmt, err := tx.PrepareContext(ctx, `INSERT INTO http_headers (
res_id,
key,
value
) VALUES (?, ?, ?)`)
if err != nil {
return nil, fmt.Errorf("sqlite: could not prepare statement: %v", err)
}
defer headerStmt.Close()
err = insertHeaders(ctx, headerStmt, resID, resLog.Response.Header)
if err != nil {
return nil, fmt.Errorf("sqlite: could not insert http headers: %v", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("sqlite: could not commit transaction: %v", err)
}
return resLog, nil
}
func (c *Client) UpsertSettings(ctx context.Context, module string, settings interface{}) error {
if c.db == nil {
// TODO: Fix where `ErrNoProject` lives.
return proj.ErrNoProject
}
jsonSettings, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("sqlite: could not encode settings as JSON: %v", err)
}
_, err = c.db.ExecContext(ctx,
`INSERT INTO settings (module, settings) VALUES (?, ?)
ON CONFLICT(module) DO UPDATE SET settings = ?`, module, jsonSettings, jsonSettings)
if err != nil {
return fmt.Errorf("sqlite: could not insert scope settings: %v", err)
}
return nil
}
func (c *Client) FindSettingsByModule(ctx context.Context, module string, settings interface{}) error {
if c.db == nil {
return proj.ErrNoProject
}
var jsonSettings []byte
row := c.db.QueryRowContext(ctx, `SELECT settings FROM settings WHERE module = ?`, module)
err := row.Scan(&jsonSettings)
if err == sql.ErrNoRows {
return proj.ErrNoSettings
}
if err != nil {
return fmt.Errorf("sqlite: could not scan row: %v", err)
}
if err := json.Unmarshal(jsonSettings, &settings); err != nil {
return fmt.Errorf("sqlite: could not decode settings from JSON: %v", err)
}
return nil
}
func insertHeaders(ctx context.Context, stmt *sql.Stmt, id int64, headers http.Header) error {
for key, values := range headers {
for _, value := range values {
if _, err := stmt.ExecContext(ctx, id, key, value); err != nil {
return fmt.Errorf("could not execute statement: %v", err)
}
}
}
return nil
}
func findHeaders(ctx context.Context, stmt *sql.Stmt, id int64) (http.Header, error) {
headers := make(http.Header)
rows, err := stmt.QueryContext(ctx, id)
if err != nil {
return nil, fmt.Errorf("sqlite: could not execute query: %v", err)
}
defer rows.Close()
for rows.Next() {
var key, value string
err := rows.Scan(
&key,
&value,
)
if err != nil {
return nil, fmt.Errorf("sqlite: could not scan row: %v", err)
}
headers.Add(key, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("sqlite: could not iterate over rows: %v", err)
}
return headers, nil
}
func parseHTTPRequestLogsQuery(ctx context.Context) httpRequestLogsQuery {
var joinResponse bool
var reqHeaderCols, resHeaderCols []string
opCtx := graphql.GetOperationContext(ctx)
reqFields := graphql.CollectFieldsCtx(ctx, nil)
reqCols := []string{"req.id AS req_id", "res.id AS res_id"}
for _, reqField := range reqFields {
if col, ok := reqFieldToColumnMap[reqField.Name]; ok {
reqCols = append(reqCols, "req."+col)
}
if reqField.Name == "headers" {
headerFields := graphql.CollectFields(opCtx, reqField.Selections, nil)
for _, headerField := range headerFields {
if col, ok := headerFieldToColumnMap[headerField.Name]; ok {
reqHeaderCols = append(reqHeaderCols, col)
}
}
}
if reqField.Name == "response" {
joinResponse = true
resFields := graphql.CollectFields(opCtx, reqField.Selections, nil)
for _, resField := range resFields {
if resField.Name == "headers" {
reqCols = append(reqCols, "res.id AS res_id")
headerFields := graphql.CollectFields(opCtx, resField.Selections, nil)
for _, headerField := range headerFields {
if col, ok := headerFieldToColumnMap[headerField.Name]; ok {
resHeaderCols = append(resHeaderCols, col)
}
}
}
if col, ok := resFieldToColumnMap[resField.Name]; ok {
reqCols = append(reqCols, "res."+col)
}
}
}
}
return httpRequestLogsQuery{
requestCols: reqCols,
requestHeaderCols: reqHeaderCols,
responseHeaderCols: resHeaderCols,
joinResponse: joinResponse,
}
}
func (c *Client) queryHeaders(
ctx context.Context,
query httpRequestLogsQuery,
reqLogs []reqlog.Request,
) error {
if len(query.requestHeaderCols) > 0 {
reqHeadersQuery, _, err := sq.
Select(query.requestHeaderCols...).
From("http_headers").Where("req_id = ?").
ToSql()
if err != nil {
return fmt.Errorf("could not parse request headers query: %v", err)
}
reqHeadersStmt, err := c.db.PrepareContext(ctx, reqHeadersQuery)
if err != nil {
return fmt.Errorf("could not prepare statement: %v", err)
}
defer reqHeadersStmt.Close()
for i := range reqLogs {
headers, err := findHeaders(ctx, reqHeadersStmt, reqLogs[i].ID)
if err != nil {
return fmt.Errorf("could not query request headers: %v", err)
}
reqLogs[i].Request.Header = headers
}
}
if len(query.responseHeaderCols) > 0 {
resHeadersQuery, _, err := sq.
Select(query.responseHeaderCols...).
From("http_headers").Where("res_id = ?").
ToSql()
if err != nil {
return fmt.Errorf("could not parse response headers query: %v", err)
}
resHeadersStmt, err := c.db.PrepareContext(ctx, resHeadersQuery)
if err != nil {
return fmt.Errorf("could not prepare statement: %v", err)
}
defer resHeadersStmt.Close()
for i := range reqLogs {
if reqLogs[i].Response == nil {
continue
}
headers, err := findHeaders(ctx, resHeadersStmt, reqLogs[i].Response.ID)
if err != nil {
return fmt.Errorf("could not query response headers: %v", err)
}
reqLogs[i].Response.Response.Header = headers
}
}
return nil
}
func (c *Client) IsOpen() bool {
return c.db != nil
}

View File

@ -5,148 +5,268 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"math/rand"
"regexp" "regexp"
"sync" "sync"
"time"
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
) )
type OnProjectOpenFn func(name string) error //nolint:gosec
type OnProjectCloseFn func(name string) error var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
type (
OnProjectOpenFn func(projectID ulid.ULID) error
OnProjectCloseFn func(projectID ulid.ULID) error
)
// Service is used for managing projects. // Service is used for managing projects.
type Service struct { type Service interface {
CreateProject(ctx context.Context, name string) (Project, error)
OpenProject(ctx context.Context, projectID ulid.ULID) (Project, error)
CloseProject() error
DeleteProject(ctx context.Context, projectID ulid.ULID) error
ActiveProject(ctx context.Context) (Project, error)
IsProjectActive(projectID ulid.ULID) bool
Projects(ctx context.Context) ([]Project, error)
Scope() *scope.Scope
SetScopeRules(ctx context.Context, rules []scope.Rule) error
SetRequestLogFindFilter(ctx context.Context, filter reqlog.FindRequestsFilter) error
OnProjectOpen(fn OnProjectOpenFn)
OnProjectClose(fn OnProjectCloseFn)
}
type service struct {
repo Repository repo Repository
activeProject string reqLogSvc *reqlog.Service
scope *scope.Scope
activeProjectID ulid.ULID
onProjectOpenFns []OnProjectOpenFn onProjectOpenFns []OnProjectOpenFn
onProjectCloseFns []OnProjectCloseFn onProjectCloseFns []OnProjectCloseFn
mu sync.RWMutex mu sync.RWMutex
} }
type Project struct { type Project struct {
ID ulid.ULID
Name string Name string
IsActive bool Settings Settings
isActive bool
}
type Settings struct {
ReqLogBypassOutOfScope bool
ReqLogOnlyFindInScope bool
ScopeRules []scope.Rule
SearchExpr search.Expression
} }
var ( var (
ErrNoProject = errors.New("proj: no open project") ErrProjectNotFound = errors.New("proj: project not found")
ErrNoSettings = errors.New("proj: settings not found") ErrNoProject = errors.New("proj: no open project")
ErrInvalidName = errors.New("proj: invalid name, must be alphanumeric or whitespace chars") ErrNoSettings = errors.New("proj: settings not found")
ErrInvalidName = errors.New("proj: invalid name, must be alphanumeric or whitespace chars")
) )
var nameRegexp = regexp.MustCompile(`^[\w\d\s]+$`) var nameRegexp = regexp.MustCompile(`^[\w\d\s]+$`)
type Config struct {
Repository Repository
ReqLogService *reqlog.Service
Scope *scope.Scope
}
// NewService returns a new Service. // NewService returns a new Service.
func NewService(repo Repository) (*Service, error) { func NewService(cfg Config) (Service, error) {
return &Service{ return &service{
repo: repo, repo: cfg.Repository,
reqLogSvc: cfg.ReqLogService,
scope: cfg.Scope,
}, nil }, nil
} }
// Close closes the currently open project database (if there is one). func (svc *service) CreateProject(ctx context.Context, name string) (Project, error) {
func (svc *Service) Close() error {
svc.mu.Lock()
defer svc.mu.Unlock()
closedProject := svc.activeProject
if err := svc.repo.Close(); err != nil {
return fmt.Errorf("proj: could not close project: %v", err)
}
svc.activeProject = ""
svc.emitProjectClosed(closedProject)
return nil
}
// Delete removes a project database file from disk (if there is one).
func (svc *Service) Delete(name string) error {
if name == "" {
return errors.New("proj: name cannot be empty")
}
if svc.activeProject == name {
return fmt.Errorf("proj: project (%v) is active", name)
}
if err := svc.repo.DeleteProject(name); err != nil {
return fmt.Errorf("proj: could not delete project: %v", err)
}
return nil
}
// Open opens a database identified with `name`. If a database with this
// identifier doesn't exist yet, it will be automatically created.
func (svc *Service) Open(ctx context.Context, name string) (Project, error) {
if !nameRegexp.MatchString(name) { if !nameRegexp.MatchString(name) {
return Project{}, ErrInvalidName return Project{}, ErrInvalidName
} }
project := Project{
ID: ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy),
Name: name,
}
err := svc.repo.UpsertProject(ctx, project)
if err != nil {
return Project{}, fmt.Errorf("proj: could not create project: %w", err)
}
return project, nil
}
// CloseProject closes the currently open project (if there is one).
func (svc *service) CloseProject() error {
svc.mu.Lock() svc.mu.Lock()
defer svc.mu.Unlock() defer svc.mu.Unlock()
if err := svc.repo.Close(); err != nil { if svc.activeProjectID.Compare(ulid.ULID{}) == 0 {
return Project{}, fmt.Errorf("proj: could not close previously open database: %v", err) return nil
} }
if err := svc.repo.OpenProject(name); err != nil { closedProjectID := svc.activeProjectID
return Project{}, fmt.Errorf("proj: could not open database: %v", err)
}
svc.activeProject = name svc.activeProjectID = ulid.ULID{}
svc.emitProjectOpened() svc.reqLogSvc.ActiveProjectID = ulid.ULID{}
svc.reqLogSvc.BypassOutOfScopeRequests = false
svc.reqLogSvc.FindReqsFilter = reqlog.FindRequestsFilter{}
svc.scope.SetRules(nil)
return Project{ svc.emitProjectClosed(closedProjectID)
Name: name,
IsActive: true, return nil
}, nil
} }
func (svc *Service) ActiveProject() (Project, error) { // DeleteProject removes a project from the repository.
activeProject := svc.activeProject func (svc *service) DeleteProject(ctx context.Context, projectID ulid.ULID) error {
if activeProject == "" { if svc.activeProjectID.Compare(projectID) == 0 {
return fmt.Errorf("proj: project (%v) is active", projectID.String())
}
if err := svc.repo.DeleteProject(ctx, projectID); err != nil {
return fmt.Errorf("proj: could not delete project: %w", err)
}
return nil
}
// OpenProject sets a project as the currently active project.
func (svc *service) OpenProject(ctx context.Context, projectID ulid.ULID) (Project, error) {
svc.mu.Lock()
defer svc.mu.Unlock()
project, err := svc.repo.FindProjectByID(ctx, projectID)
if err != nil {
return Project{}, fmt.Errorf("proj: failed to get project: %w", err)
}
svc.activeProjectID = project.ID
svc.reqLogSvc.FindReqsFilter = reqlog.FindRequestsFilter{
ProjectID: project.ID,
OnlyInScope: project.Settings.ReqLogOnlyFindInScope,
SearchExpr: project.Settings.SearchExpr,
}
svc.reqLogSvc.BypassOutOfScopeRequests = project.Settings.ReqLogBypassOutOfScope
svc.reqLogSvc.ActiveProjectID = project.ID
svc.scope.SetRules(project.Settings.ScopeRules)
svc.emitProjectOpened()
return project, nil
}
func (svc *service) ActiveProject(ctx context.Context) (Project, error) {
activeProjectID := svc.activeProjectID
if activeProjectID.Compare(ulid.ULID{}) == 0 {
return Project{}, ErrNoProject return Project{}, ErrNoProject
} }
return Project{ project, err := svc.repo.FindProjectByID(ctx, activeProjectID)
Name: activeProject, if err != nil {
}, nil return Project{}, fmt.Errorf("proj: failed to get active project: %w", err)
}
project.isActive = true
return project, nil
} }
func (svc *Service) Projects() ([]Project, error) { func (svc *service) Projects(ctx context.Context) ([]Project, error) {
projects, err := svc.repo.Projects() projects, err := svc.repo.Projects(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("proj: could not get projects: %v", err) return nil, fmt.Errorf("proj: could not get projects: %w", err)
} }
return projects, nil return projects, nil
} }
func (svc *Service) OnProjectOpen(fn OnProjectOpenFn) { func (svc *service) Scope() *scope.Scope {
return svc.scope
}
func (svc *service) OnProjectOpen(fn OnProjectOpenFn) {
svc.mu.Lock() svc.mu.Lock()
defer svc.mu.Unlock() defer svc.mu.Unlock()
svc.onProjectOpenFns = append(svc.onProjectOpenFns, fn) svc.onProjectOpenFns = append(svc.onProjectOpenFns, fn)
} }
func (svc *Service) OnProjectClose(fn OnProjectCloseFn) { func (svc *service) OnProjectClose(fn OnProjectCloseFn) {
svc.mu.Lock() svc.mu.Lock()
defer svc.mu.Unlock() defer svc.mu.Unlock()
svc.onProjectCloseFns = append(svc.onProjectCloseFns, fn) svc.onProjectCloseFns = append(svc.onProjectCloseFns, fn)
} }
func (svc *Service) emitProjectOpened() { func (svc *service) emitProjectOpened() {
for _, fn := range svc.onProjectOpenFns { for _, fn := range svc.onProjectOpenFns {
if err := fn(svc.activeProject); err != nil { if err := fn(svc.activeProjectID); err != nil {
log.Printf("[ERROR] Could not execute onProjectOpen function: %v", err) log.Printf("[ERROR] Could not execute onProjectOpen function: %v", err)
} }
} }
} }
func (svc *Service) emitProjectClosed(name string) { func (svc *service) emitProjectClosed(projectID ulid.ULID) {
for _, fn := range svc.onProjectCloseFns { for _, fn := range svc.onProjectCloseFns {
if err := fn(name); err != nil { if err := fn(projectID); err != nil {
log.Printf("[ERROR] Could not execute onProjectClose function: %v", err) log.Printf("[ERROR] Could not execute onProjectClose function: %v", err)
} }
} }
} }
func (svc *service) SetScopeRules(ctx context.Context, rules []scope.Rule) error {
project, err := svc.ActiveProject(ctx)
if err != nil {
return err
}
project.Settings.ScopeRules = rules
err = svc.repo.UpsertProject(ctx, project)
if err != nil {
return fmt.Errorf("proj: failed to update project: %w", err)
}
svc.scope.SetRules(rules)
return nil
}
func (svc *service) SetRequestLogFindFilter(ctx context.Context, filter reqlog.FindRequestsFilter) error {
project, err := svc.ActiveProject(ctx)
if err != nil {
return err
}
filter.ProjectID = project.ID
project.Settings.ReqLogOnlyFindInScope = filter.OnlyInScope
project.Settings.SearchExpr = filter.SearchExpr
err = svc.repo.UpsertProject(ctx, project)
if err != nil {
return fmt.Errorf("proj: failed to update project: %w", err)
}
svc.reqLogSvc.FindReqsFilter = filter
return nil
}
func (svc *service) IsProjectActive(projectID ulid.ULID) bool {
return projectID.Compare(svc.activeProjectID) == 0
}

View File

@ -2,13 +2,14 @@ package proj
import ( import (
"context" "context"
"github.com/oklog/ulid"
) )
type Repository interface { type Repository interface {
UpsertSettings(ctx context.Context, module string, settings interface{}) error FindProjectByID(ctx context.Context, id ulid.ULID) (Project, error)
FindSettingsByModule(ctx context.Context, module string, settings interface{}) error UpsertProject(ctx context.Context, project Project) error
OpenProject(name string) error DeleteProject(ctx context.Context, id ulid.ULID) error
DeleteProject(name string) error Projects(ctx context.Context) ([]Project, error)
Projects() ([]Project, error)
Close() error Close() error
} }

View File

@ -25,7 +25,7 @@ import (
var MaxSerialNumber = big.NewInt(0).SetBytes(bytes.Repeat([]byte{255}, 20)) var MaxSerialNumber = big.NewInt(0).SetBytes(bytes.Repeat([]byte{255}, 20))
// CertConfig is a set of configuration values that are used to build TLS configs // CertConfig is a set of configuration values that are used to build TLS configs
// capable of MITM // capable of MITM.
type CertConfig struct { type CertConfig struct {
ca *x509.Certificate ca *x509.Certificate
caPriv crypto.PrivateKey caPriv crypto.PrivateKey
@ -40,6 +40,7 @@ func NewCertConfig(ca *x509.Certificate, caPrivKey crypto.PrivateKey) (*CertConf
if err != nil { if err != nil {
return nil, err return nil, err
} }
pub := priv.Public() pub := priv.Public()
// Subject Key Identifier support for end entity certificate. // Subject Key Identifier support for end entity certificate.
@ -48,6 +49,7 @@ func NewCertConfig(ca *x509.Certificate, caPrivKey crypto.PrivateKey) (*CertConf
if err != nil { if err != nil {
return nil, err return nil, err
} }
h := sha1.New() h := sha1.New()
h.Write(pkixPubKey) h.Write(pkixPubKey)
keyID := h.Sum(nil) keyID := h.Sum(nil)
@ -67,58 +69,69 @@ func LoadOrCreateCA(caKeyFile, caCertFile string) (*x509.Certificate, *rsa.Priva
if err == nil { if err == nil {
caCert, err := x509.ParseCertificate(tlsCA.Certificate[0]) caCert, err := x509.ParseCertificate(tlsCA.Certificate[0])
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("proxy: could not parse CA: %v", err) return nil, nil, fmt.Errorf("proxy: could not parse CA: %w", err)
} }
caKey, ok := tlsCA.PrivateKey.(*rsa.PrivateKey) caKey, ok := tlsCA.PrivateKey.(*rsa.PrivateKey)
if !ok { if !ok {
return nil, nil, errors.New("proxy: private key is not RSA") return nil, nil, errors.New("proxy: private key is not RSA")
} }
return caCert, caKey, nil return caCert, caKey, nil
} }
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
return nil, nil, fmt.Errorf("proxy: could not load CA key pair: %v", err) return nil, nil, fmt.Errorf("proxy: could not load CA key pair: %w", err)
} }
// Create directories for files if they don't exist yet. // Create directories for files if they don't exist yet.
keyDir, _ := filepath.Split(caKeyFile) keyDir, _ := filepath.Split(caKeyFile)
if keyDir != "" { if keyDir != "" {
if _, err := os.Stat(keyDir); os.IsNotExist(err) { if _, err := os.Stat(keyDir); os.IsNotExist(err) {
os.MkdirAll(keyDir, 0755) if err := os.MkdirAll(keyDir, 0755); err != nil {
return nil, nil, fmt.Errorf("proxy: could not create directory for CA key: %w", err)
}
} }
} }
keyDir, _ = filepath.Split(caCertFile) keyDir, _ = filepath.Split(caCertFile)
if keyDir != "" { if keyDir != "" {
if _, err := os.Stat("keyDir"); os.IsNotExist(err) { if _, err := os.Stat("keyDir"); os.IsNotExist(err) {
os.MkdirAll(keyDir, 0755) if err := os.MkdirAll(keyDir, 0755); err != nil {
return nil, nil, fmt.Errorf("proxy: could not create directory for CA cert: %w", err)
}
} }
} }
// Create new CA keypair. // Create new CA keypair.
caCert, caKey, err := NewCA("Hetty", "Hetty CA", time.Duration(365*24*time.Hour)) caCert, caKey, err := NewCA("Hetty", "Hetty CA", 365*24*time.Hour)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("proxy: could not generate new CA keypair: %v", err) return nil, nil, fmt.Errorf("proxy: could not generate new CA keypair: %w", err)
} }
// Open CA certificate and key files for writing. // Open CA certificate and key files for writing.
certOut, err := os.Create(caCertFile) certOut, err := os.Create(caCertFile)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("proxy: could not open cert file for writing: %v", err) return nil, nil, fmt.Errorf("proxy: could not open cert file for writing: %w", err)
} }
keyOut, err := os.OpenFile(caKeyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) keyOut, err := os.OpenFile(caKeyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("proxy: could not open key file for writing: %v", err) return nil, nil, fmt.Errorf("proxy: could not open key file for writing: %w", err)
} }
// Write PEM blocks to CA certificate and key files. // Write PEM blocks to CA certificate and key files.
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}); err != nil { if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}); err != nil {
return nil, nil, fmt.Errorf("proxy: could not write CA certificate to disk: %v", err) return nil, nil, fmt.Errorf("proxy: could not write CA certificate to disk: %w", err)
} }
privBytes, err := x509.MarshalPKCS8PrivateKey(caKey) privBytes, err := x509.MarshalPKCS8PrivateKey(caKey)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("proxy: could not convert private key to DER format: %v", err) return nil, nil, fmt.Errorf("proxy: could not convert private key to DER format: %w", err)
} }
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil { if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
return nil, nil, fmt.Errorf("proxy: could not write CA key to disk: %v", err) return nil, nil, fmt.Errorf("proxy: could not write CA key to disk: %w", err)
} }
return caCert, caKey, nil return caCert, caKey, nil
@ -130,6 +143,7 @@ func NewCA(name, organization string, validity time.Duration) (*x509.Certificate
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
pub := priv.Public() pub := priv.Public()
// Subject Key Identifier support for end entity certificate. // Subject Key Identifier support for end entity certificate.
@ -138,6 +152,7 @@ func NewCA(name, organization string, validity time.Duration) (*x509.Certificate
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
h := sha1.New() h := sha1.New()
h.Write(pkixpub) h.Write(pkixpub)
keyID := h.Sum(nil) keyID := h.Sum(nil)
@ -187,8 +202,10 @@ func (c *CertConfig) TLSConfig() *tls.Config {
if clientHello.ServerName == "" { if clientHello.ServerName == "" {
return nil, errors.New("missing server name (SNI)") return nil, errors.New("missing server name (SNI)")
} }
return c.cert(clientHello.ServerName) return c.cert(clientHello.ServerName)
}, },
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"}, NextProtos: []string{"http/1.1"},
} }
} }

View File

@ -5,18 +5,17 @@ import (
"crypto" "crypto"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"errors"
"fmt" "fmt"
"log" "log"
"net" "net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"github.com/dstotijn/hetty/pkg/scope"
) )
type contextKey int type contextKey int
const ReqIDKey contextKey = 0 const ReqLogIDKey contextKey = 0
// Proxy implements http.Handler and offers MITM behaviour for modifying // Proxy implements http.Handler and offers MITM behaviour for modifying
// HTTP requests and responses. // HTTP requests and responses.
@ -27,8 +26,6 @@ type Proxy struct {
// TODO: Add mutex for modifier funcs. // TODO: Add mutex for modifier funcs.
reqModifiers []RequestModifyMiddleware reqModifiers []RequestModifyMiddleware
resModifiers []ResponseModifyMiddleware resModifiers []ResponseModifyMiddleware
scope *scope.Scope
} }
// NewProxy returns a new Proxy. // NewProxy returns a new Proxy.
@ -55,7 +52,7 @@ func NewProxy(ca *x509.Certificate, key crypto.PrivateKey) (*Proxy, error) {
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect { if r.Method == http.MethodConnect {
p.handleConnect(w, r) p.handleConnect(w)
return return
} }
@ -103,11 +100,12 @@ func (p *Proxy) modifyResponse(res *http.Response) error {
// handleConnect hijacks the incoming HTTP request and sets up an HTTP tunnel. // handleConnect hijacks the incoming HTTP request and sets up an HTTP tunnel.
// During the TLS handshake with the client, we use the proxy's CA config to // During the TLS handshake with the client, we use the proxy's CA config to
// create a certificate on-the-fly. // create a certificate on-the-fly.
func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { func (p *Proxy) handleConnect(w http.ResponseWriter) {
hj, ok := w.(http.Hijacker) hj, ok := w.(http.Hijacker)
if !ok { if !ok {
log.Printf("[ERROR] handleConnect: ResponseWriter is not a http.Hijacker (type: %T)", w) log.Printf("[ERROR] handleConnect: ResponseWriter is not a http.Hijacker (type: %T)", w)
writeError(w, r, http.StatusServiceUnavailable) writeError(w, http.StatusServiceUnavailable)
return return
} }
@ -116,7 +114,8 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
clientConn, _, err := hj.Hijack() clientConn, _, err := hj.Hijack()
if err != nil { if err != nil {
log.Printf("[ERROR] Hijacking client connection failed: %v", err) log.Printf("[ERROR] Hijacking client connection failed: %v", err)
writeError(w, r, http.StatusServiceUnavailable) writeError(w, http.StatusServiceUnavailable)
return return
} }
defer clientConn.Close() defer clientConn.Close()
@ -127,14 +126,15 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
log.Printf("[ERROR] Securing client connection failed: %v", err) log.Printf("[ERROR] Securing client connection failed: %v", err)
return return
} }
clientConnNotify := ConnNotify{clientConn, make(chan struct{})}
clientConnNotify := ConnNotify{clientConn, make(chan struct{})}
l := &OnceAcceptListener{clientConnNotify.Conn} l := &OnceAcceptListener{clientConnNotify.Conn}
err = http.Serve(l, p) err = http.Serve(l, p)
if err != nil && err != ErrAlreadyAccepted { if err != nil && !errors.Is(err, ErrAlreadyAccepted) {
log.Printf("[ERROR] Serving HTTP request failed: %v", err) log.Printf("[ERROR] Serving HTTP request failed: %v", err)
} }
<-clientConnNotify.closed <-clientConnNotify.closed
} }
@ -144,20 +144,22 @@ func (p *Proxy) clientTLSConn(conn net.Conn) (*tls.Conn, error) {
tlsConn := tls.Server(conn, tlsConfig) tlsConn := tls.Server(conn, tlsConfig)
if err := tlsConn.Handshake(); err != nil { if err := tlsConn.Handshake(); err != nil {
tlsConn.Close() tlsConn.Close()
return nil, fmt.Errorf("handshake error: %v", err) return nil, fmt.Errorf("handshake error: %w", err)
} }
return tlsConn, nil return tlsConn, nil
} }
func errorHandler(w http.ResponseWriter, r *http.Request, err error) { func errorHandler(w http.ResponseWriter, r *http.Request, err error) {
if err == context.Canceled { if errors.Is(err, context.Canceled) {
return return
} }
log.Printf("[ERROR]: Proxy error: %v", err) log.Printf("[ERROR]: Proxy error: %v", err)
w.WriteHeader(http.StatusBadGateway) w.WriteHeader(http.StatusBadGateway)
} }
func writeError(w http.ResponseWriter, r *http.Request, code int) { func writeError(w http.ResponseWriter, code int) {
http.Error(w, http.StatusText(code), code) http.Error(w, http.StatusText(code), code)
} }

View File

@ -2,22 +2,16 @@ package reqlog
import ( import (
"context" "context"
"net/http"
"time" "github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
) )
type RepositoryProvider interface {
Repository() Repository
}
type Repository interface { type Repository interface {
FindRequestLogs(ctx context.Context, filter FindRequestsFilter, scope *scope.Scope) ([]Request, error) FindRequestLogs(ctx context.Context, filter FindRequestsFilter, scope *scope.Scope) ([]RequestLog, error)
FindRequestLogByID(ctx context.Context, id int64) (Request, error) FindRequestLogByID(ctx context.Context, id ulid.ULID) (RequestLog, error)
AddRequestLog(ctx context.Context, req http.Request, body []byte, timestamp time.Time) (*Request, error) StoreRequestLog(ctx context.Context, reqLog RequestLog) error
AddResponseLog(ctx context.Context, reqID int64, res http.Response, body []byte, timestamp time.Time) (*Response, error) StoreResponseLog(ctx context.Context, reqLogID ulid.ULID, resLog ResponseLog) error
ClearRequestLogs(ctx context.Context) error ClearRequestLogs(ctx context.Context, projectID ulid.ULID) error
UpsertSettings(ctx context.Context, module string, settings interface{}) error
FindSettingsByModule(ctx context.Context, module string, settings interface{}) error
} }

View File

@ -0,0 +1,291 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package reqlog_test
import (
"context"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/oklog/ulid"
"sync"
)
// Ensure, that RepoMock does implement reqlog.Repository.
// If this is not the case, regenerate this file with moq.
var _ reqlog.Repository = &RepoMock{}
// RepoMock is a mock implementation of reqlog.Repository.
//
// func TestSomethingThatUsesRepository(t *testing.T) {
//
// // make and configure a mocked reqlog.Repository
// mockedRepository := &RepoMock{
// ClearRequestLogsFunc: func(ctx context.Context, projectID ulid.ULID) error {
// panic("mock out the ClearRequestLogs method")
// },
// FindRequestLogByIDFunc: func(ctx context.Context, id ulid.ULID) (reqlog.RequestLog, error) {
// panic("mock out the FindRequestLogByID method")
// },
// FindRequestLogsFunc: func(ctx context.Context, filter reqlog.FindRequestsFilter, scopeMoqParam *scope.Scope) ([]reqlog.RequestLog, error) {
// panic("mock out the FindRequestLogs method")
// },
// StoreRequestLogFunc: func(ctx context.Context, reqLog reqlog.RequestLog) error {
// panic("mock out the StoreRequestLog method")
// },
// StoreResponseLogFunc: func(ctx context.Context, reqLogID ulid.ULID, resLog reqlog.ResponseLog) error {
// panic("mock out the StoreResponseLog method")
// },
// }
//
// // use mockedRepository in code that requires reqlog.Repository
// // and then make assertions.
//
// }
type RepoMock struct {
// ClearRequestLogsFunc mocks the ClearRequestLogs method.
ClearRequestLogsFunc func(ctx context.Context, projectID ulid.ULID) error
// FindRequestLogByIDFunc mocks the FindRequestLogByID method.
FindRequestLogByIDFunc func(ctx context.Context, id ulid.ULID) (reqlog.RequestLog, error)
// FindRequestLogsFunc mocks the FindRequestLogs method.
FindRequestLogsFunc func(ctx context.Context, filter reqlog.FindRequestsFilter, scopeMoqParam *scope.Scope) ([]reqlog.RequestLog, error)
// StoreRequestLogFunc mocks the StoreRequestLog method.
StoreRequestLogFunc func(ctx context.Context, reqLog reqlog.RequestLog) error
// StoreResponseLogFunc mocks the StoreResponseLog method.
StoreResponseLogFunc func(ctx context.Context, reqLogID ulid.ULID, resLog reqlog.ResponseLog) error
// calls tracks calls to the methods.
calls struct {
// ClearRequestLogs holds details about calls to the ClearRequestLogs method.
ClearRequestLogs []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// ProjectID is the projectID argument value.
ProjectID ulid.ULID
}
// FindRequestLogByID holds details about calls to the FindRequestLogByID method.
FindRequestLogByID []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// ID is the id argument value.
ID ulid.ULID
}
// FindRequestLogs holds details about calls to the FindRequestLogs method.
FindRequestLogs []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Filter is the filter argument value.
Filter reqlog.FindRequestsFilter
// ScopeMoqParam is the scopeMoqParam argument value.
ScopeMoqParam *scope.Scope
}
// StoreRequestLog holds details about calls to the StoreRequestLog method.
StoreRequestLog []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// ReqLog is the reqLog argument value.
ReqLog reqlog.RequestLog
}
// StoreResponseLog holds details about calls to the StoreResponseLog method.
StoreResponseLog []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// ReqLogID is the reqLogID argument value.
ReqLogID ulid.ULID
// ResLog is the resLog argument value.
ResLog reqlog.ResponseLog
}
}
lockClearRequestLogs sync.RWMutex
lockFindRequestLogByID sync.RWMutex
lockFindRequestLogs sync.RWMutex
lockStoreRequestLog sync.RWMutex
lockStoreResponseLog sync.RWMutex
}
// ClearRequestLogs calls ClearRequestLogsFunc.
func (mock *RepoMock) ClearRequestLogs(ctx context.Context, projectID ulid.ULID) error {
if mock.ClearRequestLogsFunc == nil {
panic("RepoMock.ClearRequestLogsFunc: method is nil but Repository.ClearRequestLogs was just called")
}
callInfo := struct {
Ctx context.Context
ProjectID ulid.ULID
}{
Ctx: ctx,
ProjectID: projectID,
}
mock.lockClearRequestLogs.Lock()
mock.calls.ClearRequestLogs = append(mock.calls.ClearRequestLogs, callInfo)
mock.lockClearRequestLogs.Unlock()
return mock.ClearRequestLogsFunc(ctx, projectID)
}
// ClearRequestLogsCalls gets all the calls that were made to ClearRequestLogs.
// Check the length with:
// len(mockedRepository.ClearRequestLogsCalls())
func (mock *RepoMock) ClearRequestLogsCalls() []struct {
Ctx context.Context
ProjectID ulid.ULID
} {
var calls []struct {
Ctx context.Context
ProjectID ulid.ULID
}
mock.lockClearRequestLogs.RLock()
calls = mock.calls.ClearRequestLogs
mock.lockClearRequestLogs.RUnlock()
return calls
}
// FindRequestLogByID calls FindRequestLogByIDFunc.
func (mock *RepoMock) FindRequestLogByID(ctx context.Context, id ulid.ULID) (reqlog.RequestLog, error) {
if mock.FindRequestLogByIDFunc == nil {
panic("RepoMock.FindRequestLogByIDFunc: method is nil but Repository.FindRequestLogByID was just called")
}
callInfo := struct {
Ctx context.Context
ID ulid.ULID
}{
Ctx: ctx,
ID: id,
}
mock.lockFindRequestLogByID.Lock()
mock.calls.FindRequestLogByID = append(mock.calls.FindRequestLogByID, callInfo)
mock.lockFindRequestLogByID.Unlock()
return mock.FindRequestLogByIDFunc(ctx, id)
}
// FindRequestLogByIDCalls gets all the calls that were made to FindRequestLogByID.
// Check the length with:
// len(mockedRepository.FindRequestLogByIDCalls())
func (mock *RepoMock) FindRequestLogByIDCalls() []struct {
Ctx context.Context
ID ulid.ULID
} {
var calls []struct {
Ctx context.Context
ID ulid.ULID
}
mock.lockFindRequestLogByID.RLock()
calls = mock.calls.FindRequestLogByID
mock.lockFindRequestLogByID.RUnlock()
return calls
}
// FindRequestLogs calls FindRequestLogsFunc.
func (mock *RepoMock) FindRequestLogs(ctx context.Context, filter reqlog.FindRequestsFilter, scopeMoqParam *scope.Scope) ([]reqlog.RequestLog, error) {
if mock.FindRequestLogsFunc == nil {
panic("RepoMock.FindRequestLogsFunc: method is nil but Repository.FindRequestLogs was just called")
}
callInfo := struct {
Ctx context.Context
Filter reqlog.FindRequestsFilter
ScopeMoqParam *scope.Scope
}{
Ctx: ctx,
Filter: filter,
ScopeMoqParam: scopeMoqParam,
}
mock.lockFindRequestLogs.Lock()
mock.calls.FindRequestLogs = append(mock.calls.FindRequestLogs, callInfo)
mock.lockFindRequestLogs.Unlock()
return mock.FindRequestLogsFunc(ctx, filter, scopeMoqParam)
}
// FindRequestLogsCalls gets all the calls that were made to FindRequestLogs.
// Check the length with:
// len(mockedRepository.FindRequestLogsCalls())
func (mock *RepoMock) FindRequestLogsCalls() []struct {
Ctx context.Context
Filter reqlog.FindRequestsFilter
ScopeMoqParam *scope.Scope
} {
var calls []struct {
Ctx context.Context
Filter reqlog.FindRequestsFilter
ScopeMoqParam *scope.Scope
}
mock.lockFindRequestLogs.RLock()
calls = mock.calls.FindRequestLogs
mock.lockFindRequestLogs.RUnlock()
return calls
}
// StoreRequestLog calls StoreRequestLogFunc.
func (mock *RepoMock) StoreRequestLog(ctx context.Context, reqLog reqlog.RequestLog) error {
if mock.StoreRequestLogFunc == nil {
panic("RepoMock.StoreRequestLogFunc: method is nil but Repository.StoreRequestLog was just called")
}
callInfo := struct {
Ctx context.Context
ReqLog reqlog.RequestLog
}{
Ctx: ctx,
ReqLog: reqLog,
}
mock.lockStoreRequestLog.Lock()
mock.calls.StoreRequestLog = append(mock.calls.StoreRequestLog, callInfo)
mock.lockStoreRequestLog.Unlock()
return mock.StoreRequestLogFunc(ctx, reqLog)
}
// StoreRequestLogCalls gets all the calls that were made to StoreRequestLog.
// Check the length with:
// len(mockedRepository.StoreRequestLogCalls())
func (mock *RepoMock) StoreRequestLogCalls() []struct {
Ctx context.Context
ReqLog reqlog.RequestLog
} {
var calls []struct {
Ctx context.Context
ReqLog reqlog.RequestLog
}
mock.lockStoreRequestLog.RLock()
calls = mock.calls.StoreRequestLog
mock.lockStoreRequestLog.RUnlock()
return calls
}
// StoreResponseLog calls StoreResponseLogFunc.
func (mock *RepoMock) StoreResponseLog(ctx context.Context, reqLogID ulid.ULID, resLog reqlog.ResponseLog) error {
if mock.StoreResponseLogFunc == nil {
panic("RepoMock.StoreResponseLogFunc: method is nil but Repository.StoreResponseLog was just called")
}
callInfo := struct {
Ctx context.Context
ReqLogID ulid.ULID
ResLog reqlog.ResponseLog
}{
Ctx: ctx,
ReqLogID: reqLogID,
ResLog: resLog,
}
mock.lockStoreResponseLog.Lock()
mock.calls.StoreResponseLog = append(mock.calls.StoreResponseLog, callInfo)
mock.lockStoreResponseLog.Unlock()
return mock.StoreResponseLogFunc(ctx, reqLogID, resLog)
}
// StoreResponseLogCalls gets all the calls that were made to StoreResponseLog.
// Check the length with:
// len(mockedRepository.StoreResponseLogCalls())
func (mock *RepoMock) StoreResponseLogCalls() []struct {
Ctx context.Context
ReqLogID ulid.ULID
ResLog reqlog.ResponseLog
} {
var calls []struct {
Ctx context.Context
ReqLogID ulid.ULID
ResLog reqlog.ResponseLog
}
mock.lockStoreResponseLog.RLock()
calls = mock.calls.StoreResponseLog
mock.lockStoreResponseLog.RUnlock()
return calls
}

View File

@ -4,15 +4,18 @@ import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"io/ioutil" "io/ioutil"
"log" "log"
"math/rand"
"net/http" "net/http"
"net/url"
"time" "time"
"github.com/dstotijn/hetty/pkg/proj" "github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/proxy" "github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search" "github.com/dstotijn/hetty/pkg/search"
@ -22,166 +25,169 @@ type contextKey int
const LogBypassedKey contextKey = 0 const LogBypassedKey contextKey = 0
const moduleName = "reqlog"
var ( var (
ErrRequestNotFound = errors.New("reqlog: request not found") ErrRequestNotFound = errors.New("reqlog: request not found")
ErrProjectIDMustBeSet = errors.New("reqlog: project ID must be set")
) )
type Request struct { //nolint:gosec
ID int64 var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
Request http.Request
Body []byte type RequestLog struct {
Timestamp time.Time ID ulid.ULID
Response *Response ProjectID ulid.ULID
URL *url.URL
Method string
Proto string
Header http.Header
Body []byte
Response *ResponseLog
} }
type Response struct { type ResponseLog struct {
ID int64 Proto string
RequestID int64 StatusCode int
Response http.Response Status string
Body []byte Header http.Header
Timestamp time.Time Body []byte
} }
type Service struct { type Service struct {
BypassOutOfScopeRequests bool BypassOutOfScopeRequests bool
FindReqsFilter FindRequestsFilter FindReqsFilter FindRequestsFilter
ActiveProjectID ulid.ULID
scope *scope.Scope scope *scope.Scope
repo Repository repo Repository
} }
type FindRequestsFilter struct { type FindRequestsFilter struct {
OnlyInScope bool ProjectID ulid.ULID
SearchExpr search.Expression `json:"-"` OnlyInScope bool
RawSearchExpr string SearchExpr search.Expression
} }
type Config struct { type Config struct {
Scope *scope.Scope Scope *scope.Scope
Repository Repository Repository Repository
ProjectService *proj.Service
BypassOutOfScopeRequests bool
} }
func NewService(cfg Config) *Service { func NewService(cfg Config) *Service {
svc := &Service{ return &Service{
scope: cfg.Scope, repo: cfg.Repository,
repo: cfg.Repository, scope: cfg.Scope,
BypassOutOfScopeRequests: cfg.BypassOutOfScopeRequests,
} }
cfg.ProjectService.OnProjectOpen(func(_ string) error {
err := svc.loadSettings()
if err == proj.ErrNoSettings {
return nil
}
if err != nil {
return fmt.Errorf("reqlog: could not load settings: %v", err)
}
return nil
})
cfg.ProjectService.OnProjectClose(func(_ string) error {
svc.unloadSettings()
return nil
})
return svc
} }
func (svc *Service) FindRequests(ctx context.Context) ([]Request, error) { func (svc *Service) FindRequests(ctx context.Context) ([]RequestLog, error) {
return svc.repo.FindRequestLogs(ctx, svc.FindReqsFilter, svc.scope) return svc.repo.FindRequestLogs(ctx, svc.FindReqsFilter, svc.scope)
} }
func (svc *Service) FindRequestLogByID(ctx context.Context, id int64) (Request, error) { func (svc *Service) FindRequestLogByID(ctx context.Context, id ulid.ULID) (RequestLog, error) {
return svc.repo.FindRequestLogByID(ctx, id) return svc.repo.FindRequestLogByID(ctx, id)
} }
func (svc *Service) SetRequestLogFilter(ctx context.Context, filter FindRequestsFilter) error { func (svc *Service) ClearRequests(ctx context.Context, projectID ulid.ULID) error {
svc.FindReqsFilter = filter return svc.repo.ClearRequestLogs(ctx, projectID)
return svc.repo.UpsertSettings(ctx, "reqlog", svc)
} }
func (svc *Service) ClearRequests(ctx context.Context) error { func (svc *Service) storeResponse(ctx context.Context, reqLogID ulid.ULID, res *http.Response) error {
return svc.repo.ClearRequestLogs(ctx)
}
func (svc *Service) addRequest(
ctx context.Context,
req http.Request,
body []byte,
timestamp time.Time,
) (*Request, error) {
return svc.repo.AddRequestLog(ctx, req, body, timestamp)
}
func (svc *Service) addResponse(
ctx context.Context,
reqID int64,
res http.Response,
body []byte,
timestamp time.Time,
) (*Response, error) {
if res.Header.Get("Content-Encoding") == "gzip" { if res.Header.Get("Content-Encoding") == "gzip" {
gzipReader, err := gzip.NewReader(bytes.NewBuffer(body)) gzipReader, err := gzip.NewReader(res.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("reqlog: could not create gzip reader: %v", err) return fmt.Errorf("could not create gzip reader: %w", err)
} }
defer gzipReader.Close() defer gzipReader.Close()
body, err = ioutil.ReadAll(gzipReader)
if err != nil { buf := &bytes.Buffer{}
return nil, fmt.Errorf("reqlog: could not read gzipped response body: %v", err)
if _, err := io.Copy(buf, gzipReader); err != nil {
return fmt.Errorf("could not read gzipped response body: %w", err)
} }
res.Body = io.NopCloser(buf)
} }
return svc.repo.AddResponseLog(ctx, reqID, res, body, timestamp) body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("could not read body: %w", err)
}
resLog := ResponseLog{
Proto: res.Proto,
StatusCode: res.StatusCode,
Status: res.Status,
Header: res.Header,
Body: body,
}
return svc.repo.StoreResponseLog(ctx, reqLogID, resLog)
} }
func (svc *Service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestModifyFunc { func (svc *Service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestModifyFunc {
return func(req *http.Request) { return func(req *http.Request) {
now := time.Now()
next(req) next(req)
clone := req.Clone(req.Context()) clone := req.Clone(req.Context())
var body []byte var body []byte
if req.Body != nil { if req.Body != nil {
// TODO: Use io.LimitReader. // TODO: Use io.LimitReader.
var err error var err error
body, err = ioutil.ReadAll(req.Body) body, err = ioutil.ReadAll(req.Body)
if err != nil { if err != nil {
log.Printf("[ERROR] Could not read request body for logging: %v", err) log.Printf("[ERROR] Could not read request body for logging: %v", err)
return return
} }
req.Body = ioutil.NopCloser(bytes.NewBuffer(body)) req.Body = ioutil.NopCloser(bytes.NewBuffer(body))
clone.Body = ioutil.NopCloser(bytes.NewBuffer(body))
}
// Bypass logging if no project is active.
if svc.ActiveProjectID.Compare(ulid.ULID{}) == 0 {
ctx := context.WithValue(req.Context(), LogBypassedKey, true)
*req = *req.WithContext(ctx)
return
} }
// Bypass logging if this setting is enabled and the incoming request // Bypass logging if this setting is enabled and the incoming request
// doens't match any rules of the scope. // doesn't match any scope rules.
if svc.BypassOutOfScopeRequests && !svc.scope.Match(clone, body) { if svc.BypassOutOfScopeRequests && !svc.scope.Match(clone, body) {
ctx := context.WithValue(req.Context(), LogBypassedKey, true) ctx := context.WithValue(req.Context(), LogBypassedKey, true)
*req = *req.WithContext(ctx) *req = *req.WithContext(ctx)
return return
} }
reqLog, err := svc.addRequest(req.Context(), *clone, body, now) reqLog := RequestLog{
if err == proj.ErrNoProject { ID: ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy),
ctx := context.WithValue(req.Context(), LogBypassedKey, true) ProjectID: svc.ActiveProjectID,
*req = *req.WithContext(ctx) Method: clone.Method,
return URL: clone.URL,
Proto: clone.Proto,
Header: clone.Header,
Body: body,
} }
err := svc.repo.StoreRequestLog(req.Context(), reqLog)
if err != nil { if err != nil {
log.Printf("[ERROR] Could not store request log: %v", err) log.Printf("[ERROR] Could not store request log: %v", err)
return return
} }
ctx := context.WithValue(req.Context(), proxy.ReqIDKey, reqLog.ID)
ctx := context.WithValue(req.Context(), proxy.ReqLogIDKey, reqLog.ID)
*req = *req.WithContext(ctx) *req = *req.WithContext(ctx)
} }
} }
func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.ResponseModifyFunc { func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.ResponseModifyFunc {
return func(res *http.Response) error { return func(res *http.Response) error {
now := time.Now()
if err := next(res); err != nil { if err := next(res); err != nil {
return err return err
} }
@ -190,8 +196,8 @@ func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon
return nil return nil
} }
reqID, _ := res.Request.Context().Value(proxy.ReqIDKey).(int64) reqLogID, ok := res.Request.Context().Value(proxy.ReqLogIDKey).(ulid.ULID)
if reqID == 0 { if !ok {
return errors.New("reqlog: request is missing ID") return errors.New("reqlog: request is missing ID")
} }
@ -200,12 +206,14 @@ func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon
// TODO: Use io.LimitReader. // TODO: Use io.LimitReader.
body, err := ioutil.ReadAll(res.Body) body, err := ioutil.ReadAll(res.Body)
if err != nil { if err != nil {
return fmt.Errorf("reqlog: could not read response body: %v", err) return fmt.Errorf("reqlog: could not read response body: %w", err)
} }
res.Body = ioutil.NopCloser(bytes.NewBuffer(body)) res.Body = ioutil.NopCloser(bytes.NewBuffer(body))
clone.Body = ioutil.NopCloser(bytes.NewBuffer(body))
go func() { go func() {
if _, err := svc.addResponse(context.Background(), reqID, clone, body, now); err != nil { if err := svc.storeResponse(context.Background(), reqLogID, &clone); err != nil {
log.Printf("[ERROR] Could not store response log: %v", err) log.Printf("[ERROR] Could not store response log: %v", err)
} }
}() }()
@ -213,40 +221,3 @@ func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon
return nil return nil
} }
} }
// 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)
}
func (svc *Service) unloadSettings() {
svc.BypassOutOfScopeRequests = false
svc.FindReqsFilter = FindRequestsFilter{}
}

124
pkg/reqlog/reqlog_test.go Normal file
View File

@ -0,0 +1,124 @@
package reqlog_test
//go:generate go run github.com/matryer/moq -out repo_mock_test.go -pkg reqlog_test . Repository:RepoMock
import (
"context"
"io"
"math/rand"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
)
//nolint:gosec
var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
//nolint:paralleltest
func TestRequestModifier(t *testing.T) {
repoMock := &RepoMock{
StoreRequestLogFunc: func(_ context.Context, _ reqlog.RequestLog) error {
return nil
},
}
svc := reqlog.NewService(reqlog.Config{
Repository: repoMock,
Scope: &scope.Scope{},
})
svc.ActiveProjectID = ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
next := func(req *http.Request) {
req.Body = io.NopCloser(strings.NewReader("modified body"))
}
reqModFn := svc.RequestModifier(next)
req := httptest.NewRequest("GET", "https://example.com/", strings.NewReader("bar"))
reqModFn(req)
t.Run("request log was stored in repository", func(t *testing.T) {
gotCount := len(repoMock.StoreRequestLogCalls())
if expCount := 1; expCount != gotCount {
t.Fatalf("incorrect `proj.Service.AddRequestLog` calls (expected: %v, got: %v)", expCount, gotCount)
}
exp := reqlog.RequestLog{
ID: ulid.ULID{}, // Empty value
ProjectID: svc.ActiveProjectID,
Method: req.Method,
URL: req.URL,
Proto: req.Proto,
Header: req.Header,
Body: []byte("modified body"),
}
got := repoMock.StoreRequestLogCalls()[0].ReqLog
got.ID = ulid.ULID{} // Override to empty value so we can compare against expected value.
if diff := cmp.Diff(exp, got); diff != "" {
t.Fatalf("request log not equal (-exp, +got):\n%v", diff)
}
})
}
//nolint:paralleltest
func TestResponseModifier(t *testing.T) {
repoMock := &RepoMock{
StoreResponseLogFunc: func(_ context.Context, _ ulid.ULID, _ reqlog.ResponseLog) error {
return nil
},
}
svc := reqlog.NewService(reqlog.Config{
Repository: repoMock,
})
svc.ActiveProjectID = ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
next := func(res *http.Response) error {
res.Body = io.NopCloser(strings.NewReader("modified body"))
return nil
}
resModFn := svc.ResponseModifier(next)
req := httptest.NewRequest("GET", "https://example.com/", strings.NewReader("bar"))
reqLogID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
req = req.WithContext(context.WithValue(req.Context(), proxy.ReqLogIDKey, reqLogID))
res := &http.Response{
Request: req,
Body: io.NopCloser(strings.NewReader("bar")),
}
if err := resModFn(res); err != nil {
t.Fatalf("unexpected error (expected: nil, got: %v)", err)
}
t.Run("request log was stored in repository", func(t *testing.T) {
// Dirty (but simple) wait for other goroutine to finish calling repository.
time.Sleep(10 * time.Millisecond)
got := len(repoMock.StoreResponseLogCalls())
if exp := 1; exp != got {
t.Fatalf("incorrect `proj.Service.AddResponseLog` calls (expected: %v, got: %v)", exp, got)
}
t.Run("ran next modifier first, before calling repository", func(t *testing.T) {
got := repoMock.StoreResponseLogCalls()[0].ResLog.Body
if exp := "modified body"; exp != string(got) {
t.Fatalf("incorrect `ResponseLog.Body` value (expected: %v, got: %v)", exp, string(got))
}
})
t.Run("called repository with request log id", func(t *testing.T) {
got := repoMock.StoreResponseLogCalls()[0].ReqLogID
if exp := reqLogID; exp.Compare(got) != 0 {
t.Fatalf("incorrect `reqLogID` argument for `Repository.AddResponseLogCalls` (expected: %v, got: %v)", exp.String(), got.String())
}
})
})
}

235
pkg/reqlog/search.go Normal file
View File

@ -0,0 +1,235 @@
package reqlog
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
)
var reqLogSearchKeyFns = map[string]func(rl RequestLog) string{
"req.id": func(rl RequestLog) string { return rl.ID.String() },
"req.proto": func(rl RequestLog) string { return rl.Proto },
"req.url": func(rl RequestLog) string {
if rl.URL == nil {
return ""
}
return rl.URL.String()
},
"req.method": func(rl RequestLog) string { return rl.Method },
"req.body": func(rl RequestLog) string { return string(rl.Body) },
"req.timestamp": func(rl RequestLog) string { return ulid.Time(rl.ID.Time()).String() },
}
var resLogSearchKeyFns = map[string]func(rl ResponseLog) string{
"res.proto": func(rl ResponseLog) string { return rl.Proto },
"res.statusCode": func(rl ResponseLog) string { return strconv.Itoa(rl.StatusCode) },
"res.statusReason": func(rl ResponseLog) string { return rl.Status },
"res.body": func(rl ResponseLog) string { return string(rl.Body) },
}
// 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) {
switch e := expr.(type) {
case search.PrefixExpression:
return reqLog.matchPrefixExpr(e)
case search.InfixExpression:
return reqLog.matchInfixExpr(e)
case search.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) {
switch expr.Operator {
case search.TokOpNot:
match, err := reqLog.Matches(expr.Right)
if err != nil {
return false, err
}
return !match, nil
default:
return false, errors.New("operator is not supported")
}
}
func (reqLog RequestLog) matchInfixExpr(expr search.InfixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpAnd:
left, err := reqLog.Matches(expr.Left)
if err != nil {
return false, err
}
right, err := reqLog.Matches(expr.Right)
if err != nil {
return false, err
}
return left && right, nil
case search.TokOpOr:
left, err := reqLog.Matches(expr.Left)
if err != nil {
return false, err
}
right, err := reqLog.Matches(expr.Right)
if err != nil {
return false, err
}
return left || right, nil
}
left, ok := expr.Left.(search.StringLiteral)
if !ok {
return false, errors.New("left operand must be a string literal")
}
leftVal := reqLog.getMappedStringLiteral(left.Value)
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
right, ok := expr.Right.(*regexp.Regexp)
if !ok {
return false, errors.New("right operand must be a regular expression")
}
switch expr.Operator {
case search.TokOpRe:
return right.MatchString(leftVal), nil
case search.TokOpNotRe:
return !right.MatchString(leftVal), nil
}
}
right, ok := expr.Right.(search.StringLiteral)
if !ok {
return false, errors.New("right operand must be a string literal")
}
rightVal := reqLog.getMappedStringLiteral(right.Value)
switch expr.Operator {
case search.TokOpEq:
return leftVal == rightVal, nil
case search.TokOpNotEq:
return leftVal != rightVal, nil
case search.TokOpGt:
// TODO(?) attempt to parse as int.
return leftVal > rightVal, nil
case search.TokOpLt:
// TODO(?) attempt to parse as int.
return leftVal < rightVal, nil
case search.TokOpGtEq:
// TODO(?) attempt to parse as int.
return leftVal >= rightVal, nil
case search.TokOpLtEq:
// TODO(?) attempt to parse as int.
return leftVal <= rightVal, nil
default:
return false, errors.New("unsupported operator")
}
}
func (reqLog RequestLog) getMappedStringLiteral(s string) string {
switch {
case strings.HasPrefix(s, "req."):
fn, ok := reqLogSearchKeyFns[s]
if ok {
return fn(reqLog)
}
case strings.HasPrefix(s, "res."):
if reqLog.Response == nil {
return ""
}
fn, ok := resLogSearchKeyFns[s]
if ok {
return fn(*reqLog.Response)
}
}
return s
}
func (reqLog RequestLog) matchStringLiteral(strLiteral search.StringLiteral) (bool, error) {
for _, fn := range reqLogSearchKeyFns {
if strings.Contains(
strings.ToLower(fn(reqLog)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
if reqLog.Response != nil {
for _, fn := range resLogSearchKeyFns {
if strings.Contains(
strings.ToLower(fn(*reqLog.Response)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
return false, nil
}
func (reqLog RequestLog) MatchScope(s *scope.Scope) bool {
for _, rule := range s.Rules() {
if rule.URL != nil && reqLog.URL != nil {
if matches := rule.URL.MatchString(reqLog.URL.String()); matches {
return true
}
}
for key, values := range reqLog.Header {
var keyMatches, valueMatches bool
if rule.Header.Key != nil {
if matches := rule.Header.Key.MatchString(key); matches {
keyMatches = true
}
}
if rule.Header.Value != nil {
for _, value := range values {
if matches := rule.Header.Value.MatchString(value); matches {
valueMatches = true
break
}
}
}
// When only key or value is set, match on whatever is set.
// When both are set, both must match.
switch {
case rule.Header.Key != nil && rule.Header.Value == nil && keyMatches:
return true
case rule.Header.Key == nil && rule.Header.Value != nil && valueMatches:
return true
case rule.Header.Key != nil && rule.Header.Value != nil && keyMatches && valueMatches:
return true
}
}
if rule.Body != nil {
if matches := rule.Body.Match(reqLog.Body); matches {
return true
}
}
}
return false
}

203
pkg/reqlog/search_test.go Normal file
View File

@ -0,0 +1,203 @@
package reqlog_test
import (
"testing"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/search"
)
func TestRequestLogMatch(t *testing.T) {
t.Parallel()
tests := []struct {
name string
query string
requestLog reqlog.RequestLog
expectedMatch bool
expectedError error
}{
{
name: "infix expression, equal operator, match",
query: "req.body = foo",
requestLog: reqlog.RequestLog{
Body: []byte("foo"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "infix expression, not equal operator, match",
query: "req.body != bar",
requestLog: reqlog.RequestLog{
Body: []byte("foo"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "infix expression, greater than operator, match",
query: "req.body > a",
requestLog: reqlog.RequestLog{
Body: []byte("b"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "infix expression, less than operator, match",
query: "req.body < b",
requestLog: reqlog.RequestLog{
Body: []byte("a"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "infix expression, greater than or equal operator, match greater than",
query: "req.body >= a",
requestLog: reqlog.RequestLog{
Body: []byte("b"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "infix expression, greater than or equal operator, match equal",
query: "req.body >= a",
requestLog: reqlog.RequestLog{
Body: []byte("a"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "infix expression, less than or equal operator, match less than",
query: "req.body <= b",
requestLog: reqlog.RequestLog{
Body: []byte("a"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "infix expression, less than or equal operator, match equal",
query: "req.body <= b",
requestLog: reqlog.RequestLog{
Body: []byte("b"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "infix expression, regular expression operator, match",
query: `req.body =~ "^foo(.*)$"`,
requestLog: reqlog.RequestLog{
Body: []byte("foobar"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "infix expression, negate regular expression operator, match",
query: `req.body !~ "^foo(.*)$"`,
requestLog: reqlog.RequestLog{
Body: []byte("xoobar"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "infix expression, and operator, match",
query: "req.body = bar AND res.body = yolo",
requestLog: reqlog.RequestLog{
Body: []byte("bar"),
Response: &reqlog.ResponseLog{
Body: []byte("yolo"),
},
},
expectedMatch: true,
expectedError: nil,
},
{
name: "infix expression, or operator, match",
query: "req.body = bar OR res.body = yolo",
requestLog: reqlog.RequestLog{
Body: []byte("foo"),
Response: &reqlog.ResponseLog{
Body: []byte("yolo"),
},
},
expectedMatch: true,
expectedError: nil,
},
{
name: "prefix expression, not operator, match",
query: "NOT (req.body = bar)",
requestLog: reqlog.RequestLog{
Body: []byte("foo"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "string literal expression, match in request log",
query: "foo",
requestLog: reqlog.RequestLog{
Body: []byte("foo"),
},
expectedMatch: true,
expectedError: nil,
},
{
name: "string literal expression, no match",
query: "foo",
requestLog: reqlog.RequestLog{
Body: []byte("bar"),
},
expectedMatch: false,
expectedError: nil,
},
{
name: "string literal expression, match in response log",
query: "foo",
requestLog: reqlog.RequestLog{
Response: &reqlog.ResponseLog{
Body: []byte("foo"),
},
},
expectedMatch: true,
expectedError: nil,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
searchExpr, err := search.ParseQuery(tt.query)
assertError(t, nil, err)
got, err := tt.requestLog.Matches(searchExpr)
assertError(t, tt.expectedError, err)
if tt.expectedMatch != got {
t.Errorf("expected match result: %v, got: %v", tt.expectedMatch, got)
}
})
}
}
func assertError(t *testing.T, exp, got error) {
t.Helper()
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

@ -1,8 +0,0 @@
package scope
import "context"
type Repository interface {
UpsertSettings(ctx context.Context, module string, settings interface{}) error
FindSettingsByModule(ctx context.Context, module string, settings interface{}) error
}

View File

@ -1,23 +1,16 @@
package scope package scope
import ( import (
"context" "bytes"
"encoding/json" "encoding/gob"
"fmt"
"net/http" "net/http"
"regexp" "regexp"
"sync" "sync"
"github.com/dstotijn/hetty/pkg/proj"
) )
const moduleName = "scope"
type Scope struct { type Scope struct {
rules []Rule rules []Rule
repo Repository mu sync.RWMutex
mu sync.RWMutex
} }
type Rule struct { type Rule struct {
@ -31,75 +24,24 @@ type Header struct {
Value *regexp.Regexp Value *regexp.Regexp
} }
func New(repo Repository, projService *proj.Service) *Scope {
s := &Scope{
repo: repo,
}
projService.OnProjectOpen(func(_ string) error {
err := s.load(context.Background())
if err == proj.ErrNoSettings {
return nil
}
if err != nil {
return fmt.Errorf("scope: could not load scope: %v", err)
}
return nil
})
projService.OnProjectClose(func(_ string) error {
s.unload()
return nil
})
return s
}
func (s *Scope) Rules() []Rule { func (s *Scope) Rules() []Rule {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
return s.rules return s.rules
} }
func (s *Scope) load(ctx context.Context) error { func (s *Scope) SetRules(rules []Rule) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
var rules []Rule
err := s.repo.FindSettingsByModule(ctx, moduleName, &rules)
if err == proj.ErrNoSettings {
return err
}
if err != nil {
return fmt.Errorf("scope: could not load scope settings: %v", err)
}
s.rules = rules s.rules = rules
return nil
}
func (s *Scope) unload() {
s.mu.Lock()
defer s.mu.Unlock()
s.rules = nil
}
func (s *Scope) SetRules(ctx context.Context, rules []Rule) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.repo.UpsertSettings(ctx, moduleName, rules); err != nil {
return fmt.Errorf("scope: cannot set rules in repository: %v", err)
}
s.rules = rules
return nil
} }
func (s *Scope) Match(req *http.Request, body []byte) bool { func (s *Scope) Match(req *http.Request, body []byte) bool {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
for _, rule := range s.rules { for _, rule := range s.rules {
if matches := rule.Match(req, body); matches { if matches := rule.Match(req, body); matches {
return true return true
@ -118,11 +60,13 @@ func (r Rule) Match(req *http.Request, body []byte) bool {
for key, values := range req.Header { for key, values := range req.Header {
var keyMatches, valueMatches bool var keyMatches, valueMatches bool
if r.Header.Key != nil { if r.Header.Key != nil {
if matches := r.Header.Key.MatchString(key); matches { if matches := r.Header.Key.MatchString(key); matches {
keyMatches = true keyMatches = true
} }
} }
if r.Header.Value != nil { if r.Header.Value != nil {
for _, value := range values { for _, value := range values {
if matches := r.Header.Value.MatchString(value); matches { if matches := r.Header.Value.MatchString(value); matches {
@ -152,44 +96,54 @@ func (r Rule) Match(req *http.Request, body []byte) bool {
return false return false
} }
// MarshalJSON implements json.Marshaler. func regexpToString(r *regexp.Regexp) string {
func (r Rule) MarshalJSON() ([]byte, error) { if r == nil {
type headerDTO struct { return ""
Key string
Value string
}
type ruleDTO struct {
URL string
Header headerDTO
Body string
} }
dto := ruleDTO{ return r.String()
URL: regexpToString(r.URL),
Header: headerDTO{
Key: regexpToString(r.Header.Key),
Value: regexpToString(r.Header.Value),
},
Body: regexpToString(r.Body),
}
return json.Marshal(dto)
} }
// UnmarshalJSON implements json.Unmarshaler. func stringToRegexp(s string) (*regexp.Regexp, error) {
func (r *Rule) UnmarshalJSON(data []byte) error { if s == "" {
type headerDTO struct { return nil, nil
}
return regexp.Compile(s)
}
type ruleDTO struct {
URL string
Header struct {
Key string Key string
Value string Value string
} }
type ruleDTO struct { Body string
URL string }
Header headerDTO
Body string func (r Rule) MarshalBinary() ([]byte, error) {
dto := ruleDTO{
URL: regexpToString(r.URL),
Body: regexpToString(r.Body),
}
dto.Header.Key = regexpToString(r.Header.Key)
dto.Header.Value = regexpToString(r.Header.Value)
buf := bytes.Buffer{}
err := gob.NewEncoder(&buf).Encode(dto)
if err != nil {
return nil, err
} }
var dto ruleDTO return buf.Bytes(), nil
if err := json.Unmarshal(data, &dto); err != nil { }
func (r *Rule) UnmarshalBinary(data []byte) error {
dto := ruleDTO{}
err := gob.NewDecoder(bytes.NewReader(data)).Decode(&dto)
if err != nil {
return err return err
} }
@ -197,14 +151,17 @@ func (r *Rule) UnmarshalJSON(data []byte) error {
if err != nil { if err != nil {
return err return err
} }
headerKey, err := stringToRegexp(dto.Header.Key) headerKey, err := stringToRegexp(dto.Header.Key)
if err != nil { if err != nil {
return err return err
} }
headerValue, err := stringToRegexp(dto.Header.Value) headerValue, err := stringToRegexp(dto.Header.Value)
if err != nil { if err != nil {
return err return err
} }
body, err := stringToRegexp(dto.Body) body, err := stringToRegexp(dto.Body)
if err != nil { if err != nil {
return err return err
@ -221,17 +178,3 @@ func (r *Rule) UnmarshalJSON(data []byte) error {
return nil return nil
} }
func regexpToString(r *regexp.Regexp) string {
if r == nil {
return ""
}
return r.String()
}
func stringToRegexp(s string) (*regexp.Regexp, error) {
if s == "" {
return nil, nil
}
return regexp.Compile(s)
}

View File

@ -1,6 +1,10 @@
package search package search
import "strings" import (
"encoding/gob"
"regexp"
"strings"
)
type Expression interface { type Expression interface {
String() string String() string
@ -11,8 +15,7 @@ type PrefixExpression struct {
Right Expression Right Expression
} }
func (pe *PrefixExpression) expressionNode() {} func (pe PrefixExpression) String() string {
func (pe *PrefixExpression) String() string {
b := strings.Builder{} b := strings.Builder{}
b.WriteString("(") b.WriteString("(")
b.WriteString(pe.Operator.String()) b.WriteString(pe.Operator.String())
@ -29,8 +32,7 @@ type InfixExpression struct {
Right Expression Right Expression
} }
func (ie *InfixExpression) expressionNode() {} func (ie InfixExpression) String() string {
func (ie *InfixExpression) String() string {
b := strings.Builder{} b := strings.Builder{}
b.WriteString("(") b.WriteString("(")
b.WriteString(ie.Left.String()) b.WriteString(ie.Left.String())
@ -47,7 +49,32 @@ type StringLiteral struct {
Value string Value string
} }
func (sl *StringLiteral) expressionNode() {} func (sl StringLiteral) String() string {
func (sl *StringLiteral) String() string {
return sl.Value return sl.Value
} }
type RegexpLiteral struct {
*regexp.Regexp
}
func (rl RegexpLiteral) MarshalBinary() ([]byte, error) {
return []byte(rl.Regexp.String()), nil
}
func (rl *RegexpLiteral) UnmarshalBinary(data []byte) error {
re, err := regexp.Compile(string(data))
if err != nil {
return err
}
*rl = RegexpLiteral{re}
return nil
}
func init() {
gob.Register(PrefixExpression{})
gob.Register(InfixExpression{})
gob.Register(StringLiteral{})
gob.Register(RegexpLiteral{})
}

View File

@ -17,21 +17,21 @@ const eof = 0
// Token types. // Token types.
const ( const (
// Flow // Flow.
TokInvalid TokenType = iota TokInvalid TokenType = iota
TokEOF TokEOF
TokParenOpen TokParenOpen
TokParenClose TokParenClose
// Literals // Literals.
TokString TokString
// Boolean operators // Boolean operators.
TokOpNot TokOpNot
TokOpAnd TokOpAnd
TokOpOr TokOpOr
// Comparison operators // Comparison operators.
TokOpEq TokOpEq
TokOpNotEq TokOpNotEq
TokOpGt TokOpGt
@ -98,6 +98,7 @@ func (tt TokenType) String() string {
if typeString, ok := tokenTypeStrings[tt]; ok { if typeString, ok := tokenTypeStrings[tt]; ok {
return typeString return typeString
} }
return "<unknown>" return "<unknown>"
} }
@ -113,6 +114,7 @@ func (l *Lexer) read() (r rune) {
l.width = 0 l.width = 0
return eof return eof
} }
r, l.width = utf8.DecodeRuneInString(l.input[l.pos:]) r, l.width = utf8.DecodeRuneInString(l.input[l.pos:])
l.pos += l.width l.pos += l.width
@ -124,6 +126,7 @@ func (l *Lexer) emit(tokenType TokenType) {
Type: tokenType, Type: tokenType,
Literal: l.input[l.start:l.pos], Literal: l.input[l.start:l.pos],
} }
l.start = l.pos l.start = l.pos
} }
@ -159,6 +162,7 @@ func begin(l *Lexer) stateFn {
l.backup() l.backup()
l.emit(TokOpEq) l.emit(TokOpEq)
} }
return begin return begin
case '!': case '!':
switch next := l.read(); next { switch next := l.read(); next {
@ -169,6 +173,7 @@ func begin(l *Lexer) stateFn {
default: default:
return l.errorf("invalid rune %v", r) return l.errorf("invalid rune %v", r)
} }
return begin return begin
case '<': case '<':
if next := l.read(); next == '=' { if next := l.read(); next == '=' {
@ -177,6 +182,7 @@ func begin(l *Lexer) stateFn {
l.backup() l.backup()
l.emit(TokOpLt) l.emit(TokOpLt)
} }
return begin return begin
case '>': case '>':
if next := l.read(); next == '=' { if next := l.read(); next == '=' {
@ -185,6 +191,7 @@ func begin(l *Lexer) stateFn {
l.backup() l.backup()
l.emit(TokOpGt) l.emit(TokOpGt)
} }
return begin return begin
case '(': case '(':
l.emit(TokParenOpen) l.emit(TokParenOpen)
@ -231,15 +238,18 @@ func unquotedString(l *Lexer) stateFn {
case r == eof: case r == eof:
l.backup() l.backup()
l.emitUnquotedString() l.emitUnquotedString()
return begin return begin
case unicode.IsSpace(r): case unicode.IsSpace(r):
l.backup() l.backup()
l.emitUnquotedString() l.emitUnquotedString()
l.skip() l.skip()
return begin return begin
case isReserved(r): case isReserved(r):
l.backup() l.backup()
l.emitUnquotedString() l.emitUnquotedString()
return begin return begin
} }
} }
@ -251,6 +261,7 @@ func (l *Lexer) emitUnquotedString() {
l.emit(tokType) l.emit(tokType)
return return
} }
l.emit(TokString) l.emit(TokString)
} }
@ -260,5 +271,6 @@ func isReserved(r rune) bool {
return true return true
} }
} }
return false return false
} }

View File

@ -3,6 +3,8 @@ package search
import "testing" import "testing"
func TestNextToken(t *testing.T) { func TestNextToken(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
input string input string

View File

@ -2,6 +2,7 @@ package search
import ( import (
"fmt" "fmt"
"regexp"
) )
type precedence int type precedence int
@ -18,8 +19,10 @@ const (
precGroup precGroup
) )
type prefixParser func(*Parser) (Expression, error) type (
type infixParser func(*Parser, Expression) (Expression, error) prefixParser func(*Parser) (Expression, error)
infixParser func(*Parser, Expression) (Expression, error)
)
var ( var (
prefixParsers = map[TokenType]prefixParser{} prefixParsers = map[TokenType]prefixParser{}
@ -77,7 +80,6 @@ func NewParser(l *Lexer) *Parser {
p.nextToken() p.nextToken()
return p return p
} }
func ParseQuery(input string) (expr Expression, err error) { func ParseQuery(input string) (expr Expression, err error) {
@ -91,18 +93,20 @@ func ParseQuery(input string) (expr Expression, err error) {
for !p.curTokenIs(TokEOF) { for !p.curTokenIs(TokEOF) {
right, err := p.parseExpression(precLowest) right, err := p.parseExpression(precLowest)
if err != nil {
return nil, fmt.Errorf("search: could not parse expression: %v", err) switch {
} case err != nil:
if expr == nil { return nil, fmt.Errorf("search: could not parse expression: %w", err)
case expr == nil:
expr = right expr = right
} else { default:
expr = &InfixExpression{ expr = InfixExpression{
Operator: TokOpAnd, Operator: TokOpAnd,
Left: expr, Left: expr,
Right: right, Right: right,
} }
} }
p.nextToken() p.nextToken()
} }
@ -122,18 +126,11 @@ func (p *Parser) peekTokenIs(t TokenType) bool {
return p.peek.Type == t return p.peek.Type == t
} }
func (p *Parser) expectPeek(t TokenType) error {
if !p.peekTokenIs(t) {
return fmt.Errorf("expected next token to be %v, got %v", t, p.peek.Type)
}
p.nextToken()
return nil
}
func (p *Parser) curPrecedence() precedence { func (p *Parser) curPrecedence() precedence {
if p, ok := tokenPrecedences[p.cur.Type]; ok { if p, ok := tokenPrecedences[p.cur.Type]; ok {
return p return p
} }
return precLowest return precLowest
} }
@ -141,6 +138,7 @@ func (p *Parser) peekPrecedence() precedence {
if p, ok := tokenPrecedences[p.peek.Type]; ok { if p, ok := tokenPrecedences[p.peek.Type]; ok {
return p return p
} }
return precLowest return precLowest
} }
@ -152,7 +150,7 @@ func (p *Parser) parseExpression(prec precedence) (Expression, error) {
expr, err := prefixParser(p) expr, err := prefixParser(p)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse expression prefix: %v", err) return nil, fmt.Errorf("could not parse expression prefix: %w", err)
} }
for !p.peekTokenIs(eof) && prec < p.peekPrecedence() { for !p.peekTokenIs(eof) && prec < p.peekPrecedence() {
@ -165,7 +163,7 @@ func (p *Parser) parseExpression(prec precedence) (Expression, error) {
expr, err = infixParser(p, expr) expr, err = infixParser(p, expr)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse infix expression: %v", err) return nil, fmt.Errorf("could not parse infix expression: %w", err)
} }
} }
@ -173,7 +171,7 @@ func (p *Parser) parseExpression(prec precedence) (Expression, error) {
} }
func parsePrefixExpression(p *Parser) (Expression, error) { func parsePrefixExpression(p *Parser) (Expression, error) {
expr := &PrefixExpression{ expr := PrefixExpression{
Operator: p.cur.Type, Operator: p.cur.Type,
} }
@ -181,15 +179,16 @@ func parsePrefixExpression(p *Parser) (Expression, error) {
right, err := p.parseExpression(precPrefix) right, err := p.parseExpression(precPrefix)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse expression for right operand: %v", err) return nil, fmt.Errorf("could not parse expression for right operand: %w", err)
} }
expr.Right = right expr.Right = right
return expr, nil return expr, nil
} }
func parseInfixExpression(p *Parser, left Expression) (Expression, error) { func parseInfixExpression(p *Parser, left Expression) (Expression, error) {
expr := &InfixExpression{ expr := InfixExpression{
Operator: p.cur.Type, Operator: p.cur.Type,
Left: left, Left: left,
} }
@ -199,15 +198,27 @@ func parseInfixExpression(p *Parser, left Expression) (Expression, error) {
right, err := p.parseExpression(prec) right, err := p.parseExpression(prec)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse expression for right operand: %v", err) return nil, fmt.Errorf("could not parse expression for right operand: %w", err)
} }
if expr.Operator == TokOpRe || expr.Operator == TokOpNotRe {
if rightStr, ok := right.(StringLiteral); ok {
re, err := regexp.Compile(rightStr.Value)
if err != nil {
return nil, fmt.Errorf("could not compile regular expression %q: %w", rightStr.Value, err)
}
right = re
}
}
expr.Right = right expr.Right = right
return expr, nil return expr, nil
} }
func parseStringLiteral(p *Parser) (Expression, error) { func parseStringLiteral(p *Parser) (Expression, error) {
return &StringLiteral{Value: p.cur.Literal}, nil return StringLiteral{Value: p.cur.Literal}, nil
} }
func parseGroupedExpression(p *Parser) (Expression, error) { func parseGroupedExpression(p *Parser) (Expression, error) {
@ -215,18 +226,20 @@ func parseGroupedExpression(p *Parser) (Expression, error) {
expr, err := p.parseExpression(precLowest) expr, err := p.parseExpression(precLowest)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse grouped expression: %v", err) return nil, fmt.Errorf("could not parse grouped expression: %w", err)
} }
for p.nextToken(); !p.curTokenIs(TokParenClose); p.nextToken() { for p.nextToken(); !p.curTokenIs(TokParenClose); p.nextToken() {
if p.curTokenIs(TokEOF) { if p.curTokenIs(TokEOF) {
return nil, fmt.Errorf("unexpected EOF: unmatched parentheses") return nil, fmt.Errorf("unexpected EOF: unmatched parentheses")
} }
right, err := p.parseExpression(precLowest) right, err := p.parseExpression(precLowest)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse expression: %v", err) return nil, fmt.Errorf("could not parse expression: %w", err)
} }
expr = &InfixExpression{
expr = InfixExpression{
Operator: TokOpAnd, Operator: TokOpAnd,
Left: expr, Left: expr,
Right: right, Right: right,

View File

@ -3,10 +3,13 @@ package search
import ( import (
"errors" "errors"
"reflect" "reflect"
"regexp"
"testing" "testing"
) )
func TestParseQuery(t *testing.T) { func TestParseQuery(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
input string input string
@ -22,101 +25,101 @@ func TestParseQuery(t *testing.T) {
{ {
name: "string literal expression", name: "string literal expression",
input: "foobar", input: "foobar",
expectedExpression: &StringLiteral{Value: "foobar"}, expectedExpression: StringLiteral{Value: "foobar"},
expectedError: nil, expectedError: nil,
}, },
{ {
name: "boolean expression with equal operator", name: "boolean expression with equal operator",
input: "foo = bar", input: "foo = bar",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpEq, Operator: TokOpEq,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: StringLiteral{Value: "bar"},
}, },
expectedError: nil, expectedError: nil,
}, },
{ {
name: "boolean expression with not equal operator", name: "boolean expression with not equal operator",
input: "foo != bar", input: "foo != bar",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpNotEq, Operator: TokOpNotEq,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: StringLiteral{Value: "bar"},
}, },
expectedError: nil, expectedError: nil,
}, },
{ {
name: "boolean expression with greater than operator", name: "boolean expression with greater than operator",
input: "foo > bar", input: "foo > bar",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpGt, Operator: TokOpGt,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: StringLiteral{Value: "bar"},
}, },
expectedError: nil, expectedError: nil,
}, },
{ {
name: "boolean expression with less than operator", name: "boolean expression with less than operator",
input: "foo < bar", input: "foo < bar",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpLt, Operator: TokOpLt,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: StringLiteral{Value: "bar"},
}, },
expectedError: nil, expectedError: nil,
}, },
{ {
name: "boolean expression with greater than or equal operator", name: "boolean expression with greater than or equal operator",
input: "foo >= bar", input: "foo >= bar",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpGtEq, Operator: TokOpGtEq,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: StringLiteral{Value: "bar"},
}, },
expectedError: nil, expectedError: nil,
}, },
{ {
name: "boolean expression with less than or equal operator", name: "boolean expression with less than or equal operator",
input: "foo <= bar", input: "foo <= bar",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpLtEq, Operator: TokOpLtEq,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: StringLiteral{Value: "bar"},
}, },
expectedError: nil, expectedError: nil,
}, },
{ {
name: "boolean expression with regular expression operator", name: "boolean expression with regular expression operator",
input: "foo =~ bar", input: "foo =~ bar",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpRe, Operator: TokOpRe,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: regexp.MustCompile("bar"),
}, },
expectedError: nil, expectedError: nil,
}, },
{ {
name: "boolean expression with not regular expression operator", name: "boolean expression with not regular expression operator",
input: "foo !~ bar", input: "foo !~ bar",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpNotRe, Operator: TokOpNotRe,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: regexp.MustCompile("bar"),
}, },
expectedError: nil, expectedError: nil,
}, },
{ {
name: "boolean expression with AND, OR and NOT operators", name: "boolean expression with AND, OR and NOT operators",
input: "foo AND bar OR NOT baz", input: "foo AND bar OR NOT baz",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpAnd, Operator: TokOpAnd,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &InfixExpression{ Right: InfixExpression{
Operator: TokOpOr, Operator: TokOpOr,
Left: &StringLiteral{Value: "bar"}, Left: StringLiteral{Value: "bar"},
Right: &PrefixExpression{ Right: PrefixExpression{
Operator: TokOpNot, Operator: TokOpNot,
Right: &StringLiteral{Value: "baz"}, Right: StringLiteral{Value: "baz"},
}, },
}, },
}, },
@ -125,16 +128,16 @@ func TestParseQuery(t *testing.T) {
{ {
name: "boolean expression with nested group", name: "boolean expression with nested group",
input: "(foo AND bar) OR NOT baz", input: "(foo AND bar) OR NOT baz",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpOr, Operator: TokOpOr,
Left: &InfixExpression{ Left: InfixExpression{
Operator: TokOpAnd, Operator: TokOpAnd,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: StringLiteral{Value: "bar"},
}, },
Right: &PrefixExpression{ Right: PrefixExpression{
Operator: TokOpNot, Operator: TokOpNot,
Right: &StringLiteral{Value: "baz"}, Right: StringLiteral{Value: "baz"},
}, },
}, },
expectedError: nil, expectedError: nil,
@ -142,59 +145,59 @@ func TestParseQuery(t *testing.T) {
{ {
name: "implicit boolean expression with string literal operands", name: "implicit boolean expression with string literal operands",
input: "foo bar baz", input: "foo bar baz",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpAnd, Operator: TokOpAnd,
Left: &InfixExpression{ Left: InfixExpression{
Operator: TokOpAnd, Operator: TokOpAnd,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: StringLiteral{Value: "bar"},
}, },
Right: &StringLiteral{Value: "baz"}, Right: StringLiteral{Value: "baz"},
}, },
expectedError: nil, expectedError: nil,
}, },
{ {
name: "implicit boolean expression nested in group", name: "implicit boolean expression nested in group",
input: "(foo bar)", input: "(foo bar)",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpAnd, Operator: TokOpAnd,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: StringLiteral{Value: "bar"},
}, },
expectedError: nil, expectedError: nil,
}, },
{ {
name: "implicit and explicit boolean expression with string literal operands", name: "implicit and explicit boolean expression with string literal operands",
input: "foo bar OR baz yolo", input: "foo bar OR baz yolo",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpAnd, Operator: TokOpAnd,
Left: &InfixExpression{ Left: InfixExpression{
Operator: TokOpAnd, Operator: TokOpAnd,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &InfixExpression{ Right: InfixExpression{
Operator: TokOpOr, Operator: TokOpOr,
Left: &StringLiteral{Value: "bar"}, Left: StringLiteral{Value: "bar"},
Right: &StringLiteral{Value: "baz"}, Right: StringLiteral{Value: "baz"},
}, },
}, },
Right: &StringLiteral{Value: "yolo"}, Right: StringLiteral{Value: "yolo"},
}, },
expectedError: nil, expectedError: nil,
}, },
{ {
name: "implicit boolean expression with comparison operands", name: "implicit boolean expression with comparison operands",
input: "foo=bar baz=~yolo", input: "foo=bar baz=~yolo",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpAnd, Operator: TokOpAnd,
Left: &InfixExpression{ Left: InfixExpression{
Operator: TokOpEq, Operator: TokOpEq,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: StringLiteral{Value: "bar"},
}, },
Right: &InfixExpression{ Right: InfixExpression{
Operator: TokOpRe, Operator: TokOpRe,
Left: &StringLiteral{Value: "baz"}, Left: StringLiteral{Value: "baz"},
Right: &StringLiteral{Value: "yolo"}, Right: regexp.MustCompile("yolo"),
}, },
}, },
expectedError: nil, expectedError: nil,
@ -202,17 +205,17 @@ func TestParseQuery(t *testing.T) {
{ {
name: "eq operator takes precedence over boolean ops", name: "eq operator takes precedence over boolean ops",
input: "foo=bar OR baz=yolo", input: "foo=bar OR baz=yolo",
expectedExpression: &InfixExpression{ expectedExpression: InfixExpression{
Operator: TokOpOr, Operator: TokOpOr,
Left: &InfixExpression{ Left: InfixExpression{
Operator: TokOpEq, Operator: TokOpEq,
Left: &StringLiteral{Value: "foo"}, Left: StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"}, Right: StringLiteral{Value: "bar"},
}, },
Right: &InfixExpression{ Right: InfixExpression{
Operator: TokOpEq, Operator: TokOpEq,
Left: &StringLiteral{Value: "baz"}, Left: StringLiteral{Value: "baz"},
Right: &StringLiteral{Value: "yolo"}, Right: StringLiteral{Value: "yolo"},
}, },
}, },
expectedError: nil, expectedError: nil,
@ -233,6 +236,8 @@ func TestParseQuery(t *testing.T) {
} }
func assertError(t *testing.T, exp, got error) { func assertError(t *testing.T, exp, got error) {
t.Helper()
switch { switch {
case exp == nil && got != nil: case exp == nil && got != nil:
t.Fatalf("expected: nil, got: %v", got) t.Fatalf("expected: nil, got: %v", got)

9
tools.go Normal file
View File

@ -0,0 +1,9 @@
//go:build tools
// +build tools
package tools
import (
_ "github.com/99designs/gqlgen"
_ "github.com/matryer/moq"
)