@@ -21,8 +21,11 @@ def extract_timestamp(line): | |||||
class Client(object): | class Client(object): | ||||
"""Main client interface to the Nilm database.""" | """Main client interface to the Nilm database.""" | ||||
def __init__(self, url): | |||||
self.http = nilmdb.client.httpclient.HTTPClient(url) | |||||
def __init__(self, url, post_json = False): | |||||
"""Initialize client with given URL. If post_json is true, | |||||
POST requests are sent with Content-Type 'application/json' | |||||
instead of the default 'x-www-form-urlencoded'.""" | |||||
self.http = nilmdb.client.httpclient.HTTPClient(url, post_json) | |||||
# __enter__/__exit__ allow this class to be a context manager | # __enter__/__exit__ allow this class to be a context manager | ||||
def __enter__(self): | def __enter__(self): | ||||
@@ -10,7 +10,7 @@ import requests | |||||
class HTTPClient(object): | class HTTPClient(object): | ||||
"""Class to manage and perform HTTP requests from the client""" | """Class to manage and perform HTTP requests from the client""" | ||||
def __init__(self, baseurl = ""): | |||||
def __init__(self, baseurl = "", post_json = False): | |||||
"""If baseurl is supplied, all other functions that take | """If baseurl is supplied, all other functions that take | ||||
a URL can be given a relative URL instead.""" | a URL can be given a relative URL instead.""" | ||||
# Verify / clean up URL | # Verify / clean up URL | ||||
@@ -26,6 +26,10 @@ class HTTPClient(object): | |||||
# Saved response, so that tests can verify a few things. | # Saved response, so that tests can verify a few things. | ||||
self._last_response = {} | self._last_response = {} | ||||
# Whether to send application/json POST bodies (versus | |||||
# x-www-form-urlencoded) | |||||
self.post_json = post_json | |||||
def _handle_error(self, url, code, body): | def _handle_error(self, url, code, body): | ||||
# Default variables for exception. We use the entire body as | # Default variables for exception. We use the entire body as | ||||
# the default message, in case we can't extract it from a JSON | # the default message, in case we can't extract it from a JSON | ||||
@@ -57,13 +61,14 @@ class HTTPClient(object): | |||||
def close(self): | def close(self): | ||||
self.session.close() | self.session.close() | ||||
def _do_req(self, method, url, query_data, body_data, stream): | |||||
def _do_req(self, method, url, query_data, body_data, stream, headers): | |||||
url = urlparse.urljoin(self.baseurl, url) | url = urlparse.urljoin(self.baseurl, url) | ||||
try: | try: | ||||
response = self.session.request(method, url, | response = self.session.request(method, url, | ||||
params = query_data, | params = query_data, | ||||
data = body_data, | data = body_data, | ||||
stream = stream) | |||||
stream = stream, | |||||
headers = headers) | |||||
except requests.RequestException as e: | except requests.RequestException as e: | ||||
raise ServerError(status = "502 Error", url = url, | raise ServerError(status = "502 Error", url = url, | ||||
message = str(e.message)) | message = str(e.message)) | ||||
@@ -77,12 +82,13 @@ class HTTPClient(object): | |||||
return (response, False) | return (response, False) | ||||
# Normal versions that return data directly | # Normal versions that return data directly | ||||
def _req(self, method, url, query = None, body = None): | |||||
def _req(self, method, url, query = None, body = None, headers = None): | |||||
""" | """ | ||||
Make a request and return the body data as a string or parsed | Make a request and return the body data as a string or parsed | ||||
JSON object, or raise an error if it contained an error. | JSON object, or raise an error if it contained an error. | ||||
""" | """ | ||||
(response, isjson) = self._do_req(method, url, query, body, False) | |||||
(response, isjson) = self._do_req(method, url, query, body, | |||||
stream = False, headers = headers) | |||||
if isjson: | if isjson: | ||||
return json.loads(response.content) | return json.loads(response.content) | ||||
return response.content | return response.content | ||||
@@ -93,20 +99,26 @@ class HTTPClient(object): | |||||
def post(self, url, params = None): | def post(self, url, params = None): | ||||
"""Simple POST (parameters in body)""" | """Simple POST (parameters in body)""" | ||||
return self._req("POST", url, None, params) | |||||
if self.post_json: | |||||
return self._req("POST", url, None, | |||||
json.dumps(params), | |||||
{ 'Content-type': 'application/json' }) | |||||
else: | |||||
return self._req("POST", url, None, params) | |||||
def put(self, url, data, params = None): | def put(self, url, data, params = None): | ||||
"""Simple PUT (parameters in URL, data in body)""" | """Simple PUT (parameters in URL, data in body)""" | ||||
return self._req("PUT", url, params, data) | return self._req("PUT", url, params, data) | ||||
# Generator versions that return data one line at a time. | # Generator versions that return data one line at a time. | ||||
def _req_gen(self, method, url, query = None, body = None): | |||||
def _req_gen(self, method, url, query = None, body = None, headers = None): | |||||
""" | """ | ||||
Make a request and return a generator that gives back strings | Make a request and return a generator that gives back strings | ||||
or JSON decoded lines of the body data, or raise an error if | or JSON decoded lines of the body data, or raise an error if | ||||
it contained an eror. | it contained an eror. | ||||
""" | """ | ||||
(response, isjson) = self._do_req(method, url, query, body, True) | |||||
(response, isjson) = self._do_req(method, url, query, body, | |||||
stream = True, headers = headers) | |||||
for line in response.iter_lines(): | for line in response.iter_lines(): | ||||
if isjson: | if isjson: | ||||
yield json.loads(line) | yield json.loads(line) | ||||
@@ -117,6 +117,14 @@ def CORS_allow(methods): | |||||
cherrypy.tools.CORS_allow = cherrypy.Tool('on_start_resource', CORS_allow) | cherrypy.tools.CORS_allow = cherrypy.Tool('on_start_resource', CORS_allow) | ||||
# Helper for json_in tool to process JSON data into normal request | |||||
# parameters. | |||||
def json_to_request_params(body): | |||||
cherrypy.lib.jsontools.json_processor(body) | |||||
if not isinstance(cherrypy.request.json, dict): | |||||
raise cherrypy.HTTPError(415) | |||||
cherrypy.request.params.update(cherrypy.request.json) | |||||
# CherryPy apps | # CherryPy apps | ||||
class Root(NilmApp): | class Root(NilmApp): | ||||
"""Root application for NILM database""" | """Root application for NILM database""" | ||||
@@ -175,6 +183,7 @@ class Stream(NilmApp): | |||||
# /stream/create?path=/newton/prep&layout=PrepData | # /stream/create?path=/newton/prep&layout=PrepData | ||||
@cherrypy.expose | @cherrypy.expose | ||||
@cherrypy.tools.json_in() | |||||
@cherrypy.tools.json_out() | @cherrypy.tools.json_out() | ||||
@exception_to_httperror(NilmDBError, ValueError) | @exception_to_httperror(NilmDBError, ValueError) | ||||
@cherrypy.tools.CORS_allow(methods = ["POST"]) | @cherrypy.tools.CORS_allow(methods = ["POST"]) | ||||
@@ -186,6 +195,7 @@ class Stream(NilmApp): | |||||
# /stream/destroy?path=/newton/prep | # /stream/destroy?path=/newton/prep | ||||
@cherrypy.expose | @cherrypy.expose | ||||
@cherrypy.tools.json_in() | |||||
@cherrypy.tools.json_out() | @cherrypy.tools.json_out() | ||||
@exception_to_httperror(NilmDBError) | @exception_to_httperror(NilmDBError) | ||||
@cherrypy.tools.CORS_allow(methods = ["POST"]) | @cherrypy.tools.CORS_allow(methods = ["POST"]) | ||||
@@ -219,6 +229,7 @@ class Stream(NilmApp): | |||||
# /stream/set_metadata?path=/newton/prep&data=<json> | # /stream/set_metadata?path=/newton/prep&data=<json> | ||||
@cherrypy.expose | @cherrypy.expose | ||||
@cherrypy.tools.json_in() | |||||
@cherrypy.tools.json_out() | @cherrypy.tools.json_out() | ||||
@exception_to_httperror(NilmDBError, LookupError, TypeError) | @exception_to_httperror(NilmDBError, LookupError, TypeError) | ||||
@cherrypy.tools.CORS_allow(methods = ["POST"]) | @cherrypy.tools.CORS_allow(methods = ["POST"]) | ||||
@@ -231,6 +242,7 @@ class Stream(NilmApp): | |||||
# /stream/update_metadata?path=/newton/prep&data=<json> | # /stream/update_metadata?path=/newton/prep&data=<json> | ||||
@cherrypy.expose | @cherrypy.expose | ||||
@cherrypy.tools.json_in() | |||||
@cherrypy.tools.json_out() | @cherrypy.tools.json_out() | ||||
@exception_to_httperror(NilmDBError, LookupError, TypeError) | @exception_to_httperror(NilmDBError, LookupError, TypeError) | ||||
@cherrypy.tools.CORS_allow(methods = ["POST"]) | @cherrypy.tools.CORS_allow(methods = ["POST"]) | ||||
@@ -297,6 +309,7 @@ class Stream(NilmApp): | |||||
# /stream/remove?path=/newton/prep | # /stream/remove?path=/newton/prep | ||||
# /stream/remove?path=/newton/prep&start=1234567890.0&end=1234567899.0 | # /stream/remove?path=/newton/prep&start=1234567890.0&end=1234567899.0 | ||||
@cherrypy.expose | @cherrypy.expose | ||||
@cherrypy.tools.json_in() | |||||
@cherrypy.tools.json_out() | @cherrypy.tools.json_out() | ||||
@exception_to_httperror(NilmDBError) | @exception_to_httperror(NilmDBError) | ||||
@cherrypy.tools.CORS_allow(methods = ["POST"]) | @cherrypy.tools.CORS_allow(methods = ["POST"]) | ||||
@@ -463,6 +476,12 @@ class Server(object): | |||||
app_config.update({ 'tools.CORS_allow.on': True, | app_config.update({ 'tools.CORS_allow.on': True, | ||||
'tools.CORS_allow.methods': ['GET', 'HEAD'] }) | 'tools.CORS_allow.methods': ['GET', 'HEAD'] }) | ||||
# Configure the 'json_in' tool to also allow other content-types | |||||
# (like x-www-form-urlencoded), and to treat JSON as a dict that | |||||
# fills requests.param. | |||||
app_config.update({ 'tools.json_in.force': False, | |||||
'tools.json_in.processor': json_to_request_params }) | |||||
# Send tracebacks in error responses. They're hidden by the | # Send tracebacks in error responses. They're hidden by the | ||||
# error_page function for client errors (code 400-499). | # error_page function for client errors (code 400-499). | ||||
app_config.update({ 'request.show_tracebacks' : True }) | app_config.update({ 'request.show_tracebacks' : True }) | ||||
@@ -357,43 +357,45 @@ class TestClient(object): | |||||
client.close() | client.close() | ||||
def test_client_08_unicode(self): | def test_client_08_unicode(self): | ||||
# Basic Unicode tests | |||||
client = nilmdb.Client(url = testurl) | |||||
# Delete streams that exist | |||||
for stream in client.stream_list(): | |||||
client.stream_destroy(stream[0]) | |||||
# Database is empty | |||||
eq_(client.stream_list(), []) | |||||
# Create Unicode stream, match it | |||||
raw = [ u"/düsseldorf/raw", u"uint16_6" ] | |||||
prep = [ u"/düsseldorf/prep", u"uint16_6" ] | |||||
client.stream_create(*raw) | |||||
eq_(client.stream_list(), [raw]) | |||||
eq_(client.stream_list(layout=raw[1]), [raw]) | |||||
eq_(client.stream_list(path=raw[0]), [raw]) | |||||
client.stream_create(*prep) | |||||
eq_(client.stream_list(), [prep, raw]) | |||||
# Set / get metadata with Unicode keys and values | |||||
eq_(client.stream_get_metadata(raw[0]), {}) | |||||
eq_(client.stream_get_metadata(prep[0]), {}) | |||||
meta1 = { u"alpha": u"α", | |||||
u"β": u"beta" } | |||||
meta2 = { u"alpha": u"α" } | |||||
meta3 = { u"β": u"beta" } | |||||
client.stream_set_metadata(prep[0], meta1) | |||||
client.stream_update_metadata(prep[0], {}) | |||||
client.stream_update_metadata(raw[0], meta2) | |||||
client.stream_update_metadata(raw[0], meta3) | |||||
eq_(client.stream_get_metadata(prep[0]), meta1) | |||||
eq_(client.stream_get_metadata(raw[0]), meta1) | |||||
eq_(client.stream_get_metadata(raw[0], [ "alpha" ]), meta2) | |||||
eq_(client.stream_get_metadata(raw[0], [ "alpha", "β" ]), meta1) | |||||
client.close() | |||||
# Try both with and without posting JSON | |||||
for post_json in (False, True): | |||||
# Basic Unicode tests | |||||
client = nilmdb.Client(url = testurl, post_json = post_json) | |||||
# Delete streams that exist | |||||
for stream in client.stream_list(): | |||||
client.stream_destroy(stream[0]) | |||||
# Database is empty | |||||
eq_(client.stream_list(), []) | |||||
# Create Unicode stream, match it | |||||
raw = [ u"/düsseldorf/raw", u"uint16_6" ] | |||||
prep = [ u"/düsseldorf/prep", u"uint16_6" ] | |||||
client.stream_create(*raw) | |||||
eq_(client.stream_list(), [raw]) | |||||
eq_(client.stream_list(layout=raw[1]), [raw]) | |||||
eq_(client.stream_list(path=raw[0]), [raw]) | |||||
client.stream_create(*prep) | |||||
eq_(client.stream_list(), [prep, raw]) | |||||
# Set / get metadata with Unicode keys and values | |||||
eq_(client.stream_get_metadata(raw[0]), {}) | |||||
eq_(client.stream_get_metadata(prep[0]), {}) | |||||
meta1 = { u"alpha": u"α", | |||||
u"β": u"beta" } | |||||
meta2 = { u"alpha": u"α" } | |||||
meta3 = { u"β": u"beta" } | |||||
client.stream_set_metadata(prep[0], meta1) | |||||
client.stream_update_metadata(prep[0], {}) | |||||
client.stream_update_metadata(raw[0], meta2) | |||||
client.stream_update_metadata(raw[0], meta3) | |||||
eq_(client.stream_get_metadata(prep[0]), meta1) | |||||
eq_(client.stream_get_metadata(raw[0]), meta1) | |||||
eq_(client.stream_get_metadata(raw[0], [ "alpha" ]), meta2) | |||||
eq_(client.stream_get_metadata(raw[0], [ "alpha", "β" ]), meta1) | |||||
client.close() | |||||
def test_client_09_closing(self): | def test_client_09_closing(self): | ||||
# Make sure we actually close sockets correctly. New | # Make sure we actually close sockets correctly. New | ||||
@@ -237,3 +237,20 @@ class TestServer(object): | |||||
"header in response:\n", r.headers) | "header in response:\n", r.headers) | ||||
eq_(r.headers["access-control-allow-methods"], "GET, HEAD") | eq_(r.headers["access-control-allow-methods"], "GET, HEAD") | ||||
eq_(r.headers["access-control-allow-headers"], "X-Custom") | eq_(r.headers["access-control-allow-headers"], "X-Custom") | ||||
def test_post_bodies(self): | |||||
# Test JSON post bodies | |||||
r = requests.post("http://127.0.0.1:32180/stream/set_metadata", | |||||
headers = { "Content-Type": "application/json" }, | |||||
data = '{"hello": 1}') | |||||
eq_(r.status_code, 404) # wrong parameters | |||||
r = requests.post("http://127.0.0.1:32180/stream/set_metadata", | |||||
headers = { "Content-Type": "application/json" }, | |||||
data = '["hello"]') | |||||
eq_(r.status_code, 415) # not a dict | |||||
r = requests.post("http://127.0.0.1:32180/stream/set_metadata", | |||||
headers = { "Content-Type": "application/json" }, | |||||
data = '[hello]') | |||||
eq_(r.status_code, 400) # badly formatted JSON |