Drew Short
6 years ago
45 changed files with 1907 additions and 122 deletions
-
4.dockerignore
-
15.gitignore
-
31.gitlab-ci.yml
-
6CONTRIBUTING.md
-
23Dockerfile
-
33README.md
-
12server/.dockerignore
-
0server/CHANGELOG.md
-
4server/Dockerfile
-
25server/Pipfile
-
223server/Pipfile.lock
-
57server/README.md
-
4server/corvus/__init__.py
-
6server/corvus/api/authentication_api.py
-
74server/corvus/api/model.py
-
80server/corvus/api/user_api.py
-
11server/corvus/errors.py
-
2server/corvus/middleware/authentication_middleware.py
-
26server/corvus/service/authentication_service.py
-
117server/corvus/service/patch_service.py
-
22server/corvus/service/role_service.py
-
28server/corvus/service/transformation_service.py
-
89server/corvus/service/user_service.py
-
12server/corvus/service/user_token_service.py
-
170server/corvus/service/validation_service.py
-
4server/corvus/utility/json_utility.py
-
20server/corvus/utility/pagination_utility.py
-
1server/corvus/utility/session_utility.py
-
20server/documentation/Makefile
-
128server/documentation/api/authentication.rst
-
9server/documentation/api/index.rst
-
273server/documentation/api/user.rst
-
157server/documentation/conf.py
-
20server/documentation/index.rst
-
4server/documentation/introduction.rst
-
36server/documentation/make.bat
-
2server/manage.py
-
11server/run_tests.sh
-
2server/tests/api/test_authentication_api.py
-
109server/tests/api/test_user_api.py
-
2server/tests/conftest.py
-
87server/tests/service/test_patch_service.py
-
7server/tests/service/test_role_service.py
-
38server/tests/service/test_transformation_service.py
-
25server/tests/service/test_validation_service.py
@ -1,4 +0,0 @@ |
|||
server/instance/ |
|||
server/setup.py |
|||
server/test/ |
|||
.admin_credentials |
@ -1,8 +1,11 @@ |
|||
instance/ |
|||
.idea |
|||
*.iml |
|||
.admin_credentials |
|||
.idea/ |
|||
*__pycache__/ |
|||
.pytest_cache/ |
|||
.coverage |
|||
.mypy_cache/ |
|||
|
|||
# Atheneum Specific Ignores |
|||
/server/.admin_credentials |
|||
/server/.coverage |
|||
/server/.mypy_cache/ |
|||
/server/.pytest_cache/ |
|||
/server/documentation/_build/ |
|||
/server/instance/ |
@ -1,21 +1,32 @@ |
|||
stages: |
|||
- test |
|||
- deploy |
|||
|
|||
Corvus:Tests: |
|||
tests: |
|||
image: python:3.6-slim-stretch |
|||
stage: test |
|||
script: |
|||
- python3 --version |
|||
- python3 -m pip --version |
|||
- python3 -m pip install pipenv |
|||
- python3 -m pipenv --version |
|||
- cd server |
|||
- pipenv install --dev --system |
|||
- pylint corvus |
|||
- mypy corvus tests |
|||
- PYTHONPATH=$(pwd) coverage run --source corvus -m pytest |
|||
- coverage report --fail-under=85 -m --skip-covered |
|||
- pycodestyle corvus tests |
|||
- pydocstyle corvus |
|||
- bash ./run_tests.sh |
|||
tags: |
|||
- docker |
|||
|
|||
pages: |
|||
image: python:3.6-slim-stretch |
|||
stage: deploy |
|||
script: |
|||
- python3 -m pip install pipenv |
|||
- cd server |
|||
- pipenv install --dev --system |
|||
- cd documentation |
|||
- sphinx-build -M html "." "_build" |
|||
- mv _build/html/ ../../public/ |
|||
artifacts: |
|||
paths: |
|||
- public |
|||
tags: |
|||
- docker |
|||
only: |
|||
- master |
@ -0,0 +1,6 @@ |
|||
# Contributing |
|||
|
|||
* Fork the repository |
|||
* Make changes |
|||
* Test everything |
|||
* Open issue and attach patchfile for changes |
@ -1,23 +0,0 @@ |
|||
FROM python:3.6-slim-stretch |
|||
MAINTAINER Drew Short <warrick@sothr.com> |
|||
|
|||
ENV CORVUS_APP_DIRECTORY /opt/corvus |
|||
ENV CORVUS_CONFIG_DIRECTORY /srv/corvus/config |
|||
ENV CORVUS_DATA_DIRECTORY /srv/corvus/data |
|||
|
|||
RUN mkdir -p ${CORVUS_APP_DIRECTORY} \ |
|||
&& mkdir -p ${CORVUS_CONFIG_DIRECTORY} \ |
|||
&& mkdir -p ${CORVUS_DATA_DIRECTORY} \ |
|||
&& pip install pipenv gunicorn |
|||
|
|||
VOLUME ${CORVUS_CONFIG_DIRECTORY} |
|||
VOLUME ${CORVUS_DATA_DIRECTORY} |
|||
|
|||
COPY ./server/ ${CORVUS_APP_DIRECTORY}/ |
|||
|
|||
RUN cd ${CORVUS_APP_DIRECTORY} \ |
|||
&& pipenv install --system --deploy --ignore-pipfile |
|||
|
|||
WORKDIR ${CORVUS_APP_DIRECTORY} |
|||
|
|||
CMD ./entrypoint.sh |
@ -0,0 +1,33 @@ |
|||
# Corvus [![pipeline status](https://gitlab.com/WarrickSothr/Corvus/badges/master/pipeline.svg)](https://gitlab.com/WarrickSothr/Corvus/commits/master) |
|||
|
|||
A python flask framework and web client. |
|||
|
|||
## Parts |
|||
|
|||
### Server |
|||
|
|||
The core API server |
|||
|
|||
More information available at server/README.md |
|||
|
|||
### Administration |
|||
|
|||
The administration SPA. |
|||
|
|||
More information available at administration/README.md |
|||
|
|||
## Release History |
|||
|
|||
## Changelog |
|||
|
|||
See: |
|||
* server/CHANGELOG.md |
|||
* administration/CHANGELOG.md |
|||
|
|||
## FAQ |
|||
|
|||
* TODO |
|||
|
|||
## Maintainers |
|||
|
|||
* Drew Short <warrick(AT)sothr(DOT)com> |
@ -0,0 +1,12 @@ |
|||
.admin_credentials |
|||
.coverage |
|||
.pylintrc |
|||
mypy.ini |
|||
run_tests.sh |
|||
setup.py |
|||
test_settings.py |
|||
.mypy_cache/ |
|||
.pytest_cache/ |
|||
documentation/ |
|||
instance/ |
|||
tests/ |
@ -0,0 +1,57 @@ |
|||
# Corvus Server |
|||
|
|||
## [API Documentation](https://warricksothr.gitlab.io/Corvus) |
|||
|
|||
## Requirements |
|||
|
|||
* Python 3.6 |
|||
* Pipenv |
|||
|
|||
## Installation |
|||
|
|||
```bash |
|||
git clone https://gitlab.com/WarrickSothr/Corvus.git |
|||
cd Corvus/server |
|||
pipenv install |
|||
pipenv shell |
|||
``` |
|||
|
|||
## Configuration |
|||
|
|||
## Running |
|||
|
|||
### Docker |
|||
|
|||
```bash |
|||
docker build -t corvus:local-test . |
|||
docker run -d corvus:local-test |
|||
``` |
|||
|
|||
### Local Development Version |
|||
|
|||
```bash |
|||
FLASK_APP=corvus:corvus flask db upgrade |
|||
python manage.py user register-admin |
|||
FLASK_APP=corvus:corvus flask run |
|||
``` |
|||
|
|||
## FAQ |
|||
|
|||
## Development |
|||
|
|||
```bash |
|||
pipenv install --dev |
|||
``` |
|||
|
|||
* Make changes |
|||
* Add/Update tests |
|||
|
|||
```bash |
|||
./run_tests |
|||
``` |
|||
|
|||
* If everything passes follow contributing guide. |
|||
|
|||
## Contributing |
|||
|
|||
See ../CONTRIBUTING.md |
@ -0,0 +1,117 @@ |
|||
"""Patching support for db.Model objects.""" |
|||
from typing import Type, Set, Optional, Any, Dict |
|||
|
|||
from corvus import db |
|||
from corvus import errors |
|||
from corvus.model import User |
|||
from corvus.service import transformation_service |
|||
from corvus.service import validation_service |
|||
|
|||
|
|||
def get_patch_fields(patch_json: Dict[str, Any]) -> Set[str]: |
|||
"""Convert json fields to python fields.""" |
|||
return { |
|||
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], |
|||
patched_fields: Optional[Set[str]]) \ |
|||
-> Type[db.Model]: |
|||
""" |
|||
Patch changed attributes onto original model. |
|||
|
|||
: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 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, patched_fields) |
|||
model_validation = validation_service.validate_model( |
|||
request_user, original_model, change_set) |
|||
if model_validation.success: |
|||
for attribute, value in change_set.items(): |
|||
setattr(original_model, attribute, value) |
|||
db.session.commit() |
|||
else: |
|||
raise errors.ValidationError( |
|||
'Restricted attributes modified. Invalid Patch Set.') |
|||
return original_model |
|||
|
|||
|
|||
def versioning_aware_patch(request_user: User, |
|||
original_model: Type[db.Model], |
|||
patch_model: Type[db.Model], |
|||
model_attributes: Set[str], |
|||
patched_fields: Optional[Set[str]]) \ |
|||
-> Type[db.Model]: |
|||
""" |
|||
Account for version numbers in the model. |
|||
|
|||
Versions must match to perform the patching. Otherwise a simultaneous edit |
|||
error has occurred. If the versions match and the patch moves forward, bump |
|||
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 |
|||
:param model_attributes: The attributes that are valid for patching |
|||
:return: Thd patched original_model |
|||
""" |
|||
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, |
|||
patched_fields) |
|||
raise errors.ValidationError( |
|||
'Versions do not match. Concurrent edit in progress.') |
|||
|
|||
|
|||
def patch( |
|||
request_user: User, |
|||
original_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 patch_model.id is not None and original_model.id != patch_model.id: |
|||
raise errors.ValidationError('Cannot change ids through patching') |
|||
if 'version' in model_attributes: |
|||
return versioning_aware_patch( |
|||
request_user, |
|||
original_model, |
|||
patch_model, |
|||
model_attributes, |
|||
patched_fields) |
|||
return perform_patch( |
|||
request_user, |
|||
original_model, |
|||
patch_model, |
|||
model_attributes, |
|||
patched_fields) |
|||
raise errors.ValidationError( |
|||
'Model types "{}" and "{}" do not match'.format( |
|||
original_model.__class__.__name__, |
|||
patch_model.__class__.__name__ |
|||
)) |
@ -0,0 +1,170 @@ |
|||
"""Validation service for Corvus models.""" |
|||
|
|||
from typing import Type, Dict, Callable, Any, Set, Optional, Tuple |
|||
|
|||
from sqlalchemy import orm |
|||
|
|||
from corvus import db, errors |
|||
from corvus.model import User |
|||
|
|||
_changable_attribute_names: Dict[str, Set[str]] = {} |
|||
|
|||
|
|||
def get_changable_attribute_names(model: Type[db.Model]) -> Set[str]: |
|||
""" |
|||
Retrieve columns from a SQLAlchemy model. |
|||
|
|||
Caches already seen models to improve performance. |
|||
|
|||
:param model: |
|||
:return: A list of changeable model attribute names |
|||
""" |
|||
class_name = model.__class__.__name__ |
|||
if class_name in _changable_attribute_names: |
|||
return _changable_attribute_names[class_name] |
|||
|
|||
model_attributes = {prop.key for prop in |
|||
orm.class_mapper(model.__class__).iterate_properties |
|||
if isinstance(prop, orm.ColumnProperty)} |
|||
_changable_attribute_names[class_name] = model_attributes |
|||
return model_attributes |
|||
|
|||
|
|||
def determine_change_set(original_model: Type[db.Model], |
|||
update_model: Type[db.Model], |
|||
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 options: |
|||
original_attribute = getattr(original_model, attribute) |
|||
changed_attribute = getattr(update_model, attribute) |
|||
if original_attribute != changed_attribute: |
|||
change_set[attribute] = changed_attribute |
|||
return change_set |
|||
|
|||
|
|||
class ModelValidationResult: # pylint: disable=too-few-public-methods |
|||
"""Result from model validation.""" |
|||
|
|||
field_results: Dict[str, Tuple[bool, str]] |
|||
success: bool |
|||
failed: Dict[str, str] = {} |
|||
|
|||
def __init__(self, field_results: Dict[str, Tuple[bool, str]]) -> None: |
|||
"""Initialize the validation results.""" |
|||
self.field_results = field_results |
|||
self.success = len( |
|||
[result for (result, _) in self.field_results.values() if |
|||
result is False]) == 0 |
|||
if not self.success: |
|||
failed = [(field, rslt[1]) for (field, rslt) in |
|||
self.field_results.items() if rslt[0] is False] |
|||
self.failed = {} |
|||
for field, reason in failed: |
|||
self.failed[field] = reason |
|||
|
|||
|
|||
def get_change_set_value( |
|||
change_set: Optional[Dict[str, Any]], field: str) -> Any: |
|||
"""Read a value or default from changeset.""" |
|||
if change_set is not None and field in change_set.keys(): |
|||
return change_set[field] |
|||
return None |
|||
|
|||
|
|||
class BaseValidator: |
|||
"""Base Model validator.""" |
|||
|
|||
type: Type[db.Model] |
|||
|
|||
def __init__(self, request_user: User, model: Type[db.Model]) -> None: |
|||
"""Initialize the base validator.""" |
|||
self.request_user = request_user |
|||
self._fields: Set[str] = get_changable_attribute_names(model) |
|||
self.model = model |
|||
|
|||
def validate(self, |
|||
change_set: Optional[Dict[str, Any]] = None) \ |
|||
-> ModelValidationResult: |
|||
"""Validate Model fields.""" |
|||
field_validators = self._validators() |
|||
fields_to_validate = self._fields |
|||
if change_set: |
|||
fields_to_validate = set(change_set.keys()) |
|||
validation_results: Dict[str, Tuple[bool, str]] = {} |
|||
for field in fields_to_validate: |
|||
if field not in field_validators: |
|||
raise errors.ValidationError( |
|||
'Invalid key: %r. Valid keys: %r.' % ( |
|||
field, list(sorted(field_validators.keys())))) |
|||
field_validator = field_validators[field] |
|||
field_result = field_validator( |
|||
get_change_set_value(change_set, field)) |
|||
validation_results[field] = field_result |
|||
return ModelValidationResult(validation_results) |
|||
|
|||
def _validators( |
|||
self) -> Dict[str, Callable[[Any], Tuple[bool, str]]]: |
|||
"""Field definitions.""" |
|||
raise NotImplementedError() |
|||
|
|||
@staticmethod |
|||
def no_validation(_new_value: Any) -> Tuple[bool, str]: |
|||
"""Perform no validation.""" |
|||
return True, '' |
|||
|
|||
def validate_version(self, new_version: Any) -> Tuple[bool, str]: |
|||
"""Perform a standard version validation.""" |
|||
if new_version is not None: |
|||
version_increasing = self.model.version <= new_version |
|||
if version_increasing: |
|||
return version_increasing, '' |
|||
return version_increasing, 'Unacceptable version change' |
|||
return True, '' |
|||
|
|||
|
|||
_model_validators: Dict[str, Type[BaseValidator]] = {} |
|||
|
|||
|
|||
def register_validator( |
|||
model_validator: Type[BaseValidator]) -> Type[BaseValidator]: |
|||
"""Add a model to the serializer mapping.""" |
|||
model_name = model_validator.type.__name__ |
|||
if model_name not in _model_validators: |
|||
_model_validators[model_name] = model_validator |
|||
else: |
|||
raise KeyError( |
|||
' '.join([ |
|||
'A validator for type "{}" already exists with class "{}".', |
|||
'Cannot register a new validator with class "{}"' |
|||
]).format( |
|||
model_name, |
|||
_model_validators[model_name].__name__, |
|||
model_validator.__name__)) |
|||
return model_validator |
|||
|
|||
|
|||
def validate_model(request_user: User, |
|||
model_obj: db.Model, |
|||
change_set: Optional[Dict[str, Any]] = None) \ |
|||
-> ModelValidationResult: |
|||
"""Lookup a Model and hand off to the validator.""" |
|||
try: |
|||
return _model_validators[type(model_obj).__name__]( |
|||
request_user, model_obj).validate(change_set) |
|||
except KeyError: |
|||
raise NotImplementedError( |
|||
'{} has no registered validator'.format(model_obj.__name__)) |
@ -0,0 +1,20 @@ |
|||
"""Pagination utility functions.""" |
|||
from typing import Tuple |
|||
|
|||
from werkzeug.datastructures import MultiDict |
|||
|
|||
from corvus 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)) |
@ -0,0 +1,20 @@ |
|||
# Minimal makefile for Sphinx documentation
|
|||
#
|
|||
|
|||
# You can set these variables from the command line.
|
|||
SPHINXOPTS = |
|||
SPHINXBUILD = sphinx-build |
|||
SPHINXPROJ = Corvus |
|||
SOURCEDIR = . |
|||
BUILDDIR = _build |
|||
|
|||
# Put it first so that "make" without argument is like "make help".
|
|||
help: |
|||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) |
|||
|
|||
.PHONY: help Makefile |
|||
|
|||
# Catch-all target: route all unknown targets to Sphinx using the new
|
|||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
|||
%: Makefile |
|||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) |
@ -0,0 +1,128 @@ |
|||
Authentication API |
|||
================== |
|||
|
|||
.. http:post:: /auth/login |
|||
|
|||
Authenticate with the server and receive a userToken for requests. |
|||
|
|||
**Example request**: |
|||
|
|||
.. sourcecode:: http |
|||
|
|||
POST /auth/login HTTP/1.1 |
|||
Host: example.tld |
|||
Accept: application/json |
|||
Authorization: Basic <Base64 Encoded Basic Auth> |
|||
|
|||
**Example response**: |
|||
|
|||
.. sourcecode:: http |
|||
|
|||
HTTP/1.1 200 OK |
|||
Vary: Accept |
|||
Content-Type: application/json |
|||
|
|||
{ |
|||
"creationTime": "2018-07-29T11:59:29-05:00", |
|||
"enabled": true, |
|||
"token": "b94cf5c7-cddc-4610-9d4c-6b8e04088ae8", |
|||
"version": 0 |
|||
} |
|||
|
|||
:<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 |
|||
|
|||
Bump user login information. |
|||
|
|||
**Example request**: |
|||
|
|||
.. sourcecode:: http |
|||
|
|||
POST /auth/bump 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 |
|||
|
|||
{ |
|||
"lastLoginTime": "2018-07-29T12:15:51-05:00" |
|||
} |
|||
|
|||
:<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 |
|||
|
|||
Logout a user and remove the provided userToken from valid tokens. |
|||
|
|||
**Example request**: |
|||
|
|||
.. sourcecode:: http |
|||
|
|||
POST /auth/logout 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 |
|||
|
|||
{ |
|||
"success": true |
|||
} |
|||
|
|||
:<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 |
|||
|
|||
Authentication Object Models |
|||
============================ |
|||
|
|||
.. json:object:: UserToken |
|||
:showexample: |
|||
|
|||
UserToken definition |
|||
|
|||
:property token: The token value, used for authentication |
|||
:proptype token: string |
|||
:property note: Additional information about the token |
|||
:proptype note: string |
|||
:property enabled: Determine if a token will be accepted |
|||
:proptype enabled: boolean |
|||
:property expirationTime: The time that the token becomes invalid, regardless of enabled state |
|||
:proptype expirationTime: iso8601 |
|||
:property creationTime: The time that the token was created |
|||
:proptype creationTime: iso8601 |
|||
:property lastUsageTime: The time that the token was last used |
|||
:proptype lastUsageTime: iso8601 |
|||
:property version: An identifier for the token version |
|||
:proptype version: integer |
@ -0,0 +1,9 @@ |
|||
Corvus API documentation |
|||
========================== |
|||
|
|||
.. toctree:: |
|||
:maxdepth: 2 |
|||
:caption: Contents: |
|||
|
|||
authentication |
|||
user |
@ -0,0 +1,273 @@ |
|||
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": "corvus_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. |
|||
|
|||
**Example request**: |
|||
|
|||
.. sourcecode:: http |
|||
|
|||
GET /user/corvus_administrator 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 |
|||
|
|||
{ |
|||
"creationTime": "2018-07-29T11:58:17-05:00", |
|||
"lastLoginTime": "2018-07-29T12:43:27-05:00", |
|||
"name": "corvus_administrator", |
|||
"role": "ADMIN", |
|||
"version": 0 |
|||
} |
|||
|
|||
: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) |
|||
|
|||
Patch a user. |
|||
|
|||
**Example request**: |
|||
|
|||
.. sourcecode:: http |
|||
|
|||
PATCH /user/corvus_administrator HTTP/1.1 |
|||
Host: example.tld |
|||
Accept: application/json |
|||
Authorization: Token <Base64(user:userToken)> |
|||
Content-Type: application/json |
|||
|
|||
{ |
|||
"lastLoginTime": "2019-07-29T12:43:27-05:00", |
|||
"version": 0 |
|||
} |
|||
|
|||
**Example response**: |
|||
|
|||
.. sourcecode:: http |
|||
|
|||
HTTP/1.1 200 OK |
|||
Vary: Accept |
|||
Content-Type: application/json |
|||
|
|||
{ |
|||
"creationTime": "2018-07-29T11:58:17-05:00", |
|||
"lastLoginTime": "2019-07-29T12:43:27-05:00", |
|||
"name": "corvus_administrator", |
|||
"role": "ADMIN", |
|||
"version": 1 |
|||
} |
|||
|
|||
: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. |
|||
|
|||
**Example request**: |
|||
|
|||
.. sourcecode:: http |
|||
|
|||
POST /user HTTP/1.1 |
|||
Host: example.tld |
|||
Accept: application/json |
|||
Authorization: Token <Base64(user:userToken)> |
|||
Content-Type: application/json |
|||
|
|||
{ |
|||
"name": "test_user", |
|||
"password": "JvZ9bm79", |
|||
"role": "USER" |
|||
} |
|||
|
|||
**Example response**: |
|||
|
|||
.. sourcecode:: http |
|||
|
|||
HTTP/1.1 200 OK |
|||
Vary: Accept |
|||
Content-Type: application/json |
|||
|
|||
{ |
|||
"creationTime": "2018-07-29T14:16:48-05:00", |
|||
"name": "test_user", |
|||
"role": "USER", |
|||
"version": 0 |
|||
} |
|||
|
|||
:<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) |
|||
|
|||
Register a new user with the service. |
|||
|
|||
**Example request**: |
|||
|
|||
.. sourcecode:: http |
|||
|
|||
DELETE /user/test_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 |
|||
|
|||
{ |
|||
"message": "Successfully Deleted", |
|||
"success": true |
|||
} |
|||
|
|||
: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 |
|||
|
|||
User Object Models |
|||
================== |
|||
|
|||
.. json:object:: Page<User> |
|||
:showexample: |
|||
|
|||
Page<User> definition |
|||
|
|||
:property page: The page returned |
|||
:proptype page: integer |
|||
:property count: The number of items on this page |
|||
:proptype count: integer |
|||
:property totalCount: The total number of items available |
|||
:proptype totalCount: integer |
|||
:property lastPage: The last page that is accessible before 404 |
|||
:proptype lastPage: integer |
|||
:property items: The list of items on the page |
|||
:proptype items: :json:list: 'User' |
|||
|
|||
.. json:object:: User |
|||
:showexample: |
|||
|
|||
User definition |
|||
|
|||
:property name: The unique name of the user |
|||
:proptype name: string |
|||
:property creationTime: The time that the user was created |
|||
:proptype creationTime: iso8601 |
|||
:property lastLoginTime: The time that the user last logged in |
|||
:proptype lastLoginTime: iso8601 |
|||
:property version: An identifier for the user version |
|||
:proptype version: integer |
|||
:property role: The assigned role for the user |
|||
:proptype role: string |
@ -0,0 +1,157 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# |
|||
# Configuration file for the Sphinx documentation builder. |
|||
# |
|||
# This file does only contain a selection of the most common options. For a |
|||
# full list see the documentation: |
|||
# http://www.sphinx-doc.org/en/master/config |
|||
|
|||
# -- Path setup -------------------------------------------------------------- |
|||
|
|||
# If extensions (or modules to document with autodoc) are in another directory, |
|||
# add these directories to sys.path here. If the directory is relative to the |
|||
# documentation root, use os.path.abspath to make it absolute, like shown here. |
|||
# |
|||
# import os |
|||
# import sys |
|||
# sys.path.insert(0, os.path.abspath('.')) |
|||
|
|||
|
|||
# -- Project information ----------------------------------------------------- |
|||
|
|||
project = 'Corvus' |
|||
copyright = '2018, Drew Short' |
|||
author = 'Drew Short' |
|||
|
|||
# The short X.Y version |
|||
version = '2018.8' |
|||
# The full version, including alpha/beta/rc tags |
|||
release = '2018.8.1' |
|||
|
|||
|
|||
# -- General configuration --------------------------------------------------- |
|||
|
|||
# If your documentation needs a minimal Sphinx version, state it here. |
|||
# |
|||
# needs_sphinx = '1.0' |
|||
|
|||
# Add any Sphinx extension module names here, as strings. They can be |
|||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom |
|||
# ones. |
|||
extensions = [ |
|||
'sphinxcontrib.httpdomain', |
|||
'sphinxjsondomain' |
|||
] |
|||
|
|||
# Add any paths that contain templates here, relative to this directory. |
|||
templates_path = ['_templates'] |
|||
|
|||
# The suffix(es) of source filenames. |
|||
# You can specify multiple suffix as a list of string: |
|||
# |
|||
# source_suffix = ['.rst', '.md'] |
|||
source_suffix = '.rst' |
|||
|
|||
# The master toctree document. |
|||
master_doc = 'index' |
|||
|
|||
# The language for content autogenerated by Sphinx. Refer to documentation |
|||
# for a list of supported languages. |
|||
# |
|||
# This is also used if you do content translation via gettext catalogs. |
|||
# Usually you set "language" from the command line for these cases. |
|||
language = None |
|||
|
|||
# List of patterns, relative to source directory, that match files and |
|||
# directories to ignore when looking for source files. |
|||
# This pattern also affects html_static_path and html_extra_path . |
|||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] |
|||
|
|||
# The name of the Pygments (syntax highlighting) style to use. |
|||
pygments_style = 'sphinx' |
|||
|
|||
|
|||
# -- Options for HTML output ------------------------------------------------- |
|||
|
|||
# The theme to use for HTML and HTML Help pages. See the documentation for |
|||
# a list of builtin themes. |
|||
# |
|||
html_theme = 'sphinx_rtd_theme' |
|||
|
|||
# Theme options are theme-specific and customize the look and feel of a theme |
|||
# further. For a list of options available for each theme, see the |
|||
# documentation. |
|||
# |
|||
# html_theme_options = {} |
|||
|
|||
# Add any paths that contain custom static files (such as style sheets) here, |
|||
# relative to this directory. They are copied after the builtin static files, |
|||
# so a file named "default.css" will overwrite the builtin "default.css". |
|||
html_static_path = ['_static'] |
|||
|
|||
# Custom sidebar templates, must be a dictionary that maps document names |
|||
# to template names. |
|||
# |
|||
# The default sidebars (for documents that don't match any pattern) are |
|||
# defined by theme itself. Builtin themes are using these templates by |
|||
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', |
|||
# 'searchbox.html']``. |
|||
# |
|||
# html_sidebars = {} |
|||
|
|||
|
|||
# -- Options for HTMLHelp output --------------------------------------------- |
|||
|
|||
# Output file base name for HTML help builder. |
|||
htmlhelp_basename = 'Corvusdoc' |
|||
|
|||
|
|||
# -- Options for LaTeX output ------------------------------------------------ |
|||
|
|||
latex_elements = { |
|||
# The paper size ('letterpaper' or 'a4paper'). |
|||
# |
|||
# 'papersize': 'letterpaper', |
|||
|
|||
# The font size ('10pt', '11pt' or '12pt'). |
|||
# |
|||
# 'pointsize': '10pt', |
|||
|
|||
# Additional stuff for the LaTeX preamble. |
|||
# |
|||
# 'preamble': '', |
|||
|
|||
# Latex figure (float) alignment |
|||
# |
|||
# 'figure_align': 'htbp', |
|||
} |
|||
|
|||
# Grouping the document tree into LaTeX files. List of tuples |
|||
# (source start file, target name, title, |
|||
# author, documentclass [howto, manual, or own class]). |
|||
latex_documents = [ |
|||
(master_doc, 'Corvus.tex', 'Corvus Documentation', |
|||
'Drew Short', 'manual'), |
|||
] |
|||
|
|||
|
|||
# -- Options for manual page output ------------------------------------------ |
|||
|
|||
# One entry per manual page. List of tuples |
|||
# (source start file, name, description, authors, manual section). |
|||
man_pages = [ |
|||
(master_doc, 'corvus', 'Corvus Documentation', |
|||
[author], 1) |
|||
] |
|||
|
|||
|
|||
# -- Options for Texinfo output ---------------------------------------------- |
|||
|
|||
# Grouping the document tree into Texinfo files. List of tuples |
|||
# (source start file, target name, title, author, |
|||
# dir menu entry, description, category) |
|||
texinfo_documents = [ |
|||
(master_doc, 'Corvus', 'Corvus Documentation', |
|||
author, 'Corvus', 'One line description of project.', |
|||
'Miscellaneous'), |
|||
] |
@ -0,0 +1,20 @@ |
|||
.. Corvus documentation master file, created by |
|||
sphinx-quickstart on Sun Jul 29 11:09:44 2018. |
|||
You can adapt this file completely to your liking, but it should at least |
|||
contain the root `toctree` directive. |
|||
|
|||
Welcome to Corvus's documentation! |
|||
==================================== |
|||
|
|||
.. toctree:: |
|||
:maxdepth: 2 |
|||
:caption: Contents: |
|||
|
|||
introduction |
|||
api/index |
|||
|
|||
Indices and tables |
|||
================== |
|||
|
|||
* :ref:`genindex` |
|||
* :ref:`search` |
@ -0,0 +1,4 @@ |
|||
Introduction To Corvus |
|||
======================== |
|||
|
|||
TODO |
@ -0,0 +1,36 @@ |
|||
@ECHO OFF |
|||
|
|||
pushd %~dp0 |
|||
|
|||
REM Command file for Sphinx documentation |
|||
|
|||
if "%SPHINXBUILD%" == "" ( |
|||
set SPHINXBUILD=sphinx-build |
|||
) |
|||
set SOURCEDIR=. |
|||
set BUILDDIR=_build |
|||
set SPHINXPROJ=Corvus |
|||
|
|||
if "%1" == "" goto help |
|||
|
|||
%SPHINXBUILD% >NUL 2>NUL |
|||
if errorlevel 9009 ( |
|||
echo. |
|||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx |
|||
echo.installed, then set the SPHINXBUILD environment variable to point |
|||
echo.to the full path of the 'sphinx-build' executable. Alternatively you |
|||
echo.may add the Sphinx directory to PATH. |
|||
echo. |
|||
echo.If you don't have Sphinx installed, grab it from |
|||
echo.http://sphinx-doc.org/ |
|||
exit /b 1 |
|||
) |
|||
|
|||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% |
|||
goto end |
|||
|
|||
:help |
|||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% |
|||
|
|||
:end |
|||
popd |
@ -0,0 +1,87 @@ |
|||
from datetime import datetime, timedelta |
|||
|
|||
import pytest |
|||
from mock import MagicMock, patch |
|||
|
|||
from corvus import errors |
|||
from corvus.model import UserToken, User |
|||
from corvus.service import patch_service, role_service |
|||
|
|||
service_module = 'corvus.service.patch_service' |
|||
|
|||
|
|||
@patch(service_module + '.db.session.commit') |
|||
def test_patch_models( |
|||
mock_db_session_commit: MagicMock): |
|||
request_user = User() |
|||
request_user.role = role_service.Role.ADMIN |
|||
|
|||
user = User() |
|||
user.name = 'TestUser' |
|||
user.version = 1 |
|||
user.last_login_time = datetime.now() - timedelta(days=1) |
|||
|
|||
user_patch = User() |
|||
user_patch.name = 'TestUser' |
|||
user_patch.version = 1 |
|||
user_patch.last_login_time = datetime.now() |
|||
|
|||
patched_user = patch_service.patch(request_user, user, user_patch) |
|||
assert patched_user.version > 1 |
|||
assert patched_user.last_login_time == user_patch.last_login_time |
|||
mock_db_session_commit.assert_called_once() |
|||
|
|||
|
|||
def test_patch_of_different_types(): |
|||
request_user = User() |
|||
request_user.role = role_service.Role.ADMIN |
|||
|
|||
user = User() |
|||
user_token = UserToken() |
|||
|
|||
with pytest.raises(errors.ValidationError) as error_info: |
|||
patch_service.patch(request_user, user, user_token) |
|||
|
|||
|
|||
def test_patch_different_ids(): |
|||
request_user = User() |
|||
request_user.role = role_service.Role.ADMIN |
|||
|
|||
user1 = User() |
|||
user1.id = 1 |
|||
|
|||
user2 = User() |
|||
user2.id = 2 |
|||
|
|||
with pytest.raises(errors.ValidationError) as error_info: |
|||
patch_service.patch(request_user, user1, user2) |
|||
|
|||
|
|||
def test_patch_different_versions(): |
|||
request_user = User() |
|||
request_user.role = role_service.Role.ADMIN |
|||
|
|||
user1 = User() |
|||
user1.version = 1 |
|||
|
|||
user2 = User() |
|||
user2.version = 2 |
|||
|
|||
with pytest.raises(errors.ValidationError) as error_info: |
|||
patch_service.patch(request_user, user1, user2) |
|||
|
|||
|
|||
def test_patch_restricted_attributes(): |
|||
request_user = User() |
|||
request_user.role = role_service.Role.USER |
|||
|
|||
user1 = User() |
|||
user1.version = 1 |
|||
user1.name = 'Bob' |
|||
|
|||
user2 = User() |
|||
user2.version = 1 |
|||
user2.name = 'Chris' |
|||
|
|||
with pytest.raises(errors.ValidationError) as error_info: |
|||
patch_service.patch(request_user, user1, user2) |
@ -0,0 +1,25 @@ |
|||
from corvus.model import User |
|||
from corvus.service import role_service, validation_service |
|||
|
|||
|
|||
def test_successful_validation(): |
|||
request_user = User() |
|||
request_user.role = role_service.Role.USER |
|||
|
|||
user = User() |
|||
user.role = role_service.Role.USER |
|||
|
|||
validation_result = validation_service.validate_model(request_user, user) |
|||
assert validation_result.success |
|||
|
|||
|
|||
def test_failed_validation(): |
|||
request_user = User() |
|||
request_user.role = role_service.Role.ANONYMOUS |
|||
|
|||
user = User() |
|||
user.role = role_service.Role.USER |
|||
|
|||
validation_result = validation_service.validate_model(request_user, user) |
|||
assert validation_result.success is False |
|||
assert 'role' in validation_result.failed |
Write
Preview
Loading…
Cancel
Save
Reference in new issue