├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── androiddnsfix.go ├── cmd └── ht │ └── main.go ├── docs └── images │ └── screenshot.png ├── exchange ├── build.go ├── build_test.go ├── client.go └── options.go ├── flags ├── ask_password_unix.go ├── ask_password_windows.go ├── flags.go └── flags_test.go ├── go.mod ├── go.sum ├── input ├── args.go ├── args_test.go ├── input.go └── options.go ├── main.go ├── output ├── file.go ├── options.go ├── plain.go ├── pretty.go ├── pretty_test.go └── printer.go └── version ├── license.go └── version.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/golang:1.14.7 6 | steps: 7 | - checkout 8 | - run: 9 | name: Check code format 10 | command: diff -u <(echo -n) <(gofmt -d ./) 11 | - run: 12 | name: Build (linux) 13 | command: | 14 | make EXE_NAME=httpie-go_linux_amd64 15 | - run: 16 | name: Test 17 | command: make test 18 | - run: 19 | name: Build (mac) 20 | command: | 21 | GOOS=darwin make EXE_NAME=httpie-go_darwin_amd64 22 | - run: 23 | name: Build (win) 24 | command: | 25 | GOOS=windows make EXE_NAME=httpie-go_windows_amd64.exe 26 | - store_artifacts: 27 | path: ~/project/httpie-go_linux_amd64 28 | destination: bin/httpie-go_linux_amd64 29 | - store_artifacts: 30 | path: ~/project/httpie-go_darwin_amd64 31 | destination: bin/httpie-go_darwin_amd64 32 | - store_artifacts: 33 | path: ~/project/httpie-go_windows_amd64.exe 34 | destination: bin/httpie-go_windows_amd64.exe 35 | workflows: 36 | version: 2 37 | build_and_test: 38 | jobs: 39 | - build 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Binaries 15 | /cmd/ht/ht 16 | /ht 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Yusuke Nojima 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | EXE_NAME=ht 2 | 3 | build: 4 | go build -o $(EXE_NAME) ./cmd/ht 5 | 6 | build-termux: 7 | env CGO_ENABLED=0 go build -o $(EXE_NAME) -tags='androiddnsfix' ./cmd/ht 8 | 9 | install: 10 | go install ./cmd/ht 11 | 12 | fmt: 13 | go fmt ./... 14 | 15 | test: 16 | go test ./... 17 | 18 | clean: 19 | rm -vf ./$(EXE_NAME) 20 | 21 | .PHONY: build test clean 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httpie-go 2 | 3 | [![CircleCI](https://circleci.com/gh/nojima/httpie-go.svg?style=shield)](https://circleci.com/gh/nojima/httpie-go) 4 | 5 | ![httpie-go screenshot](./docs/images/screenshot.png) 6 | 7 | **httpie-go** (`ht`) is a user-friendly HTTP client CLI. 8 | Requests can be issued with fewer types compared to `curl`. 9 | Responses are displayed with syntax highlighting. 10 | 11 | httpie-go is a clone of [httpie](https://httpie.org/). 12 | Since httpie-go is written in Go, it is a single binary and does not require a heavy runtime. 13 | 14 | ## Examples 15 | 16 | This example sends a GET request to http://httpbin.org/get. 17 | 18 | ```bash 19 | $ ht GET httpbin.org/get 20 | ``` 21 | 22 | The second example sends a POST request with JSON body `{"hello": "world", "foo": "bar"}`. 23 | 24 | ```bash 25 | $ ht POST httpbin.org/post hello=world foo=bar 26 | ``` 27 | 28 | You can see the request that is being sent with `-v` option. 29 | 30 | ```bash 31 | $ ht -v POST httpbin.org/post hello=world foo=bar 32 | ``` 33 | 34 | Request HTTP headers can be specified in the form of `key:value`. 35 | 36 | ```bash 37 | $ ht -v POST httpbin.org/post X-Foo:foobar 38 | ``` 39 | 40 | Disable TLS verification. 41 | 42 | ```bash 43 | $ ht --verify=no https://httpbin.org/get 44 | ``` 45 | 46 | Download a file. 47 | 48 | ```bash 49 | $ ht --download 50 | ``` 51 | 52 | ## Documents 53 | 54 | Although httpie-go does not currently have documents, you can refer to the original [httpie's documentation](https://httpie.org/doc) since httpie-go is a clone of httpie. 55 | Note that some minor options are yet to be implemented in httpie-go. 56 | 57 | ## How to build 58 | 59 | ``` 60 | make 61 | ``` 62 | 63 | For non-standard Linux system like Android [termux](https://termux.com/), use following method to avoid the DNS issue. 64 | 65 | ``` 66 | make build-termux 67 | ``` 68 | -------------------------------------------------------------------------------- /androiddnsfix.go: -------------------------------------------------------------------------------- 1 | // +build androiddnsfix 2 | 3 | package httpie 4 | 5 | import _ "github.com/mtibben/androiddnsfix" 6 | -------------------------------------------------------------------------------- /cmd/ht/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/nojima/httpie-go" 8 | ) 9 | 10 | func main() { 11 | if err := httpie.Main(&httpie.Options{}); err != nil { 12 | fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) 13 | os.Exit(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nojima/httpie-go/35d74a5f8766de092abf05b559720adb5bafd2c6/docs/images/screenshot.png -------------------------------------------------------------------------------- /exchange/build.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "mime/multipart" 10 | "net/http" 11 | "net/textproto" 12 | "net/url" 13 | "os" 14 | "path" 15 | "strings" 16 | 17 | "github.com/nojima/httpie-go/input" 18 | "github.com/nojima/httpie-go/version" 19 | "github.com/pkg/errors" 20 | ) 21 | 22 | type bodyTuple struct { 23 | body io.ReadCloser 24 | getBody func() (io.ReadCloser, error) 25 | contentLength int64 26 | contentType string 27 | } 28 | 29 | func BuildHTTPRequest(in *input.Input, options *Options) (*http.Request, error) { 30 | u, err := buildURL(in) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | header, err := buildHTTPHeader(in) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | bodyTuple, err := buildHTTPBody(in) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | if header.Get("Content-Type") == "" && bodyTuple.contentType != "" { 46 | header.Set("Content-Type", bodyTuple.contentType) 47 | } 48 | if header.Get("User-Agent") == "" { 49 | header.Set("User-Agent", fmt.Sprintf("httpie-go/%s", version.Current())) 50 | } 51 | 52 | r := http.Request{ 53 | Method: string(in.Method), 54 | URL: u, 55 | Header: header, 56 | Host: header.Get("Host"), 57 | Body: bodyTuple.body, 58 | GetBody: bodyTuple.getBody, 59 | ContentLength: bodyTuple.contentLength, 60 | } 61 | 62 | if options.Auth.Enabled { 63 | r.SetBasicAuth(options.Auth.UserName, options.Auth.Password) 64 | } 65 | 66 | return &r, nil 67 | } 68 | 69 | func buildURL(in *input.Input) (*url.URL, error) { 70 | q, err := url.ParseQuery(in.URL.RawQuery) 71 | if err != nil { 72 | return nil, errors.Wrap(err, "parsing query string") 73 | } 74 | for _, field := range in.Parameters { 75 | value, err := resolveFieldValue(field) 76 | if err != nil { 77 | return nil, err 78 | } 79 | q.Add(field.Name, value) 80 | } 81 | 82 | u := *in.URL 83 | u.RawQuery = q.Encode() 84 | return &u, nil 85 | } 86 | 87 | func buildHTTPHeader(in *input.Input) (http.Header, error) { 88 | header := make(http.Header) 89 | for _, field := range in.Header.Fields { 90 | value, err := resolveFieldValue(field) 91 | if err != nil { 92 | return nil, err 93 | } 94 | header.Add(field.Name, value) 95 | } 96 | return header, nil 97 | } 98 | 99 | func buildHTTPBody(in *input.Input) (bodyTuple, error) { 100 | switch in.Body.BodyType { 101 | case input.EmptyBody: 102 | return bodyTuple{}, nil 103 | case input.JSONBody: 104 | return buildJSONBody(in) 105 | case input.FormBody: 106 | return buildFormBody(in) 107 | case input.RawBody: 108 | return buildRawBody(in) 109 | default: 110 | return bodyTuple{}, errors.Errorf("unknown body type: %v", in.Body.BodyType) 111 | } 112 | } 113 | 114 | func buildJSONBody(in *input.Input) (bodyTuple, error) { 115 | obj := map[string]interface{}{} 116 | for _, field := range in.Body.Fields { 117 | value, err := resolveFieldValue(field) 118 | if err != nil { 119 | return bodyTuple{}, err 120 | } 121 | obj[field.Name] = value 122 | } 123 | for _, field := range in.Body.RawJSONFields { 124 | value, err := resolveFieldValue(field) 125 | if err != nil { 126 | return bodyTuple{}, err 127 | } 128 | var v interface{} 129 | if err := json.Unmarshal([]byte(value), &v); err != nil { 130 | return bodyTuple{}, errors.Wrapf(err, "parsing JSON value of '%s'", field.Name) 131 | } 132 | obj[field.Name] = v 133 | } 134 | body, err := json.Marshal(obj) 135 | if err != nil { 136 | return bodyTuple{}, errors.Wrap(err, "marshaling JSON of HTTP body") 137 | } 138 | return bodyTuple{ 139 | body: ioutil.NopCloser(bytes.NewReader(body)), 140 | getBody: func() (io.ReadCloser, error) { 141 | return ioutil.NopCloser(bytes.NewReader(body)), nil 142 | }, 143 | contentLength: int64(len(body)), 144 | contentType: "application/json", 145 | }, nil 146 | } 147 | 148 | func buildFormBody(in *input.Input) (bodyTuple, error) { 149 | if len(in.Body.Files) > 0 { 150 | return buildMultipartBody(in) 151 | } else { 152 | return buildURLEncodedBody(in) 153 | } 154 | } 155 | 156 | func buildURLEncodedBody(in *input.Input) (bodyTuple, error) { 157 | form := url.Values{} 158 | for _, field := range in.Body.Fields { 159 | value, err := resolveFieldValue(field) 160 | if err != nil { 161 | return bodyTuple{}, err 162 | } 163 | form.Add(field.Name, value) 164 | } 165 | body := form.Encode() 166 | return bodyTuple{ 167 | body: ioutil.NopCloser(strings.NewReader(body)), 168 | getBody: func() (io.ReadCloser, error) { 169 | return ioutil.NopCloser(strings.NewReader(body)), nil 170 | }, 171 | contentLength: int64(len(body)), 172 | contentType: "application/x-www-form-urlencoded; charset=utf-8", 173 | }, nil 174 | } 175 | 176 | func buildMultipartBody(in *input.Input) (bodyTuple, error) { 177 | var buffer bytes.Buffer 178 | multipartWriter := multipart.NewWriter(&buffer) 179 | 180 | for _, field := range in.Body.Fields { 181 | if err := buildInlinePart(field, multipartWriter); err != nil { 182 | return bodyTuple{}, err 183 | } 184 | } 185 | for _, field := range in.Body.Files { 186 | if err := buildFilePart(field, multipartWriter); err != nil { 187 | return bodyTuple{}, err 188 | } 189 | } 190 | 191 | multipartWriter.Close() 192 | 193 | body := buffer.Bytes() 194 | return bodyTuple{ 195 | body: ioutil.NopCloser(bytes.NewReader(body)), 196 | getBody: func() (io.ReadCloser, error) { 197 | return ioutil.NopCloser(bytes.NewReader(body)), nil 198 | }, 199 | contentLength: int64(len(body)), 200 | contentType: multipartWriter.FormDataContentType(), 201 | }, nil 202 | } 203 | 204 | func buildInlinePart(field input.Field, multipartWriter *multipart.Writer) error { 205 | value, err := resolveFieldValue(field) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | h := make(textproto.MIMEHeader) 211 | h.Set("Content-Disposition", buildContentDisposition(field.Name, "")) 212 | w, err := multipartWriter.CreatePart(h) 213 | if err != nil { 214 | return err 215 | } 216 | if _, err := w.Write([]byte(value)); err != nil { 217 | return err 218 | } 219 | return nil 220 | } 221 | 222 | func buildFilePart(field input.Field, multipartWriter *multipart.Writer) error { 223 | h := make(textproto.MIMEHeader) 224 | 225 | var filename string 226 | if field.IsFile { 227 | filename = path.Base(field.Value) 228 | } 229 | h.Set("Content-Disposition", buildContentDisposition(field.Name, filename)) 230 | 231 | w, err := multipartWriter.CreatePart(h) 232 | if err != nil { 233 | return err 234 | } 235 | 236 | if field.IsFile { 237 | file, err := os.Open(field.Value) 238 | if err != nil { 239 | return errors.Wrapf(err, "failed to open '%s'", field.Value) 240 | } 241 | defer file.Close() 242 | 243 | if _, err := io.Copy(w, file); err != nil { 244 | return errors.Wrapf(err, "failed to read from '%s'", field.Value) 245 | } 246 | } else { 247 | if _, err := io.Copy(w, strings.NewReader(field.Value)); err != nil { 248 | return errors.Wrap(err, "failed to write to multipart writer") 249 | } 250 | } 251 | return nil 252 | } 253 | 254 | func buildContentDisposition(name string, filename string) string { 255 | var buffer bytes.Buffer 256 | buffer.WriteString("form-data") 257 | 258 | if name != "" { 259 | if needEscape(name) { 260 | fmt.Fprintf(&buffer, `; name*=utf-8''%s`, url.PathEscape(name)) 261 | } else { 262 | fmt.Fprintf(&buffer, `; name="%s"`, name) 263 | } 264 | } 265 | 266 | if filename != "" { 267 | if needEscape(filename) { 268 | fmt.Fprintf(&buffer, `; filename*=utf-8''%s`, url.PathEscape(filename)) 269 | } else { 270 | fmt.Fprintf(&buffer, `; filename="%s"`, filename) 271 | } 272 | } 273 | 274 | return buffer.String() 275 | } 276 | 277 | func needEscape(s string) bool { 278 | for _, c := range s { 279 | if c > 127 { 280 | return true 281 | } 282 | if c < 32 && c != '\t' { 283 | return true 284 | } 285 | if c == '"' || c == '\\' { 286 | return true 287 | } 288 | } 289 | return false 290 | } 291 | 292 | func buildRawBody(in *input.Input) (bodyTuple, error) { 293 | return bodyTuple{ 294 | body: ioutil.NopCloser(bytes.NewReader(in.Body.Raw)), 295 | getBody: func() (io.ReadCloser, error) { 296 | return ioutil.NopCloser(bytes.NewReader(in.Body.Raw)), nil 297 | }, 298 | contentLength: int64(len(in.Body.Raw)), 299 | contentType: "application/json", 300 | }, nil 301 | } 302 | 303 | func resolveFieldValue(field input.Field) (string, error) { 304 | if field.IsFile { 305 | data, err := ioutil.ReadFile(field.Value) 306 | if err != nil { 307 | return "", errors.Wrapf(err, "reading field value of '%s'", field.Name) 308 | } 309 | return string(data), nil 310 | } else { 311 | return field.Value, nil 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /exchange/build_test.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "path" 12 | "reflect" 13 | "regexp" 14 | "strings" 15 | "testing" 16 | 17 | "github.com/nojima/httpie-go/input" 18 | "github.com/nojima/httpie-go/version" 19 | ) 20 | 21 | func parseURL(t *testing.T, rawurl string) *url.URL { 22 | u, err := url.Parse(rawurl) 23 | if err != nil { 24 | t.Fatalf("failed to parse URL: %s", err) 25 | } 26 | return u 27 | } 28 | 29 | func TestBuildHTTPRequest(t *testing.T) { 30 | // Setup 31 | in := &input.Input{ 32 | Method: input.Method("POST"), 33 | URL: parseURL(t, "https://localhost:4000/foo"), 34 | Parameters: []input.Field{ 35 | {Name: "q", Value: "hello world"}, 36 | }, 37 | Header: input.Header{ 38 | Fields: []input.Field{ 39 | {Name: "X-Foo", Value: "fizz buzz"}, 40 | {Name: "Host", Value: "example.com:8080"}, 41 | }, 42 | }, 43 | Body: input.Body{ 44 | BodyType: input.JSONBody, 45 | Fields: []input.Field{ 46 | {Name: "hoge", Value: "fuga"}, 47 | }, 48 | }, 49 | } 50 | options := Options{ 51 | Auth: AuthOptions{ 52 | Enabled: true, 53 | UserName: "alice", 54 | Password: "open sesame", 55 | }, 56 | } 57 | 58 | // Exercise 59 | actual, err := BuildHTTPRequest(in, &options) 60 | if err != nil { 61 | t.Fatalf("unexpected error: err=%v", err) 62 | } 63 | 64 | // Verify 65 | if actual.Method != "POST" { 66 | t.Errorf("unexpected method: expected=%v, actual=%v", "POST", actual.Method) 67 | } 68 | expectedURL := parseURL(t, "https://localhost:4000/foo?q=hello+world") 69 | if !reflect.DeepEqual(actual.URL, expectedURL) { 70 | t.Errorf("unexpected URL: expected=%v, actual=%v", expectedURL, actual.URL) 71 | } 72 | expectedHeader := http.Header{ 73 | "X-Foo": []string{"fizz buzz"}, 74 | "Content-Type": []string{"application/json"}, 75 | "User-Agent": []string{fmt.Sprintf("httpie-go/%s", version.Current())}, 76 | "Host": []string{"example.com:8080"}, 77 | "Authorization": []string{"Basic YWxpY2U6b3BlbiBzZXNhbWU="}, 78 | } 79 | if !reflect.DeepEqual(expectedHeader, actual.Header) { 80 | t.Errorf("unexpected header: expected=%v, actual=%v", expectedHeader, actual.Header) 81 | } 82 | expectedHost := "example.com:8080" 83 | if actual.Host != expectedHost { 84 | t.Errorf("unexpected host: expected=%v, actual=%v", expectedHost, actual.Host) 85 | } 86 | expectedBody := `{"hoge": "fuga"}` 87 | actualBody := readAll(t, actual.Body) 88 | if !isEquivalentJSON(t, expectedBody, actualBody) { 89 | t.Errorf("unexpected body: expected=%v, actual=%v", expectedBody, actualBody) 90 | } 91 | } 92 | 93 | func TestBuildURL(t *testing.T) { 94 | testCases := []struct { 95 | title string 96 | url string 97 | parameters []input.Field 98 | expected string 99 | }{ 100 | { 101 | title: "Typical case", 102 | url: "http://example.com/hello", 103 | parameters: []input.Field{ 104 | {Name: "foo", Value: "bar"}, 105 | {Name: "fizz", Value: "buzz"}, 106 | }, 107 | expected: "http://example.com/hello?fizz=buzz&foo=bar", 108 | }, 109 | { 110 | title: "Both URL and Parameters have query string", 111 | url: "http://example.com/hello?hoge=fuga", 112 | parameters: []input.Field{ 113 | {Name: "foo", Value: "bar"}, 114 | {Name: "fizz", Value: "buzz"}, 115 | }, 116 | expected: "http://example.com/hello?fizz=buzz&foo=bar&hoge=fuga", 117 | }, 118 | { 119 | title: "Multiple values with a key", 120 | url: "http://example.com/hello", 121 | parameters: []input.Field{ 122 | {Name: "foo", Value: "value 1"}, 123 | {Name: "foo", Value: "value 2"}, 124 | {Name: "foo", Value: "value 3"}, 125 | }, 126 | expected: "http://example.com/hello?foo=value+1&foo=value+2&foo=value+3", 127 | }, 128 | { 129 | title: "Multiple values with a key in both URL and Parameters", 130 | url: "http://example.com/hello?foo=a&foo=z", 131 | parameters: []input.Field{ 132 | {Name: "foo", Value: "value 1"}, 133 | {Name: "foo", Value: "value 2"}, 134 | {Name: "foo", Value: "value 3"}, 135 | }, 136 | expected: "http://example.com/hello?foo=a&foo=z&foo=value+1&foo=value+2&foo=value+3", 137 | }, 138 | } 139 | for _, tt := range testCases { 140 | t.Run(tt.title, func(t *testing.T) { 141 | in := &input.Input{ 142 | URL: parseURL(t, tt.url), 143 | Parameters: tt.parameters, 144 | } 145 | u, err := buildURL(in) 146 | if err != nil { 147 | t.Fatalf("unexpected error: err=%v", err) 148 | } 149 | if u.String() != tt.expected { 150 | t.Errorf("unexpected URL: expected=%s, actual=%s", tt.expected, u) 151 | } 152 | }) 153 | } 154 | } 155 | 156 | func makeTempFile(t *testing.T, content string) string { 157 | tmpfile, err := ioutil.TempFile("", "httpie-go-test-") 158 | if err != nil { 159 | t.Fatalf("failed to create temporary file: %v", err) 160 | } 161 | if _, err := tmpfile.Write([]byte(content)); err != nil { 162 | os.Remove(tmpfile.Name()) 163 | t.Fatalf("failed to write to temporary file: %v", err) 164 | } 165 | return tmpfile.Name() 166 | } 167 | 168 | func TestBuildHTTPHeader(t *testing.T) { 169 | // Setup 170 | fileName := makeTempFile(t, "test test") 171 | defer os.Remove(fileName) 172 | header := input.Header{ 173 | Fields: []input.Field{ 174 | {Name: "X-Foo", Value: "foo", IsFile: false}, 175 | {Name: "X-From-File", Value: fileName, IsFile: true}, 176 | {Name: "X-Multi-Value", Value: "value 1"}, 177 | {Name: "X-Multi-Value", Value: "value 2"}, 178 | }, 179 | } 180 | in := &input.Input{Header: header} 181 | 182 | // Exercise 183 | httpHeader, err := buildHTTPHeader(in) 184 | if err != nil { 185 | t.Fatalf("unexpected error: err=%+v", err) 186 | } 187 | 188 | // Verify 189 | expected := http.Header{ 190 | "X-Foo": []string{"foo"}, 191 | "X-From-File": []string{"test test"}, 192 | "X-Multi-Value": []string{"value 1", "value 2"}, 193 | } 194 | if !reflect.DeepEqual(httpHeader, expected) { 195 | t.Errorf("unexpected header: expected=%v, actual=%v", expected, httpHeader) 196 | } 197 | } 198 | 199 | func isEquivalentJSON(t *testing.T, json1, json2 string) bool { 200 | var obj1, obj2 interface{} 201 | if err := json.Unmarshal([]byte(json1), &obj1); err != nil { 202 | t.Fatalf("failed to unmarshal json1: %v", err) 203 | } 204 | if err := json.Unmarshal([]byte(json2), &obj2); err != nil { 205 | t.Fatalf("failed to unmarshal json2: %v", err) 206 | } 207 | return reflect.DeepEqual(obj1, obj2) 208 | } 209 | 210 | func readAll(t *testing.T, reader io.Reader) string { 211 | b, err := ioutil.ReadAll(reader) 212 | if err != nil { 213 | t.Fatalf("failed to read all: %s", err) 214 | } 215 | return string(b) 216 | } 217 | 218 | func TestBuildHTTPBody_EmptyBody(t *testing.T) { 219 | // Setup 220 | fileName := makeTempFile(t, "test test") 221 | defer os.Remove(fileName) 222 | body := input.Body{ 223 | BodyType: input.EmptyBody, 224 | } 225 | in := &input.Input{Body: body} 226 | 227 | // Exercise 228 | actual, err := buildHTTPBody(in) 229 | if err != nil { 230 | t.Fatalf("unexpected error: err=%+v", err) 231 | } 232 | 233 | // Verify 234 | expected := bodyTuple{} 235 | if !reflect.DeepEqual(actual, expected) { 236 | t.Errorf("unexpected body tuple: expected=%+v, actual=%+v", expected, actual) 237 | } 238 | } 239 | 240 | func TestBuildHTTPBody_JSONBody(t *testing.T) { 241 | // Setup 242 | fileName := makeTempFile(t, "test test") 243 | defer os.Remove(fileName) 244 | body := input.Body{ 245 | BodyType: input.JSONBody, 246 | Fields: []input.Field{ 247 | {Name: "foo", Value: "bar"}, 248 | {Name: "from_file", Value: fileName, IsFile: true}, 249 | }, 250 | RawJSONFields: []input.Field{ 251 | {Name: "boolean", Value: "true"}, 252 | {Name: "array", Value: `[1, null, "hello"]`}, 253 | }, 254 | } 255 | in := &input.Input{Body: body} 256 | 257 | // Exercise 258 | bodyTuple, err := buildHTTPBody(in) 259 | if err != nil { 260 | t.Fatalf("unexpected error: err=%+v", err) 261 | } 262 | 263 | // Verify 264 | expectedBody := `{ 265 | "foo": "bar", 266 | "from_file": "test test", 267 | "boolean": true, 268 | "array": [1, null, "hello"] 269 | }` 270 | actualBody := readAll(t, bodyTuple.body) 271 | if !isEquivalentJSON(t, expectedBody, actualBody) { 272 | t.Errorf("unexpected body: expected=%s, actual=%s", expectedBody, actualBody) 273 | } 274 | expectedContentType := "application/json" 275 | if bodyTuple.contentType != expectedContentType { 276 | t.Errorf("unexpected content type: expected=%s, actual=%s", expectedContentType, bodyTuple.contentType) 277 | } 278 | if bodyTuple.contentLength != int64(len(actualBody)) { 279 | t.Errorf("invalid content length: len(body)=%v, actual=%v", len(actualBody), bodyTuple.contentLength) 280 | } 281 | } 282 | 283 | func TestBuildHTTPBody_FormBody_URLEncoded(t *testing.T) { 284 | // Setup 285 | fileName := makeTempFile(t, "love & peace") 286 | defer os.Remove(fileName) 287 | body := input.Body{ 288 | BodyType: input.FormBody, 289 | Fields: []input.Field{ 290 | {Name: "foo", Value: "bar"}, 291 | {Name: "from_file", Value: fileName, IsFile: true}, 292 | }, 293 | } 294 | in := &input.Input{Body: body} 295 | 296 | // Exercise 297 | bodyTuple, err := buildHTTPBody(in) 298 | if err != nil { 299 | t.Fatalf("unexpected error: err=%+v", err) 300 | } 301 | 302 | // Verify 303 | expectedBody := `foo=bar&from_file=love+%26+peace` 304 | actualBody := readAll(t, bodyTuple.body) 305 | if actualBody != expectedBody { 306 | t.Errorf("unexpected body: expected=%s, actual=%s", expectedBody, actualBody) 307 | } 308 | expectedContentType := "application/x-www-form-urlencoded; charset=utf-8" 309 | if bodyTuple.contentType != expectedContentType { 310 | t.Errorf("unexpected content type: expected=%s, actual=%s", expectedContentType, bodyTuple.contentType) 311 | } 312 | if bodyTuple.contentLength != int64(len(actualBody)) { 313 | t.Errorf("invalid content length: len(body)=%v, actual=%v", len(actualBody), bodyTuple.contentLength) 314 | } 315 | } 316 | 317 | func TestBuildHTTPBody_FormBody_Multipart(t *testing.T) { 318 | // Setup 319 | fileName := makeTempFile(t, "🍣 & 🍺") 320 | defer os.Remove(fileName) 321 | body := input.Body{ 322 | BodyType: input.FormBody, 323 | Fields: []input.Field{ 324 | {Name: "hello", Value: "🍺 world!"}, 325 | {Name: `"double-quoted"`, Value: "should be escaped"}, 326 | }, 327 | Files: []input.Field{ 328 | {Name: "file1", Value: fileName, IsFile: true}, 329 | {Name: "file2", Value: "From STDIN", IsFile: false}, 330 | }, 331 | } 332 | in := &input.Input{Body: body} 333 | 334 | // Exercise 335 | bodyTuple, err := buildHTTPBody(in) 336 | if err != nil { 337 | t.Fatalf("unexpected error: err=%+v", err) 338 | } 339 | 340 | // Verify 341 | expectedBody := regexp.MustCompile(strings.Join([]string{ 342 | `--[0-9a-z]+`, 343 | regexp.QuoteMeta(`Content-Disposition: form-data; name="hello"`), 344 | regexp.QuoteMeta(``), 345 | regexp.QuoteMeta(`🍺 world!`), 346 | `--[0-9a-z]+`, 347 | regexp.QuoteMeta(`Content-Disposition: form-data; name*=utf-8''%22double-quoted%22`), 348 | regexp.QuoteMeta(``), 349 | regexp.QuoteMeta(`should be escaped`), 350 | `--[0-9a-z]+`, 351 | regexp.QuoteMeta(`Content-Disposition: form-data; name="file1"; filename="` + path.Base(fileName) + `"`), 352 | regexp.QuoteMeta(``), 353 | regexp.QuoteMeta(`🍣 & 🍺`), 354 | `--[0-9a-z]+`, 355 | regexp.QuoteMeta(`Content-Disposition: form-data; name="file2"`), 356 | regexp.QuoteMeta(``), 357 | regexp.QuoteMeta(`From STDIN`), 358 | `--[0-9a-z]+--`, 359 | regexp.QuoteMeta(``), 360 | }, "\r\n")) 361 | 362 | actualBody := readAll(t, bodyTuple.body) 363 | if !expectedBody.MatchString(actualBody) { 364 | t.Errorf("unexpected body: expected='%s', actual='%s'", expectedBody, actualBody) 365 | } 366 | expectedContentType := "multipart/form-data; " 367 | if !strings.HasPrefix(bodyTuple.contentType, expectedContentType) { 368 | t.Errorf("unexpected content type: expected=%s, actual=%s", expectedContentType, bodyTuple.contentType) 369 | } 370 | if bodyTuple.contentLength != int64(len(actualBody)) { 371 | t.Errorf("invalid content length: len(body)=%v, actual=%v", len(actualBody), bodyTuple.contentLength) 372 | } 373 | } 374 | 375 | func TestBuildHTTPBody_RawBody(t *testing.T) { 376 | // Setup 377 | body := input.Body{ 378 | BodyType: input.RawBody, 379 | Raw: []byte("Hello, World!!"), 380 | } 381 | in := &input.Input{Body: body} 382 | 383 | // Exercise 384 | bodyTuple, err := buildHTTPBody(in) 385 | if err != nil { 386 | t.Fatalf("unexpected error: err=%+v", err) 387 | } 388 | 389 | // Verify 390 | expectedBody := "Hello, World!!" 391 | actualBody := readAll(t, bodyTuple.body) 392 | if actualBody != expectedBody { 393 | t.Errorf("unexpected body: expected=%s, actual=%s", expectedBody, actualBody) 394 | } 395 | expectedContentType := "application/json" 396 | if bodyTuple.contentType != expectedContentType { 397 | t.Errorf("unexpected content type: expected=%s, actual=%s", expectedContentType, bodyTuple.contentType) 398 | } 399 | if bodyTuple.contentLength != int64(len(actualBody)) { 400 | t.Errorf("invalid content length: len(body)=%v, actual=%v", len(actualBody), bodyTuple.contentLength) 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /exchange/client.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "crypto/tls" 5 | "net/http" 6 | ) 7 | 8 | func BuildHTTPClient(options *Options) (*http.Client, error) { 9 | checkRedirect := func(req *http.Request, via []*http.Request) error { 10 | // Do not follow redirects 11 | return http.ErrUseLastResponse 12 | } 13 | if options.FollowRedirects { 14 | checkRedirect = nil 15 | } 16 | 17 | client := http.Client{ 18 | CheckRedirect: checkRedirect, 19 | Timeout: options.Timeout, 20 | } 21 | 22 | var transp http.RoundTripper 23 | if options.Transport == nil { 24 | transp = http.DefaultTransport.(*http.Transport).Clone() 25 | } else { 26 | transp = options.Transport 27 | } 28 | if httpTransport, ok := transp.(*http.Transport); ok { 29 | httpTransport.TLSClientConfig.InsecureSkipVerify = options.SkipVerify 30 | if options.ForceHTTP1 { 31 | httpTransport.TLSClientConfig.NextProtos = []string{"http/1.1", "http/1.0"} 32 | httpTransport.TLSNextProto = make(map[string]func(string, *tls.Conn) http.RoundTripper) 33 | } 34 | } 35 | client.Transport = transp 36 | 37 | return &client, nil 38 | } 39 | -------------------------------------------------------------------------------- /exchange/options.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | type Options struct { 9 | Timeout time.Duration 10 | FollowRedirects bool 11 | Auth AuthOptions 12 | SkipVerify bool 13 | ForceHTTP1 bool 14 | CheckStatus bool 15 | Transport http.RoundTripper 16 | } 17 | 18 | type AuthOptions struct { 19 | Enabled bool 20 | UserName string 21 | Password string 22 | } 23 | -------------------------------------------------------------------------------- /flags/ask_password_unix.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package flags 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "syscall" 9 | 10 | "github.com/pkg/errors" 11 | "golang.org/x/crypto/ssh/terminal" 12 | ) 13 | 14 | func askPassword() (string, error) { 15 | var fd int 16 | if terminal.IsTerminal(syscall.Stdin) { 17 | fd = syscall.Stdin 18 | } else { 19 | tty, err := os.Open("/dev/tty") 20 | if err != nil { 21 | return "", errors.Wrap(err, "failed to allocate terminal") 22 | } 23 | defer tty.Close() 24 | fd = int(tty.Fd()) 25 | } 26 | 27 | fmt.Fprintf(os.Stderr, "Password: ") 28 | password, err := terminal.ReadPassword(fd) 29 | if err != nil { 30 | return "", errors.Wrap(err, "failed to read password from terminal") 31 | } 32 | fmt.Fprintln(os.Stderr) 33 | return string(password), nil 34 | } 35 | -------------------------------------------------------------------------------- /flags/ask_password_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package flags 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | 9 | "github.com/pkg/errors" 10 | "golang.org/x/crypto/ssh/terminal" 11 | ) 12 | 13 | func askPassword() (string, error) { 14 | fmt.Fprintf(os.Stderr, "Password: ") 15 | fd := int(os.Stdin.Fd()) 16 | password, err := terminal.ReadPassword(fd) 17 | if err != nil { 18 | return "", errors.Wrap(err, "failed to read password from terminal") 19 | } 20 | fmt.Fprintln(os.Stderr) 21 | return string(password), nil 22 | } 23 | -------------------------------------------------------------------------------- /flags/flags.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "regexp" 8 | "strings" 9 | "time" 10 | 11 | "github.com/mattn/go-isatty" 12 | "github.com/nojima/httpie-go/exchange" 13 | "github.com/nojima/httpie-go/input" 14 | "github.com/nojima/httpie-go/output" 15 | "github.com/nojima/httpie-go/version" 16 | "github.com/pborman/getopt" 17 | "github.com/pkg/errors" 18 | ) 19 | 20 | var reNumber = regexp.MustCompile(`^[0-9.]+$`) 21 | 22 | type Usage interface { 23 | PrintUsage(w io.Writer) 24 | } 25 | 26 | type OptionSet struct { 27 | InputOptions input.Options 28 | ExchangeOptions exchange.Options 29 | OutputOptions output.Options 30 | } 31 | 32 | type terminalInfo struct { 33 | stdinIsTerminal bool 34 | stdoutIsTerminal bool 35 | } 36 | 37 | func Parse(args []string) ([]string, Usage, *OptionSet, error) { 38 | return parse(args, terminalInfo{ 39 | stdinIsTerminal: isatty.IsTerminal(os.Stdin.Fd()), 40 | stdoutIsTerminal: isatty.IsTerminal(os.Stdout.Fd()), 41 | }) 42 | } 43 | 44 | func parse(args []string, terminalInfo terminalInfo) ([]string, Usage, *OptionSet, error) { 45 | inputOptions := input.Options{} 46 | outputOptions := output.Options{} 47 | exchangeOptions := exchange.Options{} 48 | var ignoreStdin bool 49 | var verifyFlag string 50 | var verboseFlag bool 51 | var headersFlag bool 52 | var bodyFlag bool 53 | printFlag := "\000" // "\000" is a special value that indicates user did not specified --print 54 | timeout := "30s" 55 | var authFlag string 56 | var prettyFlag string 57 | var versionFlag bool 58 | var licenseFlag bool 59 | 60 | // Default value 20 is a bit too small for options of httpie-go. 61 | getopt.HelpColumn = 22 62 | 63 | flagSet := getopt.New() 64 | flagSet.SetParameters("[METHOD] URL [ITEM [ITEM ...]]") 65 | flagSet.BoolVarLong(&inputOptions.JSON, "json", 'j', "data items are serialized as JSON (default)") 66 | flagSet.BoolVarLong(&inputOptions.Form, "form", 'f', "data items are serialized as form fields") 67 | flagSet.StringVarLong(&printFlag, "print", 'p', "specifies what the output should contain (HBhb)") 68 | flagSet.BoolVarLong(&verboseFlag, "verbose", 'v', "print the request as well as the response. shortcut for --print=HBhb") 69 | flagSet.BoolVarLong(&headersFlag, "headers", 'h', "print only the request headers. shortcut for --print=h") 70 | flagSet.BoolVarLong(&bodyFlag, "body", 'b', "print only response body. shourtcut for --print=b") 71 | flagSet.BoolVarLong(&ignoreStdin, "ignore-stdin", 0, "do not attempt to read stdin") 72 | flagSet.BoolVarLong(&outputOptions.Download, "download", 'd', "download file") 73 | flagSet.BoolVarLong(&outputOptions.Overwrite, "overwrite", 0, "overwrite existing file") 74 | flagSet.BoolVarLong(&exchangeOptions.ForceHTTP1, "http1", 0, "force HTTP/1.1 protocol") 75 | flagSet.StringVarLong(&outputOptions.OutputFile, "output", 'o', "output file") 76 | flagSet.StringVarLong(&verifyFlag, "verify", 0, "verify Host SSL certificate, 'yes' or 'no' ('yes' by default, uppercase is also working)") 77 | flagSet.StringVarLong(&timeout, "timeout", 0, "timeout seconds that you allow the whole operation to take") 78 | flagSet.BoolVarLong(&exchangeOptions.CheckStatus, "check-status", 0, "Also check the HTTP status code and exit with an error if the status indicates one") 79 | flagSet.StringVarLong(&authFlag, "auth", 'a', "colon-separated username and password for authentication") 80 | flagSet.StringVarLong(&prettyFlag, "pretty", 0, "controls output formatting (all, format, none)") 81 | flagSet.BoolVarLong(&exchangeOptions.FollowRedirects, "follow", 'F', "follow 30x Location redirects") 82 | flagSet.BoolVarLong(&versionFlag, "version", 0, "print version and exit") 83 | flagSet.BoolVarLong(&licenseFlag, "license", 0, "print license information and exit") 84 | flagSet.Parse(args) 85 | 86 | // Check --version 87 | if versionFlag { 88 | fmt.Fprintf(os.Stderr, "httpie-go %s\n", version.Current()) 89 | os.Exit(0) 90 | } 91 | 92 | // Check --license 93 | if licenseFlag { 94 | version.PrintLicenses(os.Stderr) 95 | os.Exit(0) 96 | } 97 | 98 | // Check stdin 99 | if !ignoreStdin && !terminalInfo.stdinIsTerminal { 100 | inputOptions.ReadStdin = true 101 | } 102 | 103 | // Parse --print 104 | if err := parsePrintFlag( 105 | printFlag, 106 | verboseFlag, 107 | headersFlag, 108 | bodyFlag, 109 | terminalInfo.stdoutIsTerminal, 110 | &outputOptions, 111 | ); err != nil { 112 | return nil, nil, nil, err 113 | } 114 | 115 | // Parse --timeout 116 | d, err := parseDurationOrSeconds(timeout) 117 | if err != nil { 118 | return nil, nil, nil, err 119 | } 120 | if outputOptions.Download { 121 | d = time.Duration(0) 122 | exchangeOptions.FollowRedirects = true 123 | } 124 | exchangeOptions.Timeout = d 125 | 126 | // Parse --pretty 127 | if err := parsePretty(prettyFlag, terminalInfo.stdoutIsTerminal, &outputOptions); err != nil { 128 | return nil, nil, nil, err 129 | } 130 | 131 | // Verify SSL 132 | verifyFlag = strings.ToLower(verifyFlag) 133 | switch verifyFlag { 134 | case "no": 135 | exchangeOptions.SkipVerify = true 136 | case "yes": 137 | case "": 138 | exchangeOptions.SkipVerify = false 139 | default: 140 | return nil, nil, nil, fmt.Errorf("%s", "Verify flag must be 'yes' or 'no'") 141 | } 142 | 143 | // Parse --auth 144 | if authFlag != "" { 145 | username, password := parseAuth(authFlag) 146 | 147 | if password == nil { 148 | p, err := askPassword() 149 | if err != nil { 150 | return nil, nil, nil, err 151 | } 152 | password = &p 153 | } 154 | 155 | exchangeOptions.Auth.Enabled = true 156 | exchangeOptions.Auth.UserName = username 157 | exchangeOptions.Auth.Password = *password 158 | } 159 | 160 | optionSet := &OptionSet{ 161 | InputOptions: inputOptions, 162 | ExchangeOptions: exchangeOptions, 163 | OutputOptions: outputOptions, 164 | } 165 | return flagSet.Args(), flagSet, optionSet, nil 166 | } 167 | 168 | func parsePrintFlag( 169 | printFlag string, 170 | verboseFlag bool, 171 | headersFlag bool, 172 | bodyFlag bool, 173 | stdoutIsTerminal bool, 174 | outputOptions *output.Options, 175 | ) error { 176 | if printFlag == "\000" { // --print is not specified 177 | if headersFlag { 178 | outputOptions.PrintResponseHeader = true 179 | } else if bodyFlag { 180 | outputOptions.PrintResponseBody = true 181 | } else if verboseFlag { 182 | outputOptions.PrintRequestBody = true 183 | outputOptions.PrintRequestHeader = true 184 | outputOptions.PrintResponseHeader = true 185 | outputOptions.PrintResponseBody = true 186 | } else if stdoutIsTerminal { 187 | outputOptions.PrintResponseHeader = true 188 | outputOptions.PrintResponseBody = true 189 | } else { 190 | outputOptions.PrintResponseBody = true 191 | } 192 | } else { // --print is specified 193 | for _, c := range printFlag { 194 | switch c { 195 | case 'H': 196 | outputOptions.PrintRequestHeader = true 197 | case 'B': 198 | outputOptions.PrintRequestBody = true 199 | case 'h': 200 | outputOptions.PrintResponseHeader = true 201 | case 'b': 202 | outputOptions.PrintResponseBody = true 203 | default: 204 | return errors.Errorf("invalid char in --print value (must be consist of HBhb): %c", c) 205 | } 206 | } 207 | } 208 | return nil 209 | } 210 | 211 | func parsePretty(prettyFlag string, stdoutIsTerminal bool, outputOptions *output.Options) error { 212 | switch prettyFlag { 213 | case "": 214 | outputOptions.EnableFormat = stdoutIsTerminal 215 | outputOptions.EnableColor = stdoutIsTerminal 216 | case "all": 217 | outputOptions.EnableFormat = true 218 | outputOptions.EnableColor = true 219 | case "none": 220 | outputOptions.EnableFormat = false 221 | outputOptions.EnableColor = false 222 | case "format": 223 | outputOptions.EnableFormat = true 224 | outputOptions.EnableColor = false 225 | case "colors": 226 | return errors.New("--pretty=colors is not implemented") 227 | default: 228 | return errors.Errorf("unknown value of --pretty: %s", prettyFlag) 229 | } 230 | return nil 231 | } 232 | 233 | func parseDurationOrSeconds(timeout string) (time.Duration, error) { 234 | if reNumber.MatchString(timeout) { 235 | timeout += "s" 236 | } 237 | d, err := time.ParseDuration(timeout) 238 | if err != nil { 239 | return time.Duration(0), errors.Errorf("Value of --timeout must be a number or duration string: %v", timeout) 240 | } 241 | return d, nil 242 | } 243 | 244 | func parseAuth(authFlag string) (string, *string) { 245 | colonIndex := strings.Index(authFlag, ":") 246 | if colonIndex == -1 { 247 | return authFlag, nil 248 | } 249 | password := authFlag[colonIndex+1:] 250 | return authFlag[:colonIndex], &password 251 | } 252 | -------------------------------------------------------------------------------- /flags/flags_test.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | 8 | "github.com/nojima/httpie-go/exchange" 9 | "github.com/nojima/httpie-go/output" 10 | ) 11 | 12 | func TestParse(t *testing.T) { 13 | args, _, optionSet, err := parse([]string{}, terminalInfo{ 14 | stdinIsTerminal: true, 15 | stdoutIsTerminal: true, 16 | }) 17 | if err != nil { 18 | t.Fatalf("unexpected error: err=%+v", err) 19 | } 20 | 21 | var expectedArgs []string 22 | if !reflect.DeepEqual(expectedArgs, args) { 23 | t.Errorf("unexpected returned args: expected=%v, actual=%v", expectedArgs, args) 24 | } 25 | expectedOptionSet := &OptionSet{ 26 | ExchangeOptions: exchange.Options{ 27 | Timeout: 30 * time.Second, 28 | }, 29 | OutputOptions: output.Options{ 30 | PrintResponseHeader: true, 31 | PrintResponseBody: true, 32 | EnableColor: true, 33 | EnableFormat: true, 34 | }, 35 | } 36 | if !reflect.DeepEqual(expectedOptionSet, optionSet) { 37 | t.Errorf("unexpected option set: expected=\n%+v\nactual=\n%+v", expectedOptionSet, optionSet) 38 | } 39 | } 40 | 41 | func TestParsePrintFlag(t *testing.T) { 42 | noPrintFlag := "\000" 43 | testCases := []struct { 44 | title string 45 | printFlag string 46 | verboseFlag bool 47 | headersFlag bool 48 | bodyFlag bool 49 | stdoutIsTerminal bool 50 | expectedPrintRequestHeader bool 51 | expectedPrintRequestBody bool 52 | expectedPrintResponseHeader bool 53 | expectedPrintResponseBody bool 54 | }{ 55 | { 56 | title: "No flags specified (stdout is terminal)", 57 | printFlag: noPrintFlag, 58 | stdoutIsTerminal: true, 59 | expectedPrintResponseHeader: true, 60 | expectedPrintResponseBody: true, 61 | }, 62 | { 63 | title: "No flags specified (stdout is NOT terminal)", 64 | printFlag: noPrintFlag, 65 | stdoutIsTerminal: false, 66 | expectedPrintResponseBody: true, 67 | }, 68 | { 69 | title: `--print=""`, 70 | printFlag: "", 71 | }, 72 | { 73 | title: `--print=H`, 74 | printFlag: "H", 75 | expectedPrintRequestHeader: true, 76 | }, 77 | { 78 | title: `--print=B`, 79 | printFlag: "B", 80 | expectedPrintRequestBody: true, 81 | }, 82 | { 83 | title: `--print=h`, 84 | printFlag: "h", 85 | expectedPrintResponseHeader: true, 86 | }, 87 | { 88 | title: `--print=b`, 89 | printFlag: "b", 90 | expectedPrintResponseBody: true, 91 | }, 92 | { 93 | title: `--print=HBhb`, 94 | printFlag: "HBhb", 95 | expectedPrintRequestHeader: true, 96 | expectedPrintRequestBody: true, 97 | expectedPrintResponseHeader: true, 98 | expectedPrintResponseBody: true, 99 | }, 100 | { 101 | title: "--headers", 102 | printFlag: noPrintFlag, 103 | headersFlag: true, 104 | expectedPrintResponseHeader: true, 105 | }, 106 | { 107 | title: "--body", 108 | printFlag: noPrintFlag, 109 | bodyFlag: true, 110 | expectedPrintResponseBody: true, 111 | }, 112 | { 113 | title: "--verbose", 114 | printFlag: noPrintFlag, 115 | verboseFlag: true, 116 | expectedPrintRequestHeader: true, 117 | expectedPrintRequestBody: true, 118 | expectedPrintResponseHeader: true, 119 | expectedPrintResponseBody: true, 120 | }, 121 | } 122 | 123 | for _, tt := range testCases { 124 | t.Run(tt.title, func(t *testing.T) { 125 | options := output.Options{} 126 | if err := parsePrintFlag( 127 | tt.printFlag, 128 | tt.verboseFlag, 129 | tt.headersFlag, 130 | tt.bodyFlag, 131 | tt.stdoutIsTerminal, 132 | &options, 133 | ); err != nil { 134 | t.Fatalf("unexpected error: err=%+v", err) 135 | } 136 | 137 | if options.PrintRequestHeader != tt.expectedPrintRequestHeader { 138 | t.Errorf("unexpected PrintRequestHeader: expected=%v, actual=%v", 139 | tt.expectedPrintRequestHeader, options.PrintRequestHeader) 140 | } 141 | if options.PrintRequestBody != tt.expectedPrintRequestBody { 142 | t.Errorf("unexpected PrintRequestBody: expected=%v, actual=%v", 143 | tt.expectedPrintRequestBody, options.PrintRequestBody) 144 | } 145 | if options.PrintResponseHeader != tt.expectedPrintResponseHeader { 146 | t.Errorf("unexpected PrintResponseHeader: expected=%v, actual=%v", 147 | tt.expectedPrintResponseHeader, options.PrintResponseHeader) 148 | } 149 | if options.PrintResponseBody != tt.expectedPrintResponseBody { 150 | t.Errorf("unexpected PrintResponseBody: expected=%v, actual=%v", 151 | tt.expectedPrintResponseBody, options.PrintResponseBody) 152 | } 153 | }) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nojima/httpie-go 2 | 3 | go 1.13 4 | 5 | require ( 6 | code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48 7 | github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 8 | github.com/mattn/go-isatty v0.0.12 9 | github.com/mtibben/androiddnsfix v0.0.0-20200907095054-ff0280446354 10 | github.com/onsi/ginkgo v1.12.0 // indirect 11 | github.com/onsi/gomega v1.9.0 // indirect 12 | github.com/pborman/getopt v0.0.0-20190409184431-ee0cd42419d3 13 | github.com/pkg/errors v0.9.1 14 | github.com/vbauerster/mpb/v5 v5.0.2 15 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48 h1:/EMHruHCFXR9xClkGV/t0rmHrdhX4+trQUcBqjwc9xE= 2 | code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc= 3 | github.com/VividCortex/ewma v1.1.1 h1:MnEK4VOv6n0RSY4vtRe3h11qjxL3+t0B8yOL8iMXdcM= 4 | github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA= 5 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= 6 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= 7 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 8 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 9 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 10 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 11 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 12 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 13 | github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 h1:bqDmpDG49ZRnB5PcgP0RXtQvnMSgIF14M7CBd2shtXs= 14 | github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= 15 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 16 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 17 | github.com/mtibben/androiddnsfix v0.0.0-20200907095054-ff0280446354 h1:aS4S9U7xE7bwYB6gn/X0BteBAasVEfQwPV5k8trGXW4= 18 | github.com/mtibben/androiddnsfix v0.0.0-20200907095054-ff0280446354/go.mod h1:Cu3Rcze2YUpuTWfggCBafY8U9/ckCksdAiONQ7XDvB8= 19 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 20 | github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU= 21 | github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= 22 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 23 | github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg= 24 | github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= 25 | github.com/pborman/getopt v0.0.0-20190409184431-ee0cd42419d3 h1:YtFkrqsMEj7YqpIhRteVxJxCeC3jJBieuLr0d4C4rSA= 26 | github.com/pborman/getopt v0.0.0-20190409184431-ee0cd42419d3/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 27 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 28 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 29 | github.com/vbauerster/mpb/v5 v5.0.2 h1:J03Y437wGmtK1Yl012mC/PU6+0ZCA1skJ04hgh+Z/rE= 30 | github.com/vbauerster/mpb/v5 v5.0.2/go.mod h1:at3flS9HS2cEMEqoEJZO3p1cCdAT4AMcclJxgCd6jcA= 31 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 32 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= 33 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 34 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 35 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= 36 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 37 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= 38 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 39 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 40 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 41 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 42 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 43 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So= 45 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 46 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 47 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 48 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= 49 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 50 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 53 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 54 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 55 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 56 | gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= 57 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 58 | -------------------------------------------------------------------------------- /input/args.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "io/ioutil" 7 | "net/url" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | var ( 15 | reMethod = regexp.MustCompile(`^[a-zA-Z]+$`) 16 | reHeaderFieldName = regexp.MustCompile("^[-!#$%&'*+.^_|~a-zA-Z0-9]+$") 17 | reScheme = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+-.]*://`) 18 | emptyMethod = Method("") 19 | ) 20 | 21 | type itemType int 22 | 23 | const ( 24 | unknownItem itemType = iota 25 | httpHeaderItem 26 | urlParameterItem 27 | dataFieldItem 28 | rawJSONFieldItem 29 | formFileFieldItem 30 | ) 31 | 32 | type UsageError string 33 | 34 | func (e *UsageError) Error() string { 35 | return string(*e) 36 | } 37 | 38 | func newUsageError(message string) error { 39 | u := UsageError(message) 40 | return errors.WithStack(&u) 41 | } 42 | 43 | type state struct { 44 | preferredBodyType BodyType 45 | stdinConsumed bool 46 | } 47 | 48 | func ParseArgs(args []string, stdin io.Reader, options *Options) (*Input, error) { 49 | var argMethod string 50 | var argURL string 51 | var argItems []string 52 | switch len(args) { 53 | case 0: 54 | return nil, newUsageError("URL is required") 55 | case 1: 56 | argURL = args[0] 57 | default: 58 | if reMethod.MatchString(args[0]) { 59 | argMethod = args[0] 60 | argURL = args[1] 61 | argItems = args[2:] 62 | } else { 63 | argURL = args[0] 64 | argItems = args[1:] 65 | } 66 | } 67 | 68 | in := Input{} 69 | state := state{} 70 | 71 | u, err := parseURL(argURL) 72 | if err != nil { 73 | return nil, err 74 | } 75 | in.URL = u 76 | 77 | state.preferredBodyType, err = determinePreferredBodyType(options) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | for _, arg := range argItems { 83 | if err := parseItem(arg, stdin, &state, &in); err != nil { 84 | return nil, err 85 | } 86 | } 87 | if options.ReadStdin && !state.stdinConsumed { 88 | if in.Body.BodyType != EmptyBody { 89 | return nil, errors.New("request body (from stdin) and request item (key=value) cannot be mixed") 90 | } 91 | in.Body.BodyType = RawBody 92 | in.Body.Raw, err = ioutil.ReadAll(stdin) 93 | if err != nil { 94 | return nil, errors.Wrap(err, "failed to read stdin") 95 | } 96 | state.stdinConsumed = true 97 | } 98 | 99 | if argMethod != "" { 100 | method, err := parseMethod(argMethod) 101 | if err != nil { 102 | return nil, err 103 | } 104 | in.Method = method 105 | } else { 106 | in.Method = guessMethod(&in) 107 | } 108 | 109 | return &in, nil 110 | } 111 | 112 | func determinePreferredBodyType(options *Options) (BodyType, error) { 113 | if options.JSON && options.Form { 114 | return EmptyBody, errors.New("You cannot specify both of --json and --form") 115 | } 116 | if options.Form { 117 | return FormBody, nil 118 | } else { 119 | return JSONBody, nil 120 | } 121 | } 122 | 123 | func parseMethod(s string) (Method, error) { 124 | if !reMethod.MatchString(s) { 125 | return emptyMethod, errors.Errorf("METHOD must consist of alphabets: %s", s) 126 | } 127 | 128 | method := Method(strings.ToUpper(s)) 129 | return method, nil 130 | } 131 | 132 | func guessMethod(in *Input) Method { 133 | if in.Body.BodyType == EmptyBody { 134 | return Method("GET") 135 | } else { 136 | return Method("POST") 137 | } 138 | } 139 | 140 | func parseURL(s string) (*url.URL, error) { 141 | defaultScheme := "http" 142 | defaultHost := "localhost" 143 | 144 | // ex) :8080/hello or /hello 145 | if strings.HasPrefix(s, ":") || strings.HasPrefix(s, "/") { 146 | s = defaultHost + s 147 | } 148 | 149 | // ex) example.com/hello 150 | if !reScheme.MatchString(s) { 151 | s = defaultScheme + "://" + s 152 | } 153 | 154 | u, err := url.Parse(s) 155 | if err != nil { 156 | return nil, newUsageError("Invalid URL: " + s) 157 | } 158 | u.Host = strings.TrimSuffix(u.Host, ":") 159 | if u.Path == "" { 160 | u.Path = "/" 161 | } 162 | return u, nil 163 | } 164 | 165 | func parseItem(s string, stdin io.Reader, state *state, in *Input) error { 166 | itemType, name, value := splitItem(s) 167 | switch itemType { 168 | case dataFieldItem: 169 | in.Body.BodyType = state.preferredBodyType 170 | field, err := parseField(name, value, stdin, state) 171 | if err != nil { 172 | return err 173 | } 174 | in.Body.Fields = append(in.Body.Fields, field) 175 | case rawJSONFieldItem: 176 | if state.preferredBodyType != JSONBody { 177 | return errors.New("raw JSON field item cannot be used in non-JSON body") 178 | } 179 | in.Body.BodyType = JSONBody 180 | field, err := parseField(name, value, stdin, state) 181 | if err != nil { 182 | return err 183 | } 184 | if !json.Valid([]byte(field.Value)) { 185 | return errors.Errorf("invalid JSON at '%s': %s", name, field.Value) 186 | } 187 | in.Body.RawJSONFields = append(in.Body.RawJSONFields, field) 188 | case httpHeaderItem: 189 | if !isValidHeaderFieldName(name) { 190 | return errors.Errorf("invalid header field name: %s", name) 191 | } 192 | field, err := parseField(name, value, stdin, state) 193 | if err != nil { 194 | return err 195 | } 196 | in.Header.Fields = append(in.Header.Fields, field) 197 | case urlParameterItem: 198 | field, err := parseField(name, value, stdin, state) 199 | if err != nil { 200 | return err 201 | } 202 | in.Parameters = append(in.Parameters, field) 203 | case formFileFieldItem: 204 | if state.preferredBodyType != FormBody { 205 | return errors.New("form file field item cannot be used in non-form body (perhaps you meant --form?)") 206 | } 207 | in.Body.BodyType = FormBody 208 | field, err := parseField(name, "@"+value, stdin, state) 209 | if err != nil { 210 | return err 211 | } 212 | in.Body.Files = append(in.Body.Files, field) 213 | default: 214 | return errors.Errorf("unknown request item: %s", s) 215 | } 216 | return nil 217 | } 218 | 219 | func splitItem(s string) (itemType, string, string) { 220 | for i, c := range s { 221 | switch c { 222 | case ':': 223 | if i+1 < len(s) && s[i+1] == '=' { 224 | return rawJSONFieldItem, s[:i], s[i+2:] 225 | } else { 226 | return httpHeaderItem, s[:i], s[i+1:] 227 | } 228 | case '=': 229 | if i+1 < len(s) && s[i+1] == '=' { 230 | return urlParameterItem, s[:i], s[i+2:] 231 | } else { 232 | return dataFieldItem, s[:i], s[i+1:] 233 | } 234 | case '@': 235 | return formFileFieldItem, s[:i], s[i+1:] 236 | } 237 | } 238 | return unknownItem, "", "" 239 | } 240 | 241 | func isValidHeaderFieldName(s string) bool { 242 | return reHeaderFieldName.MatchString(s) 243 | } 244 | 245 | func parseField(name, value string, stdin io.Reader, state *state) (Field, error) { 246 | // TODO: handle escaped "@" 247 | if strings.HasPrefix(value, "@") { 248 | if value[1:] == "-" { 249 | b, err := ioutil.ReadAll(stdin) 250 | if err != nil { 251 | return Field{}, errors.Wrapf(err, "reading stdin for '%s'", name) 252 | } 253 | state.stdinConsumed = true 254 | return Field{Name: name, Value: string(b), IsFile: false}, nil 255 | } else { 256 | return Field{Name: name, Value: value[1:], IsFile: true}, nil 257 | } 258 | } else { 259 | return Field{Name: name, Value: value, IsFile: false}, nil 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /input/args_test.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "net/url" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func mustURL(rawurl string) *url.URL { 11 | u, err := url.Parse(rawurl) 12 | if err != nil { 13 | panic("Failed to parse URL: " + rawurl) 14 | } 15 | return u 16 | } 17 | 18 | func TestParseArgs(t *testing.T) { 19 | testCases := []struct { 20 | title string 21 | args []string 22 | stdin string 23 | options *Options 24 | expectedInput *Input 25 | shouldBeError bool 26 | }{ 27 | { 28 | title: "Happy case", 29 | args: []string{"GET", "http://example.com/hello"}, 30 | expectedInput: &Input{ 31 | Method: Method("GET"), 32 | URL: mustURL("http://example.com/hello"), 33 | }, 34 | shouldBeError: false, 35 | }, 36 | { 37 | title: "Method is omitted (only host)", 38 | args: []string{"localhost"}, 39 | expectedInput: &Input{ 40 | Method: Method("GET"), 41 | URL: mustURL("http://localhost/"), 42 | }, 43 | }, 44 | { 45 | title: "Method is omitted (JSON body)", 46 | args: []string{"example.com", "foo=bar"}, 47 | expectedInput: &Input{ 48 | Method: Method("POST"), 49 | URL: mustURL("http://example.com/"), 50 | Body: Body{ 51 | BodyType: JSONBody, 52 | Fields: []Field{ 53 | {Name: "foo", Value: "bar"}, 54 | }, 55 | }, 56 | }, 57 | }, 58 | { 59 | title: "Method is omitted (query parameter)", 60 | args: []string{"example.com", "foo==bar"}, 61 | expectedInput: &Input{ 62 | Method: Method("GET"), 63 | URL: mustURL("http://example.com/"), 64 | Parameters: []Field{ 65 | {Name: "foo", Value: "bar"}, 66 | }, 67 | }, 68 | }, 69 | { 70 | title: "URL missing", 71 | args: []string{}, 72 | expectedInput: nil, 73 | shouldBeError: true, 74 | }, 75 | { 76 | title: "Lower case method", 77 | args: []string{"get", "localhost"}, 78 | expectedInput: &Input{ 79 | Method: Method("GET"), 80 | URL: mustURL("http://localhost/"), 81 | }, 82 | }, 83 | { 84 | title: "Read body from stdin", 85 | args: []string{"example.com"}, 86 | stdin: "Hello, World!", 87 | options: &Options{ 88 | ReadStdin: true, 89 | }, 90 | expectedInput: &Input{ 91 | Method: Method("POST"), 92 | URL: mustURL("http://example.com/"), 93 | Body: Body{ 94 | BodyType: RawBody, 95 | Raw: []byte("Hello, World!"), 96 | }, 97 | }, 98 | }, 99 | { 100 | title: "Stdin and request items mixed", 101 | args: []string{"example.com", "foo=bar"}, 102 | stdin: "Hello, World!", 103 | options: &Options{ 104 | ReadStdin: true, 105 | }, 106 | shouldBeError: true, 107 | }, 108 | { 109 | title: "Read request item from stdin", 110 | args: []string{"example.com", "hello=@-"}, 111 | stdin: "Hello, World!", 112 | options: &Options{ 113 | ReadStdin: true, 114 | }, 115 | expectedInput: &Input{ 116 | Method: Method("POST"), 117 | URL: mustURL("http://example.com/"), 118 | Body: Body{ 119 | BodyType: JSONBody, 120 | Fields: []Field{ 121 | {Name: "hello", Value: "Hello, World!", IsFile: false}, 122 | }, 123 | }, 124 | }, 125 | }, 126 | } 127 | for _, tt := range testCases { 128 | t.Run(tt.title, func(t *testing.T) { 129 | // Setup 130 | options := &Options{} 131 | if tt.options != nil { 132 | options = tt.options 133 | } 134 | 135 | // Exercise 136 | input, err := ParseArgs(tt.args, strings.NewReader(tt.stdin), options) 137 | if (err != nil) != tt.shouldBeError { 138 | t.Fatalf("unexpected error: shouldBeError=%v, err=%v", tt.shouldBeError, err) 139 | } 140 | if err != nil { 141 | return 142 | } 143 | 144 | // Verify 145 | if !reflect.DeepEqual(input, tt.expectedInput) { 146 | t.Errorf("unexpected input: expected=%+v, actual=%+v", tt.expectedInput, input) 147 | } 148 | }) 149 | } 150 | } 151 | 152 | func TestParseItem(t *testing.T) { 153 | testCases := []struct { 154 | title string 155 | item string 156 | stdin string 157 | preferredBodyType BodyType 158 | expectedBodyFields []Field 159 | expectedBodyRawJSONFields []Field 160 | expectedBodyFiles []Field 161 | expectedHeaderFields []Field 162 | expectedParameters []Field 163 | expectedBodyType BodyType 164 | shouldBeError bool 165 | }{ 166 | { 167 | title: "Data field", 168 | item: "hello=world", 169 | expectedBodyFields: []Field{{Name: "hello", Value: "world"}}, 170 | expectedBodyType: JSONBody, 171 | }, 172 | { 173 | title: "Data field in JSON body type", 174 | item: "hello=world", 175 | preferredBodyType: JSONBody, 176 | expectedBodyFields: []Field{{Name: "hello", Value: "world"}}, 177 | expectedBodyType: JSONBody, 178 | }, 179 | { 180 | title: "Data field (form)", 181 | item: "hello=world", 182 | preferredBodyType: FormBody, 183 | expectedBodyFields: []Field{{Name: "hello", Value: "world"}}, 184 | expectedBodyType: FormBody, 185 | }, 186 | { 187 | title: "Data field with empty value", 188 | item: "hello=", 189 | expectedBodyFields: []Field{{Name: "hello", Value: ""}}, 190 | expectedBodyType: JSONBody, 191 | }, 192 | { 193 | title: "Data field from file", 194 | item: "hello=@world.txt", 195 | expectedBodyFields: []Field{{Name: "hello", Value: "world.txt", IsFile: true}}, 196 | expectedBodyType: JSONBody, 197 | }, 198 | { 199 | title: "Data field from stdin", 200 | item: "hello=@-", 201 | stdin: "Hello, World!", 202 | expectedBodyFields: []Field{{Name: "hello", Value: "Hello, World!", IsFile: false}}, 203 | expectedBodyType: JSONBody, 204 | }, 205 | { 206 | title: "Raw JSON field", 207 | item: `hello:=[1, true, "world"]`, 208 | expectedBodyRawJSONFields: []Field{{Name: "hello", Value: `[1, true, "world"]`}}, 209 | expectedBodyType: JSONBody, 210 | }, 211 | { 212 | title: "Raw JSON field with invalid JSON", 213 | item: `hello:={invalid: JSON}`, 214 | shouldBeError: true, 215 | }, 216 | { 217 | title: "Raw JSON field in form body type", 218 | item: `hello:=[1, true, "world"]`, 219 | preferredBodyType: FormBody, 220 | shouldBeError: true, 221 | }, 222 | { 223 | title: "Header field", 224 | item: "X-Example:Sample Value", 225 | expectedHeaderFields: []Field{{Name: "X-Example", Value: "Sample Value"}}, 226 | expectedBodyType: EmptyBody, 227 | }, 228 | { 229 | title: "Header field with empty value", 230 | item: "X-Example:", 231 | expectedHeaderFields: []Field{{Name: "X-Example", Value: ""}}, 232 | expectedBodyType: EmptyBody, 233 | }, 234 | { 235 | title: "Invalid header field name", 236 | item: `Bad"header":test`, 237 | shouldBeError: true, 238 | }, 239 | { 240 | title: "URL parameter", 241 | item: "hello==world", 242 | expectedParameters: []Field{{Name: "hello", Value: "world"}}, 243 | expectedBodyType: EmptyBody, 244 | }, 245 | { 246 | title: "URL parameter with empty value", 247 | item: "hello==", 248 | expectedParameters: []Field{{Name: "hello", Value: ""}}, 249 | expectedBodyType: EmptyBody, 250 | }, 251 | { 252 | title: "Form file field", 253 | item: "file@./hello.txt", 254 | preferredBodyType: FormBody, 255 | expectedBodyType: FormBody, 256 | expectedBodyFiles: []Field{{Name: "file", Value: "./hello.txt", IsFile: true}}, 257 | }, 258 | { 259 | title: "Form file field in JSON context", 260 | item: "file@./hello.txt", 261 | preferredBodyType: JSONBody, 262 | shouldBeError: true, 263 | }, 264 | } 265 | for _, tt := range testCases { 266 | t.Run(tt.title, func(t *testing.T) { 267 | // Setup 268 | in := Input{} 269 | preferredBodyType := JSONBody 270 | if tt.preferredBodyType != EmptyBody { 271 | preferredBodyType = tt.preferredBodyType 272 | } 273 | state := state{preferredBodyType: preferredBodyType} 274 | stdin := strings.NewReader(tt.stdin) 275 | 276 | // Exercise 277 | err := parseItem(tt.item, stdin, &state, &in) 278 | if (err != nil) != tt.shouldBeError { 279 | t.Fatalf("unexpected error: shouldBeError=%v, err=%v", tt.shouldBeError, err) 280 | } 281 | if err != nil { 282 | return 283 | } 284 | 285 | // Verify 286 | if !reflect.DeepEqual(in.Body.Fields, tt.expectedBodyFields) { 287 | t.Errorf("unexpected body field: expected=%+v, actual=%+v", tt.expectedBodyFields, in.Body.Fields) 288 | } 289 | if !reflect.DeepEqual(in.Body.RawJSONFields, tt.expectedBodyRawJSONFields) { 290 | t.Errorf("unexpected raw JSON body field: expected=%+v, actual=%+v", tt.expectedBodyRawJSONFields, in.Body.RawJSONFields) 291 | } 292 | if !reflect.DeepEqual(in.Body.Files, tt.expectedBodyFiles) { 293 | t.Errorf("unexpected files: expected=%+v, actual=%+v", tt.expectedBodyFiles, in.Body.Files) 294 | } 295 | if !reflect.DeepEqual(in.Header.Fields, tt.expectedHeaderFields) { 296 | t.Errorf("unexpected header field: expected=%+v, actual=%+v", tt.expectedHeaderFields, in.Header.Fields) 297 | } 298 | if !reflect.DeepEqual(in.Parameters, tt.expectedParameters) { 299 | t.Errorf("unexpected parameters: expected=%+v, actual=%+v", tt.expectedParameters, in.Parameters) 300 | } 301 | if in.Body.BodyType != tt.expectedBodyType { 302 | t.Errorf("unexpected body type: expected=%v, actual=%v", tt.expectedBodyType, in.Body.BodyType) 303 | } 304 | }) 305 | } 306 | } 307 | 308 | func TestParseUrl(t *testing.T) { 309 | testCases := []struct { 310 | title string 311 | input string 312 | expected url.URL 313 | }{ 314 | { 315 | title: "Typical case", 316 | input: "http://example.com/hello/world", 317 | expected: url.URL{ 318 | Scheme: "http", 319 | Host: "example.com", 320 | Path: "/hello/world", 321 | }, 322 | }, 323 | { 324 | title: "No scheme", 325 | input: "example.com/hello/world", 326 | expected: url.URL{ 327 | Scheme: "http", 328 | Host: "example.com", 329 | Path: "/hello/world", 330 | }, 331 | }, 332 | { 333 | title: "No host and port", 334 | input: "/hello/world", 335 | expected: url.URL{ 336 | Scheme: "http", 337 | Host: "localhost", 338 | Path: "/hello/world", 339 | }, 340 | }, 341 | { 342 | title: "No host and port but has colon", 343 | input: ":/foo", 344 | expected: url.URL{ 345 | Scheme: "http", 346 | Host: "localhost", 347 | Path: "/foo", 348 | }, 349 | }, 350 | { 351 | title: "Only colon", 352 | input: ":", 353 | expected: url.URL{ 354 | Scheme: "http", 355 | Host: "localhost", 356 | Path: "/", 357 | }, 358 | }, 359 | { 360 | title: "No host but has port", 361 | input: ":8080/hello/world", 362 | expected: url.URL{ 363 | Scheme: "http", 364 | Host: "localhost:8080", 365 | Path: "/hello/world", 366 | }, 367 | }, 368 | { 369 | title: "Has query parameters", 370 | input: "http://example.com/?q=hello&lang=ja", 371 | expected: url.URL{ 372 | Scheme: "http", 373 | Host: "example.com", 374 | Path: "/", 375 | RawQuery: "q=hello&lang=ja", 376 | }, 377 | }, 378 | { 379 | title: "No path", 380 | input: "https://example.com", 381 | expected: url.URL{ 382 | Scheme: "https", 383 | Host: "example.com", 384 | Path: "/", 385 | }, 386 | }, 387 | } 388 | for _, tt := range testCases { 389 | t.Run(tt.title, func(t *testing.T) { 390 | // Exercise 391 | u, err := parseURL(tt.input) 392 | if err != nil { 393 | t.Fatalf("unexpected error: err=%v", err) 394 | } 395 | 396 | // Verify 397 | if !reflect.DeepEqual(*u, tt.expected) { 398 | t.Errorf("unexpected result: expected=%+v, actual=%+v", tt.expected, *u) 399 | } 400 | }) 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /input/input.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import "net/url" 4 | 5 | type Input struct { 6 | Method Method 7 | URL *url.URL 8 | Parameters []Field 9 | Header Header 10 | Body Body 11 | } 12 | 13 | type Method string 14 | 15 | type Header struct { 16 | Fields []Field 17 | } 18 | 19 | type BodyType int 20 | 21 | const ( 22 | EmptyBody BodyType = iota 23 | JSONBody 24 | FormBody 25 | RawBody 26 | ) 27 | 28 | type Body struct { 29 | BodyType BodyType 30 | Fields []Field 31 | RawJSONFields []Field // used only when BodyType == JSONBody 32 | Files []Field // used only when BodyType == FormBody 33 | Raw []byte // used only when BodyType == RawBody 34 | } 35 | 36 | type Field struct { 37 | Name string 38 | Value string 39 | IsFile bool 40 | } 41 | -------------------------------------------------------------------------------- /input/options.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | type Options struct { 4 | JSON bool 5 | Form bool 6 | ReadStdin bool 7 | } 8 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package httpie 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "net/http" 8 | "net/http/httputil" 9 | "os" 10 | 11 | "github.com/nojima/httpie-go/exchange" 12 | "github.com/nojima/httpie-go/flags" 13 | "github.com/nojima/httpie-go/input" 14 | "github.com/nojima/httpie-go/output" 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | type Options struct { 19 | // Transport is applied to the underlying HTTP client. Use to mock or 20 | // intercept network traffic. If nil, http.DefaultTransport will be cloned. 21 | Transport http.RoundTripper 22 | } 23 | 24 | func Main(options *Options) error { 25 | // Parse flags 26 | args, usage, optionSet, err := flags.Parse(os.Args) 27 | if err != nil { 28 | return err 29 | } 30 | inputOptions := optionSet.InputOptions 31 | exchangeOptions := optionSet.ExchangeOptions 32 | exchangeOptions.Transport = options.Transport 33 | outputOptions := optionSet.OutputOptions 34 | 35 | // Parse positional arguments 36 | in, err := input.ParseArgs(args, os.Stdin, &inputOptions) 37 | if _, ok := errors.Cause(err).(*input.UsageError); ok { 38 | usage.PrintUsage(os.Stderr) 39 | return err 40 | } 41 | if err != nil { 42 | return err 43 | } 44 | 45 | // Send request and receive response 46 | status, err := Exchange(in, &exchangeOptions, &outputOptions) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | if exchangeOptions.CheckStatus { 52 | os.Exit(getExitStatus(status)) 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func getExitStatus(statusCode int) int { 59 | if 300 <= statusCode && statusCode < 600 { 60 | return statusCode / 100 61 | } 62 | return 0 63 | } 64 | 65 | func Exchange(in *input.Input, exchangeOptions *exchange.Options, outputOptions *output.Options) (int, error) { 66 | // Prepare printer 67 | writer := bufio.NewWriter(os.Stdout) 68 | defer writer.Flush() 69 | printer := output.NewPrinter(writer, outputOptions) 70 | 71 | // Build HTTP request 72 | request, err := exchange.BuildHTTPRequest(in, exchangeOptions) 73 | if err != nil { 74 | return -1, err 75 | } 76 | 77 | // Print HTTP request 78 | if outputOptions.PrintRequestHeader || outputOptions.PrintRequestBody { 79 | // `request` does not contain HTTP headers that HttpClient.Do adds. 80 | // We can get these headers by DumpRequestOut and ReadRequest. 81 | dump, err := httputil.DumpRequestOut(request, true) 82 | if err != nil { 83 | return -1, err // should not happen 84 | } 85 | r, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(dump))) 86 | if err != nil { 87 | return -1, err // should not happen 88 | } 89 | defer r.Body.Close() 90 | 91 | // ReadRequest deletes Host header. We must restore it. 92 | if request.Host != "" { 93 | r.Header.Set("Host", request.Host) 94 | } else { 95 | r.Header.Set("Host", request.URL.Host) 96 | } 97 | 98 | if outputOptions.PrintRequestHeader { 99 | if err := printer.PrintRequestLine(r); err != nil { 100 | return -1, err 101 | } 102 | if err := printer.PrintHeader(r.Header); err != nil { 103 | return -1, err 104 | } 105 | } 106 | if outputOptions.PrintRequestBody { 107 | if err := printer.PrintBody(r.Body, r.Header.Get("Content-Type")); err != nil { 108 | return -1, err 109 | } 110 | } 111 | fmt.Fprintln(writer) 112 | writer.Flush() 113 | } 114 | 115 | // Send HTTP request and receive HTTP request 116 | httpClient, err := exchange.BuildHTTPClient(exchangeOptions) 117 | if err != nil { 118 | return -1, err 119 | } 120 | resp, err := httpClient.Do(request) 121 | if err != nil { 122 | return -1, errors.Wrap(err, "sending HTTP request") 123 | } 124 | defer resp.Body.Close() 125 | 126 | if outputOptions.PrintResponseHeader { 127 | if err := printer.PrintStatusLine(resp.Proto, resp.Status, resp.StatusCode); err != nil { 128 | return -1, err 129 | } 130 | if err := printer.PrintHeader(resp.Header); err != nil { 131 | return -1, err 132 | } 133 | writer.Flush() 134 | } 135 | 136 | if outputOptions.Download { 137 | file := output.NewFileWriter(in.URL, outputOptions) 138 | 139 | if err := printer.PrintDownload(resp.ContentLength, file.Filename()); err != nil { 140 | return -1, err 141 | } 142 | writer.Flush() 143 | 144 | if err = file.Download(resp); err != nil { 145 | return -1, err 146 | } 147 | } else { 148 | if outputOptions.PrintResponseBody { 149 | if err := printer.PrintBody(resp.Body, resp.Header.Get("Content-Type")); err != nil { 150 | return -1, err 151 | } 152 | } 153 | } 154 | 155 | return resp.StatusCode, nil 156 | } 157 | -------------------------------------------------------------------------------- /output/file.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/vbauerster/mpb/v5" 15 | "github.com/vbauerster/mpb/v5/decor" 16 | ) 17 | 18 | type FileWriter struct { 19 | fullPath string 20 | } 21 | 22 | func NewFileWriter(url *url.URL, options *Options) *FileWriter { 23 | var fullPath string 24 | 25 | if options.OutputFile == "" { 26 | fullPath = fmt.Sprintf("./%s", filepath.Base(url.Path)) 27 | } else { 28 | fullPath = options.OutputFile 29 | } 30 | 31 | if !options.Overwrite { 32 | fullPath = makeNonOverlappingFilename(fullPath) 33 | } 34 | 35 | return &FileWriter{ 36 | fullPath: fullPath, 37 | } 38 | } 39 | 40 | func makeNonOverlappingFilename(path string) string { 41 | _, err := os.Stat(path) 42 | if err == nil { 43 | re := regexp.MustCompile(`\.(\d+)$`) 44 | newPath := re.ReplaceAllStringFunc(path, func(index string) string { 45 | i, err := strconv.Atoi(strings.TrimPrefix(index, ".")) 46 | if err != nil { 47 | panic(err) 48 | } 49 | i++ 50 | return fmt.Sprintf(".%d", i) 51 | }) 52 | if path == newPath { 53 | path = fmt.Sprintf("%s.%d", path, 1) 54 | } else { 55 | path = newPath 56 | } 57 | path = makeNonOverlappingFilename(path) 58 | } 59 | return path 60 | } 61 | 62 | func (f *FileWriter) Download(resp *http.Response) error { 63 | // Create new progress bar 64 | pb := mpb.New(mpb.WithWidth(60)) 65 | 66 | // Create file 67 | file, err := os.Create(f.fullPath) 68 | if err != nil { 69 | return err 70 | } 71 | defer file.Close() 72 | 73 | // Parameters of th new progress bar 74 | bar := pb.AddBar(resp.ContentLength, 75 | mpb.PrependDecorators( 76 | decor.CountersKiloByte("% .2f / % .2f "), 77 | decor.AverageSpeed(decor.UnitKB, "(% .2f)"), 78 | ), 79 | mpb.AppendDecorators( 80 | decor.Percentage(), 81 | decor.Name(" - "), 82 | decor.Elapsed(decor.ET_STYLE_GO, decor.WC{W: 4}), 83 | decor.Name(" - "), 84 | decor.OnComplete( 85 | decor.AverageETA(decor.ET_STYLE_GO, decor.WC{W: 4}), "done", 86 | ), 87 | ), 88 | ) 89 | 90 | // Update progress bar while writing file 91 | _, err = io.Copy(file, bar.ProxyReader(resp.Body)) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | pb.Wait() 97 | 98 | return nil 99 | } 100 | 101 | func (f *FileWriter) Filename() string { 102 | return filepath.Base(f.fullPath) 103 | } 104 | -------------------------------------------------------------------------------- /output/options.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | type Options struct { 4 | PrintRequestHeader bool 5 | PrintRequestBody bool 6 | PrintResponseHeader bool 7 | PrintResponseBody bool 8 | 9 | EnableFormat bool 10 | EnableColor bool 11 | 12 | Download bool 13 | OutputFile string 14 | Overwrite bool 15 | } 16 | -------------------------------------------------------------------------------- /output/plain.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | 8 | "code.cloudfoundry.org/bytefmt" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type PlainPrinter struct { 13 | writer io.Writer 14 | } 15 | 16 | func NewPlainPrinter(writer io.Writer) Printer { 17 | return &PlainPrinter{ 18 | writer: writer, 19 | } 20 | } 21 | 22 | func (p *PlainPrinter) PrintStatusLine(proto string, status string, statusCode int) error { 23 | fmt.Fprintf(p.writer, "%s %s\n", proto, status) 24 | return nil 25 | } 26 | 27 | func (p *PlainPrinter) PrintRequestLine(req *http.Request) error { 28 | fmt.Fprintf(p.writer, "%s %s %s\n", req.Method, req.URL, req.Proto) 29 | return nil 30 | } 31 | 32 | func (p *PlainPrinter) PrintHeader(header http.Header) error { 33 | for name, values := range header { 34 | for _, value := range values { 35 | fmt.Fprintf(p.writer, "%s: %s\n", name, value) 36 | } 37 | } 38 | fmt.Fprintln(p.writer) 39 | return nil 40 | } 41 | 42 | func (p *PlainPrinter) PrintBody(body io.Reader, contentType string) error { 43 | _, err := io.Copy(p.writer, body) 44 | if err != nil { 45 | return errors.Wrap(err, "printing body") 46 | } 47 | return nil 48 | } 49 | 50 | func (p *PlainPrinter) PrintDownload(length int64, filename string) error { 51 | fmt.Fprintf(p.writer, "Downloading %sB to \"%s\"\n", bytefmt.ByteSize(uint64(length)), filename) 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /output/pretty.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "sort" 11 | "strings" 12 | 13 | "code.cloudfoundry.org/bytefmt" 14 | "github.com/logrusorgru/aurora" 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | type PrettyPrinter struct { 19 | writer io.Writer 20 | plain Printer 21 | aurora aurora.Aurora 22 | headerPalette *HeaderPalette 23 | jsonPalette *JSONPalette 24 | indentWidth int 25 | } 26 | 27 | type PrettyPrinterConfig struct { 28 | Writer io.Writer 29 | EnableColor bool 30 | } 31 | 32 | type HeaderPalette struct { 33 | Method aurora.Color 34 | URL aurora.Color 35 | Proto aurora.Color 36 | SuccessfulStatus aurora.Color 37 | NonSuccessfulStatus aurora.Color 38 | FieldName aurora.Color 39 | FieldValue aurora.Color 40 | FieldSeparator aurora.Color 41 | } 42 | 43 | var defaultHeaderPalette = HeaderPalette{ 44 | Method: aurora.WhiteFg | aurora.BoldFm, 45 | URL: aurora.GreenFg | aurora.BoldFm, 46 | Proto: aurora.BlueFg, 47 | SuccessfulStatus: aurora.GreenFg | aurora.BoldFm, 48 | NonSuccessfulStatus: aurora.YellowFg | aurora.BoldFm, 49 | FieldName: aurora.WhiteFg, 50 | FieldValue: aurora.CyanFg, 51 | FieldSeparator: aurora.WhiteFg, 52 | } 53 | 54 | type JSONPalette struct { 55 | Key aurora.Color 56 | String aurora.Color 57 | Number aurora.Color 58 | Boolean aurora.Color 59 | Null aurora.Color 60 | Delimiter aurora.Color 61 | } 62 | 63 | var defaultJSONPalette = JSONPalette{ 64 | Key: aurora.BlueFg, 65 | String: aurora.YellowFg, 66 | Number: aurora.CyanFg, 67 | Boolean: aurora.RedFg | aurora.BoldFm, 68 | Null: aurora.RedFg | aurora.BoldFm, 69 | Delimiter: aurora.WhiteFg, 70 | } 71 | 72 | var errMalformedJSON = errors.New("output: malformed json") 73 | 74 | func NewPrettyPrinter(config PrettyPrinterConfig) Printer { 75 | return &PrettyPrinter{ 76 | writer: config.Writer, 77 | plain: NewPlainPrinter(config.Writer), 78 | aurora: aurora.NewAurora(config.EnableColor), 79 | headerPalette: &defaultHeaderPalette, 80 | jsonPalette: &defaultJSONPalette, 81 | indentWidth: 4, 82 | } 83 | } 84 | 85 | func (p *PrettyPrinter) PrintStatusLine(proto string, status string, statusCode int) error { 86 | var statusColor aurora.Color 87 | if 200 <= statusCode && statusCode < 300 { 88 | statusColor = p.headerPalette.SuccessfulStatus 89 | } else { 90 | statusColor = p.headerPalette.NonSuccessfulStatus 91 | } 92 | 93 | fmt.Fprintf(p.writer, "%s %s\n", 94 | p.aurora.Colorize(proto, p.headerPalette.Proto), 95 | p.aurora.Colorize(status, statusColor), 96 | ) 97 | return nil 98 | } 99 | 100 | func (p *PrettyPrinter) PrintRequestLine(req *http.Request) error { 101 | fmt.Fprintf(p.writer, "%s %s %s\n", 102 | p.aurora.Colorize(req.Method, p.headerPalette.Method), 103 | p.aurora.Colorize(req.URL, p.headerPalette.URL), 104 | p.aurora.Colorize(req.Proto, p.headerPalette.Proto), 105 | ) 106 | return nil 107 | } 108 | 109 | func (p *PrettyPrinter) PrintHeader(header http.Header) error { 110 | var names []string 111 | for name := range header { 112 | names = append(names, name) 113 | } 114 | sort.Strings(names) 115 | 116 | for _, name := range names { 117 | values := header[name] 118 | for _, value := range values { 119 | fmt.Fprintf(p.writer, "%s%s %s\n", 120 | p.aurora.Colorize(name, p.headerPalette.FieldName), 121 | p.aurora.Colorize(":", p.headerPalette.FieldSeparator), 122 | p.aurora.Colorize(value, p.headerPalette.FieldValue)) 123 | } 124 | } 125 | 126 | fmt.Fprintln(p.writer) 127 | return nil 128 | } 129 | 130 | func isJSON(contentType string) bool { 131 | contentType = strings.TrimSpace(contentType) 132 | 133 | semicolon := strings.Index(contentType, ";") 134 | if semicolon != -1 { 135 | contentType = contentType[:semicolon] 136 | } 137 | 138 | return contentType == "application/json" || strings.HasSuffix(contentType, "+json") 139 | } 140 | 141 | func (p *PrettyPrinter) PrintBody(body io.Reader, contentType string) error { 142 | // Fallback to PlainPrinter when the body is not JSON 143 | if !isJSON(contentType) { 144 | return p.plain.PrintBody(body, contentType) 145 | } 146 | 147 | content, err := ioutil.ReadAll(body) 148 | if err != nil { 149 | return errors.Wrap(err, "reading body") 150 | } 151 | 152 | // decode JSON creating a new "token buffer" from which we will pretty-print 153 | // the data. 154 | toks, err := newTokenBuffer(json.NewDecoder(bytes.NewReader(content))) 155 | if err != nil || len(toks.tokens) == 0 { 156 | // Failed to parse body as JSON. Print as-is. 157 | p.writer.Write(content) 158 | return nil 159 | } 160 | 161 | err = p.printJSON(toks, 0) 162 | // errMalformedJSON errors can be ignored. This is because the JSON is 163 | // pre-tokenized, and therefore errMalformedJSON errors only occur when 164 | // the JSON ends in the middle. 165 | if err != nil && !errors.Is(err, errMalformedJSON) { 166 | return err 167 | } 168 | 169 | fmt.Fprintln(p.writer) 170 | return nil 171 | } 172 | 173 | // newTokenBuffer allows you to create a tokenBuffer which contains all the 174 | // tokens of the given json.Decoder. 175 | func newTokenBuffer(dec *json.Decoder) (*tokenBuffer, error) { 176 | dec.UseNumber() 177 | tks := make([]json.Token, 0, 64) 178 | for { 179 | tok, err := dec.Token() 180 | switch err { 181 | case nil: 182 | tks = append(tks, tok) 183 | case io.EOF: 184 | return &tokenBuffer{tokens: tks}, nil 185 | default: 186 | return nil, err 187 | } 188 | } 189 | } 190 | 191 | type tokenBuffer struct { 192 | tokens []json.Token 193 | pos int 194 | } 195 | 196 | // endOfBody is a marker of the end of a token sequence. 197 | type endOfBody struct{} 198 | 199 | // token reads a new token adancing in the buffer 200 | func (t *tokenBuffer) token() json.Token { 201 | if t.pos >= len(t.tokens) { 202 | return endOfBody{} 203 | } 204 | v := t.tokens[t.pos] 205 | t.pos++ 206 | return v 207 | } 208 | 209 | // peek reads the next token without advancing in the buffer. 210 | func (t *tokenBuffer) peek() json.Token { 211 | if t.pos >= len(t.tokens) { 212 | return endOfBody{} 213 | } 214 | return t.tokens[t.pos] 215 | } 216 | 217 | func (p *PrettyPrinter) printJSON(buf *tokenBuffer, depth int) error { 218 | switch v := buf.token().(type) { 219 | case json.Delim: 220 | switch v { 221 | case '[': 222 | return p.printArray(buf, depth) 223 | case '{': 224 | return p.printMap(buf, depth) 225 | default: 226 | return errors.Errorf("[BUG] wrong delim: %v", v) 227 | } 228 | case bool: 229 | return p.printBool(v) 230 | case json.Number: 231 | return p.printNumber(v) 232 | case string: 233 | return p.printString(v) 234 | case nil: 235 | return p.printNull() 236 | case endOfBody: 237 | return errMalformedJSON 238 | default: 239 | return errors.Errorf("[BUG] unknown value in JSON: %#v", v) 240 | } 241 | } 242 | 243 | func (p *PrettyPrinter) printNull() error { 244 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize("null", p.jsonPalette.Null)) 245 | return nil 246 | } 247 | 248 | func (p *PrettyPrinter) printBool(v bool) error { 249 | var s string 250 | if v { 251 | s = "true" 252 | } else { 253 | s = "false" 254 | } 255 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize(s, p.jsonPalette.Boolean)) 256 | return nil 257 | } 258 | 259 | func (p *PrettyPrinter) printNumber(n json.Number) error { 260 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize(n.String(), p.jsonPalette.Number)) 261 | return nil 262 | } 263 | 264 | func (p *PrettyPrinter) printString(s string) error { 265 | b, _ := json.Marshal(s) 266 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize(string(b), p.jsonPalette.String)) 267 | return nil 268 | } 269 | 270 | func (p *PrettyPrinter) printArray(buf *tokenBuffer, depth int) error { 271 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize("[", p.jsonPalette.Delimiter)) 272 | 273 | // fast path: array is empty 274 | if d, ok := buf.peek().(json.Delim); ok && d == ']' { 275 | buf.token() 276 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize("]", p.jsonPalette.Delimiter)) 277 | return nil 278 | } 279 | 280 | for { 281 | p.breakLine(depth + 1) 282 | 283 | if err := p.printJSON(buf, depth+1); err != nil { 284 | return err 285 | } 286 | 287 | if d, ok := buf.peek().(json.Delim); ok && d == ']' { 288 | // we're finished 289 | buf.token() 290 | break 291 | } 292 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize(",", p.jsonPalette.Delimiter)) 293 | } 294 | 295 | p.breakLine(depth) 296 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize("]", p.jsonPalette.Delimiter)) 297 | return nil 298 | } 299 | 300 | func (p *PrettyPrinter) printMap(buf *tokenBuffer, depth int) error { 301 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize("{", p.jsonPalette.Delimiter)) 302 | 303 | // fast path: object is empty 304 | if d, ok := buf.peek().(json.Delim); ok && d == '}' { 305 | buf.token() 306 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize("}", p.jsonPalette.Delimiter)) 307 | return nil 308 | } 309 | 310 | for { 311 | p.breakLine(depth + 1) 312 | 313 | key, ok := buf.token().(string) 314 | if !ok { 315 | return errMalformedJSON 316 | } 317 | encodedKey, _ := json.Marshal(key) 318 | fmt.Fprintf(p.writer, "%s%s ", 319 | p.aurora.Colorize(encodedKey, p.jsonPalette.Key), 320 | p.aurora.Colorize(":", p.jsonPalette.Delimiter)) 321 | 322 | if err := p.printJSON(buf, depth+1); err != nil { 323 | return err 324 | } 325 | 326 | if d, ok := buf.peek().(json.Delim); ok && d == '}' { 327 | // we're finished 328 | buf.token() 329 | break 330 | } 331 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize(",", p.jsonPalette.Delimiter)) 332 | } 333 | 334 | p.breakLine(depth) 335 | fmt.Fprintf(p.writer, "%s", p.aurora.Colorize("}", p.jsonPalette.Delimiter)) 336 | return nil 337 | } 338 | 339 | func (p *PrettyPrinter) breakLine(depth int) { 340 | fmt.Fprintf(p.writer, "\n%s", strings.Repeat(" ", depth*p.indentWidth)) 341 | } 342 | 343 | func (p *PrettyPrinter) PrintDownload(length int64, filename string) error { 344 | fmt.Fprintf(p.writer, "Downloading %sB to \"%s\"\n", bytefmt.ByteSize(uint64(length)), filename) 345 | return nil 346 | } 347 | -------------------------------------------------------------------------------- /output/pretty_test.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func parseURL(t *testing.T, rawurl string) *url.URL { 11 | u, err := url.Parse(rawurl) 12 | if err != nil { 13 | t.Fatalf("failed to parse URL: url=%s, err=%s", u, err) 14 | } 15 | return u 16 | } 17 | 18 | func TestPrettyPrinter_PrintStatusLine(t *testing.T) { 19 | // Setup 20 | var buffer strings.Builder 21 | printer := NewPrettyPrinter(PrettyPrinterConfig{ 22 | Writer: &buffer, 23 | EnableColor: false, 24 | }) 25 | response := &http.Response{ 26 | Status: "200 OK", 27 | StatusCode: 200, 28 | Proto: "HTTP/1.1", 29 | } 30 | 31 | // Exercise 32 | err := printer.PrintStatusLine(response.Proto, response.Status, response.StatusCode) 33 | if err != nil { 34 | t.Fatalf("unexpected error: err=%+v", err) 35 | } 36 | 37 | // Verify 38 | expected := "HTTP/1.1 200 OK\n" 39 | if buffer.String() != expected { 40 | t.Errorf("unexpected output: expected=%s, actual=%s", expected, buffer.String()) 41 | } 42 | } 43 | 44 | func TestPrettyPrinter_PrintRequestLine(t *testing.T) { 45 | // Setup 46 | var buffer strings.Builder 47 | printer := NewPrettyPrinter(PrettyPrinterConfig{ 48 | Writer: &buffer, 49 | EnableColor: false, 50 | }) 51 | request := &http.Request{ 52 | Method: "GET", 53 | URL: parseURL(t, "http://example.com/hello?foo=bar&hoge=piyo"), 54 | Proto: "HTTP/1.1", 55 | } 56 | 57 | // Exercise 58 | err := printer.PrintRequestLine(request) 59 | if err != nil { 60 | t.Fatalf("unexpected error: err=%+v", err) 61 | } 62 | 63 | // Verify 64 | expected := "GET http://example.com/hello?foo=bar&hoge=piyo HTTP/1.1\n" 65 | if buffer.String() != expected { 66 | t.Errorf("unexpected output: expected=%s, actual=%s", expected, buffer.String()) 67 | } 68 | } 69 | 70 | func TestPrettyPrinter_PrintHeader(t *testing.T) { 71 | // Setup 72 | var buffer strings.Builder 73 | printer := NewPrettyPrinter(PrettyPrinterConfig{ 74 | Writer: &buffer, 75 | EnableColor: false, 76 | }) 77 | header := http.Header{ 78 | "Content-Type": []string{"application/json"}, 79 | "X-Foo": []string{"hello", "world", "aaa"}, 80 | "Date": []string{"Tue, 12 Feb 2019 16:01:54 GMT"}, 81 | } 82 | 83 | // Exercise 84 | err := printer.PrintHeader(header) 85 | if err != nil { 86 | t.Fatalf("unexpected error: err=%+v", err) 87 | } 88 | 89 | // Verify 90 | expected := strings.Join([]string{ 91 | "Content-Type: application/json\n", 92 | "Date: Tue, 12 Feb 2019 16:01:54 GMT\n", 93 | "X-Foo: hello\n", 94 | "X-Foo: world\n", 95 | "X-Foo: aaa\n", 96 | "\n", 97 | }, "") 98 | if buffer.String() != expected { 99 | t.Errorf("unexpected output: expected=\n%s\n (len=%d)\nactual=\n%s\n (len=%d)", 100 | expected, len(expected), buffer.String(), len(buffer.String())) 101 | } 102 | } 103 | 104 | func TestPrettyPrinter_PrintBody(t *testing.T) { 105 | testCases := []struct { 106 | title string 107 | body string 108 | expected string 109 | }{ 110 | { 111 | title: "Normal JSON", 112 | body: `{"zzz": "hello \u26a1", "aaa": [3.14, true, false, "🍺"], "123": {}, "": [], "🍣": null}`, 113 | expected: strings.Join([]string{ 114 | `{`, 115 | ` "zzz": "hello ⚡",`, // unicode escapes should be converted to the characters they represent 116 | ` "aaa": [`, 117 | ` 3.14,`, 118 | ` true,`, 119 | ` false,`, 120 | ` "🍺"`, 121 | ` ],`, 122 | ` "123": {},`, 123 | ` "": [],`, 124 | ` "🍣": null`, 125 | "}\n", 126 | }, "\n"), 127 | }, 128 | { 129 | title: "Escaped", 130 | body: `{"\"": "aaa\nbbb"}`, 131 | expected: strings.Join([]string{ 132 | `{`, 133 | ` "\"": "aaa\nbbb"`, 134 | "}\n", 135 | }, "\n"), 136 | }, 137 | { 138 | title: "Body is empty", 139 | body: "", 140 | expected: "", 141 | }, 142 | { 143 | title: "Body contains only whitespaces", 144 | body: " \n", 145 | expected: " \n", 146 | }, 147 | { 148 | title: "Not a JSON 1", 149 | body: "xyz", 150 | expected: "xyz", 151 | }, 152 | { 153 | title: "Not a JSON 2", 154 | body: `[100 200]`, 155 | expected: `[100 200]`, 156 | }, 157 | { 158 | title: "Malformed JSON 1", 159 | body: `{`, 160 | expected: "{\n \n", 161 | }, 162 | { 163 | title: "Malformed JSON 2", 164 | body: `[`, 165 | expected: "[\n \n", 166 | }, 167 | { 168 | title: "Malformed JSON 3", 169 | body: `[1`, 170 | expected: "[\n 1,\n \n", 171 | }, 172 | { 173 | title: "Malformed JSON 4", 174 | body: `{"hello": "world"`, 175 | expected: strings.Join([]string{ 176 | `{`, 177 | ` "hello": "world",`, 178 | ` `, 179 | ``, 180 | }, "\n"), 181 | }, 182 | } 183 | 184 | for _, tt := range testCases { 185 | t.Run(tt.title, func(t *testing.T) { 186 | // Setup 187 | var buffer strings.Builder 188 | printer := NewPrettyPrinter(PrettyPrinterConfig{ 189 | Writer: &buffer, 190 | EnableColor: false, 191 | }) 192 | 193 | // Exercise 194 | err := printer.PrintBody(strings.NewReader(tt.body), "application/json") 195 | if err != nil { 196 | t.Fatalf("unexpected error: err=%+v", err) 197 | } 198 | 199 | // Verify 200 | if buffer.String() != tt.expected { 201 | t.Errorf("unexpected output: expected=\n%s\nactual=\n%s\n", tt.expected, buffer.String()) 202 | } 203 | }) 204 | } 205 | } 206 | 207 | func TestPrettyPrinter_DetectJSON(t *testing.T) { 208 | if !isJSON("application/json") { 209 | t.Errorf("didn't detect application/json as JSON") 210 | } 211 | 212 | // See https://tools.ietf.org/html/rfc7807 213 | if !isJSON("application/problem+json") { 214 | t.Errorf("didn't detect application/problem+json as JSON") 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /output/printer.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | ) 7 | 8 | type Printer interface { 9 | PrintStatusLine(proto string, status string, statusCode int) error 10 | PrintRequestLine(request *http.Request) error 11 | PrintHeader(header http.Header) error 12 | PrintBody(body io.Reader, contentType string) error 13 | PrintDownload(length int64, filename string) error 14 | } 15 | 16 | func NewPrinter(w io.Writer, options *Options) Printer { 17 | if options.EnableFormat { 18 | return NewPrettyPrinter(PrettyPrinterConfig{ 19 | Writer: w, 20 | EnableColor: options.EnableColor, 21 | }) 22 | } else { 23 | return NewPlainPrinter(w) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /version/license.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | type License struct { 9 | ModuleName string 10 | LicenseName string 11 | Link string 12 | } 13 | 14 | var Licenses = []License{ 15 | { 16 | ModuleName: "httpie-go", 17 | LicenseName: "MIT License", 18 | Link: "https://github.com/nojima/httpie-go/blob/master/LICENSE", 19 | }, 20 | { 21 | ModuleName: "Go", 22 | LicenseName: "BSD License", 23 | Link: "https://golang.org/LICENSE", 24 | }, 25 | { 26 | ModuleName: "aurora", 27 | LicenseName: "WTFPL", 28 | Link: "https://github.com/logrusorgru/aurora/blob/master/LICENSE", 29 | }, 30 | { 31 | ModuleName: "go-isatty", 32 | LicenseName: "MIT License", 33 | Link: "https://github.com/mattn/go-isatty/blob/master/LICENSE", 34 | }, 35 | { 36 | ModuleName: "getopt", 37 | LicenseName: "BSD License", 38 | Link: "https://github.com/pborman/getopt/blob/master/LICENSE", 39 | }, 40 | { 41 | ModuleName: "errors", 42 | LicenseName: "BSD License", 43 | Link: "https://github.com/pkg/errors/blob/master/LICENSE", 44 | }, 45 | { 46 | ModuleName: "bytefmt", 47 | LicenseName: "Apache License", 48 | Link: "https://github.com/cloudfoundry/bytefmt/blob/master/LICENSE", 49 | }, 50 | { 51 | ModuleName: "ewma", 52 | LicenseName: "MIT License", 53 | Link: "https://github.com/VividCortex/ewma/blob/master/LICENSE", 54 | }, 55 | { 56 | ModuleName: "stripansi", 57 | LicenseName: "MIT License", 58 | Link: "https://github.com/acarl005/stripansi/blob/master/LICENSE", 59 | }, 60 | { 61 | ModuleName: "mpb", 62 | LicenseName: "Unlicense", 63 | Link: "https://github.com/vbauerster/mpb/blob/master/UNLICENSE", 64 | }, 65 | } 66 | 67 | func PrintLicenses(w io.Writer) { 68 | for _, license := range Licenses { 69 | fmt.Fprintf(w, "%s:\n %s\n %s\n\n", 70 | license.ModuleName, 71 | license.LicenseName, 72 | license.Link, 73 | ) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "fmt" 4 | 5 | // Version represents a version of httpie-go 6 | type Version struct { 7 | major int 8 | minor int 9 | patch int 10 | } 11 | 12 | func (v *Version) String() string { 13 | return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch) 14 | } 15 | 16 | // Current returns current version of httpie-go 17 | func Current() *Version { 18 | return &Version{major: 0, minor: 8, patch: 0} 19 | } 20 | --------------------------------------------------------------------------------