Compare commits

..

48 Commits
v0.4.0 ... main

Author SHA1 Message Date
52c83a1989 Update go version matrices for CI/CD workflows 2025-01-13 23:26:31 +01:00
7ed553866b Update README and usage docs to with new default DB file path 2025-01-13 23:18:22 +01:00
fcf0e1c51e Replace BadgerDB with bbolt 2025-01-13 23:15:18 +01:00
24c2ecfa4b Remove database mocks, replace Service interface indirection 2025-01-04 00:39:40 +01:00
d23b4ed024 Update copyright footer 2025-01-01 20:52:14 +01:00
ff9e4140aa Remove Tines as sponsor 2025-01-01 20:50:52 +01:00
f7def87d0f Add HTTP header support to string literal matching 2022-03-31 15:23:56 +02:00
aa9822854d Rename search package to filter package 2022-03-31 15:12:54 +02:00
2ce4218a30 Add filter support for HTTP headers 2022-03-31 14:53:40 +02:00
fd27955e11 Sort HTTP headers 2022-03-31 12:07:35 +02:00
426a7d5f96 Add "New request" button to Sender page 2022-03-31 11:23:17 +02:00
21b679dc91 Add new sponsorship options 2022-03-30 13:02:58 +02:00
e4f468d4d2 Publish Docker image to ghcr.io and docker.io 2022-03-30 11:50:16 +02:00
d3246b0918 Add intercept feature to README 2022-03-29 14:06:51 +02:00
61fd3fcc45 Update admin dependencies 2022-03-29 13:58:38 +02:00
d34258dfd1 Add links to intercept filter docs 2022-03-29 13:52:03 +02:00
0e9fb0ac91 Add tests for search.Expression interface implementations 2022-03-23 15:39:45 +01:00
02408b5196 Add intercept module 2022-03-23 14:31:27 +01:00
6ffc55cde3 Fix Snapcraft plugs config to allow network binding 2022-03-15 10:11:53 +01:00
bdd667381a Add Snapcraft notice 2022-03-06 14:18:25 +01:00
f60202e41c Add Snapcraft and Scoop config 2022-03-03 14:31:58 +01:00
87b8b18047 Fix README badges 2022-03-03 08:29:14 +01:00
edab744d01 Remove old docs 2022-03-02 20:08:39 +01:00
3f5277e419 Fix light/dark mode logo in README 2022-03-02 20:05:37 +01:00
29550ff43b Update README 2022-03-02 19:16:53 +01:00
7afc23b3ff Add Homebrew tap to GoReleaser config 2022-03-02 14:49:24 +01:00
6aa93b782e Add "Copy to Sender" button in reqlog table 2022-03-02 08:14:44 +01:00
ed9a539ce3 Remove stray console.log calls 2022-03-02 07:52:17 +01:00
857aa0c49e Misc lint fixes 2022-02-28 16:21:01 +01:00
af26987601 Fix sort order of request logs 2022-02-28 15:40:13 +01:00
ad26478043 Add certificate management subcommands 2022-02-28 15:31:16 +01:00
ca0c085021 Use ffcli, tidy up usage message 2022-02-28 12:50:09 +01:00
d438f93ee0 Fix incorrect var names 2022-02-28 09:41:55 +01:00
fa3f24eb70 Move gql handler out of main, improve admin route matching 2022-02-28 09:23:08 +01:00
f15438e10b Fix stray outdated enum values 2022-02-28 09:21:43 +01:00
bef52d956e Add support to launch Chrome 2022-02-27 19:00:11 +01:00
8269af9478 Fix missing HTTP/1.0 proto enums 2022-02-27 17:55:41 +01:00
c5f76e1f9a Remove unused project open/close event listeners 2022-02-27 14:42:39 +01:00
2ddf2a77e8 Add logger 2022-02-27 14:28:28 +01:00
d2858a2be4 Fix input fields for key-value pair tables losing focus 2022-02-26 09:58:00 +01:00
7e43479b54 Reuse components across Proxy and Sender modules 2022-02-25 21:08:15 +01:00
11f70282d7 Tidy up admin structure 2022-02-23 15:20:23 +01:00
efc20564c1 Add Sender module 2022-02-22 14:10:39 +01:00
afa211d0ec Add lint Github Action 2022-02-01 18:14:14 +01:00
44193cd723 Use Node v16 in CI/CD 2022-02-01 18:13:34 +01:00
e07163fef3 Add prettier lint config 2022-02-01 18:13:14 +01:00
ed394507d3 Fix default make target 2022-02-01 18:12:44 +01:00
cd5403e353 Fix README 2022-01-31 16:17:19 +01:00
223 changed files with 17744 additions and 14770 deletions

4
.github/FUNDING.yml vendored
View File

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

View File

@ -5,7 +5,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.17", "1.16"]
go: ["1.23", "1.22", "1.21"]
name: Go ${{ matrix.go }} - Build
steps:
- uses: actions/checkout@v2
@ -14,7 +14,7 @@ jobs:
go-version: ${{ matrix.go }}
- uses: actions/setup-node@v2
with:
node-version: "14"
node-version: "16"
- uses: actions/cache@v2
with:
path: |
@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.17", "1.16"]
go: ["1.23", "1.22", "1.21"]
name: Go ${{ matrix.go }} - Test
steps:
- uses: actions/checkout@v2

16
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Lint
on: [push, pull_request]
defaults:
run:
working-directory: ./admin
jobs:
lint-admin:
runs-on: ubuntu-latest
name: Admin (Next.js)
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: "16"
- run: yarn install
- run: yarn run lint

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
/.vscode
*.vscode
/dist
/hetty
/cmd/hetty/admin

View File

@ -12,6 +12,7 @@ linters:
- test
- unused
disable:
- dupl
- exhaustive
- exhaustivestruct
- gochecknoglobals
@ -21,9 +22,11 @@ linters:
- gomnd
- interfacer
- maligned
- nilnil
- nlreturn
- scopelint
- testpackage
- varnamelen
- wrapcheck
linters-settings:
@ -31,6 +34,8 @@ linters-settings:
local-prefixes: github.com/dstotijn/hetty
godot:
capital: true
ireturn:
allow: "error,empty,anon,stdlib,.*(or|er)$,github.com/99designs/gqlgen/graphql.Marshaler,github.com/dstotijn/hetty/pkg/api.QueryResolver,github.com/dstotijn/hetty/pkg/filter.Expression"
issues:
exclude-rules:

View File

@ -28,6 +28,72 @@ archives:
- goos: windows
format: zip
brews:
- tap:
owner: hettysoft
name: homebrew-tap
folder: Formula
homepage: https://hetty.xyz
description: An HTTP toolkit for security research.
license: MIT
commit_author:
name: David Stotijn
email: dstotijn@gmail.com
test: |
system "#{bin}/hetty -v"
snapcrafts:
- publish: true
summary: An HTTP toolkit for security research.
description: |
Hetty is an HTTP toolkit for security research. It aims to become an open
source alternative to commercial software like Burp Suite Pro, with
powerful features tailored to the needs of the infosec and bug bounty
community.
grade: stable
confinement: strict
license: MIT
apps:
hetty:
command: hetty
plugs: ["network", "network-bind"]
scoop:
bucket:
owner: hettysoft
name: scoop-bucket
commit_author:
name: David Stotijn
email: dstotijn@gmail.com
homepage: https://hetty.xyz
description: An HTTP toolkit for security research.
license: MIT
dockers:
- extra_files:
- go.mod
- go.sum
- pkg
- cmd
- admin
image_templates:
- "ghcr.io/dstotijn/hetty:{{ .Version }}"
- "ghcr.io/dstotijn/hetty:{{ .Major }}"
- "ghcr.io/dstotijn/hetty:{{ .Major }}.{{ .Minor }}"
- "ghcr.io/dstotijn/hetty:latest"
- "dstotijn/hetty:{{ .Version }}"
- "dstotijn/hetty:{{ .Major }}"
- "dstotijn/hetty:{{ .Major }}.{{ .Minor }}"
- "dstotijn/hetty:latest"
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.source=https://github.com/dstotijn/hetty"
- "--build-arg=HETTY_VERSION={{.Version}}"
checksum:
name_template: "checksums.txt"
@ -39,4 +105,4 @@ changelog:
filters:
exclude:
- "^docs:"
- "^test:"
- "^test:"

View File

@ -1,13 +1,9 @@
export CGO_ENABLED = 0
export NEXT_TELEMETRY_DISABLED = 1
.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
build: build-admin
go build ./cmd/hetty
.PHONY: build-admin
build-admin:
@ -16,6 +12,9 @@ build-admin:
yarn run export && \
mv dist ../cmd/hetty/admin
.PHONY: build
build: build-admin
go build ./cmd/hetty
.PHONY: clean
clean:
rm -f hetty
rm -rf ./cmd/hetty/admin
rm -rf ./admin/dist
rm -rf ./admin/.next

274
README.md
View File

@ -1,243 +1,155 @@
<h1>
<a href="https://github.com/dstotijn/hetty">
<img src="https://hetty.xyz/assets/logo.png" width="293">
</a>
</h1>
<img src="https://user-images.githubusercontent.com/983924/156430531-6193e187-7400-436b-81c6-f86862783ea5.svg#gh-light-mode-only" width="240"/>
<img src="https://user-images.githubusercontent.com/983924/156430660-9d5bd555-dcfd-47e2-ba70-54294c20c1b4.svg#gh-dark-mode-only" width="240"/>
[![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/releases/latest)
[![Build Status](https://img.shields.io/endpoint.svg?url=https://actions-badge.atrox.dev/dstotijn/hetty/badge&style=flat-square&label=build+%26+test&logo=none&color=18BA91)](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
![GitHub download count](https://img.shields.io/github/downloads/dstotijn/hetty/total?color=18BA91&style=flat-square)
[![GitHub](https://img.shields.io/github/license/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/blob/master/LICENSE)
[![Documentation](https://img.shields.io/badge/hetty-docs-18BA91?style=flat-square)](https://hetty.xyz/)
[![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=25ae8f)](https://github.com/dstotijn/hetty/releases/latest)
[![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fdstotijn%2Fhetty%2Fbadge%3Fref%3Dmain&label=build&color=24ae8f)](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
![GitHub download count](https://img.shields.io/github/downloads/dstotijn/hetty/total?color=25ae8f)
[![GitHub](https://img.shields.io/github/license/dstotijn/hetty?color=25ae8f)](https://github.com/dstotijn/hetty/blob/master/LICENSE)
[![Documentation](https://img.shields.io/badge/hetty-docs-25ae8f)](https://hetty.xyz/)
**Hetty** is an HTTP toolkit for security research. It aims to become an open
source alternative to commercial software like Burp Suite Pro, with powerful
features tailored to the needs of the infosec and bug bounty community.
<img src="https://hetty.xyz/assets/hetty_v0.2.0_header.png">
<img src="https://hetty.xyz/img/hero.png" width="907" alt="Hetty proxy logs (screenshot)" />
## Features
- Man-in-the-middle (MITM) HTTP/1.1 proxy with logs
- Project based database storage (SQLite)
- Scope support
- Headless management API using GraphQL
- Embedded web interface (Next.js)
- Machine-in-the-middle (MITM) HTTP proxy, with logs and advanced search
- HTTP client for manually creating/editing requests, and replay proxied requests
- Intercept requests and responses for manual review (edit, send/receive, cancel)
- Scope support, to help keep work organized
- Easy-to-use web based admin interface
- Project based database storage, to help keep work organized
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.
👷‍♂ Hetty is under active development. Check the <a
href="https://github.com/dstotijn/hetty/projects/1">backlog</a> for the current
status.
## Documentation
📣 Are you pen testing professionaly in a team? I would love to hear your
thoughts on tooling via [this 5 minute
survey](https://forms.gle/36jtgNc3TJ2imi5A8). Thank you!
📖 [Read the docs.](https://hetty.xyz/)
## Getting started
## Installation
💡 The [Getting started](https://hetty.xyz/docs/getting-started) doc has more
detailed install and usage instructions.
Hetty compiles to a self-contained binary, with an embedded BadgerDB database
and web based admin interface.
### Installation
### Install pre-built release (recommended)
The quickest way to install and update Hetty is via a package manager:
👉 Downloads for Linux, macOS and Windows are available on the [releases page](https://github.com/dstotijn/hetty/releases).
#### macOS
### Build from source
#### Prerequisites
- [Go 1.16](https://golang.org/)
- [Yarn](https://yarnpkg.com/)
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:
```
$ git clone git@github.com:dstotijn/hetty.git
$ cd hetty
$ make build
```sh
brew install hettysoft/tap/hetty
```
### Docker
#### Linux
A Docker image is available on Docker Hub: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty).
For persistent storage of CA certificates and projects database, mount a volume:
```
$ mkdir -p $HOME/.hetty
$ docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 dstotijn/hetty
```sh
sudo snap install hetty
```
## Usage
#### Windows
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, 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).
To start, ensure `hetty` (downloaded from a release, or manually built) is in your
`$PATH` and run:
```
$ hetty
```sh
scoop bucket add hettysoft https://github.com/hettysoft/scoop-bucket.git
scoop install hettysoft/hetty
```
An overview of configuration flags:
#### Other
Alternatively, you can [download the latest release from
GitHub](https://github.com/dstotijn/hetty/releases/latest) for your OS and
architecture, and move the binary to a directory in your `$PATH`. If your OS is
not available for one of the package managers or not listed in the GitHub
releases, you can compile from source _(link coming soon)_.
#### Docker
Docker images are distributed via [GitHub's Container registry](https://github.com/dstotijn/hetty/pkgs/container/hetty)
and [Docker Hub](https://hub.docker.com/r/dstotijn/hetty). To run Hetty via with a volume for database and certificate
storage, and port 8080 forwarded:
```
$ hetty -h
Usage of ./hetty:
-addr string
TCP address to listen on, in the form "host:port" (default ":8080")
-adminPath string
File path to admin build
-cert string
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")
-db string
Database directory path (default "~/.hetty/db")
docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 \
ghcr.io/dstotijn/hetty:latest
```
You should see:
### Usage
```
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.
Detailed documentation is under development and will be available soon.
## Certificate Setup and Installation
In order for Hetty to proxy requests going to HTTPS endpoints, a root CA certificate for
Hetty will need to be set up. Furthermore, the CA certificate may need to be
installed to the host for them to be trusted by your browser. The following steps
will cover how you can generate your certificate, provide them to hetty, and how
you can install them in your local CA store.
⚠️ _This process was done on a Linux machine but should_
_provide guidance on Windows and macOS as well._
### Generating CA certificates
You can generate a CA keypair two different ways. The first is bundled directly
with Hetty, and simplifies the process immensely. The alternative is using OpenSSL
to generate them, which provides more control over expiration time and cryptography
used, but requires you install the OpenSSL tooling. The first is suggested for any
beginners trying to get started.
#### Generating CA certificates with hetty
Hetty will generate the default key and certificate on its own if none are supplied
or found in `~/.hetty/` when first running the CLI. To generate a default key and
certificate with hetty, simply run the command with no arguments
Once installed, start Hetty via:
```sh
hetty
```
You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and
`~/.hetty/hetty_cert.pem` respectively.
💡 Read the [Getting started](https://hetty.xyz/docs/getting-started) doc for
more details.
#### Generating CA certificates with OpenSSL
You can start off by generating a new key and CA certificate which will both expire
after a month.
```sh
mkdir ~/.hetty
openssl req -newkey rsa:2048 -new -nodes -x509 -days 31 -keyout ~/.hetty/hetty_key.pem -out ~/.hetty/hetty_cert.pem
```
The default location which `hetty` will check for the key and CA certificate is under
`~/.hetty/`, at `hetty_key.pem` and `hetty_cert.pem` respectively. You can move them
here and `hetty` will detect them automatically. Otherwise, you can specify the
location of these as arguments to `hetty`.
To list all available options, run: `hetty --help`:
```
hetty -key key.pem -cert cert.pem
$ hetty --help
Usage:
hetty [flags] [subcommand] [flags]
Runs an HTTP server with (MITM) proxy, GraphQL service, and a web based admin interface.
Options:
--cert Path to root CA certificate. Creates file if it doesn't exist. (Default: "~/.hetty/hetty_cert.pem")
--key Path to root CA private key. Creates file if it doesn't exist. (Default: "~/.hetty/hetty_key.pem")
--db Database file path. Creates file if it doesn't exist. (Default: "~/.hetty/hetty.db")
--addr TCP address for HTTP server to listen on, in the form \"host:port\". (Default: ":8080")
--chrome Launch Chrome with proxy settings applied and certificate errors ignored. (Default: false)
--verbose Enable verbose logging.
--json Encode logs as JSON, instead of pretty/human readable output.
--version, -v Output version.
--help, -h Output this usage text.
Subcommands:
- cert Certificate management
Run `hetty <subcommand> --help` for subcommand specific usage instructions.
Visit https://hetty.xyz to learn more about Hetty.
```
### Trusting the CA certificate
## Documentation
In order for your browser to allow traffic to the local Hetty proxy, you may need
to install these certificates to your local CA store.
On Ubuntu, you can update your local CA store with the certificate by running the
following commands:
```sh
sudo cp ~/.hetty/hetty_cert.pem /usr/local/share/ca-certificates/hetty.crt
sudo update-ca-certificates
```
On Windows, you would add your certificate by using the Certificate Manager. You
can launch that by running the command:
```batch
certmgr.msc
```
On macOS, you can add your certificate by using the Keychain Access program. This
can be found under `Application/Utilities/Keychain Access.app`. After opening this,
drag the certificate into the app. Next, open the certificate in the app, enter the
_Trust_ section, and under _When using this certificate_ select _Always Trust_.
_Note: Various Linux distributions may require other steps or commands for updating_
_their certificate authority. See the documentation relevant to your distribution for_
_more information on how to update the system to trust your self-signed certificate._
## Vision and roadmap
- Fast core/engine, built with Go, with a minimal memory footprint.
- Easy to use admin interface, built with Next.js and Material UI.
- Headless management, via GraphQL API.
- Extensibility is top of mind. All modules are written as Go packages, to
be used by Hetty, but also as libraries by other software.
- Pluggable architecture for MITM proxy, projects, scope. It should be possible.
to build a plugin system in the (near) future.
- Based on feedback and real-world usage of pentesters and bug bounty hunters.
- Aim for a relatively small core feature set that the majority of security researchers need.
📖 [Read the docs](https://hetty.xyz/docs)
## Support
Use [issues](https://github.com/dstotijn/hetty/issues) for bug reports and
feature requests, and [discussions](https://github.com/dstotijn/hetty/discussions)
for questions and troubleshooting.
feature requests, and
[discussions](https://github.com/dstotijn/hetty/discussions) for questions and
troubleshooting.
## Community
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP).
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP)
## Contributing
Want to contribute? Great! Please check the [Contribution Guidelines](CONTRIBUTING.md)
for details.
Want to contribute? Great! Please check the [Contribution
Guidelines](CONTRIBUTING.md) for details.
## Acknowledgements
- Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord)
for all the encouragement and feedback.
- The font used in the logo and admin interface is [JetBrains Mono](https://www.jetbrains.com/lp/mono/).
for the encouragement and early 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>
💖 Are you enjoying Hetty? You can [sponsor me](https://github.com/sponsors/dstotijn)!
## License
[MIT License](LICENSE)
[MIT](LICENSE)
---
© 2021 David Stotijn — [Twitter](https://twitter.com/dstotijn), [Email](mailto:dstotijn@gmail.com)
© 20192025 Hetty Software

View File

@ -1,6 +1,56 @@
{
"extends": "next/core-web-vitals",
"root": true,
"extends": ["next/core-web-vitals", "prettier", "plugin:@typescript-eslint/recommended", "plugin:import/typescript"],
"plugins": ["prettier", "@typescript-eslint", "import"],
"ignorePatterns": ["next*", "src/lib/graphql/generated.tsx"],
"settings": {
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"]
},
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
}
}
},
"rules": {
"@next/next/no-css-tags": "off"
"prettier/prettier": ["error"],
"@next/next/no-css-tags": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"ignoreRestSiblings": true
}
],
"import/default": "off",
"import/no-unresolved": "error",
"import/named": "error",
"import/namespace": "error",
"import/export": "error",
"import/no-deprecated": "error",
"import/no-cycle": "error",
"import/no-named-as-default": "warn",
"import/no-named-as-default-member": "warn",
"import/no-duplicates": "warn",
"import/newline-after-import": "warn",
"import/order": [
"warn",
{
"alphabetize": { "order": "asc", "caseInsensitive": false },
"newlines-between": "always",
"groups": ["builtin", "external", "parent", "sibling", "index"]
}
],
"import/no-unused-modules": [
"error",
{
"missingExports": true,
"ignoreExports": ["./src/pages"]
}
]
}
}

9
admin/gqlcodegen.yml Normal file
View File

@ -0,0 +1,9 @@
overwrite: true
schema: "../pkg/api/schema.graphql"
documents: "src/**/*.graphql"
generates:
src/lib/graphql/generated.tsx:
plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-apollo"

View File

@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"export": "next build && next export -o dist"
"export": "next build && next export -o dist",
"generate": "graphql-codegen --config gqlcodegen.yml"
},
"dependencies": {
"@apollo/client": "^3.2.0",
@ -18,6 +19,8 @@
"@mui/icons-material": "^5.3.1",
"@mui/lab": "^5.0.0-alpha.66",
"@mui/material": "^5.3.1",
"@mui/styles": "^5.4.2",
"allotment": "^1.9.0",
"deepmerge": "^4.2.2",
"graphql": "^16.2.0",
"lodash": "^4.17.21",
@ -26,18 +29,28 @@
"next-fonts": "^1.0.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"typescript": "^4.0.3"
"react-split-pane": "^0.1.92"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@graphql-codegen/cli": "2.6.1",
"@graphql-codegen/introspection": "2.1.1",
"@graphql-codegen/typescript": "2.4.3",
"@graphql-codegen/typescript-operations": "2.3.0",
"@graphql-codegen/typescript-react-apollo": "3.2.6",
"@types/lodash": "^4.14.178",
"@types/node": "^17.0.12",
"@types/react": "^17.0.38",
"@typescript-eslint/eslint-plugin": "^5.12.0",
"@typescript-eslint/parser": "^5.12.0",
"eslint": "^8.7.0",
"eslint-config-next": "12.0.8",
"eslint-config-prettier": "^8.3.0",
"eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"prettier": "^2.1.2",
"typescript": "^4.0.3",
"webpack": "^5.67.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -1,21 +0,0 @@
import { Paper } from "@mui/material";
function CenteredPaper({ children }: { children: React.ReactNode }): JSX.Element {
return (
<div>
<Paper
elevation={0}
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: 36,
}}
>
{children}
</Paper>
</div>
);
}
export default CenteredPaper;

View File

@ -1,117 +0,0 @@
import { gql, useMutation } from "@apollo/client";
import { Box, Button, CircularProgress, TextField, Typography } from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import React, { useState } from "react";
const CREATE_PROJECT = gql`
mutation CreateProject($name: String!) {
createProject(name: $name) {
id
name
}
}
`;
const OPEN_PROJECT = gql`
mutation OpenProject($id: ID!) {
openProject(id: $id) {
id
name
isActive
}
}
`;
function NewProject(): JSX.Element {
const [name, setName] = useState("");
const [createProject, { error: createProjErr, loading: createProjLoading }] = useMutation(CREATE_PROJECT, {
onError: () => {},
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.id,
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
id
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.id,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
id
name
isActive
type
}
`,
});
return DELETE;
},
},
});
},
});
const handleCreateAndOpenProjectForm = (e: React.SyntheticEvent) => {
e.preventDefault();
createProject({ variables: { name } });
};
return (
<div>
<Box mb={3}>
<Typography variant="h6">New project</Typography>
</Box>
<form onSubmit={handleCreateAndOpenProjectForm} autoComplete="off">
<TextField
sx={{
mr: 2,
}}
color="primary"
size="small"
label="Project name"
placeholder="Project name…"
onChange={(e) => setName(e.target.value)}
error={Boolean(createProjErr || openProjErr)}
helperText={(createProjErr && createProjErr.message) || (openProjErr && openProjErr.message)}
/>
<Button
type="submit"
variant="contained"
color="primary"
size="large"
sx={{
pt: 0.9,
pb: 0.7,
}}
disabled={createProjLoading || openProjLoading}
startIcon={createProjLoading || openProjLoading ? <CircularProgress size={22} /> : <AddIcon />}
>
Create & open project
</Button>
</form>
</div>
);
}
export default NewProject;

View File

@ -1,104 +0,0 @@
import { Table, TableBody, TableCell, TableContainer, TableRow, Snackbar } from "@mui/material";
import { Alert } from "@mui/lab";
import React, { useState } from "react";
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 [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);
windowSel.removeAllRanges();
windowSel.addRange(r);
document.execCommand("copy");
windowSel.removeAllRanges();
setOpen(true);
};
const handleClose = (event: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
setOpen(false);
};
return (
<div>
<Snackbar open={open} autoHideDuration={3000} onClose={handleClose}>
<Alert onClose={handleClose} severity="info">
Copied to clipboard.
</Alert>
</Snackbar>
<TableContainer>
<Table
sx={{
tableLayout: "fixed",
width: "100%",
}}
size="small"
>
<TableBody>
{headers.map(({ key, value }, index) => (
<TableRow key={index}>
<TableCell component="th" scope="row" sx={keyCellStyle} onClick={handleClick}>
<code>{key}:</code>
</TableCell>
<TableCell sx={valueCellStyle} onClick={handleClick}>
<code>{value}</code>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
);
}
export default HttpHeadersTable;

View File

@ -1,80 +0,0 @@
import { gql, useQuery } from "@apollo/client";
import { Box, Grid, Paper, CircularProgress } from "@mui/material";
import ResponseDetail from "./ResponseDetail";
import RequestDetail from "./RequestDetail";
import Alert from "@mui/lab/Alert";
const HTTP_REQUEST_LOG = gql`
query HttpRequestLog($id: ID!) {
httpRequestLog(id: $id) {
id
method
url
proto
headers {
key
value
}
body
response {
proto
headers {
key
value
}
statusCode
statusReason
body
}
}
}
`;
interface Props {
requestId: string;
}
function LogDetail({ requestId: id }: Props): JSX.Element {
const { loading, error, data } = useQuery(HTTP_REQUEST_LOG, {
variables: { id },
});
if (loading) {
return <CircularProgress />;
}
if (error) {
return <Alert severity="error">Error fetching logs details: {error.message}</Alert>;
}
if (!data.httpRequestLog) {
return (
<Alert severity="warning">
Request <strong>{id}</strong> was not found.
</Alert>
);
}
const { method, url, proto, headers, body, response } = data.httpRequestLog;
return (
<div>
<Grid container item spacing={2}>
<Grid item xs={6}>
<Box component={Paper}>
<RequestDetail request={{ method, url, proto, headers, body }} />
</Box>
</Grid>
<Grid item xs={6}>
{response && (
<Box component={Paper}>
<ResponseDetail response={response} />
</Box>
)}
</Grid>
</Grid>
</div>
);
}
export default LogDetail;

View File

@ -1,59 +0,0 @@
import { useRouter } from "next/router";
import Link from "next/link";
import { Box, CircularProgress, Link as MaterialLink, Typography } from "@mui/material";
import Alert from "@mui/lab/Alert";
import RequestList from "./RequestList";
import LogDetail from "./LogDetail";
import CenteredPaper from "../CenteredPaper";
import { useHttpRequestLogs } from "./hooks/useHttpRequestLogs";
function LogsOverview(): JSX.Element {
const router = useRouter();
const detailReqLogId = router.query.id as string | undefined;
const { loading, error, data } = useHttpRequestLogs();
const handleLogClick = (reqId: string) => {
router.push("/proxy/logs?id=" + reqId, undefined, {
shallow: false,
});
};
if (loading) {
return <CircularProgress />;
}
if (error) {
if (error.graphQLErrors[0]?.extensions?.code === "no_active_project") {
return (
<Alert severity="info">
There is no project active.{" "}
<Link href="/projects" passHref>
<MaterialLink color="primary">Create or open</MaterialLink>
</Link>{" "}
one first.
</Alert>
);
}
return <Alert severity="error">Error fetching logs: {error.message}</Alert>;
}
const { httpRequestLogs: logs } = data;
return (
<div>
<Box mb={2}>
<RequestList logs={logs || []} selectedReqLogId={detailReqLogId} onLogClick={handleLogClick} />
</Box>
<Box>
{detailReqLogId && <LogDetail requestId={detailReqLogId} />}
{logs.length !== 0 && !detailReqLogId && (
<CenteredPaper>
<Typography>Select a log entry</Typography>
</CenteredPaper>
)}
</Box>
</div>
);
}
export default LogsOverview;

View File

@ -1,56 +0,0 @@
import React from "react";
import { Typography, Box, Divider } from "@mui/material";
import HttpHeadersTable from "./HttpHeadersTable";
import Editor from "./Editor";
interface Props {
request: {
method: string;
url: string;
proto: string;
headers: Array<{ key: string; value: string }>;
body?: string;
};
}
function RequestDetail({ request }: Props): JSX.Element {
const { method, url, proto, headers, body } = request;
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" }}>
Request
</Typography>
<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" }}>
{proto}
</Typography>
</Typography>
</Box>
<Divider />
<Box p={2}>
<HttpHeadersTable headers={headers} />
</Box>
{body && <Editor content={body} contentType={contentType} />}
</div>
);
}
export default RequestDetail;

View File

@ -1,113 +0,0 @@
import {
TableContainer,
Paper,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Typography,
Box,
useTheme,
} from "@mui/material";
import HttpStatusIcon from "./HttpStatusCode";
import CenteredPaper from "../CenteredPaper";
import { RequestLog } from "../../lib/requestLogs";
interface Props {
logs: RequestLog[];
selectedReqLogId?: string;
onLogClick(requestId: string): void;
}
export default function RequestList({ logs, onLogClick, selectedReqLogId }: Props): JSX.Element {
return (
<div>
<RequestListTable onLogClick={onLogClick} logs={logs} selectedReqLogId={selectedReqLogId} />
{logs.length === 0 && (
<Box my={1}>
<CenteredPaper>
<Typography>No logs found.</Typography>
</CenteredPaper>
</Box>
)}
</div>
);
}
interface RequestListTableProps {
logs: RequestLog[];
selectedReqLogId?: string;
onLogClick(requestId: string): void;
}
function RequestListTable({ logs, selectedReqLogId, onLogClick }: RequestListTableProps): JSX.Element {
const theme = useTheme();
return (
<TableContainer
component={Paper}
style={{
minHeight: logs.length ? 200 : 0,
height: logs.length ? "24vh" : "inherit",
}}
>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>Method</TableCell>
<TableCell>Origin</TableCell>
<TableCell>Path</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{logs.map(({ id, method, url, response }) => {
const { origin, pathname, search, hash } = new URL(url);
const cellStyle = {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
} as any;
return (
<TableRow
key={id}
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 sx={{ ...cellStyle, maxWidth: "100px" }}>{origin}</TableCell>
<TableCell sx={{ ...cellStyle, maxWidth: "200px" }}>
{decodeURIComponent(pathname + search + hash)}
</TableCell>
<TableCell style={{ maxWidth: "100px" }}>
{response && (
<div>
<HttpStatusIcon status={response.statusCode} />{" "}
<code>
{response.statusCode} {response.statusReason}
</code>
</div>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}

View File

@ -1,47 +0,0 @@
import { Typography, Box, Divider } from "@mui/material";
import HttpStatusIcon from "./HttpStatusCode";
import Editor from "./Editor";
import HttpHeadersTable from "./HttpHeadersTable";
interface Props {
response: {
proto: string;
statusCode: number;
statusReason: string;
headers: Array<{ key: string; value: string }>;
body?: string;
};
}
function ResponseDetail({ response }: Props): JSX.Element {
const contentType = response.headers.find((header) => header.key === "Content-Type")?.value;
return (
<div>
<Box p={2}>
<Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
Response
</Typography>
<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" }}>
{response.proto}
</Typography>
</Typography>{" "}
{response.statusCode} {response.statusReason}
</Typography>
</Box>
<Divider />
<Box p={2}>
<HttpHeadersTable headers={response.headers} />
</Box>
{response.body && <Editor content={response.body} contentType={contentType} />}
</div>
);
}
export default ResponseDetail;

View File

@ -1,16 +0,0 @@
import { gql, useMutation } from "@apollo/client";
import { HTTP_REQUEST_LOGS } from "./useHttpRequestLogs";
const CLEAR_HTTP_REQUEST_LOG = gql`
mutation ClearHTTPRequestLog {
clearHTTPRequestLog {
success
}
}
`;
export function useClearHTTPRequestLog() {
return useMutation(CLEAR_HTTP_REQUEST_LOG, {
refetchQueries: [{ query: HTTP_REQUEST_LOGS }],
});
}

View File

@ -1,22 +0,0 @@
import { gql, useQuery } from "@apollo/client";
export const HTTP_REQUEST_LOGS = gql`
query HttpRequestLogs {
httpRequestLogs {
id
method
url
timestamp
response {
statusCode
statusReason
}
}
}
`;
export function useHttpRequestLogs() {
return useQuery(HTTP_REQUEST_LOGS, {
pollInterval: 1000,
});
}

View File

@ -1,4 +1,12 @@
import React from "react";
import AltRouteIcon from "@mui/icons-material/AltRoute";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import FolderIcon from "@mui/icons-material/Folder";
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
import HomeIcon from "@mui/icons-material/Home";
import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
import MenuIcon from "@mui/icons-material/Menu";
import SendIcon from "@mui/icons-material/Send";
import {
Theme,
useTheme,
@ -12,29 +20,28 @@ import {
CSSObject,
Box,
ListItemText,
Badge,
} 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 "@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";
import React, { useState } from "react";
import { useActiveProject } from "lib/ActiveProjectContext";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
export enum Page {
Home,
GetStarted,
Intercept,
Projects,
ProxySetup,
ProxyLogs,
Sender,
Scope,
Settings,
}
const drawerWidth = 240;
@ -132,8 +139,10 @@ interface Props {
}
export function Layout({ title, page, children }: Props): JSX.Element {
const activeProject = useActiveProject();
const interceptedRequests = useInterceptedRequests();
const theme = useTheme();
const [open, setOpen] = React.useState(false);
const [open, setOpen] = useState(false);
const handleDrawerOpen = () => {
setOpen(true);
@ -151,7 +160,7 @@ export function Layout({ title, page, children }: Props): JSX.Element {
});
return (
<Box sx={{ display: "flex" }}>
<Box sx={{ display: "flex", height: "100%" }}>
<AppBar position="fixed" open={open}>
<Toolbar>
<IconButton
@ -200,17 +209,29 @@ export function Layout({ title, page, children }: Props): JSX.Element {
</ListItemButton>
</Link>
<Link href="/proxy/logs" passHref>
<ListItemButton key="proxyLogs" selected={page === Page.ProxyLogs}>
<Tooltip title="Proxy">
<ListItemButton key="proxyLogs" disabled={!activeProject} selected={page === Page.ProxyLogs}>
<Tooltip title="Proxy logs">
<ListItemIcon>
<SettingsEthernetIcon />
<FormatListBulletedIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Proxy" />
<ListItemText primary="Logs" />
</ListItemButton>
</Link>
<Link href="/proxy/intercept" passHref>
<ListItemButton key="proxyIntercept" disabled={!activeProject} selected={page === Page.Intercept}>
<Tooltip title="Proxy intercept">
<ListItemIcon>
<Badge color="error" badgeContent={interceptedRequests?.length || 0}>
<AltRouteIcon />
</Badge>
</ListItemIcon>
</Tooltip>
<ListItemText primary="Intercept" />
</ListItemButton>
</Link>
<Link href="/sender" passHref>
<ListItemButton key="sender" selected={page === Page.Sender}>
<ListItemButton key="sender" disabled={!activeProject} selected={page === Page.Sender}>
<Tooltip title="Sender">
<ListItemIcon>
<SendIcon />
@ -220,7 +241,7 @@ export function Layout({ title, page, children }: Props): JSX.Element {
</ListItemButton>
</Link>
<Link href="/scope" passHref>
<ListItemButton key="scope" selected={page === Page.Scope}>
<ListItemButton key="scope" disabled={!activeProject} selected={page === Page.Scope}>
<Tooltip title="Scope">
<ListItemIcon>
<LocationSearchingIcon />
@ -241,12 +262,9 @@ export function Layout({ title, page, children }: Props): JSX.Element {
</Link>
</List>
</Drawer>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<DrawerHeader />
<Box component="main" sx={{ flexGrow: 1, mx: 3, mt: 11 }}>
{children}
</Box>
</Box>
);
}
export default Layout;

View File

@ -0,0 +1,366 @@
import CancelIcon from "@mui/icons-material/Cancel";
import DownloadIcon from "@mui/icons-material/Download";
import SendIcon from "@mui/icons-material/Send";
import SettingsIcon from "@mui/icons-material/Settings";
import { Alert, Box, Button, CircularProgress, IconButton, Tooltip, Typography } from "@mui/material";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import { KeyValuePair } from "lib/components/KeyValuePair";
import Link from "lib/components/Link";
import RequestTabs from "lib/components/RequestTabs";
import ResponseStatus from "lib/components/ResponseStatus";
import ResponseTabs from "lib/components/ResponseTabs";
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
import {
HttpProtocol,
HttpRequest,
useCancelRequestMutation,
useCancelResponseMutation,
useGetInterceptedRequestQuery,
useModifyRequestMutation,
useModifyResponseMutation,
} from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL";
import updateKeyPairItem from "lib/updateKeyPairItem";
import updateURLQueryParams from "lib/updateURLQueryParams";
function EditRequest(): JSX.Element {
const router = useRouter();
const interceptedRequests = useInterceptedRequests();
useEffect(() => {
// If there's no request selected and there are pending reqs, navigate to
// the first one in the list. This helps you quickly review/handle reqs
// without having to manually select the next one in the requests table.
if (router.isReady && !router.query.id && interceptedRequests?.length) {
const req = interceptedRequests[0];
router.replace(`/proxy/intercept?id=${req.id}`);
}
}, [router, interceptedRequests]);
const reqId = router.query.id as string | undefined;
const [method, setMethod] = useState(HttpMethod.Get);
const [url, setURL] = useState("");
const [proto, setProto] = useState(HttpProto.Http20);
const [queryParams, setQueryParams] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [reqHeaders, setReqHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [resHeaders, setResHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [reqBody, setReqBody] = useState("");
const [resBody, setResBody] = useState("");
const handleQueryParamChange = (key: string, value: string, idx: number) => {
setQueryParams((prev) => {
const updated = updateKeyPairItem(key, value, idx, prev);
setURL((prev) => updateURLQueryParams(prev, updated));
return updated;
});
};
const handleQueryParamDelete = (idx: number) => {
setQueryParams((prev) => {
const updated = prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length));
setURL((prev) => updateURLQueryParams(prev, updated));
return updated;
});
};
const handleReqHeaderChange = (key: string, value: string, idx: number) => {
setReqHeaders((prev) => updateKeyPairItem(key, value, idx, prev));
};
const handleReqHeaderDelete = (idx: number) => {
setReqHeaders((prev) => prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length)));
};
const handleResHeaderChange = (key: string, value: string, idx: number) => {
setResHeaders((prev) => updateKeyPairItem(key, value, idx, prev));
};
const handleResHeaderDelete = (idx: number) => {
setResHeaders((prev) => prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length)));
};
const handleURLChange = (url: string) => {
setURL(url);
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex === -1) {
setQueryParams([{ key: "", value: "" }]);
return;
}
const newQueryParams = queryParamsFromURL(url);
// Push empty row.
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
};
const getReqResult = useGetInterceptedRequestQuery({
variables: { id: reqId as string },
skip: reqId === undefined,
onCompleted: ({ interceptedRequest }) => {
if (!interceptedRequest) {
return;
}
setURL(interceptedRequest.url);
setMethod(interceptedRequest.method);
setReqBody(interceptedRequest.body || "");
const newQueryParams = queryParamsFromURL(interceptedRequest.url);
// Push empty row.
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
const newReqHeaders = interceptedRequest.headers || [];
setReqHeaders([...newReqHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
setResBody(interceptedRequest.response?.body || "");
const newResHeaders = interceptedRequest.response?.headers || [];
setResHeaders([...newResHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
},
});
const interceptedReq =
reqId && !getReqResult?.data?.interceptedRequest?.response ? getReqResult?.data?.interceptedRequest : undefined;
const interceptedRes = reqId ? getReqResult?.data?.interceptedRequest?.response : undefined;
const [modifyRequest, modifyReqResult] = useModifyRequestMutation();
const [cancelRequest, cancelReqResult] = useCancelRequestMutation();
const [modifyResponse, modifyResResult] = useModifyResponseMutation();
const [cancelResponse, cancelResResult] = useCancelResponseMutation();
const onActionCompleted = () => {
setURL("");
setMethod(HttpMethod.Get);
setReqBody("");
setQueryParams([]);
setReqHeaders([]);
router.replace(`/proxy/intercept`);
};
const handleFormSubmit: React.FormEventHandler = (e) => {
e.preventDefault();
if (interceptedReq) {
modifyRequest({
variables: {
request: {
id: interceptedReq.id,
url,
method,
proto: httpProtoMap.get(proto) || HttpProtocol.Http20,
headers: reqHeaders.filter((kv) => kv.key !== ""),
body: reqBody || undefined,
},
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedReq.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
}
if (interceptedRes) {
modifyResponse({
variables: {
response: {
requestID: interceptedRes.id,
proto: interceptedRes.proto, // TODO: Allow modifying
statusCode: interceptedRes.statusCode, // TODO: Allow modifying
statusReason: interceptedRes.statusReason, // TODO: Allow modifying
headers: resHeaders.filter((kv) => kv.key !== ""),
body: resBody || undefined,
},
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedRes.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
}
};
const handleReqCancelClick = () => {
if (!interceptedReq) {
return;
}
cancelRequest({
variables: {
id: interceptedReq.id,
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedReq.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
};
const handleResCancelClick = () => {
if (!interceptedRes) {
return;
}
cancelResponse({
variables: {
requestID: interceptedRes.id,
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedRes.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
};
return (
<Box display="flex" flexDirection="column" height="100%" gap={2}>
<Box component="form" autoComplete="off" onSubmit={handleFormSubmit}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<UrlBar
method={method}
onMethodChange={interceptedReq ? setMethod : undefined}
url={url.toString()}
onUrlChange={interceptedReq ? handleURLChange : undefined}
proto={proto}
onProtoChange={interceptedReq ? setProto : undefined}
sx={{ flex: "1 auto" }}
/>
{!interceptedRes && (
<>
<Button
variant="contained"
disableElevation
type="submit"
disabled={!interceptedReq || modifyReqResult.loading || cancelReqResult.loading}
startIcon={modifyReqResult.loading ? <CircularProgress size={22} /> : <SendIcon />}
>
Send
</Button>
<Button
variant="contained"
color="error"
disableElevation
onClick={handleReqCancelClick}
disabled={!interceptedReq || modifyReqResult.loading || cancelReqResult.loading}
startIcon={cancelReqResult.loading ? <CircularProgress size={22} /> : <CancelIcon />}
>
Cancel
</Button>
</>
)}
{interceptedRes && (
<>
<Button
variant="contained"
disableElevation
type="submit"
disabled={modifyResResult.loading || cancelResResult.loading}
endIcon={modifyResResult.loading ? <CircularProgress size={22} /> : <DownloadIcon />}
>
Receive
</Button>
<Button
variant="contained"
color="error"
disableElevation
onClick={handleResCancelClick}
disabled={modifyResResult.loading || cancelResResult.loading}
endIcon={cancelResResult.loading ? <CircularProgress size={22} /> : <CancelIcon />}
>
Cancel
</Button>
</>
)}
<Tooltip title="Intercept settings">
<IconButton LinkComponent={Link} href="/settings#intercept">
<SettingsIcon />
</IconButton>
</Tooltip>
</Box>
{modifyReqResult.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{modifyReqResult.error.message}
</Alert>
)}
{cancelReqResult.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{cancelReqResult.error.message}
</Alert>
)}
</Box>
<Box flex="1 auto" overflow="scroll">
{interceptedReq && (
<Box sx={{ height: "100%", pb: 2 }}>
<Typography variant="overline" color="textSecondary" sx={{ position: "absolute", right: 0, mt: 1.2 }}>
Request
</Typography>
<RequestTabs
queryParams={interceptedReq ? queryParams : []}
headers={interceptedReq ? reqHeaders : []}
body={reqBody}
onQueryParamChange={interceptedReq ? handleQueryParamChange : undefined}
onQueryParamDelete={interceptedReq ? handleQueryParamDelete : undefined}
onHeaderChange={interceptedReq ? handleReqHeaderChange : undefined}
onHeaderDelete={interceptedReq ? handleReqHeaderDelete : undefined}
onBodyChange={interceptedReq ? setReqBody : undefined}
/>
</Box>
)}
{interceptedRes && (
<Box sx={{ height: "100%", pb: 2 }}>
<Box sx={{ position: "absolute", right: 0, mt: 1.4 }}>
<Typography variant="overline" color="textSecondary" sx={{ float: "right", ml: 3 }}>
Response
</Typography>
{interceptedRes && (
<Box sx={{ float: "right", mt: 0.2 }}>
<ResponseStatus
proto={interceptedRes.proto}
statusCode={interceptedRes.statusCode}
statusReason={interceptedRes.statusReason}
/>
</Box>
)}
</Box>
<ResponseTabs
headers={interceptedRes ? resHeaders : []}
body={resBody}
onHeaderChange={interceptedRes ? handleResHeaderChange : undefined}
onHeaderDelete={interceptedRes ? handleResHeaderDelete : undefined}
onBodyChange={interceptedRes ? setResBody : undefined}
hasResponse={interceptedRes !== undefined && interceptedRes !== null}
/>
</Box>
)}
</Box>
</Box>
);
}
export default EditRequest;

View File

@ -0,0 +1,21 @@
import { Box } from "@mui/material";
import EditRequest from "./EditRequest";
import Requests from "./Requests";
import SplitPane from "lib/components/SplitPane";
export default function Sender(): JSX.Element {
return (
<Box sx={{ height: "100%", position: "relative" }}>
<SplitPane split="horizontal" size="70%">
<Box sx={{ width: "100%", pt: "0.75rem" }}>
<EditRequest />
</Box>
<Box sx={{ height: "100%", overflow: "scroll" }}>
<Requests />
</Box>
</SplitPane>
</Box>
);
}

View File

@ -0,0 +1,33 @@
import { Box, Paper, Typography } from "@mui/material";
import { useRouter } from "next/router";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import RequestsTable from "lib/components/RequestsTable";
function Requests(): JSX.Element {
const interceptedRequests = useInterceptedRequests();
const router = useRouter();
const activeId = router.query.id as string | undefined;
const handleRowClick = (id: string) => {
router.push(`/proxy/intercept?id=${id}`);
};
return (
<Box>
{interceptedRequests && interceptedRequests.length > 0 && (
<RequestsTable requests={interceptedRequests} onRowClick={handleRowClick} activeRowId={activeId} />
)}
<Box sx={{ mt: 2, height: "100%" }}>
{interceptedRequests?.length === 0 && (
<Paper variant="centered">
<Typography>No pending intercepted requests.</Typography>
</Paper>
)}
</Box>
</Box>
);
}
export default Requests;

View File

@ -0,0 +1,5 @@
mutation CancelRequest($id: ID!) {
cancelRequest(id: $id) {
success
}
}

View File

@ -0,0 +1,5 @@
mutation CancelResponse($requestID: ID!) {
cancelResponse(requestID: $requestID) {
success
}
}

View File

@ -0,0 +1,24 @@
query GetInterceptedRequest($id: ID!) {
interceptedRequest(id: $id) {
id
url
method
proto
headers {
key
value
}
body
response {
id
proto
statusCode
statusReason
headers {
key
value
}
body
}
}
}

View File

@ -0,0 +1,5 @@
mutation ModifyRequest($request: ModifyRequestInput!) {
modifyRequest(request: $request) {
success
}
}

View File

@ -0,0 +1,5 @@
mutation ModifyResponse($response: ModifyResponseInput!) {
modifyResponse(response: $response) {
success
}
}

View File

@ -0,0 +1,67 @@
import AddIcon from "@mui/icons-material/Add";
import { Box, Button, CircularProgress, TextField, Typography } from "@mui/material";
import React, { useState } from "react";
import useOpenProjectMutation from "../hooks/useOpenProjectMutation";
import { useCreateProjectMutation } from "lib/graphql/generated";
function NewProject(): JSX.Element {
const [name, setName] = useState("");
const [createProject, createProjResult] = useCreateProjectMutation({
onCompleted(data) {
setName("");
if (data?.createProject) {
openProject({ variables: { id: data.createProject?.id } });
}
},
});
const [openProject, openProjResult] = useOpenProjectMutation();
const handleCreateAndOpenProjectForm = (e: React.SyntheticEvent) => {
e.preventDefault();
createProject({ variables: { name } });
};
return (
<div>
<Box mb={3}>
<Typography variant="h6">New project</Typography>
</Box>
<form onSubmit={handleCreateAndOpenProjectForm} autoComplete="off">
<TextField
sx={{
mr: 2,
}}
color="primary"
size="small"
label="Project name"
placeholder="Project name…"
onChange={(e) => setName(e.target.value)}
error={Boolean(createProjResult.error || openProjResult.error)}
helperText={
(createProjResult.error && createProjResult.error.message) ||
(openProjResult.error && openProjResult.error.message)
}
/>
<Button
type="submit"
variant="contained"
color="primary"
size="large"
sx={{
pt: 0.9,
pb: 0.7,
}}
disabled={createProjResult.loading || openProjResult.loading}
startIcon={createProjResult.loading || openProjResult.loading ? <CircularProgress size={22} /> : <AddIcon />}
>
Create & open project
</Button>
</form>
</div>
);
}
export default NewProject;

View File

@ -1,4 +1,9 @@
import { gql, useMutation, useQuery } from "@apollo/client";
import CloseIcon from "@mui/icons-material/Close";
import DeleteIcon from "@mui/icons-material/Delete";
import DescriptionIcon from "@mui/icons-material/Description";
import LaunchIcon from "@mui/icons-material/Launch";
import SettingsIcon from "@mui/icons-material/Settings";
import { Alert } from "@mui/lab";
import {
Avatar,
Box,
@ -21,102 +26,27 @@ import {
Typography,
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";
import { Project } from "../../lib/Project";
import useOpenProjectMutation from "../hooks/useOpenProjectMutation";
const PROJECTS = gql`
query Projects {
projects {
id
name
isActive
}
}
`;
const OPEN_PROJECT = gql`
mutation OpenProject($id: ID!) {
openProject(id: $id) {
id
name
isActive
}
}
`;
const CLOSE_PROJECT = gql`
mutation CloseProject {
closeProject {
success
}
}
`;
const DELETE_PROJECT = gql`
mutation DeleteProject($id: ID!) {
deleteProject(id: $id) {
success
}
}
`;
import Link, { NextLinkComposed } from "lib/components/Link";
import {
ProjectsQuery,
useCloseProjectMutation,
useDeleteProjectMutation,
useProjectsQuery,
} from "lib/graphql/generated";
function ProjectList(): JSX.Element {
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 [closeProject, { error: closeProjErr }] = useMutation(CLOSE_PROJECT, {
const projResult = useProjectsQuery({ fetchPolicy: "network-only" });
const [openProject, openProjResult] = useOpenProjectMutation();
const [closeProject, closeProjResult] = useCloseProjectMutation({
errorPolicy: "all",
onError: () => {},
onCompleted() {
closeProjResult.client.resetStore();
},
update(cache) {
cache.modify({
fields: {
@ -133,9 +63,8 @@ function ProjectList(): JSX.Element {
});
},
});
const [deleteProject, { loading: deleteProjLoading, error: deleteProjErr }] = useMutation(DELETE_PROJECT, {
const [deleteProject, deleteProjResult] = useDeleteProjectMutation({
errorPolicy: "all",
onError: () => {},
update(cache) {
cache.modify({
fields: {
@ -149,14 +78,16 @@ function ProjectList(): JSX.Element {
},
});
const [deleteProj, setDeleteProj] = useState<Project>();
const [deleteProj, setDeleteProj] = useState<ProjectsQuery["projects"][number]>();
const [deleteDiagOpen, setDeleteDiagOpen] = useState(false);
const handleDeleteButtonClick = (project: any) => {
const handleDeleteButtonClick = (project: ProjectsQuery["projects"][number]) => {
setDeleteProj(project);
setDeleteDiagOpen(true);
};
const handleDeleteConfirm = () => {
deleteProject({ variables: { id: deleteProj?.id } });
if (deleteProj) {
deleteProject({ variables: { id: deleteProj.id } });
}
};
const handleDeleteCancel = () => {
setDeleteDiagOpen(false);
@ -180,7 +111,9 @@ function ProjectList(): JSX.Element {
<DialogContentText>
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>}
{deleteProjResult.error && (
<Alert severity="error">Error closing project: {deleteProjResult.error.message}</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} autoFocus color="secondary" variant="contained">
@ -195,7 +128,7 @@ function ProjectList(): JSX.Element {
},
}}
onClick={handleDeleteConfirm}
disabled={deleteProjLoading}
disabled={deleteProjResult.loading}
variant="contained"
>
Delete
@ -219,16 +152,18 @@ function ProjectList(): JSX.Element {
</Box>
<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>}
{projResult.loading && <CircularProgress />}
{projResult.error && <Alert severity="error">Error fetching projects: {projResult.error.message}</Alert>}
{openProjResult.error && <Alert severity="error">Error opening project: {openProjResult.error.message}</Alert>}
{closeProjResult.error && (
<Alert severity="error">Error closing project: {closeProjResult.error.message}</Alert>
)}
</Box>
{projData && projData.projects.length > 0 && (
{projResult.data && projResult.data.projects.length > 0 && (
<Paper>
<List>
{projData.projects.map((project) => (
{projResult.data.projects.map((project) => (
<ListItem key={project.id}>
<ListItemAvatar>
<Avatar
@ -246,6 +181,11 @@ function ProjectList(): JSX.Element {
{project.name} {project.isActive && <em>(Active)</em>}
</ListItemText>
<ListItemSecondaryAction>
<Tooltip title="Project settings">
<IconButton LinkComponent={Link} href="/settings" disabled={!project.isActive}>
<SettingsIcon />
</IconButton>
</Tooltip>
{project.isActive && (
<Tooltip title="Close project">
<IconButton onClick={() => closeProject()}>
@ -257,7 +197,7 @@ function ProjectList(): JSX.Element {
<Tooltip title="Open project">
<span>
<IconButton
disabled={openProjLoading || projLoading}
disabled={openProjResult.loading || projResult.loading}
onClick={() =>
openProject({
variables: { id: project.id },
@ -282,7 +222,7 @@ function ProjectList(): JSX.Element {
</List>
</Paper>
)}
{projData?.projects.length === 0 && (
{projResult.data?.projects.length === 0 && (
<Alert severity="info">There are no projects. Create one to get started.</Alert>
)}
</div>

View File

@ -0,0 +1,15 @@
query ActiveProject {
activeProject {
id
name
isActive
settings {
intercept {
requestsEnabled
responsesEnabled
requestFilter
responseFilter
}
}
}
}

View File

@ -0,0 +1,5 @@
mutation CloseProject {
closeProject {
success
}
}

View File

@ -0,0 +1,6 @@
mutation CreateProject($name: String!) {
createProject(name: $name) {
id
name
}
}

View File

@ -0,0 +1,5 @@
mutation DeleteProject($id: ID!) {
deleteProject(id: $id) {
success
}
}

View File

@ -0,0 +1,7 @@
mutation OpenProject($id: ID!) {
openProject(id: $id) {
id
name
isActive
}
}

View File

@ -0,0 +1,7 @@
query Projects {
projects {
id
name
isActive
}
}

View File

@ -0,0 +1,47 @@
import { gql } from "@apollo/client";
import { useOpenProjectMutation as _useOpenProjectMutation } from "lib/graphql/generated";
export default function useOpenProjectMutation() {
return _useOpenProjectMutation({
errorPolicy: "all",
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: data?.openProject,
fragment: gql`
fragment OpenProject on Project {
id
name
isActive
type
}
`,
});
return DELETE;
},
httpRequestLogFilter(_, { DELETE }) {
return DELETE;
},
},
});
},
});
}

View File

@ -0,0 +1,61 @@
import AltRouteIcon from "@mui/icons-material/AltRoute";
import DeleteIcon from "@mui/icons-material/Delete";
import { Alert } from "@mui/lab";
import { Badge, Button, IconButton, Tooltip } from "@mui/material";
import Link from "next/link";
import { useActiveProject } from "lib/ActiveProjectContext";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
import { HttpRequestLogsDocument, useClearHttpRequestLogMutation } from "lib/graphql/generated";
function Actions(): JSX.Element {
const activeProject = useActiveProject();
const interceptedRequests = useInterceptedRequests();
const [clearHTTPRequestLog, clearLogsResult] = useClearHttpRequestLogMutation({
refetchQueries: [{ query: HttpRequestLogsDocument }],
});
const clearHTTPConfirmationDialog = useConfirmationDialog();
return (
<div>
<ConfirmationDialog
isOpen={clearHTTPConfirmationDialog.isOpen}
onClose={clearHTTPConfirmationDialog.close}
onConfirm={clearHTTPRequestLog}
>
All proxy logs are going to be removed. This action cannot be undone.
</ConfirmationDialog>
{clearLogsResult.error && <Alert severity="error">Failed to clear HTTP logs: {clearLogsResult.error}</Alert>}
{(activeProject?.settings.intercept.requestsEnabled || activeProject?.settings.intercept.responsesEnabled) && (
<Link href="/proxy/intercept/?id=" passHref>
<Button
variant="contained"
disabled={interceptedRequests === null || interceptedRequests.length === 0}
color="primary"
component="a"
size="large"
startIcon={
<Badge color="error" badgeContent={interceptedRequests?.length || 0}>
<AltRouteIcon />
</Badge>
}
sx={{ mr: 1 }}
>
Review Intercepted
</Button>
</Link>
)}
<Tooltip title="Clear all">
<IconButton onClick={clearHTTPConfirmationDialog.open}>
<DeleteIcon />
</IconButton>
</Tooltip>
</div>
);
}
export default Actions;

View File

@ -0,0 +1,57 @@
import Alert from "@mui/lab/Alert";
import { Box, CircularProgress, Paper, Typography } from "@mui/material";
import RequestDetail from "./RequestDetail";
import Response from "lib/components/Response";
import SplitPane from "lib/components/SplitPane";
import { useHttpRequestLogQuery } from "lib/graphql/generated";
interface Props {
id?: string;
}
function LogDetail({ id }: Props): JSX.Element {
const { loading, error, data } = useHttpRequestLogQuery({
variables: { id: id as string },
skip: id === undefined,
});
if (loading) {
return <CircularProgress />;
}
if (error) {
return <Alert severity="error">Error fetching logs details: {error.message}</Alert>;
}
if (data && !data.httpRequestLog) {
return (
<Alert severity="warning">
Request <strong>{id}</strong> was not found.
</Alert>
);
}
if (!data?.httpRequestLog) {
return (
<Paper variant="centered" sx={{ mt: 2 }}>
<Typography>Select a log entry</Typography>
</Paper>
);
}
const reqLog = data.httpRequestLog;
return (
<SplitPane split="vertical" size={"50%"}>
<RequestDetail request={reqLog} />
{reqLog.response && (
<Box sx={{ height: "100%", pt: 1, pl: 2, pb: 2 }}>
<Response response={reqLog.response} />
</Box>
)}
</SplitPane>
);
}
export default LogDetail;

View File

@ -0,0 +1,47 @@
import { Typography, Box } from "@mui/material";
import React from "react";
import RequestTabs from "lib/components/RequestTabs";
import { HttpRequestLogQuery } from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL";
interface Props {
request: NonNullable<HttpRequestLogQuery["httpRequestLog"]>;
}
function RequestDetail({ request }: Props): JSX.Element {
const { method, url, headers, body } = request;
const parsedUrl = new URL(url);
return (
<Box sx={{ height: "100%", display: "flex", flexDirection: "column", pr: 2, pb: 2 }}>
<Box sx={{ p: 2, pb: 0 }}>
<Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
Request
</Typography>
<Typography
variant="h6"
component="h2"
sx={{
fontSize: "1rem",
fontFamily: "'JetBrains Mono', monospace",
display: "block",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
pr: 2,
}}
>
{method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}
</Typography>
</Box>
<Box flex="1 auto" overflow="scroll">
<RequestTabs headers={headers} queryParams={queryParamsFromURL(url)} body={body} />
</Box>
</Box>
);
}
export default RequestDetail;

View File

@ -0,0 +1,137 @@
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import {
Alert,
Box,
IconButton,
Link,
MenuItem,
Snackbar,
styled,
TableCell,
TableCellProps,
Tooltip,
} from "@mui/material";
import { useRouter } from "next/router";
import { useState } from "react";
import Actions from "./Actions";
import LogDetail from "./LogDetail";
import Search from "./Search";
import RequestsTable from "lib/components/RequestsTable";
import SplitPane from "lib/components/SplitPane";
import useContextMenu from "lib/components/useContextMenu";
import { useCreateSenderRequestFromHttpRequestLogMutation, useHttpRequestLogsQuery } from "lib/graphql/generated";
const ActionsTableCell = styled(TableCell)<TableCellProps>(() => ({
paddingTop: 0,
paddingBottom: 0,
}));
export function RequestLogs(): JSX.Element {
const router = useRouter();
const id = router.query.id as string | undefined;
const { data } = useHttpRequestLogsQuery({
pollInterval: 1000,
});
const [createSenderReqFromLog] = useCreateSenderRequestFromHttpRequestLogMutation({
onCompleted({ createSenderRequestFromHttpRequestLog }) {
const { id } = createSenderRequestFromHttpRequestLog;
setNewSenderReqId(id);
setCopiedReqNotifOpen(true);
},
});
const [copyToSenderId, setCopyToSenderId] = useState("");
const [Menu, handleContextMenu, handleContextMenuClose] = useContextMenu();
const handleCopyToSenderClick = () => {
createSenderReqFromLog({
variables: {
id: copyToSenderId,
},
});
handleContextMenuClose();
};
const [newSenderReqId, setNewSenderReqId] = useState("");
const [copiedReqNotifOpen, setCopiedReqNotifOpen] = useState(false);
const handleCloseCopiedNotif = (_: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
setCopiedReqNotifOpen(false);
};
const handleRowClick = (id: string) => {
router.push(`/proxy/logs?id=${id}`);
};
const handleRowContextClick = (e: React.MouseEvent, id: string) => {
setCopyToSenderId(id);
handleContextMenu(e);
};
const actionsCell = (id: string) => (
<ActionsTableCell>
<Tooltip title="Copy to Sender">
<IconButton
size="small"
onClick={() => {
setCopyToSenderId(id);
createSenderReqFromLog({
variables: {
id,
},
});
}}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</ActionsTableCell>
);
return (
<Box display="flex" flexDirection="column" height="100%">
<Box display="flex">
<Box flex="1 auto">
<Search />
</Box>
<Box pt={0.5}>
<Actions />
</Box>
</Box>
<Box sx={{ display: "flex", flex: "1 auto", position: "relative" }}>
<SplitPane split="horizontal" size={"40%"}>
<Box sx={{ width: "100%", height: "100%", pb: 2 }}>
<Box sx={{ width: "100%", height: "100%", overflow: "scroll" }}>
<Menu>
<MenuItem onClick={handleCopyToSenderClick}>Copy request to Sender</MenuItem>
</Menu>
<Snackbar
open={copiedReqNotifOpen}
autoHideDuration={3000}
onClose={handleCloseCopiedNotif}
anchorOrigin={{ horizontal: "center", vertical: "bottom" }}
>
<Alert onClose={handleCloseCopiedNotif} severity="info">
Request was copied. <Link href={`/sender?id=${newSenderReqId}`}>Edit in Sender.</Link>
</Alert>
</Snackbar>
<RequestsTable
requests={data?.httpRequestLogs || []}
activeRowId={id}
actionsCell={actionsCell}
onRowClick={handleRowClick}
onContextMenu={handleRowContextClick}
/>
</Box>
</Box>
<LogDetail id={id} />
</SplitPane>
</Box>
</Box>
);
}

View File

@ -1,3 +1,6 @@
import FilterListIcon from "@mui/icons-material/FilterList";
import SearchIcon from "@mui/icons-material/Search";
import { Alert } from "@mui/lab";
import {
Box,
Checkbox,
@ -11,70 +14,37 @@ import {
useTheme,
} 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 "@mui/lab";
import { useClearHTTPRequestLog } from "./hooks/useClearHTTPRequestLog";
import { ConfirmationDialog, useConfirmationDialog } from "./ConfirmationDialog";
const FILTER = gql`
query HttpRequestLogFilter {
httpRequestLogFilter {
onlyInScope
searchExpression
}
}
`;
const SET_FILTER = gql`
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
setHttpRequestLogFilter(filter: $filter) {
onlyInScope
searchExpression
}
}
`;
export interface SearchFilter {
onlyInScope: boolean;
searchExpression: string;
}
import {
HttpRequestLogFilterDocument,
useHttpRequestLogFilterQuery,
useSetHttpRequestLogFilterMutation,
} from "lib/graphql/generated";
import { withoutTypename } from "lib/graphql/omitTypename";
function Search(): JSX.Element {
const theme = useTheme();
const [searchExpr, setSearchExpr] = useState("");
const {
loading: filterLoading,
error: filterErr,
data: filter,
} = useQuery(FILTER, {
const filterResult = useHttpRequestLogFilterQuery({
onCompleted: (data) => {
setSearchExpr(data.httpRequestLogFilter?.searchExpression || "");
},
});
const filter = filterResult.data?.httpRequestLogFilter;
const [setFilterMutate, { error: setFilterErr, loading: setFilterLoading }] = useMutation<{
setHttpRequestLogFilter: SearchFilter | null;
}>(SET_FILTER, {
const [setFilterMutate, setFilterResult] = useSetHttpRequestLogFilterMutation({
update(cache, { data }) {
cache.writeQuery({
query: FILTER,
query: HttpRequestLogFilterDocument,
data: {
httpRequestLogFilter: data?.setHttpRequestLogFilter,
},
});
},
onError: () => {},
});
const [clearHTTPRequestLog, clearHTTPRequestLogResult] = useClearHTTPRequestLog();
const clearHTTPConfirmationDialog = useConfirmationDialog();
const filterRef = useRef<HTMLFormElement>(null);
const [filterOpen, setFilterOpen] = useState(false);
@ -82,7 +52,7 @@ function Search(): JSX.Element {
setFilterMutate({
variables: {
filter: {
...withoutTypename(filter?.httpRequestLogFilter),
...withoutTypename(filter),
searchExpression: searchExpr,
},
},
@ -100,13 +70,13 @@ function Search(): JSX.Element {
return (
<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 fetching filter" error={filterResult.error} />
<Error prefix="Error setting filter" error={setFilterResult.error} />
<Box style={{ display: "flex", flex: 1 }}>
<ClickAwayListener onClickAway={handleClickAway}>
<Paper
component="form"
autoComplete="off"
onSubmit={handleSubmit}
ref={filterRef}
sx={{
@ -121,10 +91,10 @@ function Search(): JSX.Element {
onClick={() => setFilterOpen(!filterOpen)}
sx={{
p: 1,
color: filter?.httpRequestLogFilter?.onlyInScope ? "primary.main" : "inherit",
color: filter?.onlyInScope ? "primary.main" : "inherit",
}}
>
{filterLoading || setFilterLoading ? (
{filterResult.loading || setFilterResult.loading ? (
<CircularProgress sx={{ color: theme.palette.text.primary }} size={23} />
) : (
<FilterListIcon />
@ -140,6 +110,8 @@ function Search(): JSX.Element {
value={searchExpr}
onChange={(e) => setSearchExpr(e.target.value)}
onFocus={() => setFilterOpen(true)}
autoCorrect="false"
spellCheck="false"
/>
<Tooltip title="Search">
<IconButton type="submit" sx={{ padding: 1.25 }}>
@ -162,13 +134,13 @@ function Search(): JSX.Element {
<FormControlLabel
control={
<Checkbox
checked={filter?.httpRequestLogFilter?.onlyInScope ? true : false}
disabled={filterLoading || setFilterLoading}
checked={filter?.onlyInScope ? true : false}
disabled={filterResult.loading || setFilterResult.loading}
onChange={(e) =>
setFilterMutate({
variables: {
filter: {
...withoutTypename(filter?.httpRequestLogFilter),
...withoutTypename(filter),
onlyInScope: e.target.checked,
},
},
@ -182,21 +154,7 @@ function Search(): JSX.Element {
</Popper>
</Paper>
</ClickAwayListener>
<Box style={{ marginLeft: "auto" }}>
<Tooltip title="Clear all">
<IconButton onClick={clearHTTPConfirmationDialog.open}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
<ConfirmationDialog
isOpen={clearHTTPConfirmationDialog.isOpen}
onClose={clearHTTPConfirmationDialog.close}
onConfirm={clearHTTPRequestLog}
>
All proxy logs are going to be removed. This action cannot be undone.
</ConfirmationDialog>
</Box>
);
}

View File

@ -0,0 +1,5 @@
mutation ClearHTTPRequestLog {
clearHTTPRequestLog {
success
}
}

View File

@ -0,0 +1,24 @@
query HttpRequestLog($id: ID!) {
httpRequestLog(id: $id) {
id
method
url
proto
headers {
key
value
}
body
response {
id
proto
headers {
key
value
}
statusCode
statusReason
body
}
}
}

View File

@ -0,0 +1,6 @@
query HttpRequestLogFilter {
httpRequestLogFilter {
onlyInScope
searchExpression
}
}

View File

@ -0,0 +1,12 @@
query HttpRequestLogs {
httpRequestLogs {
id
method
url
timestamp
response {
statusCode
statusReason
}
}
}

View File

@ -0,0 +1,6 @@
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
setHttpRequestLogFilter(filter: $filter) {
onlyInScope
searchExpression
}
}

View File

@ -0,0 +1,3 @@
import { RequestLogs } from "./components/RequestLogs";
export default RequestLogs;

View File

@ -1,4 +1,6 @@
import { gql, useApolloClient, useMutation } from "@apollo/client";
import { useApolloClient } from "@apollo/client";
import AddIcon from "@mui/icons-material/Add";
import { Alert } from "@mui/lab";
import {
Box,
Button,
@ -10,35 +12,22 @@ import {
RadioGroup,
TextField,
} 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";
import React, { useState } from "react";
const SET_SCOPE = gql`
mutation SetScope($scope: [ScopeRuleInput!]!) {
setScope(scope: $scope) {
url
}
}
`;
import { ScopeDocument, ScopeQuery, ScopeRule, useSetScopeMutation } from "lib/graphql/generated";
function AddRule(): JSX.Element {
const [ruleType, setRuleType] = React.useState("url");
const [expression, setExpression] = React.useState("");
const [ruleType, setRuleType] = useState("url");
const [expression, setExpression] = useState("");
const client = useApolloClient();
const [setScope, { error, loading }] = useMutation(SET_SCOPE, {
onError() {},
onCompleted() {
setExpression("");
},
update(_, { data: { setScope } }) {
const [setScope, { error, loading }] = useSetScopeMutation({
onCompleted({ setScope }) {
client.writeQuery({
query: SCOPE,
query: ScopeDocument,
data: { scope: setScope },
});
setExpression("");
},
});
@ -50,8 +39,8 @@ function AddRule(): JSX.Element {
let scope: ScopeRule[] = [];
try {
const data = client.readQuery<{ scope: ScopeRule[] }>({
query: SCOPE,
const data = client.readQuery<ScopeQuery>({
query: ScopeDocument,
});
if (data) {
scope = data.scope;

View File

@ -1,4 +1,6 @@
import { gql, useApolloClient, useMutation, useQuery } from "@apollo/client";
import { useApolloClient } from "@apollo/client";
import CodeIcon from "@mui/icons-material/Code";
import DeleteIcon from "@mui/icons-material/Delete";
import {
Avatar,
Chip,
@ -9,32 +11,24 @@ import {
ListItemText,
Tooltip,
} 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!]!) {
setScope(scope: $scope) {
url
}
}
`;
import { ScopeDocument, ScopeQuery, useSetScopeMutation } from "lib/graphql/generated";
type ScopeRule = ScopeQuery["scope"][number];
type RuleListItemProps = {
scope: ScopeRule[];
scope: ScopeQuery["scope"];
rule: ScopeRule;
index: number;
};
function RuleListItem({ scope, rule, index }: RuleListItemProps): JSX.Element {
const client = useApolloClient();
const [setScope, { loading }] = useMutation(SET_SCOPE, {
update(_, { data: { setScope } }) {
const [setScope, { loading }] = useSetScopeMutation({
onCompleted({ setScope }) {
client.writeQuery({
query: SCOPE,
query: ScopeDocument,
data: { scope: setScope },
});
},

View File

@ -1,20 +1,13 @@
import { gql, useQuery } from "@apollo/client";
import { CircularProgress, List } from "@mui/material";
import { Alert } from "@mui/lab";
import { CircularProgress, List } from "@mui/material";
import React from "react";
import RuleListItem from "./RuleListItem";
import { ScopeRule } from "../../lib/scope";
export const SCOPE = gql`
query Scope {
scope {
url
}
}
`;
import RuleListItem from "./RuleListItem";
import { useScopeQuery } from "lib/graphql/generated";
function Rules(): JSX.Element {
const { loading, error, data } = useQuery<{ scope: ScopeRule[] }>(SCOPE);
const { loading, error, data } = useScopeQuery();
return (
<div>

View File

@ -0,0 +1,5 @@
query Scope {
scope {
url
}
}

View File

@ -0,0 +1,5 @@
mutation SetScope($scope: [ScopeRuleInput!]!) {
setScope(scope: $scope) {
url
}
}

View File

@ -0,0 +1,220 @@
import AddIcon from "@mui/icons-material/Add";
import { Alert, Box, Button, Fab, Tooltip, Typography, useTheme } from "@mui/material";
import { useRouter } from "next/router";
import React, { useState } from "react";
import { KeyValuePair } from "lib/components/KeyValuePair";
import RequestTabs from "lib/components/RequestTabs";
import Response from "lib/components/Response";
import SplitPane from "lib/components/SplitPane";
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
import {
GetSenderRequestQuery,
useCreateOrUpdateSenderRequestMutation,
useGetSenderRequestQuery,
useSendRequestMutation,
} from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL";
import updateKeyPairItem from "lib/updateKeyPairItem";
import updateURLQueryParams from "lib/updateURLQueryParams";
const defaultMethod = HttpMethod.Get;
const defaultProto = HttpProto.Http20;
const emptyKeyPair = [{ key: "", value: "" }];
function EditRequest(): JSX.Element {
const router = useRouter();
const reqId = router.query.id as string | undefined;
const theme = useTheme();
const [method, setMethod] = useState(defaultMethod);
const [url, setURL] = useState("");
const [proto, setProto] = useState(defaultProto);
const [queryParams, setQueryParams] = useState<KeyValuePair[]>(emptyKeyPair);
const [headers, setHeaders] = useState<KeyValuePair[]>(emptyKeyPair);
const [body, setBody] = useState("");
const handleQueryParamChange = (key: string, value: string, idx: number) => {
setQueryParams((prev) => {
const updated = updateKeyPairItem(key, value, idx, prev);
setURL((prev) => updateURLQueryParams(prev, updated));
return updated;
});
};
const handleQueryParamDelete = (idx: number) => {
setQueryParams((prev) => {
const updated = prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length));
setURL((prev) => updateURLQueryParams(prev, updated));
return updated;
});
};
const handleHeaderChange = (key: string, value: string, idx: number) => {
setHeaders((prev) => updateKeyPairItem(key, value, idx, prev));
};
const handleHeaderDelete = (idx: number) => {
setHeaders((prev) => prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length)));
};
const handleURLChange = (url: string) => {
setURL(url);
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex === -1) {
setQueryParams([{ key: "", value: "" }]);
return;
}
const newQueryParams = queryParamsFromURL(url);
// Push empty row.
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
};
const [response, setResponse] = useState<NonNullable<GetSenderRequestQuery["senderRequest"]>["response"]>(null);
const getReqResult = useGetSenderRequestQuery({
variables: { id: reqId as string },
skip: reqId === undefined,
onCompleted: ({ senderRequest }) => {
if (!senderRequest) {
return;
}
setURL(senderRequest.url);
setMethod(senderRequest.method);
setBody(senderRequest.body || "");
const newQueryParams = queryParamsFromURL(senderRequest.url);
// Push empty row.
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
const newHeaders = senderRequest.headers || [];
setHeaders([...newHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
setResponse(senderRequest.response);
},
});
const [createOrUpdateRequest, createResult] = useCreateOrUpdateSenderRequestMutation();
const [sendRequest, sendResult] = useSendRequestMutation();
const createOrUpdateRequestAndSend = () => {
const senderReq = getReqResult?.data?.senderRequest;
createOrUpdateRequest({
variables: {
request: {
// Update existing sender request if it was cloned from a request log
// and it doesn't have a response body yet (e.g. not sent yet).
...(senderReq && senderReq.sourceRequestLogID && !senderReq.response && { id: senderReq.id }),
url,
method,
proto: httpProtoMap.get(proto),
headers: headers.filter((kv) => kv.key !== ""),
body: body || undefined,
},
},
onCompleted: ({ createOrUpdateSenderRequest }) => {
const { id } = createOrUpdateSenderRequest;
sendRequestAndPushRoute(id);
},
});
};
const sendRequestAndPushRoute = (id: string) => {
sendRequest({
errorPolicy: "all",
onCompleted: () => {
router.push(`/sender?id=${id}`);
},
variables: {
id,
},
});
};
const handleFormSubmit: React.FormEventHandler = (e) => {
e.preventDefault();
createOrUpdateRequestAndSend();
};
const handleNewRequest = () => {
setURL("");
setMethod(defaultMethod);
setProto(defaultProto);
setQueryParams(emptyKeyPair);
setHeaders(emptyKeyPair);
setBody("");
setResponse(null);
router.push(`/sender`);
};
return (
<Box display="flex" flexDirection="column" height="100%" gap={2}>
<Box sx={{ position: "absolute", bottom: theme.spacing(2), right: theme.spacing(2) }}>
<Tooltip title="New request">
<Fab color="primary" onClick={handleNewRequest}>
<AddIcon />
</Fab>
</Tooltip>
</Box>
<Box component="form" autoComplete="off" onSubmit={handleFormSubmit}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<UrlBar
method={method}
onMethodChange={setMethod}
url={url.toString()}
onUrlChange={handleURLChange}
proto={proto}
onProtoChange={setProto}
sx={{ flex: "1 auto" }}
/>
<Button
variant="contained"
disableElevation
sx={{ width: "8rem" }}
type="submit"
disabled={createResult.loading || sendResult.loading}
>
Send
</Button>
</Box>
{createResult.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{createResult.error.message}
</Alert>
)}
{sendResult.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{sendResult.error.message}
</Alert>
)}
</Box>
<Box flex="1 auto" position="relative">
<SplitPane split="vertical" size={"50%"}>
<Box sx={{ height: "100%", mr: 2, pb: 2, position: "relative" }}>
<Typography variant="overline" color="textSecondary" sx={{ position: "absolute", right: 0, mt: 1.2 }}>
Request
</Typography>
<RequestTabs
queryParams={queryParams}
headers={headers}
body={body}
onQueryParamChange={handleQueryParamChange}
onQueryParamDelete={handleQueryParamDelete}
onHeaderChange={handleHeaderChange}
onHeaderDelete={handleHeaderDelete}
onBodyChange={setBody}
/>
</Box>
<Box sx={{ height: "100%", position: "relative", ml: 2, pb: 2 }}>
<Response response={response} />
</Box>
</SplitPane>
</Box>
</Box>
);
}
export default EditRequest;

View File

@ -0,0 +1,35 @@
import { Box, Paper, Typography } from "@mui/material";
import { useRouter } from "next/router";
import RequestsTable from "lib/components/RequestsTable";
import { useGetSenderRequestsQuery } from "lib/graphql/generated";
function History(): JSX.Element {
const { data, loading } = useGetSenderRequestsQuery({
pollInterval: 1000,
});
const router = useRouter();
const activeId = router.query.id as string | undefined;
const handleRowClick = (id: string) => {
router.push(`/sender?id=${id}`);
};
return (
<Box>
{!loading && data?.senderRequests && data?.senderRequests.length > 0 && (
<RequestsTable requests={data.senderRequests} onRowClick={handleRowClick} activeRowId={activeId} />
)}
<Box sx={{ mt: 2, height: "100%" }}>
{!loading && data?.senderRequests.length === 0 && (
<Paper variant="centered">
<Typography>No requests created yet.</Typography>
</Paper>
)}
</Box>
</Box>
);
}
export default History;

View File

@ -0,0 +1,21 @@
import { Box } from "@mui/material";
import EditRequest from "./EditRequest";
import History from "./History";
import SplitPane from "lib/components/SplitPane";
export default function Sender(): JSX.Element {
return (
<Box sx={{ height: "100%", position: "relative" }}>
<SplitPane split="horizontal" size="70%">
<Box sx={{ width: "100%", pt: "0.75rem" }}>
<EditRequest />
</Box>
<Box sx={{ height: "100%", overflow: "scroll" }}>
<History />
</Box>
</SplitPane>
</Box>
);
}

View File

@ -0,0 +1,5 @@
mutation CreateOrUpdateSenderRequest($request: SenderRequestInput!) {
createOrUpdateSenderRequest(request: $request) {
id
}
}

View File

@ -0,0 +1,5 @@
mutation CreateSenderRequestFromHttpRequestLog($id: ID!) {
createSenderRequestFromHttpRequestLog(id: $id) {
id
}
}

View File

@ -0,0 +1,5 @@
mutation SendRequest($id: ID!) {
sendRequest(id: $id) {
id
}
}

View File

@ -0,0 +1,26 @@
query GetSenderRequest($id: ID!) {
senderRequest(id: $id) {
id
sourceRequestLogID
url
method
proto
headers {
key
value
}
body
timestamp
response {
id
proto
statusCode
statusReason
body
headers {
key
value
}
}
}
}

View File

@ -0,0 +1,12 @@
query GetSenderRequests {
senderRequests {
id
url
method
response {
id
statusCode
statusReason
}
}
}

View File

@ -0,0 +1,3 @@
import Sender from "./components/Sender";
export default Sender;

View File

@ -0,0 +1,307 @@
import { useApolloClient } from "@apollo/client";
import { TabContext, TabPanel } from "@mui/lab";
import TabList from "@mui/lab/TabList";
import {
Alert,
Box,
Button,
CircularProgress,
FormControl,
FormControlLabel,
FormHelperText,
Snackbar,
Switch,
Tab,
TextField,
TextFieldProps,
Typography,
} from "@mui/material";
import MaterialLink from "@mui/material/Link";
import { SwitchBaseProps } from "@mui/material/internal/SwitchBase";
import { useEffect, useState } from "react";
import { useActiveProject } from "lib/ActiveProjectContext";
import Link from "lib/components/Link";
import { ActiveProjectDocument, useUpdateInterceptSettingsMutation } from "lib/graphql/generated";
import { withoutTypename } from "lib/graphql/omitTypename";
enum TabValue {
Intercept = "intercept",
}
function FilterTextField(props: TextFieldProps): JSX.Element {
return (
<TextField
color="primary"
variant="outlined"
InputProps={{
sx: { fontFamily: "'JetBrains Mono', monospace" },
autoCorrect: "false",
spellCheck: "false",
}}
InputLabelProps={{
shrink: true,
}}
margin="normal"
sx={{ mr: 1 }}
{...props}
/>
);
}
export default function Settings(): JSX.Element {
const client = useApolloClient();
const activeProject = useActiveProject();
const [updateInterceptSettings, updateIntercepSettingsResult] = useUpdateInterceptSettingsMutation({
onCompleted(data) {
client.cache.updateQuery({ query: ActiveProjectDocument }, (cachedData) => ({
activeProject: {
...cachedData.activeProject,
settings: {
...cachedData.activeProject.settings,
intercept: data.updateInterceptSettings,
},
},
}));
setInterceptReqFilter(data.updateInterceptSettings.requestFilter || "");
setInterceptResFilter(data.updateInterceptSettings.responseFilter || "");
setSettingsUpdatedOpen(true);
},
});
const [interceptReqFilter, setInterceptReqFilter] = useState("");
const [interceptResFilter, setInterceptResFilter] = useState("");
useEffect(() => {
setInterceptReqFilter(activeProject?.settings.intercept.requestFilter || "");
}, [activeProject?.settings.intercept.requestFilter]);
useEffect(() => {
setInterceptResFilter(activeProject?.settings.intercept.responseFilter || "");
}, [activeProject?.settings.intercept.responseFilter]);
const handleReqInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => {
if (!activeProject) {
e.preventDefault();
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
requestsEnabled: checked,
},
},
});
};
const handleResInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => {
if (!activeProject) {
e.preventDefault();
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
responsesEnabled: checked,
},
},
});
};
const handleInterceptReqFilter = () => {
if (!activeProject) {
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
requestFilter: interceptReqFilter,
},
},
});
};
const handleInterceptResFilter = () => {
if (!activeProject) {
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
responseFilter: interceptResFilter,
},
},
});
};
const [tabValue, setTabValue] = useState(TabValue.Intercept);
const [settingsUpdatedOpen, setSettingsUpdatedOpen] = useState(false);
const handleSettingsUpdatedClose = (_: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
setSettingsUpdatedOpen(false);
};
const tabSx = {
textTransform: "none",
};
return (
<Box p={4}>
<Snackbar open={settingsUpdatedOpen} autoHideDuration={3000} onClose={handleSettingsUpdatedClose}>
<Alert onClose={handleSettingsUpdatedClose} severity="info">
Intercept settings have been updated.
</Alert>
</Snackbar>
<Typography variant="h4" sx={{ mb: 2 }}>
Settings
</Typography>
<Typography paragraph sx={{ mb: 4 }}>
Settings allow you to tweak the behaviour of Hettys features.
</Typography>
<Typography variant="h5" sx={{ mb: 2 }}>
Project settings
</Typography>
{!activeProject && (
<Typography paragraph>
There is no project active. To configure project settings, first <Link href="/projects">open a project</Link>.
</Typography>
)}
{activeProject && (
<>
<TabContext value={tabValue}>
<TabList onChange={(_, value) => setTabValue(value)} sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tab value={TabValue.Intercept} label="Intercept" sx={tabSx} />
</TabList>
<TabPanel value={TabValue.Intercept} sx={{ px: 0 }}>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
Requests
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
disabled={updateIntercepSettingsResult.loading}
onChange={handleReqInterceptEnabled}
checked={activeProject.settings.intercept.requestsEnabled}
/>
}
label="Enable request interception"
labelPlacement="start"
sx={{ display: "inline-block", m: 0 }}
/>
<FormHelperText>
When enabled, incoming HTTP requests to the proxy are stalled for{" "}
<Link href="/proxy/intercept">manual review</Link>.
</FormHelperText>
</FormControl>
<form>
<FormControl sx={{ width: "50%" }}>
<FilterTextField
label="Request filter"
placeholder={`Example: method = "GET" OR url =~ "/foobar"`}
value={interceptReqFilter}
onChange={(e) => setInterceptReqFilter(e.target.value)}
/>
<FormHelperText>
Filter expression to match incoming requests on. When set, only matching requests are intercepted.{" "}
<MaterialLink
href="https://hetty.xyz/docs/guides/intercept?utm_source=hettyapp#request-filter"
target="_blank"
>
Read docs.
</MaterialLink>
</FormHelperText>
</FormControl>
<Button
type="submit"
variant="text"
color="primary"
size="large"
sx={{
mt: 2,
py: 1.8,
}}
onClick={handleInterceptReqFilter}
disabled={updateIntercepSettingsResult.loading}
startIcon={updateIntercepSettingsResult.loading ? <CircularProgress size={22} /> : undefined}
>
Update
</Button>
</form>
<Typography variant="h6" sx={{ mt: 3 }}>
Responses
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
disabled={updateIntercepSettingsResult.loading}
onChange={handleResInterceptEnabled}
checked={activeProject.settings.intercept.responsesEnabled}
/>
}
label="Enable response interception"
labelPlacement="start"
sx={{ display: "inline-block", m: 0 }}
/>
<FormHelperText>
When enabled, HTTP responses received by the proxy are stalled for{" "}
<Link href="/proxy/intercept">manual review</Link>.
</FormHelperText>
</FormControl>
<form>
<FormControl sx={{ width: "50%" }}>
<FilterTextField
label="Response filter"
placeholder={`Example: statusCode =~ "^2" OR body =~ "foobar"`}
value={interceptResFilter}
onChange={(e) => setInterceptResFilter(e.target.value)}
/>
<FormHelperText>
Filter expression to match received responses on. When set, only matching responses are intercepted.{" "}
<MaterialLink
href="https://hetty.xyz/docs/guides/intercept/?utm_source=hettyapp#response-filter"
target="_blank"
>
Read docs.
</MaterialLink>
</FormHelperText>
</FormControl>
<Button
type="submit"
variant="text"
color="primary"
size="large"
sx={{
mt: 2,
py: 1.8,
}}
onClick={handleInterceptResFilter}
disabled={updateIntercepSettingsResult.loading}
startIcon={updateIntercepSettingsResult.loading ? <CircularProgress size={22} /> : undefined}
>
Update
</Button>
</form>
</TabPanel>
</TabContext>
</>
)}
</Box>
);
}

View File

@ -0,0 +1,8 @@
mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) {
updateInterceptSettings(input: $input) {
requestsEnabled
responsesEnabled
requestFilter
responseFilter
}
}

View File

@ -0,0 +1,20 @@
import React, { createContext, useContext } from "react";
import { Project, useActiveProjectQuery } from "./graphql/generated";
const ActiveProjectContext = createContext<Project | null>(null);
interface Props {
children?: React.ReactNode | undefined;
}
export function ActiveProjectProvider({ children }: Props): JSX.Element {
const { data } = useActiveProjectQuery();
const project = data?.activeProject || null;
return <ActiveProjectContext.Provider value={project}>{children}</ActiveProjectContext.Provider>;
}
export function useActiveProject() {
return useContext(ActiveProjectContext);
}

View File

@ -0,0 +1,22 @@
import React, { createContext, useContext } from "react";
import { GetInterceptedRequestsQuery, useGetInterceptedRequestsQuery } from "./graphql/generated";
const InterceptedRequestsContext = createContext<GetInterceptedRequestsQuery["interceptedRequests"] | null>(null);
interface Props {
children?: React.ReactNode | undefined;
}
export function InterceptedRequestsProvider({ children }: Props): JSX.Element {
const { data } = useGetInterceptedRequestsQuery({
pollInterval: 1000,
});
const reqs = data?.interceptedRequests || null;
return <InterceptedRequestsContext.Provider value={reqs}>{children}</InterceptedRequestsContext.Provider>;
}
export function useInterceptedRequests() {
return useContext(InterceptedRequestsContext);
}

View File

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

View File

@ -1,10 +1,10 @@
import React, { useState } from "react";
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";
import React, { useState } from "react";
export function useConfirmationDialog() {
const [isOpen, setIsOpen] = useState(false);

View File

@ -1,7 +1,6 @@
import MonacoEditor from "@monaco-editor/react";
import monaco from "monaco-editor/esm/vs/editor/editor.api";
import MonacoEditor, { EditorProps } from "@monaco-editor/react";
const monacoOptions: monaco.editor.IEditorOptions = {
const defaultMonacoOptions: EditorProps["options"] = {
readOnly: true,
wordWrap: "on",
minimap: {
@ -12,8 +11,9 @@ const monacoOptions: monaco.editor.IEditorOptions = {
type language = "html" | "typescript" | "json";
function languageForContentType(contentType?: string): language | undefined {
switch (contentType) {
switch (contentType?.toLowerCase()) {
case "text/html":
case "text/html; charset=utf-8":
return "html";
case "application/json":
case "application/json; charset=utf-8":
@ -29,16 +29,18 @@ function languageForContentType(contentType?: string): language | undefined {
interface Props {
content: string;
contentType?: string;
monacoOptions?: EditorProps["options"];
onChange?: EditorProps["onChange"];
}
function Editor({ content, contentType }: Props): JSX.Element {
function Editor({ content, contentType, monacoOptions, onChange }: Props): JSX.Element {
return (
<MonacoEditor
height={"600px"}
language={languageForContentType(contentType)}
theme="vs-dark"
options={monacoOptions}
options={{ ...defaultMonacoOptions, ...monacoOptions }}
value={content}
onChange={onChange}
/>
);
}

View File

@ -1,5 +1,5 @@
import { SvgIconTypeMap } from "@mui/material";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import { SvgIconTypeMap } from "@mui/material";
interface Props {
status: number;

View File

@ -0,0 +1,187 @@
import ClearIcon from "@mui/icons-material/Clear";
import {
Alert,
IconButton,
InputBase,
InputBaseProps,
Snackbar,
styled,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableRowProps,
} from "@mui/material";
import { useState } from "react";
const StyledInputBase = styled(InputBase)<InputBaseProps>(() => ({
fontSize: "0.875rem",
"&.MuiInputBase-root input": {
p: 0,
},
}));
const StyledTableRow = styled(TableRow)<TableRowProps>(() => ({
"& .delete-button": {
visibility: "hidden",
},
"&:hover .delete-button": {
visibility: "inherit",
},
}));
export interface KeyValuePair {
key: string;
value: string;
}
export interface KeyValuePairTableProps {
items: KeyValuePair[];
onChange?: (key: string, value: string, index: number) => void;
onDelete?: (index: number) => void;
}
export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTableProps): JSX.Element {
const [copyConfOpen, setCopyConfOpen] = useState(false);
const handleCellClick = (e: React.MouseEvent) => {
e.preventDefault();
const windowSel = window.getSelection();
if (!windowSel || !document) {
return;
}
const r = document.createRange();
r.selectNode(e.currentTarget);
windowSel.removeAllRanges();
windowSel.addRange(r);
document.execCommand("copy");
windowSel.removeAllRanges();
setCopyConfOpen(true);
};
const handleCopyConfClose = (_: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
setCopyConfOpen(false);
};
return (
<div>
<Snackbar open={copyConfOpen} autoHideDuration={3000} onClose={handleCopyConfClose}>
<Alert onClose={handleCopyConfClose} severity="info">
Copied to clipboard.
</Alert>
</Snackbar>
<TableContainer sx={{ overflowX: "initial" }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Key</TableCell>
<TableCell>Value</TableCell>
{onDelete && <TableCell padding="checkbox"></TableCell>}
</TableRow>
</TableHead>
<TableBody
sx={{
"td, th, input": {
fontFamily: "'JetBrains Mono', monospace",
fontSize: "0.75rem",
py: 0.2,
},
"td span, th span": {
display: "block",
py: 0.7,
},
}}
>
{items.map(({ key, value }, idx) => (
<StyledTableRow key={idx} hover>
<TableCell
component="th"
scope="row"
onClick={(e) => {
!onChange && handleCellClick(e);
}}
sx={{
...(!onChange && {
"&:hover": {
cursor: "copy",
},
}),
}}
>
{!onChange && <span>{key}</span>}
{onChange && (
<StyledInputBase
size="small"
fullWidth
placeholder="Key"
value={key}
onChange={(e) => {
onChange && onChange(e.target.value, value, idx);
}}
/>
)}
</TableCell>
<TableCell
onClick={(e) => {
!onChange && handleCellClick(e);
}}
sx={{
width: "60%",
wordBreak: "break-all",
...(!onChange && {
"&:hover": {
cursor: "copy",
},
}),
}}
>
{!onChange && value}
{onChange && (
<StyledInputBase
size="small"
fullWidth
placeholder="Value"
value={value}
onChange={(e) => {
onChange && onChange(key, e.target.value, idx);
}}
/>
)}
</TableCell>
{onDelete && (
<TableCell>
<div className="delete-button">
<IconButton
size="small"
onClick={() => {
onDelete && onDelete(idx);
}}
sx={{
visibility: onDelete === undefined || items.length === idx + 1 ? "hidden" : "inherit",
}}
>
<ClearIcon fontSize="inherit" />
</IconButton>
</div>
</TableCell>
)}
</StyledTableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
);
}
export default KeyValuePairTable;

View File

@ -0,0 +1,94 @@
import MuiLink, { LinkProps as MuiLinkProps } from "@mui/material/Link";
import { styled } from "@mui/material/styles";
import clsx from "clsx";
import NextLink, { LinkProps as NextLinkProps } from "next/link";
import { useRouter } from "next/router";
import * as React from "react";
// Add support for the sx prop for consistency with the other branches.
const Anchor = styled("a")({});
interface NextLinkComposedProps
extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">,
Omit<NextLinkProps, "href" | "as"> {
to: NextLinkProps["href"];
linkAs?: NextLinkProps["as"];
}
export const NextLinkComposed = React.forwardRef<HTMLAnchorElement, NextLinkComposedProps>(function NextLinkComposed(
props,
ref
) {
const { to, linkAs, replace, scroll, shallow, prefetch, locale, ...other } = props;
return (
<NextLink
href={to}
prefetch={prefetch}
as={linkAs}
replace={replace}
scroll={scroll}
shallow={shallow}
passHref
locale={locale}
>
<Anchor ref={ref} {...other} />
</NextLink>
);
});
export type LinkProps = {
activeClassName?: string;
as?: NextLinkProps["as"];
href: NextLinkProps["href"];
linkAs?: NextLinkProps["as"]; // Useful when the as prop is shallow by styled().
noLinkStyle?: boolean;
} & Omit<NextLinkComposedProps, "to" | "linkAs" | "href"> &
Omit<MuiLinkProps, "href">;
// A styled version of the Next.js Link component:
// https://nextjs.org/docs/api-reference/next/link
const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(function Link(props, ref) {
const {
activeClassName = "active",
as,
className: classNameProps,
href,
linkAs: linkAsProp,
locale,
noLinkStyle,
prefetch,
replace,
role, // Link don't have roles.
scroll,
shallow,
...other
} = props;
const router = useRouter();
const pathname = typeof href === "string" ? href : href.pathname;
const className = clsx(classNameProps, {
[activeClassName]: router.pathname === pathname && activeClassName,
});
const isExternal = typeof href === "string" && (href.indexOf("http") === 0 || href.indexOf("mailto:") === 0);
if (isExternal) {
if (noLinkStyle) {
return <Anchor className={className} href={href} ref={ref} {...other} />;
}
return <MuiLink className={className} href={href} ref={ref} {...other} />;
}
const linkAs = linkAsProp || as;
const nextjsProps = { to: href, linkAs, replace, scroll, shallow, prefetch, locale };
if (noLinkStyle) {
return <NextLinkComposed className={className} ref={ref} {...nextjsProps} {...other} />;
}
return <MuiLink component={NextLinkComposed} className={className} ref={ref} {...nextjsProps} {...other} />;
});
export default Link;

View File

@ -0,0 +1,91 @@
import { TabContext, TabList, TabPanel } from "@mui/lab";
import { Box, Tab } from "@mui/material";
import React, { useState } from "react";
import { KeyValuePairTable, KeyValuePair, KeyValuePairTableProps } from "./KeyValuePair";
import Editor from "lib/components/Editor";
enum TabValue {
QueryParams = "queryParams",
Headers = "headers",
Body = "body",
}
interface RequestTabsProps {
queryParams: KeyValuePair[];
headers: KeyValuePair[];
onQueryParamChange?: KeyValuePairTableProps["onChange"];
onQueryParamDelete?: KeyValuePairTableProps["onDelete"];
onHeaderChange?: KeyValuePairTableProps["onChange"];
onHeaderDelete?: KeyValuePairTableProps["onDelete"];
body?: string | null;
onBodyChange?: (value: string) => void;
}
function RequestTabs(props: RequestTabsProps): JSX.Element {
const {
queryParams,
onQueryParamChange,
onQueryParamDelete,
headers,
onHeaderChange,
onHeaderDelete,
body,
onBodyChange,
} = props;
const [tabValue, setTabValue] = useState(TabValue.QueryParams);
const tabSx = {
textTransform: "none",
};
const queryParamsLength = onQueryParamChange ? queryParams.length - 1 : queryParams.length;
const headersLength = onHeaderChange ? headers.length - 1 : headers.length;
return (
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
<TabContext value={tabValue}>
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 1 }}>
<TabList onChange={(_, value) => setTabValue(value)}>
<Tab
value={TabValue.QueryParams}
label={"Query Params" + (queryParamsLength ? ` (${queryParamsLength})` : "")}
sx={tabSx}
/>
<Tab value={TabValue.Headers} label={"Headers" + (headersLength ? ` (${headersLength})` : "")} sx={tabSx} />
<Tab
value={TabValue.Body}
label={"Body" + (body?.length ? ` (${body.length} byte` + (body.length > 1 ? "s" : "") + ")" : "")}
sx={tabSx}
/>
</TabList>
</Box>
<Box flex="1 auto" overflow="scroll" height="100%">
<TabPanel value={TabValue.QueryParams} sx={{ p: 0, height: "100%" }}>
<Box>
<KeyValuePairTable items={queryParams} onChange={onQueryParamChange} onDelete={onQueryParamDelete} />
</Box>
</TabPanel>
<TabPanel value={TabValue.Headers} sx={{ p: 0, height: "100%" }}>
<Box>
<KeyValuePairTable items={headers} onChange={onHeaderChange} onDelete={onHeaderDelete} />
</Box>
</TabPanel>
<TabPanel value={TabValue.Body} sx={{ p: 0, height: "100%" }}>
<Editor
content={body || ""}
onChange={(value) => {
onBodyChange && onBodyChange(value || "");
}}
monacoOptions={{ readOnly: onBodyChange === undefined }}
contentType={headers.find(({ key }) => key.toLowerCase() === "content-type")?.value}
/>
</TabPanel>
</Box>
</TabContext>
</Box>
);
}
export default RequestTabs;

View File

@ -0,0 +1,128 @@
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
styled,
TableCellProps,
TableRowProps,
} from "@mui/material";
import HttpStatusIcon from "./HttpStatusIcon";
import { HttpMethod } from "lib/graphql/generated";
const baseCellStyle = {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
} as const;
const MethodTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
width: "100px",
}));
const OriginTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
maxWidth: "100px",
}));
const PathTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
maxWidth: "200px",
}));
const StatusTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
width: "100px",
}));
const RequestTableRow = styled(TableRow)<TableRowProps>(() => ({
"&:hover": {
cursor: "pointer",
},
}));
interface HttpRequest {
id: string;
url: string;
method: HttpMethod;
response?: HttpResponse | null;
}
interface HttpResponse {
statusCode: number;
statusReason: string;
body?: string;
}
interface Props {
requests: HttpRequest[];
activeRowId?: string;
actionsCell?: (id: string) => JSX.Element;
onRowClick?: (id: string) => void;
onContextMenu?: (e: React.MouseEvent, id: string) => void;
}
export default function RequestsTable(props: Props): JSX.Element {
const { requests, activeRowId, actionsCell, onRowClick, onContextMenu } = props;
return (
<TableContainer sx={{ overflowX: "initial" }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Method</TableCell>
<TableCell>Origin</TableCell>
<TableCell>Path</TableCell>
<TableCell>Status</TableCell>
{actionsCell && <TableCell padding="checkbox"></TableCell>}
</TableRow>
</TableHead>
<TableBody>
{requests.map(({ id, method, url, response }) => {
const { origin, pathname, search, hash } = new URL(url);
return (
<RequestTableRow
key={id}
hover
selected={id === activeRowId}
onClick={() => {
onRowClick && onRowClick(id);
}}
onContextMenu={(e) => {
onContextMenu && onContextMenu(e, id);
}}
>
<MethodTableCell>
<code>{method}</code>
</MethodTableCell>
<OriginTableCell>{origin}</OriginTableCell>
<PathTableCell>{decodeURIComponent(pathname + search + hash)}</PathTableCell>
<StatusTableCell>
{response && <Status code={response.statusCode} reason={response.statusReason} />}
</StatusTableCell>
{actionsCell && actionsCell(id)}
</RequestTableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
function Status({ code, reason }: { code: number; reason: string }): JSX.Element {
return (
<div>
<HttpStatusIcon status={code} />{" "}
<code>
{code} {reason}
</code>
</div>
);
}

View File

@ -0,0 +1,38 @@
import { Box, Typography } from "@mui/material";
import ResponseTabs from "./ResponseTabs";
import ResponseStatus from "lib/components/ResponseStatus";
import { HttpResponseLog } from "lib/graphql/generated";
interface ResponseProps {
response?: HttpResponseLog | null;
}
function Response({ response }: ResponseProps): JSX.Element {
return (
<Box height="100%">
<Box sx={{ position: "absolute", right: 0, mt: 1.4 }}>
<Typography variant="overline" color="textSecondary" sx={{ float: "right", ml: 3 }}>
Response
</Typography>
{response && (
<Box sx={{ float: "right", mt: 0.2 }}>
<ResponseStatus
proto={response.proto}
statusCode={response.statusCode}
statusReason={response.statusReason}
/>
</Box>
)}
</Box>
<ResponseTabs
body={response?.body}
headers={response?.headers || []}
hasResponse={response !== undefined && response !== null}
/>
</Box>
);
}
export default Response;

View File

@ -0,0 +1,38 @@
import { Typography } from "@mui/material";
import HttpStatusIcon from "./HttpStatusIcon";
import { HttpProtocol } from "lib/graphql/generated";
type ResponseStatusProps = {
proto: HttpProtocol;
statusCode: number;
statusReason: string;
};
function mapProto(proto: HttpProtocol): string {
switch (proto) {
case HttpProtocol.Http10:
return "HTTP/1.0";
case HttpProtocol.Http11:
return "HTTP/1.1";
case HttpProtocol.Http20:
return "HTTP/2.0";
default:
return proto;
}
}
export default function ResponseStatus({ proto, statusCode, statusReason }: ResponseStatusProps): JSX.Element {
return (
<Typography variant="h6" style={{ fontSize: "1rem", whiteSpace: "nowrap" }}>
<HttpStatusIcon status={statusCode} />{" "}
<Typography component="span" color="textSecondary">
<Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
{mapProto(proto)}
</Typography>
</Typography>{" "}
{statusCode} {statusReason}
</Typography>
);
}

View File

@ -0,0 +1,78 @@
import { TabContext, TabList, TabPanel } from "@mui/lab";
import { Box, Paper, Tab, Typography } from "@mui/material";
import React, { useState } from "react";
import { KeyValuePairTable, KeyValuePair, KeyValuePairTableProps } from "./KeyValuePair";
import Editor from "lib/components/Editor";
interface ResponseTabsProps {
headers: KeyValuePair[];
onHeaderChange?: KeyValuePairTableProps["onChange"];
onHeaderDelete?: KeyValuePairTableProps["onDelete"];
body?: string | null;
onBodyChange?: (value: string) => void;
hasResponse: boolean;
}
enum TabValue {
Body = "body",
Headers = "headers",
}
const reqNotSent = (
<Paper variant="centered">
<Typography>Response not received yet.</Typography>
</Paper>
);
function ResponseTabs(props: ResponseTabsProps): JSX.Element {
const { headers, onHeaderChange, onHeaderDelete, body, onBodyChange, hasResponse } = props;
const [tabValue, setTabValue] = useState(TabValue.Body);
const contentType = headers.find((header) => header.key.toLowerCase() === "content-type")?.value;
const tabSx = {
textTransform: "none",
};
const headersLength = onHeaderChange ? headers.length - 1 : headers.length;
return (
<Box height="100%" sx={{ display: "flex", flexDirection: "column" }}>
<TabContext value={tabValue}>
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 1 }}>
<TabList onChange={(_, value) => setTabValue(value)}>
<Tab
value={TabValue.Body}
label={"Body" + (body?.length ? ` (${body.length} byte` + (body.length > 1 ? "s" : "") + ")" : "")}
sx={tabSx}
/>
<Tab value={TabValue.Headers} label={"Headers" + (headersLength ? ` (${headersLength})` : "")} sx={tabSx} />
</TabList>
</Box>
<Box flex="1 auto" overflow="hidden">
<TabPanel value={TabValue.Body} sx={{ p: 0, height: "100%" }}>
{hasResponse && (
<Editor
content={body || ""}
onChange={(value) => {
onBodyChange && onBodyChange(value || "");
}}
monacoOptions={{ readOnly: onBodyChange === undefined }}
contentType={contentType}
/>
)}
{!hasResponse && reqNotSent}
</TabPanel>
<TabPanel value={TabValue.Headers} sx={{ p: 0, height: "100%", overflow: "scroll" }}>
{hasResponse && <KeyValuePairTable items={headers} onChange={onHeaderChange} onDelete={onHeaderDelete} />}
{!hasResponse && reqNotSent}
</TabPanel>
</Box>
</TabContext>
</Box>
);
}
export default ResponseTabs;

View File

@ -0,0 +1,53 @@
import { alpha, styled } from "@mui/material/styles";
import ReactSplitPane, { SplitPaneProps } from "react-split-pane";
const BORDER_WIDTH_FACTOR = 1.75;
const SIZE_FACTOR = 4;
const MARGIN_FACTOR = -1.75;
const SplitPane = styled(ReactSplitPane)<SplitPaneProps>(({ theme }) => ({
".Resizer": {
zIndex: theme.zIndex.mobileStepper,
boxSizing: "border-box",
backgroundClip: "padding-box",
backgroundColor: alpha(theme.palette.grey[400], 0.05),
},
".Resizer:hover": {
transition: "all 0.5s ease",
backgroundColor: alpha(theme.palette.primary.main, 1),
},
".Resizer.horizontal": {
height: theme.spacing(SIZE_FACTOR),
marginTop: theme.spacing(MARGIN_FACTOR),
marginBottom: theme.spacing(MARGIN_FACTOR),
borderTop: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
borderBottom: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
borderBottomColor: "rgba(255, 255, 255, 0)",
cursor: "row-resize",
width: "100%",
},
".Resizer.vertical": {
width: theme.spacing(SIZE_FACTOR),
marginLeft: theme.spacing(MARGIN_FACTOR),
marginRight: theme.spacing(MARGIN_FACTOR),
borderLeft: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
borderRight: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
cursor: "col-resize",
},
".Resizer.disabled": {
cursor: "not-allowed",
},
".Resizer.disabled:hover": {
borderColor: "transparent",
},
".Pane": {
overflow: "hidden",
},
}));
export default SplitPane;

View File

@ -0,0 +1,122 @@
import { Box, BoxProps, FormControl, InputLabel, MenuItem, Select, TextField } from "@mui/material";
import { HttpProtocol } from "lib/graphql/generated";
export enum HttpMethod {
Get = "GET",
Post = "POST",
Put = "PUT",
Patch = "PATCH",
Delete = "DELETE",
Head = "HEAD",
Options = "OPTIONS",
Connect = "CONNECT",
Trace = "TRACE",
}
export enum HttpProto {
Http10 = "HTTP/1.0",
Http11 = "HTTP/1.1",
Http20 = "HTTP/2.0",
}
export const httpProtoMap = new Map([
[HttpProto.Http10, HttpProtocol.Http10],
[HttpProto.Http11, HttpProtocol.Http11],
[HttpProto.Http20, HttpProtocol.Http20],
]);
interface UrlBarProps extends BoxProps {
method: HttpMethod;
onMethodChange?: (method: HttpMethod) => void;
url: string;
onUrlChange?: (url: string) => void;
proto: HttpProto;
onProtoChange?: (proto: HttpProto) => void;
}
function UrlBar(props: UrlBarProps) {
const { method, onMethodChange, url, onUrlChange, proto, onProtoChange, ...other } = props;
return (
<Box {...other} sx={{ ...other.sx, display: "flex" }}>
<FormControl>
<InputLabel id="req-method-label">Method</InputLabel>
<Select
labelId="req-method-label"
id="req-method"
value={method}
label="Method"
disabled={!onMethodChange}
onChange={(e) => onMethodChange && onMethodChange(e.target.value as HttpMethod)}
sx={{
width: "8rem",
".MuiOutlinedInput-notchedOutline": {
borderRightWidth: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderRightWidth: 1,
},
}}
>
{Object.values(HttpMethod).map((method) => (
<MenuItem key={method} value={method}>
{method}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="URL"
placeholder="E.g. “https://example.com/foobar”"
value={url}
disabled={!onUrlChange}
onChange={(e) => onUrlChange && onUrlChange(e.target.value)}
required
variant="outlined"
InputLabelProps={{
shrink: true,
}}
InputProps={{
sx: {
".MuiOutlinedInput-notchedOutline": {
borderRadius: 0,
},
},
}}
sx={{ flexGrow: 1 }}
/>
<FormControl>
<InputLabel id="req-proto-label">Protocol</InputLabel>
<Select
labelId="req-proto-label"
id="req-proto"
value={proto}
label="Protocol"
disabled={!onProtoChange}
onChange={(e) => onProtoChange && onProtoChange(e.target.value as HttpProto)}
sx={{
".MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 0,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 1,
},
}}
>
{Object.values(HttpProto).map((proto) => (
<MenuItem key={proto} value={proto}>
{proto}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
}
export default UrlBar;

View File

@ -0,0 +1,49 @@
import { Menu } from "@mui/material";
import React, { useState } from "react";
interface ContextMenuProps {
children?: React.ReactNode;
}
export default function useContextMenu(): [
(props: ContextMenuProps) => JSX.Element,
(e: React.MouseEvent) => void,
() => void
] {
const [contextMenu, setContextMenu] = useState<{
mouseX: number;
mouseY: number;
} | null>(null);
const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
setContextMenu(
contextMenu === null
? {
mouseX: event.clientX - 2,
mouseY: event.clientY - 4,
}
: // repeated contextmenu when it is already open closes it with Chrome 84 on Ubuntu
// Other native context menus might behave different.
// With this behavior we prevent contextmenu from the backdrop to re-locale existing context menus.
null
);
};
const handleClose = () => {
setContextMenu(null);
};
const menu = ({ children }: ContextMenuProps): JSX.Element => (
<Menu
open={contextMenu !== null}
onClose={handleClose}
anchorReference="anchorPosition"
anchorPosition={contextMenu !== null ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
>
{children}
</Menu>
);
return [menu, handleContextMenu, handleClose];
}

View File

@ -1,61 +0,0 @@
import { useMemo } from "react";
import { ApolloClient, HttpLink, InMemoryCache, NormalizedCacheObject } from "@apollo/client";
import merge from "deepmerge";
import isEqual from "lodash/isEqual";
export const APOLLO_STATE_PROP_NAME = "__APOLLO_STATE__";
let apolloClient: ApolloClient<NormalizedCacheObject>;
function createApolloClient() {
return new ApolloClient({
ssrMode: typeof window === "undefined",
link: new HttpLink({
uri: "/api/graphql/",
}),
cache: new InMemoryCache(),
});
}
export function initializeApollo(initialState = null) {
const _apolloClient = apolloClient ?? createApolloClient();
// If your page has Next.js data fetching methods that use Apollo Client, the initial state
// gets hydrated here
if (initialState) {
// Get existing cache, loaded during client side data fetching
const existingCache = _apolloClient.extract();
// 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;
// Create the Apollo Client once in the client
if (!apolloClient) apolloClient = _apolloClient;
return _apolloClient;
}
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;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
query GetInterceptedRequests {
interceptedRequests {
id
url
method
response {
statusCode
statusReason
}
}
}

View File

@ -0,0 +1,7 @@
function omitTypename<T>(key: string, value: T): T | undefined {
return key === "__typename" ? undefined : value;
}
export function withoutTypename<T>(input: T): T {
return JSON.parse(JSON.stringify(input), omitTypename);
}

View File

@ -0,0 +1,39 @@
import { ApolloClient, HttpLink, InMemoryCache, NormalizedCacheObject } from "@apollo/client";
let apolloClient: ApolloClient<NormalizedCacheObject>;
function createApolloClient() {
return new ApolloClient({
ssrMode: typeof window === "undefined",
link: new HttpLink({
uri: "/api/graphql/",
}),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
interceptedRequests: {
merge(_, incoming) {
return incoming;
},
},
},
},
ProjectSettings: {
merge: true,
},
},
}),
});
}
export function useApollo() {
const _apolloClient = apolloClient ?? createApolloClient();
// For SSG and SSR always create a new Apollo Client
if (typeof window === "undefined") return _apolloClient;
// Create the Apollo Client once in the client
if (!apolloClient) apolloClient = _apolloClient;
return _apolloClient;
}

View File

@ -1,5 +1,11 @@
import { createTheme } from "@mui/material/styles";
import * as colors from "@mui/material/colors";
import { createTheme } from "@mui/material/styles";
declare module "@mui/material/Paper" {
interface PaperPropsVariantOverrides {
centered: true;
}
}
const heading = {
fontFamily: "'JetBrains Mono', monospace",
@ -41,13 +47,28 @@ theme = createTheme(theme, {
},
},
components: {
MuiTableCell: {
MuiTableRow: {
styleOverrides: {
stickyHeader: {
backgroundColor: theme.palette.secondary.dark,
root: {
"&.Mui-selected, &.Mui-selected:hover": {
backgroundColor: theme.palette.grey[700],
},
},
},
},
MuiPaper: {
variants: [
{
props: { variant: "centered" },
style: {
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: theme.spacing(4),
},
},
],
},
},
});

View File

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

View File

@ -0,0 +1,17 @@
import { KeyValuePair } from "./components/KeyValuePair";
export function queryParamsFromURL(url: string): KeyValuePair[] {
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex === -1) {
return [];
}
const queryParams: KeyValuePair[] = [];
const searchParams = new URLSearchParams(url.slice(questionMarkIndex + 1));
for (const [key, value] of searchParams) {
queryParams.push({ key, value });
}
return queryParams;
}

View File

@ -1,24 +0,0 @@
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
}

View File

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

Some files were not shown because too many files have changed in this diff Show More