├── .github
└── FUNDING.yml
├── .gitignore
├── .goreleaser.yml
├── .httplab.sample
├── .travis.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── cmd
└── httplab
│ └── main.go
├── contrib
├── archlinux
│ ├── .gitignore
│ └── PKGBUILD
├── snap
│ └── snapcraft.yaml
└── stunnel
│ ├── README.md
│ ├── makecert.sh
│ ├── openssl.cnf
│ └── stunnel.conf
├── dump.go
├── dump_test.go
├── go.mod
├── go.sum
├── images
├── httplab_logo.png
├── httplab_logo.svg
└── screencast.gif
├── response.go
├── response_test.go
├── testdata
├── httplab.json
└── index.html
└── ui
├── bindings.go
├── editor.go
├── split.go
├── split_test.go
├── ui.go
└── ui_test.go
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [gchaincl]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | custom: https://tippin.me/@gchaincl
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *_amd64
2 | .httplab
3 | dist
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | builds:
2 | - binary: httplab
3 | - main: cmd/httplab/main.go
4 | goos:
5 | - windows
6 | - darwin
7 | - linux
8 | archive:
9 | wrap_in_directory: true
10 | format_overrides:
11 | - goos: windows
12 | format: zip
13 |
--------------------------------------------------------------------------------
/.httplab.sample:
--------------------------------------------------------------------------------
1 | {
2 | "Responses": {
3 | "ok": {
4 | "Status": 200,
5 | "Delay": 0,
6 | "Body": "Hello, World",
7 | "Headers": {
8 | "X-Server": "HTTPLab"
9 | }
10 | },
11 | "create": {
12 | "Status": 201,
13 | "Delay": 1000,
14 | "Body": "{\"created\":\"ok\"}",
15 | "Headers": {
16 | "Content-Type": "application/json"
17 | }
18 | },
19 | "notfound": {
20 | "Status": 404,
21 | "Delay": 0,
22 | "Body": "Page Not Found",
23 | "Headers": {
24 | }
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: go
2 | go:
3 | - 1.9.x
4 | - 1.10.x
5 | - 1.11.x
6 | - 1.12.x
7 | - tip
8 |
9 | install:
10 | - go get golang.org/x/lint/golint
11 | - go get -t ./...
12 |
13 | script:
14 | - golint $(go list ./... | grep -v /vendor/)
15 | - go test $(go list ./...| grep -v /vendor/)
16 |
17 | after_success:
18 | - test -n "$TRAVIS_TAG" && curl -sL https://git.io/goreleaser | bash
19 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## v0.5.0-dev
2 | * Configure response with flags
3 | * Add response auto-update support
4 |
5 | ## v0.4.0
6 | * Display CORS request by default (issue #42)
7 | * Add short flags support (@dnguy078, issue #49)
8 | * Display request headers in a sorted way on the client (@waleoyediran, issue #47)
9 | * [bugfix] Location header not rendered to client (@vjeantet, issue #44)
10 | * CORS support via `-cors` (issue #39)
11 | * Add graceful shutdown (@maciekmm, issue #66)
12 |
13 | ## v0.3.0
14 | * Enable Line Wrapping (issue #38)
15 | * Add mouse support
16 | * Save Requests payload into a file (ctrl+f)
17 | * Compact UI
18 | * Make ui a package
19 | * Responses can be deleted with 'd'
20 | * [fix] Truncate .http file before saving
21 | * [fix] startup error handling
22 | * Split cmd and lib
23 |
24 | ## v0.2.1 (2017-06-04)
25 | * [fix] Open File dialog bugs
26 |
27 | ## v0.2.0 (2017-04-04)
28 | * Ctrl+R reset/clears the request history
29 | * Toggle response builder
30 | * Expand body's file path
31 | * Implement File Body response
32 | * UI bug fixes & refactor
33 | * Add -version flag [@pradeepchhetri]
34 |
35 | ## v0.1.0 (2017-03-02)
36 | * Parameterized config file.
37 | * Use the -config flag to specify a custom config file.
38 | * By default, lookup for `.httplab` on the current dir, if not found fallback to `$HOME/.httplab`.
39 | * Request Scrolling.
40 | * Display bindings when invoked with -h|help.
41 |
42 | ## v0.0.1 (2017-02-08)
43 | First Release
44 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to HTTPLab
2 | Welcome and thanks for showing interest in contributing to the project.
3 | HTTPLab is an opensource so its success depend on your help. Your contributions are going to help many HTTPLab users directly.
4 |
5 | There are many ways you can contribute, here's how:
6 |
7 | - [Asking aquestions](#asking-question)
8 | - [Writing code](#writing-code)
9 | - [Improving documentation](#improving-documentation)
10 | - [Reporting Bugs](#reporting-bugs)
11 |
12 | ## Asking questions
13 | What seems obvious for one person might not be for other, there's different backgrounds, context and way of thinking.
14 | Don't be shy if something is not clear, just ask.
15 | We use the [issue](https://github.com/gchaincl/httplab/issues) tracker for this,
16 | so please *make sure your question hasn't been replied* before opening a new issue.
17 |
18 | ## Writing code
19 | If you fix a bug, or introduce a new feature, make sure that:
20 | * **No one else is already working on that**. Review the open pull requests (see [#In Progress](https://github.com/gchaincl/httplab/issues?q=is%3Aopen+is%3Aissue+label%3A%22In+Progress%22) issues) before start working on it.
21 | * Whenever is possible, **add tests**
22 | * Try to follow the conventions
23 | * Keep the pull request small
24 | * Document the public API.
25 |
26 | ## Improving documentation
27 | Documentation is as important as code, undocumented features are unexisting features.
28 | If you think documentation is insufficient or imprecise, please open a [pull request](https://github.com/gchaincl/httplab/pulls).
29 | We treat documentation problems as bugs, so make sure you've read the [Fixing bugs](#fixing-bugs) before.
30 |
31 | ## Reporting bugs
32 | Bug reports are valuable information, and will help make HTTPLab better.
33 | Be expressive enough to make developer's job as easy as possible, so make sure the report includes the step to reproduce it.
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Gustavo Chaín
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | [](https://travis-ci.org/gchaincl/httplab) [](https://goreportcard.com/report/gchaincl/httplab) [](http://makeapullrequest.com)
4 |
5 |
6 | The interactive web server.
7 |
8 | HTTPLabs let you inspect HTTP requests and forge responses.
9 |
10 | ---
11 | 
12 |
13 | # Install
14 | ### Golang
15 | ```bash
16 | go install github.com/gchaincl/httplab/cmd/httplab@latest
17 | ```
18 |
19 | ### Archlinux
20 | ```
21 | yaourt httplab
22 | ```
23 |
24 | ### ~Snap~ [FIXME](https://github.com/gchaincl/httplab/issues/78)
25 | On [systems](https://snapcraft.io/docs/core/install) where snap is supported:
26 | ```
27 | snap install httplab
28 | ```
29 |
30 | ### Binary distribution
31 | Each release provides pre-built binaries for different architectures, you can download them here: https://github.com/gchaincl/httplab/releases/latest
32 |
33 | ## Help
34 | ```
35 | Usage of httplab:
36 | -a, --auto-update Auto-updates response when fields change. (default true)
37 | -b, --body string Specifies the inital response body. (default "Hello, World")
38 | -c, --config string Specifies custom config path.
39 | --cors Enable CORS.
40 | --cors-display Display CORS requests. (default true)
41 | -d, --delay int Specifies the initial response delay in ms.
42 | -H, --headers strings Specifies the initial response headers. (default [X-Server:HTTPLab])
43 | -p, --port int Specifies the port where HTTPLab will bind to. (default 10080)
44 | -s, --status string Specifies the initial response status. (default "200")
45 | -v, --version Prints current version.
46 | ```
47 |
48 | ### Key Bindings
49 | Key | Description
50 | ----------------------------------------|---------------------------------------
51 | Tab | Next Input
52 | Shift+Tab | Previous Input
53 | Ctrl+a | Apply Response changes
54 | Ctrl+r | Resets Request history
55 | Ctrl+s | Save Response as
56 | Ctrl+f | Save Request as
57 | Ctrl+l | Toggle Responses list
58 | Ctrl+t | Toggle Response builder
59 | Ctrl+o | Open Body file
60 | Ctrl+b | Switch Body mode
61 | Ctrl+h | Toggle Help
62 | Ctrl+w | Toggle line wrapping
63 | q | Close popup
64 | PgUp | Previous Request
65 | PgDown | Next Request
66 | Ctrl+c | Quit
67 |
68 | HTTPLab uses file to store pre-built responses, it will look for a file called `.httplab` on the current directory if not found it will fallback to `$HOME`.
69 | A sample file can be found [here](https://github.com/gchaincl/httplab/blob/master/.httplab.sample).
70 |
71 | _HTTPLab is heavily inspired by [wuzz](https://github.com/asciimoo/wuzz)_
72 |
--------------------------------------------------------------------------------
/cmd/httplab/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "net/http"
8 | "os"
9 | "os/user"
10 | "strings"
11 | "time"
12 |
13 | "github.com/gchaincl/httplab"
14 | "github.com/gchaincl/httplab/ui"
15 | "github.com/jroimartin/gocui"
16 | "github.com/rs/cors"
17 | flag "github.com/spf13/pflag"
18 | )
19 |
20 | // VERSION is the current version
21 | const VERSION = "v0.5.0-dev"
22 |
23 | // NewHandler returns a new http.Handler
24 | func NewHandler(ui *ui.UI, g *gocui.Gui) http.Handler {
25 | fn := func(w http.ResponseWriter, req *http.Request) {
26 | if err := ui.AddRequest(g, req); err != nil {
27 | ui.Info(g, "%v", err)
28 | }
29 |
30 | resp := ui.Response()
31 | time.Sleep(resp.Delay)
32 | resp.Write(w)
33 |
34 | }
35 | return http.HandlerFunc(fn)
36 | }
37 |
38 | func defaultConfigPath() string {
39 | var path = ".httplab"
40 |
41 | if _, err := os.Stat(path); !os.IsNotExist(err) {
42 | return path
43 | }
44 |
45 | u, err := user.Current()
46 | if err != nil {
47 | return path
48 | }
49 |
50 | return u.HomeDir + "/" + path
51 | }
52 |
53 | func usage() {
54 | fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
55 | flag.PrintDefaults()
56 | fmt.Fprintf(os.Stderr, "\nBindings:\n%s", ui.Bindings.Help())
57 | }
58 |
59 | // Version prints the version and exits
60 | func Version() {
61 | fmt.Fprintf(os.Stdout, "%s\n", VERSION)
62 | os.Exit(0)
63 | }
64 |
65 | type cmdArgs struct {
66 | autoUpdate bool
67 | body string
68 | config string
69 | corsEnabled bool
70 | corsDisplay bool
71 | delay int
72 | headers []string
73 | port int
74 | status string
75 | version bool
76 | }
77 |
78 | func main() {
79 | var args cmdArgs
80 |
81 | flag.Usage = usage
82 |
83 | flag.BoolVarP(&args.autoUpdate, "auto-update", "a", true, "Auto-updates response when fields change.")
84 | flag.StringVarP(&args.body, "body", "b", "Hello, World", "Specifies the initial response body.")
85 | flag.StringVarP(&args.config, "config", "c", "", "Specifies custom config path.")
86 | flag.BoolVar(&args.corsEnabled, "cors", false, "Enable CORS.")
87 | flag.BoolVar(&args.corsDisplay, "cors-display", true, "Display CORS requests.")
88 | flag.IntVarP(&args.delay, "delay", "d", 0, "Specifies the initial response delay in ms.")
89 | flag.StringSliceVarP(&args.headers, "headers", "H", []string{"X-Server:HTTPLab"}, "Specifies the initial response headers.")
90 | flag.IntVarP(&args.port, "port", "p", 10080, "Specifies the port where HTTPLab will bind to.")
91 | flag.StringVarP(&args.status, "status", "s", "200", "Specifies the initial response status.")
92 | flag.BoolVarP(&args.version, "version", "v", false, "Prints current version.")
93 |
94 | flag.Parse()
95 |
96 | if args.version {
97 | Version()
98 | }
99 |
100 | // noop
101 | middleware := func(next http.Handler) http.Handler {
102 | return next
103 | }
104 |
105 | if args.corsEnabled {
106 | middleware = cors.New(cors.Options{
107 | OptionsPassthrough: args.corsDisplay,
108 | }).Handler
109 | }
110 |
111 | if srv, err := run(args, middleware); err != nil {
112 | if err == gocui.ErrQuit {
113 | log.Println("HTTPLab is shutting down")
114 | srv.Shutdown(context.Background())
115 | } else {
116 | log.Println(err)
117 | }
118 | }
119 | }
120 |
121 | func newResponse(args *cmdArgs) (*httplab.Response, error) {
122 | resp, err := httplab.NewResponse(args.status, strings.Join(args.headers, "\n"), args.body)
123 | if err != nil {
124 | return nil, err
125 | }
126 | resp.Delay = time.Duration(args.delay) * time.Millisecond
127 | return resp, nil
128 | }
129 |
130 | func run(args cmdArgs, middleware func(next http.Handler) http.Handler) (*http.Server, error) {
131 | g, err := gocui.NewGui(gocui.Output256)
132 | if err != nil {
133 | return nil, err
134 | }
135 | defer g.Close()
136 |
137 | if args.config == "" {
138 | args.config = defaultConfigPath()
139 | }
140 |
141 | resp, err := newResponse(&args)
142 | if err != nil {
143 | return nil, err
144 | }
145 |
146 | ui := ui.New(resp, args.config)
147 | ui.AutoUpdate = args.autoUpdate
148 |
149 | errCh, err := ui.Init(g)
150 | if err != nil {
151 | return nil, err
152 | }
153 |
154 | srv := &http.Server{
155 | Addr: fmt.Sprintf(":%d", args.port),
156 | Handler: http.Handler(middleware(NewHandler(ui, g))),
157 | }
158 |
159 | go func() {
160 | // Make sure gocui has started
161 | g.Update(func(g *gocui.Gui) error { return nil })
162 |
163 | if err := srv.ListenAndServe(); err != nil {
164 | errCh <- err
165 | } else {
166 | ui.Info(g, "Listening on :%d", args.port)
167 | }
168 | }()
169 |
170 | return srv, g.MainLoop()
171 | }
172 |
--------------------------------------------------------------------------------
/contrib/archlinux/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 | !PKGBUILD
--------------------------------------------------------------------------------
/contrib/archlinux/PKGBUILD:
--------------------------------------------------------------------------------
1 | # Maintainer: Gustavo Chain
2 | pkgname=httplab
3 | pkgver=0.2.1
4 | pkgrel=2
5 | pkgdesc="An interactive web server"
6 | arch=(x86_64)
7 | url="http://github.com/gchaincl/httplab"
8 | license=('MIT')
9 | makedepends=('wget')
10 | provides=('httplab=$pkgver')
11 | conflicts=('httplab')
12 | replaces=('httplab')
13 | install=
14 | source=("$pkgname"::'https://github.com/gchaincl/httplab/releases/download/v0.2.1/httplab_linux_amd64')
15 | md5sums=(
16 | '6e1051b464963eb40e89a786ef9dcce8'
17 | )
18 |
19 | package() {
20 | install -D -s -m755 "httplab" "${pkgdir}/usr/bin/${pkgname}"
21 | }
22 |
--------------------------------------------------------------------------------
/contrib/snap/snapcraft.yaml:
--------------------------------------------------------------------------------
1 | name: httplab
2 | version: '0.1.0+git'
3 | summary: An interactive web server
4 | description: |
5 | HTTPLabs let you inspect HTTP requests and forge responses.
6 |
7 | grade: stable
8 | confinement: strict
9 |
10 | apps:
11 | httplab:
12 | command: httplab
13 | plugs:
14 | - network
15 | - network-bind
16 |
17 | parts:
18 | httplab:
19 | plugin: go
20 | source: ../../
21 | source-type: git
22 | go-importpath: github.com/gchaincl/httplab
23 |
--------------------------------------------------------------------------------
/contrib/stunnel/README.md:
--------------------------------------------------------------------------------
1 | # HTTPS Support
2 |
3 | HTTPLab does not provides support for HTTPS. In order to decrypt TLS traffic, you can use a proxy like Stunnel.
4 |
5 | ## How?
6 | ```bash
7 | # Generate a self-signed cert
8 | ./makecert.sh # Hit Enter until it finishes
9 |
10 | # Run Stunnel
11 | ./stunnel stunnel.conf
12 | ```
13 |
14 | Now you can point your HTTP client to :10443.
--------------------------------------------------------------------------------
/contrib/stunnel/makecert.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | if test -n "$1"; then
4 | CONF="$1/openssl.cnf"
5 | else
6 | CONF="openssl.cnf"
7 | fi
8 |
9 | if test -n "$2"; then
10 | OPENSSL="$2/bin/openssl"
11 | else
12 | OPENSSL=openssl
13 | fi
14 |
15 | if test -n "$3"; then
16 | RAND="$3"
17 | else
18 | RAND="/dev/urandom"
19 | fi
20 |
21 | dd if="$RAND" of=stunnel.rnd bs=256 count=1
22 | $OPENSSL req -new -x509 -days 1461 -rand stunnel.rnd -config $CONF \
23 | -out stunnel.pem -keyout stunnel.pem
24 | rm -f stunnel.rnd
25 |
26 | echo
27 | echo "Certificate details:"
28 | $OPENSSL x509 -subject -dates -fingerprint -noout -in stunnel.pem
29 | echo
30 |
--------------------------------------------------------------------------------
/contrib/stunnel/openssl.cnf:
--------------------------------------------------------------------------------
1 | # OpenSSL configuration file to create a server certificate
2 | # by Michal Trojnara 1998-2017
3 |
4 | [ req ]
5 | # comment out the next line to protect the private key with a passphrase
6 | encrypt_key = no
7 | # the default key length is secure and quite fast - do not change it
8 | default_bits = 2048
9 | default_md = sha1
10 | x509_extensions = stunnel_extensions
11 | distinguished_name = stunnel_dn
12 |
13 | [ stunnel_extensions ]
14 | nsCertType = server
15 | basicConstraints = CA:TRUE,pathlen:0
16 | keyUsage = keyCertSign
17 | extendedKeyUsage = serverAuth
18 | nsComment = "stunnel self-signed certificate"
19 |
20 | [ stunnel_dn ]
21 | countryName = Country Name (2 letter code)
22 | countryName_default = PL
23 | countryName_min = 2
24 | countryName_max = 2
25 |
26 | stateOrProvinceName = State or Province Name (full name)
27 | stateOrProvinceName_default = Mazovia Province
28 |
29 | localityName = Locality Name (eg, city)
30 | localityName_default = Warsaw
31 |
32 | organizationName = Organization Name (eg, company)
33 | organizationName_default = Stunnel Developers
34 |
35 | organizationalUnitName = Organizational Unit Name (eg, section)
36 | organizationalUnitName_default = Provisional CA
37 |
38 | 0.commonName = Common Name (FQDN of your server)
39 | 0.commonName_default = localhost
40 |
41 | # To create a certificate for more than one name uncomment:
42 | # 1.commonName = DNS alias of your server
43 | # 2.commonName = DNS alias of your server
44 | # ...
45 | # See http://home.netscape.com/eng/security/ssl_2.0_certificate.html
46 | # to see how Netscape understands commonName.
47 |
48 |
--------------------------------------------------------------------------------
/contrib/stunnel/stunnel.conf:
--------------------------------------------------------------------------------
1 | foreground = yes
2 | debug = info
3 |
4 | [httplab]
5 | accept = 10443
6 | connect = 10080
7 | cert = stunnel.pem
8 |
--------------------------------------------------------------------------------
/dump.go:
--------------------------------------------------------------------------------
1 | package httplab
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "regexp"
10 | "sort"
11 | "strings"
12 | )
13 |
14 | var decolorizeRegex = regexp.MustCompile("\x1b\\[0;\\d+m")
15 |
16 | // Decolorize remove the color escape sequences from a []byte encoded string
17 | func Decolorize(s []byte) []byte {
18 | return decolorizeRegex.ReplaceAll(s, nil)
19 | }
20 |
21 | func valueOrDefault(value, def string) string {
22 | if value == "" {
23 | return def
24 | }
25 | return value
26 | }
27 |
28 | func withColor(color int, text string) string {
29 | return fmt.Sprintf("\x1b[0;%dm%s\x1b[0;0m", color, text)
30 | }
31 |
32 | func writeBody(buf *bytes.Buffer, req *http.Request) error {
33 | body, err := io.ReadAll(req.Body)
34 | if err != nil {
35 | return err
36 | }
37 |
38 | if len(body) > 0 {
39 | buf.WriteRune('\n')
40 | }
41 |
42 | if strings.Contains(req.Header.Get("Content-Type"), "application/json") {
43 | if err := json.Indent(buf, body, "", " "); err == nil {
44 | return nil
45 | }
46 | }
47 |
48 | _, err = buf.Write(body)
49 | return err
50 | }
51 |
52 | // DumpRequest pretty prints an http.Request
53 | func DumpRequest(req *http.Request) ([]byte, error) {
54 | buf := bytes.NewBuffer(nil)
55 |
56 | reqURI := req.RequestURI
57 | if reqURI == "" {
58 | reqURI = req.URL.RequestURI()
59 | }
60 |
61 | fmt.Fprintf(buf, "%s %s %s/%d.%d\n",
62 | withColor(35, valueOrDefault(req.Method, "GET")),
63 | reqURI,
64 | withColor(35, "HTTP"),
65 | req.ProtoMajor,
66 | req.ProtoMinor,
67 | )
68 |
69 | keys := sortedHeaderKeys(req)
70 | for _, key := range keys {
71 | val := req.Header.Get(key)
72 | fmt.Fprintf(buf, "%s: %s\n", withColor(31, key), withColor(32, val))
73 | }
74 |
75 | err := writeBody(buf, req)
76 | return buf.Bytes(), err
77 | }
78 |
79 | func sortedHeaderKeys(req *http.Request) []string {
80 | var keys []string
81 | for k := range req.Header {
82 | keys = append(keys, k)
83 | }
84 | sort.Strings(keys)
85 | return keys
86 | }
87 |
--------------------------------------------------------------------------------
/dump_test.go:
--------------------------------------------------------------------------------
1 | package httplab
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "net/http"
7 | "testing"
8 |
9 | "sort"
10 | "strings"
11 |
12 | "github.com/stretchr/testify/assert"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | func TestDumpRequestWithJSON(t *testing.T) {
17 | t.Run("should be indented", func(t *testing.T) {
18 | req, _ := http.NewRequest("GET", "/withJSON", bytes.NewBuffer(
19 | []byte(`{"foo": "bar", "a": [1,2,3]}`),
20 | ))
21 | req.Header.Set("Content-Type", "application/json")
22 |
23 | buf, err := DumpRequest(req)
24 | require.NoError(t, err)
25 | fmt.Printf("%s\n", buf)
26 | })
27 |
28 | t.Run("should be displayed as is", func(t *testing.T) {
29 | req, _ := http.NewRequest("GET", "/invalidJSON", bytes.NewBuffer(
30 | []byte(`invalid json`),
31 | ))
32 | req.Header.Set("Content-Type", "application/json")
33 |
34 | buf, err := DumpRequest(req)
35 | require.NoError(t, err)
36 | fmt.Printf("%s\n", buf)
37 | })
38 | }
39 |
40 | func TestDumpRequestHeaders(t *testing.T) {
41 | t.Run("request headers should be dumped in sorted order", func(t *testing.T) {
42 |
43 | keys := []string{"B", "A", "C", "D", "E", "F", "H", "G", "I"}
44 | req, _ := http.NewRequest("GET", "/", bytes.NewBuffer(nil))
45 | for _, k := range keys {
46 | req.Header.Set(k, "")
47 | }
48 |
49 | buf, err := DumpRequest(req)
50 | require.NoError(t, err)
51 | sort.Strings(keys)
52 |
53 | startLine := "GET / HTTP/1.1\n"
54 | response := startLine + strings.Join(keys, ": \n") + ": \n"
55 |
56 | assert.Contains(t, response, string(Decolorize(buf)))
57 | })
58 | }
59 |
60 | func TestDecolorization(t *testing.T) {
61 | for i := range [107]struct{}{} {
62 | text := "Some Text"
63 | nocolor := Decolorize([]byte(withColor(i, text)))
64 | assert.Equal(t, text, string(nocolor))
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/gchaincl/httplab
2 |
3 | go 1.20
4 |
5 | require (
6 | github.com/jroimartin/gocui v0.5.0
7 | github.com/rs/cors v0.0.0-20170529160756-bf64c5349c0f
8 | github.com/spf13/pflag v0.0.0-20170901120850-7aff26db30c1
9 | github.com/stretchr/testify v0.0.0-20170130113145-4d4bfba8f1d1
10 | )
11 |
12 | require (
13 | github.com/davecgh/go-spew v1.1.1 // indirect
14 | github.com/mattn/go-runewidth v0.0.14 // indirect
15 | github.com/nsf/termbox-go v1.1.1 // indirect
16 | github.com/pmezard/go-difflib v1.0.0 // indirect
17 | github.com/rivo/uniseg v0.4.4 // indirect
18 | )
19 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/jroimartin/gocui v0.5.0 h1:DCZc97zY9dMnHXJSJLLmx9VqiEnAj0yh0eTNpuEtG/4=
4 | github.com/jroimartin/gocui v0.5.0/go.mod h1:l7Hz8DoYoL6NoYnlnaX6XCNR62G7J5FfSW5jEogzaxE=
5 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
6 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
7 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
8 | github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY=
9 | github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
12 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
13 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
14 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
15 | github.com/rs/cors v0.0.0-20170529160756-bf64c5349c0f h1:XQnRgDvz3XfY8aX6ydJmG8l5BEbLGL5OVs6QvP2zVC8=
16 | github.com/rs/cors v0.0.0-20170529160756-bf64c5349c0f/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
17 | github.com/spf13/pflag v0.0.0-20170901120850-7aff26db30c1 h1:TRYBd3V/2jfUifd2vqT9S1O6mTgEwmgxgfRpI5zx6FU=
18 | github.com/spf13/pflag v0.0.0-20170901120850-7aff26db30c1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
19 | github.com/stretchr/testify v0.0.0-20170130113145-4d4bfba8f1d1 h1:Zx8Rp9ozC4FPFxfEKRSUu8+Ay3sZxEUZ7JrCWMbGgvE=
20 | github.com/stretchr/testify v0.0.0-20170130113145-4d4bfba8f1d1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
21 |
--------------------------------------------------------------------------------
/images/httplab_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qustavo/httplab/005426c2557cc2365afcfd7a364a07dff70e9adc/images/httplab_logo.png
--------------------------------------------------------------------------------
/images/httplab_logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/images/screencast.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qustavo/httplab/005426c2557cc2365afcfd7a364a07dff70e9adc/images/screencast.gif
--------------------------------------------------------------------------------
/response.go:
--------------------------------------------------------------------------------
1 | package httplab
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "os"
10 | "sort"
11 | "strconv"
12 | "strings"
13 | "time"
14 | )
15 |
16 | // BodyMode represent the current Body mode
17 | type BodyMode uint
18 |
19 | // String to satisfy interface fmt.Stringer
20 | func (m BodyMode) String() string {
21 | switch m {
22 | case BodyInput:
23 | return "Input"
24 | case BodyFile:
25 | return "File"
26 | }
27 | return ""
28 | }
29 |
30 | const (
31 | // BodyInput takes the body input from input box
32 | BodyInput BodyMode = iota + 1
33 | // BodyFile takes the body input from a file
34 | BodyFile
35 | )
36 |
37 | // Body is our response body content, that will either reference an local file or a runtime-supplied []byte.
38 | type Body struct {
39 | Mode BodyMode
40 | Input []byte
41 | File *os.File
42 | }
43 |
44 | // Payload reads out a []byte payload according to it's configuration in Body.BodyMode.
45 | func (body *Body) Payload() []byte {
46 | switch body.Mode {
47 | case BodyInput:
48 | return body.Input
49 | case BodyFile:
50 | if body.File == nil {
51 | return nil
52 | }
53 |
54 | // XXX: Handle this error
55 | bytes, _ := io.ReadAll(body.File)
56 | body.File.Seek(0, 0)
57 | return bytes
58 | }
59 | return nil
60 | }
61 |
62 | // Info returns some basic info on the body.
63 | func (body *Body) Info() []byte {
64 | switch body.Mode {
65 | case BodyInput:
66 | return body.Input
67 | case BodyFile:
68 | if body.File == nil {
69 | return nil
70 | }
71 |
72 | // XXX: Handle this error
73 | stats, _ := body.File.Stat()
74 | w := &bytes.Buffer{}
75 | fmt.Fprintf(w, "file: %s\n", body.File.Name())
76 | fmt.Fprintf(w, "size: %d bytes\n", stats.Size())
77 | fmt.Fprintf(w, "perm: %s\n", stats.Mode())
78 | return w.Bytes()
79 | }
80 | return nil
81 | }
82 |
83 | // SetFile set a new source file for the body, if it exists.
84 | func (body *Body) SetFile(path string) error {
85 | file, err := os.Open(ExpandPath(path))
86 | if err != nil {
87 | return err
88 | }
89 |
90 | body.File = file
91 | body.Mode = BodyFile
92 | return nil
93 | }
94 |
95 | // Response is the the preconfigured HTTP response that will be returned to the client.
96 | type Response struct {
97 | Status int
98 | Headers http.Header
99 | Body Body
100 | Delay time.Duration
101 | }
102 |
103 | // UnmarshalJSON inflates the Response from []byte representing JSON.
104 | func (r *Response) UnmarshalJSON(data []byte) error {
105 | type alias Response
106 | v := struct {
107 | alias
108 | Body string
109 | File string
110 | Headers map[string]string
111 | }{}
112 | if err := json.Unmarshal(data, &v); err != nil {
113 | return err
114 | }
115 |
116 | r.Status = v.Status
117 | r.Delay = v.Delay
118 | r.Body.Input = []byte(v.Body)
119 | if v.File != "" {
120 | if err := r.Body.SetFile(v.File); err != nil {
121 | return err
122 | }
123 | }
124 |
125 | if r.Body.File != nil {
126 | r.Body.Mode = BodyFile
127 | } else {
128 | r.Body.Mode = BodyInput
129 | }
130 |
131 | if r.Headers == nil {
132 | r.Headers = http.Header{}
133 | }
134 | for key := range v.Headers {
135 | r.Headers.Set(key, v.Headers[key])
136 | }
137 |
138 | return nil
139 | }
140 |
141 | // MarshalJSON serializes the response into a JSON []byte.
142 | func (r *Response) MarshalJSON() ([]byte, error) {
143 | type alias Response
144 | v := struct {
145 | alias
146 | Body string
147 | File string
148 | Headers map[string]string
149 | }{
150 | Headers: make(map[string]string),
151 | }
152 |
153 | v.Delay = time.Duration(r.Delay) / time.Millisecond
154 | v.Status = r.Status
155 |
156 | if len(r.Body.Input) > 0 {
157 | v.Body = string(r.Body.Input)
158 | }
159 |
160 | if r.Body.File != nil {
161 | v.File = r.Body.File.Name()
162 | }
163 |
164 | for key := range r.Headers {
165 | v.Headers[key] = r.Headers.Get(key)
166 | }
167 |
168 | return json.MarshalIndent(v, "", " ")
169 | }
170 |
171 | // NewResponse configures a new response. An empty status will be interpreted as 200 OK.
172 | func NewResponse(status, headers, body string) (*Response, error) {
173 | // Parse Status
174 | status = strings.Trim(status, " \r\n")
175 | if status == "" {
176 | status = "200"
177 | }
178 | code, err := strconv.Atoi(status)
179 | if err != nil {
180 | return nil, fmt.Errorf("Status: %v", err)
181 | }
182 |
183 | if code < 100 || code > 599 {
184 | return nil, fmt.Errorf("Status should be between 100 and 599")
185 | }
186 |
187 | // Parse Headers
188 | hdr := http.Header{}
189 | lines := strings.Split(headers, "\n")
190 | for _, line := range lines {
191 | if line == "" {
192 | continue
193 | }
194 |
195 | kv := strings.SplitN(line, ":", 2)
196 | if len(kv) != 2 {
197 | continue
198 | }
199 | key := strings.TrimSpace(kv[0])
200 | val := strings.TrimSpace(kv[1])
201 | hdr.Set(key, val)
202 | }
203 |
204 | return &Response{
205 | Status: code,
206 | Headers: hdr,
207 | Body: Body{
208 | Mode: BodyInput,
209 | Input: []byte(body),
210 | },
211 | }, nil
212 | }
213 |
214 | // Write flushes the body into the ResponseWriter, hence sending it over the wire.
215 | func (r *Response) Write(w http.ResponseWriter) error {
216 | for key := range r.Headers {
217 | w.Header().Set(key, r.Headers.Get(key))
218 | }
219 | w.WriteHeader(r.Status)
220 | _, err := w.Write(r.Body.Payload())
221 |
222 | return err
223 | }
224 |
225 | // ResponsesList holds the multiple configured responses.
226 | type ResponsesList struct {
227 | List map[string]*Response
228 | keys []string
229 | current int
230 | }
231 |
232 | // NewResponsesList creates a new empty response list and returns it.
233 | func NewResponsesList() *ResponsesList {
234 | return (&ResponsesList{}).reset()
235 | }
236 |
237 | func (rl *ResponsesList) reset() *ResponsesList {
238 | rl.current = 0
239 | rl.List = make(map[string]*Response)
240 | rl.keys = nil
241 | return rl
242 | }
243 |
244 | func (rl *ResponsesList) load(path string) (map[string]*Response, error) {
245 | f, err := openConfigFile(path)
246 | if err != nil {
247 | return nil, err
248 | }
249 |
250 | rs := struct {
251 | Responses map[string]*Response
252 | }{}
253 |
254 | if err := json.NewDecoder(f).Decode(&rs); err != nil {
255 | if err == io.EOF {
256 | return nil, nil
257 | }
258 | return nil, err
259 | }
260 |
261 | return rs.Responses, nil
262 | }
263 |
264 | // Load loads a response list from a local JSON document.
265 | func (rl *ResponsesList) Load(path string) error {
266 | rs, err := rl.load(path)
267 | if err != nil {
268 | return err
269 | }
270 |
271 | rl.reset()
272 | if rs != nil {
273 | rl.List = rs
274 | }
275 |
276 | for key := range rs {
277 | rl.keys = append(rl.keys, key)
278 | }
279 | sort.Strings(rl.keys)
280 |
281 | return nil
282 | }
283 |
284 | // Save saves the current response list to a JSON document on local disk.
285 | func (rl *ResponsesList) Save(path string) error {
286 | f, err := openConfigFile(path)
287 | if err != nil {
288 | return err
289 | }
290 | defer f.Close()
291 |
292 | stat, err := f.Stat()
293 | if err != nil {
294 | return err
295 | }
296 |
297 | if err := f.Truncate(stat.Size()); err != nil {
298 | return err
299 | }
300 |
301 | buf, err := json.MarshalIndent(struct {
302 | Responses map[string]*Response
303 | }{rl.List}, "", " ")
304 | if err != nil {
305 | return err
306 | }
307 |
308 | if _, err := f.Write(buf); err != nil {
309 | return err
310 | }
311 |
312 | return nil
313 | }
314 |
315 | // Next iterates to the next item in the response list.
316 | func (rl *ResponsesList) Next() { rl.current = (rl.current + 1) % len(rl.keys) }
317 |
318 | // Prev iterates to the previous item in the response list.
319 | func (rl *ResponsesList) Prev() { rl.current = (rl.current - 1 + len(rl.keys)) % len(rl.keys) }
320 |
321 | // Cur retrieves the current response from the response list.
322 | func (rl *ResponsesList) Cur() *Response { return rl.List[rl.keys[rl.current]] }
323 |
324 | // Index retrieves the index of the current item in the response list.
325 | func (rl *ResponsesList) Index() int { return rl.current }
326 |
327 | // Len reports the length of the response list.
328 | func (rl *ResponsesList) Len() int { return len(rl.keys) }
329 |
330 | // Keys retrieves an []string of all keys in the response list.
331 | func (rl *ResponsesList) Keys() []string { return rl.keys }
332 |
333 | // Get retrieves a specific response by name from the response list.
334 | func (rl *ResponsesList) Get(key string) *Response { return rl.List[key] }
335 |
336 | // Add appends a response item to the list. You need to supply a key for the item.
337 | func (rl *ResponsesList) Add(key string, r *Response) *ResponsesList {
338 | rl.keys = append(rl.keys, key)
339 | sort.Strings(rl.keys)
340 | rl.List[key] = r
341 | return rl
342 | }
343 |
344 | // Del removes an item spceified by its key from the response list. It returns false if the item didn't exist at all.
345 | func (rl *ResponsesList) Del(key string) bool {
346 | if _, ok := rl.List[key]; !ok {
347 | return false
348 | }
349 | delete(rl.List, key)
350 |
351 | i := sort.SearchStrings(rl.keys, key)
352 | rl.keys = append(rl.keys[:i], rl.keys[i+1:]...)
353 |
354 | return true
355 | }
356 |
357 | // ExpandPath expands a given path by replacing '~' with $HOME of the current user.
358 | func ExpandPath(path string) string {
359 | if path[0] == '~' {
360 | path = "$HOME" + path[1:]
361 | }
362 | return os.ExpandEnv(path)
363 | }
364 |
365 | func openConfigFile(path string) (*os.File, error) {
366 | return os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0666)
367 | }
368 |
--------------------------------------------------------------------------------
/response_test.go:
--------------------------------------------------------------------------------
1 | package httplab
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "net/http/httptest"
7 | "os"
8 | "strconv"
9 | "testing"
10 | "time"
11 |
12 | "github.com/stretchr/testify/assert"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | func TestResponseStatus(t *testing.T) {
17 | // only status between 100 and 599 are valid
18 | for i := 100; i < 600; i++ {
19 | status := strconv.Itoa(i)
20 | _, err := NewResponse(status, "", "")
21 | assert.NoError(t, err)
22 | }
23 |
24 | for _, status := range []string{"600", "99", "foo", "2xx"} {
25 | _, err := NewResponse(status, "", "")
26 | assert.Error(t, err, fmt.Sprintf("status '%s' should be invalid", status))
27 | }
28 |
29 | for _, format := range []string{" %d ", "%d\n", " %d \n", "%d\r\r"} {
30 | status := fmt.Sprintf(format, 200)
31 | _, err := NewResponse(status, "", "")
32 | assert.NoError(t, err)
33 | }
34 |
35 | // default value
36 | t.Run("Default Value", func(t *testing.T) {
37 | resp, err := NewResponse("", "", "")
38 | require.NoError(t, err)
39 | assert.Equal(t, 200, resp.Status)
40 | })
41 | }
42 |
43 | func TestResponseHeaders(t *testing.T) {
44 | headers := `
45 | Content-Type: application/json
46 | X-MyHeader: value
47 | Location: http://foo.bar:8000
48 | X-Empty:
49 | Invalid
50 | `
51 |
52 | resp, err := NewResponse("", headers, "")
53 | require.NoError(t, err)
54 | assert.Equal(t, "application/json", resp.Headers.Get("Content-Type"))
55 | assert.Equal(t, "value", resp.Headers.Get("X-MyHeader"))
56 | assert.Equal(t, "", resp.Headers.Get("X-Empty"))
57 | assert.Equal(t, "http://foo.bar:8000", resp.Headers.Get("Location"))
58 | assert.Contains(t, resp.Headers, "X-Empty")
59 | assert.NotContains(t, resp.Headers, "Invalid")
60 | }
61 |
62 | func TestResponseWrite(t *testing.T) {
63 | rec := httptest.NewRecorder()
64 | resp := &Response{
65 | Status: 201,
66 | Headers: http.Header{
67 | "X-Foo": []string{"bar"},
68 | },
69 | Body: Body{
70 | Input: []byte("Hello, World"),
71 | },
72 | }
73 |
74 | resp.Write(rec)
75 |
76 | assert.Equal(t, resp.Status, rec.Code)
77 | assert.Equal(t, resp.Headers.Get("X-Foo"), rec.Header().Get("X-Foo"))
78 | assert.Equal(t, resp.Body.Payload(), rec.Body.Bytes())
79 | }
80 |
81 | func TestResponsesList(t *testing.T) {
82 | rl := NewResponsesList()
83 | rl.Add("200", &Response{Status: 200}).
84 | Add("201", &Response{Status: 201}).
85 | Add("404", &Response{Status: 404}).
86 | Add("500", &Response{Status: 500})
87 |
88 | t.Run("Len()", func(t *testing.T) {
89 | assert.Equal(t, 4, rl.Len())
90 | })
91 |
92 | t.Run("Get()", func(t *testing.T) {
93 | assert.Equal(t, 200, rl.Get("200").Status)
94 | assert.Equal(t, 201, rl.Get("201").Status)
95 | assert.Equal(t, 404, rl.Get("404").Status)
96 | assert.Equal(t, 500, rl.Get("500").Status)
97 | })
98 |
99 | t.Run("Indexing", func(t *testing.T) {
100 | assert.Equal(t, 200, rl.Cur().Status)
101 | assert.Equal(t, 0, rl.Index())
102 |
103 | rl.Next()
104 | assert.Equal(t, 201, rl.Cur().Status)
105 | assert.Equal(t, 1, rl.Index())
106 |
107 | rl.Next()
108 | assert.Equal(t, 404, rl.Cur().Status)
109 | assert.Equal(t, 2, rl.Index())
110 |
111 | rl.Next()
112 | assert.Equal(t, 500, rl.Cur().Status)
113 | assert.Equal(t, 3, rl.Index())
114 |
115 | rl.Next()
116 | assert.Equal(t, 200, rl.Cur().Status)
117 | assert.Equal(t, 0, rl.Index())
118 |
119 | rl.Prev()
120 | assert.Equal(t, 500, rl.Cur().Status)
121 | assert.Equal(t, 3, rl.Index())
122 |
123 | rl.Prev()
124 | assert.Equal(t, 404, rl.Cur().Status)
125 | assert.Equal(t, 2, rl.Index())
126 | })
127 |
128 | t.Run("Del()", func(t *testing.T) {
129 | for _, status := range []string{"200", "201", "404", "500"} {
130 | assert.NotNil(t, rl.Get(status))
131 | rl.Del(status)
132 | assert.Nil(t, rl.Get(status))
133 | }
134 | })
135 | }
136 |
137 | func TestLoadFromJSON(t *testing.T) {
138 | rl := NewResponsesList()
139 | require.NoError(t, rl.Load("./testdata/httplab.json"))
140 |
141 | r := rl.Get("t1")
142 | require.NotNil(t, r)
143 | assert.Equal(t, 200, r.Status)
144 | assert.Equal(t, time.Duration(1000), r.Delay)
145 | assert.Equal(t, "value", r.Headers.Get("X-MyHeader"))
146 |
147 | r.Body.Mode = BodyInput
148 | assert.Equal(t, []byte("xxx"), r.Body.Payload())
149 | assert.Equal(t, []byte("xxx"), r.Body.Info())
150 |
151 | r.Body.Mode = BodyFile
152 | assert.Equal(t, []byte(""), r.Body.Payload())
153 |
154 | t.Run("When config file is empty", func(t *testing.T) {
155 | path := string(time.Now().UnixNano())
156 | defer os.Remove(path)
157 |
158 | require.NoError(t, rl.Load(path))
159 | assert.Equal(t, 0, rl.Len())
160 |
161 | // file has to be created
162 | _, err := os.Stat(path)
163 | assert.NoError(t, err)
164 | })
165 | }
166 |
167 | func TestExpandPathExpansion(t *testing.T) {
168 | defer os.Setenv("HOME", os.Getenv("HOME"))
169 |
170 | for key, val := range map[string]string{
171 | "HOME": "/home/gchaincl",
172 | "ENV1": "env1",
173 | "ENV2": "env2",
174 | } {
175 | os.Setenv(key, val)
176 | }
177 |
178 | paths := []struct {
179 | expr string
180 | expected string
181 | }{
182 | {"~/foo", "/home/gchaincl/foo"},
183 | {"./foo/~/bar", "./foo/~/bar"},
184 | {"/$ENV1/foo/$ENV2", "/env1/foo/env2"},
185 | {"$NOTDEFINED/foo", "/foo"},
186 | }
187 |
188 | for _, path := range paths {
189 | assert.Equal(t, path.expected, ExpandPath(path.expr))
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/testdata/httplab.json:
--------------------------------------------------------------------------------
1 | {
2 | "Responses": {
3 | "t1": {
4 | "Status": 200,
5 | "Delay": 1000,
6 | "Headers": {
7 | "X-MyHeader": "value"
8 | },
9 | "Body": "xxx",
10 | "File": "./testdata/index.html"
11 | }
12 | }
13 | }
--------------------------------------------------------------------------------
/testdata/index.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/ui/bindings.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "text/tabwriter"
7 |
8 | "github.com/jroimartin/gocui"
9 | )
10 |
11 | // ActionFn is binded to a key combination
12 | type ActionFn func(*gocui.Gui, *gocui.View) error
13 |
14 | type binding struct {
15 | keyCode interface{}
16 | keyName string
17 | help string
18 | views []string
19 | action func(*UI) ActionFn
20 | }
21 |
22 | type bindings []binding
23 |
24 | func (bs bindings) Apply(ui *UI, g *gocui.Gui) error {
25 | for _, b := range bs {
26 | if b.action == nil {
27 | continue
28 | }
29 |
30 | views := b.views
31 | if len(views) == 0 {
32 | views = []string{""}
33 | }
34 |
35 | for _, v := range views {
36 | err := g.SetKeybinding(v, b.keyCode, gocui.ModNone, b.action(ui))
37 | if err != nil {
38 | return err
39 | }
40 | }
41 | }
42 |
43 | return g.SetKeybinding("", gocui.KeyCtrlH, gocui.ModNone, func(g *gocui.Gui, _ *gocui.View) error {
44 | return ui.toggleHelp(g, bs.Help())
45 | })
46 | }
47 |
48 | func (bs bindings) Help() string {
49 | buf := &bytes.Buffer{}
50 | w := tabwriter.NewWriter(buf, 0, 0, 3, ' ', tabwriter.DiscardEmptyColumns)
51 | for _, b := range bs {
52 | if b.keyName == "" || b.help == "" {
53 | continue
54 | }
55 | fmt.Fprintf(w, " %s\t: %s\n", b.keyName, b.help)
56 | }
57 |
58 | fmt.Fprintf(w, " %s\t: %s\n", "Ctrl+h", "Toggle Help")
59 | w.Flush()
60 | return buf.String()
61 | }
62 |
63 | // Bindings are the list of binded key combinations
64 | var Bindings = &bindings{
65 | {gocui.KeyTab, "Tab", "Next Input", nil, onNextView},
66 | {0xFF, "Shift+Tab", "Previous Input", nil, nil}, // only to display on help
67 | {gocui.KeyCtrlA, "Ctrl+a", "Update Response", nil, onUpdateResponse},
68 | {gocui.KeyCtrlR, "Ctrl+r", "Reset Request history", nil, onResetRequests},
69 | {gocui.KeyCtrlS, "Ctrl+s", "Save Response as", nil, onSaveResponseAs},
70 | {gocui.KeyCtrlF, "Ctrl+f", "Save Request as", nil, onSaveRequestAs},
71 | {gocui.KeyCtrlL, "Ctrl+l", "Toggle Responses list", nil, onToggleResponsesList},
72 | {gocui.KeyCtrlT, "Ctrl+t", "Toggle Response builder", nil, onToggleResponseBuilder},
73 | {gocui.KeyCtrlO, "Ctrl+o", "Open Body file...", nil, onOpenFile},
74 | {gocui.KeyCtrlB, "Ctrl+b", "Switch Body mode", nil, onSwitchBodyMode},
75 | {gocui.KeyCtrlW, "Ctrl+w", "Toggle line wrapping", nil, onTogglLineWrapping},
76 | {'q', "q", "Close Popup", []string{"bindings", "responses"}, onClosePopup},
77 | {gocui.KeyPgup, "PgUp", "Previous Request", nil, onPrevRequest},
78 | {gocui.KeyPgdn, "PgDown", "Next Request", nil, onNextRequest},
79 | {gocui.KeyCtrlC, "Ctrl+c", "Quit", nil, onQuit},
80 | }
81 |
82 | func onNextView(ui *UI) ActionFn {
83 | return func(g *gocui.Gui, _ *gocui.View) error {
84 | return ui.nextView(g)
85 | }
86 | }
87 |
88 | func onUpdateResponse(ui *UI) ActionFn {
89 | return func(g *gocui.Gui, v *gocui.View) error {
90 | return ui.updateResponse(g)
91 | }
92 | }
93 |
94 | func onResetRequests(ui *UI) ActionFn {
95 | return func(g *gocui.Gui, v *gocui.View) error {
96 | return ui.resetRequests(g)
97 | }
98 | }
99 |
100 | func onSaveResponseAs(ui *UI) ActionFn {
101 | return func(g *gocui.Gui, v *gocui.View) error {
102 | return ui.saveResponsePopup(g)
103 | }
104 | }
105 |
106 | func onSaveRequestAs(ui *UI) ActionFn {
107 | return func(g *gocui.Gui, v *gocui.View) error {
108 | return ui.saveRequestPopup(g)
109 | }
110 | }
111 |
112 | func onToggleResponsesList(ui *UI) ActionFn {
113 | return func(g *gocui.Gui, v *gocui.View) error {
114 | if err := ui.toggleResponsesLoader(g); err != nil {
115 | ui.Info(g, err.Error())
116 | }
117 | return nil
118 | }
119 | }
120 |
121 | func onToggleResponseBuilder(ui *UI) ActionFn {
122 | return func(g *gocui.Gui, v *gocui.View) error {
123 | if err := ui.toggleResponseBuilder(g); err != nil {
124 | ui.Info(g, err.Error())
125 | }
126 | return nil
127 | }
128 | }
129 |
130 | func onOpenFile(ui *UI) ActionFn {
131 | return func(g *gocui.Gui, v *gocui.View) error {
132 | return ui.openBodyFilePopup(g)
133 | }
134 | }
135 |
136 | func onSwitchBodyMode(ui *UI) ActionFn {
137 | return func(g *gocui.Gui, v *gocui.View) error {
138 | return ui.nextBodyMode(g)
139 | }
140 | }
141 |
142 | func onTogglLineWrapping(ui *UI) ActionFn {
143 | return func(g *gocui.Gui, v *gocui.View) error {
144 | return ui.toggleLineWrap(g)
145 | }
146 | }
147 |
148 | func onPrevRequest(ui *UI) ActionFn {
149 | return func(g *gocui.Gui, v *gocui.View) error {
150 | return ui.prevRequest(g)
151 | }
152 | }
153 |
154 | func onNextRequest(ui *UI) ActionFn {
155 | return func(g *gocui.Gui, v *gocui.View) error {
156 | return ui.nextRequest(g)
157 | }
158 | }
159 |
160 | func onClosePopup(ui *UI) ActionFn {
161 | return func(g *gocui.Gui, v *gocui.View) error {
162 | return ui.closePopup(g, v.Name())
163 | }
164 | }
165 |
166 | func onQuit(ui *UI) ActionFn {
167 | return func(g *gocui.Gui, v *gocui.View) error {
168 | return gocui.ErrQuit
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/ui/editor.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/jroimartin/gocui"
7 | )
8 |
9 | type editor struct {
10 | ui *UI
11 | g *gocui.Gui
12 | handler gocui.Editor
13 | backTabEscape bool
14 | }
15 |
16 | func newEditor(ui *UI, g *gocui.Gui, handler gocui.Editor) *editor {
17 | if handler == nil {
18 | handler = gocui.DefaultEditor
19 | }
20 |
21 | return &editor{ui, g, handler, false}
22 | }
23 |
24 | func (e *editor) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) {
25 | if ch == '[' && mod == gocui.ModAlt {
26 | e.backTabEscape = true
27 | return
28 | }
29 |
30 | if e.backTabEscape {
31 | if ch == 'Z' {
32 | e.ui.prevView(e.g)
33 | e.backTabEscape = false
34 | return
35 | }
36 | }
37 |
38 | // update hasChange status only when user has updated any response component
39 | if v.Name() != "request" {
40 | e.ui.hasChanged = true
41 | }
42 |
43 | // prevent infinite scrolling
44 | if (key == gocui.KeyArrowDown || key == gocui.KeyArrowRight) && mod == gocui.ModNone {
45 | _, cy := v.Cursor()
46 | if _, err := v.Line(cy); err != nil {
47 | return
48 | }
49 | }
50 |
51 | e.handler.Edit(v, key, ch, mod)
52 | }
53 |
54 | type motionEditor struct{}
55 |
56 | func (e *motionEditor) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) {
57 | _, y := v.Cursor()
58 | maxY := strings.Count(v.Buffer(), "\n")
59 | switch {
60 | case key == gocui.KeyArrowDown:
61 | if y < maxY {
62 | v.MoveCursor(0, 1, true)
63 | }
64 | case key == gocui.KeyArrowUp:
65 | v.MoveCursor(0, -1, false)
66 | case key == gocui.KeyArrowLeft:
67 | v.MoveCursor(-1, 0, false)
68 | case key == gocui.KeyArrowRight:
69 | v.MoveCursor(1, 0, false)
70 | }
71 | }
72 |
73 | type numberEditor struct {
74 | maxLength int
75 | }
76 |
77 | func (e *numberEditor) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) {
78 | x, _ := v.Cursor()
79 | switch {
80 | case ch >= 48 && ch <= 57:
81 | if len(v.Buffer()) > e.maxLength+1 {
82 | return
83 | }
84 | gocui.DefaultEditor.Edit(v, key, ch, mod)
85 | case key == gocui.KeyBackspace || key == gocui.KeyBackspace2:
86 | v.EditDelete(true)
87 | case key == gocui.KeyArrowLeft:
88 | v.MoveCursor(-1, 0, false)
89 | case key == gocui.KeyArrowRight:
90 | if x < len(v.Buffer())-1 {
91 | v.MoveCursor(1, 0, false)
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/ui/split.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import "math"
4 |
5 | // Split simplifies the layout definition.
6 | type Split struct {
7 | size int
8 | left int
9 | points []int
10 | index int
11 | }
12 |
13 | // NewSplit returns a new Split
14 | func NewSplit(size int) *Split {
15 | return &Split{size: size, left: size, points: []int{0}}
16 | }
17 |
18 | // Fixed defines a set of fixed or absolute points
19 | func (s *Split) Fixed(points ...int) *Split {
20 | for _, point := range points {
21 | s.points = append(s.points, point+(s.size-s.left))
22 | s.left -= point
23 | }
24 |
25 | return s
26 | }
27 |
28 | // Relative defines a set of relative points
29 | func (s *Split) Relative(points ...int) *Split {
30 | for _, point := range points {
31 | per := float64(point) / 100.0
32 | rel := math.Floor(0.5 + float64(s.left)*per)
33 | s.Fixed(int(rel))
34 | }
35 | return s
36 | }
37 |
38 | // Next returns the next point in the set
39 | func (s *Split) Next() int {
40 | if s.index+1 == len(s.points) {
41 | return 0
42 | }
43 |
44 | s.index++
45 | next := s.points[s.index]
46 | return next
47 | }
48 |
49 | // Current returns the current point in the set
50 | func (s *Split) Current() int {
51 | return s.points[s.index]
52 | }
53 |
--------------------------------------------------------------------------------
/ui/split_test.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestSplit(t *testing.T) {
10 | t.Run("Fixed", func(t *testing.T) {
11 | /*
12 | | 1 2 3 4 5 6 7 8 9 A |
13 | | _ _ x _ _ x _ x _ _ |
14 | */
15 | split := NewSplit(10).Fixed(3, 3, 2)
16 | assert.Equal(t, 3, split.Next())
17 | assert.Equal(t, 6, split.Next())
18 | assert.Equal(t, 8, split.Next())
19 | assert.Equal(t, 0, split.Next())
20 | })
21 |
22 | t.Run("Relative", func(t *testing.T) {
23 |
24 | /*
25 | | 1 2 3 4 5 6 7 8 9 A |
26 | | x _ _ _ _ x _ _ x _ |
27 | */
28 |
29 | split := NewSplit(10).Relative(20, 50, 50)
30 | assert.Equal(t, 2, split.Next())
31 | assert.Equal(t, 6, split.Next())
32 | assert.Equal(t, 8, split.Next())
33 | assert.Equal(t, 0, split.Next())
34 | })
35 |
36 | /*
37 | | 1 2 3 4 5 6 7 8 9 A |
38 | | f f _ _ _ r f _ r _ |
39 | */
40 |
41 | split := NewSplit(10).Fixed(1, 1).Relative(50).Fixed(1).Relative(50)
42 | assert.Equal(t, 1, split.Next())
43 | assert.Equal(t, 2, split.Next())
44 | assert.Equal(t, 6, split.Next())
45 | assert.Equal(t, 7, split.Next())
46 | assert.Equal(t, 9, split.Next())
47 | assert.Equal(t, 0, split.Next())
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/ui/ui.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net/http"
7 | "os"
8 | "strconv"
9 | "strings"
10 | "sync"
11 | "time"
12 |
13 | "github.com/gchaincl/httplab"
14 | "github.com/jroimartin/gocui"
15 | )
16 |
17 | const (
18 | // StatusView widget sets the response staus code
19 | StatusView = "status"
20 | // DelayView widget sets the response delay time
21 | DelayView = "delay"
22 | // HeaderView widget sets the response headers
23 | HeaderView = "headers"
24 | // BodyView widget sets the response body content
25 | BodyView = "body"
26 | // BodyFileView widget shows the file content to be set on the response body
27 | BodyFileView = "bodyfile"
28 | // RequestView widget displays the request
29 | RequestView = "request"
30 | // InfoView widget displays the bottom info bar
31 | InfoView = "info"
32 | // SaveView widget displays the saving location
33 | SaveView = "save"
34 | // ResponsesView widget displays the saved responses
35 | ResponsesView = "responses"
36 | // BindingsView widget displays binding help
37 | BindingsView = "bindings"
38 | // FileDialogView widget displays the popup to choose the response body file
39 | FileDialogView = "file-dialog"
40 | )
41 |
42 | var cicleable = []string{
43 | StatusView,
44 | DelayView,
45 | HeaderView,
46 | BodyView,
47 | RequestView,
48 | }
49 |
50 | func max(a, b int) int {
51 | if a > b {
52 | return a
53 | }
54 | return b
55 | }
56 |
57 | // Cursors stores the cursor position for a specific view
58 | // this is used to restore mouse position when click is detected.
59 | type Cursors map[string]struct{ x, y int }
60 |
61 | // NewCursors returns a new Cursor.
62 | func NewCursors() Cursors {
63 | return make(Cursors)
64 | }
65 |
66 | // Restore restores the cursor position.
67 | func (c Cursors) Restore(view *gocui.View) error {
68 | return view.SetCursor(c.Get(view.Name()))
69 | }
70 |
71 | // Get gets the cursor position.
72 | func (c Cursors) Get(view string) (int, int) {
73 | if v, ok := c[view]; ok {
74 | return v.x, v.y
75 | }
76 | return 0, 0
77 | }
78 |
79 | // Set sets the cursor position.
80 | func (c Cursors) Set(view string, x, y int) {
81 | c[view] = struct{ x, y int }{x, y}
82 | }
83 |
84 | // UI represent the state of the ui.
85 | type UI struct {
86 | resp *httplab.Response
87 | responses *httplab.ResponsesList
88 | infoTimer *time.Timer
89 | viewIndex int
90 | currentPopup string
91 | configPath string
92 | hideResponseBuilder bool
93 | cursors Cursors
94 |
95 | reqLock sync.Mutex
96 | requests [][]byte
97 | currentRequest int
98 |
99 | AutoUpdate bool
100 | hasChanged bool
101 | }
102 |
103 | // New returns a new UI with default values specified on the Response.
104 | func New(resp *httplab.Response, configPath string) *UI {
105 | return &UI{
106 | resp: resp,
107 | responses: httplab.NewResponsesList(),
108 | configPath: configPath,
109 | cursors: NewCursors(),
110 | }
111 | }
112 |
113 | // Init initializes the UI.
114 | func (ui *UI) Init(g *gocui.Gui) (chan<- error, error) {
115 | g.Cursor = true
116 | g.Highlight = true
117 | g.SelFgColor = gocui.ColorGreen
118 | g.Mouse = true
119 |
120 | g.SetManager(ui)
121 | if err := Bindings.Apply(ui, g); err != nil {
122 | return nil, err
123 | }
124 |
125 | errCh := make(chan error)
126 | go func() {
127 | err := <-errCh
128 | g.Update(func(g *gocui.Gui) error {
129 | return err
130 | })
131 | }()
132 |
133 | for _, view := range cicleable {
134 | fn := func(g *gocui.Gui, v *gocui.View) error {
135 | cx, cy := v.Cursor()
136 | line, err := v.Line(cy)
137 | if err != nil {
138 | ui.cursors.Restore(v)
139 | ui.setView(g, v.Name())
140 | return nil
141 | }
142 |
143 | if cx > len(line) {
144 | v.SetCursor(len(line), cy)
145 | ui.cursors.Set(v.Name(), len(line), cy)
146 | }
147 |
148 | ui.setView(g, v.Name())
149 | return nil
150 | }
151 |
152 | if err := g.SetKeybinding(view, gocui.MouseLeft, gocui.ModNone, fn); err != nil {
153 | return nil, err
154 | }
155 |
156 | if err := g.SetKeybinding(view, gocui.MouseRelease, gocui.ModNone, fn); err != nil {
157 | return nil, err
158 | }
159 | }
160 |
161 | return errCh, nil
162 | }
163 |
164 | // AddRequest adds a new request to the UI.
165 | func (ui *UI) AddRequest(g *gocui.Gui, req *http.Request) error {
166 | ui.reqLock.Lock()
167 | defer ui.reqLock.Unlock()
168 |
169 | ui.Info(g, "New Request from "+req.Host)
170 | buf, err := httplab.DumpRequest(req)
171 | if err != nil {
172 | return err
173 | }
174 |
175 | if ui.currentRequest == len(ui.requests)-1 {
176 | ui.currentRequest++
177 | }
178 |
179 | ui.requests = append(ui.requests, buf)
180 | return ui.updateRequest(g)
181 | }
182 |
183 | func (ui *UI) updateRequest(g *gocui.Gui) error {
184 | req := ui.requests[ui.currentRequest]
185 |
186 | view, err := g.View(RequestView)
187 | if err != nil {
188 | return err
189 | }
190 |
191 | view.Title = fmt.Sprintf("Request (%d/%d)", ui.currentRequest+1, len(ui.requests))
192 | return ui.Display(g, RequestView, req)
193 | }
194 |
195 | func (ui *UI) resetRequests(g *gocui.Gui) error {
196 | ui.reqLock.Lock()
197 | defer ui.reqLock.Unlock()
198 | ui.requests = nil
199 | ui.currentRequest = 0
200 |
201 | v, err := g.View(RequestView)
202 | if err != nil {
203 | return err
204 | }
205 |
206 | v.Title = "Request"
207 | v.Clear()
208 | ui.Info(g, "Requests cleared")
209 | return nil
210 | }
211 |
212 | // Layout sets the layout
213 | func (ui *UI) Layout(g *gocui.Gui) error {
214 | maxX, maxY := g.Size()
215 |
216 | var splitX, splitY *Split
217 | if ui.hideResponseBuilder {
218 | splitX = NewSplit(maxX).Fixed(maxX - 1)
219 | } else {
220 | splitX = NewSplit(maxX).Relative(70)
221 | }
222 | splitY = NewSplit(maxY).Fixed(maxY - 2)
223 |
224 | if v, err := g.SetView(RequestView, 0, 0, splitX.Next(), splitY.Next()); err != nil {
225 | if err != gocui.ErrUnknownView {
226 | return err
227 | }
228 | v.Title = "Request"
229 | v.Editable = true
230 | v.Editor = newEditor(ui, g, &motionEditor{})
231 | }
232 |
233 | if err := ui.setResponseView(g, splitX.Current(), 0, maxX-1, splitY.Current()); err != nil {
234 | return err
235 | }
236 |
237 | if v, err := g.SetView(InfoView, -1, splitY.Current(), maxX-1, maxY); err != nil {
238 | if err != gocui.ErrUnknownView {
239 | return err
240 | }
241 | v.Frame = false
242 | }
243 |
244 | if v := g.CurrentView(); v == nil {
245 | _, err := g.SetCurrentView(StatusView)
246 | if err != gocui.ErrUnknownView {
247 | return err
248 | }
249 | }
250 |
251 | return nil
252 | }
253 |
254 | func (ui *UI) setResponseView(g *gocui.Gui, x0, y0, x1, y1 int) error {
255 | if ui.hideResponseBuilder {
256 | g.DeleteView(StatusView)
257 | g.DeleteView(DelayView)
258 | g.DeleteView(HeaderView)
259 | g.DeleteView(BodyView)
260 | return nil
261 | }
262 |
263 | split := NewSplit(y1).Fixed(2, 2).Relative(40)
264 | if v, err := g.SetView(StatusView, x0, y0, x1, split.Next()); err != nil {
265 | if err != gocui.ErrUnknownView {
266 | return err
267 | }
268 |
269 | v.Title = "Status"
270 | v.Editable = true
271 | v.Editor = newEditor(ui, g, &numberEditor{3})
272 | fmt.Fprintf(v, "%d", ui.resp.Status)
273 | }
274 |
275 | if v, err := g.SetView(DelayView, x0, split.Current(), x1, split.Next()); err != nil {
276 | if err != gocui.ErrUnknownView {
277 | return err
278 | }
279 |
280 | v.Title = "Delay (ms) "
281 | v.Editable = true
282 | v.Editor = newEditor(ui, g, &numberEditor{9})
283 | fmt.Fprintf(v, "%d", ui.resp.Delay/time.Millisecond)
284 | }
285 |
286 | if v, err := g.SetView(HeaderView, x0, split.Current(), x1, split.Next()); err != nil {
287 | if err != gocui.ErrUnknownView {
288 | return err
289 | }
290 | v.Editable = true
291 | v.Editor = newEditor(ui, g, nil)
292 | v.Title = "Headers"
293 | var headers []string
294 | for key := range ui.resp.Headers {
295 | headers = append(headers, key+": "+ui.resp.Headers.Get(key))
296 | }
297 | fmt.Fprint(v, strings.Join(headers, "\n"))
298 | }
299 |
300 | if v, err := g.SetView(BodyView, x0, split.Current(), x1, y1); err != nil {
301 | if err != gocui.ErrUnknownView {
302 | return err
303 | }
304 | v.Editable = true
305 | v.Editor = newEditor(ui, g, nil)
306 | ui.renderBody(g)
307 | }
308 |
309 | return nil
310 | }
311 |
312 | // Info prints information on the InfoView.
313 | func (ui *UI) Info(g *gocui.Gui, format string, args ...interface{}) {
314 | v, err := g.View(InfoView)
315 | if v == nil || err != nil {
316 | return
317 | }
318 |
319 | g.Update(func(g *gocui.Gui) error {
320 | v.Clear()
321 | _, err := fmt.Fprintf(v, format, args...)
322 | return err
323 | })
324 |
325 | if ui.infoTimer != nil {
326 | ui.infoTimer.Stop()
327 | }
328 | ui.infoTimer = time.AfterFunc(3*time.Second, func() {
329 | g.Update(func(g *gocui.Gui) error {
330 | v.Clear()
331 | return nil
332 | })
333 | })
334 | }
335 |
336 | // Display displays arbitraty info into a given view.
337 | func (ui *UI) Display(g *gocui.Gui, view string, bytes []byte) error {
338 | v, err := g.View(view)
339 | if err != nil {
340 | return err
341 | }
342 |
343 | g.Update(func(g *gocui.Gui) error {
344 | v.Clear()
345 | _, err := v.Write(bytes)
346 | return err
347 | })
348 |
349 | return nil
350 | }
351 |
352 | // Response returns the current response setting.
353 | func (ui *UI) Response() *httplab.Response {
354 | return ui.resp
355 | }
356 |
357 | func (ui *UI) nextView(g *gocui.Gui) error {
358 | if ui.hideResponseBuilder {
359 | return nil
360 | }
361 | ui.viewIndex = (ui.viewIndex + 1) % len(cicleable)
362 | return ui.setView(g, cicleable[ui.viewIndex])
363 | }
364 |
365 | func (ui *UI) prevView(g *gocui.Gui) error {
366 | if ui.hideResponseBuilder {
367 | return nil
368 | }
369 | ui.viewIndex = (ui.viewIndex - 1 + len(cicleable)) % len(cicleable)
370 | return ui.setView(g, cicleable[ui.viewIndex])
371 | }
372 |
373 | func (ui *UI) prevRequest(g *gocui.Gui) error {
374 | ui.reqLock.Lock()
375 | defer ui.reqLock.Unlock()
376 |
377 | if ui.currentRequest == 0 {
378 | return nil
379 | }
380 |
381 | ui.currentRequest--
382 |
383 | return ui.updateRequest(g)
384 | }
385 |
386 | func (ui *UI) nextRequest(g *gocui.Gui) error {
387 | ui.reqLock.Lock()
388 | defer ui.reqLock.Unlock()
389 |
390 | if ui.currentRequest >= len(ui.requests)-1 {
391 | return nil
392 | }
393 |
394 | ui.currentRequest++
395 | return ui.updateRequest(g)
396 | }
397 |
398 | func getViewBuffer(g *gocui.Gui, view string) string {
399 | v, err := g.View(view)
400 | if err != nil {
401 | return ""
402 | }
403 | return v.Buffer()
404 | }
405 |
406 | func (ui *UI) currentResponse(g *gocui.Gui) (*httplab.Response, error) {
407 | status := getViewBuffer(g, StatusView)
408 | headers := getViewBuffer(g, HeaderView)
409 |
410 | resp, err := httplab.NewResponse(status, headers, "")
411 | if err != nil {
412 | return nil, err
413 | }
414 |
415 | resp.Body = ui.resp.Body
416 | if ui.Response().Body.Mode == httplab.BodyInput {
417 | resp.Body.Input = []byte(getViewBuffer(g, BodyView))
418 | }
419 |
420 | delay := getViewBuffer(g, DelayView)
421 | delay = strings.Trim(delay, " \n")
422 | intDelay, err := strconv.Atoi(delay)
423 | if err != nil {
424 | return nil, fmt.Errorf("Can't parse '%s' as number", delay)
425 | }
426 | resp.Delay = time.Duration(intDelay) * time.Millisecond
427 |
428 | return resp, nil
429 | }
430 |
431 | func (ui *UI) updateResponse(g *gocui.Gui) error {
432 | resp, err := ui.currentResponse(g)
433 | if err != nil {
434 | ui.Info(g, err.Error())
435 | return err
436 | }
437 |
438 | ui.resp = resp
439 | ui.Info(g, "Response updated!")
440 | return nil
441 | }
442 |
443 | func (ui *UI) restoreResponse(g *gocui.Gui, r *httplab.Response) {
444 | ui.resp = r
445 |
446 | var v *gocui.View
447 | v, _ = g.View(StatusView)
448 | v.Clear()
449 | fmt.Fprintf(v, "%d", r.Status)
450 |
451 | v, _ = g.View(DelayView)
452 | v.Clear()
453 | fmt.Fprintf(v, "%d", r.Delay)
454 |
455 | v, _ = g.View(HeaderView)
456 | v.Clear()
457 | for key := range r.Headers {
458 | fmt.Fprintf(v, "%s: %s", key, r.Headers.Get(key))
459 | }
460 |
461 | ui.renderBody(g)
462 |
463 | ui.Info(g, "Response loaded!")
464 | }
465 |
466 | func (ui *UI) setView(g *gocui.Gui, view string) error {
467 | if err := ui.closePopup(g, ui.currentPopup); err != nil {
468 | return err
469 | }
470 |
471 | // Save cursor position before switch view
472 | cur := g.CurrentView()
473 | x, y := cur.Cursor()
474 | ui.cursors.Set(cur.Name(), x, y)
475 |
476 | if _, err := g.SetCurrentView(view); err != nil {
477 | return err
478 | }
479 |
480 | if ui.AutoUpdate && ui.hasChanged {
481 | ui.hasChanged = false
482 | return ui.updateResponse(g)
483 | }
484 |
485 | return nil
486 | }
487 |
488 | func (ui *UI) createPopupView(g *gocui.Gui, viewname string, w, h int) (*gocui.View, error) {
489 | maxX, maxY := g.Size()
490 | x := maxX/2 - w/2
491 | y := maxY/2 - h/2
492 | view, err := g.SetView(viewname, x, y, x+w, y+h)
493 | if err != nil && err != gocui.ErrUnknownView {
494 | return nil, err
495 | }
496 |
497 | return view, nil
498 | }
499 |
500 | func (ui *UI) closePopup(g *gocui.Gui, viewname string) error {
501 | if _, err := g.View(viewname); err != nil {
502 | if err == gocui.ErrUnknownView {
503 | return nil
504 | }
505 | return err
506 | }
507 |
508 | g.DeleteView(viewname)
509 | g.DeleteKeybindings(viewname)
510 | g.Cursor = true
511 | ui.currentPopup = ""
512 | return ui.setView(g, cicleable[ui.viewIndex])
513 | }
514 |
515 | func (ui *UI) openPopup(g *gocui.Gui, viewname string, x, y int) (*gocui.View, error) {
516 | view, err := ui.createPopupView(g, viewname, x, y)
517 | if err != nil {
518 | return nil, err
519 | }
520 |
521 | if err := ui.setView(g, view.Name()); err != nil {
522 | return nil, err
523 | }
524 | ui.currentPopup = viewname
525 | g.Cursor = false
526 |
527 | return view, nil
528 | }
529 |
530 | func (ui *UI) toggleHelp(g *gocui.Gui, help string) error {
531 | if ui.currentPopup == BindingsView {
532 | return ui.closePopup(g, BindingsView)
533 | }
534 |
535 | view, err := ui.openPopup(g, BindingsView, 40, strings.Count(help, "\n"))
536 | if err != nil {
537 | return err
538 | }
539 |
540 | view.Title = "Bindings"
541 | fmt.Fprint(view, help)
542 |
543 | return nil
544 | }
545 |
546 | func (ui *UI) toggleResponsesLoader(g *gocui.Gui) error {
547 | if ui.currentPopup == ResponsesView {
548 | return ui.closePopup(g, ResponsesView)
549 | }
550 |
551 | if err := ui.responses.Load(ui.configPath); err != nil {
552 | return err
553 | }
554 |
555 | if ui.responses.Len() == 0 {
556 | return errors.New("No responses has been saved")
557 | }
558 |
559 | popup, err := ui.openPopup(g, ResponsesView, 30, ui.responses.Len()+1)
560 | if err != nil {
561 | return err
562 | }
563 |
564 | cx, _ := g.CurrentView().Cursor()
565 | onUp := func(g *gocui.Gui, v *gocui.View) error {
566 | ui.responses.Prev()
567 | v.SetCursor(cx, ui.responses.Index())
568 | return nil
569 | }
570 |
571 | onDown := func(g *gocui.Gui, v *gocui.View) error {
572 | ui.responses.Next()
573 | v.SetCursor(cx, ui.responses.Index())
574 | return nil
575 | }
576 |
577 | onDelete := func(g *gocui.Gui, v *gocui.View) error {
578 | key := ui.responses.Keys()[ui.responses.Index()]
579 | ui.responses.Del(key)
580 | if err := ui.responses.Save(ui.configPath); err != nil {
581 | return err
582 | }
583 |
584 | if err := ui.closePopup(g, ResponsesView); err != nil {
585 | return err
586 | }
587 |
588 | if err := ui.toggleResponsesLoader(g); err != nil {
589 | return nil
590 | }
591 |
592 | return nil
593 | }
594 |
595 | onEnter := func(g *gocui.Gui, v *gocui.View) error {
596 | ui.restoreResponse(g, ui.responses.Cur())
597 | return nil
598 | }
599 |
600 | onQuit := func(g *gocui.Gui, v *gocui.View) error {
601 | return ui.closePopup(g, ResponsesView)
602 | }
603 |
604 | view := []string{popup.Name()}
605 | (&bindings{
606 | {gocui.KeyArrowUp, "", "", view, func(*UI) ActionFn { return onUp }},
607 | {gocui.KeyArrowDown, "", "", view, func(*UI) ActionFn { return onDown }},
608 | {gocui.KeyEnter, "", "", view, func(*UI) ActionFn { return onEnter }},
609 | {'d', "", "", view, func(*UI) ActionFn { return onDelete }},
610 | {'q', "", "", view, func(*UI) ActionFn { return onQuit }},
611 | }).Apply(ui, g)
612 |
613 | for _, key := range ui.responses.Keys() {
614 | fmt.Fprintf(popup, "%s > %d\n", key, ui.responses.Get(key).Status)
615 | }
616 |
617 | popup.Title = "Responses"
618 | popup.Highlight = true
619 | return nil
620 | }
621 |
622 | func (ui *UI) toggleResponseBuilder(g *gocui.Gui) error {
623 | ui.hideResponseBuilder = !ui.hideResponseBuilder
624 | if ui.hideResponseBuilder {
625 | _, err := g.SetCurrentView(RequestView)
626 | return err
627 | }
628 | return nil
629 | }
630 |
631 | func (ui *UI) openSavePopup(g *gocui.Gui, title string, fn func(*gocui.Gui, string) error) error {
632 | if err := ui.closePopup(g, ui.currentPopup); err != nil {
633 | return err
634 | }
635 |
636 | popup, err := ui.openPopup(g, SaveView, max(20, len(title)+3), 2)
637 | if err != nil {
638 | return err
639 | }
640 |
641 | onEnter := func(g *gocui.Gui, v *gocui.View) error {
642 | value := strings.Trim(v.Buffer(), " \n")
643 | if err := fn(g, value); err != nil {
644 | ui.Info(g, err.Error())
645 | }
646 | return ui.closePopup(g, SaveView)
647 | }
648 |
649 | if err := g.SetKeybinding(popup.Name(), gocui.KeyEnter, gocui.ModNone, onEnter); err != nil {
650 | return err
651 | }
652 |
653 | popup.Title = title
654 | popup.Editable = true
655 | g.Cursor = true
656 | return nil
657 | }
658 |
659 | func (ui *UI) saveResponsePopup(g *gocui.Gui) error {
660 | return ui.openSavePopup(g, "Save Response as...", ui.saveResponseAs)
661 | }
662 |
663 | func (ui *UI) saveResponseAs(g *gocui.Gui, name string) error {
664 | resp, err := ui.currentResponse(g)
665 | if err != nil {
666 | return err
667 | }
668 |
669 | ui.responses.Add(name, resp)
670 | if err := ui.responses.Save(ui.configPath); err != nil {
671 | return err
672 | }
673 |
674 | ui.Info(g, "Response applied and saved as '%s'", name)
675 | return nil
676 | }
677 |
678 | func (ui *UI) saveRequestPopup(g *gocui.Gui) error {
679 | // Only open the popup if there's requests
680 | if len(ui.requests) == 0 {
681 | ui.Info(g, "No Requests to save")
682 | return nil
683 | }
684 |
685 | return ui.openSavePopup(g, "Save Request as...", ui.saveRequestAs)
686 | }
687 |
688 | func (ui *UI) saveRequestAs(g *gocui.Gui, name string) error {
689 | ui.reqLock.Lock()
690 | defer ui.reqLock.Unlock()
691 | if len(ui.requests) == 0 {
692 | return nil
693 | }
694 | req := ui.requests[ui.currentRequest]
695 |
696 | file, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
697 | if err != nil {
698 | return err
699 | }
700 | defer file.Close()
701 |
702 | if _, err := file.Write(httplab.Decolorize(req)); err != nil {
703 | return err
704 | }
705 |
706 | ui.Info(g, "Request saved as '%s'", name)
707 | return nil
708 | }
709 |
710 | func (ui *UI) renderBody(g *gocui.Gui) error {
711 | v, err := g.View(BodyView)
712 | if err != nil {
713 | return err
714 | }
715 |
716 | body := ui.resp.Body
717 |
718 | v.Title = fmt.Sprintf("Body (%s)", body.Mode)
719 | v.Clear()
720 | v.Write(body.Info())
721 | return nil
722 | }
723 |
724 | func (ui *UI) openBodyFilePopup(g *gocui.Gui) error {
725 | if err := ui.closePopup(g, ui.currentPopup); err != nil {
726 | return err
727 | }
728 |
729 | popup, err := ui.openPopup(g, FileDialogView, 20, 2)
730 | if err != nil {
731 | return err
732 | }
733 |
734 | g.Cursor = true
735 | popup.Title = "Open Body File"
736 | popup.Editable = true
737 |
738 | onEnter := func(g *gocui.Gui, v *gocui.View) error {
739 | path := strings.Trim(v.Buffer(), " \n")
740 | if path == "" {
741 | return ui.closePopup(g, popup.Name())
742 | }
743 |
744 | if err := ui.resp.Body.SetFile(path); err != nil {
745 | ui.Info(g, "%+v", err)
746 | } else {
747 | if err := ui.renderBody(g); err != nil {
748 | return err
749 | }
750 | }
751 | return ui.closePopup(g, popup.Name())
752 | }
753 |
754 | return g.SetKeybinding(popup.Name(), gocui.KeyEnter, gocui.ModNone, onEnter)
755 | }
756 |
757 | func (ui *UI) nextBodyMode(g *gocui.Gui) error {
758 | modes := []httplab.BodyMode{
759 | httplab.BodyInput,
760 | httplab.BodyFile,
761 | }
762 | body := &ui.resp.Body
763 | body.Mode = body.Mode%httplab.BodyMode(len(modes)) + 1
764 | return ui.renderBody(g)
765 | }
766 |
767 | func (ui *UI) toggleLineWrap(g *gocui.Gui) error {
768 | view, err := g.View(RequestView)
769 | if err != nil {
770 | return err
771 | }
772 |
773 | view.Wrap = !view.Wrap
774 | return nil
775 | }
776 |
--------------------------------------------------------------------------------
/ui/ui_test.go:
--------------------------------------------------------------------------------
1 | //+build ui
2 |
3 | package ui
4 |
5 | import (
6 | "bytes"
7 | "fmt"
8 | "net/http"
9 | "strings"
10 | "testing"
11 |
12 | "github.com/jroimartin/gocui"
13 | "github.com/stretchr/testify/assert"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | func newTestUI(t *testing.T) (*gocui.Gui, *UI) {
18 | g, err := gocui.NewGui(gocui.Output256)
19 | require.NoError(t, err)
20 |
21 | ui := NewUI("")
22 | require.NoError(t, ui.Layout(g))
23 |
24 | return g, ui
25 | }
26 |
27 | func TestUIAddRequestSavesInOrder(t *testing.T) {
28 | g, ui := newTestUI(t)
29 | defer g.Close()
30 |
31 | for i := 0; i < 10; i++ {
32 | req, _ := http.NewRequest("GET", fmt.Sprintf("/%d", i), &bytes.Buffer{})
33 | require.NoError(t, ui.AddRequest(g, req))
34 | }
35 |
36 | assert.Len(t, ui.requests, 10)
37 | for i := 0; i < 10; i++ {
38 | req := ui.requests[i]
39 | split := strings.Split(string(req), " ")
40 | path := split[1]
41 | assert.Equal(t, fmt.Sprintf("/%d", i), path)
42 | }
43 |
44 | assert.Equal(t, 9, ui.currentRequest)
45 | }
46 |
47 | func TestUIScrollRequests(t *testing.T) {
48 | g, ui := newTestUI(t)
49 | defer g.Close()
50 |
51 | for i := 0; i < 10; i++ {
52 | req, _ := http.NewRequest("GET", fmt.Sprintf("/%d", i), &bytes.Buffer{})
53 | require.NoError(t, ui.AddRequest(g, req))
54 | }
55 |
56 | cur := ui.currentRequest
57 | ui.prevRequest(g)
58 | assert.Equal(t, ui.currentRequest, cur-1)
59 |
60 | ui.nextRequest(g)
61 | assert.Equal(t, ui.currentRequest, cur)
62 |
63 | t.Run("Doesn't autoscroll", func(t *testing.T) {
64 | req, _ := http.NewRequest("GET", "/", &bytes.Buffer{})
65 | ui.prevRequest(g)
66 | cur := ui.currentRequest
67 |
68 | ui.AddRequest(g, req)
69 | assert.Equal(t, cur, ui.currentRequest)
70 | })
71 | }
72 |
--------------------------------------------------------------------------------