Implemented etag support on the endpoint `GET /device/{uuid}`

pull/78/head
Matheus Lima 2019-03-18 16:34:34 -03:00
parent 134bd9cc03
commit df14af5322
6 changed files with 117 additions and 4 deletions

View File

@ -23,6 +23,8 @@ class DeviceEndpoint(PublicEndpoint):
def get(self, device_id):
self._authenticate(device_id)
etag_key = 'device.etag:{uuid}'.format(uuid=device_id)
self._validate_etag(etag_key)
with get_db_connection(self.config['DB_CONNECTION_POOL']) as db:
device = DeviceRepository(db).get_device_by_id(device_id)
if device:
@ -35,6 +37,8 @@ class DeviceEndpoint(PublicEndpoint):
device['user'] = dict(uuid=device['account_id'])
del device['account_id']
response = device, HTTPStatus.OK
self._add_etag(etag_key)
else:
response = '', HTTPStatus.NO_CONTENT
return response

View File

@ -16,4 +16,15 @@ Feature: Get device's information
Scenario: Update device information
When the device is updated
And device is retrieved
Then the information should be updated
Then the information should be updated
Scenario: Get a not modified device using etag
When device is retrieved
And try to fetch a device using a valid etag
Then 304 status code should be returned by the device endpoint
Scenario: Get a device using an expired etag
Given an etag expired by selene ui
When try to fetch a device using an expired etag
Then should return status 200
And a new etag

View File

@ -2,8 +2,10 @@ import json
import uuid
from http import HTTPStatus
from behave import when, then
from hamcrest import assert_that, equal_to, has_key
from behave import when, then, given
from hamcrest import assert_that, equal_to, has_key, not_none, is_not
from selene.util.cache import SeleneCache
new_fields = dict(
platform='mycroft_mark_1',
@ -21,6 +23,7 @@ def get_device(context):
'/v1/device/{uuid}'.format(uuid=device_id),
headers=headers
)
context.device_etag = context.get_device_response.headers.get('ETag')
@then('a valid device should be returned')
@ -86,3 +89,64 @@ def validate_update(context):
assert_that(device['coreVersion'], equal_to(new_fields['coreVersion']))
assert_that(device['enclosureVersion'], equal_to(new_fields['enclosureVersion']))
assert_that(device['platform'], equal_to(new_fields['platform']))
@when('try to fetch a device using a valid etag')
def get_device_using_etag(context):
etag = context.device_etag
assert_that(etag, not_none())
access_token = context.device_login['accessToken']
device_uuid = context.device_login['uuid']
headers = {
'Authorization': 'Bearer {token}'.format(token=access_token),
'If-None-Match': etag
}
context.response_using_etag = context.client.get(
'/v1/device/{uuid}'.format(uuid=device_uuid),
headers=headers
)
@then('304 status code should be returned by the device endpoint')
def validate_etag(context):
response = context.response_using_etag
assert_that(response.status_code, equal_to(HTTPStatus.NOT_MODIFIED))
@given('an etag expired by selene ui')
def expire_etag(context):
context.device_etag = '123'
new_etag = '456'
device_id = context.device_login['uuid']
cache: SeleneCache = context.client_config['SELENE_CACHE']
cache.set('device.etag:{uuid}'.format(uuid=device_id), new_etag)
@when('try to fetch a device using an expired etag')
def fetch_device_expired_etag(context):
etag = context.device_etag
assert_that(etag, not_none())
access_token = context.device_login['accessToken']
device_uuid = context.device_login['uuid']
headers = {
'Authorization': 'Bearer {token}'.format(token=access_token),
'If-None-Match': etag
}
context.response_using_invalid_etag = context.client.get(
'/v1/device/{uuid}'.format(uuid=device_uuid),
headers=headers
)
@then('should return status 200')
def validate_status_code(context):
response = context.response_using_invalid_etag
assert_that(response.status_code, equal_to(HTTPStatus.OK))
@then('a new etag')
def validate_new_etag(context):
etag = context.device_etag
response = context.response_using_invalid_etag
etag_from_response = response.headers.get('ETag')
assert_that(etag, is_not(etag_from_response))

View File

@ -4,6 +4,7 @@ from flask import Blueprint
from schematics.exceptions import DataError
from selene.util.auth import AuthenticationError
from selene.util.not_modified import NotModifiedError
selene_api = Blueprint('selene_api', __name__)
@ -16,3 +17,8 @@ def handle_data_error(error):
@selene_api.app_errorhandler(AuthenticationError)
def handle_data_error(error):
return dict(error=str(error)), HTTPStatus.UNAUTHORIZED
@selene_api.app_errorhandler(NotModifiedError)
def handle_not_modified(error):
return '', HTTPStatus.NOT_MODIFIED

View File

@ -1,11 +1,14 @@
import hashlib
import json
import random
import string
import uuid
from flask import current_app, request
from flask import current_app, request, Response, after_this_request
from flask.views import MethodView
from selene.util.auth import AuthenticationError
from selene.util.not_modified import NotModifiedError
from ..util.cache import SeleneCache
ONE_DAY = 86400
@ -58,6 +61,8 @@ def generate_device_login(device_id: str, cache: SeleneCache) -> dict:
class PublicEndpoint(MethodView):
"""Abstract class for all endpoints used by Mycroft devices"""
etag_chars = string.ascii_letters + string.digits
def __init__(self):
self.config: dict = current_app.config
self.request = request
@ -81,3 +86,23 @@ class PublicEndpoint(MethodView):
device_authenticated = True
if not device_authenticated:
raise AuthenticationError('device not authorized')
def _add_etag(self, key):
"""Add a etag header to the response. We try to get the etag from the cache using the given key.
If the cache has the etag, we use it, otherwise we generate a etag, store it and add it to the response"""
etag = self.cache.get(key)
if etag is None:
etag = ''.join(random.choice(self.etag_chars) for _ in range(32))
self.cache.set(key, etag)
@after_this_request
def set_etag_header(response: Response):
response.headers['ETag'] = etag
return response
def _validate_etag(self, key):
etag_from_request = self.request.headers.get('If-None-Match')
if etag_from_request is not None:
etag_from_cache = self.cache.get(key)
if etag_from_cache is not None and etag_from_request == etag_from_cache.decode('utf-8'):
raise NotModifiedError()

View File

@ -0,0 +1,3 @@
class NotModifiedError(Exception):
pass