├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── flask_jsontools ├── __init__.py ├── decorators.py ├── formatting.py ├── response.py ├── testing.py └── views.py ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── tests ├── jsonapi-test.py └── methodview-test.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # ===[ APP ]=== # 2 | 3 | # ===[ PYTHON PACKAGE ]=== # 4 | /.tox/ 5 | /build/ 6 | /dist/ 7 | /MANIFEST 8 | /*.egg-info/ 9 | /*.egg/ 10 | 11 | # ===[ OTHER ]=== # 12 | 13 | # IDE Projects 14 | .idea 15 | .nbproject 16 | .project 17 | *.sublime-project 18 | 19 | # Temps 20 | *~ 21 | *.tmp 22 | *.bak 23 | *.swp 24 | *.kate-swp 25 | *.DS_Store 26 | Thumbs.db 27 | 28 | # Utils 29 | .python-version 30 | .sass-cache/ 31 | .coverage 32 | 33 | # Generated 34 | __pycache__ 35 | *.py[cod] 36 | *.pot 37 | *.mo 38 | 39 | # Runtime 40 | /*.log 41 | /*.pid 42 | 43 | # ===[ EXCLUDES ]=== # 44 | !.gitkeep 45 | !.htaccess 46 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | sudo: false 3 | language: python 4 | 5 | matrix: 6 | include: 7 | - python: 2.7 8 | env: TOXENV=py 9 | - python: 3.4 10 | env: TOXENV=py 11 | - python: 3.5 12 | env: TOXENV=py 13 | - python: 3.6 14 | env: TOXENV=py 15 | - python: 3.7 16 | env: TOXENV=py 17 | - python: 3.8 18 | env: TOXENV=py 19 | - python: pypy 20 | env: TOXENV=py 21 | - python: pypy3 22 | env: TOXENV=py 23 | 24 | install: 25 | - pip install tox 26 | cache: 27 | - pip 28 | script: 29 | - tox 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Mark Vartanyan 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 16 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 17 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 18 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 19 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 20 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 21 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 22 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 23 | POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.* 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | 3 | SHELL := /bin/bash 4 | 5 | # Package 6 | .PHONY: clean 7 | clean: 8 | @rm -rf build/ dist/ *.egg-info/ 9 | #README.md: 10 | # @python misc/_doc/README.py | j2 --format=json -o README.md misc/_doc/README.md.j2 11 | 12 | .PHONY: build publish-test publish 13 | build: README.md 14 | @./setup.py build sdist bdist_wheel 15 | publish-test: README.md 16 | @twine upload --repository pypitest dist/* 17 | publish: README.md 18 | @twine upload dist/* 19 | 20 | 21 | .PHONY: test test-tox test-docker test-docker-2.6 22 | test: 23 | @nosetests 24 | test-tox: 25 | @tox 26 | test-docker: 27 | @docker run --rm -it -v `pwd`:/src themattrix/tox 28 | test-docker-2.6: # temporary, since `themattrix/tox` has faulty 2.6 29 | @docker run --rm -it -v $(realpath .):/app mrupgrade/deadsnakes:2.6 bash -c 'cd /app && pip install -e . && pip install nose argparse && nosetests' 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://api.travis-ci.org/kolypto/py-flask-jsontools.png?branch=master)](https://travis-ci.org/kolypto/py-flask-jsontools) 2 | [![Pythons](https://img.shields.io/badge/python-2.7%20%7C%203.4%E2%80%933.8%20%7C%20pypy-blue.svg)](.travis.yml) 3 | 4 | 5 | Flask JsonTools 6 | =============== 7 | 8 | JSON API tools for Flask 9 | 10 | Table of Contents 11 | ================= 12 | 13 | * View Utilities 14 | * @jsonapi 15 | * JsonResponse 16 | * make_json_response() 17 | * FlaskJsonClient 18 | * Class-Based Views 19 | * MethodView 20 | * RestfulView 21 | 22 | 23 | 24 | View Utilities 25 | ============== 26 | 27 | @jsonapi 28 | -------- 29 | 30 | Decorate a view function that talks JSON. 31 | 32 | Such function can return: 33 | 34 | * tuples of `(response, status[, headers])`: to set custom status code and optionally - headers 35 | * Instances of [`JsonResponse`](#jsonresponse) 36 | * The result of helper function [`make_json_response`](#make_json_response) 37 | 38 | Example: 39 | 40 | ```python 41 | from flask.ext.jsontools import jsonapi 42 | 43 | @app.route('/users') 44 | @jsonapi 45 | def list_users(): 46 | return [ 47 | {'id': 1, 'login': 'kolypto'}, 48 | #... 49 | ] 50 | 51 | @app.route('/user/', methods=['DELETE']) 52 | def delete_user(id): 53 | return {'error': 'Access denied'}, 403 54 | ``` 55 | 56 | ### JsonResponse 57 | 58 | Extends [`flask.Request`](http://flask.pocoo.org/docs/api/#incoming-request-data) and encodes the response with JSON. 59 | Views decorated with [`@jsonapi`](#jsonapi) return these objects. 60 | 61 | Arguments: 62 | 63 | * `response`: response data 64 | * `status`: status code. Optional, defaults to 200 65 | * `headers`: additional headers dict. Optional. 66 | * `**kwargs`: additional argumets for [`Response`](http://flask.pocoo.org/docs/api/#response-objects) 67 | 68 | Methods: 69 | 70 | * `preprocess_response_data(response)`: Override to get custom response behavior. 71 | * `get_json()`: Get the original response data. 72 | * `__getitem__(key)`: Get an item from the response data 73 | 74 | The extra methods allows to reuse views: 75 | 76 | ```python 77 | from flask.ext.jsontools import jsonapi 78 | 79 | @app.route('/user', methods=['GET']) 80 | @jsonapi 81 | def list_users(): 82 | return [ { 1: 'first', 2: 'second' } ] 83 | 84 | @app.route('/user/', methods=['GET']) 85 | @jsonapi 86 | def get_user(id): 87 | return list_users().get_json()[id] # Long form 88 | return list_users()[id] # Shortcut 89 | ``` 90 | 91 | ### make_json_response() 92 | Helper function that actually preprocesses view return value into [`JsonResponse`](#jsonresponse). 93 | 94 | Accepts `rv` as any of: 95 | 96 | * tuple of `(response, status[, headers])` 97 | * Object to encode as JSON 98 | 99 | 100 | 101 | 102 | 103 | 104 | FlaskJsonClient 105 | =============== 106 | 107 | FlaskJsonClient is a JSON-aware test client: it can post JSON and parse JSON responses into [`JsonResponse`](#jsonresponse). 108 | 109 | ```python 110 | from myapplication import Application 111 | from flask.ext.jsontools import FlaskJsonClient 112 | 113 | def JsonTest(unittest.TestCase): 114 | def setUp(self): 115 | self.app = Application(__name__) 116 | self.app.test_client_class = FlaskJsonClient 117 | 118 | def testCreateUser(self): 119 | with self.app.test_client() as c: 120 | rv = c.post('/user/', json={'name': 'kolypto'}) 121 | # rv is JsonResponse 122 | rv.status_code 123 | rv.get_json()['user'] # Long form for the previous 124 | rv['user'] # Shortcut for the previous 125 | ``` 126 | 127 | 128 | 129 | 130 | 131 | Formatting Utils 132 | ================ 133 | 134 | DynamicJSONEncoder 135 | ----------- 136 | 137 | In python, de-facto standard for encoding objects of custom classes is the `__json__()` method which returns 138 | the representation of the object. 139 | 140 | `DynamicJSONEncoder` is the implementation of this protocol: if an object has the `__json__()` method, its result if used for 141 | the representation. 142 | 143 | You'll definitely want to subclass it to support other types, e.g. dates and times: 144 | 145 | ```python 146 | from flask.ext.jsontools import DynamicJSONEncoder 147 | 148 | class ApiJSONEncoder(DynamicJSONEncoder): 149 | def default(self, o): 150 | # Custom formats 151 | if isinstance(o, datetime.datetime): 152 | return o.isoformat(' ') 153 | if isinstance(o, datetime.date): 154 | return o.isoformat() 155 | if isinstance(o, set): 156 | return list(o) 157 | 158 | # Fallback 159 | return super(ApiJSONEncoder, self).default(o) 160 | ``` 161 | 162 | Now, just install the encoder to your Flask: 163 | 164 | ```python 165 | from flask import Flask 166 | 167 | app = Flask(__name__) 168 | app.json_encoder = DynamicJSONEncoder 169 | ``` 170 | 171 | 172 | 173 | JsonSerializableBase 174 | -------------------- 175 | 176 | Serializing SqlAlchemy models to JSON is a headache: if an attribute is present on an instance, this does not mean 177 | it's loaded from the database. 178 | 179 | `JsonSerializableBase` is a mixin for SqlAlchemy Declarative Base that adds a magic `__json__()` method, compatible with 180 | [`DynamicJSONEncoder`](#dynamicjsonencoder). When serializing, it makes sure that entity serialization will *never* issue additional requests. 181 | 182 | Example: 183 | 184 | ```python 185 | from sqlalchemy.ext.declarative import declarative_base 186 | from flask.ext.jsontools import JsonSerializableBase 187 | 188 | Base = declarative_base(cls=(JsonSerializableBase,)) 189 | 190 | class User(Base): 191 | #... 192 | ``` 193 | 194 | Now, you can safely respond with SqlAlchemy models in your JSON views, and jsontools will handle the rest :) 195 | 196 | 197 | 198 | 199 | 200 | 201 | Class-Based Views 202 | ================= 203 | 204 | Module `flask.ext.jsontools.views` contains a couple of classes that allow to build class-based views 205 | which dispatch to different methods. 206 | 207 | In contrast to [MethodView](http://flask.pocoo.org/docs/api/#flask.views.MethodView), this gives much higher flexibility. 208 | 209 | MethodView 210 | ---------- 211 | 212 | Using `MethodView` class for methods, decorate them with `@methodview()`, which takes the following arguments: 213 | 214 | * `methods=()`: Iterable of HTTP methods to use with this method. 215 | * `ifnset=None`: Conditional matching. List of route parameter names that should *not* be set for this method to match. 216 | * `ifset=None`: Conditional matching. List of route parameter names that should be set for this method to match. 217 | 218 | This allows to map HTTP methods to class methods, and in addition define when individual methods should match. 219 | 220 | Quick example: 221 | 222 | ```python 223 | from flask.ext.jsontools import jsonapi, MethodView, methodview 224 | 225 | class UserView(MethodView): 226 | # Canonical way to specify decorators for class-based views 227 | decorators = (jsonapi, ) 228 | 229 | @methodview 230 | def list(self): 231 | """ List users """ 232 | return db.query(User).all() 233 | 234 | @methodview 235 | def get(self, user_id): 236 | """ Load a user by id """ 237 | return db.query(User).get(user_id) 238 | 239 | userview = CrudView.as_view('user') 240 | app.add_url_rule('/user/', view_func=userview) 241 | app.add_url_rule('/user/', view_func=userview) 242 | ``` 243 | 244 | Now, `GET` HTTP method is routed to two different methods depending on conditions. 245 | Keep defining more methods to get good routing :) 246 | 247 | To simplify the last step of creating the view, there's a helper: 248 | 249 | ```python 250 | UserView.route_as_view(app, 'user', ('/user/', '/user/')) 251 | ``` 252 | 253 | RestfulView 254 | ----------- 255 | 256 | Since `MethodView` is mostly useful to expose APIs over collections of entities, there is a RESTful helper which 257 | automatically decorates some special methods with `@methodview`. 258 | 259 | | View method | HTTP method | URL | 260 | |-------------|-------------|---------| 261 | | list() | GET | `/` | 262 | | create() | POST | `/` | 263 | | get() | GET | `/` | 264 | | replace() | PUT | `/` | 265 | | update() | POST | `/` | 266 | | delete() | DELETE | `/` | 267 | 268 | By subclassing `RestfulView` and implementing some of these methods, 269 | you'll get a complete API endpoint with a single class. 270 | 271 | It's also required to define the list of primary key fields by defining the `primary_key` property: 272 | 273 | ```python 274 | from flask.ext.jsontools import jsonapi, RestfulView 275 | 276 | class User(RestfulView): 277 | decorators = (jsonapi, ) 278 | primary_key = ('id',) 279 | 280 | #region Operation on the collection 281 | 282 | def list(): 283 | return db.query(User).all() 284 | 285 | def create(): 286 | db.save(user) 287 | return user 288 | 289 | #endregion 290 | 291 | #region Operation on entities 292 | 293 | def get(id): 294 | return db.query(User).get(id) 295 | 296 | def replace(id): 297 | db.save(user, id) 298 | 299 | def update(id): 300 | db.save(user) 301 | 302 | def delete(id): 303 | db.delete(user) 304 | 305 | #endregion 306 | ``` 307 | 308 | When a class like this is defined, its metaclass goes through the methods and decorates them with `@methodview`. 309 | This way, `list()` gets `@methodview('GET', ifnset=('id',))`, and `get()` gets `@methodview('GET', ifset=('id',))`. 310 | -------------------------------------------------------------------------------- /flask_jsontools/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from .response import JsonResponse, make_json_response 4 | from .decorators import jsonapi 5 | from .testing import FlaskJsonClient 6 | from .formatting import DynamicJSONEncoder, JsonSerializableBase 7 | from .views import MethodView, RestfulView, methodview 8 | -------------------------------------------------------------------------------- /flask_jsontools/decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | 4 | from functools import wraps, update_wrapper, partial 5 | 6 | from .response import normalize_response_value, make_json_response 7 | 8 | 9 | def jsonapi(f): 10 | """ Declare the view as a JSON API method 11 | 12 | This converts view return value into a :cls:JsonResponse. 13 | 14 | The following return types are supported: 15 | - tuple: a tuple of (response, status, headers) 16 | - any other object is converted to JSON 17 | """ 18 | @wraps(f) 19 | def wrapper(*args, **kwargs): 20 | rv = f(*args, **kwargs) 21 | return make_json_response(rv) 22 | return wrapper 23 | -------------------------------------------------------------------------------- /flask_jsontools/formatting.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from builtins import object 3 | 4 | from flask.json import JSONEncoder 5 | 6 | 7 | class DynamicJSONEncoder(JSONEncoder): 8 | """ JSON encoder for custom classes: 9 | 10 | Uses __json__() method if available to prepare the object. 11 | Especially useful for SQLAlchemy models 12 | """ 13 | 14 | def default(self, o): 15 | # Custom JSON-encodeable objects 16 | if hasattr(o, '__json__'): 17 | return o.__json__() 18 | 19 | # Default 20 | return super(DynamicJSONEncoder, self).default(o) 21 | 22 | 23 | #region SqlAlchemy Tools 24 | 25 | try: 26 | from sqlalchemy import inspect 27 | from sqlalchemy.orm.state import InstanceState 28 | except ImportError as e: 29 | def __nomodule(*args, **kwargs): raise e 30 | inspect = __nomodule 31 | InstanceState = __nomodule 32 | 33 | 34 | class JsonSerializableBase(object): 35 | """ Declarative Base mixin to allow objects serialization 36 | 37 | Defines interfaces utilized by :cls:ApiJSONEncoder 38 | 39 | In particular, it defines the __json__() method that converts the 40 | SQLAlchemy model to a dictionary. It iterates over model fields 41 | (DB columns and relationships) and collects them in the dictionary. 42 | 43 | The important aspect here is that it collects only loaded attributes. 44 | For example, all relationships are lazy-loaded by default, so they will 45 | not be present in the output JSON unless you use eager loading. 46 | So if you want to include nested objects into the JSON output, then 47 | you should use eager loading. 48 | 49 | Beside the __json__() method, this base defines two properties: 50 | _json_include = [] 51 | _json_exclude = [] 52 | 53 | They both are needed to customize this loaded-only-fields serialization. 54 | 55 | - _json_include is the list of strings (DB columns and relationship 56 | names) that should be present in JSON, even if they are not loaded. 57 | Useful for hybrid properties and relationships that cannot be loaded 58 | eagerly. Just put their names to the _json_include list. 59 | 60 | - _json_exclude is the black-list that actually removes fields from 61 | the output JSON representation. It is applied last, so it beats all 62 | other things like _json_include. 63 | Useful for hiding sensitive data, like password hashes stored in DB. 64 | """ 65 | 66 | _json_include = [] 67 | _json_exclude = [] 68 | 69 | def __json__(self, excluded_keys=set()): 70 | ins = inspect(self) 71 | 72 | columns = set(ins.mapper.column_attrs.keys()) 73 | relationships = set(ins.mapper.relationships.keys()) 74 | unloaded = ins.unloaded 75 | expired = ins.expired_attributes 76 | include = set(self._json_include) 77 | exclude = set(self._json_exclude) | excluded_keys 78 | 79 | # This set of keys determines which fields will be present in 80 | # the resulting JSON object. 81 | # Here we initialize it with properties defined by the model class, 82 | # and then add/delete some columns below in a tricky way. 83 | keys = columns | relationships 84 | 85 | 86 | # 1. Remove not yet loaded properties. 87 | # Basically this is needed to serialize only .join()'ed relationships 88 | # and omit all other lazy-loaded things. 89 | if not ins.transient: 90 | # If the entity is not transient -- exclude unloaded keys 91 | # Transient entities won't load these anyway, so it's safe to 92 | # include all columns and get defaults 93 | keys -= unloaded 94 | 95 | # 2. Re-load expired attributes. 96 | # At the previous step (1) we substracted unloaded keys, and usually 97 | # that includes all expired keys. Actually we don't want to remove the 98 | # expired keys, we want to refresh them, so here we have to re-add them 99 | # back. And they will be refreshed later, upon first read. 100 | if ins.expired: 101 | keys |= expired 102 | 103 | # 3. Add keys explicitly specified in _json_include list. 104 | # That allows you to override those attributes unloaded above. 105 | # For example, you may include some lazy-loaded relationship() there 106 | # (which is usually removed at the step 1). 107 | keys |= include 108 | 109 | # 4. For objects in `deleted` or `detached` state, remove all 110 | # relationships and lazy-loaded attributes, because they require 111 | # refreshing data from the DB, but this cannot be done in these states. 112 | # That is: 113 | # - if the object is deleted, you can't refresh data from the DB 114 | # because there is no data in the DB, everything is deleted 115 | # - if the object is detached, then there is no DB session associated 116 | # with the object, so you don't have a DB connection to send a query 117 | # So in both cases you get an error if you try to read such attributes. 118 | if ins.deleted or ins.detached: 119 | keys -= relationships 120 | keys -= unloaded 121 | 122 | # 5. Delete all explicitly black-listed keys. 123 | # That should be done last, since that may be used to hide some 124 | # sensitive data from JSON representation. 125 | keys -= exclude 126 | 127 | return { key: getattr(self, key) for key in keys } 128 | 129 | #endregion 130 | -------------------------------------------------------------------------------- /flask_jsontools/response.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from flask import current_app, request, Response, json 4 | 5 | 6 | class JsonResponse(Response): 7 | """ Response from a JSON API view """ 8 | 9 | def __init__(self, response, status=None, headers=None, **kwargs): 10 | """ Init a JSON response 11 | :param response: Response data 12 | :type response: * 13 | :param status: Status code 14 | :type status: int|None 15 | :param headers: Additional headers 16 | :type headers: dict|None 17 | """ 18 | # Store response 19 | self._response_data = self.preprocess_response_data(response) 20 | 21 | # PrettyPrint? 22 | try: 23 | indent = 2 if current_app.config['JSONIFY_PRETTYPRINT_REGULAR'] and not request.is_xhr else None 24 | except RuntimeError: # "RuntimeError: working outside of application context" 25 | indent = None 26 | 27 | # Init super 28 | super(JsonResponse, self).__init__( 29 | json.dumps(self._response_data, indent=indent), 30 | headers=headers, status=status, mimetype='application/json', 31 | direct_passthrough=True, **kwargs) 32 | 33 | def preprocess_response_data(self, response): 34 | """ Preprocess the response data. 35 | 36 | Override this method to have custom handling of the response 37 | 38 | :param response: Return value from the view function 39 | :type response: * 40 | :return: Preprocessed value 41 | """ 42 | return response 43 | 44 | def get_json(self): 45 | """ Get the response data object (preprocessed) """ 46 | return self._response_data 47 | 48 | def __getitem__(self, item): 49 | """ Proxy method to get items from the underlying object """ 50 | return self._response_data[item] 51 | 52 | 53 | def normalize_response_value(rv): 54 | """ Normalize the response value into a 3-tuple (rv, status, headers) 55 | :type rv: tuple|* 56 | :returns: tuple(rv, status, headers) 57 | :rtype: tuple(Response|JsonResponse|*, int|None, dict|None) 58 | """ 59 | status = headers = None 60 | if isinstance(rv, tuple): 61 | rv, status, headers = rv + (None,) * (3 - len(rv)) 62 | return rv, status, headers 63 | 64 | 65 | def make_json_response(rv): 66 | """ Make JsonResponse 67 | :param rv: Response: the object to encode, or tuple (response, status, headers) 68 | :type rv: tuple|* 69 | :rtype: JsonResponse 70 | """ 71 | # Tuple of (response, status, headers) 72 | rv, status, headers = normalize_response_value(rv) 73 | 74 | # JsonResponse 75 | if isinstance(rv, JsonResponse): 76 | return rv 77 | 78 | # Data 79 | return JsonResponse(rv, status, headers) 80 | -------------------------------------------------------------------------------- /flask_jsontools/testing.py: -------------------------------------------------------------------------------- 1 | import flask.json 2 | from flask.testing import FlaskClient 3 | 4 | from .response import JsonResponse 5 | 6 | 7 | class FlaskJsonClient(FlaskClient): 8 | """ JSON-aware test client """ 9 | 10 | def open(self, path, json=None, **kwargs): 11 | """ Open an URL, optionally posting JSON data 12 | :param path: URI to request 13 | :type path: str 14 | :param json: JSON data to post 15 | :param method: HTTP Method to use. 'POST' by default if data is provided 16 | :param data: Custom data to post, if required 17 | """ 18 | # Prepare request 19 | if json: 20 | kwargs['data'] = flask.json.dumps(json) 21 | kwargs['content_type'] = 'application/json' 22 | kwargs.setdefault('method', 'POST') 23 | 24 | # Request 25 | rv = super(FlaskJsonClient, self).open(path, **kwargs) 26 | ':type rv: flask.Response' 27 | 28 | # Response: JSON? 29 | if rv.mimetype == 'application/json': 30 | response = flask.json.loads(rv.get_data()) 31 | return JsonResponse(response, rv.status_code, rv.headers) 32 | return rv 33 | -------------------------------------------------------------------------------- /flask_jsontools/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from builtins import object 3 | 4 | import inspect 5 | from collections import defaultdict 6 | from functools import wraps 7 | 8 | from flask.views import View, with_metaclass 9 | from flask import request 10 | from werkzeug.exceptions import MethodNotAllowed 11 | from future.utils import string_types 12 | 13 | 14 | def methodview(methods=(), ifnset=None, ifset=None): 15 | """ Decorator to mark a method as a view. 16 | 17 | NOTE: This should be a top-level decorator! 18 | 19 | :param methods: List of HTTP verbs it works with 20 | :type methods: str|Iterable[str] 21 | :param ifnset: Conditional matching: only if the route param is not set (or is None) 22 | :type ifnset: str|Iterable[str]|None 23 | :param ifset: Conditional matching: only if the route param is set (and is not None) 24 | :type ifset: str|Iterable[str]|None 25 | """ 26 | return _MethodViewInfo(methods, ifnset, ifset).decorator 27 | 28 | 29 | class _MethodViewInfo(object): 30 | """ Method view info object """ 31 | 32 | def decorator(self, func): 33 | """ Wrapper function to decorate a function """ 34 | # This wrapper seems useless, but in fact is serves the purpose 35 | # of being a clean namespace for setting custom attributes 36 | @wraps(func) 37 | def wrapper(*args, **kwargs): 38 | return func(*args, **kwargs) 39 | 40 | # Put the sign 41 | wrapper._methodview = self 42 | 43 | return wrapper 44 | 45 | @classmethod 46 | def get_info(cls, func): 47 | """ :rtype: _MethodViewInfo|None """ 48 | try: return func._methodview 49 | except AttributeError: return None 50 | 51 | def __init__(self, methods=None, ifnset=None, ifset=None): 52 | if isinstance(methods, string_types): 53 | methods = (methods,) 54 | if isinstance(ifnset, string_types): 55 | ifnset = (ifnset,) 56 | if isinstance(ifset, string_types): 57 | ifset = (ifset,) 58 | 59 | #: Method verbs, uppercase 60 | self.methods = frozenset([m.upper() for m in methods]) if methods else None 61 | 62 | #: Conditional matching: route params that should not be set 63 | self.ifnset = frozenset(ifnset) if ifnset else None 64 | 65 | # : Conditional matching: route params that should be set 66 | self.ifset = frozenset(ifset ) if ifset else None 67 | 68 | def matches(self, verb, params): 69 | """ Test if the method matches the provided set of arguments 70 | 71 | :param verb: HTTP verb. Uppercase 72 | :type verb: str 73 | :param params: Existing route parameters 74 | :type params: set 75 | :returns: Whether this view matches 76 | :rtype: bool 77 | """ 78 | return (self.ifset is None or self.ifset <= params) and \ 79 | (self.ifnset is None or self.ifnset.isdisjoint(params)) and \ 80 | (self.methods is None or verb in self.methods) 81 | 82 | def __repr__(self): 83 | return '<{cls}: methods={methods} ifset={ifset} ifnset={ifnset}>'.format( 84 | cls=self.__class__.__name__, 85 | methods=set(self.methods), 86 | ifset=set(self.ifset) if self.ifset else '-', 87 | ifnset=set(self.ifnset) if self.ifnset else '-', 88 | ) 89 | 90 | class MethodViewType(type): 91 | """ Metaclass that collects methods decorated with @methodview """ 92 | 93 | def __init__(cls, name, bases, d): 94 | # Prepare 95 | methods = set(cls.methods or []) 96 | methods_map = defaultdict(dict) 97 | # Methods 98 | for view_name, func in inspect.getmembers(cls): 99 | # Collect methods decorated with methodview() 100 | info = _MethodViewInfo.get_info(func) 101 | if info is not None: 102 | # @methodview-decorated view 103 | for method in info.methods: 104 | methods_map[method][view_name] = info 105 | methods.add(method) 106 | 107 | # Finish 108 | cls.methods = tuple(sorted(methods_map.keys())) # ('GET', ... ) 109 | cls.methods_map = dict(methods_map) # { 'GET': {'get': _MethodViewInfo } } 110 | super(MethodViewType, cls).__init__(name, bases, d) 111 | 112 | 113 | class MethodView(with_metaclass(MethodViewType, View)): 114 | """ Class-based view that dispatches requests to methods decorated with @methodview """ 115 | 116 | def _match_view(self, method, route_params): 117 | """ Detect a view matching the query 118 | 119 | :param method: HTTP method 120 | :param route_params: Route parameters dict 121 | :return: Method 122 | :rtype: Callable|None 123 | """ 124 | method = method.upper() 125 | route_params = frozenset(k for k, v in route_params.items() if v is not None) 126 | 127 | for view_name, info in self.methods_map[method].items(): 128 | if info.matches(method, route_params): 129 | return getattr(self, view_name) 130 | else: 131 | return None 132 | 133 | def dispatch_request(self, *args, **kwargs): 134 | view = self._match_view(request.method, kwargs) 135 | if view is None: 136 | raise MethodNotAllowed(description='No view implemented for {}({})'.format(request.method, ', '.join(kwargs.keys()))) 137 | return view(*args, **kwargs) 138 | 139 | @classmethod 140 | def route_as_view(cls, app, name, rules, *class_args, **class_kwargs): 141 | """ Register the view with an URL route 142 | :param app: Flask application 143 | :type app: flask.Flask|flask.Blueprint 144 | :param name: Unique view name 145 | :type name: str 146 | :param rules: List of route rules to use 147 | :type rules: Iterable[str|werkzeug.routing.Rule] 148 | :param class_args: Args to pass to object constructor 149 | :param class_kwargs: KwArgs to pass to object constructor 150 | :return: View callable 151 | :rtype: Callable 152 | """ 153 | view = super(MethodView, cls).as_view(name, *class_args, **class_kwargs) 154 | for rule in rules: 155 | app.add_url_rule(rule, view_func=view) 156 | return view 157 | 158 | 159 | class RestfulViewType(MethodViewType): 160 | """ Metaclass that automatically defines REST methods """ 161 | methods_map = { 162 | # view-name: (needs-primary-key, http-method) 163 | # Collection methods 164 | 'list': (False, 'GET'), 165 | 'create': (False, 'POST'), 166 | # Item methods 167 | 'get': (True, 'GET'), 168 | 'replace': (True, 'PUT'), 169 | 'update': (True, 'POST'), 170 | 'delete': (True, 'DELETE'), 171 | } 172 | 173 | def __init__(cls, name, bases, d): 174 | pk = getattr(cls, 'primary_key', ()) 175 | mcs = type(cls) 176 | 177 | # Do not do anything with this class unless the primary key is set 178 | if pk: 179 | # Walk through known REST methods 180 | # list() is used to make sure we have a copy and do not re-wrap the same method twice 181 | for view_name, (needs_pk, method) in list(mcs.methods_map.items()): 182 | # Get the view func 183 | view = getattr(cls, view_name, None) 184 | if callable(view): # method exists and is callable 185 | # Automatically decorate it with @methodview() and conditions on PK 186 | view = methodview( 187 | method, 188 | ifnset=None if needs_pk else pk, 189 | ifset=pk if needs_pk else None, 190 | )(view) 191 | setattr(cls, view_name, view) 192 | 193 | # Proceed 194 | super(RestfulViewType, cls).__init__(name, bases, d) 195 | 196 | 197 | class RestfulView(with_metaclass(RestfulViewType, MethodView)): 198 | """ Method View that automatically defines the following methods: 199 | 200 | Collection: 201 | GET / -> list() 202 | POST / -> create() 203 | Individual item: 204 | GET / -> get() 205 | PUT / -> replace() 206 | POST / -> update() 207 | DELETE / -> delete() 208 | 209 | You just need to specify PK fields 210 | """ 211 | 212 | #: List of route parameters used as a primary key. 213 | #: If specified -- then we're working with an individual entry, and if not -- with the whole collection 214 | primary_key = () 215 | 216 | 217 | __all__ = ('methodview', 'MethodView', 'RestfulView') 218 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | nose 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ JSON API tools for Flask """ 3 | 4 | from setuptools import setup, find_packages 5 | 6 | setup( 7 | # http://pythonhosted.org/setuptools/setuptools.html 8 | name='flask_jsontools', 9 | version='0.1.7', 10 | author='Mark Vartanyan', 11 | author_email='kolypto@gmail.com', 12 | 13 | url='https://github.com/kolypto/py-flask-jsontools', 14 | license='BSD', 15 | description=__doc__, 16 | long_description=open('README.md').read(), 17 | long_description_content_type='text/markdown', 18 | keywords=['flask', 'json'], 19 | 20 | packages=find_packages(), 21 | scripts=[], 22 | entry_points={}, 23 | 24 | install_requires=[ 25 | 'flask >= 0.10.1', 26 | 'future >= 0.17.1', 27 | ], 28 | extras_require={}, 29 | include_package_data=True, 30 | test_suite='nose.collector', 31 | 32 | platforms='any', 33 | classifiers=[ 34 | # https://pypi.python.org/pypi?%3Aaction=list_classifiers 35 | 'Development Status :: 5 - Production/Stable', 36 | 'Intended Audience :: Developers', 37 | 'Natural Language :: English', 38 | 'Operating System :: OS Independent', 39 | 'Programming Language :: Python :: 2', 40 | 'Programming Language :: Python :: 3', 41 | 'Topic :: Software Development :: Libraries :: Python Modules', 42 | ], 43 | ) 44 | -------------------------------------------------------------------------------- /tests/jsonapi-test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from flask import Flask, request, Response 3 | from werkzeug.exceptions import NotFound 4 | 5 | from flask_jsontools import jsonapi, FlaskJsonClient, JsonResponse, make_json_response 6 | 7 | 8 | class TestJsonApi(unittest.TestCase): 9 | def setUp(self): 10 | # Database 11 | users = [ 12 | {'id': 1, 'name': 'a'}, 13 | {'id': 2, 'name': 'b'}, 14 | {'id': 3, 'name': 'c'}, 15 | ] 16 | 17 | # Init app 18 | self.app = app = Flask(__name__) 19 | self.app.debug = self.app.testing = True 20 | self.app.test_client_class = FlaskJsonClient 21 | 22 | # Views 23 | @app.route('/user', methods=['GET']) 24 | @jsonapi 25 | def list_users(): 26 | # Just list users 27 | return users 28 | 29 | @app.route('/user/', methods=['GET']) 30 | @jsonapi 31 | def get_user(id): 32 | # Return a user, or http not found 33 | # Use list_users() 34 | try: 35 | return {'user': list_users()[id-1]} 36 | except IndexError: 37 | raise NotFound('User #{} not found'.format(id)) 38 | 39 | @app.route('/user/', methods=['PATCH']) 40 | @jsonapi 41 | def patch_user(id): 42 | # Try custom http codes 43 | if id == 1: 44 | return {'error': 'Denied'}, 403 45 | 46 | # Try PATCH method 47 | req = request.get_json() 48 | users[id-1] = req['user'] 49 | return users[id-1] 50 | 51 | @app.route('/user/', methods=['DELETE']) 52 | def delete_user(id): 53 | # Try returning JsonResponse 54 | if id == 1: 55 | return JsonResponse({'error': 'Denied'}, 403) 56 | 57 | # Try DELETE method 58 | del users[id-1] 59 | return make_json_response(True) 60 | 61 | def testList(self): 62 | """ Test GET /user: returning json objects """ 63 | with self.app.test_client() as c: 64 | rv = c.get('/user') 65 | self.assertEqual(rv.status_code, 200) 66 | self.assertIsInstance(rv, JsonResponse) 67 | self.assertEqual(rv.get_json(), [ {'id': 1, 'name': 'a'}, {'id': 2, 'name': 'b'}, {'id': 3, 'name': 'c'} ]) 68 | 69 | def testGet(self): 70 | """ Test GET /user/: HTTP Errors """ 71 | with self.app.test_client() as c: 72 | # JSON user 73 | rv = c.get('/user/1') 74 | self.assertEqual(rv.status_code, 200) 75 | self.assertIsInstance(rv, JsonResponse) 76 | self.assertEqual(rv.get_json(), {'user': {'id': 1, 'name': 'a'} }) 77 | 78 | # Text HTTP 79 | rv = c.get('/user/99') 80 | self.assertEqual(rv.status_code, 404) 81 | self.assertIsInstance(rv, Response) 82 | self.assertIn('User #99 not found', str(rv.get_data())) 83 | 84 | def testUpdate(self): 85 | """ Test PATCH /user/: custom error codes, exceptions """ 86 | with self.app.test_client() as c: 87 | # JSON error 88 | rv = c.patch('/user/1') 89 | self.assertEqual(rv.status_code, 403) 90 | self.assertIsInstance(rv, JsonResponse) 91 | self.assertEqual(rv.get_json(), {'error': 'Denied'}) 92 | 93 | # JSON user 94 | rv = c.patch('/user/2', {'user': {'id': 2, 'name': 'bbb'}}) 95 | self.assertEqual(rv.status_code, 200) 96 | self.assertIsInstance(rv, JsonResponse) 97 | self.assertEqual(rv.get_json(), {'id': 2, 'name': 'bbb'}) 98 | 99 | # IndexError 100 | self.assertRaises(IndexError, c.patch, '/user/99', {'user': {}}) 101 | 102 | def testDelete(self): 103 | """ Test DELETE /user/: using JsonResponse """ 104 | with self.app.test_client() as c: 105 | # JsonResponse 106 | rv = c.delete('/user/1') 107 | self.assertEqual(rv.status_code, 403) 108 | self.assertIsInstance(rv, JsonResponse) 109 | self.assertEqual(rv.get_json(), {'error': 'Denied'}) 110 | 111 | # make_json_response 112 | rv = c.delete('/user/2') 113 | self.assertEqual(rv.status_code, 200) 114 | self.assertIsInstance(rv, JsonResponse) 115 | self.assertEqual(rv.get_json(), True) 116 | -------------------------------------------------------------------------------- /tests/methodview-test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from functools import wraps 3 | from flask import Flask 4 | from flask_jsontools import jsonapi, FlaskJsonClient 5 | from flask_jsontools import MethodView, methodview, RestfulView 6 | 7 | 8 | def stupid(f): 9 | @wraps(f) 10 | def wrapper(*args, **kwargs): 11 | return f(*args, **kwargs) 12 | return wrapper 13 | 14 | 15 | class CrudView(MethodView): 16 | 17 | decorators = (jsonapi,) 18 | 19 | @stupid 20 | @methodview('GET', ifnset=('id',)) 21 | def list(self): # listing 22 | return [1, 2, 3] 23 | 24 | @methodview('GET', ifset='id') 25 | @stupid 26 | def get(self, id): 27 | return id 28 | 29 | @stupid 30 | @methodview('CUSTOM', ifset='id') 31 | @stupid 32 | def custom(self, id): 33 | return True 34 | 35 | 36 | class RestView(RestfulView): 37 | 38 | decorators = (jsonapi,) 39 | 40 | def list(self): return [1,2,3] 41 | def create(self): return 'ok' 42 | def get(self, id): return id 43 | def replace(self, id): return 're' 44 | def update(self, id): return 'up' 45 | def delete(self, id): return 'del' 46 | 47 | @methodview('CUSTOM', ifset='id') 48 | def custom(self, id): 49 | return ':)' 50 | 51 | @methodview('CUSTOM2', ifset='id') 52 | def custom2(self, id): 53 | return ':))' 54 | 55 | @methodview('CUSTOM3', ifset='id') 56 | def custom3(self, id): 57 | return 'custom3' 58 | 59 | 60 | class RestViewSubclass(RestView): 61 | primary_key = ('id',) 62 | custom2 = None # override 63 | custom3 = None 64 | 65 | @methodview('CUSTOM3', ifset='id') 66 | def custom_new(self, id): 67 | return 'new_custom_3' 68 | 69 | 70 | class RestfulView_CompositePK(RestfulView): 71 | primary_key = ('a', 'b', 'c') 72 | decorators = (jsonapi,) 73 | 74 | def list(self): return 'list' 75 | def create(self): return 'create' 76 | def get(self, a, b, c): return dict(m='get', args=(a, b, c)) 77 | def replace(self, a, b, c): return dict(m='replace', args=(a,b,c)) 78 | def update(self, a, b, c): return dict(m='update', args=(a,b,c)) 79 | def delete(self, a, b, c): return dict(m='delete', args=(a,b,c)) 80 | 81 | @methodview('GET', ifset=('a',), ifnset=('b', 'c',)) 82 | def list_by(self, a, b=None, c=None): return dict(m='list_by', args=(a, b, c)) 83 | 84 | 85 | class RestfulView_Upsert(RestfulView): 86 | primary_key = ('id',) 87 | decorators = (jsonapi,) 88 | 89 | def upsert(self, id=None): 90 | return 'upsert({id})'.format(id=id) 91 | 92 | create = upsert 93 | update = upsert 94 | 95 | class ViewsTest(unittest.TestCase): 96 | def setUp(self): 97 | app = Flask(__name__) 98 | app.test_client_class = FlaskJsonClient 99 | app.debug = app.testing = True 100 | 101 | CrudView.route_as_view(app, 'user', ('/user/', '/user/')) 102 | RestViewSubclass.route_as_view(app, 'rest', ('/api/', '/api/')) # subclass should work as well 103 | RestfulView_CompositePK.route_as_view(app, 'rest_cpk', ( 104 | '/api_cpk/', 105 | '/api_cpk/', # for listing 106 | '/api_cpk///', 107 | )) 108 | RestfulView_Upsert.route_as_view(app, 'upsert', ('/upsert/', '/upsert/')) 109 | 110 | self.app = app 111 | 112 | def _testRequest(self, method, path, expected_code, expected_response=None): 113 | """ Test a request to the app 114 | :param method: HTTP method 115 | :param path: 116 | :type path: 117 | :param expected_code: 118 | :type expected_code: 119 | :param expected_response: 120 | :type expected_response: 121 | :return: 122 | :rtype: 123 | """ 124 | with self.app.test_client() as c: 125 | rv = c.open(path, method=method) 126 | self.assertEqual(rv.status_code, expected_code) 127 | if expected_response is not None: 128 | self.assertEqual(rv.get_json(), expected_response) 129 | 130 | def test_method_view_wrapped(self): 131 | # Correctly wrapped with @wraps 132 | self.assertEqual(RestView.custom.__name__, 'custom') 133 | 134 | def test_method_view(self): 135 | """ Test MethodView(), low-level testing """ 136 | self.assertTrue(CrudView.list._methodview.matches('GET', {'a'})) 137 | self.assertFalse(CrudView.list._methodview.matches('GET', {'id', 'a'})) 138 | self.assertTrue(CrudView.get._methodview.matches('GET', {'id', 'a'})) 139 | self.assertFalse(CrudView.get._methodview.matches('GET', {'a'})) 140 | self.assertTrue(CrudView.custom._methodview.matches('CUSTOM', {'id', 'a'})) 141 | self.assertFalse(CrudView.custom._methodview.matches('CUSTOM', {'a'})) 142 | self.assertFalse(CrudView.custom._methodview.matches('????', {'a'})) 143 | 144 | def test_method_view_requests(self): 145 | """ Test MethodView with real requests """ 146 | self._testRequest('GET', '/user/', 200, [1,2,3]) 147 | self._testRequest('GET', '/user/999', 200, 999) 148 | self._testRequest('CUSTOM', '/user/', 405) # No method (by us) 149 | self._testRequest('CUSTOM', '/user/999', 200, True) 150 | self._testRequest('UNKNOWN', '/user/999', 405) # No method (by flask) 151 | 152 | def test_restful_view_requests(self): 153 | """ Test RestfulView with real requests """ 154 | self._testRequest('GET', '/api/', 200, [1, 2, 3]) 155 | self._testRequest('POST', '/api/', 200, 'ok') 156 | 157 | self._testRequest('GET', '/api/999', 200, 999) 158 | self._testRequest('PUT', '/api/999', 200, 're') 159 | self._testRequest('POST', '/api/999', 200, 'up') 160 | self._testRequest('DELETE', '/api/999', 200, 'del') 161 | self._testRequest('CUSTOM', '/api/999', 200, ':)') 162 | self._testRequest('CUSTOM2', '/api/999', 405) # it was overridden by `None` 163 | self._testRequest('CUSTOM3', '/api/999', 200, 'new_custom_3') # it was overridden 164 | 165 | self._testRequest('PATCH', '/api/999', 405) 166 | self._testRequest('PUT', '/api/', 405) 167 | self._testRequest('PATCH', '/api/', 405) 168 | self._testRequest('DELETE', '/api/', 405) 169 | self._testRequest('CUSTOM', '/api/', 405) 170 | self._testRequest('CUSTOM2', '/api/', 405) 171 | 172 | def test_restfulview_with_composite_primary_key(self): 173 | self._testRequest('GET', '/api_cpk/', 200, 'list') 174 | self._testRequest('POST', '/api_cpk/', 200, 'create') 175 | 176 | self._testRequest('GET', '/api_cpk/1/2/3', 200, dict(m='get', args=[1, 2, 3])) 177 | self._testRequest('PUT', '/api_cpk/1/2/3', 200, dict(m='replace', args=[1, 2, 3])) 178 | self._testRequest('POST', '/api_cpk/1/2/3', 200, dict(m='update', args=[1, 2, 3])) 179 | self._testRequest('DELETE', '/api_cpk/1/2/3', 200, dict(m='delete', args=[1, 2, 3])) 180 | 181 | # List by partial PK 182 | self._testRequest('GET', '/api_cpk/1', 200, dict(m='list_by', args=[1, None, None])) 183 | 184 | def test_restful_view_upsert(self): 185 | """ Test RestfulView with upsert """ 186 | self._testRequest('POST', '/upsert/', 200, 'upsert(None)') 187 | self._testRequest('POST', '/upsert/1', 200, 'upsert(1)') 188 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py{27,34,35,36,37,38},pypy,pypy3 3 | skip_missing_interpreters=True 4 | 5 | [testenv] 6 | deps=-rrequirements-dev.txt 7 | commands= 8 | nosetests {posargs:tests/} 9 | whitelist_externals=make 10 | 11 | [testenv:dev] 12 | deps=-rrequirements-dev.txt 13 | usedevelop=True 14 | --------------------------------------------------------------------------------