From b9be4f1f3c2d30b698a00fc3d5cdf7cf5e73631f Mon Sep 17 00:00:00 2001 From: Shrikant Sharat Kandula Date: Wed, 20 Mar 2019 14:02:56 +0000 Subject: [PATCH] Initial commit. Fairly working implementation. --- .gitignore | 1 + README.md | 7 ++ autoload/roast.vim | 18 ++++ doc/roast.txt | 209 ++++++++++++++++++++++++++++++++++++++++++ ftdetect/roast.vim | 1 + ftplugin/roast.vim | 3 + python3/roast.py | 120 ++++++++++++++++++++++++ python3/roast_api.py | 137 +++++++++++++++++++++++++++ python3/test_roast.py | 71 ++++++++++++++ syntax/roast.vim | 49 ++++++++++ 10 files changed, 616 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 autoload/roast.vim create mode 100644 doc/roast.txt create mode 100644 ftdetect/roast.vim create mode 100644 ftplugin/roast.vim create mode 100644 python3/roast.py create mode 100644 python3/roast_api.py create mode 100644 python3/test_roast.py create mode 100644 syntax/roast.vim diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..90d3f33 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# roast.vim + +An http client as a ViM editor plugin. Utilizes the `+python3` feature. Not tested on Neovim. Known to work on ViM 8.1. + +## Run tests + +Go to the python3 directory and run `pytest .`, with Python 3.6 at least. diff --git a/autoload/roast.vim b/autoload/roast.vim new file mode 100644 index 0000000..b3a788e --- /dev/null +++ b/autoload/roast.vim @@ -0,0 +1,18 @@ +aug roast_response_maps + au! + au BufNewFile __roast_* nnoremap :py3 roast.next_render() + au BufNewFile __roast_* nnoremap :py3 roast.prev_render() +aug END + +if (&bg ==? 'light') + highlight default RoastCurrentSuccess guibg=#E7F4D2 gui=bold + highlight default RoastCurrentFailure guibg=#F4DFD2 gui=bold +else + highlight default RoastCurrentSuccess guibg=#005A66 gui=bold +endif + +py3 import roast + +fun! roast#run() + py3 roast.run() +endfun \ No newline at end of file diff --git a/doc/roast.txt b/doc/roast.txt new file mode 100644 index 0000000..2bf78af --- /dev/null +++ b/doc/roast.txt @@ -0,0 +1,209 @@ +*roast.txt* An HTTP client for ViM + +Author: Shrikant Kandula +Repo: https://github.com/sharat87/roast.vim +License: See LICENSE file. + +USAGE *roast* + +Open (or create) a file with the extension of `.http` or `.roast` and the roast +plugin will be active in that buffer. Now put the following text in that file +and hit the key while the cursor is on that line: +> + GET http://httpbin.org/get answer=42 +< +The output of this `GET` request should show up in a new vertically split +window. The line itself should highlight in a shade of green. If the request +was a failure, you'd see that the line would highlight in a shade red. + + +FEATURES OVERVIEW *roast-features* + +- Syntax roughly similar to HTTP protocol requests. +- Headers apply to all requests below that header definition line. +- A single session is used for all requests from a buffer. That is, cookies are + preserved across requests from a single buffer. +- Simple string variables support that can be used for interpolation. +- Variable interpolation is implemented with Python's `.format` syntax. +- Variable interpolation works in request paths, query params, header values, + request bodies, other variable definitions. +- Each line is parsed with Python's `shlex` module. That is, comments start with + `#`, quotes should be used to specify strings with special characters such as + spaces. + +A good place to find working examples would be the `python3/test_roast.py` +module where several of the above features are tested. + + +HEADERS *roast-headers* + +The syntax for setting headers is similar to how they appear in an actual HTTP +request. +> + Accept: application/json +< +This line will ensure that this header is set to all requests that are below +this line. For example, consider the following file: +> + GET /url/path/1 + Accept: application/json + GET /url/path/2 +< +If you place the cursor on the request for `/url/path/1` and hit , the +`Accept` header is not sent. If, however, you place the cursor on the request +for `/url/path/2` and hit , the header is sent. The header is sent for +any and all requests below `/url/path/2` as well. + +The header can be given a different value somewhere down and that will be +effective from that point on in the file. + +To remove the header, set the header with no value. Like: +> + Accept: +< +No `Accept` header will be sent on the following requests. + +TODO: Write about the special handling of `Host` header. This header won't show +up in the headers view. + + +REQUEST QUERY PARAMS + +Query parameters can be given as part of the URL in the usual fashion, +> + GET http://httpbin.org/get?key1=value1&key2=value2 +< +Note that the URL is processed with python's `.format` with |roast-variables| +so variable interpolation can be used here. + +But it is usually more convenient to use spaces that roast.vim allows instead +of the `&` and `?` marks. The above can be written as: +> + GET http://httpbin.org/get key1=value1 key2=value2 +< +In this form, only the values support interpolation. For example, +> + set answer 42 + GET http://httpbin.org/get text=number_{answer} +< +This sends a request to the following URL: +> + http://httpbin.org/get?text=number_42 +< +There's also a shortcut for the following common use case: +> + set answer 42 + GET http://httpbin.org/get answer={answer} +< +This can be written as: +> + set answer 42 + GET http://httpbin.org/get answer +< +Both send request to the following URL: +> + http://httpbin.org/get?answer=42 +< + + +REQUEST BODY *roast-post-body* + +The body for POST or other requests can be specified using heredoc-like syntax. +This is best illustrated with an example: +> + POST http://httpbin.org/post << body + here goes the post body + this content is passed to the POST request as-is. + body +< +The heredoc marker is `body` here. It can be any alphanumeric string. + + +USING VARIABLES *roast-variables* + +Variables allow for interpolation within braces and this lets us create nice +little DSL's made up of HTTP APIs. + +The syntax to set a variable is as following: +> + set name value +< +If the value contains spaces or special characters, it can be surrounded by +quotes, similar to escaping semantics of a shell. Note the variable +interpolations are allowed in the value part of this syntax. + +Variable interpolations are powered by Python's `str.format` method. If you're +not familiar with that method, don't worry, here's what it looks like: +> + set first_name Elijah + set last_name Baley + set full_name {first_name}_{last_name} +< +The variable `full_name` will be set to `Elijah_Baley`. + +Variable interpolations work in header values, URL's, query parameter values and +other variable declarations. A variable can be set to a different value with the +same file. Only the closest `set` above the current line will be taken into +consideration. + +A neat shortcut regarding interpolation in query params is that it is possible +to use params already set for interpolation in later params, with the name +prefixed with the `@` symbol. That doesn't make sense, I know, but an example +will click right away. Here it is: +> + GET https://httpbin.org/get first=Jon last=Snow full={@first}_{@last} +< +Get it now? :) + + +VIEWING RESPONSE *roast-viewing-response* + +When a request is run (by hitting the key, by default), a new window is +opened by splitting vertically, unless a window is already displaying a roast +result. + +This window will show the response of the query with json syntax highlighting if +the appropriate response header is present. + +To view other details of the request/response, go to the window displaying a the +result and hit or to cycle between several renderers of the response +data. + +There is a provision for custom renderers, but it's not mature enough to be +documented and encouraged in the wild. Coming soon. + + +TIPS *roast-tips* + +1. Switch the host header between various staging servers of your app. + +For example, if we have http://dev.staging.example.com/ as our dev server and +http://qa.staging.example.com/ as our QA server, we can set the host in our +roast file in the first line like: +> + Host: http://dev.staging.example.com/ +< +With this, we can have a hotkey to switch this host between dev and QA hosts. +This can be achieved with something like the following (to be put in +~/.vim/after/ftplugin/roast.vim): + +> +py3 import vim +nnoremap d :py3 vim.current.buffer[0] = 'Host: http://dev.staging.example.com/' +nnoremap q :py3 vim.current.buffer[0] = 'Host: http://qa.staging.example.com/' +< + +Of course, this is very basic, it assumes the Host header is on the first line or +overwrites that line when the hotkey is hit. + + +More tips coming soon! + + +ABOUT *roast-about* + +Roast plugin is developed by sharat87. Contributions welcome. If you notice +a bug or have an idea for a feature request, please open an issue on GitHub. + + + vim:ft=help diff --git a/ftdetect/roast.vim b/ftdetect/roast.vim new file mode 100644 index 0000000..4a23e6b --- /dev/null +++ b/ftdetect/roast.vim @@ -0,0 +1 @@ +au BufRead,BufNewFile *.roast,*.http setf roast diff --git a/ftplugin/roast.vim b/ftplugin/roast.vim new file mode 100644 index 0000000..763a20c --- /dev/null +++ b/ftplugin/roast.vim @@ -0,0 +1,3 @@ +setl commentstring=#\ %s + +nnoremap :call roast#run() diff --git a/python3/roast.py b/python3/roast.py new file mode 100644 index 0000000..39d4990 --- /dev/null +++ b/python3/roast.py @@ -0,0 +1,120 @@ +""" +The implementation module for roast.vim plugin. This module does most of the heavy lifting for the functionality +provided by the plugin. + +Example: Put the following in a `api.roast` file and hit `` on it. + + GET http://httpbin.org/get name=value + +Inspiration / Ideas: + https://github.com/Huachao/vscode-restclient + https://github.com/baverman/vial-http +""" + +from collections import defaultdict + +import requests +import vim + +import roast_api + + +sessions = defaultdict(requests.Session) + +renderers = [ + 'pretty', + 'headers', +] + + +def run(): + request = roast_api.build_request(vim.current.buffer, vim.current.range.end) + + try: + response = sessions[vim.current.buffer.number].send(request.prepare()) + except requests.ConnectionError as e: + vim.current.buffer.vars['_roast_error'] = repr(e) + vim.command(f"echoerr b:_roast_error") + else: + show_response(response) + highlight_line_text('RoastCurrentSuccess' if response.ok else 'RoastCurrentFailure') + + +def show_response(response: requests.Response): + # A window holding a roast buffer, to be used as a workspace for setting up all roast buffers. + workspace_window = workspace_renderer = None + for window in vim.windows: + if '_roast_renderer' in window.buffer.vars: + workspace_window = window + workspace_renderer = window.buffer.vars['_roast_renderer'].decode() + break + + # Switch to workspace window. + prev_window = vim.current.window + + for renderer in renderers: + buf_name = f'__roast_{renderer}__' + num = bufnr(buf_name) + if num < 0: + if workspace_window is not None: + vim.current.window = workspace_window + vim.command(f'keepalt edit {buf_name} | setl bt=nofile bh=hide noswf nornu') + num = bufnr(buf_name) + else: + vim.command(f'keepalt vnew {buf_name} | setl bt=nofile bh=hide noswf nornu') + num = bufnr(buf_name) + vim.current.window = workspace_window = vim.windows[int(vim.eval(f'bufwinnr({num})')) - 1] + else: + if workspace_window is not None: + vim.current.window = workspace_window + vim.command(f'keepalt {num}buffer') + else: + vim.command(f'keepalt vertical {num}sbuffer') + vim.current.window = workspace_window = vim.windows[int(vim.eval(f'bufwinnr({num})')) - 1] + + buf = vim.buffers[num] + buf[:] = None + + buf.vars['_roast_renderer'] = renderer + actions = getattr(roast_api, f'render_{renderer}')(buf, response) + apply_actions(buf, actions) + + if vim.current.window is workspace_window: + vim.command(f'keepalt buffer __roast_{workspace_renderer or renderers[0]}__') + + vim.current.window = prev_window + + +def highlight_line_text(group): + match_id = int(vim.current.buffer.vars.get('_roast_match_id', 0)) + + if match_id: + try: + vim.eval(f'matchdelete({match_id})') + except vim.error: + # TODO: Only hide E803 error, which is thrown if this match_id has already been deleted. + pass + + vim.current.buffer.vars['_roast_match_id'] = vim.eval(f"matchadd('{group}', '\\V{vim.current.line}')") + + +def apply_actions(buf, actions): + if 'lines' in actions: + buf[:] = actions['lines'] + + if 'commands' in actions: + for cmd in actions['commands']: + vim.command(cmd) + + +def next_render(delta=1): + renderer = vim.current.buffer.vars['_roast_renderer'].decode() + vim.command('buffer __roast_' + renderers[(renderers.index(renderer) + delta) % len(renderers)] + '__') + + +def prev_render(): + next_render(-1) + + +def bufnr(name) -> int: + return int(vim.eval(f'bufnr("{name}")')) diff --git a/python3/roast_api.py b/python3/roast_api.py new file mode 100644 index 0000000..32dd383 --- /dev/null +++ b/python3/roast_api.py @@ -0,0 +1,137 @@ +""" +Core functionality for roast.vim plugin. This module is inteded to be testable outside vim, so MUST NOT import the `vim` +module. +""" + +import shlex +from itertools import takewhile +from typing import List, Dict, Optional +import json +import requests + + +def build_request(lines, line_num) -> requests.Request: + headers = {} + variables = {} + aliases = {} + templates = {} + current_template = None + + for line in lines[:line_num]: + stripped_line = line.strip() + if not stripped_line or stripped_line.startswith('#'): + continue + + is_indented = line.startswith(' ' * 4) + if not is_indented: + current_template = None + elif current_template: + current_template.append(line) + continue + + parts = tokenize(line) + if len(parts) < 2: + continue + + head, *rest = parts + + if head == 'set': + # Interpolations in variables are applied when the variable is defined. + variables[rest[0]] = ' '.join(rest[1:]).format(**variables) + + elif head == 'alias': + # Interpolations in aliases are applied when the alias is used. + aliases[rest[0]] = ' '.join(rest[1:]) + + elif head.endswith(':'): + key = head[:-1].lower() + if rest: + headers[key] = ' '.join(rest).format(**variables) + else: + del headers[key] + + elif head == 'template': + templates[rest[0]] = current_template = [] + + line = lines[line_num] + for alias, replacement in aliases.items(): + if line.startswith(alias + ' '): + line = line.replace(alias + ' ', replacement, 1) + break + + method, loc, *tokens = tokenize(line) + + heredoc = pop_heredoc(tokens) + body = '\n'.join(takewhile(lambda l: l != heredoc, lines[line_num + 1:])) if heredoc else None + + if 'host' in headers: + url = headers.pop('host').rstrip('/') + '/' + loc.lstrip('/').format(**variables) + else: + url = loc.format(**variables) + + params = build_params_dict(tokens, variables) + + return requests.Request(method, url, headers, params=params, data=body) + + +def pop_heredoc(tokens: List[str]) -> Optional[str]: + heredoc = None + if tokens and tokens[-1].startswith('<<'): + heredoc = tokens.pop().lstrip('<') + elif len(tokens) >= 2 and tokens[-2] == '<<': + heredoc = tokens.pop() + tokens.pop() + return heredoc + + +def build_params_dict(tokens: List[str], variables: Dict[str, str] = None) -> Dict[str, str]: + if variables is None: + variables = {} + + params = {} + for var in tokens: + if '=' in var: + name, value = var.split('=', 1) + value = value.format(**variables) + else: + name, value = var, variables[var] + params[name] = variables['@' + name] = value + + return params + + +def tokenize(text: str) -> List[str]: + return shlex.split(text, comments=True) + + +def render_pretty(buf, response): + actions = {'commands': ['call clearmatches()']} + content_type = response.headers['content-type'].split(';')[0] if 'content-type' in response.headers else None + if content_type == 'application/json': + try: + actions['lines'] = json.dumps(response.json(), ensure_ascii=False, indent=2).splitlines() + except json.JSONDecodeError: + actions['lines'] = response.text.splitlines() + actions['commands'].append('set filetype=txt') + actions['commands'].append('call matchaddpos("Error", range(1, line("$")))') + else: + actions['commands'].append('set filetype=json') + + else: + actions['lines'] = response.text.splitlines() + + return actions + + +def render_headers(buf, response): + lines = ['=== Response Headers ==='] + for key, value in response.headers.items(): + lines.append(f'{key}: {value}') + + lines.append('') + lines.append('') + lines.append('=== Request Headers ===') + for key, value in response.request.headers.items(): + lines.append(f'{key.title()}: {value}') + + return {'lines': lines} diff --git a/python3/test_roast.py b/python3/test_roast.py new file mode 100644 index 0000000..6301786 --- /dev/null +++ b/python3/test_roast.py @@ -0,0 +1,71 @@ +import roast_api as ra + + +def test_host_header(): + req = ra.build_request([ + 'Host: https://httpbin.org', + 'GET /get', + ], 1) + + assert req.method == 'GET' + assert 'host' not in req.headers + assert req.url == 'https://httpbin.org/get' + + +def test_header_collection(): + req = ra.build_request([ + 'Accept: application/json', + 'X-Custom-Header: nonsense', + 'GET https://httpbin.org/get', + ], 2) + + assert req.method == 'GET' + assert req.headers['x-custom-header'] == 'nonsense' + assert req.url == 'https://httpbin.org/get' + + +def test_post_body(): + req = ra.build_request([ + 'POST https://httpbin.org/post <