├── .gitignore ├── LICENSE ├── README.md ├── httpsh ├── httpshell ├── __init__.py ├── ansicolors.py ├── formatters.py ├── http.py ├── httpshell.py ├── loggers.py └── version.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.cache 2 | *.pyc 3 | *.sublime-project 4 | *.sublime-workspace 5 | .DS_Store 6 | build/ 7 | dist/ 8 | httpshell.egg-info/ 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Chris Longo (chris.longo@gmail.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Info 2 | 3 | An interactive shell for issuing HTTP commands to a web server or REST API. 4 | 5 | ![A picture of httpsh in action](http://i.imgur.com/bDQha.png) 6 | 7 | Issue HTTP commands (HEAD, GET, POST, PUT, DELETE, OPTIONS, TRACE) to a server 8 | with interactive feedback. Makes debugging and testing REST services and 9 | public APIs much easier than with cURL. 10 | 11 | # Usage 12 | 13 | ### Treats the server like a filesystem 14 | 15 | ``` 16 | $ httpsh http://api.twitter.com/1/statuses 17 | api.twitter.com:/1/statuses> get public_timeline.json 18 | Connecting to http://api.twitter.com/a/statuses/publc_timeline.json 19 | 20 | HTTP/1.1 200 OK 21 | >content-length: 40945 22 | >vary: Accept-Encoding 23 | >x-transaction-mask: a6183ffa5f8ca943ff1b53b5644ef1140f40ebd7 24 | ... 25 | ``` 26 | 27 | ### Use familiar shell commands for navigation 28 | 29 | ``` 30 | api.twitter.com:/1/statuses> cd .. 31 | api.twitter.com:/1/> cd / 32 | api.twitter.com:/> 33 | ``` 34 | 35 | ### Use relative or absolute paths just like bash 36 | 37 | ``` 38 | api.twitter.com:/1/statuses> get /1/users/suggestions.json 39 | Connecting to http://api.twitter.com/1/users/suggestions.json 40 | 41 | HTTP/1.1 200 OK 42 | ... 43 | api.twitter.com:/1/statuses> get public_timeline.json 44 | Connecting to http://api.twitter.com/1/statuses/public_timeline.json 45 | 46 | HTTP/1.1 200 OK 47 | ... 48 | ``` 49 | 50 | ### Pipe output to external commands for formatting, validation, etc. 51 | 52 | ``` 53 | api.twitter.com:/1/statuses> get public_timeline.xml | xmllint --format - 54 | ... 55 | 56 | 57 | 58 | Wed Dec 14 00:57:12 +0000 2011 59 | ... 60 | ``` 61 | 62 | ### Easily post data to a server/service 63 | 64 | MongoDB example: 65 | 66 | ``` 67 | localhost:28017:/> post /foo/bar 68 | ... { "a" : 123456 } 69 | ... 70 | HTTP/1.0 201 71 | >content-type: text/plain;charset=utf-8 72 | >connection: close 73 | >x-ns: foo._defaultCollection 74 | >content-length: 15 75 | >x-action: bar 76 | 77 | { "ok" : true } 78 | ``` 79 | 80 | ### Use JSON to post to standard web forms using special ```@{}``` notation! 81 | 82 | Post to standard web forms by using JSON notation prefaced with 83 | the "@" character: 84 | 85 | ``` 86 | example.com:/> headers Content-Type:application/x-www-form-urlencoded 87 | example.com::/> post /some/form/handler 88 | ... @{ 89 | ... "name": "Chris", 90 | ... "occupation": "Developer" 91 | ... } 92 | ``` 93 | 94 | Converts the JSON definition above to: `name=Chris&occupation=Developer` for 95 | form posting. 96 | 97 | ### Syntax highlighting 98 | 99 | Syntax highlighting of response data for many formats (JSON, XML, HTML, 100 | Javascript, etc). 101 | 102 | ![Syntax hilighting](http://i.imgur.com/DxB9P.jpg) 103 | 104 | ### Auto-format responses 105 | 106 | The ```--format``` command-line parameter will tell httpsh to automatically 107 | format any JSON or XML response returned by a server. 108 | 109 | ``` 110 | chris@macbookpro:/$ httpsh http://localhost:8888 --format 111 | localhost:8888:/> GET /movies/tt0118715 112 | Connecting to http://localhost:8888/movies/tt0118715 113 | 114 | HTTP/1.0 200 OK 115 | date: Wed, 21 Dec 2011 02:32:18 GMT 118 | >content-type: application/json 119 | >server: RESTless/1.2 120 | 121 | { 122 | "Title": "The Big Lebowski", 123 | "Rating": 9.5 124 | } 125 | ``` 126 | 127 | ### Set headers 128 | 129 | ``` 130 | localhost:28017:/> headers Cookie:session=5cb9586618eea2374377bb1584f7de74 131 | localhost:28017:/> headers User-Agent:AppleWebKit/535.13 132 | localhost:28017:/> headers 133 | headers User-Agent: 141 | localhost:28017:/> headers 142 | cookies api_key=8e7d1367cb1b466df014ceb2ad1b0202 148 | ``` 149 | 150 | ``` 151 | www.google.com:/> cookies 152 | Name: PREF 153 | Value: ID=871a0e9212108e48:FF=0:TM=132244474:LM=132121074:S=oVVVAx3_LCZsaPaa 154 | Expires: Fri, 20-Dec-2013 02:37:54 GMT 155 | Domain: .google.com, 156 | Path: / 157 | ``` 158 | 159 | ### Tack on query parameters. 160 | 161 | If you're using an API that requires a key tacked on every URL, rather than 162 | typing it every time set a "tackon" and it will be sent automatically: 163 | 164 | ``` 165 | graph.facebook.com:/> tackons access_token=AAACEcEase0c... 166 | graph.facebook.com:/> tackons 167 | access_token=AAACEcEase0c... 168 | 169 | graph.facebook.com:/> get /me 170 | Connecting to https://graph.facebook.com/me?access_token=AAACEcEase0c... 171 | 172 | HTTP/1.1 200 OK 173 | ... 174 | ``` 175 | 176 | Works for POSTs too: 177 | 178 | ``` 179 | graph.facebook.com:/> post /me/feed 180 | ... @{ 181 | ... "message": "Posting from HttpShell", 182 | ... "picture": "http://i.imgur.com/3RPIS.png", 183 | ... "link": "https://github.com/chrislongo/HttpShell" 184 | ... } 185 | ... 186 | Connecting to https://graph.facebook.com/me/feed?access_token=AAACEcEase0c... 187 | 188 | HTTP/1.1 200 OK 189 | ... 190 | 191 | {"id":"100001681000101_24221026521205"} 192 | ``` 193 | 194 | ### OAuth 195 | 196 | Will automatically sign requests to APIs that use [OAuth](http://oauth.net/). 197 | 198 | [See the wiki for examples](https://github.com/chrislongo/HttpShell/wiki/OAuth-How-To) 199 | 200 | 201 | ### Supports SSL 202 | 203 | ``` 204 | $ httpsh https://www.google.com 205 | www.google.com:/> head 206 | Connecting to https://www.google.com/ 207 | 208 | HTTP/1.1 200 OK 209 | ... 210 | ``` 211 | 212 | # Help 213 | Command line help: 214 | 215 | ``` 216 | usage: httpsh [-h] [-f] [-c] [-i] [--version] URL 217 | 218 | An interactive shell for issuing HTTP commands to a web server or REST API 219 | 220 | positional arguments: 221 | URL url to connect to 222 | 223 | optional arguments: 224 | -h, --help show this help message and exit 225 | -f, --format Attempt to automatically format known mimetypes (JSON or 226 | XML) 227 | -c, --no-cookies Do not respect cookies sent by host 228 | -i, --no-headers Suppress the display of headers 229 | --version show program's version number and exit 230 | ``` 231 | 232 | In-app help: 233 | 234 | ``` 235 | Verbs 236 | head [] 237 | get [] [| ] 238 | post [] [| ] 239 | put [] [| ] 240 | delete [| ] 241 | options [] [| ] 242 | trace [] [| ] 243 | Navigation 244 | cd or .. 245 | open 246 | Metacommands 247 | headers []:[] 248 | tackons []=[] 249 | cookies []=[] 250 | debuglevel [#] 251 | quit 252 | ``` 253 | 254 | # Installation 255 | 256 | $ python setup.py install 257 | 258 | Or if pip is installed: 259 | 260 | $ pip install httpshell 261 | 262 | May require sudo to install! 263 | 264 | -------------------------------------------------------------------------------- /httpsh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import argparse 5 | from httpshell import httpshell 6 | from httpshell import version 7 | 8 | 9 | def parse_command_line(): 10 | parser = argparse.ArgumentParser( 11 | description="An interactive shell for issuing HTTP commands to a web server or REST API") 12 | 13 | parser.add_argument( 14 | "url", 15 | metavar="URL", 16 | help="url to connect to") 17 | 18 | parser.add_argument( 19 | "-f", "--format", 20 | action="store_true", 21 | default=False, 22 | dest="auto_format", 23 | help="Attempt to automatically format known mimetypes (JSON or XML)") 24 | 25 | parser.add_argument( 26 | "-c", "--no-cookies", 27 | action="store_true", 28 | default=False, 29 | dest="disable_cookies", 30 | help="Do not respect cookies sent by host") 31 | 32 | parser.add_argument( 33 | "-i", "--no-headers", 34 | action="store_false", 35 | default=True, 36 | dest="show_headers", 37 | help="Suppress the display of headers") 38 | 39 | parser.add_argument( 40 | "--version", 41 | action="version", 42 | version="{0} {1}".format("%(prog)s", version.VERSION)) 43 | 44 | return parser.parse_args() 45 | 46 | 47 | def main(): 48 | args = parse_command_line() 49 | shell = httpshell.HttpShell(args) 50 | shell.input_loop() 51 | 52 | 53 | if __name__ == "__main__": 54 | main() 55 | -------------------------------------------------------------------------------- /httpshell/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrislongo/HttpShell/ff43dbd03c8f3e2fd4f2ed411857202cb35ff638/httpshell/__init__.py -------------------------------------------------------------------------------- /httpshell/ansicolors.py: -------------------------------------------------------------------------------- 1 | # bare bones ANSI color support 2 | 3 | 4 | class Color(object): 5 | GREY = 30 6 | RED = 31 7 | GREEN = 32 8 | YELLOW = 33 9 | BLUE = 34 10 | MAGENTA = 35 11 | CYAN = 36 12 | WHITE = 37 13 | 14 | 15 | class Attribute(object): 16 | NORMAL = 0 17 | BRIGHT = 1 18 | 19 | 20 | def colorize(text, color, attribute=Attribute.NORMAL): 21 | escape = "\033[" 22 | reset = escape + "0m" 23 | 24 | return "{0}{1};{2}m{3}{4}".format( 25 | escape, 26 | attribute, 27 | color, 28 | text, 29 | reset) 30 | -------------------------------------------------------------------------------- /httpshell/formatters.py: -------------------------------------------------------------------------------- 1 | import json 2 | import xml.dom.minidom 3 | from StringIO import StringIO 4 | 5 | 6 | class Formatter(object): 7 | def __init__(self, args=None): 8 | self.args = args 9 | 10 | def format(text): 11 | pass 12 | 13 | 14 | class JsonFormatter(Formatter): 15 | def __init__(self, args=None): 16 | super(JsonFormatter, self).__init__(args) 17 | 18 | def format(self, text): 19 | formatted = None 20 | 21 | try: 22 | o = json.loads(text) 23 | formatted = json.dumps(o, indent=2) 24 | except (TypeError, ValueError): 25 | formatted = text 26 | 27 | return formatted 28 | 29 | 30 | # under Python <= 2.7.2 the minidom output is gnarly, should be fixed in 2.7.3+ 31 | # http://bugs.python.org/issue4147 32 | class XmlFormatter(Formatter): 33 | def __init__(self, args=None): 34 | super(XmlFormatter, self).__init__(args) 35 | 36 | # for the time being this big time workaround will do it 37 | def format_xml(self, node, writer, indent="", addindent="", newl=""): 38 | # minidom likes to treat whitepace as text nodes 39 | if node.nodeType == xml.dom.minidom.Node.TEXT_NODE and node.data.strip() == "": 40 | return 41 | 42 | writer.write(indent + "<" + node.tagName) 43 | 44 | attrs = node.attributes 45 | keys = sorted(attrs.keys()) 46 | 47 | for key in keys: 48 | writer.write(" %s=\"" % key) 49 | writer.write(attrs[key].value) 50 | writer.write("\"") 51 | 52 | if node.childNodes: 53 | writer.write(">") 54 | 55 | if all(map(lambda n: n.nodeType == xml.dom.minidom.Node.TEXT_NODE, 56 | node.childNodes)): 57 | for child in node.childNodes: 58 | child.writexml(writer, "", "", "") 59 | else: 60 | writer.write(newl) 61 | for child in node.childNodes: 62 | self.format_xml(child, writer, indent + addindent, 63 | addindent, newl) 64 | writer.write(indent) 65 | writer.write("%s" % (node.tagName, newl)) 66 | else: 67 | writer.write("/>%s" % (newl)) 68 | 69 | def format(self, text): 70 | formatted = None 71 | 72 | try: 73 | x = xml.dom.minidom.parseString(text) 74 | writer = StringIO() 75 | self.format_xml(x.childNodes[0], writer, addindent=" ", newl="\n") 76 | formatted = writer.getvalue() 77 | x.unlink() 78 | except: 79 | formatted = text 80 | 81 | return formatted 82 | 83 | 84 | JSONTYPES = ( 85 | 'application/json', 86 | 'application/x-javascript', 87 | 'text/javascript', 88 | 'text/x-javascript', 89 | 'text/x-json') 90 | 91 | XMLTYPES = ( 92 | 'application/xml', 93 | 'application/atom+xml', 94 | 'application/mathml+xml', 95 | 'application/rss+xml', 96 | 'application/xhtml+xml', 97 | 'text/xml') 98 | 99 | 100 | def format_by_mimetype(text, mimetype): 101 | formatter = None 102 | 103 | if mimetype in JSONTYPES: 104 | formatter = JsonFormatter() 105 | elif mimetype in XMLTYPES: 106 | formatter = XmlFormatter() 107 | 108 | if formatter: 109 | return formatter.format(text) 110 | else: 111 | return text 112 | -------------------------------------------------------------------------------- /httpshell/http.py: -------------------------------------------------------------------------------- 1 | import Cookie 2 | import formatters 3 | import httplib2 4 | import json 5 | import oauth2 as oauth 6 | import os 7 | import subprocess 8 | import version 9 | 10 | 11 | class Http(object): 12 | def __init__(self, args, logger, verb): 13 | self.args = args 14 | self.logger = logger 15 | self.verb = verb 16 | 17 | def run(self, url, path, pipe=None, headers=None, cookies=None, body=""): 18 | self.url = url 19 | host = self.url.netloc 20 | 21 | httpclient = self.init_httpclient() 22 | httpclient.follow_redirects = False 23 | httplib2.debuglevel = self.args.debuglevel 24 | 25 | # check for authentication credentials 26 | if "@" in host: 27 | split = host.split("@") 28 | if len(split) > 1: 29 | host = split[1] 30 | creds = split[0].split(":") 31 | httpclient.add_credentials(creds[0], creds[1]) 32 | else: 33 | host = split[0] 34 | 35 | uri = "{0}://{1}{2}".format(self.url.scheme, host, path) 36 | 37 | if not self.args.disable_cookies: 38 | self.set_request_cookies(cookies, headers) 39 | 40 | if not "host" in headers: 41 | headers["host"] = host 42 | if not "accept-encoding" in headers: 43 | headers["accept-encoding"] = "gzip, deflate" 44 | if not "user-agent" in headers: 45 | headers["user-agent"] = "httpsh/" + version.VERSION 46 | 47 | self.logger.print_text("Connecting to " + uri) 48 | 49 | response, content = httpclient.request( 50 | uri, method=self.verb, body=body, headers=headers) 51 | 52 | self.handle_response(response, content, headers, cookies, pipe) 53 | 54 | def init_httpclient(self): 55 | http = None 56 | 57 | keysfile = os.path.join(os.path.expanduser("~"), 58 | ".httpshell", self.url.netloc + ".json") 59 | 60 | if os.path.isfile(keysfile): 61 | try: 62 | with open(keysfile, "r") as file: 63 | keys = json.load(file) 64 | token = None 65 | 66 | consumer = oauth.Consumer(keys["consumer"]["consumer-key"], 67 | keys["consumer"]["consumer-secret"]) 68 | if "access" in keys: 69 | token = oauth.Token(keys["access"]["access-token"], 70 | keys["access"]["access-token-secret"]) 71 | 72 | http = oauth.Client(consumer, token) 73 | self.logger.print_text("Using OAuth config in " + keysfile) 74 | except: 75 | self.logger.print_error( 76 | "Failed reading OAuth data from: " + keysfile) 77 | else: 78 | http = httplib2.Http() 79 | 80 | return http 81 | 82 | def set_request_cookies(self, cookies, headers): 83 | if self.url.netloc in cookies: 84 | l = [] 85 | cookie = cookies[self.url.netloc] 86 | # very basic cookie support atm. no expiry, etc. 87 | for morsel in cookie.values(): 88 | l.append(morsel.key + "=" + morsel.coded_value) 89 | headers["cookie"] = "; ".join(l) 90 | 91 | def handle_response(self, response, content, headers, cookies, pipe=None): 92 | self.logger.print_response_code(response) 93 | if self.args.show_headers: 94 | self.logger.print_headers(headers.items(), True) 95 | self.logger.print_headers(response.items()) 96 | 97 | if not self.args.disable_cookies: 98 | self.store_response_cookies(response, cookies) 99 | 100 | if self.args.auto_format: 101 | mimetype = response["content-type"] 102 | 103 | if mimetype: 104 | content = formatters.format_by_mimetype( 105 | content, mimetype.split(";")[0]) 106 | 107 | if pipe: 108 | content = self.pipe_data(pipe, content) 109 | 110 | self.logger.print_data(content) 111 | 112 | def store_response_cookies(self, response, cookies): 113 | if "set-cookie" in response: 114 | header = response["set-cookie"] 115 | cookie = Cookie.SimpleCookie(header) 116 | cookies[self.url.netloc] = cookie 117 | 118 | # pipes output to external commands like xmllint, tidy for filtering 119 | def pipe_data(self, command, data): 120 | result = None 121 | 122 | p = subprocess.Popen(command, shell=True, bufsize=-1, 123 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, 124 | stdin=subprocess.PIPE) 125 | output, error = p.communicate(data) 126 | 127 | if error: 128 | self.logger.print_text() 129 | self.logger.print_error(error.decode("utf-8")) 130 | else: 131 | result = output.decode("utf-8") 132 | 133 | return result 134 | -------------------------------------------------------------------------------- /httpshell/httpshell.py: -------------------------------------------------------------------------------- 1 | import http 2 | import json 3 | import loggers 4 | import os 5 | import re 6 | import readline 7 | import sys 8 | import Cookie 9 | from urlparse import urlparse 10 | from urllib import urlencode 11 | 12 | 13 | class HttpShell(object): 14 | def __init__(self, args): 15 | self.http_commands = { 16 | "head": self.head, 17 | "get": self.get, 18 | "post": self.post, 19 | "put": self.put, 20 | "delete": self.delete, 21 | "trace": self.trace, 22 | "options": self.options, 23 | "cd": self.set_path, 24 | } 25 | 26 | self.meta_commands = { 27 | "help": self.help, 28 | "?": self.help, 29 | "headers": self.modify_headers, 30 | "tackons": self.modify_tackons, 31 | "cookies": self.modify_cookies, 32 | "open": self.open_host, 33 | "debuglevel": self.set_debuglevel, 34 | "quit": self.exit 35 | } 36 | 37 | # dispatch map is http + meta maps 38 | self.dispatch = dict( 39 | self.http_commands.items() + self.meta_commands.items()) 40 | 41 | self.url = None 42 | self.path = None 43 | self.query = None 44 | 45 | self.args = args 46 | self.headers = {} 47 | self.tackons = {} 48 | self.cookies = {} 49 | 50 | self.args.debuglevel = 0 51 | 52 | # all printing is done via the logger, that way a non-ANSI printer 53 | # will be a lot easier to add retroactively 54 | self.logger = loggers.AnsiLogger() 55 | 56 | self.init_readline() 57 | 58 | # setup host and initial path 59 | self.init_host(self.args.url) 60 | 61 | def init_readline(self): 62 | httpsh_dir = os.path.join(os.path.expanduser("~"), ".httpshell/") 63 | if not os.path.isdir(httpsh_dir): 64 | os.mkdir(httpsh_dir) 65 | 66 | self.history_file = os.path.join(httpsh_dir, ".history") 67 | 68 | try: 69 | readline.read_history_file(self.history_file) 70 | except IOError: 71 | pass 72 | 73 | # sets up tab command completion 74 | readline.set_completer(self.complete) 75 | readline.parse_and_bind("tab: complete") 76 | 77 | def init_host(self, url): 78 | # url parse needs a proceeding "//" for default scheme param to work 79 | if not "//" in url[:8]: 80 | url = "//" + url 81 | 82 | self.url = urlparse(url, "http") 83 | 84 | if not self.url.netloc: 85 | self.logger.print_error("Invalid URL") 86 | self.exit() 87 | 88 | self.path = self.url.path if self.url.path else "/" 89 | self.query = self.url.query 90 | 91 | # dispatch methods 92 | 93 | def head(self, path, pipe=None): 94 | http.Http(self.args, self.logger, "HEAD").run( 95 | self.url, path, pipe, self.headers, self.cookies) 96 | 97 | def get(self, path, pipe=None): 98 | http.Http(self.args, self.logger, "GET").run( 99 | self.url, path, pipe, self.headers, self.cookies) 100 | 101 | def post(self, path, pipe=None): 102 | body = self.input_body() 103 | 104 | if body: 105 | http.Http(self.args, self.logger, "POST").run( 106 | self.url, path, pipe, self.headers, self.cookies, body) 107 | 108 | def put(self, path, pipe=None): 109 | body = self.input_body() 110 | 111 | if body: 112 | http.Http(self.args, self.logger, "PUT").run( 113 | self.url, path, pipe, self.headers, self.cookies, body) 114 | 115 | def delete(self, path, pipe=None): 116 | http.Http(self.args, self.logger, "DELETE").run( 117 | self.url, path, pipe, self.headers, self.cookies) 118 | 119 | def trace(self, path, pipe=None): 120 | http.Http(self.args, self.logger, "TRACE").run( 121 | self.url, path, pipe, self.headers, self.cookies) 122 | 123 | def options(self, path, pipe=None): 124 | http.Http(self.args, self.logger, "OPTIONS").run( 125 | self.url, path, pipe, self.headers, self.cookies) 126 | 127 | def help(self): 128 | self.logger.print_help() 129 | 130 | # handles .headers meta-command 131 | def modify_headers(self, header=None): 132 | if header and len(header) > 0: 133 | # header will be header:[value] 134 | a = header.split(":", 1) 135 | key = a[0] 136 | if len(a) > 1: 137 | value = a[1] 138 | 139 | if len(value) > 0: 140 | self.headers[key] = value 141 | elif key in self.headers: 142 | del self.headers[key] # if no value provided, delete 143 | else: 144 | self.logger.print_error("Invalid syntax.") 145 | else: 146 | # print send headers 147 | self.logger.print_headers(self.headers.items(), sending=True) 148 | 149 | # handles params meta-command 150 | def modify_tackons(self, args=None): 151 | if args and len(args) > 0: 152 | # args will be param=[value] 153 | 154 | if not "=" in args: # it's not foo=bar it's just foo 155 | self.tackons[args] = "" 156 | else: 157 | a = args.split("=", 1) 158 | key = a[0] 159 | 160 | if len(a) > 1: 161 | value = a[1] 162 | 163 | if len(value) > 0: 164 | self.tackons[key] = value 165 | elif key in self.tackons: 166 | del self.tackons[key] # if no value provided, delete 167 | else: 168 | # print send tackons 169 | self.logger.print_tackons(self.tackons.items()) 170 | 171 | def modify_cookies(self, args=None): 172 | if args and len(args) > 0: 173 | # args will be cookie=[value] 174 | 175 | cookie = None 176 | 177 | if not self.url.netloc in self.cookies: 178 | cookie = Cookie.SimpleCookie() 179 | self.cookies[self.url.netloc] = cookie 180 | else: 181 | cookie = self.cookies[self.url.netloc] 182 | 183 | if args and len(args) > 0: 184 | # cookie will be cookie=[value] 185 | a = args.split("=", 1) 186 | key = a[0] 187 | if len(a) > 1: 188 | value = a[1] 189 | 190 | if len(value) > 0: 191 | cookie[key] = value 192 | else: 193 | for morsel in cookie.values(): 194 | if morsel.key == key: 195 | del cookie[morsel.key] 196 | else: 197 | self.logger.print_error("Invalid syntax.") 198 | elif self.url.netloc in self.cookies: 199 | self.logger.print_cookies(self.cookies[self.url.netloc]) 200 | 201 | # changes the current host 202 | def open_host(self, url=None): 203 | if url: 204 | self.init_host(url) 205 | 206 | # handles cd command 207 | def set_path(self, path): 208 | path = path.split("?")[0] # chop off any query params 209 | 210 | if path == "..": 211 | path = "".join(self.path.rsplit("/", 1)[:1]) 212 | 213 | self.path = path if path else "/" 214 | 215 | def set_debuglevel(self, level=None): 216 | if not level: 217 | self.logger.print_text(str(self.args.debuglevel)) 218 | else: 219 | try: 220 | self.args.debuglevel = int(level) 221 | except: 222 | pass 223 | 224 | # converts tackon dict to query params 225 | def dict_to_query(self, map): 226 | l = [] 227 | for k, v in sorted(map.items()): 228 | s = k 229 | if(v): 230 | s += "=" + str(v) 231 | l.append(s) 232 | 233 | return "&".join(l) 234 | 235 | # combine two query strings into one 236 | def combine_queries(self, a, b): 237 | s = "" 238 | if a and len(a) > 0: 239 | s = a 240 | if b and len(b) > 0: 241 | s += "&" 242 | if b and len(b) > 0: 243 | s += b 244 | 245 | return s 246 | 247 | # modifies the path for tackon query params 248 | def mod_path(self, path, query=None): 249 | q = self.combine_queries( 250 | query, self.dict_to_query(self.tackons)) 251 | 252 | if len(q) > 0: 253 | return path + "?" + q 254 | else: 255 | return path 256 | 257 | # readline complete handler 258 | def complete(self, text, state): 259 | match = [s for s in self.dispatch.keys() if s 260 | and s.startswith(text)] + [None] 261 | 262 | return match[state] 263 | 264 | # read lines of input for POST/PUT 265 | def input_body(self): 266 | list = [] 267 | 268 | while True: 269 | line = raw_input("... ") 270 | if len(line) == 0: 271 | break 272 | list.append(line) 273 | 274 | # join list to form string 275 | params = "".join(list) 276 | 277 | if params[:2] == "@{": # magic JSON -> urlencode invoke char 278 | params = self.json_to_urlencode(params[1:]) 279 | 280 | return params 281 | 282 | # converts JSON to url encoded for easier posting forms 283 | def json_to_urlencode(self, json_string): 284 | params = None 285 | 286 | try: 287 | o = json.loads(json_string) 288 | params = urlencode(o) 289 | except ValueError: 290 | self.logger.print_error("Malformed JSON.") 291 | 292 | return params 293 | 294 | @property 295 | def prompt(self): 296 | host = None 297 | 298 | if "@" in self.url.netloc: # hide password in prompt 299 | split = re.split("@|:", self.url.netloc) 300 | host = split[0] + "@" + split[-1] 301 | else: 302 | host = self.url.netloc 303 | 304 | return "{0}:{1}> ".format(host, self.path) 305 | 306 | def input_loop(self): 307 | command = None 308 | 309 | while True: 310 | try: 311 | # a valid command line will be [path] [| filter] 312 | input = raw_input(self.prompt).split() 313 | 314 | # ignore blank input 315 | if not input or len(input) == 0: 316 | continue 317 | 318 | # command will be element 0 in the array from split 319 | command = input.pop(0).lower() 320 | 321 | if command in self.dispatch: 322 | # push arguments to the stack for command 323 | args = self.parse_args(input, command) 324 | 325 | # invoke command via dispatch table 326 | try: 327 | self.dispatch[command](*args) 328 | except Exception as e: 329 | self.logger.print_error("Error: {0}".format(e)) 330 | else: 331 | self.logger.print_error("Invalid command.") 332 | except (EOFError, KeyboardInterrupt): 333 | break 334 | 335 | print 336 | self.exit() 337 | 338 | # parses input to set up the call stack for dispatch commands 339 | def parse_args(self, args, command): 340 | stack = [] 341 | 342 | # ignore meta-commands 343 | if command not in self.meta_commands: 344 | path = None 345 | pipe = None 346 | 347 | if len(args) > 0: 348 | # element 0 of args array will be the path element 349 | path = args.pop(0) 350 | 351 | # there's a pipe in my path! 352 | # user didn't use whitespace between path and pipe character 353 | # also accounts for if the user did not supply a path 354 | if "|" in path: 355 | s = path.split("|", 1) 356 | path = s.pop(0) 357 | args.insert(0, "".join(s)) 358 | 359 | # pipe, if exists, will be first element in array now 360 | if len(args) > 0: 361 | pipe = " ".join(args).strip() 362 | 363 | if pipe[0] == "|": 364 | pipe = pipe[1:] 365 | 366 | # account for requests from relative dirs 367 | if path and not path[0] in "/.": 368 | path = "{0}{1}{2}".format( 369 | self.path, 370 | "/" if self.path[-1] != "/" else "", 371 | path) 372 | 373 | # push the path on the stack for command method 374 | # if it's empty the user did not supply one so use self.path 375 | if path: 376 | query = None 377 | a = path.split("?") # chop query params 378 | 379 | if len(a) > 1: 380 | path = a[0] 381 | query = a[1] 382 | stack.append(self.mod_path(path, query)) 383 | else: 384 | stack.append(self.mod_path(self.path, self.query)) 385 | 386 | if pipe: 387 | stack.append(pipe) 388 | else: 389 | if len(args) > 0: 390 | # meta-commands to their own arg parsing 391 | stack.append(" ".join(args)) 392 | 393 | return stack 394 | 395 | def exit(self, args=None): 396 | readline.write_history_file(self.history_file) 397 | sys.exit(0) 398 | -------------------------------------------------------------------------------- /httpshell/loggers.py: -------------------------------------------------------------------------------- 1 | from ansicolors import colorize 2 | from ansicolors import Color 3 | from ansicolors import Attribute 4 | from pygments import highlight 5 | from pygments.formatters import TerminalFormatter 6 | from pygments.lexers import guess_lexer 7 | 8 | 9 | # ANSI color terminal logger 10 | # use color sparingly or the UI looks like a bowl of fruit loops 11 | class AnsiLogger(object): 12 | def print_text(self, text=None): 13 | if text: 14 | print text 15 | else: 16 | print 17 | 18 | def print_response_code(self, response): 19 | colors = [Color.GREY, Color.GREEN, Color.YELLOW, Color.RED, Color.RED] 20 | print "HTTP/{0} {1} {2}".format( 21 | response.version / 10.0, 22 | response.status, 23 | colorize(response.reason, colors[response.status / 100 - 1], 24 | Attribute.BRIGHT)) 25 | 26 | def print_headers(self, headers, sending=False): 27 | for header in headers: 28 | print "{0}{1}: {2}".format( 29 | colorize("<" if sending else ">", Color.WHITE), 30 | colorize(header[0], Color.BLUE, Attribute.BRIGHT), 31 | header[1]) 32 | 33 | def print_tackons(self, params): 34 | for param in params: 35 | print "{0}{1}{2}".format( 36 | colorize(param[0], Color.BLUE, Attribute.BRIGHT), 37 | "=" if len(param[1]) > 0 else "", 38 | param[1]) 39 | 40 | def print_cookies(self, cookie): 41 | for morsel in cookie.values(): 42 | print colorize("Name:", Color.BLUE), morsel.key 43 | print colorize("Value:", Color.BLUE), morsel.value 44 | print colorize("Expires:", Color.BLUE), morsel["expires"] 45 | print colorize("Domain:", Color.BLUE), morsel["domain"] 46 | print colorize("Path:", Color.BLUE), morsel["path"] 47 | print 48 | 49 | def print_data(self, data): 50 | if data: 51 | print 52 | print highlight(data, 53 | guess_lexer(data), 54 | TerminalFormatter()) 55 | 56 | def print_help(self): 57 | print "Verbs" 58 | print " head", colorize("[]", Color.GREY) 59 | print " get", colorize("[] [| ]", Color.GREY) 60 | print " post", colorize("[] [| ]", Color.GREY) 61 | print " put", colorize("[] [| ]", Color.GREY) 62 | print " delete", colorize("", Color.GREY, Attribute.BRIGHT), colorize(" [| ]", Color.GREY) 63 | print " options", colorize("[] [| ]", Color.GREY) 64 | print " trace", colorize("[] [| ]", Color.GREY) 65 | print "Navigation" 66 | print " cd", colorize(" or ..", Color.GREY, Attribute.BRIGHT) 67 | print " open", colorize("", Color.GREY, Attribute.BRIGHT) 68 | print "Metacommands" 69 | print " headers", colorize("[]:[]", Color.GREY) 70 | print " tackons", colorize("[]=[]", Color.GREY) 71 | print " cookies", colorize("[]=[]", Color.GREY) 72 | print " debuglevel", colorize("[#]", Color.GREY) 73 | print " quit" 74 | print 75 | print "Full documentation available at https://github.com/chrislongo/HttpShell#readme" 76 | 77 | def print_error(self, text): 78 | print text 79 | -------------------------------------------------------------------------------- /httpshell/version.py: -------------------------------------------------------------------------------- 1 | VERSION = "0.8.0" 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from httpshell import version 3 | 4 | try: 5 | from ez_setup import use_setuptools 6 | use_setuptools() 7 | except ImportError: 8 | pass 9 | 10 | try: 11 | from setuptools import setup 12 | except ImportError: 13 | from distutils.core import setup 14 | 15 | REQUIRES = ["pygments>=1.1.1", "httplib2>=0.7.0", "oauth2>=1.5"] 16 | 17 | if sys.version_info <= (2, 7): 18 | REQUIRES.append("argparse>=1.2.1") 19 | 20 | 21 | setup( 22 | name="httpshell", 23 | version=version.VERSION, 24 | packages=["httpshell"], 25 | install_requires=REQUIRES, 26 | py_modules=["ez_setup"], 27 | scripts=["httpsh"], 28 | author="Chris Longo", 29 | author_email="chris.longo@gmail.com", 30 | url="https://github.com/chrislongo/HttpShell/", 31 | download_url="http://github.com/downloads/chrislongo/HttpShell/httpshell-%s.tar.gz" % version.VERSION, 32 | description="An interactive shell for issuing HTTP commands to a web server or REST API", 33 | classifiers=[ 34 | "Development Status :: 5 - Production/Stable", 35 | "Intended Audience :: Developers", 36 | "License :: OSI Approved :: MIT License", 37 | "Natural Language :: English", 38 | "Operating System :: OS Independent", 39 | 'Programming Language :: Python :: 2.4', 40 | 'Programming Language :: Python :: 2.5', 41 | "Programming Language :: Python :: 2.6", 42 | "Programming Language :: Python :: 2.7", 43 | "Topic :: System :: Networking" 44 | ] 45 | ) 46 | --------------------------------------------------------------------------------