├── .gitignore ├── .travis.yml ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── api_utils ├── __init__.py ├── app.py ├── auth.py ├── compat.py └── formatters.py ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── test_auth.py ├── test_flask_app.py └── utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | /.tox 4 | /MANIFEST 5 | /dist 6 | /Flask_API_Utils.egg-info 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.7 4 | - 3.3 5 | - 3.4 6 | install: 7 | - pip install mock 'Flask>=0.10' mohawk Flask-Login --use-mirrors 8 | - python setup.py install 9 | script: nosetests tests 10 | notifications: 11 | email: 12 | recipients: 13 | - marselester@ya.ru 14 | on_success: change 15 | on_failure: change 16 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | Version 1.0.2 6 | ------------- 7 | 8 | - **HAWK_ENABLED** config was added. It can be convenient to globally turn off 9 | authentication when unit testing. 10 | 11 | Version 1.0.1 12 | ------------- 13 | 14 | - Fix **__all__** tuple issue, thanks @attila 15 | - Fix Flask-Login **user.is_authenticated** compatibility issue 16 | 17 | Version 1.0.0 18 | ------------- 19 | 20 | Release contains interface changes. 21 | 22 | - Hawk extension was added. It supports Hawk HTTP authentication scheme 23 | and Flask-Login extension. 24 | - Default HTTP error handler was removed from **ResponsiveFlask**. 25 | You have to manually set your own one by using 26 | **@app.default_errorhandler** decorator. 27 | 28 | Version 0.2.0 29 | ------------- 30 | 31 | - Http error handling was added. 32 | 33 | Version 0.1.0 34 | ------------- 35 | 36 | - Initial release. 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Marsel Mavletkulov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Flask-API-Utils 3 | =============== 4 | 5 | .. image:: https://travis-ci.org/marselester/flask-api-utils.png 6 | :target: https://travis-ci.org/marselester/flask-api-utils 7 | 8 | Flask-API-Utils helps you to create APIs. It makes responses in appropriate 9 | formats, for instance, JSON. All you need to do is to return dictionary 10 | from your views. Another useful feature is an authentication. 11 | The library supports Hawk_ HTTP authentication scheme and `Flask-Login`_ 12 | extension. To sum up, there is an `API example project`_. 13 | 14 | "Accept" Header based Response 15 | ------------------------------ 16 | 17 | **ResponsiveFlask** tends to make responses based on **Accept** 18 | request-header (RFC 2616). If a view function does not return a dictionary, 19 | then response will be processed as usual. Here is an example. 20 | 21 | .. code-block:: python 22 | 23 | from api_utils import ResponsiveFlask 24 | 25 | app = ResponsiveFlask(__name__) 26 | 27 | 28 | @app.route('/') 29 | def hello_world(): 30 | return {'hello': 'world'} 31 | 32 | 33 | def dummy_xml_formatter(*args, **kwargs): 34 | return 'world' 35 | 36 | xml_mimetype = 'application/vnd.company+xml' 37 | app.response_formatters[xml_mimetype] = dummy_xml_formatter 38 | 39 | if __name__ == '__main__': 40 | app.run() 41 | 42 | 43 | It's assumed that file was saved as ``api.py``: 44 | 45 | .. code-block:: console 46 | 47 | $ python api.py 48 | * Running on http://127.0.0.1:5000/ 49 | 50 | Here are curl examples with different **Accept** headers: 51 | 52 | .. code-block:: console 53 | 54 | $ curl http://127.0.0.1:5000/ -i 55 | HTTP/1.0 200 OK 56 | Content-Type: application/json 57 | Content-Length: 22 58 | Server: Werkzeug/0.9.4 Python/2.7.5 59 | Date: Sat, 07 Dec 2013 14:01:14 GMT 60 | 61 | { 62 | "hello": "world" 63 | } 64 | $ curl http://127.0.0.1:5000/ -H 'Accept: application/vnd.company+xml' -i 65 | HTTP/1.0 200 OK 66 | Content-Type: application/vnd.company+xml; charset=utf-8 67 | Content-Length: 20 68 | Server: Werkzeug/0.9.4 Python/2.7.5 69 | Date: Sat, 07 Dec 2013 14:01:50 GMT 70 | 71 | world 72 | $ curl http://127.0.0.1:5000/ -H 'Accept: blah/*' -i 73 | HTTP/1.0 406 NOT ACCEPTABLE 74 | Content-Type: application/json 75 | Content-Length: 83 76 | Server: Werkzeug/0.9.4 Python/2.7.5 77 | Date: Sat, 07 Dec 2013 14:02:23 GMT 78 | 79 | { 80 | "mimetypes": [ 81 | "application/json", 82 | "application/vnd.company+xml" 83 | ] 84 | } 85 | 86 | HTTP Error Handling 87 | ------------------- 88 | 89 | You can set HTTP error handler by using **@app.default_errorhandler** 90 | decorator. Note that it might override already defined error handlers, 91 | so you should declare it before them. 92 | 93 | .. code-block:: python 94 | 95 | from flask import request 96 | from api_utils import ResponsiveFlask 97 | 98 | app = ResponsiveFlask(__name__) 99 | 100 | 101 | @app.default_errorhandler 102 | def werkzeug_default_exceptions_handler(error): 103 | error_info_url = ( 104 | 'http://developer.example.com/errors.html#error-code-{}' 105 | ).format(error.code) 106 | 107 | response = { 108 | 'code': error.code, 109 | 'message': str(error), 110 | 'info_url': error_info_url, 111 | } 112 | return response, error.code 113 | 114 | 115 | @app.errorhandler(404) 116 | def page_not_found(error): 117 | return {'error': 'This page does not exist'}, 404 118 | 119 | 120 | class MyException(Exception): 121 | pass 122 | 123 | 124 | @app.errorhandler(MyException) 125 | def special_exception_handler(error): 126 | return {'error': str(error)} 127 | 128 | 129 | @app.route('/my-exc') 130 | def hello_my_exception(): 131 | raise MyException('Krivens!') 132 | 133 | 134 | @app.route('/yarr') 135 | def hello_bad_request(): 136 | request.args['bad-key'] 137 | 138 | if __name__ == '__main__': 139 | app.run() 140 | 141 | 142 | Let's try to curl this example. First response shows that we redefined 143 | default ``{'code': 400, 'message': '400: Bad Request'}`` error format. 144 | Next ones show that you can handle specific errors as usual. 145 | 146 | .. code-block:: console 147 | 148 | $ curl http://127.0.0.1:5000/yarr -i 149 | HTTP/1.0 400 BAD REQUEST 150 | Content-Type: application/json 151 | Content-Length: 125 152 | Server: Werkzeug/0.9.4 Python/2.7.5 153 | Date: Sun, 29 Dec 2013 14:26:30 GMT 154 | 155 | { 156 | "code": 400, 157 | "info_url": "http://developer.example.com/errors.html#error-code-400", 158 | "message": "400: Bad Request" 159 | } 160 | $ curl http://127.0.0.1:5000/ -i 161 | HTTP/1.0 404 NOT FOUND 162 | Content-Type: application/json 163 | Content-Length: 41 164 | Server: Werkzeug/0.9.4 Python/2.7.5 165 | Date: Sun, 29 Dec 2013 14:28:46 GMT 166 | 167 | { 168 | "error": "This page does not exist" 169 | } 170 | $ curl http://127.0.0.1:5000/my-exc -i 171 | HTTP/1.0 200 OK 172 | Content-Type: application/json 173 | Content-Length: 25 174 | Server: Werkzeug/0.9.4 Python/2.7.5 175 | Date: Sun, 29 Dec 2013 14:27:33 GMT 176 | 177 | { 178 | "error": "Krivens!" 179 | } 180 | 181 | Authentication 182 | -------------- 183 | 184 | **Hawk** extension provides API authentication for Flask. 185 | 186 | Hawk_ is an HTTP authentication scheme using a message authentication code 187 | (MAC) algorithm to provide partial HTTP request cryptographic verification. 188 | 189 | The extension is based on Mohawk_, so make sure you have installed it. 190 | 191 | .. code-block:: console 192 | 193 | $ pip install mohawk 194 | 195 | Usage example: 196 | 197 | .. code-block:: python 198 | 199 | from flask import Flask 200 | from api_utils import Hawk 201 | 202 | app = Flask(__name__) 203 | hawk = Hawk(app) 204 | 205 | 206 | @hawk.client_key_loader 207 | def get_client_key(client_id): 208 | # In a real project you will likely use some storage. 209 | if client_id == 'Alice': 210 | return 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn' 211 | else: 212 | raise LookupError() 213 | 214 | 215 | @app.route('/') 216 | @hawk.auth_required 217 | def index(): 218 | return 'hello world' 219 | 220 | if __name__ == '__main__': 221 | app.run() 222 | 223 | .. code-block:: console 224 | 225 | $ curl http://127.0.0.1:5000/ -i 226 | HTTP/1.0 401 UNAUTHORIZED 227 | ... 228 | 229 | Cookie based authentication is disabled by default. 230 | Set ``HAWK_ALLOW_COOKIE_AUTH = True`` to enable it. Also **Hawk** supports 231 | response signing, enable it ``HAWK_SIGN_RESPONSE = True`` if you need it. 232 | 233 | Following configuration keys are used by Mohawk_ library. 234 | 235 | .. code-block:: python 236 | 237 | HAWK_ALGORITHM = 'sha256' 238 | HAWK_ACCEPT_UNTRUSTED_CONTENT = False 239 | HAWK_LOCALTIME_OFFSET_IN_SECONDS = 0 240 | HAWK_TIMESTAMP_SKEW_IN_SECONDS = 60 241 | 242 | Check `Mohawk documentation`_ for more information. 243 | 244 | It can be convenient to globally turn off authentication when unit testing 245 | by setting ``HAWK_ENABLED = False``. 246 | 247 | Tests 248 | ----- 249 | 250 | Tests are run by: 251 | 252 | .. code-block:: console 253 | 254 | $ pip install -r requirements.txt 255 | $ tox 256 | 257 | .. _API example project: https://github.com/marselester/api-example-based-on-flask 258 | .. _Hawk: https://github.com/hueniverse/hawk 259 | .. _Mohawk: https://github.com/kumar303/mohawk 260 | .. _Mohawk documentation: http://mohawk.readthedocs.org 261 | .. _Flask-Login: https://flask-login.readthedocs.org 262 | -------------------------------------------------------------------------------- /api_utils/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | api_utils 4 | ~~~~~~~~~ 5 | 6 | Flask-API-Utils helps you to create APIs. 7 | 8 | """ 9 | from .app import ResponsiveFlask 10 | from .auth import Hawk 11 | -------------------------------------------------------------------------------- /api_utils/app.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | api_utils.app 4 | ~~~~~~~~~~~~~ 5 | 6 | This module helps to make responses in appropriate formats. 7 | 8 | """ 9 | from werkzeug.exceptions import default_exceptions 10 | from flask import Flask, request 11 | 12 | from . import formatters 13 | 14 | __all__ = ('ResponsiveFlask',) 15 | 16 | 17 | class ResponsiveFlask(Flask): 18 | """Changes Flask behavior to respond in requested format. 19 | 20 | .. code-block:: python 21 | 22 | app = ResponsiveFlask(__name__) 23 | 24 | 25 | @app.route('/') 26 | def hello_world(): 27 | return {'hello': 'world'} 28 | 29 | 30 | def dummy_xml_formatter(*args, **kwargs): 31 | return 'world' 32 | 33 | xml_mimetype = 'application/vnd.company+xml' 34 | 35 | app.default_mimetype = xml_mimetype 36 | app.response_formatters[xml_mimetype] = dummy_xml_formatter 37 | 38 | """ 39 | def __init__(self, *args, **kwargs): 40 | super(ResponsiveFlask, self).__init__(*args, **kwargs) 41 | self.default_mimetype = 'application/json' 42 | self.response_formatters = { 43 | 'application/json': formatters.json 44 | } 45 | 46 | def default_errorhandler(self, f): 47 | """Decorator that registers handler of default (Werkzeug) HTTP errors. 48 | 49 | Note that it might override already defined error handlers. 50 | 51 | """ 52 | for http_code in default_exceptions: 53 | self.error_handler_spec[None][http_code] = f 54 | return f 55 | 56 | def _response_mimetype_based_on_accept_header(self): 57 | """Determines mimetype to response based on Accept header. 58 | 59 | If mimetype is not found, it returns ``None``. 60 | 61 | """ 62 | response_mimetype = None 63 | 64 | if not request.accept_mimetypes: 65 | response_mimetype = self.default_mimetype 66 | else: 67 | all_media_types_wildcard = '*/*' 68 | 69 | for mimetype, q in request.accept_mimetypes: 70 | if mimetype == all_media_types_wildcard: 71 | response_mimetype = self.default_mimetype 72 | break 73 | if mimetype in self.response_formatters: 74 | response_mimetype = mimetype 75 | break 76 | 77 | return response_mimetype 78 | 79 | def make_response(self, rv): 80 | """Returns response based on Accept header. 81 | 82 | If no Accept header field is present, then it is assumed that 83 | the client accepts all media types. This way JSON format will 84 | be used. 85 | 86 | If an Accept header field is present, and if the server cannot 87 | send a response which is acceptable according to the combined 88 | Accept field value, then a 406 (not acceptable) response will 89 | be sent. 90 | 91 | """ 92 | status = headers = None 93 | if isinstance(rv, tuple): 94 | rv, status, headers = rv + (None,) * (3 - len(rv)) 95 | 96 | response_mimetype = self._response_mimetype_based_on_accept_header() 97 | if response_mimetype is None: 98 | # Return 406, list of available mimetypes in default format. 99 | default_formatter = self.response_formatters.get( 100 | self.default_mimetype 101 | ) 102 | available_mimetypes = default_formatter( 103 | mimetypes=list(self.response_formatters) 104 | ) 105 | 106 | rv = self.response_class( 107 | response=available_mimetypes, 108 | status=406, 109 | mimetype=self.default_mimetype, 110 | ) 111 | elif isinstance(rv, dict): 112 | formatter = self.response_formatters.get(response_mimetype) 113 | rv = self.response_class( 114 | response=formatter(**rv), 115 | mimetype=response_mimetype, 116 | ) 117 | 118 | return super(ResponsiveFlask, self).make_response( 119 | rv=(rv, status, headers) 120 | ) 121 | -------------------------------------------------------------------------------- /api_utils/auth.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | api_utils.auth 4 | ~~~~~~~~~~~~~~ 5 | 6 | This module provides API authentication based on Hawk scheme and 7 | Flask-Login extension. 8 | 9 | """ 10 | from functools import wraps 11 | 12 | from flask import request, session, current_app 13 | from werkzeug.exceptions import BadRequest, Unauthorized 14 | try: 15 | import mohawk 16 | from flask.ext.login import current_user 17 | except ImportError: 18 | pass 19 | 20 | from . import compat 21 | 22 | __all__ = ('Hawk',) 23 | 24 | 25 | class Hawk(object): 26 | """HTTP authentication scheme using a message authentication code 27 | (MAC) algorithm. 28 | 29 | Instances are *not* bound to specific apps. 30 | 31 | """ 32 | def __init__(self, app=None): 33 | self._client_key_loader_func = None 34 | 35 | if app is not None: 36 | self.init_app(app) 37 | 38 | def init_app(self, app): 39 | app.config.setdefault('HAWK_ENABLED', True) 40 | app.config.setdefault('HAWK_SIGN_RESPONSE', False) 41 | app.config.setdefault('HAWK_ALLOW_COOKIE_AUTH', False) 42 | app.config.setdefault('HAWK_ALGORITHM', 'sha256') 43 | app.config.setdefault('HAWK_ACCEPT_UNTRUSTED_CONTENT', False) 44 | app.config.setdefault('HAWK_LOCALTIME_OFFSET_IN_SECONDS', 0) 45 | app.config.setdefault('HAWK_TIMESTAMP_SKEW_IN_SECONDS', 60) 46 | 47 | if app.config['HAWK_SIGN_RESPONSE']: 48 | app.after_request(self._sign_response) 49 | 50 | def client_key_loader(self, f): 51 | """Registers a function to be called to find a client key. 52 | 53 | Function you set has to take a client id and return a client key:: 54 | 55 | @hawk.client_key_loader 56 | def get_client_key(client_id): 57 | if client_id == 'Alice': 58 | return 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn' 59 | else: 60 | raise LookupError() 61 | 62 | :param f: The callback for retrieving a client key. 63 | 64 | """ 65 | @wraps(f) 66 | def wrapped_f(client_id): 67 | client_key = f(client_id) 68 | return { 69 | 'id': client_id, 70 | 'key': client_key, 71 | 'algorithm': current_app.config['HAWK_ALGORITHM'] 72 | } 73 | 74 | self._client_key_loader_func = wrapped_f 75 | return wrapped_f 76 | 77 | def auth_required(self, view_func): 78 | """Decorator that provides an access to view function for 79 | authenticated users only. 80 | 81 | Note that we don't run authentication when `HAWK_ENABLED` is `False`. 82 | 83 | """ 84 | @wraps(view_func) 85 | def wrapped_view_func(*args, **kwargs): 86 | if current_app.config['HAWK_ENABLED']: 87 | if current_app.config['HAWK_ALLOW_COOKIE_AUTH'] and session: 88 | self._auth_by_cookie() 89 | else: 90 | self._auth_by_signature() 91 | return view_func(*args, **kwargs) 92 | 93 | return wrapped_view_func 94 | 95 | def _auth_by_cookie(self): 96 | if not compat.is_user_authenticated(current_user): 97 | raise Unauthorized() 98 | 99 | def _auth_by_signature(self): 100 | if self._client_key_loader_func is None: 101 | raise RuntimeError('Client key loader function was not defined') 102 | if 'Authorization' not in request.headers: 103 | raise Unauthorized() 104 | 105 | try: 106 | mohawk.Receiver( 107 | credentials_map=self._client_key_loader_func, 108 | request_header=request.headers['Authorization'], 109 | url=request.url, 110 | method=request.method, 111 | content=request.get_data(), 112 | content_type=request.mimetype, 113 | accept_untrusted_content=current_app.config['HAWK_ACCEPT_UNTRUSTED_CONTENT'], 114 | localtime_offset_in_seconds=current_app.config['HAWK_LOCALTIME_OFFSET_IN_SECONDS'], 115 | timestamp_skew_in_seconds=current_app.config['HAWK_TIMESTAMP_SKEW_IN_SECONDS'] 116 | ) 117 | except mohawk.exc.MacMismatch: 118 | # mohawk exception contains computed MAC. 119 | # We should not expose it in response. 120 | raise Unauthorized() 121 | except ( 122 | mohawk.exc.CredentialsLookupError, 123 | mohawk.exc.AlreadyProcessed, 124 | mohawk.exc.MisComputedContentHash, 125 | mohawk.exc.TokenExpired 126 | ) as e: 127 | raise Unauthorized(str(e)) 128 | except mohawk.exc.HawkFail as e: 129 | raise BadRequest(str(e)) 130 | except KeyError: 131 | raise BadRequest() 132 | 133 | def _sign_response(self, response): 134 | """Signs a response if it's possible.""" 135 | if 'Authorization' not in request.headers: 136 | return response 137 | 138 | try: 139 | mohawk_receiver = mohawk.Receiver( 140 | credentials_map=self._client_key_loader_func, 141 | request_header=request.headers['Authorization'], 142 | url=request.url, 143 | method=request.method, 144 | content=request.get_data(), 145 | content_type=request.mimetype, 146 | accept_untrusted_content=current_app.config['HAWK_ACCEPT_UNTRUSTED_CONTENT'], 147 | localtime_offset_in_seconds=current_app.config['HAWK_LOCALTIME_OFFSET_IN_SECONDS'], 148 | timestamp_skew_in_seconds=current_app.config['HAWK_TIMESTAMP_SKEW_IN_SECONDS'] 149 | ) 150 | except mohawk.exc.HawkFail: 151 | return response 152 | 153 | response.headers['Server-Authorization'] = mohawk_receiver.respond( 154 | content=response.data, 155 | content_type=response.mimetype 156 | ) 157 | return response 158 | -------------------------------------------------------------------------------- /api_utils/compat.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | api_utils.compat 4 | ~~~~~~~~~~~~~~~~ 5 | 6 | The compat module provides support for backwards compatibility with older 7 | versions of packages. 8 | 9 | """ 10 | 11 | 12 | def is_user_authenticated(user): 13 | """Returns True if Flask-Login's `user` is authenticated. 14 | 15 | Flask-Login extension has changed `current_user.is_authenticated()` 16 | to bool value. 17 | 18 | """ 19 | if callable(user.is_authenticated): 20 | return user.is_authenticated() 21 | else: 22 | return user.is_authenticated 23 | -------------------------------------------------------------------------------- /api_utils/formatters.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | api_utils.formatters 4 | ~~~~~~~~~~~~~~~~~~~~ 5 | 6 | The aim of formatter is to convert dict to needed string representation. 7 | 8 | """ 9 | from flask import request, current_app, json as flask_json 10 | 11 | 12 | def json(*args, **kwargs): 13 | indent = None 14 | if (current_app.config['JSONIFY_PRETTYPRINT_REGULAR'] and 15 | not request.is_xhr): 16 | indent = 2 17 | return flask_json.dumps(dict(*args, **kwargs), indent=indent) 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | 3 | tox==1.6.1 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from distutils.core import setup 3 | 4 | setup( 5 | name='Flask-API-Utils', 6 | version='1.0.2', 7 | packages=['api_utils'], 8 | author='Marsel Mavletkulov', 9 | author_email='marselester@ya.ru', 10 | url='https://github.com/marselester/flask-api-utils', 11 | description='Flask-API-Utils helps you to create APIs.', 12 | long_description=open('README.rst').read(), 13 | install_requires=['Flask>=0.10'], 14 | classifiers=[ 15 | 'Environment :: Web Environment', 16 | 'Intended Audience :: Developers', 17 | 'Operating System :: OS Independent', 18 | 'Programming Language :: Python', 19 | 'Programming Language :: Python :: 3', 20 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 21 | 'Topic :: Software Development :: Libraries :: Python Modules' 22 | ], 23 | ) 24 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marselester/flask-api-utils/f633e49e883c2f4e2523941e70b2d79ff4e138dc/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import mock 3 | from unittest import TestCase 4 | 5 | from flask import Flask 6 | from werkzeug.exceptions import BadRequest, Unauthorized 7 | from api_utils import Hawk 8 | from flask.ext.login import LoginManager 9 | 10 | from .utils import HawkTestMixin 11 | 12 | app = Flask(__name__) 13 | hawk = Hawk(app) 14 | login_manager = LoginManager(app) 15 | 16 | CREDENTIALS = { 17 | 'id': 'Alice', 18 | 'key': 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn', 19 | 'algorithm': 'sha256' 20 | } 21 | 22 | 23 | @hawk.client_key_loader 24 | def get_client_key(client_id): 25 | if client_id == CREDENTIALS['id']: 26 | return CREDENTIALS['key'] 27 | else: 28 | raise LookupError() 29 | 30 | 31 | @app.route('/', methods=['GET', 'POST']) 32 | @hawk.auth_required 33 | def protected_view(): 34 | return 'hello world' 35 | 36 | 37 | class HawkDisabledAuthTest(TestCase): 38 | def setUp(self): 39 | app.config['HAWK_ENABLED'] = False 40 | self.client = app.test_client() 41 | 42 | def tearDown(self): 43 | app.config['HAWK_ENABLED'] = True 44 | 45 | def test_200_when_current_user_is_not_authenticated(self): 46 | r = self.client.open(method='GET', path='/') 47 | self.assertEqual(r.status_code, 200) 48 | 49 | 50 | class HawkAuthBySignatureTest(TestCase, HawkTestMixin): 51 | def setUp(self): 52 | self.client = app.test_client() 53 | 54 | def tearDown(self): 55 | hawk._client_key_loader_func = get_client_key 56 | 57 | def test_successful_auth_when_http_method_is_get(self): 58 | r = self.signed_request(CREDENTIALS, path='/?hello=world') 59 | self.assertEqual(r.status_code, 200) 60 | 61 | def test_successful_auth_when_http_method_is_post(self): 62 | r = self.signed_request( 63 | CREDENTIALS, 64 | method='POST', 65 | path='/?hello=world&fields=id,title', 66 | data={ 67 | 'fizz': 'buzz', 68 | 'blah': '1111' 69 | } 70 | ) 71 | self.assertEqual(r.status_code, 200) 72 | 73 | def test_runtime_error_when_client_key_loader_was_not_defined(self): 74 | hawk._client_key_loader_func = None 75 | 76 | with app.test_request_context(): 77 | message = 'Client key loader function was not defined' 78 | with self.assertRaisesRegexp(RuntimeError, message): 79 | hawk._auth_by_signature() 80 | 81 | def test_401_when_there_is_no_authorization_header(self): 82 | with app.test_request_context(): 83 | with self.assertRaises(Unauthorized): 84 | hawk._auth_by_signature() 85 | 86 | def test_400_when_authorization_header_has_wrong_scheme(self): 87 | headers = { 88 | 'Authorization': 'blah' 89 | } 90 | with app.test_request_context(headers=headers): 91 | with self.assertRaises(BadRequest) as cm: 92 | hawk._auth_by_signature() 93 | message = "Unknown scheme 'blah' when parsing header" 94 | self.assertEqual(cm.exception.description, message) 95 | 96 | def test_401_when_client_was_not_found(self): 97 | headers = { 98 | 'Authorization': 'Hawk mac="", hash="", id="Bob", ts="", nonce=""' 99 | } 100 | with app.test_request_context(headers=headers): 101 | with self.assertRaises(Unauthorized) as cm: 102 | hawk._auth_by_signature() 103 | message = 'Could not find credentials for ID Bob' 104 | self.assertEqual(cm.exception.description, message) 105 | 106 | def test_401_response_doesnt_contain_computed_mac(self): 107 | headers = { 108 | 'Authorization': 'Hawk mac="", hash="", id="Alice", ts="", nonce=""' 109 | } 110 | with app.test_request_context(headers=headers): 111 | with self.assertRaises(Unauthorized) as cm: 112 | hawk._auth_by_signature() 113 | message = 'MACs do not match; ours:' 114 | self.assertNotIn(message, cm.exception.description) 115 | 116 | def test_400_when_mohawk_cant_find_mac_and_raises_key_error(self): 117 | headers = { 118 | 'Authorization': 'Hawk hash="", id="Alice", ts="", nonce=""' 119 | } 120 | with app.test_request_context(headers=headers): 121 | with self.assertRaises(BadRequest): 122 | hawk._auth_by_signature() 123 | 124 | 125 | class HawkAuthByCookieTest(TestCase): 126 | def setUp(self): 127 | app.config['SECRET_KEY'] = 'secret' 128 | 129 | def test_401_when_current_user_is_not_authenticated(self): 130 | with app.test_request_context(): 131 | with self.assertRaises(Unauthorized): 132 | hawk._auth_by_cookie() 133 | 134 | @mock.patch('api_utils.auth.compat.is_user_authenticated') 135 | def test_successful_authentication(self, is_user_authenticated): 136 | is_user_authenticated.return_value = True 137 | 138 | with app.test_request_context(): 139 | hawk._auth_by_cookie() 140 | 141 | 142 | class HawkSignResponseTest(TestCase, HawkTestMixin): 143 | def setUp(self): 144 | self.client = app.test_client() 145 | 146 | def tearDown(self): 147 | app.config['HAWK_SIGN_RESPONSE'] = False 148 | 149 | def test_responses_are_signed_when_hawk_was_configured_to_sign(self): 150 | app.config['HAWK_SIGN_RESPONSE'] = True 151 | hawk.init_app(app) 152 | 153 | r = self.signed_request(CREDENTIALS) 154 | self.assertEqual(r.status_code, 200) 155 | self.assertIn('Server-Authorization', r.headers) 156 | 157 | def test_responses_are_not_signed_by_default(self): 158 | r = self.signed_request(CREDENTIALS) 159 | self.assertEqual(r.status_code, 200) 160 | self.assertNotIn('Server-Authorization', r.headers) 161 | -------------------------------------------------------------------------------- /tests/test_flask_app.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from flask.testsuite import FlaskTestCase 3 | from flask import json, request 4 | from api_utils import ResponsiveFlask 5 | 6 | 7 | def hello_world(): 8 | return {'hello': 'world'} 9 | expected_json = {'hello': 'world'} 10 | 11 | 12 | def dummy_xml_formatter(*args, **kwargs): 13 | return 'world' 14 | expected_xml = b'world' 15 | 16 | 17 | class ResponsiveFlaskTest(FlaskTestCase): 18 | def setUp(self): 19 | self.app = ResponsiveFlask(__name__) 20 | self.client = self.app.test_client() 21 | 22 | def test_json_response_when_accept_header_is_not_given(self): 23 | self.app.add_url_rule('/', view_func=hello_world) 24 | 25 | headers = {} 26 | r = self.client.get('/', headers=headers) 27 | r_json = json.loads(r.data) 28 | 29 | self.assertEqual(r_json, expected_json) 30 | self.assertEqual(r.mimetype, 'application/json') 31 | 32 | def test_json_response_when_accept_header_means_all_media_types(self): 33 | self.app.add_url_rule('/', view_func=hello_world) 34 | 35 | headers = { 36 | 'Accept': '*/*', 37 | } 38 | r = self.client.get('/', headers=headers) 39 | r_json = json.loads(r.data) 40 | 41 | self.assertEqual(r_json, expected_json) 42 | self.assertEqual(r.mimetype, 'application/json') 43 | 44 | def test_406_when_accept_header_is_given_but_format_is_unknown(self): 45 | self.app.add_url_rule('/', view_func=hello_world) 46 | 47 | headers = { 48 | 'Accept': 'application/vnd.company.myapp.product-v2+xml', 49 | } 50 | r = self.client.get('/', headers=headers) 51 | not_acceptable_status_code = 406 52 | 53 | self.assertEqual(r.status_code, not_acceptable_status_code) 54 | self.assertEqual(r.mimetype, 'application/json') 55 | 56 | def test_list_of_mimetypes_is_in_json_when_format_is_unknown(self): 57 | self.app.add_url_rule('/', view_func=hello_world) 58 | 59 | headers = { 60 | 'Accept': 'blah/*', 61 | } 62 | r = self.client.get('/', headers=headers) 63 | r_json = json.loads(r.data) 64 | expected_json = { 65 | 'mimetypes': ['application/json'], 66 | } 67 | 68 | self.assertEqual(r_json, expected_json) 69 | self.assertEqual(r.mimetype, 'application/json') 70 | 71 | def test_json_is_used_because_xml_formatter_is_not_set(self): 72 | self.app.add_url_rule('/', view_func=hello_world) 73 | 74 | headers = { 75 | 'Accept': 'application/xml,application/json', 76 | } 77 | r = self.client.get('/', headers=headers) 78 | r_json = json.loads(r.data) 79 | 80 | self.assertEqual(r_json, expected_json) 81 | self.assertEqual(r.mimetype, 'application/json') 82 | 83 | def test_xml_is_used_because_xml_formatter_is_set_manually(self): 84 | self.app.add_url_rule('/', view_func=hello_world) 85 | self.app.response_formatters['application/xml'] = dummy_xml_formatter 86 | 87 | headers = { 88 | 'Accept': 'application/xml,application/json', 89 | } 90 | r = self.client.get('/', headers=headers) 91 | 92 | self.assertEqual(r.data, expected_xml) 93 | self.assertEqual(r.mimetype, 'application/xml') 94 | 95 | def test_xml_is_used_because_default_mimetype_is_set_manually(self): 96 | self.app.add_url_rule('/', view_func=hello_world) 97 | self.app.default_mimetype = 'application/xml' 98 | self.app.response_formatters['application/xml'] = dummy_xml_formatter 99 | 100 | headers = { 101 | 'Accept': '*/*', 102 | } 103 | r = self.client.get('/', headers=headers) 104 | 105 | self.assertEqual(r.data, expected_xml) 106 | self.assertEqual(r.mimetype, 'application/xml') 107 | 108 | def test_json_response_is_returned_due_to_quality_factor(self): 109 | self.app.add_url_rule('/', view_func=hello_world) 110 | self.app.response_formatters['application/xml'] = dummy_xml_formatter 111 | 112 | headers = { 113 | 'Accept': 'application/xml;q=0.5,application/json', 114 | } 115 | r = self.client.get('/', headers=headers) 116 | r_json = json.loads(r.data) 117 | 118 | self.assertEqual(r_json, expected_json) 119 | self.assertEqual(r.mimetype, 'application/json') 120 | 121 | def test_rv_as_dict_response_and_status_code(self): 122 | def hello_world_201_status(): 123 | return {'hello': 'world'}, 201 124 | self.app.add_url_rule('/', view_func=hello_world_201_status) 125 | 126 | r = self.client.get('/') 127 | r_json = json.loads(r.data) 128 | expected_status_code = 201 129 | 130 | self.assertEqual(r.status_code, expected_status_code) 131 | self.assertEqual(r_json, expected_json) 132 | self.assertEqual(r.mimetype, 'application/json') 133 | 134 | def test_mimetype_is_text_html_when_view_returns_non_dict(self): 135 | def index(): 136 | return 'hello world' 137 | self.app.add_url_rule('/', view_func=index) 138 | 139 | r = self.client.get('/') 140 | 141 | self.assertEqual(r.data, b'hello world') 142 | self.assertEqual(r.mimetype, 'text/html') 143 | 144 | 145 | def hello_bad_request(): 146 | request.args['bad-key'] 147 | 148 | 149 | def code_and_message(error): 150 | """Makes dict response from given Werkzeug's default exception. 151 | 152 | It assumes that ``Flask.make_response()`` can understand dict format 153 | and make appropriate response. 154 | 155 | The format is: 156 | 157 | .. code-block:: python 158 | 159 | { 160 | 'code': 400, 161 | 'message': '400: Bad Request', 162 | } 163 | 164 | """ 165 | response = { 166 | 'code': error.code, 167 | 'message': str(error), 168 | } 169 | return response, error.code 170 | 171 | 172 | class ErrorHandlingByResponsiveFlaskTest(FlaskTestCase): 173 | def setUp(self): 174 | self.app = ResponsiveFlask(__name__) 175 | self.client = self.app.test_client() 176 | 177 | def test_error_was_is_formatted_as_text_html_by_default(self): 178 | self.app.add_url_rule('/', view_func=hello_bad_request) 179 | 180 | r = self.client.get('/') 181 | expected_status_code = 400 182 | 183 | self.assertEqual(r.status_code, expected_status_code) 184 | self.assertEqual(r.mimetype, 'text/html') 185 | 186 | def test_400_error_is_json_formatted_when_view_raises_key_error(self): 187 | self.app.add_url_rule('/', view_func=hello_bad_request) 188 | self.app.default_errorhandler(code_and_message) 189 | 190 | r = self.client.get('/') 191 | r_json = json.loads(r.data) 192 | expected_status_code = 400 193 | 194 | self.assertEqual(r.status_code, expected_status_code) 195 | self.assertEqual(r_json['code'], expected_status_code) 196 | self.assertEqual(r.mimetype, 'application/json') 197 | 198 | def test_error_response_contains_code_and_message_fields(self): 199 | self.app.add_url_rule('/', view_func=hello_bad_request) 200 | self.app.default_errorhandler(code_and_message) 201 | 202 | r = self.client.get('/') 203 | r_json = json.loads(r.data) 204 | 205 | self.assertIn('code', r_json) 206 | self.assertIn('message', r_json) 207 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import json 3 | 4 | import mohawk 5 | 6 | 7 | class HawkTestMixin(object): 8 | def signed_request(self, credentials, method='GET', path='/', data=None): 9 | url = 'http://localhost' + path 10 | content = json.dumps(data) 11 | content_type = 'application/json' 12 | 13 | sender = mohawk.Sender( 14 | credentials, 15 | url, 16 | method, 17 | content, 18 | content_type 19 | ) 20 | 21 | return self.client.open( 22 | method=method, 23 | path=path, 24 | headers={'Authorization': sender.request_header}, 25 | data=content, 26 | content_type=content_type 27 | ) 28 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py27,py33 3 | 4 | [testenv] 5 | deps= 6 | nose 7 | mock 8 | mohawk 9 | Flask-Login 10 | commands= 11 | nosetests tests 12 | --------------------------------------------------------------------------------