├── .gitignore ├── Makefile ├── README.mkd └── http-gonsole.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.[68] 2 | http-gonsole 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include $(GOROOT)/src/Make.inc 2 | 3 | TARG = http-gonsole 4 | GOFILES= http-gonsole.go 5 | 6 | include $(GOROOT)/src/Make.cmd 7 | -------------------------------------------------------------------------------- /README.mkd: -------------------------------------------------------------------------------- 1 | http-gonsole 2 | ============ 3 | 4 | This is the Go port of the [http-console](http://github.com/cloudhead/http-console). 5 | 6 | > Speak HTTP like a local 7 | 8 | Talking to an HTTP server with `curl` can be fun, but most of the time it's a `PITA`. 9 | 10 | `http-gonsole` is a simple and intuitive interface for speaking the HTTP protocol. 11 | 12 | *PS: HTTP has never been this much fun.* 13 | 14 | 15 | Prerequisite 16 | ------------ 17 | 18 | You'll need the to install [go](http://golang.org), http-gonsole is tested with release.r57.1. 19 | 20 | How to use 21 | ---------- 22 | 23 | Let's assume we have a [CouchDB](http://couchdb.apache.org) instance running locally. 24 | 25 | ### connecting # 26 | 27 | To connect, we run `http-gonsole`, passing it the server host and port as such: 28 | 29 | $ http-gonsole 127.0.0.1:5984 30 | 31 | ### navigating # 32 | 33 | Once connected, we should see the *http prompt*: 34 | 35 | http://127.0.0.1:5984/> 36 | 37 | server navigation is similar to directory navigation, except a little simpler: 38 | 39 | http://127.0.0.1:5984/> /logs 40 | http://127.0.0.1:5984/logs> /46 41 | http://127.0.0.1:5984/logs/46> .. 42 | http://127.0.0.1:5984/logs> .. 43 | http://127.0.0.1:5984/> 44 | 45 | ### requesting # 46 | 47 | HTTP requests are issued with the HTTP verbs *GET*, *PUT*, *POST*, *HEAD* and *DELETE*, and 48 | a relative path: 49 | 50 | http://127.0.0.1:5984/> GET / 51 | HTTP/1.1 200 OK 52 | Date: Mon, 31 May 2010 04:43:39 GMT 53 | Content-Length: 41 54 | 55 | { 56 | couchdb: "Welcome", 57 | version: "0.11.0" 58 | } 59 | 60 | http://127.0.0.1:5984/> GET /bob 61 | HTTP/1.1 404 Not Found 62 | Date: Mon, 31 May 2010 04:45:32 GMT 63 | Content-Length: 44 64 | 65 | { 66 | error: "not_found", 67 | reason: "no_db_file" 68 | } 69 | 70 | When issuing *POST* and *PUT* commands, we have the opportunity to send data too: 71 | 72 | http://127.0.0.1:5984/> /rabbits 73 | http://127.0.0.1:5984/rabbits> POST 74 | ... {"name":"Roger"} 75 | 76 | HTTP/1.1 201 Created 77 | Location: http://127.0.0.1/rabbits/2fd9db055885e6982462a10e54003127 78 | Date: Mon, 31 May 2010 05:09:15 GMT 79 | Content-Length: 95 80 | 81 | { 82 | ok: true, 83 | id: "2fd9db055885e6982462a10e54003127", 84 | rev: "1-0c3db91854f26486d1c3922f1a651d86" 85 | } 86 | 87 | Make sure you have your `Content-Type` header set properly, if the API requires it. More 88 | in the section below. 89 | 90 | > Note that if you're trying to POST to a form handler, you'll most probably want to send data 91 | in `multipart/form-data` format, such as `name=roger&hair=black`. http-gonsole sends your POST/PUT data *as is*, 92 | so make sure you've got the format right, and the appropriate `Content-Type` header. 93 | 94 | ### setting headers # 95 | 96 | Sometimes, it's useful to set HTTP headers: 97 | 98 | http://127.0.0.1:5984/> Accept: application/json 99 | http://127.0.0.1:5984/> X-Lodge: black 100 | 101 | These headers are sent with all requests in this session. To see all active headers, 102 | run the `\headers` or `\h` command: 103 | 104 | http://127.0.0.1:5984/> \headers 105 | Accept: application/json 106 | X-Lodge: black 107 | 108 | Removing headers is just as easy: 109 | 110 | http://127.0.0.1:5984/> Accept: 111 | http://127.0.0.1:5984/> \h 112 | X-Lodge: black 113 | 114 | ### cookies # 115 | 116 | You can enable cookie tracking with the `--cookies` option flag. 117 | To see what cookies are stored, use the `\cookies` or `\c` command. 118 | 119 | ### SSL # 120 | 121 | To enable SSL, pass the `--ssl` flag, or specify the address with `https`. 122 | 123 | ### quitting # 124 | 125 | http://127.0.0.1:5984/> \q 126 | 127 | or, 128 | 129 | http://127.0.0.1:5984/> ^D 130 | 131 | nuff' said. 132 | 133 | License 134 | ------- 135 | 136 | BSD License 137 | -------------------------------------------------------------------------------- /http-gonsole.go: -------------------------------------------------------------------------------- 1 | // Speak HTTP like a local -- a simple, intuitive HTTP console 2 | // This is a port of http://github.com/cloudhead/http-console 3 | 4 | package main 5 | 6 | import ( 7 | "bufio" 8 | "bytes" 9 | "crypto/tls" 10 | "encoding/base64" 11 | "flag" 12 | "fmt" 13 | "github.com/mattn/go-colorable" 14 | "github.com/peterh/liner" 15 | "io" 16 | "io/ioutil" 17 | "net" 18 | "net/http" 19 | "net/http/httputil" 20 | "net/url" 21 | "os" 22 | "path" 23 | "regexp" 24 | "runtime" 25 | "strconv" 26 | "strings" 27 | "time" 28 | ) 29 | 30 | var ( 31 | colors = flag.Bool("colors", true, "colorful output") 32 | useSSL = flag.Bool("ssl", false, "use SSL") 33 | useJSON = flag.Bool("json", false, "use JSON") 34 | rememberCookies = flag.Bool("cookies", false, "remember cookies") 35 | verbose = flag.Bool("v", false, "be verbose, print out the request in wire format before sending") 36 | out = colorable.NewColorableStdout() 37 | ) 38 | 39 | // Color scheme, ref: http://linuxgazette.net/issue65/padala.html 40 | const ( 41 | C_Prompt = "\x1b[90m" 42 | C_Header = "\x1b[1m" 43 | C_2xx = "\x1b[1;32m" 44 | C_3xx = "\x1b[1;36m" 45 | C_4xx = "\x1b[1;31m" 46 | C_5xx = "\x1b[1;37;41m" 47 | C_Reset = "\x1b[0m" 48 | ) 49 | 50 | func colorize(color, s string) string { 51 | return color + s + C_Reset 52 | } 53 | 54 | type myCloser struct { 55 | io.Reader 56 | } 57 | 58 | func (myCloser) Close() error { return nil } 59 | 60 | type Cookie struct { 61 | Items map[string]string 62 | path string 63 | expires time.Time 64 | domain string 65 | secure bool 66 | httpOnly bool 67 | } 68 | 69 | type Session struct { 70 | scheme string 71 | host string 72 | conn *httputil.ClientConn 73 | headers http.Header 74 | cookies *[]*Cookie 75 | path *string 76 | } 77 | 78 | func dial(host string) (conn *httputil.ClientConn) { 79 | var tcp net.Conn 80 | var err error 81 | fmt.Fprintf(os.Stderr, "http-gonsole: establishing a TCP connection ...\n") 82 | proxy := os.Getenv("HTTP_PROXY") 83 | if strings.Split(host, ":")[0] != "localhost" && len(proxy) > 0 { 84 | proxy_url, _ := url.Parse(proxy) 85 | tcp, err = net.Dial("tcp", proxy_url.Host) 86 | } else { 87 | tcp, err = net.Dial("tcp", host) 88 | } 89 | if err != nil { 90 | fmt.Fprintln(os.Stderr, "http-gonsole:", err) 91 | os.Exit(1) 92 | } 93 | if *useSSL { 94 | if len(proxy) > 0 { 95 | connReq := &http.Request{ 96 | Method: "CONNECT", 97 | URL: &url.URL{Path: host}, 98 | Host: host, 99 | Header: make(http.Header), 100 | } 101 | connReq.Write(tcp) 102 | resp, err := http.ReadResponse(bufio.NewReader(tcp), connReq) 103 | if resp.StatusCode != 200 { 104 | fmt.Fprintln(os.Stderr, "http-gonsole:", resp.Status) 105 | os.Exit(1) 106 | } 107 | if err != nil { 108 | fmt.Fprintln(os.Stderr, "http-gonsole:", err) 109 | os.Exit(1) 110 | } 111 | tcp = tls.Client(tcp, nil) 112 | conn = httputil.NewClientConn(tcp, nil) 113 | } else { 114 | tcp = tls.Client(tcp, nil) 115 | conn = httputil.NewClientConn(tcp, nil) 116 | } 117 | if err = tcp.(*tls.Conn).Handshake(); err != nil { 118 | fmt.Fprintln(os.Stderr, "http-gonsole:", err) 119 | os.Exit(1) 120 | } 121 | if err = tcp.(*tls.Conn).VerifyHostname(strings.Split(host, ":")[0]); err != nil { 122 | fmt.Fprintln(os.Stderr, "http-gonsole:", err) 123 | os.Exit(1) 124 | } 125 | } else { 126 | conn = httputil.NewClientConn(tcp, nil) 127 | } 128 | return 129 | } 130 | 131 | func (s Session) perform(method, uri, data string) { 132 | var req http.Request 133 | req.URL, _ = url.Parse(uri) 134 | req.Method = method 135 | req.Header = s.headers 136 | req.ContentLength = int64(len([]byte(data))) 137 | req.Body = myCloser{bytes.NewBufferString(data)} 138 | if *verbose { 139 | req.Write(os.Stderr) 140 | } 141 | retry := 0 142 | request: 143 | req.Body = myCloser{bytes.NewBufferString(data)} // recreate anew, in case of retry 144 | err := s.conn.Write(&req) 145 | if err != nil { 146 | if retry < 2 { 147 | if err == io.ErrUnexpectedEOF { 148 | // the underlying connection has been closed "gracefully" 149 | retry++ 150 | s.conn.Close() 151 | s.conn = dial(s.host) 152 | goto request 153 | } else if protoerr, ok := err.(*http.ProtocolError); ok && protoerr == httputil.ErrPersistEOF { 154 | // the connection has been closed in an HTTP keepalive sense 155 | retry++ 156 | s.conn.Close() 157 | s.conn = dial(s.host) 158 | goto request 159 | } 160 | } 161 | fmt.Fprintln(os.Stderr, "http-gonsole: could not send request:", err) 162 | os.Exit(1) 163 | } 164 | r, err := s.conn.Read(&req) 165 | if err != nil { 166 | if protoerr, ok := err.(*http.ProtocolError); ok && protoerr == httputil.ErrPersistEOF { 167 | // the remote requested that this be the last request serviced, 168 | // we proceed as the response is still valid 169 | defer s.conn.Close() 170 | defer func() { s.conn = dial(s.host) }() 171 | goto output 172 | } 173 | fmt.Fprintln(os.Stderr, "http-gonsole: could not read response:", err) 174 | os.Exit(1) 175 | } 176 | output: 177 | if len(data) > 0 { 178 | fmt.Println() 179 | } 180 | if r.StatusCode >= 500 { 181 | fmt.Fprintf(out, colorize(C_5xx, "%s %s\n"), r.Proto, r.Status) 182 | } else if r.StatusCode >= 400 { 183 | fmt.Fprintf(out, colorize(C_4xx, "%s %s\n"), r.Proto, r.Status) 184 | } else if r.StatusCode >= 300 { 185 | fmt.Fprintf(out, colorize(C_3xx, "%s %s\n"), r.Proto, r.Status) 186 | } else if r.StatusCode >= 200 { 187 | fmt.Fprintf(out, colorize(C_2xx, "%s %s\n"), r.Proto, r.Status) 188 | } 189 | if len(r.Header) > 0 { 190 | for key, arr := range r.Header { 191 | for _, val := range arr { 192 | fmt.Fprintf(out, colorize(C_Header, "%s: "), key) 193 | fmt.Println(val) 194 | } 195 | } 196 | fmt.Println() 197 | } 198 | if *rememberCookies { 199 | if cookies, found := r.Header["Set-Cookie"]; found { 200 | for _, h := range cookies { 201 | cookie := new(Cookie) 202 | cookie.Items = map[string]string{} 203 | re, _ := regexp.Compile("^[^=]+=[^;]+(; *(expires=[^;]+|path=[^;,]+|domain=[^;,]+|secure))*,?") 204 | rs := re.FindAllString(h, -1) 205 | for _, ss := range rs { 206 | m := strings.Split(ss, ";") 207 | for _, n := range m { 208 | t := strings.SplitN(n, "=", 2) 209 | if len(t) == 2 { 210 | t[0] = strings.Trim(t[0], " ") 211 | t[1] = strings.Trim(t[1], " ") 212 | switch t[0] { 213 | case "domain": 214 | cookie.domain = t[1] 215 | case "path": 216 | cookie.path = t[1] 217 | case "expires": 218 | tm, err := time.Parse("Fri, 02-Jan-2006 15:04:05 MST", t[1]) 219 | if err != nil { 220 | tm, err = time.Parse("Fri, 02-Jan-2006 15:04:05 -0700", t[1]) 221 | } 222 | cookie.expires = tm 223 | case "secure": 224 | cookie.secure = true 225 | case "HttpOnly": 226 | cookie.httpOnly = true 227 | default: 228 | cookie.Items[t[0]] = t[1] 229 | } 230 | } 231 | } 232 | } 233 | *s.cookies = append(*s.cookies, cookie) 234 | } 235 | } 236 | } 237 | h := r.Header.Get("Content-Length") 238 | if len(h) > 0 { 239 | n, _ := strconv.ParseInt(h, 10, 64) 240 | b := make([]byte, n) 241 | io.ReadFull(r.Body, b) 242 | fmt.Println(string(b)) 243 | } else if method != "HEAD" { 244 | b, _ := ioutil.ReadAll(r.Body) 245 | fmt.Println(string(b)) 246 | } else { 247 | // TODO: streaming? 248 | } 249 | } 250 | 251 | // Parse a single command and execute it. (REPL without the loop) 252 | // Return true when the quit command is given. 253 | func (s Session) repl() bool { 254 | var prompt string 255 | if runtime.GOOS == "windows" { 256 | prompt = fmt.Sprintf("%s://%s%s: ", s.scheme, s.host, *s.path) 257 | } else { 258 | prompt = fmt.Sprintf(colorize(C_Prompt, "%s://%s%s: "), s.scheme, s.host, *s.path) 259 | } 260 | var err error 261 | var line string 262 | ln := liner.NewLiner() 263 | defer ln.Close() 264 | for { 265 | line, err = ln.Prompt(prompt) 266 | if err != nil { 267 | fmt.Println() 268 | return true 269 | } 270 | line = strings.Trim(line, "\n") 271 | line = strings.Trim(line, "\r") 272 | if line != "" { 273 | break 274 | } 275 | } 276 | if match, _ := regexp.MatchString("^(/[^ \t]*)|(\\.\\.)$", line); match { 277 | if line == "/" || line == "//" { 278 | *s.path = "/" 279 | } else { 280 | *s.path = strings.Replace(path.Clean(path.Join(*s.path, line)), "\\", "/", -1) 281 | if len(line) > 1 && line[len(line)-1] == '/' { 282 | *s.path += "/" 283 | } 284 | } 285 | return false 286 | } 287 | re := regexp.MustCompile("^([a-zA-Z][a-zA-Z0-9\\-]+):(.*)") 288 | if match := re.FindStringSubmatch(line); match != nil { 289 | key := match[1] 290 | val := strings.TrimSpace(match[2]) 291 | if len(val) > 0 { 292 | s.headers.Set(key, val) 293 | } 294 | return false 295 | } 296 | re = regexp.MustCompile("^([A-Z]+)(.*)") 297 | if match := re.FindStringSubmatch(line); match != nil { 298 | method := match[1] 299 | p := strings.TrimSpace(match[2]) 300 | trailingSlash := (len(*s.path) > 1) && ((*s.path)[len(*s.path)-1] == '/') 301 | if len(p) == 0 { 302 | p = "/" 303 | } else { 304 | trailingSlash = p[len(p)-1] == '/' 305 | } 306 | p = strings.Replace(path.Clean(path.Join(*s.path, p)), "\\", "/", -1) 307 | if trailingSlash { 308 | p += "/" 309 | } 310 | data := "" 311 | if method == "POST" || method == "PUT" { 312 | prompt = colorize(C_Prompt, "...: ") 313 | line, err = ln.Prompt(prompt) 314 | if line == "" { 315 | return false 316 | } 317 | } 318 | ln.AppendHistory(line) 319 | s.perform(method, s.scheme+"://"+s.host+p, data) 320 | return false 321 | } 322 | if line == ".h" || line == ".headers" { 323 | for key, arr := range s.headers { 324 | for _, val := range arr { 325 | fmt.Println(key + ": " + val) 326 | } 327 | } 328 | return false 329 | } 330 | if line == ".c" || line == ".cookies" { 331 | for _, cookie := range *s.cookies { 332 | for key, val := range cookie.Items { 333 | fmt.Println(key + ": " + val) 334 | } 335 | } 336 | return false 337 | } 338 | if line == ".v" || line == ".verbose" { 339 | *verbose = !*verbose 340 | return false 341 | } 342 | if line == ".o" || line == ".options" { 343 | fmt.Printf("useSSL=%v, rememberCookies=%v, verbose=%v\n", *useSSL, *rememberCookies, *verbose) 344 | return false 345 | } 346 | if line == ".?" || line == ".help" { 347 | fmt.Println(".headers, .h show active request headers\n" + 348 | ".options, .o show options\n" + 349 | ".cookies, .c show client cookies\n" + 350 | ".help, .? display this message\n" + 351 | ".exit, .q, ^D exit console\n") 352 | return false 353 | } 354 | if line == ".q" || line == ".exit" { 355 | return true 356 | } 357 | fmt.Fprintln(os.Stderr, "unknown command:", line) 358 | return false 359 | } 360 | 361 | func main() { 362 | scheme := "http" 363 | host := "localhost:80" 364 | headers := make(http.Header) 365 | cookies := new([]*Cookie) 366 | p := "/" 367 | flag.Parse() 368 | if flag.NArg() > 0 { 369 | tmp := flag.Arg(0) 370 | if match, _ := regexp.MatchString("^[^:]+(:[0-9]+)?$", tmp); match { 371 | tmp = "http://" + tmp 372 | } 373 | targetURL, err := url.Parse(tmp) 374 | if err != nil { 375 | fmt.Fprintln(os.Stderr, "malformed URL") 376 | os.Exit(-1) 377 | } 378 | host = targetURL.Host 379 | if len(host) == 0 { 380 | fmt.Fprintln(os.Stderr, "invalid host name") 381 | os.Exit(-1) 382 | } 383 | if *useSSL || targetURL.Scheme == "https" { 384 | *useSSL = true 385 | scheme = "https" 386 | } 387 | if match, _ := regexp.MatchString("^[^:]+:[0-9]+$", host); !match { 388 | if *useSSL { 389 | host = host + ":443" 390 | } else { 391 | host = host + ":80" 392 | } 393 | } 394 | scheme = targetURL.Scheme 395 | if info := targetURL.User; info != nil { 396 | enc := base64.URLEncoding 397 | encoded := make([]byte, enc.EncodedLen(len(info.String()))) 398 | enc.Encode(encoded, []byte(info.String())) 399 | headers.Set("Authorization", "Basic "+string(encoded)) 400 | } 401 | p = strings.Replace(path.Clean(targetURL.Path), "\\", "/", -1) 402 | if p == "." { 403 | p = "/" 404 | } 405 | } else if *useSSL { 406 | scheme = "https" 407 | host = "localhost:443" 408 | } 409 | headers.Set("Host", host) 410 | session := &Session{ 411 | scheme: scheme, 412 | host: host, 413 | conn: dial(host), 414 | headers: headers, 415 | cookies: cookies, 416 | path: &p, 417 | } 418 | 419 | if *useJSON { 420 | headers.Set("Accept", "*/*") 421 | headers.Set("Content-Type", "appliaction/json") 422 | } 423 | 424 | defer session.conn.Close() 425 | done := false 426 | for !done { 427 | done = session.repl() 428 | } 429 | } 430 | --------------------------------------------------------------------------------