├── .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 |
--------------------------------------------------------------------------------