Permalink
Browse files

[captures] Add support for direct linking and pagination

This involves a schema change to the database to take better advantage
of database indexing.
  • Loading branch information...
lovett committed May 9, 2018
1 parent 9fe876b commit 34d7a4dfe89a104dc60789c36fe12182da292169
@@ -16,22 +16,29 @@
<form class="content-wrapper" method="get" action="/captures">
<div class="field">
<label for="number">Request URI</label>
<input type="text" id="query" name="query" value="{{ q|default('', true) }}"/>
<input type="text" id="path" name="path" value="{{ path|default('', true) }}"/>
</div>
<div class="field">
<button>Search</button>
</div>
</form>
<div class="content-wrapper">
{% if captures|length() == 0 %}
{% if not captures %}
<p>No captures found.</p>
{% endif %}
{% for capture in captures %}
<div class="capture">
<header>
<div>{{ capture.created|localtime("datetime12")}}</div>
<a href="/captures?cid={{ capture.rowid }}">
<svg class="icon icon-link"><use xlink:href="#icon-link"></use></svg>
</a>
<div>
#{{ capture.rowid}} {{ capture.created|localtime("datetime12")}}
</div>
</header>
<div class="half-column">
@@ -41,7 +48,7 @@
</div>
<h2>Request Headers</h2>
<code id="reqhead-{{ loop.index }}">
<code id="reqhead-{{ capture.rowid }}">
{#- The awkward indentation here prevents unwanted whitespace during copy-to-clipboard -#}
{{- capture.request_line }}
{% for key, value in capture.request.headers.items() -%}
@@ -53,21 +60,21 @@ <h2>Request Headers</h2>
{% if capture.request.params %}
<section>
<div class="options">
{{ macros.copyToClipboardBySelector("#reqbodyparam-" ~ loop.index) }}
{{ macros.copyToClipboardBySelector("#reqbodyparam-" ~ capture.rowid) }}
</div>
<h2>Request body</h2>
<code id="reqbodyparam-{{ loop.index }}">{{ capture.request.params }}</code>
<code id="reqbodyparam-{{ capture.rowid }}">{{ capture.request.params }}</code>
</section>
{% endif %}
{% if capture.request.json %}
<section>
<div class="options">
{{ macros.copyToClipboardBySelector("#reqbodyjson-" ~ loop.index) }}
{{ macros.copyToClipboardBySelector("#reqbodyjson-" ~ capture.rowid) }}
</div>
<h2>Request Body </h2>
<pre id="reqbodyjson-{{ loop.index }}">{{ capture.request.json|json }}</pre>
<pre id="reqbodyjson-{{ capture.rowid }}">{{ capture.request.json|json }}</pre>
</section>
{% endif %}
</div>
@@ -80,6 +87,24 @@ <h2>Response</h2>
</div>
</div>
{% endfor %}
{% if captures %}
<div class="pagination">
{% if newer_offset %}
<a href="/captures?offset={{ newer_offset }}" rel="noreferrer" class="next">
<svg class="icon icon-arrow-left"><use xlink:href="#icon-arrow-left"></use></svg>
Newer
</a>
{% endif %}
{% if older_offset %}
<a href="/captures?offset={{ older_offset }}" rel="noreferrer" class="previous">
Older
<svg class="icon icon-arrow-right"><use xlink:href="#icon-arrow-right"></use></svg>
</a>
{% endif %}
</div>
{% endif %}
</div>
</main>
@@ -9,21 +9,38 @@ class Controller:
name = "Captures"
@cherrypy.tools.negotiable()
def GET(self, query=None):
"""Display a list of recent captures, or captures matching a search
query.
def GET(self, path=None, cid=None, offset=0):
"""Display a list of recent captures, or captures matching a URI path.
"""
if query:
captures = cherrypy.engine.publish("capture:search", query).pop()
if cid:
captures = cherrypy.engine.publish(
"capture:get",
int(cid)
).pop()
older_offset = None
newer_offset = None
else:
captures = cherrypy.engine.publish("capture:recent").pop()
total, captures = cherrypy.engine.publish(
"capture:search",
path,
offset
).pop()
older_offset = len(captures) + offset
if older_offset >= total:
older_offset = None
newer_offset = offset - len(captures)
if newer_offset <= 0:
newer_offset = None
return {
"html": ("captures.html", {
"query": query,
"path": path,
"captures": captures,
"newer_offset": newer_offset,
"older_offset": older_offset,
"app_name": self.name
})
}
@@ -9,6 +9,15 @@
padding: .5em 1em;
}
.capture HEADER A {
float: right;
}
.capture HEADER svg {
fill: #fff;
}
.capture .half-column {
padding: 1em 1em 0 1em;
}
@@ -33,35 +33,20 @@ def test_allow(self):
@mock.patch("cherrypy.tools.negotiable._renderHtml")
@mock.patch("cherrypy.engine.publish")
def test_recent(self, publish_mock, render_mock):
"""The default view is a list of recent captures"""
def side_effect(*args, **_):
"""Side effects local function"""
if args[0] == "capture:recent":
return [[{}, {}, {}]]
return mock.DEFAULT
publish_mock.side_effect = side_effect
self.request("/")
self.assertEqual(len(helpers.html_var(render_mock, "captures")), 3)
@mock.patch("cherrypy.tools.negotiable._renderHtml")
@mock.patch("cherrypy.engine.publish")
def test_search(self, publish_mock, render_mock):
"""Captures can be searched"""
def test_search_by_path(self, publish_mock, render_mock):
"""Captures can be searched by path"""
def side_effect(*args, **_):
"""Side effects local function"""
if args[0] == "capture:search":
return [[{}]]
return [(1, [{}])]
return mock.DEFAULT
publish_mock.side_effect = side_effect
self.request("/", query="test")
self.request("/", path="test")
print(render_mock.call_args_list)
self.assertEqual(len(helpers.html_var(render_mock, "captures")), 1)
@@ -15,23 +15,18 @@ def __init__(self, bus):
self._create("""
CREATE TABLE IF NOT EXISTS captures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_line, request, response,
request_uri, request_line, request, response,
created DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS index_request_line
on captures(request_line);
CREATE INDEX IF NOT EXISTS index_request_uri
on captures(request_uri);
""")
def start(self):
self.bus.subscribe("capture:add", self.add)
self.bus.subscribe("capture:search", self.search)
self.bus.subscribe("capture:recent", self.recent)
def stop(self):
pass
self.bus.subscribe("capture:get", self.get)
def add(self, request, response):
"""Store a single HTTP request and response pair
@@ -53,37 +48,59 @@ def add(self, request, response):
"status": response.status
}, use_bin_type=True)
sql = """INSERT INTO captures
(request_line, request, response)
VALUES (?, ?, ?)"""
request_uri_parts = request.request_line.split(' ')
request_uri = " ".join(request_uri_parts[1:-1])
placeholder_values = (
request_uri,
request.request_line,
sqlite3.Binary(request_bin),
sqlite3.Binary(response_bin)
)
self._insert(sql, [placeholder_values])
self._insert("""INSERT INTO captures
(request_uri, request_line, request, response)
VALUES (?, ?, ?, ?)""", [placeholder_values])
return True
def search(self, search, limit=20):
sql = """SELECT id, request_line, request as 'request [binary]',
response as 'response [binary]', created as 'created [datetime]'
def search(self, path=None, offset=0, limit=20):
if path:
search_clause = "AND request_uri=?"
placeholders = (path, path, limit, offset)
else:
search_clause = ""
placeholders = (limit, offset)
# Annoyingly, the count query must use the same converters
# as the main query in spite of the null values.
sql = """SELECT 0 as rowid, count(*) as request_line,
null as 'request [binary]',
null as 'response [binary]', null as 'created [datetime]'
FROM captures WHERE 1=1 {search_clause}
UNION
SELECT rowid, request_line, request as 'request [binary]',
response as 'response [binary]',
created as 'created [datetime]'
FROM captures
WHERE request_line LIKE ?
ORDER BY created DESC
LIMIT ?"""
WHERE 1=1 {search_clause}
ORDER BY rowid DESC
LIMIT ? OFFSET ?""".format(search_clause=search_clause)
result = self._select(sql, placeholders)
search_sql = "%{}%".format(search)
# Because the query is ordered by rowid, the row with the
# count is last.
count = result[-1]["request_line"]
return self._select(sql, (search_sql, limit))
return (count, result[0:-1])
def recent(self, limit=50):
sql = """SELECT id, request_line, request as 'request [binary]',
response as 'response [binary]', created as 'created [datetime]'
def get(self, capture_id):
sql = """SELECT rowid, request_line, request as 'request [binary]',
response as 'response [binary]',
created as 'created [datetime]'
FROM captures
ORDER BY created DESC
LIMIT ?"""
WHERE rowid=?"""
return self._select(sql, (limit,))
return self._select(sql, (capture_id,))
@@ -29,6 +29,11 @@
<svg class="svg-defs" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<symbol id="icon-link" viewBox="0 0 32 32">
<title>link</title>
<path d="M13.757 19.868c-0.416 0-0.832-0.159-1.149-0.476-2.973-2.973-2.973-7.81 0-10.783l6-6c1.44-1.44 3.355-2.233 5.392-2.233s3.951 0.793 5.392 2.233c2.973 2.973 2.973 7.81 0 10.783l-2.743 2.743c-0.635 0.635-1.663 0.635-2.298 0s-0.635-1.663 0-2.298l2.743-2.743c1.706-1.706 1.706-4.481 0-6.187-0.826-0.826-1.925-1.281-3.094-1.281s-2.267 0.455-3.094 1.281l-6 6c-1.706 1.706-1.706 4.481 0 6.187 0.635 0.635 0.635 1.663 0 2.298-0.317 0.317-0.733 0.476-1.149 0.476z"></path>
<path d="M8 31.625c-2.037 0-3.952-0.793-5.392-2.233-2.973-2.973-2.973-7.81 0-10.783l2.743-2.743c0.635-0.635 1.664-0.635 2.298 0s0.635 1.663 0 2.298l-2.743 2.743c-1.706 1.706-1.706 4.481 0 6.187 0.826 0.826 1.925 1.281 3.094 1.281s2.267-0.455 3.094-1.281l6-6c1.706-1.706 1.706-4.481 0-6.187-0.635-0.635-0.635-1.663 0-2.298s1.663-0.635 2.298 0c2.973 2.973 2.973 7.81 0 10.783l-6 6c-1.44 1.44-3.355 2.233-5.392 2.233z"></path>
</symbol>
<symbol id="icon-copy" viewBox="0 0 32 32">
<title>copy</title>
<path d="M20 8v-8h-14l-6 6v18h12v8h20v-24h-12zM6 2.828v3.172h-3.172l3.172-3.172zM2 22v-14h6v-6h10v6l-6 6v8h-10zM18 10.828v3.172h-3.172l3.172-3.172zM30 30h-16v-14h6v-6h10v20z"></path>
@@ -19,7 +19,7 @@
__all__ = ['BaseCherryPyTestCase']
class BaseCherryPyTestCase(unittest.TestCase):
def request(self, path='/', method='GET', app_path='',
def request(self, request_path='/', method='GET', app_path='',
scheme='http', proto='HTTP/1.1', data=None,
headers={}, as_json=False, as_text=False,
json_body={}, **kwargs):
@@ -89,7 +89,7 @@ def request(self, path='/', method='GET', app_path='',
request, response = app.get_serving(local, remote, scheme, proto)
try:
header_tuples = [(k, v) for k, v in h.items()]
response = request.run(method, path, qs, proto, header_tuples, fd)
response = request.run(method, request_path, qs, proto, header_tuples, fd)
finally:
if fd:
fd.close()

0 comments on commit 34d7a4d

Please sign in to comment.