├── README.md ├── flask_yoloapi ├── __init__.py ├── endpoint.py ├── exceptions.py ├── types.py └── utils.py ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── mock_app.py └── test_app.py /README.md: -------------------------------------------------------------------------------- 1 | # Flask-YoloAPI 2 | 3 | ![whoop](https://i.imgur.com/xVS3UGq.png) 4 | 5 | A simple library for simple JSON endpoints. YOLO! 6 | 7 | Example 8 | ------- 9 | 10 | #### GET 11 | 12 | ```python 13 | from flask_yoloapi import endpoint, parameter 14 | 15 | @app.route('/api/hello') 16 | @endpoint.api( 17 | parameter('name', type=str, required=True) 18 | ) 19 | def api_hello(name): 20 | return "Hello %s!" % name 21 | ``` 22 | 23 | `http://localhost:5000/api/hello?name=Sander` 24 | 25 | ```javascript 26 | { 27 | data: "Hello Sander!" 28 | } 29 | ``` 30 | 31 | #### POST 32 | 33 | ```python 34 | from flask_yoloapi import endpoint, parameter 35 | 36 | @app.route('/api/hello', methods=['POST']) 37 | @endpoint.api( 38 | parameter('name', type=str, required=True), 39 | parameter('age', type=int, default=18) 40 | ) 41 | def api_hello(name, age): 42 | return "Hello %s, your age is %d" % (name, age) 43 | ``` 44 | 45 | `curl -H "Content-Type: application/json" -vvXPOST -d '{"name":"Sander"}' http://localhost:5000/api/hello` 46 | 47 | ```javascript 48 | { 49 | data: "Hello Sander, your age is 18" 50 | } 51 | ``` 52 | 53 | 54 | Use cases 55 | ------------- 56 | 57 | - No boilerplate code that involves classes to make API routes. 58 | - You don't want to fish incoming parameters out of `request.args` / `request.form` / `request.json` :sleeping: 59 | - You don't need to hook your endpoints directly to SQLa models. 60 | - You don't care about providing REST compliancy - you just want somewhat consistent JSON endpoints, damnit! 61 | 62 | 63 | Installation 64 | ------------ 65 | ```sh 66 | pip install flask-yoloapi 67 | ``` 68 | 69 | 70 | ## Return values 71 | In the example above, a string was returned. The following types are also supported: 72 | 73 | - `str`, `unicode`, `int`, `float`, `dict`, `list`, `datetime`, `bool`, `flask.Response`. 74 | 75 | ```python 76 | @app.route('/wishlist') 77 | @endpoint.api( 78 | parameter('category', type=str, required=False) 79 | ) 80 | def wishlist(category): 81 | if category == "cars": 82 | return ['volvo xc60', 'mclaren mp4-12c'] 83 | ``` 84 | 85 | ```javascript 86 | { 87 | "data": [ 88 | "volvo xc60", 89 | "mclaren mp4-12c" 90 | ] 91 | } 92 | ``` 93 | 94 | ## HTTP status codes 95 | 96 | To return different status codes, return a 2-length `tuple` with the second index being the status code itself. 97 | 98 | ```python 99 | @app.route('/create_foo') 100 | @endpoint.api() 101 | def create_foo(): 102 | return 'created', 201 103 | ``` 104 | 105 | ## Route parameters 106 | 107 | You can still use Flask's route parameters in conjunction with endpoint parameters. 108 | 109 | ```python 110 | @app.route('/hello/') 111 | @endpoint.api( 112 | parameter('age', type=int, required=True) 113 | ) 114 | def hello(name, age): 115 | return {'name': name, 'age': age} 116 | ``` 117 | 118 | `/hello/sander?age=27` 119 | 120 | ```javascript 121 | { 122 | "data": { 123 | "age": 27, 124 | "name": "sander" 125 | } 126 | } 127 | ``` 128 | 129 | ## Default values 130 | 131 | You can define default values for endpoint parameters via `default`. 132 | 133 | ```python 134 | @app.route('/hello/') 135 | @endpoint.api( 136 | parameter('age', type=int, required=False, default=10) 137 | ) 138 | def hello(name, age): 139 | return {'name': name, 'age': age} 140 | ``` 141 | `/hello/sander` 142 | ```javascript 143 | { 144 | "data": { 145 | "age": 10, 146 | "name": "sander" 147 | } 148 | } 149 | ``` 150 | 151 | ## Type annotations 152 | 153 | Parameter types are required, except when type annotations are in use. 154 | 155 | A Python 3.5 example: 156 | 157 | ```python 158 | @app.route('/hello/', methods=['POST']) 159 | @endpoint.api( 160 | parameter('age', required=True), 161 | parameter('name', required=True) 162 | ) 163 | def hello(name: str, age: int): 164 | return {'name': name, 'age': age} 165 | ``` 166 | 167 | Python 2 equivalent: 168 | 169 | ```python 170 | @app.route('/hello/', methods=['POST']) 171 | @endpoint.api( 172 | parameter('age', type=int, required=True), 173 | parameter('name', type=str, required=True) 174 | ) 175 | def hello(name, age): 176 | return {'name': name, 'age': age} 177 | ``` 178 | 179 | Note that type annotations are only supported from Python 3.5 and upwards (PEP 484). 180 | 181 | ## Custom validators 182 | 183 | Additional parameter validation can be done by providing a validator function. This function takes 1 parameter; the input. 184 | 185 | 186 | ```python 187 | def custom_validator(value): 188 | if value > 120: 189 | raise Exception("you can't possibly be that old!") 190 | 191 | @app.route('/hello/') 192 | @endpoint.api( 193 | parameter('age', type=int, required=True, validator=custom_validator) 194 | ) 195 | def hello(name, age): 196 | return {'name': name, 'age': age} 197 | ``` 198 | 199 | `/hello/sander?age=130` 200 | 201 | ```javascript 202 | { 203 | "data": "parameter 'age' error: you can't possibly be that old!" 204 | } 205 | ``` 206 | 207 | When the validation proves to be unsuccessful, you may do 2 things: 208 | 209 | - Raise an `Exception`, it will automatically construct a JSON response. This is shown above. 210 | - Return a `Flask.Response` object, where you may construct your own HTTP response 211 | 212 | If you need more flexibility regarding incoming types use the `flask_yoloapi.types.ANY` type. 213 | 214 | ## Parameter handling 215 | 216 | This library is rather opportunistic about gathering incoming parameters, as it will check in the following 3 places: 217 | 218 | - `request.args` 219 | - `request.json` 220 | - `request.form` 221 | 222 | An optional `location` argument can be provided to specify the source of the parameter. 223 | 224 | ```python 225 | @app.route('/login') 226 | @endpoint.api( 227 | parameter('username', type=str, location='form', required=True), 228 | parameter('password', type=str, location='form', required=True), 229 | ) 230 | def login(username, password): 231 | return "Wrong password!", 403 232 | ``` 233 | 234 | The following 3 locations are supported: 235 | 236 | - `args` - GET parameters 237 | - `form` - parameters submitted via HTTP form submission 238 | - `json` - parameters submitted via a JSON encoded HTTP request 239 | 240 | ## Datetime format 241 | 242 | To output datetime objects in `ISO 8601` format (which are trivial to parse in Javascript via `Date.parse()`), use a custom JSON encoder. 243 | 244 | ```python 245 | from datetime import date 246 | from flask.json import JSONEncoder 247 | 248 | class ApiJsonEncoder(JSONEncoder): 249 | def default(self, obj): 250 | if isinstance(obj, (date, datetime)): 251 | return obj.isoformat() 252 | return super(ApiJsonEncoder, self).default(obj) 253 | 254 | app = Flask(__name__) 255 | app.json_encoder = ApiJsonEncoder 256 | ``` 257 | 258 | 259 | ## Error handling 260 | 261 | When the view function itself raises an exception, a JSON response is generated that includes: 262 | 263 | - The error message 264 | - Docstring of the view function 265 | - HTTP 500 266 | 267 | This error response is also generated when endpoint requirements are not met. 268 | 269 | ```javascript 270 | { 271 | data: "argument 'password' is required", 272 | docstring: { 273 | help: "Logs the user in.", 274 | return: "The logged in message!", 275 | params: { 276 | username: { 277 | help: "The username of the user", 278 | required: true, 279 | type: "str" 280 | } 281 | }, 282 | ... 283 | ``` 284 | 285 | Contributors 286 | ----- 287 | 288 | - dromer 289 | - iksteen 290 | 291 | Tests 292 | ----- 293 | 294 | ``` 295 | $ pytest --cov=flask_yoloapi tests 296 | =========================================== test session starts ============================================ 297 | platform linux -- Python 3.5.3, pytest-3.1.3, py-1.5.2, pluggy-0.4.0 298 | rootdir: /home/dsc/flask-yoloapi, inifile: 299 | plugins: flask-0.10.0, cov-2.5.1 300 | collected 19 items 301 | 302 | tests/test_app.py ................... 303 | 304 | ----------- coverage: platform linux, python 3.5.3-final-0 ----------- 305 | Name Stmts Miss Cover 306 | ------------------------------------------------- 307 | flask_yoloapi/__init__.py 2 0 100% 308 | flask_yoloapi/endpoint.py 111 4 96% 309 | flask_yoloapi/exceptions.py 3 1 67% 310 | flask_yoloapi/types.py 5 2 60% 311 | flask_yoloapi/utils.py 52 5 90% 312 | ------------------------------------------------- 313 | TOTAL 173 12 93% 314 | 315 | ``` 316 | 317 | License 318 | ------------- 319 | MIT. 320 | -------------------------------------------------------------------------------- /flask_yoloapi/__init__.py: -------------------------------------------------------------------------------- 1 | import flask_yoloapi.endpoint 2 | from flask_yoloapi.endpoint import parameter 3 | 4 | -------------------------------------------------------------------------------- /flask_yoloapi/endpoint.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import inspect 3 | import logging 4 | from functools import wraps 5 | from datetime import datetime 6 | 7 | import dateutil.parser 8 | from flask import jsonify, Response 9 | from werkzeug.exceptions import HTTPException 10 | from werkzeug.wrappers import Response as WResponse 11 | 12 | from flask_yoloapi import utils 13 | from flask_yoloapi.types import ANY 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | # Python 2 and 3 support 18 | SUPPORTED_TYPES = (bool, list, dict, datetime, type(None), ANY) 19 | if sys.version_info >= (3, 0): 20 | NUMERIC_TYPES = (int, float) 21 | STRING_LIKE = (str,) 22 | else: 23 | STRING_LIKE = (unicode, str) 24 | NUMERIC_TYPES = (int, float, long) 25 | 26 | SUPPORTED_TYPES += STRING_LIKE 27 | SUPPORTED_TYPES += NUMERIC_TYPES 28 | 29 | 30 | @utils.decorator_parametrized 31 | def api(view_func, *parameters): 32 | """YOLO!""" 33 | messages = { 34 | "required": "argument '%s' is required", 35 | "type_error": "wrong type for argument '%s', " 36 | "should be of type '%s'", 37 | "type_required_py3.5": "no type specified for parameter '%s', " 38 | "specify a type argument or use " 39 | "type annotations (PEP 484)", 40 | "datetime_parse_error": "datetime '%s' could not be parsed using " 41 | "dateutil.parser(\"%s\")", 42 | "bad_return": "view function returned unsupported type '%s'", 43 | "bad_return_tuple": "when returning tuples, the first index " 44 | "must be an object of any supported " 45 | "return type, the second a valid " 46 | "HTTP return status code as an integer" 47 | } 48 | 49 | def func_err(message, http_status=500): 50 | if 500 <= http_status < 600: 51 | logger.exception(message) 52 | else: 53 | logger.error(message) 54 | 55 | return jsonify( 56 | data=message, 57 | docstring=utils.docstring(view_func, *parameters) 58 | ), http_status 59 | 60 | @wraps(view_func) 61 | def validate_and_execute(*args, **kwargs): 62 | # grabs incoming data (multiple methods) 63 | request_data = utils.get_request_data() 64 | 65 | # fall-back type annotations from function signatures 66 | # when no parameter type is specified (python >3.5 only) 67 | type_annotations = None 68 | if sys.version_info >= (3, 5): 69 | signature = inspect.signature(view_func) 70 | type_annotations = {k: v.annotation for k, v in 71 | signature.parameters.items() 72 | if v.annotation is not inspect._empty} 73 | 74 | for param in parameters: 75 | # normalize param key for the view_func(*args, **kwargs) call 76 | param_key_safe = param.key.replace('-', '_') 77 | 78 | # checks if param is required 79 | if param.key not in request_data[param.location]: 80 | if param.required: 81 | return func_err(messages["required"] % param.key) 82 | else: 83 | # set default value, if provided 84 | if param.default is not None: 85 | kwargs[param_key_safe] = param.default 86 | else: 87 | kwargs[param_key_safe] = None 88 | continue 89 | 90 | # set the param type from function annotation (runs only once) 91 | if type_annotations and param.type is None: 92 | if param.key in type_annotations: 93 | param.type = type_annotations[param.key] 94 | else: 95 | return func_err(messages["type_required_py3.5"] % param.key) 96 | 97 | # validate the param value 98 | value = request_data[param.location].get(param.key) 99 | if type(value) != param.type: 100 | if param.type in NUMERIC_TYPES: 101 | try: 102 | value = param.type(value) # opportunistic coercing to int/float/long 103 | except ValueError: 104 | return func_err(messages["type_error"] % (param.key, param.type)) 105 | elif param.type in STRING_LIKE: 106 | pass 107 | elif param.type is ANY: 108 | pass 109 | elif param.type is datetime: 110 | try: 111 | value = dateutil.parser.parse(value) 112 | except: 113 | return func_err(messages["datetime_parse_error"] % (param.key, str(value))) 114 | elif param.type is bool and type(value) in STRING_LIKE: 115 | if value.lower() in ('true', 'y'): 116 | value = True 117 | elif value.lower() in ('false', 'n'): 118 | value = False 119 | else: 120 | return func_err(messages["type_error"] % (param.key, param.type)) 121 | else: 122 | return func_err(messages["type_error"] % (param.key, param.type)) 123 | 124 | # validate via custom validator, if provided 125 | if param.kwargs.get('validator', None): 126 | try: 127 | result = param.kwargs["validator"](value) 128 | if isinstance(result, Response): 129 | return result 130 | elif result: 131 | raise Exception("validator returned an unknown format. " 132 | "either return nothing, raise an Exception or " 133 | "return a `flask.Response` object.") 134 | except Exception as ex: 135 | return func_err("parameter '%s' error: %s" % (param.key, str(ex))) 136 | 137 | kwargs[param_key_safe] = value 138 | 139 | try: 140 | result = view_func(*args, **kwargs) 141 | except HTTPException: 142 | raise 143 | except Exception as ex: 144 | return func_err(str(ex)) 145 | 146 | if isinstance(result, (Response, WResponse)): 147 | return result 148 | elif result is None: 149 | return jsonify(data=None), 204 150 | elif isinstance(result, tuple): 151 | if not len(result) == 2 or not isinstance(result[1], int): 152 | return func_err(messages["bad_return_tuple"]) 153 | return jsonify(data=result[0]), result[1] 154 | 155 | elif not isinstance(result, SUPPORTED_TYPES): 156 | raise TypeError("Bad return type for api_result") 157 | 158 | return jsonify(data=result) 159 | return validate_and_execute 160 | 161 | 162 | class parameter: 163 | def __init__(self, key, type=None, default=None, required=False, validator=None, location='all'): 164 | """ 165 | Endpoint parameter 166 | :param key: The parameter name as a string 167 | :param type: The parameter type 168 | :param default: The default value this parameter should hold 169 | :param required: Marks this parameter as 'required' 170 | :param validator: A custom function that further validates the parameter 171 | :param location: Location where to grab the parameter from. Can be any of: 'args', 'form', 'json' 172 | """ 173 | if not isinstance(key, STRING_LIKE): 174 | raise TypeError("bad type for 'key'; must be 'str'") 175 | if not isinstance(required, bool): 176 | raise TypeError("bad type for 'required'; must be 'bool'") 177 | if type is not None: 178 | if type not in SUPPORTED_TYPES: 179 | raise TypeError("parameter type '%s' not supported" % str(type)) 180 | else: 181 | if not sys.version_info >= (3, 0): 182 | raise TypeError("parameter with key '%s' missing 1 required argument: 'type'" % key) 183 | if default is not None and default.__class__ not in SUPPORTED_TYPES: 184 | raise TypeError("parameter default of type '%s' not supported" % str(type(default))) 185 | if validator is not None and not callable(validator): 186 | raise TypeError("parameter 'validator' must be a function") 187 | if location and location not in ['all', 'args', 'form', 'json']: 188 | raise ValueError("unknown location '%s'" % location) 189 | 190 | self.kwargs = {"validator": validator} 191 | self.default = default 192 | self.location = location 193 | self.key = str(key) 194 | self.type = type 195 | self.type_annotations = None 196 | self.required = required 197 | -------------------------------------------------------------------------------- /flask_yoloapi/exceptions.py: -------------------------------------------------------------------------------- 1 | class UnknownParameterType(BaseException): 2 | def __init__(self, *args, **kwargs): 3 | super(UnknownParameterType, self).__init__(*args, **kwargs) 4 | -------------------------------------------------------------------------------- /flask_yoloapi/types.py: -------------------------------------------------------------------------------- 1 | class ANY(object): 2 | """The ANY type!""" 3 | def __init__(self): 4 | pass 5 | 6 | def __name__(self): 7 | return "ANY" 8 | -------------------------------------------------------------------------------- /flask_yoloapi/utils.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | 3 | from flask_yoloapi.exceptions import UnknownParameterType 4 | 5 | 6 | def docstring(view_func, *parameters): 7 | """Takes a view function, generates an object out of 8 | the docstring""" 9 | _docstring = view_func.__doc__ 10 | if not _docstring: 11 | return 12 | 13 | data = { 14 | "params": {}, 15 | "return": None, 16 | "help": "" 17 | } 18 | 19 | for line in _docstring.strip().split("\n"): 20 | line = line.strip() 21 | 22 | if line.startswith(":param "): 23 | line = line[7:] 24 | if ":" not in line: 25 | continue 26 | 27 | k, v = line.split(':', 1) 28 | v = v.strip() 29 | 30 | try: 31 | param = next(param for param in parameters if param.key == k) 32 | required = param.required if param.required else False 33 | 34 | if param.type is None: 35 | raise UnknownParameterType() 36 | 37 | param = { 38 | "type": param.type.__name__, 39 | "required": required 40 | } 41 | except StopIteration: 42 | param = { 43 | "type": None, 44 | "required": False, 45 | "error": "docstring doesnt include this param" 46 | } 47 | except UnknownParameterType: 48 | param = { 49 | "type": None, 50 | "required": required, 51 | "error": "could not determine type" 52 | } 53 | 54 | param["help"] = v 55 | data["params"][k] = param 56 | continue 57 | 58 | elif line.startswith(":return: "): 59 | data["return"] = line[9:] 60 | continue 61 | 62 | data["help"] += "%s " % line 63 | 64 | data["help"] = data["help"].strip() 65 | return data 66 | 67 | 68 | def get_request_data(): 69 | """Very complicated and extensive algorithm 70 | to fetch incoming request data regardless 71 | of the type of request""" 72 | locations = ['args', 'form', 'json'] 73 | data = {k: {} for k in locations} 74 | data['all'] = {} 75 | 76 | for location in locations: 77 | _data = getattr(request, location) 78 | if _data: 79 | for k, v in _data.items(): 80 | data[location][k] = v 81 | data['all'][k] = v 82 | else: 83 | data[location] = {} 84 | return data 85 | 86 | 87 | def decorator_parametrized(dec): 88 | """So we can give multiple arguments to a decorator""" 89 | def layer(*args, **kwargs): 90 | def repl(view_func): 91 | return dec(view_func, *args, **kwargs) 92 | return repl 93 | return layer 94 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | 5 | version = '0.1.6' 6 | README = os.path.join(os.path.dirname(__file__), 'README.md') 7 | long_description = open(README).read() 8 | setup( 9 | name='Flask-YoloAPI', 10 | version=version, 11 | description='Simply the best Flask API library', 12 | long_description=long_description, 13 | long_description_content_type='text/markdown', 14 | classifiers=[ 15 | 'Development Status :: 4 - Beta', 16 | 'Environment :: Web Environment', 17 | 'Intended Audience :: Developers', 18 | 'License :: OSI Approved :: MIT License', 19 | 'Operating System :: OS Independent', 20 | 'Topic :: Software Development :: Libraries :: Python Modules', 21 | 'Topic :: Utilities', 22 | 'Programming Language :: Python' 23 | ], 24 | keywords='flask api flapi yoloapi', 25 | author='Sander Ferdinand', 26 | author_email='sa.ferdinand@gmail.com', 27 | url='https://github.com/skftn/flask-yoloapi', 28 | install_requires=[ 29 | 'flask', 30 | 'python-dateutil' 31 | ], 32 | extra_requires=[ 33 | 'pytest', 34 | 'pytest-flask', 35 | 'pytest-cov' 36 | ], 37 | setup_requires=['setuptools>=38.6.0'], 38 | download_url= 39 | 'https://github.com/skftn/flask-yoloapi/archive/master.zip', 40 | packages=find_packages(), 41 | include_package_data=True, 42 | ) 43 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kroketio/flask-yoloapi/605cb11eb709d56ec7ae7a0944222796fc244bd5/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # conftest.py 2 | import pytest 3 | 4 | from tests.mock_app import create_app 5 | 6 | 7 | @pytest.fixture 8 | def app(): 9 | app = create_app() 10 | app.debug = True 11 | return app 12 | -------------------------------------------------------------------------------- /tests/mock_app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from datetime import datetime 3 | 4 | from flask import Flask, Response 5 | 6 | from flask_yoloapi.types import ANY 7 | from flask_yoloapi import endpoint, parameter 8 | 9 | 10 | def create_app(): 11 | app = Flask(__name__) 12 | 13 | @app.route('/api/test_get') 14 | @endpoint.api( 15 | parameter('name', type=str, required=True) 16 | ) 17 | def api_test_get(name): 18 | return name 19 | 20 | @app.route('/api/test_get_coerce') 21 | @endpoint.api( 22 | parameter('name', type=str, required=True), 23 | parameter('age', type=int, required=True) 24 | ) 25 | def api_test_get_coerce(name, age): 26 | return [name, age] 27 | 28 | @app.route('/api/test_any') 29 | @endpoint.api( 30 | parameter('name', type=ANY, required=True) 31 | ) 32 | def api_test_any(name): 33 | return name 34 | 35 | @app.route('/api/test_post', methods=['POST']) 36 | @endpoint.api( 37 | parameter('name', type=str) 38 | ) 39 | def api_test_post(name): 40 | return name 41 | 42 | @app.route('/api/test_get_default') 43 | @endpoint.api( 44 | parameter('name', type=str, default='default') 45 | ) 46 | def api_test_get_default(name): 47 | return name 48 | 49 | @app.route('/api/test_datetime') 50 | @endpoint.api( 51 | parameter('date', type=datetime, required=True) 52 | ) 53 | def api_test_datetime(date): 54 | return date 55 | 56 | @app.route('/api/test_bool', methods=['GET', 'POST']) 57 | @endpoint.api( 58 | parameter('flag', type=bool, required=True) 59 | ) 60 | def api_test_bool(flag): 61 | return flag 62 | 63 | def age_validator(value): 64 | # test custom exception 65 | if value >= 150: 66 | raise Exception("you can't possibly be that old!") 67 | 68 | # test custom response 69 | if value >= 120: 70 | return Response('all lies', status=403) 71 | 72 | # test invalid response 73 | if value >= 110: 74 | return 1, 1 75 | 76 | @app.route('/api/test_validator') 77 | @endpoint.api( 78 | parameter('age', type=int, required=True, validator=age_validator) 79 | ) 80 | def api_test_age_validator(age): 81 | return age 82 | 83 | @app.route('/api/test_broken_route') 84 | @endpoint.api( 85 | parameter('age', type=int, required=False, default=28) 86 | ) 87 | def api_test_broken_route(age): 88 | raise Exception('whoops') 89 | 90 | @app.route('/api/test_status_code') 91 | @endpoint.api( 92 | parameter('age', type=int, required=False, default=28) 93 | ) 94 | def api_test_status_code(age): 95 | return "203 test", 203 96 | 97 | @app.route('/api/test_bad_status_code') 98 | @endpoint.api( 99 | parameter('age', type=int, required=False, default=28) 100 | ) 101 | def api_test_bad_status_code(age): 102 | return "203 test", 203, 1 103 | 104 | class UFO: 105 | def __init__(self): 106 | self.foo = 'bar' 107 | 108 | @app.route('/api/test_unknown_return') 109 | @endpoint.api( 110 | parameter('age', type=int, required=False, default=28) 111 | ) 112 | def api_test_unknown_return(age): 113 | return UFO() 114 | 115 | @app.route('/api/test_empty_return') 116 | @endpoint.api() 117 | def api_test_empty_return(): 118 | return 119 | 120 | @app.route('/api/test_custom_return') 121 | @endpoint.api() 122 | def api_test_custom_return(): 123 | return Response("foo", 203) 124 | 125 | @app.route('/api/test_docstring') 126 | @endpoint.api( 127 | parameter("foo", default="bar", type=str) 128 | ) 129 | def api_test_docstring(foo): 130 | """ 131 | Just a test. With multiple 132 | lines. 133 | :param foo: bar! 134 | :param faulty line, should be ignored as a param. 135 | :return: Nothing, really. 136 | """ 137 | raise Exception('whoops') 138 | 139 | @app.route('/api/test_types', methods=["GET", 'POST']) 140 | @endpoint.api( 141 | parameter('a', type=str, required=True), 142 | parameter('b', type=int, required=True), 143 | parameter('c', type=dict, required=False), 144 | parameter('d', type=list, required=False), 145 | parameter('e', type=datetime, required=True), 146 | parameter('f', type=bool, required=False) 147 | ) 148 | def api_test_types(a, b, c, d, e, f): 149 | return [a, b, c, d, e, f] 150 | 151 | if sys.version_info >= (3, 5): 152 | @app.route('/api/test_type_annotations') 153 | @endpoint.api( 154 | parameter('name', required=True), 155 | parameter('age', required=False) 156 | ) 157 | def api_test_type_annotations(name: str, age: int, location: str = "Amsterdam"): 158 | return {"name": name, "age": age, "location": location} 159 | 160 | @app.route('/api/test_type_annotations_fail') 161 | @endpoint.api( 162 | parameter('name', required=True), 163 | parameter('age', required=False) 164 | ) 165 | def api_test_type_annotations_fail(name: str, age): 166 | return {"name": name, "age": age} 167 | 168 | return app 169 | 170 | 171 | if __name__ == '__main__': 172 | app = create_app() -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | # test_app.py 2 | import sys 3 | import json 4 | import pytest 5 | from flask import url_for 6 | 7 | mimetype = 'application/json' 8 | headers = { 9 | 'Content-Type': mimetype, 10 | 'Accept': mimetype 11 | } 12 | 13 | 14 | class TestApp: 15 | def test_api_get(self, client): 16 | data = {'name': 'test'} 17 | res = client.get(url_for("api_test_get"), query_string=data) 18 | assert res.content_type == mimetype 19 | assert res.status_code == 200 20 | assert res.json == {'data': 'test'} 21 | 22 | def test_api_get_required(self, client): 23 | data = {} 24 | res = client.get(url_for("api_test_get"), query_string=data) 25 | assert res.content_type == mimetype 26 | assert res.status_code == 500 27 | assert 'argument \'name\' is required' in res.json.get('data') 28 | 29 | def test_api_get_coerce(self, client): 30 | data = {'name': 'test', 'age': '28'} 31 | res = client.get(url_for("api_test_get_coerce"), query_string=data) 32 | assert res.content_type == mimetype 33 | assert res.status_code == 200 34 | assert res.json == {'data': ['test', 28]} 35 | 36 | data = {'name': 'test', 'age': 'error'} 37 | res = client.get(url_for("api_test_get_coerce"), query_string=data) 38 | assert res.content_type == mimetype 39 | assert res.status_code == 500 40 | assert 'wrong type for argument \'age\'' in res.json.get('data') 41 | 42 | def test_api_any(self, client): 43 | data = {'name': 42} 44 | res = client.get(url_for("api_test_any"), query_string=data) 45 | assert res.content_type == mimetype 46 | assert res.status_code == 200 47 | assert res.json == {'data': '42'} 48 | 49 | def test_api_post(self, client): 50 | data = {'name': 'test'} 51 | res = client.post(url_for("api_test_post"), data=json.dumps(data), headers=headers) 52 | assert res.content_type == mimetype 53 | assert res.status_code == 200 54 | assert res.json == {'data': 'test'} 55 | 56 | def test_api_get_defaults(self, client): 57 | res = client.get(url_for("api_test_get_default")) 58 | assert res.content_type == mimetype 59 | assert res.status_code == 200 60 | assert res.json == {'data': 'default'} 61 | 62 | def test_api_datetime(self, client): 63 | data = {'date': '2018-01-01'} 64 | res = client.get(url_for("api_test_datetime"), query_string=data) 65 | assert res.content_type == mimetype 66 | assert res.status_code == 200 67 | assert res.json == {'data': 'Mon, 01 Jan 2018 00:00:00 GMT'} 68 | 69 | data = {'date': 'error'} 70 | res = client.get(url_for("api_test_datetime"), query_string=data) 71 | assert res.content_type == mimetype 72 | assert res.status_code == 500 73 | assert 'datetime \'date\' could not be parsed' in res.json.get('data') 74 | 75 | def test_api_bool(self, client): 76 | data = {'flag': 'true'} 77 | res = client.get(url_for("api_test_bool"), query_string=data) 78 | assert res.content_type == mimetype 79 | assert res.status_code == 200 80 | assert res.json == {'data': True} 81 | 82 | data = {'flag': 'y'} 83 | res = client.get(url_for("api_test_bool"), query_string=data) 84 | assert res.content_type == mimetype 85 | assert res.status_code == 200 86 | assert res.json == {'data': True} 87 | 88 | data = {'flag': 'N'} 89 | res = client.get(url_for("api_test_bool"), query_string=data) 90 | assert res.content_type == mimetype 91 | assert res.status_code == 200 92 | assert res.json == {'data': False} 93 | 94 | data = {'flag': 'NOPE'} 95 | res = client.get(url_for("api_test_bool"), query_string=data) 96 | assert res.content_type == mimetype 97 | assert res.status_code == 500 98 | assert 'wrong type for argument \'flag\'' in res.json.get('data') 99 | 100 | data = {'flag': ['error']} 101 | res = client.post(url_for("api_test_bool"), data=json.dumps(data), headers=headers) 102 | assert res.content_type == mimetype 103 | assert res.status_code == 500 104 | assert 'wrong type for argument \'flag\'' in res.json.get('data') 105 | 106 | def test_api_age_validator(self, client): 107 | data = {'age': 20} 108 | res = client.get(url_for("api_test_age_validator"), query_string=data) 109 | assert res.content_type == mimetype 110 | assert res.status_code == 200 111 | assert res.json == {'data': 20} 112 | 113 | # custom Flask.Response returns from within the validator 114 | data = {'age': 120} 115 | res = client.get(url_for("api_test_age_validator"), query_string=data) 116 | assert res.status_code == 403 117 | if isinstance(res.data, bytes): 118 | data = res.data.decode('utf8') 119 | assert 'all lies' in data 120 | 121 | # trigger exception from within the validator 122 | data = {'age': 150} 123 | res = client.get(url_for("api_test_age_validator"), query_string=data) 124 | assert res.content_type == mimetype 125 | assert res.status_code == 500 126 | assert "parameter 'age' error: you can't possibly be that old!" in res.json.get('data') 127 | 128 | # test invalid response 129 | data = {'age': 110} 130 | res = client.get(url_for("api_test_age_validator"), query_string=data) 131 | assert res.content_type == mimetype 132 | assert res.status_code == 500 133 | assert "validator returned an unknown format" in res.json.get('data') 134 | 135 | def test_api_broken_route(self, client): 136 | data = {'age': 28} 137 | res = client.get(url_for("api_test_broken_route"), query_string=data) 138 | assert res.content_type == mimetype 139 | assert res.status_code == 500 140 | assert res.json == {'data': 'whoops', 'docstring': None} 141 | 142 | def test_api_status_code(self, client): 143 | data = {'age': 28} 144 | res = client.get(url_for("api_test_status_code"), query_string=data) 145 | assert res.content_type == mimetype 146 | assert res.status_code == 203 147 | assert res.json == {'data': '203 test'} 148 | 149 | def test_api_bad_status_code(self, client): 150 | data = {'age': 28} 151 | res = client.get(url_for("api_test_bad_status_code"), query_string=data) 152 | assert res.content_type == mimetype 153 | assert res.status_code == 500 154 | assert "when returning tuples, the first index must be an object of" in res.json.get('data') 155 | 156 | def test_api_unknown_return(self, client): 157 | try: 158 | res = client.get(url_for("api_test_unknown_return"), query_string={'age': 28}) 159 | except Exception as ex: 160 | assert isinstance(ex, TypeError) 161 | assert 'Bad return type' in str(ex) 162 | 163 | def test_api_empty_return(self, client): 164 | res = client.get(url_for("api_test_empty_return")) 165 | assert res.status_code == 204 166 | 167 | def test_api_custom_return(self, client): 168 | res = client.get(url_for("api_test_custom_return")) 169 | assert res.status_code == 203 170 | 171 | def test_api_docstring(self, client): 172 | # tests auto docstring generation 173 | res = client.get(url_for("api_test_docstring"), query_string={'foo': 'whoop'}) 174 | assert res.status_code == 500 175 | assert res.json == { 176 | 'data': 'whoops', 177 | 'docstring': { 178 | 'return': 'Nothing, really.', 179 | 'params': { 180 | 'foo': {'required': False, 'help': 'bar!', 'type': 'str'}}, 181 | 'help': 'Just a test. With multiple lines.' 182 | } 183 | } 184 | 185 | def test_api_types(self, client): 186 | # first test GET 187 | res = client.get(url_for("api_test_types"), query_string={ 188 | 'a': 'test', 189 | 'b': 2, 190 | 'e': '2018-01-02' 191 | }) 192 | assert res.content_type == mimetype 193 | assert res.status_code == 200 194 | assert res.json == {'data': ['test', 2, None, None, 'Tue, 02 Jan 2018 00:00:00 GMT', None]} 195 | 196 | # test POST, include dict and list 197 | res = client.post(url_for("api_test_types"), data=json.dumps({ 198 | 'a': 'test', 199 | 'b': 2, 200 | 'c': {'foo': 'bar'}, 201 | 'd': ['foo', 'bar'], 202 | 'e': '2018-01-02', 203 | 'f': False 204 | }), headers=headers) 205 | assert res.status_code == 200 206 | assert res.content_type == mimetype 207 | assert res.json == {'data': ['test', 2, {'foo': 'bar'}, ['foo', 'bar'], 'Tue, 02 Jan 2018 00:00:00 GMT', False]} 208 | 209 | def test_api_type_annotations(self, client): 210 | if not sys.version_info >= (3, 5): # python >= 3.5 only 211 | return 212 | 213 | data = {'name': 'sander', 'age': 28} 214 | res = client.get(url_for("api_test_type_annotations"), query_string=data) 215 | assert res.content_type == mimetype 216 | assert res.status_code == 200 217 | assert res.json == {'data': {'name': 'sander', 'location': 'Amsterdam', 'age': 28}} 218 | 219 | data = {'name': 'sander', 'age': 28} 220 | res = client.get(url_for("api_test_type_annotations_fail"), query_string=data) 221 | assert res.content_type == mimetype 222 | assert res.status_code == 500 223 | assert 'no type specified for parameter \'age\'' in res.json.get('data') 224 | 225 | def test_yolo_decorator(self, client): 226 | from flask_yoloapi import endpoint, parameter 227 | exceptions = 0 228 | 229 | try: 230 | @client.application.route('/bad_parameter_key') 231 | @endpoint.api( 232 | parameter(1, type=int, required=True) 233 | ) 234 | def bad_parameter_key(): 235 | pass 236 | except Exception as ex: 237 | exceptions += 1 238 | assert isinstance(ex, TypeError) 239 | assert 'bad type for \'key\'; must be' in str(ex) 240 | 241 | assert exceptions == 1 242 | 243 | try: 244 | @client.application.route('/bad_parameter_required') 245 | @endpoint.api( 246 | parameter('foo', type=str, required=1) 247 | ) 248 | def bad_parameter_required(foo): 249 | pass 250 | except Exception as ex: 251 | exceptions += 1 252 | assert isinstance(ex, TypeError) 253 | assert 'bad type for \'required\'; must be' in str(ex) 254 | 255 | assert exceptions == 2 256 | 257 | try: 258 | @client.application.route('/bad_parameter_required') 259 | @endpoint.api( 260 | parameter('foo', type=str, validator=1) 261 | ) 262 | def bad_parameter_validator(foo): 263 | pass 264 | except Exception as ex: 265 | exceptions += 1 266 | assert isinstance(ex, TypeError) 267 | assert 'parameter \'validator\' must be a function' in str(ex) 268 | 269 | assert exceptions == 3 270 | 271 | try: 272 | @client.application.route('/bad_parameter_type') 273 | @endpoint.api( 274 | parameter('foo', type=FileExistsError, validator=1) 275 | ) 276 | def bad_parameter_type(foo): 277 | pass 278 | except Exception as ex: 279 | exceptions += 1 280 | assert isinstance(ex, TypeError) 281 | assert 'not supported' in str(ex) 282 | 283 | assert exceptions == 4 284 | 285 | try: 286 | @client.application.route('/bad_parameter_location') 287 | @endpoint.api( 288 | parameter('foo', type=int, location='error') 289 | ) 290 | def bad_parameter_location(foo): 291 | pass 292 | except Exception as ex: 293 | exceptions += 1 294 | assert isinstance(ex, ValueError) 295 | assert 'unknown location' in str(ex) 296 | 297 | assert exceptions == 5 298 | 299 | try: 300 | @client.application.route('/bad_parameter_bad_default') 301 | @endpoint.api( 302 | parameter('foo', type=str, default=int) 303 | ) 304 | def bad_parameter_bad_default(foo): 305 | pass 306 | except TypeError as ex: 307 | exceptions += 1 308 | assert isinstance(ex, TypeError) 309 | assert 'not supported' in str(ex) 310 | 311 | assert exceptions == 6 --------------------------------------------------------------------------------