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 6 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"
click = "*"
"rfc3339" = "*"
"iso8601" = "*"
[dev-packages]
python-dotenv = "*"

47
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": [

6
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('/<name>', methods=['PUT'])
@USER_BLUEPRINT.route('/<name>', 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)

47
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(

7
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()

6
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."""

8
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."""

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],
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:

30
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()

Loading…
Cancel
Save