Made API more robust

This commit is contained in:
Paulus Schoutsen 2013-11-01 11:34:43 -07:00
parent 24b317f10d
commit 92f0cb20ff
4 changed files with 164 additions and 102 deletions

View File

@ -75,10 +75,22 @@ Returns the current state from a category
``` ```
**/api/states/&lt;category>** - POST<br> **/api/states/&lt;category>** - POST<br>
Updates the current state of a category. Returns status code 201 if successful with location header of updated resource.<br> Updates the current state of a category. Returns status code 201 if successful with location header of updated resource and the new state in the body.<br>
parameter: new_state - string<br> parameter: new_state - string<br>
optional parameter: attributes - JSON encoded object optional parameter: attributes - JSON encoded object
```json
{
"attributes": {
"next_rising": "07:04:15 29-10-2013",
"next_setting": "18:00:31 29-10-2013"
},
"category": "weather.sun",
"last_changed": "23:24:33 28-10-2013",
"state": "below_horizon"
}
```
**/api/events/&lt;event_type>** - POST<br> **/api/events/&lt;event_type>** - POST<br>
Fires an event with event_type<br> Fires an event with event_type<br>
optional parameter: event_data - JSON encoded object optional parameter: event_data - JSON encoded object

View File

@ -43,9 +43,19 @@ Example result:
/api/states/<category> - POST /api/states/<category> - POST
Updates the current state of a category. Returns status code 201 if successful Updates the current state of a category. Returns status code 201 if successful
with location header of updated resource. with location header of updated resource and as body the new state.
parameter: new_state - string parameter: new_state - string
optional parameter: attributes - JSON encoded object optional parameter: attributes - JSON encoded object
Example result:
{
"attributes": {
"next_rising": "07:04:15 29-10-2013",
"next_setting": "18:00:31 29-10-2013"
},
"category": "weather.sun",
"last_changed": "23:24:33 28-10-2013",
"state": "below_horizon"
}
/api/events/<event_type> - POST /api/events/<event_type> - POST
Fires an event with event_type Fires an event with event_type
@ -75,6 +85,7 @@ HTTP_BAD_REQUEST = 400
HTTP_UNAUTHORIZED = 401 HTTP_UNAUTHORIZED = 401
HTTP_NOT_FOUND = 404 HTTP_NOT_FOUND = 404
HTTP_METHOD_NOT_ALLOWED = 405 HTTP_METHOD_NOT_ALLOWED = 405
HTTP_UNPROCESSABLE_ENTITY = 422
URL_ROOT = "/" URL_ROOT = "/"
@ -308,19 +319,18 @@ class RequestHandler(BaseHTTPRequestHandler):
# pylint: disable=unused-argument # pylint: disable=unused-argument
def _handle_get_states_category(self, path_match, data): def _handle_get_states_category(self, path_match, data):
""" Returns the state of a specific category. """ """ Returns the state of a specific category. """
try: category = path_match.group('category')
category = path_match.group('category')
state = self.server.statemachine.get_state(category) state = self.server.statemachine.get_state(category)
if state:
state['category'] = category state['category'] = category
self._write_json(state) self._write_json(state)
except KeyError: else:
# If category or new_state don't exist in post data # If category does not exist
self._message("Invalid state received.", HTTP_BAD_REQUEST) self._message("State does not exist.", HTTP_UNPROCESSABLE_ENTITY)
def _handle_post_states_category(self, path_match, data): def _handle_post_states_category(self, path_match, data):
""" Handles updating the state of a category. """ """ Handles updating the state of a category. """
@ -335,22 +345,28 @@ class RequestHandler(BaseHTTPRequestHandler):
# Happens if key 'attributes' does not exist # Happens if key 'attributes' does not exist
attributes = None attributes = None
# Write state
self.server.statemachine.set_state(category, self.server.statemachine.set_state(category,
new_state, new_state,
attributes) attributes)
self._redirect("/states/{}".format(category), # Return state
"State changed: {}={}".format(category, new_state), state = self.server.statemachine.get_state(category)
HTTP_CREATED)
state['category'] = category
self._write_json(state, status_code=HTTP_CREATED,
location=URL_STATES_CATEGORY.format(category))
except KeyError: except KeyError:
# If category or new_state don't exist in post data # If new_state don't exist in post data
self._message("Invalid parameters received.", self._message("No new_state submitted.",
HTTP_BAD_REQUEST) HTTP_BAD_REQUEST)
except ValueError: except ValueError:
# Occurs during error parsing json # Occurs during error parsing json
self._message("Invalid JSON for attributes", HTTP_BAD_REQUEST) self._message("Invalid JSON for attributes",
HTTP_UNPROCESSABLE_ENTITY)
def _handle_post_events_event_type(self, path_match, data): def _handle_post_events_event_type(self, path_match, data):
""" Handles firing of an event. """ """ Handles firing of an event. """
@ -369,31 +385,32 @@ class RequestHandler(BaseHTTPRequestHandler):
except ValueError: except ValueError:
# Occurs during error parsing json # Occurs during error parsing json
self._message("Invalid JSON for event_data", HTTP_BAD_REQUEST) self._message("Invalid JSON for event_data",
HTTP_UNPROCESSABLE_ENTITY)
def _message(self, message, status_code=HTTP_OK): def _message(self, message, status_code=HTTP_OK):
""" Helper method to return a message to the caller. """ """ Helper method to return a message to the caller. """
if self.use_json: if self.use_json:
self._write_json({'message': message}, status_code=status_code) self._write_json({'message': message}, status_code=status_code)
else: else:
self._redirect('/', message)
def _redirect(self, location, message=None,
status_code=HTTP_MOVED_PERMANENTLY):
""" Helper method to redirect caller. """
# Only save as flash message if we will go to debug interface next
if not self.use_json and message:
self.server.flash_message = message self.server.flash_message = message
self._redirect('/')
self.send_response(status_code) def _redirect(self, location):
""" Helper method to redirect caller. """
self.send_response(HTTP_MOVED_PERMANENTLY)
self.send_header("Location", "{}?api_password={}". self.send_header("Location", "{}?api_password={}".
format(location, self.server.api_password)) format(location, self.server.api_password))
self.end_headers() self.end_headers()
def _write_json(self, data=None, status_code=HTTP_OK): def _write_json(self, data=None, status_code=HTTP_OK, location=None):
""" Helper method to return JSON to the caller. """ """ Helper method to return JSON to the caller. """
self.send_response(status_code) self.send_response(status_code)
self.send_header('Content-type','application/json') self.send_header('Content-type','application/json')
if location:
self.send_header('Location', location)
self.end_headers() self.end_headers()
if data: if data:

View File

@ -161,11 +161,20 @@ class StateMachine(ha.StateMachine):
req = self._call_api(METHOD_GET, req = self._call_api(METHOD_GET,
hah.URL_API_STATES_CATEGORY.format(category)) hah.URL_API_STATES_CATEGORY.format(category))
data = req.json() if req.status_code == 200:
data = req.json()
return ha.create_state(data['state'], return ha.create_state(data['state'],
data['attributes'], data['attributes'],
ha.str_to_datetime(data['last_changed'])) ha.str_to_datetime(data['last_changed']))
elif req.status_code == 422:
# Category does not exist
return None
else:
raise ha.HomeAssistantException(
"Got unexpected result (3): {}.".format(req.text))
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
self.logger.exception("StateMachine:Error connecting to server") self.logger.exception("StateMachine:Error connecting to server")

View File

@ -15,54 +15,56 @@ import homeassistant as ha
import homeassistant.remote as remote import homeassistant.remote as remote
import homeassistant.httpinterface as hah import homeassistant.httpinterface as hah
API_PASSWORD = "test1234" API_PASSWORD = "test1234"
HTTP_BASE_URL = "http://127.0.0.1:{}".format(hah.SERVER_PORT) HTTP_BASE_URL = "http://127.0.0.1:{}".format(hah.SERVER_PORT)
def _url(path=""):
""" Helper method to generate urls. """
return HTTP_BASE_URL + path
class HAHelper(object): # pylint: disable=too-few-public-methods
""" Helper class to keep track of current running HA instance. """
core = None
def ensure_homeassistant_started():
""" Ensures home assistant is started. """
if not HAHelper.core:
core = {'eventbus': ha.EventBus()}
core['statemachine'] = ha.StateMachine(core['eventbus'])
core['statemachine'].set_state('test','a_state')
hah.HTTPInterface(core['eventbus'], core['statemachine'],
API_PASSWORD)
core['eventbus'].fire(ha.EVENT_START)
# Give objects time to startup
time.sleep(1)
HAHelper.core = core
return HAHelper.core['eventbus'], HAHelper.core['statemachine']
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
class TestHTTPInterface(unittest.TestCase): class TestHTTPInterface(unittest.TestCase):
""" Test the HTTP debug interface and API. """ """ Test the HTTP debug interface and API. """
HTTP_init = False
def _url(self, path=""):
""" Helper method to generate urls. """
return HTTP_BASE_URL + path
def setUp(self): # pylint: disable=invalid-name
""" Initialize the HTTP interface if not started yet. """
if not TestHTTPInterface.HTTP_init:
TestHTTPInterface.HTTP_init = True
hah.HTTPInterface(self.eventbus, self.statemachine, API_PASSWORD)
self.statemachine.set_state("test", "INIT_STATE")
self.sm_with_remote_eb.set_state("test", "INIT_STATE")
self.eventbus.fire(ha.EVENT_START)
# Give objects time to startup
time.sleep(1)
@classmethod @classmethod
def setUpClass(cls): # pylint: disable=invalid-name def setUpClass(cls): # pylint: disable=invalid-name
""" things to be run when tests are started. """ """ things to be run when tests are started. """
cls.eventbus = ha.EventBus() cls.eventbus, cls.statemachine = ensure_homeassistant_started()
cls.statemachine = ha.StateMachine(cls.eventbus)
cls.remote_sm = remote.StateMachine("127.0.0.1", API_PASSWORD)
cls.remote_eb = remote.EventBus("127.0.0.1", API_PASSWORD)
cls.sm_with_remote_eb = ha.StateMachine(cls.remote_eb)
def test_debug_interface(self): def test_debug_interface(self):
""" Test if we can login by comparing not logged in screen to """ Test if we can login by comparing not logged in screen to
logged in screen. """ logged in screen. """
with_pw = requests.get( with_pw = requests.get(
self._url("/?api_password={}".format(API_PASSWORD))) _url("/?api_password={}".format(API_PASSWORD)))
without_pw = requests.get(self._url()) without_pw = requests.get(_url())
self.assertNotEqual(without_pw.text, with_pw.text) self.assertNotEqual(without_pw.text, with_pw.text)
@ -70,7 +72,7 @@ class TestHTTPInterface(unittest.TestCase):
def test_debug_state_change(self): def test_debug_state_change(self):
""" Test if the debug interface allows us to change a state. """ """ Test if the debug interface allows us to change a state. """
requests.post( requests.post(
self._url(hah.URL_STATES_CATEGORY.format("test")), _url(hah.URL_STATES_CATEGORY.format("test")),
data={"new_state":"debug_state_change", data={"new_state":"debug_state_change",
"api_password":API_PASSWORD}) "api_password":API_PASSWORD})
@ -82,12 +84,12 @@ class TestHTTPInterface(unittest.TestCase):
""" Test if we get access denied if we omit or provide """ Test if we get access denied if we omit or provide
a wrong api password. """ a wrong api password. """
req = requests.post( req = requests.post(
self._url(hah.URL_API_STATES_CATEGORY.format("test"))) _url(hah.URL_API_STATES_CATEGORY.format("test")))
self.assertEqual(req.status_code, 401) self.assertEqual(req.status_code, 401)
req = requests.post( req = requests.post(
self._url(hah.URL_API_STATES_CATEGORY.format("test")), _url(hah.URL_API_STATES_CATEGORY.format("test")),
data={"api_password":"not the password"}) data={"api_password":"not the password"})
self.assertEqual(req.status_code, 401) self.assertEqual(req.status_code, 401)
@ -95,7 +97,7 @@ class TestHTTPInterface(unittest.TestCase):
def test_api_list_state_categories(self): def test_api_list_state_categories(self):
""" Test if the debug interface allows us to list state categories. """ """ Test if the debug interface allows us to list state categories. """
req = requests.get(self._url(hah.URL_API_STATES), req = requests.get(_url(hah.URL_API_STATES),
data={"api_password":API_PASSWORD}) data={"api_password":API_PASSWORD})
data = req.json() data = req.json()
@ -107,7 +109,7 @@ class TestHTTPInterface(unittest.TestCase):
def test_api_get_state(self): def test_api_get_state(self):
""" Test if the debug interface allows us to get a state. """ """ Test if the debug interface allows us to get a state. """
req = requests.get( req = requests.get(
self._url(hah.URL_API_STATES_CATEGORY.format("test")), _url(hah.URL_API_STATES_CATEGORY.format("test")),
data={"api_password":API_PASSWORD}) data={"api_password":API_PASSWORD})
data = req.json() data = req.json()
@ -119,50 +121,26 @@ class TestHTTPInterface(unittest.TestCase):
self.assertEqual(data['last_changed'], state['last_changed']) self.assertEqual(data['last_changed'], state['last_changed'])
self.assertEqual(data['attributes'], state['attributes']) self.assertEqual(data['attributes'], state['attributes'])
def test_api_get_non_existing_state(self):
""" Test if the debug interface allows us to get a state. """
req = requests.get(
_url(hah.URL_API_STATES_CATEGORY.format("does_not_exist")),
data={"api_password":API_PASSWORD})
self.assertEqual(req.status_code, 422)
def test_api_state_change(self): def test_api_state_change(self):
""" Test if we can change the state of a category that exists. """ """ Test if we can change the state of a category that exists. """
self.statemachine.set_state("test", "not_to_be_set_state") self.statemachine.set_state("test", "not_to_be_set_state")
requests.post(self._url(hah.URL_API_STATES_CATEGORY.format("test")), requests.post(_url(hah.URL_API_STATES_CATEGORY.format("test")),
data={"new_state":"debug_state_change2", data={"new_state":"debug_state_change2",
"api_password":API_PASSWORD}) "api_password":API_PASSWORD})
self.assertEqual(self.statemachine.get_state("test")['state'], self.assertEqual(self.statemachine.get_state("test")['state'],
"debug_state_change2") "debug_state_change2")
# pylint: disable=invalid-name
def test_remote_sm_list_state_categories(self):
""" Test if the debug interface allows us to list state categories. """
self.assertEqual(self.statemachine.categories,
self.remote_sm.categories)
def test_remote_sm_get_state(self):
""" Test if the debug interface allows us to list state categories. """
remote_state = self.remote_sm.get_state("test")
state = self.statemachine.get_state("test")
self.assertEqual(remote_state['state'], state['state'])
self.assertEqual(remote_state['last_changed'], state['last_changed'])
self.assertEqual(remote_state['attributes'], state['attributes'])
def test_remote_sm_state_change(self):
""" Test if we can change the state of a category that exists. """
self.remote_sm.set_state("test", "set_remotely", {"test": 1})
state = self.statemachine.get_state("test")
self.assertEqual(state['state'], "set_remotely")
self.assertEqual(state['attributes']['test'], 1)
# pylint: disable=invalid-name # pylint: disable=invalid-name
def test_api_state_change_of_non_existing_category(self): def test_api_state_change_of_non_existing_category(self):
""" Test if the API allows us to change a state of """ Test if the API allows us to change a state of
@ -171,7 +149,7 @@ class TestHTTPInterface(unittest.TestCase):
new_state = "debug_state_change" new_state = "debug_state_change"
req = requests.post( req = requests.post(
self._url(hah.URL_API_STATES_CATEGORY.format( _url(hah.URL_API_STATES_CATEGORY.format(
"test_category_that_does_not_exist")), "test_category_that_does_not_exist")),
data={"new_state": new_state, data={"new_state": new_state,
"api_password": API_PASSWORD}) "api_password": API_PASSWORD})
@ -194,7 +172,7 @@ class TestHTTPInterface(unittest.TestCase):
self.eventbus.listen_once("test_event_no_data", listener) self.eventbus.listen_once("test_event_no_data", listener)
requests.post( requests.post(
self._url(hah.URL_EVENTS_EVENT.format("test_event_no_data")), _url(hah.URL_EVENTS_EVENT.format("test_event_no_data")),
data={"api_password":API_PASSWORD}) data={"api_password":API_PASSWORD})
# Allow the event to take place # Allow the event to take place
@ -216,7 +194,7 @@ class TestHTTPInterface(unittest.TestCase):
self.eventbus.listen_once("test_event_with_data", listener) self.eventbus.listen_once("test_event_with_data", listener)
requests.post( requests.post(
self._url(hah.URL_EVENTS_EVENT.format("test_event_with_data")), _url(hah.URL_EVENTS_EVENT.format("test_event_with_data")),
data={"event_data":'{"test": 1}', data={"event_data":'{"test": 1}',
"api_password":API_PASSWORD}) "api_password":API_PASSWORD})
@ -238,7 +216,7 @@ class TestHTTPInterface(unittest.TestCase):
self.eventbus.listen_once("test_event_with_bad_data", listener) self.eventbus.listen_once("test_event_with_bad_data", listener)
req = requests.post( req = requests.post(
self._url(hah.URL_API_EVENTS_EVENT.format("test_event")), _url(hah.URL_API_EVENTS_EVENT.format("test_event")),
data={"event_data":'not json', data={"event_data":'not json',
"api_password":API_PASSWORD}) "api_password":API_PASSWORD})
@ -246,10 +224,57 @@ class TestHTTPInterface(unittest.TestCase):
# It shouldn't but if it fires, allow the event to take place # It shouldn't but if it fires, allow the event to take place
time.sleep(1) time.sleep(1)
self.assertEqual(req.status_code, 400) self.assertEqual(req.status_code, 422)
self.assertEqual(len(test_value), 0) self.assertEqual(len(test_value), 0)
class TestRemote(unittest.TestCase):
""" Test the homeassistant.remote module. """
@classmethod
def setUpClass(cls): # pylint: disable=invalid-name
""" things to be run when tests are started. """
cls.eventbus, cls.statemachine = ensure_homeassistant_started()
cls.remote_sm = remote.StateMachine("127.0.0.1", API_PASSWORD)
cls.remote_eb = remote.EventBus("127.0.0.1", API_PASSWORD)
cls.sm_with_remote_eb = ha.StateMachine(cls.remote_eb)
cls.sm_with_remote_eb.set_state("test", "a_state")
# pylint: disable=invalid-name # pylint: disable=invalid-name
def test_remote_sm_list_state_categories(self):
""" Test if the debug interface allows us to list state categories. """
self.assertEqual(self.statemachine.categories,
self.remote_sm.categories)
def test_remote_sm_get_state(self):
""" Test if the debug interface allows us to list state categories. """
remote_state = self.remote_sm.get_state("test")
state = self.statemachine.get_state("test")
self.assertEqual(remote_state['state'], state['state'])
self.assertEqual(remote_state['last_changed'], state['last_changed'])
self.assertEqual(remote_state['attributes'], state['attributes'])
def test_remote_sm_get_non_existing_state(self):
""" Test if the debug interface allows us to list state categories. """
self.assertEqual(self.remote_sm.get_state("test_does_not_exist"), None)
def test_remote_sm_state_change(self):
""" Test if we can change the state of a category that exists. """
self.remote_sm.set_state("test", "set_remotely", {"test": 1})
state = self.statemachine.get_state("test")
self.assertEqual(state['state'], "set_remotely")
self.assertEqual(state['attributes']['test'], 1)
# pylint: disable=invalid-name
def test_remote_eb_fire_event_with_no_data(self): def test_remote_eb_fire_event_with_no_data(self):
""" Test if the remote eventbus allows us to fire an event. """ """ Test if the remote eventbus allows us to fire an event. """
test_value = [] test_value = []
@ -303,4 +328,3 @@ class TestHTTPInterface(unittest.TestCase):
time.sleep(1) time.sleep(1)
self.assertEqual(len(test_value), 1) self.assertEqual(len(test_value), 1)