Browse Source

Merge branch 'development' into 'master'

Add paginated User endpoint

See merge request warricksothr/Atheneum!7
merge-requests/8/head
Drew Short 6 years ago
parent
commit
8419ecb7e0
  1. 3
      .gitignore
  2. 55
      server/atheneum/api/model.py
  3. 23
      server/atheneum/api/user_api.py
  4. 14
      server/atheneum/service/user_service.py
  5. 4
      server/atheneum/utility/json_utility.py
  6. 20
      server/atheneum/utility/pagination_utility.py
  7. 37
      server/documentation/api/authentication.rst
  8. 138
      server/documentation/api/user.rst
  9. 39
      server/tests/api/test_user_api.py

3
.gitignore

@ -1,4 +1,5 @@
.idea
*.iml
.idea/
*__pycache__/
# Atheneum Specific Ignores

55
server/atheneum/api/model.py

@ -1,8 +1,13 @@
"""Model definitions for the api module."""
from typing import Any, List, Optional, Dict
from typing import Any, List, Optional, Dict, Type
from flask_sqlalchemy import Pagination
class APIResponse: # pylint: disable=too-few-public-methods
from atheneum import db
# pylint: disable=too-few-public-methods
class APIResponse:
"""Custom class to wrap api responses."""
def __init__(self,
@ -15,7 +20,17 @@ class APIResponse: # pylint: disable=too-few-public-methods
self.options = options
class APIMessage: # pylint: disable=too-few-public-methods
# pylint: disable=too-few-public-methods
class BaseAPIMessage:
"""Base class for API responses."""
def to_dict(self) -> Dict[str, Any]:
"""Abstract to_dict."""
raise NotImplementedError('Not Implemented')
# pylint: disable=too-few-public-methods
class APIMessage(BaseAPIMessage):
"""Simple class to encapsulate response messages."""
success: bool
@ -28,7 +43,7 @@ class APIMessage: # pylint: disable=too-few-public-methods
self.success = success
self.message = message
def to_dict(self) -> dict:
def to_dict(self) -> Dict[str, Any]:
"""Serialize an APIMessage to a dict."""
obj: Dict[str, Any] = {
'success': self.success
@ -36,3 +51,35 @@ class APIMessage: # pylint: disable=too-few-public-methods
if self.message is not None:
obj['message'] = self.message
return obj
# pylint: disable=too-few-public-methods
class APIPage(BaseAPIMessage):
"""Simple page response."""
def __init__(self,
page: int,
total_count: int,
last_page: int,
items: List[Type[db.Model]]) -> None:
"""Construct and APIPage."""
self.page = page
self.count = len(items)
self.total_count = total_count
self.last_page = last_page
self.items = items
def to_dict(self) -> Dict[str, Any]:
"""Serialize an APIPage."""
return {
'page': self.page,
'count': self.count,
'totalCount': self.total_count,
'lastPage': self.last_page,
'items': self.items
}
@staticmethod
def from_page(page: Pagination) -> 'APIPage':
"""Create an APIPage from a Pagination object."""
return APIPage(page.page, page.total, page.pages, page.items)

23
server/atheneum/api/user_api.py

@ -1,8 +1,9 @@
"""User API blueprint and endpoint definitions."""
from flask import Blueprint, abort, request, g
from atheneum.api.decorators import return_json
from atheneum.api.model import APIResponse, APIMessage
from atheneum.api.model import APIResponse, APIMessage, APIPage
from atheneum.middleware import authentication_middleware
from atheneum.model import User
from atheneum.service import (
@ -12,11 +13,29 @@ from atheneum.service import (
)
from atheneum.service.patch_service import get_patch_fields
from atheneum.service.role_service import Role
from atheneum.utility.pagination_utility import get_pagination_params
USER_BLUEPRINT = Blueprint(
name='user', import_name=__name__, url_prefix='/user')
@USER_BLUEPRINT.route('', methods=['GET'])
@return_json
@authentication_middleware.require_token_auth
@authentication_middleware.require_role(required_role=Role.USER)
def get_users() -> APIResponse:
"""
Get a list of users.
:return: a paginated list of users
"""
page, per_page = get_pagination_params(request.args)
user_page = user_service.get_users(page, per_page)
if user_page is not None:
return APIResponse(APIPage.from_page(user_page), 200)
return abort(404)
@USER_BLUEPRINT.route('/<name>', methods=['GET'])
@return_json
@authentication_middleware.require_token_auth
@ -53,7 +72,7 @@ def patch_user(name: str) -> APIResponse:
return abort(404)
@USER_BLUEPRINT.route('/', methods=['POST'])
@USER_BLUEPRINT.route('', methods=['POST'])
@return_json
@authentication_middleware.require_token_auth
@authentication_middleware.require_role(required_role=Role.ADMIN)

14
server/atheneum/service/user_service.py

@ -5,6 +5,7 @@ import string
from datetime import datetime
from typing import Optional, Dict, Callable, Any, Tuple
from flask_sqlalchemy import Pagination
from iso8601 import iso8601
from atheneum import errors
@ -148,6 +149,19 @@ class UserValidator(BaseValidator):
return False, 'Role escalation is not permitted'
def get_users(
page: int, per_page: int = 20, max_per_page: int = 100) -> Pagination:
"""
Page through users in the system.
:param page: The page to request
:param per_page: The number per page
:param max_per_page:
:return:
"""
return User.query.paginate(page, per_page, True, max_per_page)
def find_by_name(name: str) -> Optional[User]:
"""
Find a user by name.

4
server/atheneum/utility/json_utility.py

@ -5,7 +5,7 @@ from typing import Any
import rfc3339
from flask.json import JSONEncoder
from atheneum.api.model import APIResponse, APIMessage
from atheneum.api.model import APIResponse, BaseAPIMessage
from atheneum.db import db
from atheneum.errors import BaseError
from atheneum.service.transformation_service import serialize_model
@ -25,7 +25,7 @@ class CustomJSONEncoder(JSONEncoder):
return serialize_model(o.payload, o.options)
if isinstance(payload, BaseError):
return payload.to_dict()
if isinstance(payload, APIMessage):
if isinstance(payload, BaseAPIMessage):
return payload.to_dict()
return payload
if isinstance(o, db.Model):

20
server/atheneum/utility/pagination_utility.py

@ -0,0 +1,20 @@
"""Pagination utility functions."""
from typing import Tuple
from werkzeug.datastructures import MultiDict
from atheneum import errors
def get_pagination_params(request_args: MultiDict) -> Tuple[int, int]:
"""Get page and perPage request parameters."""
page = request_args.get('page', 1)
per_page = request_args.get('perPage', 20)
try:
return int(page), int(per_page)
except ValueError:
raise errors.ClientError(
' '.join([
'Invalid pagination parameters:',
'page={}',
'perPage={}']).format(page, per_page))

37
server/documentation/api/authentication.rst

@ -29,11 +29,16 @@ Authentication API
"version": 0
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged in
:statuscode 401: authorization failed
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Encoded basic authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json datetime creationTime: Creation time for the userToken
:>json datetime expirationTime: Expiration time for the userToken
:>json boolean enabled: Whether the userToken is enabled
:>json string token: UserToken to use for further authentication
:>json int version: Version for the object
:statuscode 200: User successfully logged in
:statuscode 401: Authorization failed
.. http:post:: /auth/bump
@ -60,11 +65,12 @@ Authentication API
"lastLoginTime": "2018-07-29T12:15:51-05:00"
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user last_login_time successfully bumped
:statuscode 401: authorization failed
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Encoded token authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json datetime lastLoginTime: Updated lastLoginTime for the user
:statuscode 200: User last_login_time successfully bumped
:statuscode 401: Authorization failed
.. http:post:: /auth/logout
@ -91,8 +97,9 @@ Authentication API
"success": true
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged out
:statuscode 401: authorization failed
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Rncoded token authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json boolean success: Whether the logout was successful
:statuscode 200: User successfully logged out
:statuscode 401: Authorization failed

138
server/documentation/api/user.rst

@ -1,6 +1,57 @@
User API
========
.. http:get:: /user
Get a page of users.
**Example request**
.. sourcecode:: http
GET /user HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"page": 1,
"count": 1,
"totalCount": 1,
"lastPage": 1,
"items" :[{
"creationTime": "2018-07-29T11:58:17-05:00",
"lastLoginTime": "2018-07-29T12:43:27-05:00",
"name": "atheneum_administrator",
"role": "ADMIN",
"version": 0
}]
}
:query int page: User page to retrieve
:query int perPage: Number of records to retrieve per page (max 100)
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: The encoded basic authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json int page: Page retrieved
:>json int count: Number of items returned
:>json int totalCount: Total number of items available
:>json int lastPage: Last page that can be requested before 404
:>json int items: List of Users
:statuscode 200: Successfully retrieved the user
:statuscode 400: Invalid page or perPage values
:statuscode 401: Authorization failed
:statuscode 404: User page doesn't exist
.. http:get:: /user/(str:user_name)
Find a user by name.
@ -30,12 +81,18 @@ User API
"version": 0
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: successfully retrieved the user
:statuscode 401: authorization failed
:statuscode 404: user doesn't exist
:param string user_name: Name of the user to retrieve information about
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: The encoded basic authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json datetime creationTime: Creation time for the user
:>json datetime lastLoginTime: When the user last logged in, or was last bumped
:>json string name: The user name
:>json string role: The role assigned to the user
:>json int version: Version information
:statuscode 200: Successfully retrieved the user
:statuscode 401: Authorization failed
:statuscode 404: User doesn't exist
.. http:patch:: /user/(str:user_name)
@ -72,16 +129,27 @@ User API
"version": 1
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:reqheader Content-Type: application/json
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: successfully patched the user
:statuscode 400: an issue in the payload was discovered
:statuscode 401: authorization failed
:statuscode 404: user doesn't exist
.. http:post:: /user/
:param string user_name: Name of the user to update
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Encoded token authorization
:<header Content-Type: application/json
:<json datetime createDateTime: Update createDateTime (Administrator Only)
:<json datetime lastLoginTime: Update lastLoginTime
:<json string name: Update user name (Administrator Only)
:<json string role: Update user role (Must be less than or equal to the role authenticating the action)
:<json int version: Must match the latest version of the user
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json datetime creationTime: Creation time for the user
:>json datetime lastLoginTime: When the user last logged in, or was last bumped
:>json string name: The user name
:>json string role: The role assigned to the user
:>json int version: Version information
:statuscode 200: Successfully patched the user
:statuscode 400: An issue in the payload was discovered
:statuscode 401: Authorization failed
:statuscode 404: User doesn't exist
.. http:post:: /user
Register a new user with the service.
@ -89,7 +157,7 @@ User API
.. sourcecode:: http
POST /user/ HTTP/1.1
POST /user HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
@ -116,13 +184,20 @@ User API
"version": 0
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:reqheader Content-Type: application/json
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: successfully registered the user
:statuscode 400: an issue in the payload was discovered
:statuscode 401: authorization failed
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Encoded token authorization
:<header Content-Type: application/json
:<json string name: Name of the user
:<json string password: Password to use
:<json string role: Role to assign to the user (Must be less than or equal to the role of the authenticating user)
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json datetime creationTime: Datetime the user was created
:>json string name: Name of the created user
:>json string role: Role of the created user
:>json int version: Version number of the created user
:statuscode 200: Successfully registered the user
:statuscode 400: An issue in the payload was discovered
:statuscode 401: Authorization failed
.. http:delete:: /user/(str:user_name)
@ -150,9 +225,12 @@ User API
"success": true
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: successfully deleted the user
:statuscode 401: authorization failed
:statuscode 404: user doesn't exist
:param string user_name: Name of the user to delete
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Encoded token authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json string message: Success or failure message
:>json boolean success: Action status indicator
:statuscode 200: Successfully deleted the user
:statuscode 401: Authorization failed
:statuscode 404: User doesn't exist

39
server/tests/api/test_user_api.py

@ -7,6 +7,35 @@ from flask.testing import FlaskClient
from tests.conftest import AuthActions
def test_get_users_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result = client.get(
'/user',
headers={
auth_header[0]: auth_header[1]
})
assert 200 == result.status_code
assert result.json is not None
assert result.json['page'] == 1
assert result.json['lastPage'] == 1
assert result.json['count'] == 1
assert result.json['totalCount'] == 1
assert result.json['items'][0]['name'] == auth.username
def test_get_users_nonexistent_page(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result = client.get(
'/user?page=2',
headers={
auth_header[0]: auth_header[1]
})
assert 404 == result.status_code
assert result.json is not None
def test_get_user_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
@ -51,7 +80,7 @@ def test_register_user_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result = client.post(
'/user/',
'/user',
data=json.dumps({
'name': 'test_registered_user'
}),
@ -69,7 +98,7 @@ def test_register_user_invalid_password(
auth.login()
auth_header = auth.get_authorization_header_token()
result = client.post(
'/user/',
'/user',
data=json.dumps({
'name': 'test_registered_user',
'password': ''
@ -87,7 +116,7 @@ def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result1 = client.post(
'/user/',
'/user',
data=json.dumps({
'name': 'test_registered_user'
}),
@ -96,7 +125,7 @@ def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient):
'Content-Type': 'application/json'
})
result2 = client.post(
'/user/',
'/user',
data=json.dumps({
'name': 'test_registered_user'
}),
@ -116,7 +145,7 @@ def test_delete_user_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result1 = client.post(
'/user/',
'/user',
data=json.dumps({
'name': 'test_registered_user'
}),

Loading…
Cancel
Save