Browse Source

Added paginated user endpoint

* Updated User API documentation
merge-requests/7/head
Drew Short 6 years ago
parent
commit
747ebed08b
  1. 5
      .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. 55
      server/documentation/api/user.rst
  8. 39
      server/tests/api/test_user_api.py

5
.gitignore

@ -1,4 +1,5 @@
.idea
*.iml
.idea/
*__pycache__/ *__pycache__/
# Atheneum Specific Ignores # Atheneum Specific Ignores
@ -7,4 +8,4 @@
/server/.mypy_cache/ /server/.mypy_cache/
/server/.pytest_cache/ /server/.pytest_cache/
/server/documentation/_build/ /server/documentation/_build/
/server/instance/
/server/instance/

55
server/atheneum/api/model.py

@ -1,8 +1,13 @@
"""Model definitions for the api module.""" """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.""" """Custom class to wrap api responses."""
def __init__(self, def __init__(self,
@ -15,7 +20,17 @@ class APIResponse: # pylint: disable=too-few-public-methods
self.options = options 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.""" """Simple class to encapsulate response messages."""
success: bool success: bool
@ -28,7 +43,7 @@ class APIMessage: # pylint: disable=too-few-public-methods
self.success = success self.success = success
self.message = message self.message = message
def to_dict(self) -> dict:
def to_dict(self) -> Dict[str, Any]:
"""Serialize an APIMessage to a dict.""" """Serialize an APIMessage to a dict."""
obj: Dict[str, Any] = { obj: Dict[str, Any] = {
'success': self.success 'success': self.success
@ -36,3 +51,35 @@ class APIMessage: # pylint: disable=too-few-public-methods
if self.message is not None: if self.message is not None:
obj['message'] = self.message obj['message'] = self.message
return obj 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.""" """User API blueprint and endpoint definitions."""
from flask import Blueprint, abort, request, g from flask import Blueprint, abort, request, g
from atheneum.api.decorators import return_json 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.middleware import authentication_middleware
from atheneum.model import User from atheneum.model import User
from atheneum.service import ( from atheneum.service import (
@ -12,11 +13,29 @@ from atheneum.service import (
) )
from atheneum.service.patch_service import get_patch_fields from atheneum.service.patch_service import get_patch_fields
from atheneum.service.role_service import Role from atheneum.service.role_service import Role
from atheneum.utility.pagination_utility import get_pagination_params
USER_BLUEPRINT = Blueprint( USER_BLUEPRINT = Blueprint(
name='user', import_name=__name__, url_prefix='/user') 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']) @USER_BLUEPRINT.route('/<name>', methods=['GET'])
@return_json @return_json
@authentication_middleware.require_token_auth @authentication_middleware.require_token_auth
@ -53,7 +72,7 @@ def patch_user(name: str) -> APIResponse:
return abort(404) return abort(404)
@USER_BLUEPRINT.route('/', methods=['POST'])
@USER_BLUEPRINT.route('', methods=['POST'])
@return_json @return_json
@authentication_middleware.require_token_auth @authentication_middleware.require_token_auth
@authentication_middleware.require_role(required_role=Role.ADMIN) @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 datetime import datetime
from typing import Optional, Dict, Callable, Any, Tuple from typing import Optional, Dict, Callable, Any, Tuple
from flask_sqlalchemy import Pagination
from iso8601 import iso8601 from iso8601 import iso8601
from atheneum import errors from atheneum import errors
@ -148,6 +149,19 @@ class UserValidator(BaseValidator):
return False, 'Role escalation is not permitted' 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]: def find_by_name(name: str) -> Optional[User]:
""" """
Find a user by name. Find a user by name.

4
server/atheneum/utility/json_utility.py

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

55
server/documentation/api/user.rst

@ -1,6 +1,57 @@
User API 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) .. http:get:: /user/(str:user_name)
Find a user by name. Find a user by name.
@ -98,7 +149,7 @@ User API
:statuscode 401: Authorization failed :statuscode 401: Authorization failed
:statuscode 404: User doesn't exist :statuscode 404: User doesn't exist
.. http:post:: /user/
.. http:post:: /user
Register a new user with the service. Register a new user with the service.
@ -106,7 +157,7 @@ User API
.. sourcecode:: http .. sourcecode:: http
POST /user/ HTTP/1.1
POST /user HTTP/1.1
Host: example.tld Host: example.tld
Accept: application/json Accept: application/json
Authorization: Token <Base64(user:userToken)> Authorization: Token <Base64(user:userToken)>

39
server/tests/api/test_user_api.py

@ -7,6 +7,35 @@ from flask.testing import FlaskClient
from tests.conftest import AuthActions 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): def test_get_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()
@ -51,7 +80,7 @@ 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()
result = client.post( result = client.post(
'/user/',
'/user',
data=json.dumps({ data=json.dumps({
'name': 'test_registered_user' 'name': 'test_registered_user'
}), }),
@ -69,7 +98,7 @@ def test_register_user_invalid_password(
auth.login() auth.login()
auth_header = auth.get_authorization_header_token() auth_header = auth.get_authorization_header_token()
result = client.post( result = client.post(
'/user/',
'/user',
data=json.dumps({ data=json.dumps({
'name': 'test_registered_user', 'name': 'test_registered_user',
'password': '' 'password': ''
@ -87,7 +116,7 @@ def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient):
auth.login() auth.login()
auth_header = auth.get_authorization_header_token() auth_header = auth.get_authorization_header_token()
result1 = client.post( result1 = client.post(
'/user/',
'/user',
data=json.dumps({ data=json.dumps({
'name': 'test_registered_user' 'name': 'test_registered_user'
}), }),
@ -96,7 +125,7 @@ def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient):
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}) })
result2 = client.post( result2 = client.post(
'/user/',
'/user',
data=json.dumps({ data=json.dumps({
'name': 'test_registered_user' 'name': 'test_registered_user'
}), }),
@ -116,7 +145,7 @@ def test_delete_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()
result1 = client.post( result1 = client.post(
'/user/',
'/user',
data=json.dumps({ data=json.dumps({
'name': 'test_registered_user' 'name': 'test_registered_user'
}), }),

Loading…
Cancel
Save