mirror of
https://github.com/dstotijn/hetty.git
synced 2025-07-01 18:47:29 -04:00
Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
565c370bb8 | |||
2dc6538a3b | |||
aa8ddf4122 | |||
73ebb89863 | |||
1489cb16bf | |||
d84d2d0905 | |||
8a3b3cbf02 | |||
b3225bfb99 | |||
4e2eaea499 | |||
8122b2552d | |||
569f7bc76f | |||
ca3a729c36 | |||
ad3dc0da70 | |||
49547f535f | |||
e42e1c212b | |||
6e38b16cf2 | |||
078bf303be | |||
a42f003919 | |||
50c2eac42d | |||
4ead501f53 | |||
d2e97f2acc | |||
ad3fa7d379 |
@ -1,4 +1,8 @@
|
||||
/admin/.env
|
||||
/admin/.next
|
||||
/admin/dist
|
||||
/admin/node_modules
|
||||
/admin/node_modules
|
||||
/dist
|
||||
/docs
|
||||
/hetty
|
||||
/cmd/hetty/admin
|
52
.github/workflows/build-test.yml
vendored
Normal file
52
.github/workflows/build-test.yml
vendored
Normal 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
11
.gitignore
vendored
@ -1,7 +1,6 @@
|
||||
.release-env
|
||||
.vscode
|
||||
**/rice-box.go
|
||||
dist
|
||||
hetty
|
||||
/.vscode
|
||||
/dist
|
||||
/hetty
|
||||
/cmd/hetty/admin
|
||||
*.pem
|
||||
*.test
|
||||
*.test
|
48
.golangci.yml
Normal file
48
.golangci.yml
Normal 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"
|
@ -1,60 +1,38 @@
|
||||
env:
|
||||
- GO111MODULE=on
|
||||
- CGO_ENABLED=1
|
||||
before:
|
||||
hooks:
|
||||
- make clean
|
||||
- sh -c "NEXT_PUBLIC_VERSION={{ .Version}} make build-admin"
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- id: hetty-darwin-amd64
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
main: ./cmd/hetty
|
||||
goarch:
|
||||
- amd64
|
||||
goos:
|
||||
- darwin
|
||||
env:
|
||||
- CC=o64-clang
|
||||
- CXX=o64-clang++
|
||||
flags:
|
||||
- -mod=readonly
|
||||
|
||||
- id: hetty-linux-amd64
|
||||
main: ./cmd/hetty
|
||||
goarch:
|
||||
- amd64
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Version}}
|
||||
goos:
|
||||
- linux
|
||||
flags:
|
||||
- -mod=readonly
|
||||
|
||||
- id: hetty-windows-amd64
|
||||
main: ./cmd/hetty
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
goos:
|
||||
- windows
|
||||
env:
|
||||
- CC=x86_64-w64-mingw32-gcc
|
||||
- CXX=x86_64-w64-mingw32-g++
|
||||
flags:
|
||||
- -mod=readonly
|
||||
ldflags:
|
||||
- -buildmode=exe
|
||||
- arm64
|
||||
|
||||
archives:
|
||||
-
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
- replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
amd64: x86_64
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ .Tag }}-next"
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
|
32
Dockerfile
32
Dockerfile
@ -1,16 +1,6 @@
|
||||
ARG GO_VERSION=1.15
|
||||
ARG CGO_ENABLED=1
|
||||
ARG NODE_VERSION=14.11
|
||||
|
||||
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
|
||||
ARG GO_VERSION=1.17
|
||||
ARG NODE_VERSION=16.13
|
||||
ARG ALPINE_VERSION=3.15
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine AS node-builder
|
||||
WORKDIR /app
|
||||
@ -20,11 +10,21 @@ COPY admin/ .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
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
|
||||
COPY --from=go-builder /app/hetty .
|
||||
COPY --from=node-builder /app/dist admin
|
||||
|
||||
ENTRYPOINT ["./hetty", "-adminPath=./admin"]
|
||||
ENTRYPOINT ["./hetty"]
|
||||
|
||||
EXPOSE 8080
|
4
LICENSE
4
LICENSE
@ -1,6 +1,6 @@
|
||||
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
|
||||
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
|
||||
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
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
|
49
Makefile
49
Makefile
@ -1,34 +1,21 @@
|
||||
PACKAGE_NAME := github.com/dstotijn/hetty
|
||||
GOLANG_CROSS_VERSION ?= v1.15.2
|
||||
export CGO_ENABLED = 0
|
||||
export NEXT_TELEMETRY_DISABLED = 1
|
||||
|
||||
.PHONY: embed
|
||||
embed:
|
||||
NEXT_TELEMETRY_DISABLED=1 cd admin && yarn install && yarn run export
|
||||
cd cmd/hetty && rice embed-go
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -f hetty
|
||||
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
|
||||
build: embed
|
||||
CGO_ENABLED=1 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
|
||||
build: build-admin
|
||||
go build ./cmd/hetty
|
34
README.md
34
README.md
@ -5,6 +5,7 @@
|
||||
</h1>
|
||||
|
||||
[](https://github.com/dstotijn/hetty/releases/latest)
|
||||
[](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
|
||||

|
||||
[](https://github.com/dstotijn/hetty/blob/master/LICENSE)
|
||||
[](https://hetty.xyz/)
|
||||
@ -33,7 +34,7 @@ for details.
|
||||
|
||||
## 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.
|
||||
|
||||
### Install pre-built release (recommended)
|
||||
@ -44,14 +45,13 @@ and web based admin interface.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- [Go](https://golang.org/)
|
||||
- [Go 1.16](https://golang.org/)
|
||||
- [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))
|
||||
and needs `cgo` to compile. Additionally, the static resources for the admin interface
|
||||
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/) and embedded in
|
||||
a `.go` file with [go.rice](https://github.com/GeertJohan/go.rice) beforehand.
|
||||
When building from source, the static resources for the admin interface
|
||||
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/). The generated
|
||||
files will be embedded (using the [embed](https://golang.org/pkg/embed/)
|
||||
package) when you use the `build` Makefile target.
|
||||
|
||||
Clone the repository and use the `build` make target to create a binary:
|
||||
|
||||
@ -64,7 +64,7 @@ $ make build
|
||||
### Docker
|
||||
|
||||
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
|
||||
@ -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
|
||||
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%`
|
||||
on Windows).
|
||||
|
||||
@ -98,17 +98,17 @@ Usage of ./hetty:
|
||||
-adminPath string
|
||||
File path to admin build
|
||||
-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
|
||||
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
|
||||
-projects string
|
||||
Projects directory path (default "~/.hetty/projects")
|
||||
-db string
|
||||
Database directory path (default "~/.hetty/db")
|
||||
```
|
||||
|
||||
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.
|
||||
@ -228,10 +228,16 @@ for details.
|
||||
for all the encouragement and feedback.
|
||||
- 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
|
||||
|
||||
[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
6
admin/.eslintrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"@next/next/no-css-tags": "off"
|
||||
}
|
||||
}
|
4
admin/.prettierignore
Normal file
4
admin/.prettierignore
Normal file
@ -0,0 +1,4 @@
|
||||
/.next/
|
||||
/out/
|
||||
/build
|
||||
/coverage
|
3
admin/.prettierrc.json
Normal file
3
admin/.prettierrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"printWidth": 120
|
||||
}
|
5
admin/next-env.d.ts
vendored
5
admin/next-env.d.ts
vendored
@ -1,2 +1,5 @@
|
||||
/// <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.
|
||||
|
@ -1,7 +1,10 @@
|
||||
const withCSS = require("@zeit/next-css");
|
||||
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
|
||||
// @ts-check
|
||||
|
||||
module.exports = withCSS({
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
**/
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
trailingSlash: true,
|
||||
async rewrites() {
|
||||
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(
|
||||
new MonacoWebpackPlugin({
|
||||
languages: ["html", "json", "javascript"],
|
||||
filename: "static/[name].worker.js",
|
||||
})
|
||||
);
|
||||
|
||||
return config;
|
||||
},
|
||||
});
|
||||
module.exports = nextConfig;
|
||||
|
@ -6,31 +6,38 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"export": "rm -rf .next && next build && next export -o dist"
|
||||
"lint": "next lint",
|
||||
"export": "next build && next export -o dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.2.0",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "^4.0.0-alpha.56",
|
||||
"@zeit/next-css": "^1.0.1",
|
||||
"graphql": "^15.3.0",
|
||||
"monaco-editor": "^0.20.0",
|
||||
"monaco-editor-webpack-plugin": "^1.9.0",
|
||||
"next": "^9.5.4",
|
||||
"@emotion/react": "^11.7.1",
|
||||
"@emotion/server": "^11.4.0",
|
||||
"@emotion/styled": "^11.6.0",
|
||||
"@monaco-editor/react": "^4.3.1",
|
||||
"@mui/icons-material": "^5.3.1",
|
||||
"@mui/lab": "^5.0.0-alpha.66",
|
||||
"@mui/material": "^5.3.1",
|
||||
"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",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-monaco-editor": "^0.34.0",
|
||||
"react-syntax-highlighter": "^13.5.3",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"typescript": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^14.11.1",
|
||||
"@types/react": "^16.9.49",
|
||||
"eslint": "^7.9.0",
|
||||
"eslint-config-prettier": "^6.11.0",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"prettier": "^2.1.2"
|
||||
"@babel/core": "^7.0.0",
|
||||
"@types/lodash": "^4.14.178",
|
||||
"@types/node": "^17.0.12",
|
||||
"@types/react": "^17.0.38",
|
||||
"eslint": "^8.7.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,6 @@
|
||||
import { Paper } from "@material-ui/core";
|
||||
import { Paper } from "@mui/material";
|
||||
|
||||
function CenteredPaper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
function CenteredPaper({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Paper
|
||||
|
@ -1,31 +1,31 @@
|
||||
import React from "react";
|
||||
import {
|
||||
makeStyles,
|
||||
Theme,
|
||||
createStyles,
|
||||
useTheme,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
IconButton,
|
||||
Typography,
|
||||
Drawer,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
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 MenuIcon from "@material-ui/icons/Menu";
|
||||
import HomeIcon from "@material-ui/icons/Home";
|
||||
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
|
||||
import SendIcon from "@material-ui/icons/Send";
|
||||
import FolderIcon from "@material-ui/icons/Folder";
|
||||
import LocationSearchingIcon from "@material-ui/icons/LocationSearching";
|
||||
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
|
||||
import ChevronRightIcon from "@material-ui/icons/ChevronRight";
|
||||
import clsx from "clsx";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
|
||||
import SendIcon from "@mui/icons-material/Send";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
|
||||
export enum Page {
|
||||
Home,
|
||||
@ -39,85 +39,91 @@ export enum Page {
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
},
|
||||
appBar: {
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
transition: theme.transitions.create(["width", "margin"], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
},
|
||||
appBarShift: {
|
||||
marginLeft: drawerWidth,
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
transition: theme.transitions.create(["width", "margin"], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
},
|
||||
menuButton: {
|
||||
marginRight: 28,
|
||||
},
|
||||
hide: {
|
||||
display: "none",
|
||||
},
|
||||
drawer: {
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
drawerOpen: {
|
||||
width: drawerWidth,
|
||||
transition: theme.transitions.create("width", {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
},
|
||||
drawerClose: {
|
||||
transition: theme.transitions.create("width", {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
overflowX: "hidden",
|
||||
width: theme.spacing(7) + 1,
|
||||
[theme.breakpoints.up("sm")]: {
|
||||
width: theme.spacing(7) + 8,
|
||||
const openedMixin = (theme: Theme): CSSObject => ({
|
||||
width: drawerWidth,
|
||||
transition: theme.transitions.create("width", {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
overflowX: "hidden",
|
||||
});
|
||||
|
||||
const closedMixin = (theme: Theme): CSSObject => ({
|
||||
transition: theme.transitions.create("width", {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
overflowX: "hidden",
|
||||
width: 56,
|
||||
});
|
||||
|
||||
const DrawerHeader = styled("div")(({ theme }) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
padding: theme.spacing(0, 1),
|
||||
// necessary for content to be below app bar
|
||||
...theme.mixins.toolbar,
|
||||
}));
|
||||
|
||||
interface AppBarProps extends MuiAppBarProps {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
const AppBar = styled(MuiAppBar, {
|
||||
shouldForwardProp: (prop) => prop !== "open",
|
||||
})<AppBarProps>(({ theme, open }) => ({
|
||||
backgroundColor: theme.palette.secondary.dark,
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
transition: theme.transitions.create(["width", "margin"], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
...(open && {
|
||||
marginLeft: drawerWidth,
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
transition: theme.transitions.create(["width", "margin"], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
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",
|
||||
padding: theme.spacing(0, 1),
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const ListItemIcon = styled(MuiListItemIcon)<ListItemIconProps>(() => ({
|
||||
minWidth: 42,
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
@ -126,7 +132,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export function Layout({ title, page, children }: Props): JSX.Element {
|
||||
const classes = useStyles();
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
@ -138,145 +143,109 @@ export function Layout({ title, page, children }: Props): JSX.Element {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const SiteTitle = styled("span")({
|
||||
...(title !== "" && {
|
||||
color: theme.palette.primary.main,
|
||||
marginRight: 4,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<AppBar
|
||||
position="fixed"
|
||||
className={clsx(classes.appBar, {
|
||||
[classes.appBarShift]: open,
|
||||
})}
|
||||
>
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<AppBar position="fixed" open={open}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
aria-label="Open drawer"
|
||||
onClick={handleDrawerOpen}
|
||||
edge="start"
|
||||
className={clsx(classes.menuButton, {
|
||||
[classes.hide]: open,
|
||||
})}
|
||||
sx={{
|
||||
mr: 5,
|
||||
...(open && { display: "none" }),
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h5" noWrap>
|
||||
<span className={title !== "" ? classes.titleHighlight : ""}>
|
||||
Hetty://
|
||||
</span>
|
||||
{title}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-around",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</AppBar>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
className={clsx(classes.drawer, {
|
||||
[classes.drawerOpen]: open,
|
||||
[classes.drawerClose]: !open,
|
||||
})}
|
||||
classes={{
|
||||
paper: clsx({
|
||||
[classes.drawerOpen]: open,
|
||||
[classes.drawerClose]: !open,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Drawer variant="permanent" open={open}>
|
||||
<DrawerHeader>
|
||||
<IconButton onClick={handleDrawerClose}>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronRightIcon />
|
||||
) : (
|
||||
<ChevronLeftIcon />
|
||||
)}
|
||||
{theme.direction === "rtl" ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
</IconButton>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
<Divider />
|
||||
<List>
|
||||
<List sx={{ p: 0 }}>
|
||||
<Link href="/" passHref>
|
||||
<ListItem
|
||||
button
|
||||
component="a"
|
||||
key="home"
|
||||
selected={page === Page.Home}
|
||||
className={classes.listItem}
|
||||
>
|
||||
<ListItemButton key="home" selected={page === Page.Home}>
|
||||
<Tooltip title="Home">
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<ListItemIcon>
|
||||
<HomeIcon />
|
||||
</ListItemIcon>
|
||||
</Tooltip>
|
||||
<ListItemText primary="Home" />
|
||||
</ListItem>
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link href="/proxy/logs" passHref>
|
||||
<ListItem
|
||||
button
|
||||
component="a"
|
||||
key="proxyLogs"
|
||||
selected={page === Page.ProxyLogs}
|
||||
className={classes.listItem}
|
||||
>
|
||||
<ListItemButton key="proxyLogs" selected={page === Page.ProxyLogs}>
|
||||
<Tooltip title="Proxy">
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<ListItemIcon>
|
||||
<SettingsEthernetIcon />
|
||||
</ListItemIcon>
|
||||
</Tooltip>
|
||||
<ListItemText primary="Proxy" />
|
||||
</ListItem>
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link href="/sender" passHref>
|
||||
<ListItem
|
||||
button
|
||||
component="a"
|
||||
key="sender"
|
||||
selected={page === Page.Sender}
|
||||
className={classes.listItem}
|
||||
>
|
||||
<ListItemButton key="sender" selected={page === Page.Sender}>
|
||||
<Tooltip title="Sender">
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<ListItemIcon>
|
||||
<SendIcon />
|
||||
</ListItemIcon>
|
||||
</Tooltip>
|
||||
<ListItemText primary="Sender" />
|
||||
</ListItem>
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link href="/scope" passHref>
|
||||
<ListItem
|
||||
button
|
||||
component="a"
|
||||
key="scope"
|
||||
selected={page === Page.Scope}
|
||||
className={classes.listItem}
|
||||
>
|
||||
<ListItemButton key="scope" selected={page === Page.Scope}>
|
||||
<Tooltip title="Scope">
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<ListItemIcon>
|
||||
<LocationSearchingIcon />
|
||||
</ListItemIcon>
|
||||
</Tooltip>
|
||||
<ListItemText primary="Scope" />
|
||||
</ListItem>
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link href="/projects" passHref>
|
||||
<ListItem
|
||||
button
|
||||
component="a"
|
||||
key="projects"
|
||||
selected={page === Page.Projects}
|
||||
className={classes.listItem}
|
||||
>
|
||||
<ListItemButton key="projects" selected={page === Page.Projects}>
|
||||
<Tooltip title="Projects">
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<ListItemIcon>
|
||||
<FolderIcon />
|
||||
</ListItemIcon>
|
||||
</Tooltip>
|
||||
<ListItemText primary="Projects" />
|
||||
</ListItem>
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
</List>
|
||||
</Drawer>
|
||||
<main className={classes.content}>
|
||||
<div className={classes.toolbar} />
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||
<DrawerHeader />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,32 +1,21 @@
|
||||
import { gql, useMutation } from "@apollo/client";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
createStyles,
|
||||
makeStyles,
|
||||
TextField,
|
||||
Theme,
|
||||
Typography,
|
||||
} from "@material-ui/core";
|
||||
import AddIcon from "@material-ui/icons/Add";
|
||||
import { Box, Button, CircularProgress, TextField, Typography } from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import React, { useState } from "react";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
projectName: {
|
||||
marginTop: -6,
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
button: {
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
})
|
||||
);
|
||||
const CREATE_PROJECT = gql`
|
||||
mutation CreateProject($name: String!) {
|
||||
createProject(name: $name) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const OPEN_PROJECT = gql`
|
||||
mutation OpenProject($name: String!) {
|
||||
openProject(name: $name) {
|
||||
mutation OpenProject($id: ID!) {
|
||||
openProject(id: $id) {
|
||||
id
|
||||
name
|
||||
isActive
|
||||
}
|
||||
@ -34,23 +23,27 @@ const OPEN_PROJECT = gql`
|
||||
`;
|
||||
|
||||
function NewProject(): JSX.Element {
|
||||
const classes = useStyles();
|
||||
const [input, setInput] = useState(null);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const [openProject, { error, loading }] = useMutation(OPEN_PROJECT, {
|
||||
const [createProject, { error: createProjErr, loading: createProjLoading }] = useMutation(CREATE_PROJECT, {
|
||||
onError: () => {},
|
||||
onCompleted() {
|
||||
input.value = "";
|
||||
onCompleted(data) {
|
||||
setName("");
|
||||
openProject({ variables: { id: data.createProject.id } });
|
||||
},
|
||||
});
|
||||
const [openProject, { error: openProjErr, loading: openProjLoading }] = useMutation(OPEN_PROJECT, {
|
||||
onError: () => {},
|
||||
update(cache, { data: { openProject } }) {
|
||||
cache.modify({
|
||||
fields: {
|
||||
activeProject() {
|
||||
const activeProjRef = cache.writeFragment({
|
||||
id: openProject.name,
|
||||
id: openProject.id,
|
||||
data: openProject,
|
||||
fragment: gql`
|
||||
fragment ActiveProject on Project {
|
||||
id
|
||||
name
|
||||
isActive
|
||||
type
|
||||
@ -61,10 +54,11 @@ function NewProject(): JSX.Element {
|
||||
},
|
||||
projects(_, { DELETE }) {
|
||||
cache.writeFragment({
|
||||
id: openProject.name,
|
||||
id: openProject.id,
|
||||
data: openProject,
|
||||
fragment: gql`
|
||||
fragment OpenProject on Project {
|
||||
id
|
||||
name
|
||||
isActive
|
||||
type
|
||||
@ -78,9 +72,9 @@ function NewProject(): JSX.Element {
|
||||
},
|
||||
});
|
||||
|
||||
const handleNewProjectForm = (e: React.SyntheticEvent) => {
|
||||
const handleCreateAndOpenProjectForm = (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
openProject({ variables: { name: input.value } });
|
||||
createProject({ variables: { name } });
|
||||
};
|
||||
|
||||
return (
|
||||
@ -88,29 +82,30 @@ function NewProject(): JSX.Element {
|
||||
<Box mb={3}>
|
||||
<Typography variant="h6">New project</Typography>
|
||||
</Box>
|
||||
<form onSubmit={handleNewProjectForm} autoComplete="off">
|
||||
<form onSubmit={handleCreateAndOpenProjectForm} autoComplete="off">
|
||||
<TextField
|
||||
className={classes.projectName}
|
||||
color="secondary"
|
||||
inputProps={{
|
||||
id: "projectName",
|
||||
ref: (node) => {
|
||||
setInput(node);
|
||||
},
|
||||
sx={{
|
||||
mr: 2,
|
||||
}}
|
||||
color="primary"
|
||||
size="small"
|
||||
label="Project name"
|
||||
placeholder="Project name…"
|
||||
error={Boolean(error)}
|
||||
helperText={error && error.message}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
error={Boolean(createProjErr || openProjErr)}
|
||||
helperText={(createProjErr && createProjErr.message) || (openProjErr && openProjErr.message)}
|
||||
/>
|
||||
<Button
|
||||
className={classes.button}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
|
||||
sx={{
|
||||
pt: 0.9,
|
||||
pb: 0.7,
|
||||
}}
|
||||
disabled={createProjLoading || openProjLoading}
|
||||
startIcon={createProjLoading || openProjLoading ? <CircularProgress size={22} /> : <AddIcon />}
|
||||
>
|
||||
Create & open project
|
||||
</Button>
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
createStyles,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
@ -16,38 +15,25 @@ import {
|
||||
ListItemAvatar,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
Paper,
|
||||
Snackbar,
|
||||
Theme,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@material-ui/core";
|
||||
import CloseIcon from "@material-ui/icons/Close";
|
||||
import DescriptionIcon from "@material-ui/icons/Description";
|
||||
import DeleteIcon from "@material-ui/icons/Delete";
|
||||
import LaunchIcon from "@material-ui/icons/Launch";
|
||||
import { Alert } from "@material-ui/lab";
|
||||
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import DescriptionIcon from "@mui/icons-material/Description";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import { Alert } from "@mui/lab";
|
||||
import React, { useState } from "react";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
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,
|
||||
},
|
||||
})
|
||||
);
|
||||
import { Project } from "../../lib/Project";
|
||||
|
||||
const PROJECTS = gql`
|
||||
query Projects {
|
||||
projects {
|
||||
id
|
||||
name
|
||||
isActive
|
||||
}
|
||||
@ -55,8 +41,9 @@ const PROJECTS = gql`
|
||||
`;
|
||||
|
||||
const OPEN_PROJECT = gql`
|
||||
mutation OpenProject($name: String!) {
|
||||
openProject(name: $name) {
|
||||
mutation OpenProject($id: ID!) {
|
||||
openProject(id: $id) {
|
||||
id
|
||||
name
|
||||
isActive
|
||||
}
|
||||
@ -72,61 +59,61 @@ const CLOSE_PROJECT = gql`
|
||||
`;
|
||||
|
||||
const DELETE_PROJECT = gql`
|
||||
mutation DeleteProject($name: String!) {
|
||||
deleteProject(name: $name) {
|
||||
mutation DeleteProject($id: ID!) {
|
||||
deleteProject(id: $id) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function ProjectList(): JSX.Element {
|
||||
const classes = useStyles();
|
||||
const { loading: projLoading, error: projErr, data: projData } = useQuery(
|
||||
PROJECTS
|
||||
const theme = useTheme();
|
||||
const { loading: projLoading, error: projErr, data: projData } = useQuery<{ projects: Project[] }>(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, {
|
||||
errorPolicy: "all",
|
||||
onError: () => {},
|
||||
@ -146,10 +133,7 @@ function ProjectList(): JSX.Element {
|
||||
});
|
||||
},
|
||||
});
|
||||
const [
|
||||
deleteProject,
|
||||
{ loading: deleteProjLoading, error: deleteProjErr },
|
||||
] = useMutation(DELETE_PROJECT, {
|
||||
const [deleteProject, { loading: deleteProjLoading, error: deleteProjErr }] = useMutation(DELETE_PROJECT, {
|
||||
errorPolicy: "all",
|
||||
onError: () => {},
|
||||
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 handleDeleteButtonClick = (name: string) => {
|
||||
setDeleteProjName(name);
|
||||
const handleDeleteButtonClick = (project: any) => {
|
||||
setDeleteProj(project);
|
||||
setDeleteDiagOpen(true);
|
||||
};
|
||||
const handleDeleteConfirm = () => {
|
||||
deleteProject({ variables: { name: deleteProjName } });
|
||||
deleteProject({ variables: { id: deleteProj?.id } });
|
||||
};
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteDiagOpen(false);
|
||||
};
|
||||
|
||||
const [deleteNotifOpen, setDeleteNotifOpen] = useState(false);
|
||||
const handleCloseDeleteNotif = (_, reason?: string) => {
|
||||
const handleCloseDeleteNotif = (_: Event | React.SyntheticEvent, reason?: string) => {
|
||||
if (reason === "clickaway") {
|
||||
return;
|
||||
}
|
||||
@ -190,27 +174,29 @@ function ProjectList(): JSX.Element {
|
||||
<div>
|
||||
<Dialog open={deleteDiagOpen} onClose={handleDeleteCancel}>
|
||||
<DialogTitle>
|
||||
Delete project “<strong>{deleteProjName}</strong>”?
|
||||
Delete project “<strong>{deleteProj?.name}</strong>”?
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Deleting a project permanently removes its database file from disk.
|
||||
This action is irreversible.
|
||||
Deleting a project permanently removes all its data from the database. This action is irreversible.
|
||||
</DialogContentText>
|
||||
{deleteProjErr && (
|
||||
<Alert severity="error">
|
||||
Error closing project: {deleteProjErr.message}
|
||||
</Alert>
|
||||
)}
|
||||
{deleteProjErr && <Alert severity="error">Error closing project: {deleteProjErr.message}</Alert>}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDeleteCancel} autoFocus>
|
||||
<Button onClick={handleDeleteCancel} autoFocus color="secondary" variant="contained">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className={classes.deleteProjectButton}
|
||||
sx={{
|
||||
color: "white",
|
||||
backgroundColor: "error.main",
|
||||
"&:hover": {
|
||||
backgroundColor: "error.dark",
|
||||
},
|
||||
}}
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteProjLoading}
|
||||
variant="contained"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
@ -221,9 +207,10 @@ function ProjectList(): JSX.Element {
|
||||
open={deleteNotifOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={handleCloseDeleteNotif}
|
||||
anchorOrigin={{ horizontal: "center", vertical: "bottom" }}
|
||||
>
|
||||
<Alert onClose={handleCloseDeleteNotif} severity="info">
|
||||
Project <strong>{deleteProjName}</strong> was deleted.
|
||||
Project <strong>{deleteProj?.name}</strong> was deleted.
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
@ -233,82 +220,70 @@ function ProjectList(): JSX.Element {
|
||||
|
||||
<Box mb={4}>
|
||||
{projLoading && <CircularProgress />}
|
||||
{projErr && (
|
||||
<Alert severity="error">
|
||||
Error fetching projects: {projErr.message}
|
||||
</Alert>
|
||||
)}
|
||||
{openProjErr && (
|
||||
<Alert severity="error">
|
||||
Error opening project: {openProjErr.message}
|
||||
</Alert>
|
||||
)}
|
||||
{closeProjErr && (
|
||||
<Alert severity="error">
|
||||
Error closing project: {closeProjErr.message}
|
||||
</Alert>
|
||||
)}
|
||||
{projErr && <Alert severity="error">Error fetching projects: {projErr.message}</Alert>}
|
||||
{openProjErr && <Alert severity="error">Error opening project: {openProjErr.message}</Alert>}
|
||||
{closeProjErr && <Alert severity="error">Error closing project: {closeProjErr.message}</Alert>}
|
||||
</Box>
|
||||
|
||||
{projData?.projects.length > 0 && (
|
||||
<List className={classes.projectsList}>
|
||||
{projData.projects.map((project) => (
|
||||
<ListItem key={project.name}>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
className={
|
||||
project.isActive ? classes.activeProject : undefined
|
||||
}
|
||||
>
|
||||
<DescriptionIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText>
|
||||
{project.name} {project.isActive && <em>(Active)</em>}
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
{project.isActive && (
|
||||
<Tooltip title="Close project">
|
||||
<IconButton onClick={() => closeProject()}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!project.isActive && (
|
||||
<Tooltip title="Open project">
|
||||
{projData && projData.projects.length > 0 && (
|
||||
<Paper>
|
||||
<List>
|
||||
{projData.projects.map((project) => (
|
||||
<ListItem key={project.id}>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
sx={{
|
||||
...(project.isActive && {
|
||||
color: theme.palette.secondary.dark,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<DescriptionIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText>
|
||||
{project.name} {project.isActive && <em>(Active)</em>}
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
{project.isActive && (
|
||||
<Tooltip title="Close project">
|
||||
<IconButton onClick={() => closeProject()}>
|
||||
<CloseIcon />
|
||||
</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>
|
||||
<IconButton
|
||||
disabled={openProjLoading || projLoading}
|
||||
onClick={() =>
|
||||
openProject({
|
||||
variables: { name: project.name },
|
||||
})
|
||||
}
|
||||
>
|
||||
<LaunchIcon />
|
||||
<IconButton onClick={() => handleDeleteButtonClick(project)} disabled={project.isActive}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Delete project">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={() => handleDeleteButtonClick(project.name)}
|
||||
disabled={project.isActive}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
{projData?.projects.length === 0 && (
|
||||
<Alert severity="info">
|
||||
There are no projects. Create one to get started.
|
||||
</Alert>
|
||||
<Alert severity="info">There are no projects. Create one to get started.</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React, { useState } from "react";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import Dialog from "@material-ui/core/Dialog";
|
||||
import DialogActions from "@material-ui/core/DialogActions";
|
||||
import DialogContent from "@material-ui/core/DialogContent";
|
||||
import DialogContentText from "@material-ui/core/DialogContentText";
|
||||
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
|
||||
export function useConfirmationDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@ -38,12 +38,10 @@ export function ConfirmationDialog(props: ConfirmationDialog) {
|
||||
>
|
||||
<DialogTitle id="alert-dialog-title">Are you sure?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
{children}
|
||||
</DialogContentText>
|
||||
<DialogContentText id="alert-dialog-description">{children}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Abort</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={confirm} autoFocus>
|
||||
Confirm
|
||||
</Button>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import dynamic from "next/dynamic";
|
||||
const MonacoEditor = dynamic(import("react-monaco-editor"), { ssr: false });
|
||||
import MonacoEditor from "@monaco-editor/react";
|
||||
import monaco from "monaco-editor/esm/vs/editor/editor.api";
|
||||
|
||||
const monacoOptions = {
|
||||
const monacoOptions: monaco.editor.IEditorOptions = {
|
||||
readOnly: true,
|
||||
wordWrap: "on",
|
||||
minimap: {
|
||||
@ -11,20 +11,7 @@ const monacoOptions = {
|
||||
|
||||
type language = "html" | "typescript" | "json";
|
||||
|
||||
function editorDidMount() {
|
||||
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 {
|
||||
function languageForContentType(contentType?: string): language | undefined {
|
||||
switch (contentType) {
|
||||
case "text/html":
|
||||
return "html";
|
||||
@ -41,7 +28,7 @@ function languageForContentType(contentType: string): language {
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
contentType: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
function Editor({ content, contentType }: Props): JSX.Element {
|
||||
@ -50,8 +37,7 @@ function Editor({ content, contentType }: Props): JSX.Element {
|
||||
height={"600px"}
|
||||
language={languageForContentType(contentType)}
|
||||
theme="vs-dark"
|
||||
editorDidMount={editorDidMount}
|
||||
options={monacoOptions as any}
|
||||
options={monacoOptions}
|
||||
value={content}
|
||||
/>
|
||||
);
|
||||
|
@ -1,83 +1,66 @@
|
||||
import {
|
||||
makeStyles,
|
||||
Theme,
|
||||
createStyles,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableRow,
|
||||
Snackbar,
|
||||
} from "@material-ui/core";
|
||||
import { Alert } from "@material-ui/lab";
|
||||
import { Table, TableBody, TableCell, TableContainer, TableRow, Snackbar } from "@mui/material";
|
||||
import { Alert } from "@mui/lab";
|
||||
import React, { useState } from "react";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => {
|
||||
const paddingX = 0;
|
||||
const paddingY = theme.spacing(1) / 3;
|
||||
const tableCell = {
|
||||
paddingLeft: paddingX,
|
||||
paddingRight: paddingX,
|
||||
paddingTop: paddingY,
|
||||
paddingBottom: paddingY,
|
||||
verticalAlign: "top",
|
||||
border: "none",
|
||||
whiteSpace: "nowrap" as any,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
"&:hover": {
|
||||
color: theme.palette.secondary.main,
|
||||
whiteSpace: "inherit" as any,
|
||||
overflow: "inherit",
|
||||
textOverflow: "inherit",
|
||||
cursor: "copy",
|
||||
},
|
||||
};
|
||||
return createStyles({
|
||||
root: {},
|
||||
table: {
|
||||
tableLayout: "fixed",
|
||||
width: "100%",
|
||||
},
|
||||
keyCell: {
|
||||
...tableCell,
|
||||
paddingRight: theme.spacing(1),
|
||||
width: "40%",
|
||||
fontWeight: "bold",
|
||||
fontSize: ".75rem",
|
||||
},
|
||||
valueCell: {
|
||||
...tableCell,
|
||||
width: "60%",
|
||||
border: "none",
|
||||
fontSize: ".75rem",
|
||||
},
|
||||
});
|
||||
});
|
||||
const baseCellStyle = {
|
||||
px: 0,
|
||||
py: 0.33,
|
||||
verticalAlign: "top",
|
||||
border: "none",
|
||||
whiteSpace: "nowrap" as any,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
"&:hover": {
|
||||
color: "primary.main",
|
||||
whiteSpace: "inherit" as any,
|
||||
overflow: "inherit",
|
||||
textOverflow: "inherit",
|
||||
cursor: "copy",
|
||||
},
|
||||
};
|
||||
|
||||
const keyCellStyle = {
|
||||
...baseCellStyle,
|
||||
pr: 1,
|
||||
width: "40%",
|
||||
fontWeight: "bold",
|
||||
fontSize: ".75rem",
|
||||
};
|
||||
|
||||
const valueCellStyle = {
|
||||
...baseCellStyle,
|
||||
width: "60%",
|
||||
border: "none",
|
||||
fontSize: ".75rem",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
function HttpHeadersTable({ headers }: Props): JSX.Element {
|
||||
const classes = useStyles();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const windowSel = window.getSelection();
|
||||
|
||||
if (!windowSel || !document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = document.createRange();
|
||||
r.selectNode(e.currentTarget);
|
||||
window.getSelection().removeAllRanges();
|
||||
window.getSelection().addRange(r);
|
||||
windowSel.removeAllRanges();
|
||||
windowSel.addRange(r);
|
||||
document.execCommand("copy");
|
||||
window.getSelection().removeAllRanges();
|
||||
windowSel.removeAllRanges();
|
||||
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = (event?: React.SyntheticEvent, reason?: string) => {
|
||||
const handleClose = (event: Event | React.SyntheticEvent, reason?: string) => {
|
||||
if (reason === "clickaway") {
|
||||
return;
|
||||
}
|
||||
@ -92,20 +75,21 @@ function HttpHeadersTable({ headers }: Props): JSX.Element {
|
||||
Copied to clipboard.
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<TableContainer className={classes.root}>
|
||||
<Table className={classes.table} size="small">
|
||||
<TableContainer>
|
||||
<Table
|
||||
sx={{
|
||||
tableLayout: "fixed",
|
||||
width: "100%",
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<TableBody>
|
||||
{headers.map(({ key, value }, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
className={classes.keyCell}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<TableCell component="th" scope="row" sx={keyCellStyle} onClick={handleClick}>
|
||||
<code>{key}:</code>
|
||||
</TableCell>
|
||||
<TableCell className={classes.valueCell} onClick={handleClick}>
|
||||
<TableCell sx={valueCellStyle} onClick={handleClick}>
|
||||
<code>{value}</code>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
@ -1,31 +1,25 @@
|
||||
import { Theme, withTheme } from "@material-ui/core";
|
||||
import { orange, red } from "@material-ui/core/colors";
|
||||
import FiberManualRecordIcon from "@material-ui/icons/FiberManualRecord";
|
||||
import { SvgIconTypeMap } from "@mui/material";
|
||||
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
||||
|
||||
interface Props {
|
||||
status: number;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
function HttpStatusIcon({ status, theme }: Props): JSX.Element {
|
||||
const style = { marginTop: "-.25rem", verticalAlign: "middle" };
|
||||
export default function HttpStatusIcon({ status }: Props): JSX.Element {
|
||||
let color: SvgIconTypeMap["props"]["color"] = "inherit";
|
||||
|
||||
switch (Math.floor(status / 100)) {
|
||||
case 2:
|
||||
case 3:
|
||||
return (
|
||||
<FiberManualRecordIcon
|
||||
style={{ ...style, color: theme.palette.secondary.main }}
|
||||
/>
|
||||
);
|
||||
color = "primary";
|
||||
break;
|
||||
case 4:
|
||||
return (
|
||||
<FiberManualRecordIcon style={{ ...style, color: orange["A400"] }} />
|
||||
);
|
||||
color = "warning";
|
||||
break;
|
||||
case 5:
|
||||
return <FiberManualRecordIcon style={{ ...style, color: red["A400"] }} />;
|
||||
default:
|
||||
return <FiberManualRecordIcon style={style} />;
|
||||
color = "error";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export default withTheme(HttpStatusIcon);
|
||||
return <FiberManualRecordIcon sx={{ marginTop: "-.25rem", verticalAlign: "middle" }} color={color} />;
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
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 RequestDetail from "./RequestDetail";
|
||||
import Alert from "@material-ui/lab/Alert";
|
||||
import Alert from "@mui/lab/Alert";
|
||||
|
||||
const HTTP_REQUEST_LOG = gql`
|
||||
query HttpRequestLog($id: ID!) {
|
||||
@ -32,7 +32,7 @@ const HTTP_REQUEST_LOG = gql`
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
requestId: number;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
function LogDetail({ requestId: id }: Props): JSX.Element {
|
||||
@ -44,11 +44,7 @@ function LogDetail({ requestId: id }: Props): JSX.Element {
|
||||
return <CircularProgress />;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Error fetching logs details: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
return <Alert severity="error">Error fetching logs details: {error.message}</Alert>;
|
||||
}
|
||||
|
||||
if (!data.httpRequestLog) {
|
||||
|
@ -1,12 +1,7 @@
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
Link as MaterialLink,
|
||||
Typography,
|
||||
} from "@material-ui/core";
|
||||
import Alert from "@material-ui/lab/Alert";
|
||||
import { Box, CircularProgress, Link as MaterialLink, Typography } from "@mui/material";
|
||||
import Alert from "@mui/lab/Alert";
|
||||
|
||||
import RequestList from "./RequestList";
|
||||
import LogDetail from "./LogDetail";
|
||||
@ -15,12 +10,10 @@ import { useHttpRequestLogs } from "./hooks/useHttpRequestLogs";
|
||||
|
||||
function LogsOverview(): JSX.Element {
|
||||
const router = useRouter();
|
||||
const detailReqLogId =
|
||||
router.query.id && parseInt(router.query.id as string, 10);
|
||||
|
||||
const detailReqLogId = router.query.id as string | undefined;
|
||||
const { loading, error, data } = useHttpRequestLogs();
|
||||
|
||||
const handleLogClick = (reqId: number) => {
|
||||
const handleLogClick = (reqId: string) => {
|
||||
router.push("/proxy/logs?id=" + reqId, undefined, {
|
||||
shallow: false,
|
||||
});
|
||||
@ -35,7 +28,7 @@ function LogsOverview(): JSX.Element {
|
||||
<Alert severity="info">
|
||||
There is no project active.{" "}
|
||||
<Link href="/projects" passHref>
|
||||
<MaterialLink color="secondary">Create or open</MaterialLink>
|
||||
<MaterialLink color="primary">Create or open</MaterialLink>
|
||||
</Link>{" "}
|
||||
one first.
|
||||
</Alert>
|
||||
@ -49,11 +42,7 @@ function LogsOverview(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Box mb={2}>
|
||||
<RequestList
|
||||
logs={logs}
|
||||
selectedReqLogId={detailReqLogId}
|
||||
onLogClick={handleLogClick}
|
||||
/>
|
||||
<RequestList logs={logs || []} selectedReqLogId={detailReqLogId} onLogClick={handleLogClick} />
|
||||
</Box>
|
||||
<Box>
|
||||
{detailReqLogId && <LogDetail requestId={detailReqLogId} />}
|
||||
|
@ -1,42 +1,9 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
createStyles,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Divider,
|
||||
} from "@material-ui/core";
|
||||
import { Typography, Box, Divider } from "@mui/material";
|
||||
|
||||
import HttpHeadersTable from "./HttpHeadersTable";
|
||||
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 {
|
||||
request: {
|
||||
method: string;
|
||||
@ -49,29 +16,27 @@ interface Props {
|
||||
|
||||
function RequestDetail({ request }: Props): JSX.Element {
|
||||
const { method, url, proto, headers, body } = request;
|
||||
const classes = useStyles();
|
||||
|
||||
const contentType = headers.find((header) => header.key === "Content-Type")
|
||||
?.value;
|
||||
const contentType = headers.find((header) => header.key === "Content-Type")?.value;
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Box p={2}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
color="textSecondary"
|
||||
style={{ float: "right" }}
|
||||
>
|
||||
<Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
|
||||
Request
|
||||
</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)}{" "}
|
||||
<Typography
|
||||
component="span"
|
||||
color="textSecondary"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
<Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{proto}
|
||||
</Typography>
|
||||
</Typography>
|
||||
|
@ -8,48 +8,23 @@ import {
|
||||
TableBody,
|
||||
Typography,
|
||||
Box,
|
||||
createStyles,
|
||||
makeStyles,
|
||||
Theme,
|
||||
withTheme,
|
||||
} from "@material-ui/core";
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
|
||||
import HttpStatusIcon from "./HttpStatusCode";
|
||||
import CenteredPaper from "../CenteredPaper";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
row: {
|
||||
"&:hover": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
/* Pseudo-class applied to the root element if `hover={true}`. */
|
||||
hover: {},
|
||||
})
|
||||
);
|
||||
import { RequestLog } from "../../lib/requestLogs";
|
||||
|
||||
interface Props {
|
||||
logs: Array<any>;
|
||||
selectedReqLogId?: number;
|
||||
onLogClick(requestId: number): void;
|
||||
theme: Theme;
|
||||
logs: RequestLog[];
|
||||
selectedReqLogId?: string;
|
||||
onLogClick(requestId: string): void;
|
||||
}
|
||||
|
||||
function RequestList({
|
||||
logs,
|
||||
onLogClick,
|
||||
selectedReqLogId,
|
||||
theme,
|
||||
}: Props): JSX.Element {
|
||||
export default function RequestList({ logs, onLogClick, selectedReqLogId }: Props): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<RequestListTable
|
||||
onLogClick={onLogClick}
|
||||
logs={logs}
|
||||
selectedReqLogId={selectedReqLogId}
|
||||
theme={theme}
|
||||
/>
|
||||
<RequestListTable onLogClick={onLogClick} logs={logs} selectedReqLogId={selectedReqLogId} />
|
||||
{logs.length === 0 && (
|
||||
<Box my={1}>
|
||||
<CenteredPaper>
|
||||
@ -62,19 +37,14 @@ function RequestList({
|
||||
}
|
||||
|
||||
interface RequestListTableProps {
|
||||
logs?: any;
|
||||
selectedReqLogId?: number;
|
||||
onLogClick(requestId: number): void;
|
||||
theme: Theme;
|
||||
logs: RequestLog[];
|
||||
selectedReqLogId?: string;
|
||||
onLogClick(requestId: string): void;
|
||||
}
|
||||
|
||||
function RequestListTable({
|
||||
logs,
|
||||
selectedReqLogId,
|
||||
onLogClick,
|
||||
theme,
|
||||
}: RequestListTableProps): JSX.Element {
|
||||
const classes = useStyles();
|
||||
function RequestListTable({ logs, selectedReqLogId, onLogClick }: RequestListTableProps): JSX.Element {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
@ -102,26 +72,25 @@ function RequestListTable({
|
||||
textOverflow: "ellipsis",
|
||||
} as any;
|
||||
|
||||
const rowStyle = {
|
||||
backgroundColor:
|
||||
id === selectedReqLogId && theme.palette.action.selected,
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={id}
|
||||
className={classes.row}
|
||||
style={rowStyle}
|
||||
sx={{
|
||||
"&:hover": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
...(id === selectedReqLogId && {
|
||||
bgcolor: theme.palette.action.selected,
|
||||
}),
|
||||
}}
|
||||
hover
|
||||
onClick={() => onLogClick(id)}
|
||||
>
|
||||
<TableCell style={{ ...cellStyle, width: "100px" }}>
|
||||
<code>{method}</code>
|
||||
</TableCell>
|
||||
<TableCell style={{ ...cellStyle, maxWidth: "100px" }}>
|
||||
{origin}
|
||||
</TableCell>
|
||||
<TableCell style={{ ...cellStyle, maxWidth: "200px" }}>
|
||||
<TableCell sx={{ ...cellStyle, maxWidth: "100px" }}>{origin}</TableCell>
|
||||
<TableCell sx={{ ...cellStyle, maxWidth: "200px" }}>
|
||||
{decodeURIComponent(pathname + search + hash)}
|
||||
</TableCell>
|
||||
<TableCell style={{ maxWidth: "100px" }}>
|
||||
@ -142,5 +111,3 @@ function RequestListTable({
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTheme(RequestList);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Typography, Box, Divider } from "@material-ui/core";
|
||||
import { Typography, Box, Divider } from "@mui/material";
|
||||
|
||||
import HttpStatusIcon from "./HttpStatusCode";
|
||||
import Editor from "./Editor";
|
||||
@ -15,30 +15,17 @@ interface Props {
|
||||
}
|
||||
|
||||
function ResponseDetail({ response }: Props): JSX.Element {
|
||||
const contentType = response.headers.find(
|
||||
(header) => header.key === "Content-Type"
|
||||
)?.value;
|
||||
const contentType = response.headers.find((header) => header.key === "Content-Type")?.value;
|
||||
return (
|
||||
<div>
|
||||
<Box p={2}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
color="textSecondary"
|
||||
style={{ float: "right" }}
|
||||
>
|
||||
<Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
|
||||
Response
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
style={{ fontSize: "1rem", whiteSpace: "nowrap" }}
|
||||
>
|
||||
<Typography variant="h6" style={{ fontSize: "1rem", whiteSpace: "nowrap" }}>
|
||||
<HttpStatusIcon status={response.statusCode} />{" "}
|
||||
<Typography component="span" color="textSecondary">
|
||||
<Typography
|
||||
component="span"
|
||||
color="textSecondary"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
<Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{response.proto}
|
||||
</Typography>
|
||||
</Typography>{" "}
|
||||
@ -52,9 +39,7 @@ function ResponseDetail({ response }: Props): JSX.Element {
|
||||
<HttpHeadersTable headers={response.headers} />
|
||||
</Box>
|
||||
|
||||
{response.body && (
|
||||
<Editor content={response.body} contentType={contentType} />
|
||||
)}
|
||||
{response.body && <Editor content={response.body} contentType={contentType} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -3,29 +3,23 @@ import {
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
ClickAwayListener,
|
||||
createStyles,
|
||||
FormControlLabel,
|
||||
InputBase,
|
||||
makeStyles,
|
||||
Paper,
|
||||
Popper,
|
||||
Theme,
|
||||
Tooltip,
|
||||
useTheme,
|
||||
} from "@material-ui/core";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import SearchIcon from "@material-ui/icons/Search";
|
||||
import FilterListIcon from "@material-ui/icons/FilterList";
|
||||
import DeleteIcon from "@material-ui/icons/Delete";
|
||||
} from "@mui/material";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { gql, useMutation, useQuery } from "@apollo/client";
|
||||
import { withoutTypename } from "../../lib/omitTypename";
|
||||
import { Alert } from "@material-ui/lab";
|
||||
import { Alert } from "@mui/lab";
|
||||
import { useClearHTTPRequestLog } from "./hooks/useClearHTTPRequestLog";
|
||||
import {
|
||||
ConfirmationDialog,
|
||||
useConfirmationDialog,
|
||||
} from "./ConfirmationDialog";
|
||||
import { ConfirmationDialog, useConfirmationDialog } from "./ConfirmationDialog";
|
||||
|
||||
const FILTER = gql`
|
||||
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 {
|
||||
onlyInScope: boolean;
|
||||
searchExpression: string;
|
||||
}
|
||||
|
||||
function Search(): JSX.Element {
|
||||
const classes = useStyles();
|
||||
const theme = useTheme();
|
||||
|
||||
const [searchExpr, setSearchExpr] = useState("");
|
||||
const { loading: filterLoading, error: filterErr, data: filter } = useQuery(
|
||||
FILTER,
|
||||
{
|
||||
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 {
|
||||
loading: filterLoading,
|
||||
error: filterErr,
|
||||
data: filter,
|
||||
} = useQuery(FILTER, {
|
||||
onCompleted: (data) => {
|
||||
setSearchExpr(data.httpRequestLogFilter?.searchExpression || "");
|
||||
},
|
||||
});
|
||||
|
||||
const [
|
||||
clearHTTPRequestLog,
|
||||
clearHTTPRequestLogResult,
|
||||
] = useClearHTTPRequestLog();
|
||||
const [setFilterMutate, { error: setFilterErr, loading: setFilterLoading }] = useMutation<{
|
||||
setHttpRequestLogFilter: SearchFilter | null;
|
||||
}>(SET_FILTER, {
|
||||
update(cache, { data }) {
|
||||
cache.writeQuery({
|
||||
query: FILTER,
|
||||
data: {
|
||||
httpRequestLogFilter: data?.setHttpRequestLogFilter,
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
const [clearHTTPRequestLog, clearHTTPRequestLogResult] = useClearHTTPRequestLog();
|
||||
const clearHTTPConfirmationDialog = useConfirmationDialog();
|
||||
|
||||
const filterRef = useRef<HTMLElement | null>();
|
||||
const filterRef = useRef<HTMLFormElement>(null);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.SyntheticEvent) => {
|
||||
@ -132,8 +91,8 @@ function Search(): JSX.Element {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleClickAway = (event: React.MouseEvent<EventTarget>) => {
|
||||
if (filterRef.current.contains(event.target as HTMLElement)) {
|
||||
const handleClickAway = (event: MouseEvent | TouchEvent) => {
|
||||
if (filterRef?.current && filterRef.current.contains(event.target as HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
setFilterOpen(false);
|
||||
@ -143,63 +102,67 @@ function Search(): JSX.Element {
|
||||
<Box>
|
||||
<Error prefix="Error fetching filter" error={filterErr} />
|
||||
<Error prefix="Error setting filter" error={setFilterErr} />
|
||||
<Error
|
||||
prefix="Error clearing all HTTP logs"
|
||||
error={clearHTTPRequestLogResult.error}
|
||||
/>
|
||||
<Error prefix="Error clearing all HTTP logs" error={clearHTTPRequestLogResult.error} />
|
||||
<Box style={{ display: "flex", flex: 1 }}>
|
||||
<ClickAwayListener onClickAway={handleClickAway}>
|
||||
<Paper
|
||||
component="form"
|
||||
onSubmit={handleSubmit}
|
||||
ref={filterRef}
|
||||
className={classes.root}
|
||||
sx={{
|
||||
padding: "2px 4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: 400,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Toggle filter options">
|
||||
<IconButton
|
||||
className={classes.iconButton}
|
||||
onClick={() => setFilterOpen(!filterOpen)}
|
||||
style={{
|
||||
color: filter?.httpRequestLogFilter?.onlyInScope
|
||||
? theme.palette.secondary.main
|
||||
: "inherit",
|
||||
sx={{
|
||||
p: 1,
|
||||
color: filter?.httpRequestLogFilter?.onlyInScope ? "primary.main" : "inherit",
|
||||
}}
|
||||
>
|
||||
{filterLoading || setFilterLoading ? (
|
||||
<CircularProgress
|
||||
className={classes.filterLoading}
|
||||
size={23}
|
||||
/>
|
||||
<CircularProgress sx={{ color: theme.palette.text.primary }} size={23} />
|
||||
) : (
|
||||
<FilterListIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<InputBase
|
||||
className={classes.input}
|
||||
sx={{
|
||||
ml: 1,
|
||||
flex: 1,
|
||||
}}
|
||||
placeholder="Search proxy logs…"
|
||||
value={searchExpr}
|
||||
onChange={(e) => setSearchExpr(e.target.value)}
|
||||
onFocus={() => setFilterOpen(true)}
|
||||
/>
|
||||
<Tooltip title="Search">
|
||||
<IconButton type="submit" className={classes.iconButton}>
|
||||
<IconButton type="submit" sx={{ padding: 1.25 }}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Popper
|
||||
className={classes.filterPopper}
|
||||
open={filterOpen}
|
||||
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
|
||||
control={
|
||||
<Checkbox
|
||||
checked={
|
||||
filter?.httpRequestLogFilter?.onlyInScope ? true : false
|
||||
}
|
||||
checked={filter?.httpRequestLogFilter?.onlyInScope ? true : false}
|
||||
disabled={filterLoading || setFilterLoading}
|
||||
onChange={(e) =>
|
||||
setFilterMutate({
|
||||
|
@ -3,20 +3,18 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
createStyles,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormLabel,
|
||||
makeStyles,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
TextField,
|
||||
Theme,
|
||||
} from "@material-ui/core";
|
||||
import AddIcon from "@material-ui/icons/Add";
|
||||
import { Alert } from "@material-ui/lab";
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { Alert } from "@mui/lab";
|
||||
import React from "react";
|
||||
import { SCOPE } from "./Rules";
|
||||
import { ScopeRule } from "../../lib/scope";
|
||||
|
||||
const SET_SCOPE = gql`
|
||||
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 {
|
||||
const classes = useStyles();
|
||||
|
||||
const [ruleType, setRuleType] = React.useState("url");
|
||||
const [expression, setExpression] = React.useState(null);
|
||||
const [expression, setExpression] = React.useState("");
|
||||
|
||||
const client = useApolloClient();
|
||||
const [setScope, { error, loading }] = useMutation(SET_SCOPE, {
|
||||
onError() {},
|
||||
onCompleted() {
|
||||
expression.value = "";
|
||||
setExpression("");
|
||||
},
|
||||
update(_, { data: { setScope } }) {
|
||||
client.writeQuery({
|
||||
@ -59,21 +47,20 @@ function AddRule(): JSX.Element {
|
||||
};
|
||||
const handleSubmit = (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
let scope = [];
|
||||
let scope: ScopeRule[] = [];
|
||||
|
||||
try {
|
||||
const data = client.readQuery({
|
||||
const data = client.readQuery<{ scope: ScopeRule[] }>({
|
||||
query: SCOPE,
|
||||
});
|
||||
scope = data.scope;
|
||||
if (data) {
|
||||
scope = data.scope;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
setScope({
|
||||
variables: {
|
||||
scope: [
|
||||
...scope.map(({ url }) => ({ url })),
|
||||
{ url: expression.value },
|
||||
],
|
||||
scope: [...scope.map(({ url }) => ({ url })), { url: expression }],
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -87,15 +74,10 @@ function AddRule(): JSX.Element {
|
||||
)}
|
||||
<form onSubmit={handleSubmit} autoComplete="off">
|
||||
<FormControl fullWidth>
|
||||
<FormLabel color="secondary" component="legend">
|
||||
<FormLabel color="primary" component="legend">
|
||||
Rule Type
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
row
|
||||
name="ruleType"
|
||||
value={ruleType}
|
||||
onChange={handleTypeChange}
|
||||
>
|
||||
<RadioGroup row name="ruleType" value={ruleType} onChange={handleTypeChange}>
|
||||
<FormControlLabel value="url" control={<Radio />} label="URL" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
@ -104,20 +86,17 @@ function AddRule(): JSX.Element {
|
||||
label="Expression"
|
||||
placeholder="^https:\/\/(.*)example.com(.*)"
|
||||
helperText="Regular expression to match on."
|
||||
color="secondary"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
required
|
||||
value={expression}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
InputProps={{
|
||||
className: classes.ruleExpression,
|
||||
sx: { fontFamily: "'JetBrains Mono', monospace" },
|
||||
}}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
inputProps={{
|
||||
ref: (node) => {
|
||||
setExpression(node);
|
||||
},
|
||||
}}
|
||||
margin="normal"
|
||||
/>
|
||||
</FormControl>
|
||||
@ -125,7 +104,7 @@ function AddRule(): JSX.Element {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
|
||||
>
|
||||
|
@ -8,11 +8,12 @@ import {
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
} from "@material-ui/core";
|
||||
import CodeIcon from "@material-ui/icons/Code";
|
||||
import DeleteIcon from "@material-ui/icons/Delete";
|
||||
} from "@mui/material";
|
||||
import CodeIcon from "@mui/icons-material/Code";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import React from "react";
|
||||
import { SCOPE } from "./Rules";
|
||||
import { ScopeRule } from "../../lib/scope";
|
||||
|
||||
const SET_SCOPE = gql`
|
||||
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 [setScope, { loading }] = useMutation(SET_SCOPE, {
|
||||
update(_, { data: { setScope } }) {
|
||||
@ -65,8 +72,8 @@ function RuleListItem({ scope, rule, index }): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function RuleListItemText({ rule }): JSX.Element {
|
||||
let text: JSX.Element;
|
||||
function RuleListItemText({ rule }: { rule: ScopeRule }): JSX.Element {
|
||||
let text: JSX.Element = <div></div>;
|
||||
|
||||
if (rule.url) {
|
||||
text = <code>{rule.url}</code>;
|
||||
@ -77,10 +84,14 @@ function RuleListItemText({ rule }): JSX.Element {
|
||||
return <ListItemText>{text}</ListItemText>;
|
||||
}
|
||||
|
||||
function RuleTypeChip({ rule }): JSX.Element {
|
||||
function RuleTypeChip({ rule }: { rule: ScopeRule }): JSX.Element {
|
||||
let label = "Unknown";
|
||||
|
||||
if (rule.url) {
|
||||
return <Chip label="URL" variant="outlined" />;
|
||||
label = "URL";
|
||||
}
|
||||
|
||||
return <Chip label={label} variant="outlined" />;
|
||||
}
|
||||
|
||||
export default RuleListItem;
|
||||
|
@ -1,22 +1,9 @@
|
||||
import { gql, useQuery } from "@apollo/client";
|
||||
import {
|
||||
CircularProgress,
|
||||
createStyles,
|
||||
List,
|
||||
makeStyles,
|
||||
Theme,
|
||||
} from "@material-ui/core";
|
||||
import { Alert } from "@material-ui/lab";
|
||||
import { CircularProgress, List } from "@mui/material";
|
||||
import { Alert } from "@mui/lab";
|
||||
import React from "react";
|
||||
import RuleListItem from "./RuleListItem";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
rulesList: {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
})
|
||||
);
|
||||
import { ScopeRule } from "../../lib/scope";
|
||||
|
||||
export const SCOPE = gql`
|
||||
query Scope {
|
||||
@ -27,24 +14,20 @@ export const SCOPE = gql`
|
||||
`;
|
||||
|
||||
function Rules(): JSX.Element {
|
||||
const classes = useStyles();
|
||||
const { loading, error, data } = useQuery(SCOPE);
|
||||
const { loading, error, data } = useQuery<{ scope: ScopeRule[] }>(SCOPE);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loading && <CircularProgress />}
|
||||
{error && (
|
||||
<Alert severity="error">Error fetching scope: {error.message}</Alert>
|
||||
)}
|
||||
{data?.scope.length > 0 && (
|
||||
<List className={classes.rulesList}>
|
||||
{error && <Alert severity="error">Error fetching scope: {error.message}</Alert>}
|
||||
{data && data.scope.length > 0 && (
|
||||
<List
|
||||
sx={{
|
||||
bgcolor: "background.paper",
|
||||
}}
|
||||
>
|
||||
{data.scope.map((rule, index) => (
|
||||
<RuleListItem
|
||||
key={index}
|
||||
rule={rule}
|
||||
scope={data.scope}
|
||||
index={index}
|
||||
/>
|
||||
<RuleListItem key={index} rule={rule} scope={data.scope} index={index} />
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
5
admin/src/lib/Project.ts
Normal file
5
admin/src/lib/Project.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type Project = {
|
||||
id: string
|
||||
name: string
|
||||
isActive: boolean
|
||||
}
|
7
admin/src/lib/createEmotionCache.ts
Normal file
7
admin/src/lib/createEmotionCache.ts
Normal 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 });
|
||||
}
|
@ -1,7 +1,11 @@
|
||||
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() {
|
||||
return new ApolloClient({
|
||||
@ -9,13 +13,7 @@ function createApolloClient() {
|
||||
link: new HttpLink({
|
||||
uri: "/api/graphql/",
|
||||
}),
|
||||
cache: new InMemoryCache({
|
||||
typePolicies: {
|
||||
Project: {
|
||||
keyFields: ["name"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
cache: new InMemoryCache(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -27,9 +25,18 @@ export function initializeApollo(initialState = null) {
|
||||
if (initialState) {
|
||||
// Get existing cache, loaded during client side data fetching
|
||||
const existingCache = _apolloClient.extract();
|
||||
// Restore the cache using the data passed from getStaticProps/getServerSideProps
|
||||
// combined with the existing cached data
|
||||
_apolloClient.cache.restore({ ...existingCache, ...initialState });
|
||||
|
||||
// Merge the existing cache into data passed from getStaticProps/getServerSideProps
|
||||
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
|
||||
if (typeof window === "undefined") return _apolloClient;
|
||||
@ -39,7 +46,16 @@ export function initializeApollo(initialState = null) {
|
||||
return _apolloClient;
|
||||
}
|
||||
|
||||
export function useApollo(initialState) {
|
||||
const store = useMemo(() => initializeApollo(initialState), [initialState]);
|
||||
export function addApolloState(client: ApolloClient<NormalizedCacheObject>, pageProps: any) {
|
||||
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;
|
||||
}
|
||||
|
@ -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 {
|
||||
return JSON.parse(JSON.stringify(input), omitTypename);
|
||||
|
24
admin/src/lib/requestLogs.ts
Normal file
24
admin/src/lib/requestLogs.ts
Normal 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
3
admin/src/lib/scope.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export type ScopeRule = {
|
||||
url?: string
|
||||
}
|
@ -1,49 +1,51 @@
|
||||
import { createMuiTheme } from "@material-ui/core/styles";
|
||||
import grey from "@material-ui/core/colors/grey";
|
||||
import teal from "@material-ui/core/colors/teal";
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
import * as colors from "@mui/material/colors";
|
||||
|
||||
const theme = createMuiTheme({
|
||||
const heading = {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
let theme = createTheme({
|
||||
palette: {
|
||||
type: "dark",
|
||||
mode: "dark",
|
||||
primary: {
|
||||
main: grey[900],
|
||||
main: colors.teal["A400"],
|
||||
},
|
||||
secondary: {
|
||||
main: teal["A400"],
|
||||
},
|
||||
info: {
|
||||
main: teal["A400"],
|
||||
},
|
||||
success: {
|
||||
main: teal["A400"],
|
||||
main: colors.grey[900],
|
||||
light: "#333",
|
||||
dark: colors.common.black,
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
h2: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
h2: heading,
|
||||
h3: heading,
|
||||
h4: heading,
|
||||
h5: heading,
|
||||
h6: heading,
|
||||
},
|
||||
});
|
||||
|
||||
theme = createTheme(theme, {
|
||||
palette: {
|
||||
background: {
|
||||
default: theme.palette.secondary.main,
|
||||
paper: theme.palette.secondary.light,
|
||||
},
|
||||
h3: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
info: {
|
||||
main: theme.palette.primary.main,
|
||||
},
|
||||
h4: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
},
|
||||
h5: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
success: {
|
||||
main: theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
components: {
|
||||
MuiTableCell: {
|
||||
stickyHeader: {
|
||||
backgroundColor: grey[900],
|
||||
styleOverrides: {
|
||||
stickyHeader: {
|
||||
backgroundColor: theme.palette.secondary.dark,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -1,32 +1,31 @@
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
import Head from "next/head";
|
||||
import { AppProps } from "next/app";
|
||||
import { ApolloProvider } from "@apollo/client";
|
||||
import Head from "next/head";
|
||||
import { ThemeProvider } from "@material-ui/core/styles";
|
||||
import CssBaseline from "@material-ui/core/CssBaseline";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { CacheProvider, EmotionCache } from "@emotion/react";
|
||||
|
||||
import createEmotionCache from "../lib/createEmotionCache";
|
||||
import theme from "../lib/theme";
|
||||
import { useApollo } from "../lib/graphql";
|
||||
|
||||
function App({ Component, pageProps }: AppProps): JSX.Element {
|
||||
const apolloClient = useApollo(pageProps.initialApolloState);
|
||||
// Client-side cache, shared for the whole session of the user in the browser.
|
||||
const clientSideEmotionCache = createEmotionCache();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Remove the server-side injected CSS.
|
||||
const jssStyles = document.querySelector("#jss-server-side");
|
||||
if (jssStyles) {
|
||||
jssStyles.parentElement.removeChild(jssStyles);
|
||||
}
|
||||
}, []);
|
||||
interface MyAppProps extends AppProps {
|
||||
emotionCache?: EmotionCache;
|
||||
}
|
||||
|
||||
export default function MyApp(props: MyAppProps) {
|
||||
const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
|
||||
const apolloClient = useApollo(pageProps);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<CacheProvider value={emotionCache}>
|
||||
<Head>
|
||||
<title>Hetty://</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="minimum-scale=1, initial-scale=1, width=device-width"
|
||||
/>
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
</Head>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
@ -34,8 +33,6 @@ function App({ Component, pageProps }: AppProps): JSX.Element {
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</ApolloProvider>
|
||||
</React.Fragment>
|
||||
</CacheProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import * as React from "react";
|
||||
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";
|
||||
|
||||
export default class MyDocument extends Document {
|
||||
@ -11,14 +12,9 @@ export default class MyDocument extends Document {
|
||||
<Head>
|
||||
<meta name="theme-color" content={theme.palette.primary.main} />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" />
|
||||
{(this.props as any).emotionStyleTags}
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
@ -30,25 +26,60 @@ export default class MyDocument extends Document {
|
||||
}
|
||||
|
||||
// `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) => {
|
||||
// Render app and page and get the context of the page with collected side effects.
|
||||
const sheets = new ServerStyleSheets();
|
||||
// Resolution order
|
||||
//
|
||||
// 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;
|
||||
|
||||
// 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 = () =>
|
||||
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);
|
||||
// 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 {
|
||||
...initialProps,
|
||||
// Styles fragment is rendered after the app and page rendering finish.
|
||||
styles: [
|
||||
...React.Children.toArray(initialProps.styles),
|
||||
sheets.getStyleElement(),
|
||||
],
|
||||
emotionStyleTags,
|
||||
};
|
||||
};
|
||||
|
@ -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 React from "react";
|
||||
@ -13,17 +13,13 @@ function Index(): JSX.Element {
|
||||
<Typography variant="h4">Get started</Typography>
|
||||
</Box>
|
||||
<Typography paragraph>
|
||||
You’ve loaded a (new) project. What’s next? You can now use the MITM
|
||||
proxy and review HTTP requests and responses via the{" "}
|
||||
You’ve loaded a (new) project. What’s next? You can now use the MITM proxy and review HTTP requests and
|
||||
responses via the{" "}
|
||||
<Link href="/proxy/logs" passHref>
|
||||
<MaterialLink color="secondary">Proxy logs</MaterialLink>
|
||||
<MaterialLink color="primary">Proxy logs</MaterialLink>
|
||||
</Link>
|
||||
. Stuck? Ask for help on the{" "}
|
||||
<MaterialLink
|
||||
href="https://github.com/dstotijn/hetty/discussions"
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
>
|
||||
<MaterialLink href="https://github.com/dstotijn/hetty/discussions" color="primary" target="_blank">
|
||||
Discussions forum
|
||||
</MaterialLink>
|
||||
.
|
||||
|
@ -1,253 +1,53 @@
|
||||
import {
|
||||
Avatar,
|
||||
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 { Box, Button, Typography } from "@mui/material";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
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 { 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 {
|
||||
const classes = useStyles();
|
||||
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>
|
||||
);
|
||||
}
|
||||
const highlightSx = { color: "primary.main" };
|
||||
|
||||
return (
|
||||
<Layout page={Page.Home} title="">
|
||||
<Box p={4}>
|
||||
<Box mb={4} width="60%">
|
||||
<Typography variant="h2">
|
||||
<span className={classes.titleHighlight}>Hetty://</span>
|
||||
<Box component="span" sx={highlightSx}>
|
||||
Hetty://
|
||||
</Box>
|
||||
<br />
|
||||
The simple HTTP toolkit for security research.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography className={classes.subtitle} paragraph>
|
||||
What if security testing was intuitive, powerful, and good looking?
|
||||
What if it was <strong>free</strong>, instead of $400 per year?{" "}
|
||||
<span className={classes.titleHighlight}>Hetty</span> is listening on{" "}
|
||||
<code>:8080</code>…
|
||||
<Typography
|
||||
paragraph
|
||||
sx={{
|
||||
fontSize: "1.6rem",
|
||||
width: "60%",
|
||||
lineHeight: 2,
|
||||
mb: 5,
|
||||
}}
|
||||
>
|
||||
Welcome to{" "}
|
||||
<Box component="span" sx={highlightSx}>
|
||||
Hetty
|
||||
</Box>
|
||||
. Get started by creating a project.
|
||||
</Typography>
|
||||
|
||||
{activeProjData?.activeProject?.name ? (
|
||||
<div>
|
||||
<Box mb={1}>
|
||||
<Typography variant="h6">Active project:</Typography>
|
||||
</Box>
|
||||
<Box ml={-2} mb={2}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar className={classes.activeProject}>
|
||||
<DescriptionIcon />
|
||||
</Avatar>
|
||||
</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>
|
||||
)}
|
||||
<Link href="/projects" passHref>
|
||||
<Button
|
||||
sx={{ mr: 2 }}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
component="a"
|
||||
size="large"
|
||||
startIcon={<FolderIcon />}
|
||||
>
|
||||
Manage projects
|
||||
</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
</Layout>
|
||||
);
|
||||
|
@ -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 NewProject from "../../components/projects/NewProject";
|
||||
import ProjectList from "../../components/projects/ProjectList";
|
||||
@ -11,8 +11,7 @@ function Index(): JSX.Element {
|
||||
<Typography variant="h4">Projects</Typography>
|
||||
</Box>
|
||||
<Typography paragraph>
|
||||
Projects contain settings and data generated/processed by Hetty. They
|
||||
are stored as SQLite database files on disk.
|
||||
Projects contain settings and data generated/processed by Hetty. They are stored in a single database on disk.
|
||||
</Typography>
|
||||
<Box my={4}>
|
||||
<Divider />
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { Button, Typography } from "@material-ui/core";
|
||||
import ListIcon from "@material-ui/icons/List";
|
||||
import { Button, Typography } from "@mui/material";
|
||||
import ListIcon from "@mui/icons-material/List";
|
||||
import Link from "next/link";
|
||||
|
||||
import Layout, { Page } from "../../components/Layout";
|
||||
@ -10,13 +10,7 @@ function Index(): JSX.Element {
|
||||
<Layout page={Page.ProxySetup} title="Proxy setup">
|
||||
<Typography paragraph>Coming soon…</Typography>
|
||||
<Link href="/proxy/logs" passHref>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
component="a"
|
||||
size="large"
|
||||
startIcon={<ListIcon />}
|
||||
>
|
||||
<Button variant="contained" color="primary" component="a" size="large" startIcon={<ListIcon />}>
|
||||
View logs
|
||||
</Button>
|
||||
</Link>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Box } from "@material-ui/core";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import LogsOverview from "../../../components/reqlog/LogsOverview";
|
||||
import Layout, { Page } from "../../../components/Layout";
|
||||
|
@ -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 Layout, { Page } from "../../components/Layout";
|
||||
@ -13,11 +13,9 @@ function Index(): JSX.Element {
|
||||
<Typography variant="h4">Scope</Typography>
|
||||
</Box>
|
||||
<Typography paragraph>
|
||||
Scope rules are used by various modules in Hetty and can influence
|
||||
their behavior. For example: the Proxy logs module can match incoming
|
||||
requests against scope rules and decide its behavior (e.g. log or
|
||||
bypass) based on the outcome of the match. All scope configuration is
|
||||
stored per project.
|
||||
Scope rules are used by various modules in Hetty and can influence their behavior. For example: the Proxy logs
|
||||
module can match incoming requests against scope rules and decide its behavior (e.g. log or bypass) based on
|
||||
the outcome of the match. All scope configuration is stored per project.
|
||||
</Typography>
|
||||
<Box my={4}>
|
||||
<Divider />
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Box, Typography } from "@material-ui/core";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
|
||||
import Layout, { Page } from "../../components/Layout";
|
||||
|
||||
|
@ -8,7 +8,7 @@
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
@ -16,7 +16,8 @@
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
7010
admin/yarn.lock
7010
admin/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -2,105 +2,122 @@ package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"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/db/sqlite"
|
||||
"github.com/dstotijn/hetty/pkg/db/badger"
|
||||
"github.com/dstotijn/hetty/pkg/proj"
|
||||
"github.com/dstotijn/hetty/pkg/proxy"
|
||||
"github.com/dstotijn/hetty/pkg/reqlog"
|
||||
"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 (
|
||||
caCertFile string
|
||||
caKeyFile string
|
||||
projPath string
|
||||
dbPath 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() {
|
||||
flag.StringVar(&caCertFile, "cert", "~/.hetty/hetty_cert.pem", "CA certificate filepath. Creates a new CA certificate is 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(&projPath, "projects", "~/.hetty/projects", "Projects directory path")
|
||||
if err := run(); err != nil {
|
||||
log.Fatalf("[ERROR]: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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(&adminPath, "adminPath", "", "File path to admin build")
|
||||
flag.Parse()
|
||||
|
||||
// Expand `~` in filepaths.
|
||||
caCertFile, err := homedir.Expand(caCertFile)
|
||||
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)
|
||||
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 {
|
||||
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
|
||||
// to disk if no files exist yet.
|
||||
caCert, caKey, err := proxy.LoadOrCreateCA(caKeyFile, caCertFile)
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatalf("[FATAL] Could not create new project service: %v", err)
|
||||
}
|
||||
defer projService.Close()
|
||||
|
||||
scope := scope.New(db, projService)
|
||||
scope := &scope.Scope{}
|
||||
|
||||
reqLogService := reqlog.NewService(reqlog.Config{
|
||||
Scope: scope,
|
||||
ProjectService: projService,
|
||||
Repository: db,
|
||||
Scope: scope,
|
||||
Repository: badger,
|
||||
})
|
||||
|
||||
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)
|
||||
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.UseResponseModifier(reqLogService.ResponseModifier)
|
||||
|
||||
var adminHandler http.Handler
|
||||
if adminPath == "" {
|
||||
// Used for embedding with `rice`.
|
||||
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))
|
||||
fsSub, err := fs.Sub(adminContent, "admin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not prepare subtree file system: %w", err)
|
||||
}
|
||||
|
||||
adminHandler := http.FileServer(http.FS(fsSub))
|
||||
router := mux.NewRouter().SkipClean(true)
|
||||
|
||||
adminRouter := router.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool {
|
||||
hostname, _ := os.Hostname()
|
||||
host, _, _ := net.SplitHostPort(req.Host)
|
||||
@ -109,11 +126,11 @@ func main() {
|
||||
|
||||
// GraphQL server.
|
||||
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{
|
||||
RequestLogService: reqLogService,
|
||||
ProjectService: projService,
|
||||
ScopeService: scope,
|
||||
}})))
|
||||
adminRouter.Path("/api/graphql/").Handler(
|
||||
handler.NewDefaultServer(api.NewExecutableSchema(api.Config{Resolvers: &api.Resolver{
|
||||
RequestLogService: reqLogService,
|
||||
ProjectService: projService,
|
||||
}})))
|
||||
|
||||
// Admin interface.
|
||||
adminRouter.PathPrefix("").Handler(adminHandler)
|
||||
@ -127,9 +144,12 @@ func main() {
|
||||
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()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[FATAL] HTTP server closed: %v", err)
|
||||
if err != nil && errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("http server closed unexpected: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
BIN
docs/src/.vuepress/public/assets/tines-sponsorship-badge.png
Normal file
BIN
docs/src/.vuepress/public/assets/tines-sponsorship-badge.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
@ -24,7 +24,7 @@ Source: [pkg/api/schema.graphql](https://github.com/dstotijn/hetty/blob/master/p
|
||||
|
||||
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
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
## 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.
|
||||
|
||||
### Install pre-built release (recommended)
|
||||
@ -13,14 +13,13 @@ admin interface.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- [Go](https://golang.org/)
|
||||
- [Go 1.16](https://golang.org/)
|
||||
- [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))
|
||||
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/) and embedded in
|
||||
a `.go` file with [go.rice](https://github.com/GeertJohan/go.rice) beforehand.
|
||||
When building from source, the static resources for the admin interface
|
||||
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/). The generated
|
||||
files will be embedded (using the [embed](https://golang.org/pkg/embed/)
|
||||
package) when you use the `build` Makefile target.
|
||||
|
||||
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
|
||||
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%`
|
||||
on Windows).
|
||||
|
||||
@ -60,7 +59,7 @@ $ hetty
|
||||
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.
|
||||
@ -77,9 +76,9 @@ Usage of ./hetty:
|
||||
-adminPath string
|
||||
File path to admin build
|
||||
-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
|
||||
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
|
||||
-projects string
|
||||
Projects directory path (default "~/.hetty/projects")
|
||||
-db string
|
||||
Database directory path (default "~/.hetty/db")
|
||||
```
|
||||
|
@ -17,7 +17,7 @@ features tailored to the needs of the infosec and bug bounty community.
|
||||
## Features
|
||||
|
||||
- Machine-in-the-middle (MITM) HTTP/1.1 proxy with logs
|
||||
- Project based database storage (SQLite)
|
||||
- Project based database storage (BadgerDB)
|
||||
- Scope support
|
||||
- Headless management API using GraphQL
|
||||
- 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 details.
|
||||
:::
|
||||
|
||||
## Sponsors
|
||||
|
||||
[](https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty)
|
||||
|
@ -14,7 +14,7 @@ The available modules:
|
||||
|
||||
## 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
|
||||
targets.
|
||||
|
||||
@ -25,19 +25,15 @@ typically the first thing you do when you start using Hetty.
|
||||
### Creating a new project
|
||||
|
||||
When you open the Hetty admin interface after starting the program, you’ll be prompted
|
||||
on the homepage to create a new project. Give it a name (alphanumeric and space character)
|
||||
and click the create button:
|
||||
on the homepage to “Manage projects”, which leads to the “Projects” page where
|
||||
you can open an existing project or create a new one:
|
||||
|
||||

|
||||
|
||||
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
|
||||
Project database files by default are stored in `$HOME/.hetty/projects` on Linux
|
||||
and macOS, and `%USERPROFILE%/.hetty` on Windows. You can override this path with
|
||||
the `-projects` flag. See: [Usage](/guide/getting-started.md#usage).
|
||||
Projects are stored in a single BadgerDB database, stored in `$HOME/.hetty/db` on Linux
|
||||
and macOS, and `%USERPROFILE%/.hetty/db` on Windows. You can override this path with
|
||||
the `-db` flag. See: [Usage](/guide/getting-started.md#usage).
|
||||
:::
|
||||
|
||||
### Managing projects
|
||||
|
@ -1705,10 +1705,10 @@ bluebird@^3.1.1, bluebird@^3.5.5:
|
||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
|
||||
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
|
||||
|
||||
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0:
|
||||
version "4.11.9"
|
||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828"
|
||||
integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==
|
||||
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9:
|
||||
version "4.12.0"
|
||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
|
||||
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
|
||||
|
||||
bn.js@^5.1.1:
|
||||
version "5.1.3"
|
||||
@ -1793,7 +1793,7 @@ braces@~3.0.2:
|
||||
dependencies:
|
||||
fill-range "^7.0.1"
|
||||
|
||||
brorand@^1.0.1:
|
||||
brorand@^1.0.1, brorand@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
|
||||
integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=
|
||||
@ -3010,17 +3010,17 @@ electron-to-chromium@^1.3.585:
|
||||
integrity sha512-xoeqjMQhgHDZM7FiglJAb2aeOxHZWFruUc3MbAGTgE7GB8rr5fTn1Sdh5THGuQtndU3GuXlu91ZKqRivxoCZ/A==
|
||||
|
||||
elliptic@^6.5.3:
|
||||
version "6.5.3"
|
||||
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6"
|
||||
integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==
|
||||
version "6.5.4"
|
||||
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
|
||||
integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==
|
||||
dependencies:
|
||||
bn.js "^4.4.0"
|
||||
brorand "^1.0.1"
|
||||
bn.js "^4.11.9"
|
||||
brorand "^1.1.0"
|
||||
hash.js "^1.0.0"
|
||||
hmac-drbg "^1.0.0"
|
||||
inherits "^2.0.1"
|
||||
minimalistic-assert "^1.0.0"
|
||||
minimalistic-crypto-utils "^1.0.0"
|
||||
hmac-drbg "^1.0.1"
|
||||
inherits "^2.0.4"
|
||||
minimalistic-assert "^1.0.1"
|
||||
minimalistic-crypto-utils "^1.0.1"
|
||||
|
||||
emoji-regex@^7.0.1:
|
||||
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"
|
||||
integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==
|
||||
|
||||
hmac-drbg@^1.0.0:
|
||||
hmac-drbg@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
|
||||
integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=
|
||||
@ -4124,9 +4124,9 @@ inherits@2.0.3:
|
||||
integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
|
||||
|
||||
ini@^1.3.5, ini@~1.3.0:
|
||||
version "1.3.5"
|
||||
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
|
||||
integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
|
||||
version "1.3.8"
|
||||
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
|
||||
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
|
||||
|
||||
internal-ip@^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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
|
||||
integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=
|
||||
@ -5945,9 +5945,9 @@ pretty-time@^1.1.0:
|
||||
integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==
|
||||
|
||||
prismjs@^1.13.0:
|
||||
version "1.22.0"
|
||||
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.22.0.tgz#73c3400afc58a823dd7eed023f8e1ce9fd8977fa"
|
||||
integrity sha512-lLJ/Wt9yy0AiSYBf212kK3mM5L8ycwlyTlSxHBAneXLR0nzFMlZ5y7riFPF3E33zXOF2IH95xdY5jIyZbM9z/w==
|
||||
version "1.23.0"
|
||||
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33"
|
||||
integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA==
|
||||
optionalDependencies:
|
||||
clipboard "^2.0.0"
|
||||
|
||||
@ -6779,9 +6779,9 @@ sshpk@^1.7.0:
|
||||
tweetnacl "~0.14.0"
|
||||
|
||||
ssri@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8"
|
||||
integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.2.tgz#157939134f20464e7301ddba3e90ffa8f7728ac5"
|
||||
integrity sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==
|
||||
dependencies:
|
||||
figgy-pudding "^3.5.1"
|
||||
|
||||
@ -7825,9 +7825,9 @@ xtend@^4.0.0, xtend@~4.0.1:
|
||||
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
||||
|
||||
y18n@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
|
||||
integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4"
|
||||
integrity sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==
|
||||
|
||||
yallist@^2.1.2:
|
||||
version "2.1.2"
|
||||
|
48
go.mod
48
go.mod
@ -1,17 +1,45 @@
|
||||
module github.com/dstotijn/hetty
|
||||
|
||||
go 1.15
|
||||
go 1.17
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.13.0
|
||||
github.com/GeertJohan/go.rice v1.0.0
|
||||
github.com/Masterminds/squirrel v1.4.0
|
||||
github.com/99designs/gqlgen v0.14.0
|
||||
github.com/dgraph-io/badger/v3 v3.2103.2
|
||||
github.com/google/go-cmp v0.5.6
|
||||
github.com/gorilla/mux v1.7.4
|
||||
github.com/hashicorp/golang-lru v0.5.1 // indirect
|
||||
github.com/jmoiron/sqlx v1.2.0
|
||||
github.com/mattn/go-sqlite3 v1.14.4
|
||||
github.com/matryer/moq v0.2.5
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/mitchellh/mapstructure v1.1.2 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.1.0
|
||||
google.golang.org/appengine v1.6.6 // indirect
|
||||
github.com/oklog/ulid v1.3.1
|
||||
github.com/vektah/gqlparser/v2 v2.2.0
|
||||
)
|
||||
|
||||
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
192
go.sum
@ -1,85 +1,107 @@
|
||||
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
|
||||
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
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/GeertJohan/go.incremental v1.0.0 h1:7AH+pY1XUgQE4Y1HcXYaMqAI0m9yrFqo/jt0CW30vsg=
|
||||
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
|
||||
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/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
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.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
|
||||
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/agnivade/levenshtein v1.1.0 h1:n6qGwyHG61v3ABce1rPVZklEYRT8NFpCMrpZdBUbYGM=
|
||||
github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
|
||||
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/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/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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
|
||||
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.2 h1:dpyM5eCJAtQCBcMCZcT4UBZchuTJgCywerHHgmxfxM8=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M=
|
||||
github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
|
||||
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
|
||||
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/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/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/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
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/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.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
|
||||
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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
|
||||
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
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/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
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/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/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.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-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-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/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 v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
|
||||
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/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
|
||||
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
|
||||
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/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.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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
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/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
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/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.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
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/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/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.1.0/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
|
||||
github.com/vektah/gqlparser/v2 v2.2.0 h1:bAc3slekAAJW6sZTi07aGq0OrfaCjj4jxARAaC7g2EM=
|
||||
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-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
|
||||
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-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-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-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-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-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/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.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
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-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-20200114235610-7ae403b6b589 h1:rjUrONFu4kLchcZTfp3/96bR8bW8dIa8uz3cR5n0cgM=
|
||||
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
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=
|
||||
google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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 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/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.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||
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=
|
||||
|
@ -45,7 +45,7 @@ omit_slice_element_pointers: true
|
||||
models:
|
||||
ID:
|
||||
model:
|
||||
- github.com/99designs/gqlgen/graphql.Int64
|
||||
- github.com/dstotijn/hetty/pkg/api.ULID
|
||||
# Int:
|
||||
# model:
|
||||
# - github.com/99designs/gqlgen/graphql.Int
|
||||
|
@ -80,7 +80,6 @@ type ComplexityRoot struct {
|
||||
Body func(childComplexity int) int
|
||||
Headers func(childComplexity int) int
|
||||
Proto func(childComplexity int) int
|
||||
RequestID func(childComplexity int) int
|
||||
StatusCode func(childComplexity int) int
|
||||
StatusReason func(childComplexity int) int
|
||||
}
|
||||
@ -88,20 +87,22 @@ type ComplexityRoot struct {
|
||||
Mutation struct {
|
||||
ClearHTTPRequestLog func(childComplexity int) int
|
||||
CloseProject func(childComplexity int) int
|
||||
DeleteProject func(childComplexity int, name string) int
|
||||
OpenProject func(childComplexity int, name string) int
|
||||
CreateProject 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
|
||||
SetScope func(childComplexity int, scope []ScopeRuleInput) int
|
||||
}
|
||||
|
||||
Project struct {
|
||||
ID func(childComplexity int) int
|
||||
IsActive func(childComplexity int) int
|
||||
Name func(childComplexity int) int
|
||||
}
|
||||
|
||||
Query struct {
|
||||
ActiveProject func(childComplexity int) int
|
||||
HTTPRequestLog func(childComplexity int, id int64) int
|
||||
HTTPRequestLog func(childComplexity int, id ULID) int
|
||||
HTTPRequestLogFilter func(childComplexity int) int
|
||||
HTTPRequestLogs func(childComplexity int) int
|
||||
Projects func(childComplexity int) int
|
||||
@ -121,15 +122,16 @@ type ComplexityRoot struct {
|
||||
}
|
||||
|
||||
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)
|
||||
DeleteProject(ctx context.Context, name string) (*DeleteProjectResult, error)
|
||||
DeleteProject(ctx context.Context, id ULID) (*DeleteProjectResult, error)
|
||||
ClearHTTPRequestLog(ctx context.Context) (*ClearHTTPRequestLogResult, error)
|
||||
SetScope(ctx context.Context, scope []ScopeRuleInput) ([]ScopeRule, error)
|
||||
SetHTTPRequestLogFilter(ctx context.Context, filter *HTTPRequestLogFilterInput) (*HTTPRequestLogFilter, error)
|
||||
}
|
||||
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)
|
||||
HTTPRequestLogFilter(ctx context.Context) (*HTTPRequestLogFilter, 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
|
||||
|
||||
case "HttpResponseLog.requestId":
|
||||
if e.complexity.HTTPResponseLog.RequestID == nil {
|
||||
break
|
||||
}
|
||||
|
||||
return e.complexity.HTTPResponseLog.RequestID(childComplexity), true
|
||||
|
||||
case "HttpResponseLog.statusCode":
|
||||
if e.complexity.HTTPResponseLog.StatusCode == nil {
|
||||
break
|
||||
@ -313,6 +308,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
|
||||
|
||||
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":
|
||||
if e.complexity.Mutation.DeleteProject == nil {
|
||||
break
|
||||
@ -323,7 +330,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
|
||||
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":
|
||||
if e.complexity.Mutation.OpenProject == nil {
|
||||
@ -335,7 +342,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
|
||||
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":
|
||||
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
|
||||
|
||||
case "Project.id":
|
||||
if e.complexity.Project.ID == nil {
|
||||
break
|
||||
}
|
||||
|
||||
return e.complexity.Project.ID(childComplexity), true
|
||||
|
||||
case "Project.isActive":
|
||||
if e.complexity.Project.IsActive == nil {
|
||||
break
|
||||
@ -392,7 +406,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
|
||||
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":
|
||||
if e.complexity.Query.HTTPRequestLogFilter == nil {
|
||||
@ -533,7 +547,6 @@ var sources = []*ast.Source{
|
||||
}
|
||||
|
||||
type HttpResponseLog {
|
||||
requestId: ID!
|
||||
proto: String!
|
||||
statusCode: Int!
|
||||
statusReason: String!
|
||||
@ -547,6 +560,7 @@ type HttpHeader {
|
||||
}
|
||||
|
||||
type Project {
|
||||
id: ID!
|
||||
name: String!
|
||||
isActive: Boolean!
|
||||
}
|
||||
@ -605,9 +619,10 @@ type Query {
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
openProject(name: String!): Project
|
||||
createProject(name: String!): Project
|
||||
openProject(id: ID!): Project
|
||||
closeProject: CloseProjectResult!
|
||||
deleteProject(name: String!): DeleteProjectResult!
|
||||
deleteProject(id: ID!): DeleteProjectResult!
|
||||
clearHTTPRequestLog: ClearHTTPRequestLogResult!
|
||||
setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]!
|
||||
setHttpRequestLogFilter(
|
||||
@ -637,7 +652,7 @@ var parsedSchema = gqlparser.MustLoadSchema(sources...)
|
||||
|
||||
// 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
|
||||
args := map[string]interface{}{}
|
||||
var arg0 string
|
||||
@ -652,18 +667,33 @@ func (ec *executionContext) field_Mutation_deleteProject_args(ctx context.Contex
|
||||
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
|
||||
args := map[string]interface{}{}
|
||||
var arg0 string
|
||||
if tmp, ok := rawArgs["name"]; ok {
|
||||
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name"))
|
||||
arg0, err = ec.unmarshalNString2string(ctx, tmp)
|
||||
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["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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
var err error
|
||||
args := map[string]interface{}{}
|
||||
var arg0 int64
|
||||
var arg0 ULID
|
||||
if tmp, ok := rawArgs["id"]; ok {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -970,9 +1000,9 @@ func (ec *executionContext) _HttpRequestLog_id(ctx context.Context, field graphq
|
||||
}
|
||||
return graphql.Null
|
||||
}
|
||||
res := resTmp.(int64)
|
||||
res := resTmp.(ULID)
|
||||
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) {
|
||||
@ -1281,41 +1311,6 @@ func (ec *executionContext) _HttpRequestLogFilter_searchExpression(ctx context.C
|
||||
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) {
|
||||
defer func() {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
@ -1513,7 +1547,7 @@ func (ec *executionContext) _Mutation_openProject(ctx context.Context, field gra
|
||||
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().OpenProject(rctx, args["name"].(string))
|
||||
return ec.resolvers.Mutation().OpenProject(rctx, args["id"].(ULID))
|
||||
})
|
||||
if err != nil {
|
||||
ec.Error(ctx, err)
|
||||
@ -1587,7 +1621,7 @@ func (ec *executionContext) _Mutation_deleteProject(ctx context.Context, field g
|
||||
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().DeleteProject(rctx, args["name"].(string))
|
||||
return ec.resolvers.Mutation().DeleteProject(rctx, args["id"].(ULID))
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
@ -1815,7 +1884,7 @@ func (ec *executionContext) _Query_httpRequestLog(ctx context.Context, field gra
|
||||
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.Query().HTTPRequestLog(rctx, args["id"].(int64))
|
||||
return ec.resolvers.Query().HTTPRequestLog(rctx, args["id"].(ULID))
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
defer func() {
|
||||
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) {
|
||||
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 {
|
||||
switch k {
|
||||
@ -3346,7 +3453,10 @@ func (ec *executionContext) unmarshalInputHttpRequestLogFilterInput(ctx context.
|
||||
|
||||
func (ec *executionContext) unmarshalInputScopeHeaderInput(ctx context.Context, obj interface{}) (ScopeHeaderInput, error) {
|
||||
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 {
|
||||
switch k {
|
||||
@ -3374,7 +3484,10 @@ func (ec *executionContext) unmarshalInputScopeHeaderInput(ctx context.Context,
|
||||
|
||||
func (ec *executionContext) unmarshalInputScopeRuleInput(ctx context.Context, obj interface{}) (ScopeRuleInput, error) {
|
||||
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 {
|
||||
switch k {
|
||||
@ -3625,11 +3738,6 @@ func (ec *executionContext) _HttpResponseLog(ctx context.Context, sel ast.Select
|
||||
switch field.Name {
|
||||
case "__typename":
|
||||
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":
|
||||
out.Values[i] = ec._HttpResponseLog_proto(ctx, field, obj)
|
||||
if out.Values[i] == graphql.Null {
|
||||
@ -3678,6 +3786,8 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
|
||||
switch field.Name {
|
||||
case "__typename":
|
||||
out.Values[i] = graphql.MarshalString("Mutation")
|
||||
case "createProject":
|
||||
out.Values[i] = ec._Mutation_createProject(ctx, field)
|
||||
case "openProject":
|
||||
out.Values[i] = ec._Mutation_openProject(ctx, field)
|
||||
case "closeProject":
|
||||
@ -3724,6 +3834,11 @@ func (ec *executionContext) _Project(ctx context.Context, sel ast.SelectionSet,
|
||||
switch field.Name {
|
||||
case "__typename":
|
||||
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":
|
||||
out.Values[i] = ec._Project_name(ctx, field, obj)
|
||||
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 {
|
||||
invalids++
|
||||
}
|
||||
case "isRepeatable":
|
||||
out.Values[i] = ec.___Directive_isRepeatable(ctx, field, obj)
|
||||
if out.Values[i] == graphql.Null {
|
||||
invalids++
|
||||
}
|
||||
default:
|
||||
panic("unknown field " + strconv.Quote(field.Name))
|
||||
}
|
||||
@ -4244,6 +4364,13 @@ func (ec *executionContext) marshalNHttpHeader2ᚕgithubᚗcomᚋdstotijnᚋhett
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -4295,22 +4422,24 @@ func (ec *executionContext) marshalNHttpRequestLog2ᚕgithubᚗcomᚋdstotijnᚋ
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (ec *executionContext) unmarshalNID2int64(ctx context.Context, v interface{}) (int64, error) {
|
||||
res, err := graphql.UnmarshalInt64(v)
|
||||
func (ec *executionContext) unmarshalNID2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐULID(ctx context.Context, v interface{}) (ULID, error) {
|
||||
var res ULID
|
||||
err := res.UnmarshalGQL(v)
|
||||
return res, graphql.ErrorOnPath(ctx, err)
|
||||
}
|
||||
|
||||
func (ec *executionContext) marshalNID2int64(ctx context.Context, sel ast.SelectionSet, v int64) graphql.Marshaler {
|
||||
res := graphql.MarshalInt64(v)
|
||||
if res == graphql.Null {
|
||||
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
|
||||
ec.Errorf(ctx, "must not be null")
|
||||
}
|
||||
}
|
||||
return res
|
||||
func (ec *executionContext) marshalNID2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐULID(ctx context.Context, sel ast.SelectionSet, v ULID) graphql.Marshaler {
|
||||
return v
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -4407,6 +4543,13 @@ func (ec *executionContext) marshalNScopeRule2ᚕgithubᚗcomᚋdstotijnᚋhetty
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -4504,6 +4647,13 @@ func (ec *executionContext) marshalN__Directive2ᚕgithubᚗcomᚋ99designsᚋgq
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -4577,6 +4727,13 @@ func (ec *executionContext) marshalN__DirectiveLocation2ᚕstringᚄ(ctx context
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -4626,6 +4783,13 @@ func (ec *executionContext) marshalN__InputValue2ᚕgithubᚗcomᚋ99designsᚋg
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -4667,6 +4831,13 @@ func (ec *executionContext) marshalN__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgen
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -4846,6 +5017,13 @@ func (ec *executionContext) marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgq
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -4886,6 +5064,13 @@ func (ec *executionContext) marshalO__Field2ᚕgithubᚗcomᚋ99designsᚋgqlgen
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -4926,6 +5111,13 @@ func (ec *executionContext) marshalO__InputValue2ᚕgithubᚗcomᚋ99designsᚋg
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -4973,6 +5165,13 @@ func (ec *executionContext) marshalO__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgen
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, e := range ret {
|
||||
if e == graphql.Null {
|
||||
return graphql.Null
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
|
31
pkg/api/models.go
Normal file
31
pkg/api/models.go
Normal 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()))
|
||||
}
|
@ -27,7 +27,7 @@ type HTTPHeader struct {
|
||||
}
|
||||
|
||||
type HTTPRequestLog struct {
|
||||
ID int64 `json:"id"`
|
||||
ID ULID `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Method HTTPMethod `json:"method"`
|
||||
Proto string `json:"proto"`
|
||||
@ -48,7 +48,6 @@ type HTTPRequestLogFilterInput struct {
|
||||
}
|
||||
|
||||
type HTTPResponseLog struct {
|
||||
RequestID int64 `json:"requestId"`
|
||||
Proto string `json:"proto"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
StatusReason string `json:"statusReason"`
|
||||
@ -57,6 +56,7 @@ type HTTPResponseLog struct {
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID ULID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"isActive"`
|
||||
}
|
||||
|
@ -4,44 +4,42 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"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/reqlog"
|
||||
"github.com/dstotijn/hetty/pkg/scope"
|
||||
"github.com/dstotijn/hetty/pkg/search"
|
||||
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||
)
|
||||
|
||||
type Resolver struct {
|
||||
ProjectService proj.Service
|
||||
RequestLogService *reqlog.Service
|
||||
ProjectService *proj.Service
|
||||
ScopeService *scope.Scope
|
||||
}
|
||||
|
||||
type queryResolver struct{ *Resolver }
|
||||
type mutationResolver struct{ *Resolver }
|
||||
type (
|
||||
queryResolver struct{ *Resolver }
|
||||
mutationResolver struct{ *Resolver }
|
||||
)
|
||||
|
||||
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
|
||||
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
||||
|
||||
func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error) {
|
||||
reqs, err := r.RequestLogService.FindRequests(ctx)
|
||||
if err == proj.ErrNoProject {
|
||||
return nil, &gqlerror.Error{
|
||||
Path: graphql.GetPath(ctx),
|
||||
Message: "No active project.",
|
||||
Extensions: map[string]interface{}{
|
||||
"code": "no_active_project",
|
||||
},
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not query repository for requests: %v", err)
|
||||
if errors.Is(err, proj.ErrNoProject) {
|
||||
return nil, noActiveProjectErr(ctx)
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("could not query repository for requests: %w", err)
|
||||
}
|
||||
|
||||
logs := make([]HTTPRequestLog, len(reqs))
|
||||
|
||||
for i, req := range reqs {
|
||||
@ -49,20 +47,21 @@ func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logs[i] = req
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) HTTPRequestLog(ctx context.Context, id int64) (*HTTPRequestLog, error) {
|
||||
log, err := r.RequestLogService.FindRequestLogByID(ctx, id)
|
||||
if err == reqlog.ErrRequestNotFound {
|
||||
func (r *queryResolver) HTTPRequestLog(ctx context.Context, id ULID) (*HTTPRequestLog, error) {
|
||||
log, err := r.RequestLogService.FindRequestLogByID(ctx, ulid.ULID(id))
|
||||
if errors.Is(err, reqlog.ErrRequestNotFound) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -71,31 +70,32 @@ func (r *queryResolver) HTTPRequestLog(ctx context.Context, id int64) (*HTTPRequ
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func parseRequestLog(req reqlog.Request) (HTTPRequestLog, error) {
|
||||
method := HTTPMethod(req.Request.Method)
|
||||
func parseRequestLog(reqLog reqlog.RequestLog) (HTTPRequestLog, error) {
|
||||
method := HTTPMethod(reqLog.Method)
|
||||
if method != "" && !method.IsValid() {
|
||||
return HTTPRequestLog{}, fmt.Errorf("request has invalid method: %v", method)
|
||||
}
|
||||
|
||||
log := HTTPRequestLog{
|
||||
ID: req.ID,
|
||||
Proto: req.Request.Proto,
|
||||
ID: ULID(reqLog.ID),
|
||||
Proto: reqLog.Proto,
|
||||
Method: method,
|
||||
Timestamp: req.Timestamp,
|
||||
Timestamp: ulid.Time(reqLog.ID.Time()),
|
||||
}
|
||||
|
||||
if req.Request.URL != nil {
|
||||
log.URL = req.Request.URL.String()
|
||||
if reqLog.URL != nil {
|
||||
log.URL = reqLog.URL.String()
|
||||
}
|
||||
|
||||
if len(req.Body) > 0 {
|
||||
reqBody := string(req.Body)
|
||||
log.Body = &reqBody
|
||||
if len(reqLog.Body) > 0 {
|
||||
bodyStr := string(reqLog.Body)
|
||||
log.Body = &bodyStr
|
||||
}
|
||||
|
||||
if req.Request.Header != nil {
|
||||
if reqLog.Header != nil {
|
||||
log.Headers = make([]HTTPHeader, 0)
|
||||
for key, values := range req.Request.Header {
|
||||
|
||||
for key, values := range reqLog.Header {
|
||||
for _, value := range values {
|
||||
log.Headers = append(log.Headers, HTTPHeader{
|
||||
Key: key,
|
||||
@ -105,23 +105,26 @@ func parseRequestLog(req reqlog.Request) (HTTPRequestLog, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if req.Response != nil {
|
||||
if reqLog.Response != nil {
|
||||
log.Response = &HTTPResponseLog{
|
||||
RequestID: req.Response.RequestID,
|
||||
Proto: req.Response.Response.Proto,
|
||||
StatusCode: req.Response.Response.StatusCode,
|
||||
Proto: reqLog.Response.Proto,
|
||||
StatusCode: reqLog.Response.StatusCode,
|
||||
}
|
||||
statusReasonSubs := strings.SplitN(req.Response.Response.Status, " ", 2)
|
||||
statusReasonSubs := strings.SplitN(reqLog.Response.Status, " ", 2)
|
||||
|
||||
if len(statusReasonSubs) == 2 {
|
||||
log.Response.StatusReason = statusReasonSubs[1]
|
||||
}
|
||||
if len(req.Response.Body) > 0 {
|
||||
resBody := string(req.Response.Body)
|
||||
log.Response.Body = &resBody
|
||||
|
||||
if len(reqLog.Response.Body) > 0 {
|
||||
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)
|
||||
for key, values := range req.Response.Response.Header {
|
||||
|
||||
for key, values := range reqLog.Response.Header {
|
||||
for _, value := range values {
|
||||
log.Response.Headers = append(log.Response.Headers, HTTPHeader{
|
||||
Key: key,
|
||||
@ -135,46 +138,63 @@ func parseRequestLog(req reqlog.Request) (HTTPRequestLog, error) {
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) OpenProject(ctx context.Context, name string) (*Project, error) {
|
||||
p, err := r.ProjectService.Open(ctx, name)
|
||||
if err == proj.ErrInvalidName {
|
||||
func (r *mutationResolver) CreateProject(ctx context.Context, name string) (*Project, error) {
|
||||
p, err := r.ProjectService.CreateProject(ctx, name)
|
||||
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)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open project: %v", err)
|
||||
}
|
||||
|
||||
return &Project{
|
||||
ID: ULID(p.ID),
|
||||
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
|
||||
}
|
||||
|
||||
func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) {
|
||||
p, err := r.ProjectService.ActiveProject()
|
||||
if err == proj.ErrNoProject {
|
||||
p, err := r.ProjectService.ActiveProject(ctx)
|
||||
if errors.Is(err, proj.ErrNoProject) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open project: %v", err)
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("could not open project: %w", err)
|
||||
}
|
||||
|
||||
return &Project{
|
||||
ID: ULID(p.ID),
|
||||
Name: p.Name,
|
||||
IsActive: p.IsActive,
|
||||
IsActive: r.ProjectService.IsProjectActive(p.ID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) {
|
||||
p, err := r.ProjectService.Projects()
|
||||
p, err := r.ProjectService.Projects(ctx)
|
||||
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))
|
||||
for i, proj := range p {
|
||||
projects[i] = Project{
|
||||
ID: ULID(proj.ID),
|
||||
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) {
|
||||
rules := r.ScopeService.Rules()
|
||||
rules := r.ProjectService.Scope().Rules()
|
||||
return scopeToScopeRules(rules), nil
|
||||
}
|
||||
|
||||
@ -190,55 +210,73 @@ func regexpToStringPtr(r *regexp.Regexp) *string {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s := r.String()
|
||||
|
||||
return &s
|
||||
}
|
||||
|
||||
func (r *mutationResolver) CloseProject(ctx context.Context) (*CloseProjectResult, error) {
|
||||
if err := r.ProjectService.Close(); err != nil {
|
||||
return nil, fmt.Errorf("could not close project: %v", err)
|
||||
if err := r.ProjectService.CloseProject(); err != nil {
|
||||
return nil, fmt.Errorf("could not close project: %w", err)
|
||||
}
|
||||
|
||||
return &CloseProjectResult{true}, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) DeleteProject(ctx context.Context, name string) (*DeleteProjectResult, error) {
|
||||
if err := r.ProjectService.Delete(name); err != nil {
|
||||
return nil, fmt.Errorf("could not delete project: %v", err)
|
||||
func (r *mutationResolver) DeleteProject(ctx context.Context, id ULID) (*DeleteProjectResult, error) {
|
||||
if err := r.ProjectService.DeleteProject(ctx, ulid.ULID(id)); err != nil {
|
||||
return nil, fmt.Errorf("could not delete project: %w", err)
|
||||
}
|
||||
|
||||
return &DeleteProjectResult{
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) ClearHTTPRequestLog(ctx context.Context) (*ClearHTTPRequestLogResult, error) {
|
||||
if err := r.RequestLogService.ClearRequests(ctx); err != nil {
|
||||
return nil, fmt.Errorf("could not clear request log: %v", err)
|
||||
project, err := r.ProjectService.ActiveProject(ctx)
|
||||
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
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SetScope(ctx context.Context, input []ScopeRuleInput) ([]ScopeRule, error) {
|
||||
rules := make([]scope.Rule, len(input))
|
||||
|
||||
for i, rule := range input {
|
||||
u, err := stringPtrToRegexp(rule.URL)
|
||||
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
|
||||
|
||||
if rule.Header != nil {
|
||||
headerKey, err = stringPtrToRegexp(rule.Header.Key)
|
||||
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)
|
||||
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)
|
||||
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{
|
||||
URL: u,
|
||||
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 {
|
||||
return nil, fmt.Errorf("could not set scope: %v", err)
|
||||
err := r.ProjectService.SetScopeRules(ctx, rules)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not set scope rules: %w", err)
|
||||
}
|
||||
|
||||
return scopeToScopeRules(rules), nil
|
||||
@ -266,10 +305,14 @@ func (r *mutationResolver) SetHTTPRequestLogFilter(
|
||||
) (*HTTPRequestLogFilter, error) {
|
||||
filter, err := findRequestsFilterFromInput(input)
|
||||
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
|
||||
@ -279,6 +322,7 @@ func stringPtrToRegexp(s *string) (*regexp.Regexp, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return regexp.Compile(*s)
|
||||
}
|
||||
|
||||
@ -292,8 +336,10 @@ func scopeToScopeRules(rules []scope.Rule) []ScopeRule {
|
||||
Value: regexpToStringPtr(rule.Header.Value),
|
||||
}
|
||||
}
|
||||
|
||||
scopeRules[i].Body = regexpToStringPtr(rule.Body)
|
||||
}
|
||||
|
||||
return scopeRules
|
||||
}
|
||||
|
||||
@ -301,15 +347,17 @@ func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (filter reqlo
|
||||
if input == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if input.OnlyInScope != nil {
|
||||
filter.OnlyInScope = *input.OnlyInScope
|
||||
}
|
||||
|
||||
if input.SearchExpression != nil && *input.SearchExpression != "" {
|
||||
expr, err := search.ParseQuery(*input.SearchExpression)
|
||||
if err != nil {
|
||||
return reqlog.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %v", err)
|
||||
return reqlog.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %w", err)
|
||||
}
|
||||
filter.RawSearchExpr = *input.SearchExpression
|
||||
|
||||
filter.SearchExpr = expr
|
||||
}
|
||||
|
||||
@ -321,13 +369,25 @@ func findReqFilterToHTTPReqLogFilter(findReqFilter reqlog.FindRequestsFilter) *H
|
||||
if findReqFilter == empty {
|
||||
return nil
|
||||
}
|
||||
|
||||
httpReqLogFilter := &HTTPRequestLogFilter{
|
||||
OnlyInScope: findReqFilter.OnlyInScope,
|
||||
}
|
||||
|
||||
if findReqFilter.RawSearchExpr != "" {
|
||||
httpReqLogFilter.SearchExpression = &findReqFilter.RawSearchExpr
|
||||
if findReqFilter.SearchExpr != nil {
|
||||
searchExpr := findReqFilter.SearchExpr.String()
|
||||
httpReqLogFilter.SearchExpression = &searchExpr
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,6 @@ type HttpRequestLog {
|
||||
}
|
||||
|
||||
type HttpResponseLog {
|
||||
requestId: ID!
|
||||
proto: String!
|
||||
statusCode: Int!
|
||||
statusReason: String!
|
||||
@ -24,6 +23,7 @@ type HttpHeader {
|
||||
}
|
||||
|
||||
type Project {
|
||||
id: ID!
|
||||
name: String!
|
||||
isActive: Boolean!
|
||||
}
|
||||
@ -82,9 +82,10 @@ type Query {
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
openProject(name: String!): Project
|
||||
createProject(name: String!): Project
|
||||
openProject(id: ID!): Project
|
||||
closeProject: CloseProjectResult!
|
||||
deleteProject(name: String!): DeleteProjectResult!
|
||||
deleteProject(id: ID!): DeleteProjectResult!
|
||||
clearHTTPRequestLog: ClearHTTPRequestLogResult!
|
||||
setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]!
|
||||
setHttpRequestLogFilter(
|
||||
|
53
pkg/db/badger/badger.go
Normal file
53
pkg/db/badger/badger.go
Normal 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
110
pkg/db/badger/proj.go
Normal 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
284
pkg/db/badger/proj_test.go
Normal 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
251
pkg/db/badger/reqlog.go
Normal 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
|
||||
}
|
121
pkg/db/badger/reqlog_test.go
Normal file
121
pkg/db/badger/reqlog_test.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
266
pkg/proj/proj.go
266
pkg/proj/proj.go
@ -5,148 +5,268 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"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
|
||||
type OnProjectCloseFn func(name string) error
|
||||
//nolint:gosec
|
||||
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.
|
||||
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
|
||||
activeProject string
|
||||
reqLogSvc *reqlog.Service
|
||||
scope *scope.Scope
|
||||
activeProjectID ulid.ULID
|
||||
onProjectOpenFns []OnProjectOpenFn
|
||||
onProjectCloseFns []OnProjectCloseFn
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID ulid.ULID
|
||||
Name string
|
||||
IsActive bool
|
||||
Settings Settings
|
||||
|
||||
isActive bool
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
ReqLogBypassOutOfScope bool
|
||||
ReqLogOnlyFindInScope bool
|
||||
ScopeRules []scope.Rule
|
||||
SearchExpr search.Expression
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNoProject = errors.New("proj: no open project")
|
||||
ErrNoSettings = errors.New("proj: settings not found")
|
||||
ErrInvalidName = errors.New("proj: invalid name, must be alphanumeric or whitespace chars")
|
||||
ErrProjectNotFound = errors.New("proj: project not found")
|
||||
ErrNoProject = errors.New("proj: no open project")
|
||||
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]+$`)
|
||||
|
||||
type Config struct {
|
||||
Repository Repository
|
||||
ReqLogService *reqlog.Service
|
||||
Scope *scope.Scope
|
||||
}
|
||||
|
||||
// NewService returns a new Service.
|
||||
func NewService(repo Repository) (*Service, error) {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
func NewService(cfg Config) (Service, error) {
|
||||
return &service{
|
||||
repo: cfg.Repository,
|
||||
reqLogSvc: cfg.ReqLogService,
|
||||
scope: cfg.Scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close closes the currently open project database (if there is one).
|
||||
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) {
|
||||
func (svc *service) CreateProject(ctx context.Context, name string) (Project, error) {
|
||||
if !nameRegexp.MatchString(name) {
|
||||
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()
|
||||
defer svc.mu.Unlock()
|
||||
|
||||
if err := svc.repo.Close(); err != nil {
|
||||
return Project{}, fmt.Errorf("proj: could not close previously open database: %v", err)
|
||||
if svc.activeProjectID.Compare(ulid.ULID{}) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := svc.repo.OpenProject(name); err != nil {
|
||||
return Project{}, fmt.Errorf("proj: could not open database: %v", err)
|
||||
}
|
||||
closedProjectID := svc.activeProjectID
|
||||
|
||||
svc.activeProject = name
|
||||
svc.emitProjectOpened()
|
||||
svc.activeProjectID = ulid.ULID{}
|
||||
svc.reqLogSvc.ActiveProjectID = ulid.ULID{}
|
||||
svc.reqLogSvc.BypassOutOfScopeRequests = false
|
||||
svc.reqLogSvc.FindReqsFilter = reqlog.FindRequestsFilter{}
|
||||
svc.scope.SetRules(nil)
|
||||
|
||||
return Project{
|
||||
Name: name,
|
||||
IsActive: true,
|
||||
}, nil
|
||||
svc.emitProjectClosed(closedProjectID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) ActiveProject() (Project, error) {
|
||||
activeProject := svc.activeProject
|
||||
if activeProject == "" {
|
||||
// DeleteProject removes a project from the repository.
|
||||
func (svc *service) DeleteProject(ctx context.Context, projectID ulid.ULID) error {
|
||||
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{
|
||||
Name: activeProject,
|
||||
}, nil
|
||||
project, err := svc.repo.FindProjectByID(ctx, activeProjectID)
|
||||
if err != 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) {
|
||||
projects, err := svc.repo.Projects()
|
||||
func (svc *service) Projects(ctx context.Context) ([]Project, error) {
|
||||
projects, err := svc.repo.Projects(ctx)
|
||||
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
|
||||
}
|
||||
|
||||
func (svc *Service) OnProjectOpen(fn OnProjectOpenFn) {
|
||||
func (svc *service) Scope() *scope.Scope {
|
||||
return svc.scope
|
||||
}
|
||||
|
||||
func (svc *service) OnProjectOpen(fn OnProjectOpenFn) {
|
||||
svc.mu.Lock()
|
||||
defer svc.mu.Unlock()
|
||||
|
||||
svc.onProjectOpenFns = append(svc.onProjectOpenFns, fn)
|
||||
}
|
||||
|
||||
func (svc *Service) OnProjectClose(fn OnProjectCloseFn) {
|
||||
func (svc *service) OnProjectClose(fn OnProjectCloseFn) {
|
||||
svc.mu.Lock()
|
||||
defer svc.mu.Unlock()
|
||||
|
||||
svc.onProjectCloseFns = append(svc.onProjectCloseFns, fn)
|
||||
}
|
||||
|
||||
func (svc *Service) emitProjectOpened() {
|
||||
func (svc *service) emitProjectOpened() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *Service) emitProjectClosed(name string) {
|
||||
func (svc *service) emitProjectClosed(projectID ulid.ULID) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -2,13 +2,14 @@ package proj
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
UpsertSettings(ctx context.Context, module string, settings interface{}) error
|
||||
FindSettingsByModule(ctx context.Context, module string, settings interface{}) error
|
||||
OpenProject(name string) error
|
||||
DeleteProject(name string) error
|
||||
Projects() ([]Project, error)
|
||||
FindProjectByID(ctx context.Context, id ulid.ULID) (Project, error)
|
||||
UpsertProject(ctx context.Context, project Project) error
|
||||
DeleteProject(ctx context.Context, id ulid.ULID) error
|
||||
Projects(ctx context.Context) ([]Project, error)
|
||||
Close() error
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ import (
|
||||
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
|
||||
// capable of MITM
|
||||
// capable of MITM.
|
||||
type CertConfig struct {
|
||||
ca *x509.Certificate
|
||||
caPriv crypto.PrivateKey
|
||||
@ -40,6 +40,7 @@ func NewCertConfig(ca *x509.Certificate, caPrivKey crypto.PrivateKey) (*CertConf
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pub := priv.Public()
|
||||
|
||||
// Subject Key Identifier support for end entity certificate.
|
||||
@ -48,6 +49,7 @@ func NewCertConfig(ca *x509.Certificate, caPrivKey crypto.PrivateKey) (*CertConf
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h := sha1.New()
|
||||
h.Write(pkixPubKey)
|
||||
keyID := h.Sum(nil)
|
||||
@ -67,58 +69,69 @@ func LoadOrCreateCA(caKeyFile, caCertFile string) (*x509.Certificate, *rsa.Priva
|
||||
if err == nil {
|
||||
caCert, err := x509.ParseCertificate(tlsCA.Certificate[0])
|
||||
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)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("proxy: private key is not RSA")
|
||||
}
|
||||
|
||||
return caCert, caKey, nil
|
||||
}
|
||||
|
||||
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.
|
||||
keyDir, _ := filepath.Split(caKeyFile)
|
||||
if keyDir != "" {
|
||||
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)
|
||||
if keyDir != "" {
|
||||
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.
|
||||
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 {
|
||||
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.
|
||||
certOut, err := os.Create(caCertFile)
|
||||
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)
|
||||
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.
|
||||
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)
|
||||
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 {
|
||||
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
|
||||
@ -130,6 +143,7 @@ func NewCA(name, organization string, validity time.Duration) (*x509.Certificate
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pub := priv.Public()
|
||||
|
||||
// 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 {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
h := sha1.New()
|
||||
h.Write(pkixpub)
|
||||
keyID := h.Sum(nil)
|
||||
@ -187,8 +202,10 @@ func (c *CertConfig) TLSConfig() *tls.Config {
|
||||
if clientHello.ServerName == "" {
|
||||
return nil, errors.New("missing server name (SNI)")
|
||||
}
|
||||
|
||||
return c.cert(clientHello.ServerName)
|
||||
},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
NextProtos: []string{"http/1.1"},
|
||||
}
|
||||
}
|
||||
|
@ -5,18 +5,17 @@ import (
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
|
||||
"github.com/dstotijn/hetty/pkg/scope"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const ReqIDKey contextKey = 0
|
||||
const ReqLogIDKey contextKey = 0
|
||||
|
||||
// Proxy implements http.Handler and offers MITM behaviour for modifying
|
||||
// HTTP requests and responses.
|
||||
@ -27,8 +26,6 @@ type Proxy struct {
|
||||
// TODO: Add mutex for modifier funcs.
|
||||
reqModifiers []RequestModifyMiddleware
|
||||
resModifiers []ResponseModifyMiddleware
|
||||
|
||||
scope *scope.Scope
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if r.Method == http.MethodConnect {
|
||||
p.handleConnect(w, r)
|
||||
p.handleConnect(w)
|
||||
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.
|
||||
// During the TLS handshake with the client, we use the proxy's CA config to
|
||||
// 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)
|
||||
if !ok {
|
||||
log.Printf("[ERROR] handleConnect: ResponseWriter is not a http.Hijacker (type: %T)", w)
|
||||
writeError(w, r, http.StatusServiceUnavailable)
|
||||
writeError(w, http.StatusServiceUnavailable)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@ -116,7 +114,8 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||
clientConn, _, err := hj.Hijack()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Hijacking client connection failed: %v", err)
|
||||
writeError(w, r, http.StatusServiceUnavailable)
|
||||
writeError(w, http.StatusServiceUnavailable)
|
||||
|
||||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
clientConnNotify := ConnNotify{clientConn, make(chan struct{})}
|
||||
|
||||
clientConnNotify := ConnNotify{clientConn, make(chan struct{})}
|
||||
l := &OnceAcceptListener{clientConnNotify.Conn}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
<-clientConnNotify.closed
|
||||
}
|
||||
|
||||
@ -144,20 +144,22 @@ func (p *Proxy) clientTLSConn(conn net.Conn) (*tls.Conn, error) {
|
||||
tlsConn := tls.Server(conn, tlsConfig)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
tlsConn.Close()
|
||||
return nil, fmt.Errorf("handshake error: %v", err)
|
||||
return nil, fmt.Errorf("handshake error: %w", err)
|
||||
}
|
||||
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
func errorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if err == context.Canceled {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[ERROR]: Proxy error: %v", err)
|
||||
|
||||
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)
|
||||
}
|
||||
|
@ -2,22 +2,16 @@ package reqlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid"
|
||||
|
||||
"github.com/dstotijn/hetty/pkg/scope"
|
||||
)
|
||||
|
||||
type RepositoryProvider interface {
|
||||
Repository() Repository
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
FindRequestLogs(ctx context.Context, filter FindRequestsFilter, scope *scope.Scope) ([]Request, error)
|
||||
FindRequestLogByID(ctx context.Context, id int64) (Request, error)
|
||||
AddRequestLog(ctx context.Context, req http.Request, body []byte, timestamp time.Time) (*Request, error)
|
||||
AddResponseLog(ctx context.Context, reqID int64, res http.Response, body []byte, timestamp time.Time) (*Response, error)
|
||||
ClearRequestLogs(ctx context.Context) error
|
||||
UpsertSettings(ctx context.Context, module string, settings interface{}) error
|
||||
FindSettingsByModule(ctx context.Context, module string, settings interface{}) error
|
||||
FindRequestLogs(ctx context.Context, filter FindRequestsFilter, scope *scope.Scope) ([]RequestLog, error)
|
||||
FindRequestLogByID(ctx context.Context, id ulid.ULID) (RequestLog, error)
|
||||
StoreRequestLog(ctx context.Context, reqLog RequestLog) error
|
||||
StoreResponseLog(ctx context.Context, reqLogID ulid.ULID, resLog ResponseLog) error
|
||||
ClearRequestLogs(ctx context.Context, projectID ulid.ULID) error
|
||||
}
|
||||
|
291
pkg/reqlog/repo_mock_test.go
Normal file
291
pkg/reqlog/repo_mock_test.go
Normal 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
|
||||
}
|
@ -4,15 +4,18 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/dstotijn/hetty/pkg/proj"
|
||||
"github.com/oklog/ulid"
|
||||
|
||||
"github.com/dstotijn/hetty/pkg/proxy"
|
||||
"github.com/dstotijn/hetty/pkg/scope"
|
||||
"github.com/dstotijn/hetty/pkg/search"
|
||||
@ -22,166 +25,169 @@ type contextKey int
|
||||
|
||||
const LogBypassedKey contextKey = 0
|
||||
|
||||
const moduleName = "reqlog"
|
||||
|
||||
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 {
|
||||
ID int64
|
||||
Request http.Request
|
||||
Body []byte
|
||||
Timestamp time.Time
|
||||
Response *Response
|
||||
//nolint:gosec
|
||||
var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
type RequestLog struct {
|
||||
ID ulid.ULID
|
||||
ProjectID ulid.ULID
|
||||
|
||||
URL *url.URL
|
||||
Method string
|
||||
Proto string
|
||||
Header http.Header
|
||||
Body []byte
|
||||
|
||||
Response *ResponseLog
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ID int64
|
||||
RequestID int64
|
||||
Response http.Response
|
||||
Body []byte
|
||||
Timestamp time.Time
|
||||
type ResponseLog struct {
|
||||
Proto string
|
||||
StatusCode int
|
||||
Status string
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
BypassOutOfScopeRequests bool
|
||||
FindReqsFilter FindRequestsFilter
|
||||
ActiveProjectID ulid.ULID
|
||||
|
||||
scope *scope.Scope
|
||||
repo Repository
|
||||
}
|
||||
|
||||
type FindRequestsFilter struct {
|
||||
OnlyInScope bool
|
||||
SearchExpr search.Expression `json:"-"`
|
||||
RawSearchExpr string
|
||||
ProjectID ulid.ULID
|
||||
OnlyInScope bool
|
||||
SearchExpr search.Expression
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Scope *scope.Scope
|
||||
Repository Repository
|
||||
ProjectService *proj.Service
|
||||
BypassOutOfScopeRequests bool
|
||||
Scope *scope.Scope
|
||||
Repository Repository
|
||||
}
|
||||
|
||||
func NewService(cfg Config) *Service {
|
||||
svc := &Service{
|
||||
scope: cfg.Scope,
|
||||
repo: cfg.Repository,
|
||||
BypassOutOfScopeRequests: cfg.BypassOutOfScopeRequests,
|
||||
return &Service{
|
||||
repo: cfg.Repository,
|
||||
scope: cfg.Scope,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (svc *Service) SetRequestLogFilter(ctx context.Context, filter FindRequestsFilter) error {
|
||||
svc.FindReqsFilter = filter
|
||||
return svc.repo.UpsertSettings(ctx, "reqlog", svc)
|
||||
func (svc *Service) ClearRequests(ctx context.Context, projectID ulid.ULID) error {
|
||||
return svc.repo.ClearRequestLogs(ctx, projectID)
|
||||
}
|
||||
|
||||
func (svc *Service) ClearRequests(ctx context.Context) 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) {
|
||||
func (svc *Service) storeResponse(ctx context.Context, reqLogID ulid.ULID, res *http.Response) error {
|
||||
if res.Header.Get("Content-Encoding") == "gzip" {
|
||||
gzipReader, err := gzip.NewReader(bytes.NewBuffer(body))
|
||||
gzipReader, err := gzip.NewReader(res.Body)
|
||||
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()
|
||||
body, err = ioutil.ReadAll(gzipReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reqlog: could not read gzipped response body: %v", err)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
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 {
|
||||
return func(req *http.Request) {
|
||||
now := time.Now()
|
||||
next(req)
|
||||
|
||||
clone := req.Clone(req.Context())
|
||||
|
||||
var body []byte
|
||||
|
||||
if req.Body != nil {
|
||||
// TODO: Use io.LimitReader.
|
||||
var err error
|
||||
|
||||
body, err = ioutil.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Could not read request body for logging: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
// doens't match any rules of the scope.
|
||||
// doesn't match any scope rules.
|
||||
if svc.BypassOutOfScopeRequests && !svc.scope.Match(clone, body) {
|
||||
ctx := context.WithValue(req.Context(), LogBypassedKey, true)
|
||||
*req = *req.WithContext(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reqLog, err := svc.addRequest(req.Context(), *clone, body, now)
|
||||
if err == proj.ErrNoProject {
|
||||
ctx := context.WithValue(req.Context(), LogBypassedKey, true)
|
||||
*req = *req.WithContext(ctx)
|
||||
return
|
||||
reqLog := RequestLog{
|
||||
ID: ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy),
|
||||
ProjectID: svc.ActiveProjectID,
|
||||
Method: clone.Method,
|
||||
URL: clone.URL,
|
||||
Proto: clone.Proto,
|
||||
Header: clone.Header,
|
||||
Body: body,
|
||||
}
|
||||
|
||||
err := svc.repo.StoreRequestLog(req.Context(), reqLog)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Could not store request log: %v", err)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), proxy.ReqIDKey, reqLog.ID)
|
||||
|
||||
ctx := context.WithValue(req.Context(), proxy.ReqLogIDKey, reqLog.ID)
|
||||
*req = *req.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.ResponseModifyFunc {
|
||||
return func(res *http.Response) error {
|
||||
now := time.Now()
|
||||
if err := next(res); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -190,8 +196,8 @@ func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon
|
||||
return nil
|
||||
}
|
||||
|
||||
reqID, _ := res.Request.Context().Value(proxy.ReqIDKey).(int64)
|
||||
if reqID == 0 {
|
||||
reqLogID, ok := res.Request.Context().Value(proxy.ReqLogIDKey).(ulid.ULID)
|
||||
if !ok {
|
||||
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.
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
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))
|
||||
clone.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
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)
|
||||
}
|
||||
}()
|
||||
@ -213,40 +221,3 @@ func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon
|
||||
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
124
pkg/reqlog/reqlog_test.go
Normal 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
235
pkg/reqlog/search.go
Normal 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
203
pkg/reqlog/search_test.go
Normal 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())
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -1,23 +1,16 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/dstotijn/hetty/pkg/proj"
|
||||
)
|
||||
|
||||
const moduleName = "scope"
|
||||
|
||||
type Scope struct {
|
||||
rules []Rule
|
||||
repo Repository
|
||||
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
@ -31,75 +24,24 @@ type Header struct {
|
||||
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 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.rules
|
||||
}
|
||||
|
||||
func (s *Scope) load(ctx context.Context) error {
|
||||
func (s *Scope) SetRules(rules []Rule) {
|
||||
s.mu.Lock()
|
||||
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
|
||||
|
||||
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 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, rule := range s.rules {
|
||||
if matches := rule.Match(req, body); matches {
|
||||
return true
|
||||
@ -118,11 +60,13 @@ func (r Rule) Match(req *http.Request, body []byte) bool {
|
||||
|
||||
for key, values := range req.Header {
|
||||
var keyMatches, valueMatches bool
|
||||
|
||||
if r.Header.Key != nil {
|
||||
if matches := r.Header.Key.MatchString(key); matches {
|
||||
keyMatches = true
|
||||
}
|
||||
}
|
||||
|
||||
if r.Header.Value != nil {
|
||||
for _, value := range values {
|
||||
if matches := r.Header.Value.MatchString(value); matches {
|
||||
@ -152,44 +96,54 @@ func (r Rule) Match(req *http.Request, body []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
func (r Rule) MarshalJSON() ([]byte, error) {
|
||||
type headerDTO struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
type ruleDTO struct {
|
||||
URL string
|
||||
Header headerDTO
|
||||
Body string
|
||||
func regexpToString(r *regexp.Regexp) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
dto := ruleDTO{
|
||||
URL: regexpToString(r.URL),
|
||||
Header: headerDTO{
|
||||
Key: regexpToString(r.Header.Key),
|
||||
Value: regexpToString(r.Header.Value),
|
||||
},
|
||||
Body: regexpToString(r.Body),
|
||||
}
|
||||
|
||||
return json.Marshal(dto)
|
||||
return r.String()
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (r *Rule) UnmarshalJSON(data []byte) error {
|
||||
type headerDTO struct {
|
||||
func stringToRegexp(s string) (*regexp.Regexp, error) {
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return regexp.Compile(s)
|
||||
}
|
||||
|
||||
type ruleDTO struct {
|
||||
URL string
|
||||
Header struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
type ruleDTO struct {
|
||||
URL string
|
||||
Header headerDTO
|
||||
Body string
|
||||
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
|
||||
if err := json.Unmarshal(data, &dto); err != nil {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (r *Rule) UnmarshalBinary(data []byte) error {
|
||||
dto := ruleDTO{}
|
||||
|
||||
err := gob.NewDecoder(bytes.NewReader(data)).Decode(&dto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -197,14 +151,17 @@ func (r *Rule) UnmarshalJSON(data []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
headerKey, err := stringToRegexp(dto.Header.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
headerValue, err := stringToRegexp(dto.Header.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := stringToRegexp(dto.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -221,17 +178,3 @@ func (r *Rule) UnmarshalJSON(data []byte) error {
|
||||
|
||||
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)
|
||||
}
|
||||
|
@ -1,6 +1,10 @@
|
||||
package search
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"encoding/gob"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Expression interface {
|
||||
String() string
|
||||
@ -11,8 +15,7 @@ type PrefixExpression struct {
|
||||
Right Expression
|
||||
}
|
||||
|
||||
func (pe *PrefixExpression) expressionNode() {}
|
||||
func (pe *PrefixExpression) String() string {
|
||||
func (pe PrefixExpression) String() string {
|
||||
b := strings.Builder{}
|
||||
b.WriteString("(")
|
||||
b.WriteString(pe.Operator.String())
|
||||
@ -29,8 +32,7 @@ type InfixExpression struct {
|
||||
Right Expression
|
||||
}
|
||||
|
||||
func (ie *InfixExpression) expressionNode() {}
|
||||
func (ie *InfixExpression) String() string {
|
||||
func (ie InfixExpression) String() string {
|
||||
b := strings.Builder{}
|
||||
b.WriteString("(")
|
||||
b.WriteString(ie.Left.String())
|
||||
@ -47,7 +49,32 @@ type StringLiteral struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func (sl *StringLiteral) expressionNode() {}
|
||||
func (sl *StringLiteral) String() string {
|
||||
func (sl StringLiteral) String() string {
|
||||
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{})
|
||||
}
|
||||
|
@ -17,21 +17,21 @@ const eof = 0
|
||||
|
||||
// Token types.
|
||||
const (
|
||||
// Flow
|
||||
// Flow.
|
||||
TokInvalid TokenType = iota
|
||||
TokEOF
|
||||
TokParenOpen
|
||||
TokParenClose
|
||||
|
||||
// Literals
|
||||
// Literals.
|
||||
TokString
|
||||
|
||||
// Boolean operators
|
||||
// Boolean operators.
|
||||
TokOpNot
|
||||
TokOpAnd
|
||||
TokOpOr
|
||||
|
||||
// Comparison operators
|
||||
// Comparison operators.
|
||||
TokOpEq
|
||||
TokOpNotEq
|
||||
TokOpGt
|
||||
@ -98,6 +98,7 @@ func (tt TokenType) String() string {
|
||||
if typeString, ok := tokenTypeStrings[tt]; ok {
|
||||
return typeString
|
||||
}
|
||||
|
||||
return "<unknown>"
|
||||
}
|
||||
|
||||
@ -113,6 +114,7 @@ func (l *Lexer) read() (r rune) {
|
||||
l.width = 0
|
||||
return eof
|
||||
}
|
||||
|
||||
r, l.width = utf8.DecodeRuneInString(l.input[l.pos:])
|
||||
l.pos += l.width
|
||||
|
||||
@ -124,6 +126,7 @@ func (l *Lexer) emit(tokenType TokenType) {
|
||||
Type: tokenType,
|
||||
Literal: l.input[l.start:l.pos],
|
||||
}
|
||||
|
||||
l.start = l.pos
|
||||
}
|
||||
|
||||
@ -159,6 +162,7 @@ func begin(l *Lexer) stateFn {
|
||||
l.backup()
|
||||
l.emit(TokOpEq)
|
||||
}
|
||||
|
||||
return begin
|
||||
case '!':
|
||||
switch next := l.read(); next {
|
||||
@ -169,6 +173,7 @@ func begin(l *Lexer) stateFn {
|
||||
default:
|
||||
return l.errorf("invalid rune %v", r)
|
||||
}
|
||||
|
||||
return begin
|
||||
case '<':
|
||||
if next := l.read(); next == '=' {
|
||||
@ -177,6 +182,7 @@ func begin(l *Lexer) stateFn {
|
||||
l.backup()
|
||||
l.emit(TokOpLt)
|
||||
}
|
||||
|
||||
return begin
|
||||
case '>':
|
||||
if next := l.read(); next == '=' {
|
||||
@ -185,6 +191,7 @@ func begin(l *Lexer) stateFn {
|
||||
l.backup()
|
||||
l.emit(TokOpGt)
|
||||
}
|
||||
|
||||
return begin
|
||||
case '(':
|
||||
l.emit(TokParenOpen)
|
||||
@ -231,15 +238,18 @@ func unquotedString(l *Lexer) stateFn {
|
||||
case r == eof:
|
||||
l.backup()
|
||||
l.emitUnquotedString()
|
||||
|
||||
return begin
|
||||
case unicode.IsSpace(r):
|
||||
l.backup()
|
||||
l.emitUnquotedString()
|
||||
l.skip()
|
||||
|
||||
return begin
|
||||
case isReserved(r):
|
||||
l.backup()
|
||||
l.emitUnquotedString()
|
||||
|
||||
return begin
|
||||
}
|
||||
}
|
||||
@ -251,6 +261,7 @@ func (l *Lexer) emitUnquotedString() {
|
||||
l.emit(tokType)
|
||||
return
|
||||
}
|
||||
|
||||
l.emit(TokString)
|
||||
}
|
||||
|
||||
@ -260,5 +271,6 @@ func isReserved(r rune) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
@ -3,6 +3,8 @@ package search
|
||||
import "testing"
|
||||
|
||||
func TestNextToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
|
@ -2,6 +2,7 @@ package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type precedence int
|
||||
@ -18,8 +19,10 @@ const (
|
||||
precGroup
|
||||
)
|
||||
|
||||
type prefixParser func(*Parser) (Expression, error)
|
||||
type infixParser func(*Parser, Expression) (Expression, error)
|
||||
type (
|
||||
prefixParser func(*Parser) (Expression, error)
|
||||
infixParser func(*Parser, Expression) (Expression, error)
|
||||
)
|
||||
|
||||
var (
|
||||
prefixParsers = map[TokenType]prefixParser{}
|
||||
@ -77,7 +80,6 @@ func NewParser(l *Lexer) *Parser {
|
||||
p.nextToken()
|
||||
|
||||
return p
|
||||
|
||||
}
|
||||
|
||||
func ParseQuery(input string) (expr Expression, err error) {
|
||||
@ -91,18 +93,20 @@ func ParseQuery(input string) (expr Expression, err error) {
|
||||
|
||||
for !p.curTokenIs(TokEOF) {
|
||||
right, err := p.parseExpression(precLowest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: could not parse expression: %v", err)
|
||||
}
|
||||
if expr == nil {
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("search: could not parse expression: %w", err)
|
||||
case expr == nil:
|
||||
expr = right
|
||||
} else {
|
||||
expr = &InfixExpression{
|
||||
default:
|
||||
expr = InfixExpression{
|
||||
Operator: TokOpAnd,
|
||||
Left: expr,
|
||||
Right: right,
|
||||
}
|
||||
}
|
||||
|
||||
p.nextToken()
|
||||
}
|
||||
|
||||
@ -122,18 +126,11 @@ func (p *Parser) peekTokenIs(t TokenType) bool {
|
||||
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 {
|
||||
if p, ok := tokenPrecedences[p.cur.Type]; ok {
|
||||
return p
|
||||
}
|
||||
|
||||
return precLowest
|
||||
}
|
||||
|
||||
@ -141,6 +138,7 @@ func (p *Parser) peekPrecedence() precedence {
|
||||
if p, ok := tokenPrecedences[p.peek.Type]; ok {
|
||||
return p
|
||||
}
|
||||
|
||||
return precLowest
|
||||
}
|
||||
|
||||
@ -152,7 +150,7 @@ func (p *Parser) parseExpression(prec precedence) (Expression, error) {
|
||||
|
||||
expr, err := prefixParser(p)
|
||||
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() {
|
||||
@ -165,7 +163,7 @@ func (p *Parser) parseExpression(prec precedence) (Expression, error) {
|
||||
|
||||
expr, err = infixParser(p, expr)
|
||||
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) {
|
||||
expr := &PrefixExpression{
|
||||
expr := PrefixExpression{
|
||||
Operator: p.cur.Type,
|
||||
}
|
||||
|
||||
@ -181,15 +179,16 @@ func parsePrefixExpression(p *Parser) (Expression, error) {
|
||||
|
||||
right, err := p.parseExpression(precPrefix)
|
||||
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
|
||||
|
||||
return expr, nil
|
||||
}
|
||||
|
||||
func parseInfixExpression(p *Parser, left Expression) (Expression, error) {
|
||||
expr := &InfixExpression{
|
||||
expr := InfixExpression{
|
||||
Operator: p.cur.Type,
|
||||
Left: left,
|
||||
}
|
||||
@ -199,15 +198,27 @@ func parseInfixExpression(p *Parser, left Expression) (Expression, error) {
|
||||
|
||||
right, err := p.parseExpression(prec)
|
||||
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
|
||||
|
||||
return expr, nil
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -215,18 +226,20 @@ func parseGroupedExpression(p *Parser) (Expression, error) {
|
||||
|
||||
expr, err := p.parseExpression(precLowest)
|
||||
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() {
|
||||
if p.curTokenIs(TokEOF) {
|
||||
return nil, fmt.Errorf("unexpected EOF: unmatched parentheses")
|
||||
}
|
||||
|
||||
right, err := p.parseExpression(precLowest)
|
||||
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,
|
||||
Left: expr,
|
||||
Right: right,
|
||||
|
@ -3,10 +3,13 @@ package search
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
@ -22,101 +25,101 @@ func TestParseQuery(t *testing.T) {
|
||||
{
|
||||
name: "string literal expression",
|
||||
input: "foobar",
|
||||
expectedExpression: &StringLiteral{Value: "foobar"},
|
||||
expectedExpression: StringLiteral{Value: "foobar"},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "boolean expression with equal operator",
|
||||
input: "foo = bar",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpEq,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: StringLiteral{Value: "bar"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "boolean expression with not equal operator",
|
||||
input: "foo != bar",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpNotEq,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: StringLiteral{Value: "bar"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "boolean expression with greater than operator",
|
||||
input: "foo > bar",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpGt,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: StringLiteral{Value: "bar"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "boolean expression with less than operator",
|
||||
input: "foo < bar",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpLt,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: StringLiteral{Value: "bar"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "boolean expression with greater than or equal operator",
|
||||
input: "foo >= bar",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpGtEq,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: StringLiteral{Value: "bar"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "boolean expression with less than or equal operator",
|
||||
input: "foo <= bar",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpLtEq,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: StringLiteral{Value: "bar"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "boolean expression with regular expression operator",
|
||||
input: "foo =~ bar",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpRe,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: regexp.MustCompile("bar"),
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "boolean expression with not regular expression operator",
|
||||
input: "foo !~ bar",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpNotRe,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: regexp.MustCompile("bar"),
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "boolean expression with AND, OR and NOT operators",
|
||||
input: "foo AND bar OR NOT baz",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpAnd,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &InfixExpression{
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: InfixExpression{
|
||||
Operator: TokOpOr,
|
||||
Left: &StringLiteral{Value: "bar"},
|
||||
Right: &PrefixExpression{
|
||||
Left: StringLiteral{Value: "bar"},
|
||||
Right: PrefixExpression{
|
||||
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",
|
||||
input: "(foo AND bar) OR NOT baz",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpOr,
|
||||
Left: &InfixExpression{
|
||||
Left: InfixExpression{
|
||||
Operator: TokOpAnd,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: StringLiteral{Value: "bar"},
|
||||
},
|
||||
Right: &PrefixExpression{
|
||||
Right: PrefixExpression{
|
||||
Operator: TokOpNot,
|
||||
Right: &StringLiteral{Value: "baz"},
|
||||
Right: StringLiteral{Value: "baz"},
|
||||
},
|
||||
},
|
||||
expectedError: nil,
|
||||
@ -142,59 +145,59 @@ func TestParseQuery(t *testing.T) {
|
||||
{
|
||||
name: "implicit boolean expression with string literal operands",
|
||||
input: "foo bar baz",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpAnd,
|
||||
Left: &InfixExpression{
|
||||
Left: InfixExpression{
|
||||
Operator: TokOpAnd,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: StringLiteral{Value: "bar"},
|
||||
},
|
||||
Right: &StringLiteral{Value: "baz"},
|
||||
Right: StringLiteral{Value: "baz"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "implicit boolean expression nested in group",
|
||||
input: "(foo bar)",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpAnd,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: StringLiteral{Value: "bar"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "implicit and explicit boolean expression with string literal operands",
|
||||
input: "foo bar OR baz yolo",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpAnd,
|
||||
Left: &InfixExpression{
|
||||
Left: InfixExpression{
|
||||
Operator: TokOpAnd,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &InfixExpression{
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: InfixExpression{
|
||||
Operator: TokOpOr,
|
||||
Left: &StringLiteral{Value: "bar"},
|
||||
Right: &StringLiteral{Value: "baz"},
|
||||
Left: StringLiteral{Value: "bar"},
|
||||
Right: StringLiteral{Value: "baz"},
|
||||
},
|
||||
},
|
||||
Right: &StringLiteral{Value: "yolo"},
|
||||
Right: StringLiteral{Value: "yolo"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "implicit boolean expression with comparison operands",
|
||||
input: "foo=bar baz=~yolo",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpAnd,
|
||||
Left: &InfixExpression{
|
||||
Left: InfixExpression{
|
||||
Operator: TokOpEq,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: StringLiteral{Value: "bar"},
|
||||
},
|
||||
Right: &InfixExpression{
|
||||
Right: InfixExpression{
|
||||
Operator: TokOpRe,
|
||||
Left: &StringLiteral{Value: "baz"},
|
||||
Right: &StringLiteral{Value: "yolo"},
|
||||
Left: StringLiteral{Value: "baz"},
|
||||
Right: regexp.MustCompile("yolo"),
|
||||
},
|
||||
},
|
||||
expectedError: nil,
|
||||
@ -202,17 +205,17 @@ func TestParseQuery(t *testing.T) {
|
||||
{
|
||||
name: "eq operator takes precedence over boolean ops",
|
||||
input: "foo=bar OR baz=yolo",
|
||||
expectedExpression: &InfixExpression{
|
||||
expectedExpression: InfixExpression{
|
||||
Operator: TokOpOr,
|
||||
Left: &InfixExpression{
|
||||
Left: InfixExpression{
|
||||
Operator: TokOpEq,
|
||||
Left: &StringLiteral{Value: "foo"},
|
||||
Right: &StringLiteral{Value: "bar"},
|
||||
Left: StringLiteral{Value: "foo"},
|
||||
Right: StringLiteral{Value: "bar"},
|
||||
},
|
||||
Right: &InfixExpression{
|
||||
Right: InfixExpression{
|
||||
Operator: TokOpEq,
|
||||
Left: &StringLiteral{Value: "baz"},
|
||||
Right: &StringLiteral{Value: "yolo"},
|
||||
Left: StringLiteral{Value: "baz"},
|
||||
Right: StringLiteral{Value: "yolo"},
|
||||
},
|
||||
},
|
||||
expectedError: nil,
|
||||
@ -233,6 +236,8 @@ func TestParseQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func assertError(t *testing.T, exp, got error) {
|
||||
t.Helper()
|
||||
|
||||
switch {
|
||||
case exp == nil && got != nil:
|
||||
t.Fatalf("expected: nil, got: %v", got)
|
||||
|
Reference in New Issue
Block a user