From c811849e7716bfc4830fdcf0ae3183e99e1988e0 Mon Sep 17 00:00:00 2001 From: Drew Short Date: Thu, 26 Jul 2018 23:05:31 -0500 Subject: [PATCH] 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 --- server/Pipfile | 1 + server/Pipfile.lock | 47 ++++++++++++------- server/atheneum/api/user_api.py | 6 ++- server/atheneum/service/patch_service.py | 47 +++++++++++++++---- .../service/transformation_service.py | 7 +++ server/atheneum/service/user_service.py | 6 ++- server/atheneum/service/user_token_service.py | 8 ++-- server/atheneum/service/validation_service.py | 10 +++- server/tests/api/test_user_api.py | 30 ++++++++++++ 9 files changed, 126 insertions(+), 36 deletions(-) diff --git a/server/Pipfile b/server/Pipfile index 699ee27..0b45044 100644 --- a/server/Pipfile +++ b/server/Pipfile @@ -10,6 +10,7 @@ flask-migrate = ">=2.1,<2.2" pynacl = ">=1.2,<1.3" click = "*" "rfc3339" = "*" +"iso8601" = "*" [dev-packages] python-dotenv = "*" diff --git a/server/Pipfile.lock b/server/Pipfile.lock index 56f5236..b322b98 100644 --- a/server/Pipfile.lock +++ b/server/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "5f2e59b2a44c07b72414512802099dc64544fc8e724f5e5b033d627ac0a20626" + "sha256": "b0d90486a769e10025b310d91ee6e7023af9c9e303fce92df9a3541004e3256c" }, "pipfile-spec": 6, "requires": { @@ -18,9 +18,10 @@ "default": { "alembic": { "hashes": [ - "sha256:1cd32df9a3b8c1749082ef60ffbe05ff16617b6afadfdabc680dcb9344af33d7" + "sha256:52d73b1d750f1414fa90c25a08da47b87de1e4ad883935718a8f36396e19e78e", + "sha256:eb7db9b4510562ec37c91d00b00d95fde076c1030d3f661aea882eec532b3565" ], - "version": "==0.9.10" + "version": "==1.0.0" }, "cffi": { "hashes": [ @@ -91,6 +92,15 @@ "index": "pypi", "version": "==2.3.2" }, + "iso8601": { + "hashes": [ + "sha256:210e0134677cc0d02f6028087fee1df1e1d76d372ee1db0bf30bf66c5c1c89a3", + "sha256:49c4b20e1f38aa5cf109ddcd39647ac419f928512c869dc01d5c7098eddede82", + "sha256:bbbae5fb4a7abfe71d4688fd64bff70b91bbd74ef6a99d964bab18f7fdf286dd" + ], + "index": "pypi", + "version": "==0.1.12" + }, "itsdangerous": { "hashes": [ "sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519" @@ -189,9 +199,9 @@ }, "sqlalchemy": { "hashes": [ - "sha256:e21e5561a85dcdf16b8520ae4daec7401c5c24558e0ce004f9b60be75c4b6957" + "sha256:72325e67fb85f6e9ad304c603d83626d1df684fdf0c7ab1f0352e71feeab69d8" ], - "version": "==1.2.9" + "version": "==1.2.10" }, "werkzeug": { "hashes": [ @@ -204,10 +214,10 @@ "develop": { "astroid": { "hashes": [ - "sha256:0ef2bf9f07c3150929b25e8e61b5198c27b0dca195e156f0e4d5bdd89185ca1a", - "sha256:fc9b582dba0366e63540982c3944a9230cbc6f303641c51483fa547dcc22393a" + "sha256:0a0c484279a5f08c9bcedd6fa9b42e378866a7dcc695206b92d59dc9f2d9760d", + "sha256:218e36cf8d98a42f16214e8670819ce307fa707d1dcf7f9af84c7aede1febc7f" ], - "version": "==1.6.5" + "version": "==2.0.1" }, "atomicwrites": { "hashes": [ @@ -228,10 +238,13 @@ "sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba", "sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed", "sha256:104ab3934abaf5be871a583541e8829d6c19ce7bde2923b2751e0d3ca44db60a", + "sha256:10a46017fef60e16694a30627319f38a2b9b52e90182dddb6e37dcdab0f4bf95", "sha256:15b111b6a0f46ee1a485414a52a7ad1d703bdf984e9ed3c288a4414d3871dcbd", "sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640", "sha256:1c383d2ef13ade2acc636556fd544dba6e14fa30755f26812f54300e401f98f2", + "sha256:23d341cdd4a0371820eb2b0bd6b88f5003a7438bbedb33688cd33b8eae59affd", "sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162", + "sha256:2a5b73210bad5279ddb558d9a2bfedc7f4bf6ad7f3c988641d83c40293deaec1", "sha256:2eb564bbf7816a9d68dd3369a510be3327f1c618d2357fa6b1216994c2e3d508", "sha256:337ded681dd2ef9ca04ef5d93cfc87e52e09db2594c296b4a0a3662cb1b41249", "sha256:3a2184c6d797a125dca8367878d3b9a178b6fdd05fdc2d35d758c3006a1cd694", @@ -332,18 +345,18 @@ }, "mypy": { "hashes": [ - "sha256:1b899802a89b67bb68f30d788bba49b61b1f28779436f06b75c03495f9d6ea5c", - "sha256:f472645347430282d62d1f97d12ccb8741f19f1572b7cf30b58280e4e0818739" + "sha256:673ea75fb750289b7d1da1331c125dc62fc1c3a8db9129bb372ae7b7d5bf300a", + "sha256:c770605a579fdd4a014e9f0a34b6c7a36ce69b08100ff728e96e27445cef3b3c" ], "index": "pypi", - "version": "==0.610" + "version": "==0.620" }, "pbr": { "hashes": [ - "sha256:4f2b11d95917af76e936811be8361b2b19616e5ef3b55956a429ec7864378e0c", - "sha256:e0f23b61ec42473723b2fec2f33fb12558ff221ee551962f01dd4de9053c2055" + "sha256:1b8be50d938c9bb75d0eaf7eda111eec1bf6dc88a62a6412e33bf077457e0f45", + "sha256:b486975c0cafb6beeb50ca0e17ba047647f229087bd74e37f4a7e2cac17d2caa" ], - "version": "==4.1.0" + "version": "==4.2.0" }, "pluggy": { "hashes": [ @@ -380,11 +393,11 @@ }, "pylint": { "hashes": [ - "sha256:a48070545c12430cfc4e865bf62f5ad367784765681b3db442d8230f0960aa3c", - "sha256:fff220bcb996b4f7e2b0f6812fd81507b72ca4d8c4d05daf2655c333800cb9b3" + "sha256:2c90a24bee8fae22ac98061c896e61f45c5b73c2e0511a4bf53f99ba56e90434", + "sha256:454532779425098969b8f54ab0f056000b883909f69d05905ea114df886e3251" ], "index": "pypi", - "version": "==1.9.2" + "version": "==2.0.1" }, "pytest": { "hashes": [ diff --git a/server/atheneum/api/user_api.py b/server/atheneum/api/user_api.py index ef382f2..8330895 100644 --- a/server/atheneum/api/user_api.py +++ b/server/atheneum/api/user_api.py @@ -10,6 +10,7 @@ from atheneum.service import ( transformation_service, user_service ) +from atheneum.service.patch_service import get_patch_fields from atheneum.service.role_service import Role USER_BLUEPRINT = Blueprint( @@ -32,7 +33,7 @@ def get_user(name: str) -> APIResponse: return abort(404) -@USER_BLUEPRINT.route('/', methods=['PUT']) +@USER_BLUEPRINT.route('/', methods=['PATCH']) @return_json @authentication_middleware.require_token_auth @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, request.json) 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) except ValueError: return abort(400) diff --git a/server/atheneum/service/patch_service.py b/server/atheneum/service/patch_service.py index 1728427..d30b579 100644 --- a/server/atheneum/service/patch_service.py +++ b/server/atheneum/service/patch_service.py @@ -1,16 +1,25 @@ """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.model import User +from atheneum.service import transformation_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, original_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. @@ -18,10 +27,11 @@ def perform_patch(request_user: User, :param original_model: The model to apply the patches to :param patch_model: The model to pull the patch information from :param model_attributes: The attributes that are valid for patching + :param patched_fields: The explicitly passed fields for patching :return: Thd patched original_model """ 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( request_user, original_model, change_set) if model_validation.success: @@ -37,7 +47,9 @@ def perform_patch(request_user: User, def versioning_aware_patch(request_user: User, original_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. @@ -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 simultaneous edit. + :param patched_fields: :param request_user: :param original_model: The model to apply the patches to :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: patch_model.version = patch_model.version + 1 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.') def patch( request_user: User, 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. :param request_user: :param original_model: The model to apply the patches to :param patch_model: The model to pull the patch information from + :param patched_fields: :return: The patched original_model """ if type(original_model) is type(patch_model): model_attributes = validation_service.get_changable_attribute_names( 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') if 'version' in model_attributes: 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( - request_user, original_model, patch_model, model_attributes) + request_user, + original_model, + patch_model, + model_attributes, + patched_fields) else: raise ValueError( 'Model types "{}" and "{}" do not match'.format( diff --git a/server/atheneum/service/transformation_service.py b/server/atheneum/service/transformation_service.py index f9781f1..a5c8acf 100644 --- a/server/atheneum/service/transformation_service.py +++ b/server/atheneum/service/transformation_service.py @@ -1,5 +1,6 @@ """Handle Model Serialization.""" import logging +import re from typing import Dict, Callable, Any, List, Optional, Type from atheneum import errors @@ -110,3 +111,9 @@ def deserialize_model( except KeyError: raise NotImplementedError( '{} 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() diff --git a/server/atheneum/service/user_service.py b/server/atheneum/service/user_service.py index 9d8cde6..92962ed 100644 --- a/server/atheneum/service/user_service.py +++ b/server/atheneum/service/user_service.py @@ -5,6 +5,8 @@ import string from datetime import datetime from typing import Optional, Dict, Callable, Any, Tuple +from iso8601 import iso8601 + from atheneum import errors from atheneum.db import db from atheneum.model import User @@ -66,7 +68,7 @@ class UserTransformer(BaseTransformer): def deserialize_creation_time( model: User, creation_time: datetime) -> None: """User creation time.""" - model.creation_time = creation_time + model.creation_time = iso8601.parse_date(creation_time) def serialize_last_login_time(self) -> datetime: """User last login time.""" @@ -76,7 +78,7 @@ class UserTransformer(BaseTransformer): def deserialize_last_login_time( model: User, last_login_time: datetime) -> None: """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: """User version.""" diff --git a/server/atheneum/service/user_token_service.py b/server/atheneum/service/user_token_service.py index 6063479..a9fac5a 100644 --- a/server/atheneum/service/user_token_service.py +++ b/server/atheneum/service/user_token_service.py @@ -3,6 +3,8 @@ import uuid from datetime import datetime from typing import Optional, Dict, Callable, Any +from iso8601 import iso8601 + from atheneum.db import db from atheneum.model import User, UserToken from atheneum.service.transformation_service import ( @@ -77,7 +79,7 @@ class UserTokenTransformer(BaseTransformer): def deserialize_expiration_time( model: UserToken, expiration_time: datetime) -> None: """User token expiration time.""" - model.expiration_time = expiration_time + model.expiration_time = iso8601.parse_date(expiration_time) def serialize_creation_time(self) -> datetime: """User token creation time.""" @@ -87,7 +89,7 @@ class UserTokenTransformer(BaseTransformer): def deserialize_creation_time( model: UserToken, creation_time: datetime) -> None: """User token creation time.""" - model.creation_time = creation_time + model.creation_time = iso8601.parse_date(creation_time) def serialize_last_usage_time(self) -> datetime: """User token last usage time.""" @@ -97,7 +99,7 @@ class UserTokenTransformer(BaseTransformer): def deserialize_last_usage_time( model: UserToken, last_usage_time: datetime) -> None: """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: """User token version.""" diff --git a/server/atheneum/service/validation_service.py b/server/atheneum/service/validation_service.py index b225333..0583b5f 100644 --- a/server/atheneum/service/validation_service.py +++ b/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], 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. + :param options: :param original_model: :param update_model: :param model_attributes: :return: """ + if options is None: + options = model_attributes + else: + options = model_attributes.intersection(options) change_set = {} - for attribute in model_attributes: + for attribute in options: original_attribute = getattr(original_model, attribute) changed_attribute = getattr(update_model, attribute) if original_attribute != changed_attribute: diff --git a/server/tests/api/test_user_api.py b/server/tests/api/test_user_api.py index d0d1f73..acb66d5 100644 --- a/server/tests/api/test_user_api.py +++ b/server/tests/api/test_user_api.py @@ -1,3 +1,6 @@ +from datetime import datetime + +import rfc3339 from flask import json 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'] +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): auth.login() auth_header = auth.get_authorization_header_token()