├── .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 | ![HTTPLAB](https://github.com/gchaincl/httplab/blob/master/images/httplab_logo.png) 2 | 3 | [![Build Status](https://travis-ci.org/gchaincl/httplab.svg?branch=master)](https://travis-ci.org/gchaincl/httplab) [![Go Report Card](https://goreportcard.com/badge/github.com/gchaincl/httplab)](https://goreportcard.com/report/gchaincl/httplab) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 4 | 5 | 6 | The interactive web server. 7 | 8 | HTTPLabs let you inspect HTTP requests and forge responses. 9 | 10 | --- 11 | ![screencast](images/screencast.gif) 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 | 3 | 4 | HTTPLAB-A 5 | Created with Sketch. 6 | 7 | 8 | 9 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------