Browse Source

Validated patch mechanism works on User

* Fixed datetime deserializer
* Changed patch functionality to be the intersection of passed in keys and attributes from the model
  * This prevents None attributes on patch model from being set on the original model, unless a null was passed in with the key for that attribute
  * Nullable field validators need to be automatically generated on the model
* Updated tests to accommodate changes
* Added iso8601 library to handle parsing incoming rfc3339 datetimes
merge-requests/1/head
Drew Short 7 years ago
parent
commit
c811849e77
  1. 1
      server/Pipfile
  2. 47
      server/Pipfile.lock
  3. 6
      server/atheneum/api/user_api.py
  4. 47
      server/atheneum/service/patch_service.py
  5. 7
      server/atheneum/service/transformation_service.py
  6. 6
      server/atheneum/service/user_service.py
  7. 8
      server/atheneum/service/user_token_service.py
  8. 10
      server/atheneum/service/validation_service.py
  9. 30
      server/tests/api/test_user_api.py

1
server/Pipfile

@ -10,6 +10,7 @@ flask-migrate = ">=2.1,<2.2"
pynacl = ">=1.2,<1.3" pynacl = ">=1.2,<1.3"
click = "*" click = "*"
"rfc3339" = "*" "rfc3339" = "*"
"iso8601" = "*"
[dev-packages] [dev-packages]
python-dotenv = "*" python-dotenv = "*"

47
server/Pipfile.lock

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "5f2e59b2a44c07b72414512802099dc64544fc8e724f5e5b033d627ac0a20626"
"sha256": "b0d90486a769e10025b310d91ee6e7023af9c9e303fce92df9a3541004e3256c"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -18,9 +18,10 @@
"default": { "default": {
"alembic": { "alembic": {
"hashes": [ "hashes": [
"sha256:1cd32df9a3b8c1749082ef60ffbe05ff16617b6afadfdabc680dcb9344af33d7"
"sha256:52d73b1d750f1414fa90c25a08da47b87de1e4ad883935718a8f36396e19e78e",
"sha256:eb7db9b4510562ec37c91d00b00d95fde076c1030d3f661aea882eec532b3565"
], ],
"version": "==0.9.10"
"version": "==1.0.0"
}, },
"cffi": { "cffi": {
"hashes": [ "hashes": [
@ -91,6 +92,15 @@
"index": "pypi", "index": "pypi",
"version": "==2.3.2" "version": "==2.3.2"
}, },
"iso8601": {
"hashes": [
"sha256:210e0134677cc0d02f6028087fee1df1e1d76d372ee1db0bf30bf66c5c1c89a3",
"sha256:49c4b20e1f38aa5cf109ddcd39647ac419f928512c869dc01d5c7098eddede82",
"sha256:bbbae5fb4a7abfe71d4688fd64bff70b91bbd74ef6a99d964bab18f7fdf286dd"
],
"index": "pypi",
"version": "==0.1.12"
},
"itsdangerous": { "itsdangerous": {
"hashes": [ "hashes": [
"sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519" "sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519"
@ -189,9 +199,9 @@
}, },
"sqlalchemy": { "sqlalchemy": {
"hashes": [ "hashes": [
"sha256:e21e5561a85dcdf16b8520ae4daec7401c5c24558e0ce004f9b60be75c4b6957"
"sha256:72325e67fb85f6e9ad304c603d83626d1df684fdf0c7ab1f0352e71feeab69d8"
], ],
"version": "==1.2.9"
"version": "==1.2.10"
}, },
"werkzeug": { "werkzeug": {
"hashes": [ "hashes": [
@ -204,10 +214,10 @@
"develop": { "develop": {
"astroid": { "astroid": {
"hashes": [ "hashes": [
"sha256:0ef2bf9f07c3150929b25e8e61b5198c27b0dca195e156f0e4d5bdd89185ca1a",
"sha256:fc9b582dba0366e63540982c3944a9230cbc6f303641c51483fa547dcc22393a"
"sha256:0a0c484279a5f08c9bcedd6fa9b42e378866a7dcc695206b92d59dc9f2d9760d",
"sha256:218e36cf8d98a42f16214e8670819ce307fa707d1dcf7f9af84c7aede1febc7f"
], ],
"version": "==1.6.5"
"version": "==2.0.1"
}, },
"atomicwrites": { "atomicwrites": {
"hashes": [ "hashes": [
@ -228,10 +238,13 @@
"sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba", "sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba",
"sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed", "sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed",
"sha256:104ab3934abaf5be871a583541e8829d6c19ce7bde2923b2751e0d3ca44db60a", "sha256:104ab3934abaf5be871a583541e8829d6c19ce7bde2923b2751e0d3ca44db60a",
"sha256:10a46017fef60e16694a30627319f38a2b9b52e90182dddb6e37dcdab0f4bf95",
"sha256:15b111b6a0f46ee1a485414a52a7ad1d703bdf984e9ed3c288a4414d3871dcbd", "sha256:15b111b6a0f46ee1a485414a52a7ad1d703bdf984e9ed3c288a4414d3871dcbd",
"sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640", "sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640",
"sha256:1c383d2ef13ade2acc636556fd544dba6e14fa30755f26812f54300e401f98f2", "sha256:1c383d2ef13ade2acc636556fd544dba6e14fa30755f26812f54300e401f98f2",
"sha256:23d341cdd4a0371820eb2b0bd6b88f5003a7438bbedb33688cd33b8eae59affd",
"sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162", "sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162",
"sha256:2a5b73210bad5279ddb558d9a2bfedc7f4bf6ad7f3c988641d83c40293deaec1",
"sha256:2eb564bbf7816a9d68dd3369a510be3327f1c618d2357fa6b1216994c2e3d508", "sha256:2eb564bbf7816a9d68dd3369a510be3327f1c618d2357fa6b1216994c2e3d508",
"sha256:337ded681dd2ef9ca04ef5d93cfc87e52e09db2594c296b4a0a3662cb1b41249", "sha256:337ded681dd2ef9ca04ef5d93cfc87e52e09db2594c296b4a0a3662cb1b41249",
"sha256:3a2184c6d797a125dca8367878d3b9a178b6fdd05fdc2d35d758c3006a1cd694", "sha256:3a2184c6d797a125dca8367878d3b9a178b6fdd05fdc2d35d758c3006a1cd694",
@ -332,18 +345,18 @@
}, },
"mypy": { "mypy": {
"hashes": [ "hashes": [
"sha256:1b899802a89b67bb68f30d788bba49b61b1f28779436f06b75c03495f9d6ea5c",
"sha256:f472645347430282d62d1f97d12ccb8741f19f1572b7cf30b58280e4e0818739"
"sha256:673ea75fb750289b7d1da1331c125dc62fc1c3a8db9129bb372ae7b7d5bf300a",
"sha256:c770605a579fdd4a014e9f0a34b6c7a36ce69b08100ff728e96e27445cef3b3c"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.610"
"version": "==0.620"
}, },
"pbr": { "pbr": {
"hashes": [ "hashes": [
"sha256:4f2b11d95917af76e936811be8361b2b19616e5ef3b55956a429ec7864378e0c",
"sha256:e0f23b61ec42473723b2fec2f33fb12558ff221ee551962f01dd4de9053c2055"
"sha256:1b8be50d938c9bb75d0eaf7eda111eec1bf6dc88a62a6412e33bf077457e0f45",
"sha256:b486975c0cafb6beeb50ca0e17ba047647f229087bd74e37f4a7e2cac17d2caa"
], ],
"version": "==4.1.0"
"version": "==4.2.0"
}, },
"pluggy": { "pluggy": {
"hashes": [ "hashes": [
@ -380,11 +393,11 @@
}, },
"pylint": { "pylint": {
"hashes": [ "hashes": [
"sha256:a48070545c12430cfc4e865bf62f5ad367784765681b3db442d8230f0960aa3c",
"sha256:fff220bcb996b4f7e2b0f6812fd81507b72ca4d8c4d05daf2655c333800cb9b3"
"sha256:2c90a24bee8fae22ac98061c896e61f45c5b73c2e0511a4bf53f99ba56e90434",
"sha256:454532779425098969b8f54ab0f056000b883909f69d05905ea114df886e3251"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.9.2"
"version": "==2.0.1"
}, },
"pytest": { "pytest": {
"hashes": [ "hashes": [

6
server/atheneum/api/user_api.py

@ -10,6 +10,7 @@ from atheneum.service import (
transformation_service, transformation_service,
user_service user_service
) )
from atheneum.service.patch_service import get_patch_fields
from atheneum.service.role_service import Role from atheneum.service.role_service import Role
USER_BLUEPRINT = Blueprint( USER_BLUEPRINT = Blueprint(
@ -32,7 +33,7 @@ def get_user(name: str) -> APIResponse:
return abort(404) return abort(404)
@USER_BLUEPRINT.route('/<name>', methods=['PUT'])
@USER_BLUEPRINT.route('/<name>', methods=['PATCH'])
@return_json @return_json
@authentication_middleware.require_token_auth @authentication_middleware.require_token_auth
@authentication_middleware.require_role(required_role=Role.USER) @authentication_middleware.require_role(required_role=Role.USER)
@ -47,7 +48,8 @@ def patch_user(name: str) -> APIResponse:
user_patch: User = transformation_service.deserialize_model( user_patch: User = transformation_service.deserialize_model(
User, request.json) User, request.json)
try: try:
patched_user = patch_service.patch(g.user, user, user_patch)
patched_user = patch_service.patch(
g.user, user, user_patch, get_patch_fields(request.json))
return APIResponse(patched_user, 200) return APIResponse(patched_user, 200)
except ValueError: except ValueError:
return abort(400) return abort(400)

47
server/atheneum/service/patch_service.py

@ -1,16 +1,25 @@
"""Patching support for db.Model objects.""" """Patching support for db.Model objects."""
from typing import Type, Set
from typing import Type, Set, Optional, Any, Dict
from atheneum import db from atheneum import db
from atheneum.model import User from atheneum.model import User
from atheneum.service import transformation_service
from atheneum.service import validation_service from atheneum.service import validation_service
def get_patch_fields(patch_json: Dict[str, Any]) -> Set[str]:
"""Convert json fields to python fields."""
return set([
transformation_service.convert_key_from_json(key) for key in
patch_json.keys()])
def perform_patch(request_user: User, def perform_patch(request_user: User,
original_model: Type[db.Model], original_model: Type[db.Model],
patch_model: Type[db.Model], patch_model: Type[db.Model],
model_attributes: Set[str]) -> Type[db.Model]:
model_attributes: Set[str],
patched_fields: Optional[Set[str]]) \
-> Type[db.Model]:
""" """
Patch changed attributes onto original model. Patch changed attributes onto original model.
@ -18,10 +27,11 @@ def perform_patch(request_user: User,
:param original_model: The model to apply the patches to :param original_model: The model to apply the patches to
:param patch_model: The model to pull the patch information from :param patch_model: The model to pull the patch information from
:param model_attributes: The attributes that are valid for patching :param model_attributes: The attributes that are valid for patching
:param patched_fields: The explicitly passed fields for patching
:return: Thd patched original_model :return: Thd patched original_model
""" """
change_set = validation_service.determine_change_set( change_set = validation_service.determine_change_set(
original_model, patch_model, model_attributes)
original_model, patch_model, model_attributes, patched_fields)
model_validation = validation_service.validate_model( model_validation = validation_service.validate_model(
request_user, original_model, change_set) request_user, original_model, change_set)
if model_validation.success: if model_validation.success:
@ -37,7 +47,9 @@ def perform_patch(request_user: User,
def versioning_aware_patch(request_user: User, def versioning_aware_patch(request_user: User,
original_model: Type[db.Model], original_model: Type[db.Model],
patch_model: Type[db.Model], patch_model: Type[db.Model],
model_attributes: Set[str]) -> Type[db.Model]:
model_attributes: Set[str],
patched_fields: Optional[Set[str]]) \
-> Type[db.Model]:
""" """
Account for version numbers in the model. Account for version numbers in the model.
@ -46,6 +58,7 @@ def versioning_aware_patch(request_user: User,
the version on the model by 1 to prevent other reads from performing a the version on the model by 1 to prevent other reads from performing a
simultaneous edit. simultaneous edit.
:param patched_fields:
:param request_user: :param request_user:
:param original_model: The model to apply the patches to :param original_model: The model to apply the patches to
:param patch_model: The model to pull the patch information from :param patch_model: The model to pull the patch information from
@ -55,32 +68,46 @@ def versioning_aware_patch(request_user: User,
if original_model.version == patch_model.version: if original_model.version == patch_model.version:
patch_model.version = patch_model.version + 1 patch_model.version = patch_model.version + 1
return perform_patch( return perform_patch(
request_user, original_model, patch_model, model_attributes)
request_user,
original_model,
patch_model,
model_attributes,
patched_fields)
raise ValueError('Versions do not match. Concurrent edit in progress.') raise ValueError('Versions do not match. Concurrent edit in progress.')
def patch( def patch(
request_user: User, request_user: User,
original_model: Type[db.Model], original_model: Type[db.Model],
patch_model: Type[db.Model]) -> Type[db.Model]:
patch_model: Type[db.Model],
patched_fields: Optional[Set[str]] = None) -> Type[db.Model]:
""" """
Patch the original model with the patch model data. Patch the original model with the patch model data.
:param request_user: :param request_user:
:param original_model: The model to apply the patches to :param original_model: The model to apply the patches to
:param patch_model: The model to pull the patch information from :param patch_model: The model to pull the patch information from
:param patched_fields:
:return: The patched original_model :return: The patched original_model
""" """
if type(original_model) is type(patch_model): if type(original_model) is type(patch_model):
model_attributes = validation_service.get_changable_attribute_names( model_attributes = validation_service.get_changable_attribute_names(
original_model) original_model)
if original_model.id != patch_model.id:
if patch_model.id is not None and original_model.id != patch_model.id:
raise ValueError('Cannot change ids through patching') raise ValueError('Cannot change ids through patching')
if 'version' in model_attributes: if 'version' in model_attributes:
return versioning_aware_patch( return versioning_aware_patch(
request_user, original_model, patch_model, model_attributes)
request_user,
original_model,
patch_model,
model_attributes,
patched_fields)
return perform_patch( return perform_patch(
request_user, original_model, patch_model, model_attributes)
request_user,
original_model,
patch_model,
model_attributes,
patched_fields)
else: else:
raise ValueError( raise ValueError(
'Model types "{}" and "{}" do not match'.format( 'Model types "{}" and "{}" do not match'.format(

7
server/atheneum/service/transformation_service.py

@ -1,5 +1,6 @@
"""Handle Model Serialization.""" """Handle Model Serialization."""
import logging import logging
import re
from typing import Dict, Callable, Any, List, Optional, Type from typing import Dict, Callable, Any, List, Optional, Type
from atheneum import errors from atheneum import errors
@ -110,3 +111,9 @@ def deserialize_model(
except KeyError: except KeyError:
raise NotImplementedError( raise NotImplementedError(
'{} has no registered serializers'.format(model_type)) '{} has no registered serializers'.format(model_type))
def convert_key_from_json(key: str) -> str:
"""Convert a key from camelCase."""
substitute = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', key)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', substitute).lower()

6
server/atheneum/service/user_service.py

@ -5,6 +5,8 @@ import string
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Callable, Any, Tuple from typing import Optional, Dict, Callable, Any, Tuple
from iso8601 import iso8601
from atheneum import errors from atheneum import errors
from atheneum.db import db from atheneum.db import db
from atheneum.model import User from atheneum.model import User
@ -66,7 +68,7 @@ class UserTransformer(BaseTransformer):
def deserialize_creation_time( def deserialize_creation_time(
model: User, creation_time: datetime) -> None: model: User, creation_time: datetime) -> None:
"""User creation time.""" """User creation time."""
model.creation_time = creation_time
model.creation_time = iso8601.parse_date(creation_time)
def serialize_last_login_time(self) -> datetime: def serialize_last_login_time(self) -> datetime:
"""User last login time.""" """User last login time."""
@ -76,7 +78,7 @@ class UserTransformer(BaseTransformer):
def deserialize_last_login_time( def deserialize_last_login_time(
model: User, last_login_time: datetime) -> None: model: User, last_login_time: datetime) -> None:
"""User last login time.""" """User last login time."""
model.last_login_time = last_login_time
model.last_login_time = iso8601.parse_date(last_login_time)
def serialize_version(self) -> int: def serialize_version(self) -> int:
"""User version.""" """User version."""

8
server/atheneum/service/user_token_service.py

@ -3,6 +3,8 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Callable, Any from typing import Optional, Dict, Callable, Any
from iso8601 import iso8601
from atheneum.db import db from atheneum.db import db
from atheneum.model import User, UserToken from atheneum.model import User, UserToken
from atheneum.service.transformation_service import ( from atheneum.service.transformation_service import (
@ -77,7 +79,7 @@ class UserTokenTransformer(BaseTransformer):
def deserialize_expiration_time( def deserialize_expiration_time(
model: UserToken, expiration_time: datetime) -> None: model: UserToken, expiration_time: datetime) -> None:
"""User token expiration time.""" """User token expiration time."""
model.expiration_time = expiration_time
model.expiration_time = iso8601.parse_date(expiration_time)
def serialize_creation_time(self) -> datetime: def serialize_creation_time(self) -> datetime:
"""User token creation time.""" """User token creation time."""
@ -87,7 +89,7 @@ class UserTokenTransformer(BaseTransformer):
def deserialize_creation_time( def deserialize_creation_time(
model: UserToken, creation_time: datetime) -> None: model: UserToken, creation_time: datetime) -> None:
"""User token creation time.""" """User token creation time."""
model.creation_time = creation_time
model.creation_time = iso8601.parse_date(creation_time)
def serialize_last_usage_time(self) -> datetime: def serialize_last_usage_time(self) -> datetime:
"""User token last usage time.""" """User token last usage time."""
@ -97,7 +99,7 @@ class UserTokenTransformer(BaseTransformer):
def deserialize_last_usage_time( def deserialize_last_usage_time(
model: UserToken, last_usage_time: datetime) -> None: model: UserToken, last_usage_time: datetime) -> None:
"""User token last usage time.""" """User token last usage time."""
model.last_usage_time = last_usage_time
model.last_usage_time = iso8601.parse_date(last_usage_time)
def serialize_version(self) -> int: def serialize_version(self) -> int:
"""User token version.""" """User token version."""

10
server/atheneum/service/validation_service.py

@ -33,17 +33,23 @@ def get_changable_attribute_names(model: Type[db.Model]) -> Set[str]:
def determine_change_set(original_model: Type[db.Model], def determine_change_set(original_model: Type[db.Model],
update_model: Type[db.Model], update_model: Type[db.Model],
model_attributes: Set[str]) -> Dict[str, Any]:
model_attributes: Set[str],
options: Optional[Set[str]]) -> Dict[str, Any]:
""" """
Determine the change set for two models. Determine the change set for two models.
:param options:
:param original_model: :param original_model:
:param update_model: :param update_model:
:param model_attributes: :param model_attributes:
:return: :return:
""" """
if options is None:
options = model_attributes
else:
options = model_attributes.intersection(options)
change_set = {} change_set = {}
for attribute in model_attributes:
for attribute in options:
original_attribute = getattr(original_model, attribute) original_attribute = getattr(original_model, attribute)
changed_attribute = getattr(update_model, attribute) changed_attribute = getattr(update_model, attribute)
if original_attribute != changed_attribute: if original_attribute != changed_attribute:

30
server/tests/api/test_user_api.py

@ -1,3 +1,6 @@
from datetime import datetime
import rfc3339
from flask import json from flask import json
from flask.testing import FlaskClient from flask.testing import FlaskClient
@ -17,6 +20,33 @@ def test_get_user_happy_path(auth: AuthActions, client: FlaskClient):
assert result.json['name'] == client.application.config['test_username'] assert result.json['name'] == client.application.config['test_username']
def test_patch_user_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
last_login_time = rfc3339.format(datetime.now())
user = client.get(
'/user/{}'.format(client.application.config['test_username']),
headers={
auth_header[0]: auth_header[1]
})
patched_user = client.patch(
'/user/{}'.format(client.application.config['test_username']),
data=json.dumps({
'version': user.json['version'],
'lastLoginTime': last_login_time
}),
headers={
auth_header[0]: auth_header[1],
'Content-Type': 'application/json'
})
assert 200 == patched_user.status_code
assert patched_user.json['version'] == user.json['version'] + 1
assert patched_user.json['lastLoginTime'] == last_login_time
def test_register_user_happy_path(auth: AuthActions, client: FlaskClient): def test_register_user_happy_path(auth: AuthActions, client: FlaskClient):
auth.login() auth.login()
auth_header = auth.get_authorization_header_token() auth_header = auth.get_authorization_header_token()

Loading…
Cancel
Save