diff --git a/.gitignore b/.gitignore index 9266396..cb5fa29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -.idea +*.iml +.idea/ *__pycache__/ # Atheneum Specific Ignores @@ -7,4 +8,4 @@ /server/.mypy_cache/ /server/.pytest_cache/ /server/documentation/_build/ -/server/instance/ \ No newline at end of file +/server/instance/ diff --git a/server/atheneum/api/model.py b/server/atheneum/api/model.py index 9226e75..c452ae6 100644 --- a/server/atheneum/api/model.py +++ b/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) diff --git a/server/atheneum/api/user_api.py b/server/atheneum/api/user_api.py index 556ba13..ba851f5 100644 --- a/server/atheneum/api/user_api.py +++ b/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('/', 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) diff --git a/server/atheneum/service/user_service.py b/server/atheneum/service/user_service.py index a83ec11..550081a 100644 --- a/server/atheneum/service/user_service.py +++ b/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. diff --git a/server/atheneum/utility/json_utility.py b/server/atheneum/utility/json_utility.py index 283a0f0..3a16382 100644 --- a/server/atheneum/utility/json_utility.py +++ b/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): diff --git a/server/atheneum/utility/pagination_utility.py b/server/atheneum/utility/pagination_utility.py new file mode 100644 index 0000000..b5c9edd --- /dev/null +++ b/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)) diff --git a/server/documentation/api/user.rst b/server/documentation/api/user.rst index b779212..c60e8bc 100644 --- a/server/documentation/api/user.rst +++ b/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 + + **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 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. @@ -98,7 +149,7 @@ User API :statuscode 401: Authorization failed :statuscode 404: User doesn't exist -.. http:post:: /user/ +.. http:post:: /user Register a new user with the service. @@ -106,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 diff --git a/server/tests/api/test_user_api.py b/server/tests/api/test_user_api.py index febd3a0..2e7aacb 100644 --- a/server/tests/api/test_user_api.py +++ b/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' }),