├── .gitignore ├── bootstrap.py ├── docs └── index.rst ├── flask_scalarest ├── __init__.py ├── core │ ├── __init__.py │ ├── auth.py │ ├── marshal.py │ ├── metrics.py │ ├── rest_fields.py │ └── session.py ├── exception.py ├── extensions │ ├── __init__.py │ ├── database.py │ ├── jwt.py │ ├── mail.py │ └── rest.py ├── helper.py └── resources │ ├── __init__.py │ ├── base │ ├── __init__.py │ ├── fields.py │ ├── models.py │ └── views.py │ └── example │ ├── __init__.py │ ├── fields.py │ ├── models.py │ └── views.py ├── readme.md ├── requirements.txt ├── run.py ├── setup.cfg ├── setup.py ├── test_settings.py ├── tests ├── __init__.py ├── test_jwt.py ├── test_metrics.py ├── test_passlib.py ├── test_redis_session.py └── test_users.py └── uwsgi.ini /.gitignore: -------------------------------------------------------------------------------- 1 | flask_scalarest.egg-info 2 | *.pyc -------------------------------------------------------------------------------- /bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf8 -*- 3 | 4 | import os 5 | from appmetrics.wsgi import AppMetricsMiddleware 6 | from flask_scalarest import create_app 7 | from flask_scalarest.extensions.jwt import jwt 8 | 9 | 10 | here = os.path.dirname(os.path.realpath(__file__)) 11 | config_file = os.path.join(here, 'test_settings.py') 12 | application = create_app(config_file) 13 | application.wsgi_app = AppMetricsMiddleware(application.wsgi_app) 14 | jwt.init_app(application) 15 | 16 | 17 | if __name__ == '__main__': 18 | application.run() -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | API文档 2 | ======== -------------------------------------------------------------------------------- /flask_scalarest/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | # 人生苦短,我用python 4 | 5 | from __future__ import absolute_import, print_function 6 | from flask import Flask 7 | 8 | from .extensions.database import database 9 | from .extensions.rest import rest_api 10 | 11 | from .resources.example import (UserResource, UsersResource) 12 | from .resources.base import (ApiTokenResource,) 13 | 14 | 15 | def create_app(config_file, use_diesel=False): 16 | app = Flask(__name__) 17 | app.config.from_pyfile(config_file) 18 | configure_extensions(app) 19 | init_database(app) 20 | configure_resource(app) 21 | return app 22 | 23 | 24 | def configure_resource(app): 25 | 26 | print('Restful API LIST FINISHED!') 27 | 28 | 29 | def configure_sqlalchemy_log(app): 30 | import logging 31 | logging.basicConfig() 32 | level = logging.ERROR 33 | if app.config['DEBUG']: 34 | level = logging.INFO 35 | logging.getLogger('sqlalchemy.engine').setLevel(level) 36 | 37 | 38 | def init_database(app): 39 | database.init_app(app) 40 | database.create_all() 41 | 42 | 43 | def configure_extensions(app): 44 | rest_api.init_app(app) 45 | rest_api.app = app 46 | 47 | 48 | def configure_errors(app): 49 | errors = { 50 | 'UserAlreadyExistsError': { 51 | 'message': "A user with that username already exists.", 52 | 'status': 409, 53 | }, 54 | 'ResourceDoesNotExist': { 55 | 'message': "A resource with that ID no longer exists.", 56 | 'status': 410, 57 | 'extra': "Any extra information you want.", 58 | }, 59 | } 60 | 61 | -------------------------------------------------------------------------------- /flask_scalarest/core/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | jaryee.web 4 | ~~~~~~~~~~~~~~~~~~~ 5 | 6 | Jaryee system application 7 | 8 | :copyright: (c) Power by Daqing Chan. 9 | :license, see LICENSE for more details. 10 | """ 11 | import json 12 | import datetime 13 | from collections import OrderedDict 14 | 15 | 16 | class DictSerializableMixed(object): 17 | 18 | def to_dict(self): 19 | result = OrderedDict() 20 | for key in self.__mapper__.c.keys(): 21 | val = getattr(self, key) 22 | result[key] = val 23 | # if isinstance(val, datetime.datetime): 24 | # result[key] = val.strftime('%Y-%m-%d %H:%M:%S') 25 | # else: 26 | # result[key] = val 27 | return result 28 | 29 | 30 | class CJsonEncoder(json.JSONEncoder): 31 | """ 32 | Usage:: 33 | 34 | json.dumps(yourdatetimeobj, cls=CJsonEncoder) 35 | 36 | """ 37 | def default(self, obj): 38 | if isinstance(obj, datetime.datetime): 39 | return obj.strftime('%Y-%m-%d %H:%M:%S') 40 | elif isinstance(obj, datetime.date): 41 | return obj.strftime("%Y-%m-%d") 42 | else: 43 | return json.JSONEncoder.default(self, obj) -------------------------------------------------------------------------------- /flask_scalarest/core/auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from flask import request, Response, current_app as app, g, abort 3 | from functools import wraps 4 | 5 | 6 | def requires_auth(endpoint_class): 7 | """ Enables Authorization logic for decorated functions. 8 | 9 | :param endpoint_class: the 'class' to which the decorated endpoint belongs 10 | to. Can be 'resource' (resource endpoint), 'item' 11 | (item endpoint) and 'home' for the API entry point. 12 | 13 | .. versionchanged:: 0.0.7 14 | Passing the 'resource' argument when inoking auth.authenticate() 15 | 16 | .. versionchanged:: 0.0.5 17 | Support for Cross-Origin Resource Sharing (CORS): 'OPTIONS' request 18 | method is now public by default. The actual method ('GET', etc.) will 19 | still be protected if so configured. 20 | 21 | .. versionadded:: 0.0.4 22 | """ 23 | def fdec(f): 24 | @wraps(f) 25 | def decorated(*args, **kwargs): 26 | if args: 27 | # resource or item endpoint 28 | resource_name = args[0] 29 | resource = app.config['DOMAIN'][args[0]] 30 | if endpoint_class == 'resource': 31 | public = resource['public_methods'] 32 | roles = resource['allowed_roles'] 33 | if request.method in ['GET', 'HEAD', 'OPTIONS']: 34 | roles += resource['allowed_read_roles'] 35 | else: 36 | roles += resource['allowed_write_roles'] 37 | elif endpoint_class == 'item': 38 | public = resource['public_item_methods'] 39 | roles = resource['allowed_item_roles'] 40 | if request.method in ['GET', 'HEAD', 'OPTIONS']: 41 | roles += resource['allowed_item_read_roles'] 42 | else: 43 | roles += resource['allowed_item_write_roles'] 44 | auth = resource['authentication'] 45 | else: 46 | # home 47 | resource_name = resource = None 48 | public = app.config['PUBLIC_METHODS'] + ['OPTIONS'] 49 | roles = app.config['ALLOWED_ROLES'] 50 | if request.method in ['GET', 'OPTIONS']: 51 | roles += app.config['ALLOWED_READ_ROLES'] 52 | else: 53 | roles += app.config['ALLOWED_WRITE_ROLES'] 54 | auth = app.auth 55 | if auth and request.method not in public: 56 | if not auth.authorized(roles, resource_name, request.method): 57 | return auth.authenticate() 58 | return f(*args, **kwargs) 59 | return decorated 60 | return fdec 61 | 62 | 63 | class BasicAuth(object): 64 | """ Implements Basic AUTH logic. Should be subclassed to implement custom 65 | authentication checking. 66 | 67 | .. versionchanged:: 0.4 68 | ensure all errors returns a parseable body #366. 69 | auth.request_auth_value replaced with getter and setter methods which 70 | rely on flask's 'g' object, for enhanced thread-safity. 71 | 72 | .. versionchanged:: 0.1.1 73 | auth.request_auth_value is now used to store the auth_field value. 74 | 75 | .. versionchanged:: 0.0.9 76 | Support for user_id property. 77 | 78 | .. versionchanged:: 0.0.7 79 | Support for 'resource' argument. 80 | 81 | .. versionadded:: 0.0.4 82 | """ 83 | def set_request_auth_value(self, value): 84 | g.auth_value = value 85 | 86 | def get_request_auth_value(self): 87 | return g.get("auth_value") 88 | 89 | def check_auth(self, username, password, allowed_roles, resource, method): 90 | """ This function is called to check if a username / password 91 | combination is valid. Must be overridden with custom logic. 92 | 93 | :param username: username provided with current request. 94 | :param password: password provided with current request 95 | :param allowed_roles: allowed user roles. 96 | :param resource: resource being requested. 97 | :param method: HTTP method being executed (POST, GET, etc.) 98 | """ 99 | raise NotImplementedError 100 | 101 | def authenticate(self): 102 | """ Returns a standard a 401 response that enables basic auth. 103 | Override if you want to change the response and/or the realm. 104 | """ 105 | resp = Response(None, 401, {'WWW-Authenticate': 'Basic realm:"%s"' % 106 | __package__}) 107 | abort(401, description='Please provide proper credentials', 108 | response=resp) 109 | 110 | def authorized(self, allowed_roles, resource, method): 111 | """ Validates the the current request is allowed to pass through. 112 | 113 | :param allowed_roles: allowed roles for the current request, can be a 114 | string or a list of roles. 115 | :param resource: resource being requested. 116 | """ 117 | auth = request.authorization 118 | return auth and self.check_auth(auth.username, auth.password, 119 | allowed_roles, resource, method) 120 | 121 | 122 | class HMACAuth(BasicAuth): 123 | """ Hash Message Authentication Code (HMAC) authentication logic. Must be 124 | subclassed to implement custom authorization checking. 125 | 126 | .. versionchanged:: 0.4 127 | Ensure all errors returns a parseable body #366. 128 | 129 | .. versionchanged:: 0.0.9 130 | Replaced the now deprecated request.data with request.get_data(). 131 | 132 | .. versionchanged:: 0.0.7 133 | Support for 'resource' argument. 134 | 135 | .. versionadded:: 0.0.5 136 | """ 137 | def check_auth(self, userid, hmac_hash, headers, data, allowed_roles, 138 | resource, method): 139 | """ This function is called to check if a token is valid. Must be 140 | overridden with custom logic. 141 | 142 | :param userid: user id included with the request. 143 | :param hmac_hash: hash included with the request. 144 | :param headers: request headers. Suitable for hash computing. 145 | :param data: request data. Suitable for hash computing. 146 | :param allowed_roles: allowed user roles. 147 | :param resource: resource being requested. 148 | :param method: HTTP method being executed (POST, GET, etc.) 149 | """ 150 | raise NotImplementedError 151 | 152 | def authenticate(self): 153 | """ Returns a standard a 401. Override if you want to change the 154 | response. 155 | """ 156 | abort(401, description='Please provide proper credentials') 157 | 158 | def authorized(self, allowed_roles, resource, method): 159 | """ Validates the the current request is allowed to pass through. 160 | 161 | :param allowed_roles: allowed roles for the current request, can be a 162 | string or a list of roles. 163 | :param resource: resource being requested. 164 | """ 165 | auth = request.headers.get('Authorization') 166 | try: 167 | userid, hmac_hash = auth.split(':') 168 | except: 169 | auth = None 170 | return auth and self.check_auth(userid, hmac_hash, request.headers, 171 | request.get_data(), allowed_roles, 172 | resource, method) 173 | 174 | 175 | class TokenAuth(BasicAuth): 176 | """ Implements Token AUTH logic. Should be subclassed to implement custom 177 | authentication checking. 178 | 179 | .. versionchanged:: 0.4 180 | Ensure all errors returns a parseable body #366. 181 | 182 | .. versionchanged:: 0.0.7 183 | Support for 'resource' argument. 184 | 185 | .. versionadded:: 0.0.5 186 | """ 187 | def check_auth(self, token, allowed_roles, resource, method): 188 | """ This function is called to check if a token is valid. Must be 189 | overridden with custom logic. 190 | 191 | :param token: decoded user name. 192 | :param allowed_roles: allowed user roles 193 | :param resource: resource being requested. 194 | :param method: HTTP method being executed (POST, GET, etc.) 195 | """ 196 | raise NotImplementedError 197 | 198 | def authenticate(self): 199 | """ Returns a standard a 401 response that enables basic auth. 200 | Override if you want to change the response and/or the realm. 201 | """ 202 | resp = Response(None, 401, {'WWW-Authenticate': 'Basic realm:"%s"' % 203 | __package__}) 204 | abort(401, description='Please provide proper credentials', 205 | response=resp) 206 | 207 | def authorized(self, allowed_roles, resource, method): 208 | """ Validates the the current request is allowed to pass through. 209 | 210 | :param allowed_roles: allowed roles for the current request, can be a 211 | string or a list of roles. 212 | :param resource: resource being requested. 213 | """ 214 | auth = request.authorization 215 | return auth and self.check_auth(auth.username, allowed_roles, resource, 216 | method) 217 | 218 | 219 | def auth_field_and_value(resource): 220 | """ If auth is active and the resource requires it, return both the 221 | current request 'request_auth_value' and the 'auth_field' for the resource 222 | 223 | .. versionchanged:: 0.4 224 | Use new auth.request_auth_value() method. 225 | 226 | .. versionadded:: 0.3 227 | """ 228 | if '|resource' in request.endpoint: 229 | # We are on a resource endpoint and need to check against 230 | # `public_methods` 231 | public_method_list_to_check = 'public_methods' 232 | else: 233 | # We are on an item endpoint and need to check against 234 | # `public_item_methods` 235 | public_method_list_to_check = 'public_item_methods' 236 | 237 | resource_dict = app.config['DOMAIN'][resource] 238 | auth = resource_dict['authentication'] 239 | 240 | request_auth_value = auth.get_request_auth_value() if auth else None 241 | auth_field = resource_dict.get('auth_field', None) if request.method not \ 242 | in resource_dict[public_method_list_to_check] else None 243 | 244 | return auth_field, request_auth_value 245 | -------------------------------------------------------------------------------- /flask_scalarest/core/marshal.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | 5 | 6 | import time 7 | import six 8 | from flask_restful import fields as _fields, marshal_with as _marshal_with 9 | from functools import wraps 10 | 11 | 12 | def marshal_with_model(model, excludes=None, only=None, extends=None): 13 | """With this decorator, you can return ORM model instance, or ORM query in 14 | view function directly. We'll transform these objects to standard python 15 | data structures, like Flask-RESTFul's `marshal_with` decorator. 16 | And, you don't need define fields at all. 17 | You can specific columns to be returned, by `excludes` or `only` parameter. 18 | (Don't use these tow parameters at the same time, otherwise only `excludes` 19 | parameter will be used.) 20 | If you want return fields that outside of model, or overwrite the type of some fields, 21 | use `extends` parameter to specify them. 22 | Notice: this function only support `Flask-SQLAlchemy` 23 | Example: 24 | class Student(db.Model): 25 | id = Column(Integer, primary_key=True) 26 | name = Column(String(100)) 27 | age = Column(Integer) 28 | class SomeApi(Resource): 29 | @marshal_with_model(Student, excludes=['id']) 30 | def get(self): 31 | return Student.query 32 | # response: [{"name": "student_a", "age": "16"}, {"name": "student_b", "age": 18}] 33 | class AnotherApi(Resource): 34 | @marshal_with_model(Student, extends={"nice_guy": fields.Boolean, "age": fields.String}) 35 | def get(self): 36 | student = Student.query.get(1) 37 | student.nice_guy = True 38 | student.age = "young" if student.age < 18 else "old" # transform int field to string 39 | return student 40 | """ 41 | if isinstance(excludes, six.string_types): 42 | excludes = [excludes] 43 | if excludes and only: 44 | only = None 45 | elif isinstance(only, six.string_types): 46 | only = [only] 47 | 48 | field_definition = {} 49 | for col in model.__table__.columns: 50 | if only: 51 | if col.name not in only: 52 | continue 53 | elif excludes and col.name in excludes: 54 | continue 55 | 56 | field_definition[col.name] = _type_map[col.type.python_type.__name__] 57 | 58 | if extends is not None: 59 | for k, v in extends.iteritems(): 60 | field_definition[k] = v 61 | 62 | def decorated(func): 63 | @wraps(func) 64 | @_marshal_with(field_definition) 65 | def wrapper(*args, **kwargs): 66 | result = func(*args, **kwargs) 67 | return result if not _fields.is_indexable_but_not_string(result) else [v for v in result] 68 | return wrapper 69 | return decorated 70 | 71 | 72 | def quick_marshal(*args, **kwargs): 73 | """In some case, one view functions may return different model in different situation. 74 | Use `marshal_with_model` to handle this situation was tedious. 75 | This function can simplify this process. 76 | Usage: 77 | quick_marshal(args_to_marshal_with_model)(db_instance_or_query) 78 | """ 79 | @marshal_with_model(*args, **kwargs) 80 | def fn(value): 81 | return value 82 | return fn 83 | 84 | 85 | def _wrap_field(field): 86 | """Improve Flask-RESTFul's original field type""" 87 | class WrappedField(field): 88 | def output(self, key, obj): 89 | value = _fields.get_value(key if self.attribute is None else self.attribute, obj) 90 | 91 | # For all fields, when its value was null (None), return null directly, 92 | # instead of return its default value (eg. int type's default value was 0) 93 | # Because sometimes the client **needs** to know, was a field of the model empty, to decide its behavior. 94 | return None if value is None else self.format(value) 95 | return WrappedField 96 | 97 | 98 | class _DateTimeField(_fields.Raw): 99 | """Transform `datetime` and `date` objects to timestamp before return it.""" 100 | def format(self, value): 101 | try: 102 | return time.mktime(value.timetuple()) 103 | except OverflowError: 104 | # The `value` was generate by time zone UTC+0, 105 | # but `time.mktime()` will generate timestamp by local time zone (eg. in China, was UTC+8). 106 | # So, in some situation, we may got a timestamp that was negative. 107 | # In Linux, there's no problem. But in windows, this will cause an `OverflowError`. 108 | # Thinking of generally we don't need to handle a time so long before, at here we simply return 0. 109 | return 0 110 | 111 | except AttributeError as ae: 112 | raise _fields.MarshallingException(ae) 113 | 114 | 115 | class _FloatField(_fields.Raw): 116 | """Flask-RESTful will transform float value to a string before return it. 117 | This is not useful in most situation, so we change it to return float value directly""" 118 | 119 | def format(self, value): 120 | try: 121 | return float(value) 122 | except ValueError as ve: 123 | raise _fields.MarshallingException(ve) 124 | 125 | 126 | _type_map = { 127 | # python_type: flask-restful field 128 | 'str': _wrap_field(_fields.String), 129 | 'int': _wrap_field(_fields.Integer), 130 | 'float': _wrap_field(_FloatField), 131 | 'bool': _wrap_field(_fields.Boolean), 132 | 'datetime': _wrap_field(_DateTimeField), 133 | 'date': _wrap_field(_DateTimeField) 134 | } -------------------------------------------------------------------------------- /flask_scalarest/core/metrics.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | 5 | from appmetrics import metrics, reporter 6 | import pprint 7 | 8 | 9 | def stdout_report(_metrics): 10 | pprint.pprint(_metrics) 11 | 12 | 13 | # reporter.register(stdout_report, reporter.fixed_interval_scheduler(20)) -------------------------------------------------------------------------------- /flask_scalarest/core/rest_fields.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | 4 | from __future__ import absolute_import 5 | 6 | from flask_restful import fields 7 | from email.utils import formatdate 8 | from calendar import timegm 9 | 10 | 11 | def rfc822(dt): 12 | return formatdate(timegm(dt.timetuple())) 13 | 14 | 15 | class DateField(fields.Raw): 16 | """Return a RFC822-formatted date string in UTC""" 17 | def format(self, value): 18 | try: 19 | return rfc822(value) 20 | except AttributeError as ae: 21 | raise fields.MarshallingException(ae) 22 | -------------------------------------------------------------------------------- /flask_scalarest/core/session.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | """ 5 | flask redis session 6 | ~~~~~~~~~~~~~~~~~~~ 7 | 8 | :license: MIT, see LICENSE for more details. 9 | :Date: Sep, 2015 10 | :written by internet 11 | https://github.com/EricQAQ/Flask-RedisSession/blob/master/flask_redisSession/__init__.py 12 | 13 | """ 14 | from __future__ import print_function 15 | 16 | import json 17 | from datetime import datetime 18 | from uuid import uuid4 19 | from werkzeug.datastructures import CallbackDict 20 | from flask.sessions import SessionInterface, SessionMixin, total_seconds 21 | from werkzeug.local import Local 22 | import dateutil.parser 23 | from itsdangerous import Signer, BadSignature 24 | 25 | local = Local() 26 | SESSION_KEY_PREFIX = 'fsession#' 27 | 28 | def _sync_user_sessions(redis, prefix, user_id): 29 | user_key = _get_user_prefix(user_id) 30 | sessions = redis.hgetall(user_key) 31 | 32 | be_del_sids = [] 33 | 34 | for sid, value in sessions.iteritems(): 35 | sid = sid.decode() 36 | value = json.loads(value.decode()) 37 | expires = value['expires'] 38 | expires = dateutil.parser.parser(expires) 39 | 40 | if expires < datetime.now(): 41 | be_del_sids.append(sid) 42 | else: 43 | session = redis.get("".join([prefix, sid])) 44 | if session: 45 | session = json.loads(session.decode()) 46 | if user_id != session['user_id']: 47 | be_del_sids.append(sid) 48 | if len(be_del_sids) > 0: 49 | redis.hdel(user_key, *be_del_sids) 50 | 51 | def _get_user_prefix(user_id): 52 | return "user_sessions:%s" % user_id 53 | 54 | 55 | class FlaskRedisSession(object): 56 | 57 | def __init__(self, app=None): 58 | if app is not None: 59 | self.app = app 60 | self.init_app(app) 61 | else: 62 | self.app = None 63 | 64 | def init_app(self, app): 65 | config = app.config 66 | config.setdefault('SESSION_KEY_PREFIX', SESSION_KEY_PREFIX) 67 | config.setdefault('REDIS_SESSION', None) 68 | config.setdefault('USE_SECRET_KEY', True) 69 | config.setdefault('SESSION_REFRESH_EACH_REQUEST', True) 70 | 71 | # following config is just for the app do not have redis instance 72 | config.setdefault('REDIS_HOST', 'localhost') 73 | config.setdefault('REDIS_PORT', 6379) 74 | config.setdefault('REDIS_DB', 0) 75 | config.setdefault('REDIS_PASSWORD', None) 76 | config.setdefault('USE_REDIS_CONNECTION_POOL', False) # use the connection pool or not 77 | config.setdefault('MAX_CONNECTION', None) # the max number of connections.Valid when using connection pool 78 | 79 | app.session_interface = RedisSessionInterface( 80 | config['REDIS_SESSION'], config['USE_SECRET_KEY'], 81 | config['SESSION_KEY_PREFIX'], config['USE_REDIS_CONNECTION_POOL'], 82 | config['PERMANENT_SESSION_LIFETIME'], config['REDIS_HOST'], 83 | config['REDIS_PORT'], config['REDIS_DB'], 84 | config['REDIS_PASSWORD'], config['MAX_CONNECTION'] 85 | ) 86 | 87 | 88 | class RedisSession(CallbackDict, SessionMixin): 89 | 90 | def __init__(self, initial=None, session_id=None): 91 | def on_update(self): 92 | self.modified = True 93 | CallbackDict.__init__(self, initial, on_update) 94 | self.modified = True 95 | self.permanent = True 96 | self.session_id = session_id 97 | 98 | 99 | class ServerSessionMixin(object): 100 | 101 | def generate_sessionid(self): 102 | return str(uuid4()) 103 | 104 | 105 | class RedisSessionInterface(SessionInterface, ServerSessionMixin): 106 | serializer = json 107 | session_class = RedisSession 108 | 109 | def __init__(self, redis, use_sign, session_prefix, use_redis_connection_pool, 110 | expire_time, redis_host, redis_port, redis_db, redis_pw, max_conn=None): 111 | if redis is None: 112 | from redis import StrictRedis 113 | if use_redis_connection_pool: 114 | from redis import ConnectionPool 115 | pool = ConnectionPool(host=redis_host, port=redis_port, 116 | db=redis_db, max_connections=max_conn) 117 | redis = StrictRedis(connection_pool=pool) 118 | else: 119 | redis = StrictRedis(host=redis_host, port=redis_port, 120 | db=redis_db, password=redis_pw) 121 | self.redis = redis 122 | self.use_sign = use_sign 123 | self.session_prefix = session_prefix 124 | self.expire_time = expire_time 125 | 126 | def open_session(self, app, request): 127 | session_id = request.cookies.get(app.session_cookie_name, None) 128 | 129 | if not session_id: 130 | session_id = self.generate_sessionid() 131 | return self.session_class(session_id=session_id) 132 | 133 | if self.use_sign and app.secret_key: 134 | singer = Signer(app.secret_key, salt='fredis-session', key_derivation='hmac') 135 | try: 136 | session_id = singer.unsign(session_id).decode('utf-8') 137 | except BadSignature: 138 | session_id = None 139 | 140 | data = self.redis.get(self.session_prefix + session_id) 141 | if data is None: 142 | return self.session_class(session_id=session_id) 143 | try: 144 | json_data = self.serializer.loads(data) 145 | return self.session_class(json_data, session_id=session_id) 146 | except: 147 | return self.session_class(session_id=session_id) 148 | 149 | def save_session(self, app, session, response): 150 | domain = self.get_cookie_domain(app) 151 | path = self.get_cookie_path(app) 152 | if not session and session.modified: 153 | self.redis.delete(self.session_prefix + session.session_id) 154 | response.delete_cookie(app.session_cookie_name, domain=domain, path=path) 155 | return 156 | 157 | httponly = self.get_cookie_httponly(app) 158 | secure = self.get_cookie_secure(app) 159 | expire = self.get_expiration_time(app, session) 160 | serialize_session = self.serializer.dumps(dict(session)) 161 | 162 | pipe = self.redis.pipeline() 163 | pipe.set(self.session_prefix + session.session_id, serialize_session) 164 | pipe.expire(self.session_prefix + session.session_id, total_seconds(self.expire_time)) 165 | pipe.execute() 166 | 167 | if self.use_sign: 168 | session_id = Signer(app.secret_key, salt='fredis-session', key_derivation='hmac')\ 169 | .sign(session.session_id.encode('utf-8')) 170 | else: 171 | session_id = session.session_id 172 | print('session_id: %s' % session_id) 173 | 174 | response.set_cookie(key=app.session_cookie_name, value=session_id, 175 | max_age=self.expire_time, expires=expire, 176 | path=path, domain=domain, 177 | secure=secure, httponly=httponly) 178 | -------------------------------------------------------------------------------- /flask_scalarest/exception.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | 5 | 6 | class BaseException(Exception): 7 | pass -------------------------------------------------------------------------------- /flask_scalarest/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*-u 3 | 4 | __author__ = 'daqing' 5 | 6 | 7 | -------------------------------------------------------------------------------- /flask_scalarest/extensions/database.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | 4 | from __future__ import absolute_import 5 | 6 | import json 7 | from flask_sqlalchemy import SQLAlchemy 8 | from sqlalchemy import types 9 | 10 | 11 | def get_or_create(model, defaults=None, **kwargs): 12 | """ 13 | 获取或者创建对象,模仿django的。 14 | """ 15 | instance = model.query.filter_by(**kwargs).first() 16 | if instance: 17 | setattr(instance, 'is_new', False) 18 | else: 19 | kwargs.update(defaults) 20 | instance = model(**kwargs) 21 | database.session.add(instance) 22 | database.session.commit() 23 | setattr(instance, 'is_new', True) 24 | return instance 25 | 26 | 27 | class JSONEncodedDict(types.TypeDecorator): 28 | """ 29 | Represents an immutable structure as a json-encoded string. 30 | 31 | Usage:: 32 | 33 | database.JSONEncodedDict(255) 34 | 35 | """ 36 | 37 | impl = types.VARCHAR 38 | 39 | def process_bind_param(self, value, dialect): 40 | if value is not None: 41 | value = json.dumps(value) 42 | return value 43 | 44 | def process_result_value(self, value, dialect): 45 | if value is not None: 46 | value = json.loads(value) 47 | return value 48 | 49 | 50 | class ChoiceType(types.TypeDecorator): 51 | """ 52 | example:: 53 | choices=( 54 | ('key1', 'value1'), 55 | ('key2', 'value2') 56 | ) 57 | 58 | filed:: 59 | db.Column(db.ChoiceType(length=xx, choices=choices)) 60 | 61 | """ 62 | impl = types.String 63 | 64 | def __init__(self, choices, **kw): 65 | self.choices = dict(choices) 66 | super(ChoiceType, self).__init__(**kw) 67 | 68 | def process_bind_param(self, value, dialect): 69 | return [k for k, v in self.choices.iteritems() if k == value][0] 70 | 71 | def process_result_value(self, value, dialect): 72 | return self.choices[value] 73 | 74 | 75 | class DataBase(SQLAlchemy): 76 | ChoiceType = ChoiceType 77 | JSONEncodedDict = JSONEncodedDict 78 | 79 | def init_app(self, app): 80 | """需要用到。要不sqlalchemy部分功能无法正常使用""" 81 | self.app = app 82 | super(DataBase, self).init_app(app) 83 | 84 | 85 | database = DataBase() 86 | 87 | 88 | class Model(database.Model): 89 | __abstract__ = True 90 | __table_args__ = { 91 | 'mysql_engine': 'InnoDB', 92 | 'mysql_charset': 'utf8mb4', 93 | } 94 | 95 | 96 | database.Model = Model -------------------------------------------------------------------------------- /flask_scalarest/extensions/jwt.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | 5 | from flask_jwt import JWT, jwt_required 6 | 7 | jwt = JWT() -------------------------------------------------------------------------------- /flask_scalarest/extensions/mail.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf8 -*- 3 | 4 | from flask_mail import Mail 5 | 6 | email = Mail() 7 | -------------------------------------------------------------------------------- /flask_scalarest/extensions/rest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | jaryee.web 4 | ~~~~~~~~~~~~~~~~~~~ 5 | 6 | Jaryee system application 7 | 8 | :copyright: (c) Power by Daqing Chan. 9 | :license, see LICENSE for more details. 10 | """ 11 | 12 | from flask_restful import Api 13 | 14 | rest_api = Api() -------------------------------------------------------------------------------- /flask_scalarest/helper.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | 5 | 6 | import jwt 7 | 8 | 9 | def jwt_decode(token, secret=None, algorithms=None, verify=False): 10 | """ 11 | 对经过JWT(JSON Web Token)Hash的token进行对称加密出明文 12 | :param token: token, jwtencode之后的 13 | :param secret: 需要加入hash的密钥 14 | :param algorithms: hash算法 15 | :param verify: 验证 16 | """ 17 | decode_data = jwt.decode(token, secret, algorithms=algorithms or ['HS256'], verify=verify) 18 | return decode_data -------------------------------------------------------------------------------- /flask_scalarest/resources/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | jaryee.web 4 | ~~~~~~~~~~~~~~~~~~~ 5 | 6 | Jaryee system application 7 | 8 | :copyright: (c) Power by Daqing Chan. 9 | :license, see LICENSE for more details. 10 | """ 11 | -------------------------------------------------------------------------------- /flask_scalarest/resources/base/__init__.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | 5 | from .views import ApiTokenResource 6 | from .models import User, UserDetail -------------------------------------------------------------------------------- /flask_scalarest/resources/base/fields.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 -------------------------------------------------------------------------------- /flask_scalarest/resources/base/models.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | 5 | import datetime 6 | import random 7 | from passlib.hash import sha256_crypt 8 | 9 | from ...extensions.database import database 10 | from ...core import DictSerializableMixed 11 | 12 | 13 | class User(database.Model, DictSerializableMixed): 14 | 15 | __tablename__ = 'tb_user' 16 | 17 | id = database.Column(database.Integer, autoincrement=True, primary_key=True) 18 | email = database.Column(database.String(100), index=True) 19 | head_ico = database.Column(database.String(100)) 20 | username = database.Column(database.String(50), index=True, unique=True) 21 | role = database.Column(database.SmallInteger, default=0, index=True) # 0:学生, 1:商家(公司)2:其他 22 | password = database.Column(database.String(88)) 23 | salt = database.Column(database.String(8)) 24 | add_time = database.Column(database.DateTime, default=datetime.datetime.now) 25 | 26 | # relationship follow 27 | detail = database.relationship('UserDetail', uselist=False, backref=database.backref('user')) 28 | addresses = database.relationship('Address', backref=database.backref('user'), lazy="dynamic") 29 | 30 | 31 | def create_password(self, password): 32 | self.salt = ''.join(random.sample('1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()', 8)) 33 | encrypt_str = '%s%s' % (password, self.salt) 34 | self.password = sha256_crypt.encrypt(encrypt_str) 35 | return self.password 36 | 37 | def verify_password(self, password): 38 | encrypt_str = '%s%s' % (password, self.salt) 39 | verify_result = sha256_crypt.verify(encrypt_str, self.password) 40 | return verify_result 41 | 42 | 43 | class UserDetail(database.Model, DictSerializableMixed): 44 | 45 | __tablename__ = 'tb_user_detail' 46 | 47 | id = database.Column(database.Integer, autoincrement=True, primary_key=True) 48 | real_name = database.Column(database.String(100)) 49 | intro = database.Column(database.String(200)) 50 | add_time = database.Column(database.DateTime, default=datetime.datetime.now) 51 | 52 | user_id = database.Column(database.Integer, database.ForeignKey('tb_user.id')) -------------------------------------------------------------------------------- /flask_scalarest/resources/base/views.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | 5 | import datetime 6 | 7 | from flask_restful import Resource, marshal_with, reqparse, abort, url_for, marshal 8 | from flask import jsonify, current_app 9 | 10 | from ...extensions.rest import rest_api 11 | from ...extensions.jwt import jwt, jwt_required 12 | from ...core.metrics import metrics 13 | from ...extensions.database import database as db 14 | 15 | from .models import User 16 | 17 | 18 | @jwt.user_handler 19 | def load_user(payload): 20 | """ 21 | _jwt.decode_callback回调函数将token对称解密之后的用户数据传输到次,用以加载当前有效的用户数据, 22 | 并设置到 `_request_ctx_stack.top.current_user` 23 | """ 24 | user_id = payload['user_id'] 25 | user = User.query.filter(User.id == user_id).first() 26 | return user 27 | 28 | 29 | @jwt.authentication_handler 30 | def authenticate(username, password): 31 | user = User.query.filter(User.username == username).first() 32 | if not user: 33 | abort(404, message=u'用户不存在,请检查用户ID和密码是否正确' % username) 34 | if not user.verify_password(password): 35 | abort(403, message=u'用户密码错误,请重新授权?至多尝试5次') 36 | return user 37 | 38 | 39 | @jwt.response_handler 40 | def jwt_token_response(payload): 41 | return jsonify({'token': payload, 'exp': current_app.config.get('JWT_EXPIRATION_DELTA').seconds}) 42 | 43 | 44 | class ApiTokenResource(Resource): 45 | 46 | def __init__(self): 47 | self.reqparse = reqparse.RequestParser() 48 | 49 | self.reqparse.add_argument('uid', type=int, required=True, help='user id must here', location='form') 50 | self.reqparse.add_argument('password', type=str, required=True, help='password is must here!', location='form') 51 | 52 | super(ApiTokenResource, self).__init__() 53 | 54 | def post(self): 55 | args = self.reqparse.parse_args() 56 | 57 | user_id = int(args['uid']) 58 | password = str(args['password']) 59 | 60 | user = User.query.filter(User.id == user_id).first() 61 | if not user: 62 | abort(404, message=u'用户不存在,请检查用户ID和密码是否正确' % user_id, error_code=404) 63 | if not user.verify_password(password): 64 | abort(403, message=u'用户密码错误,请重新授权?至多尝试5次', error_code=403) 65 | 66 | ret = { 67 | 'token': 'TOKEN:%s' % user.id, 68 | 'uid': user.id, 69 | 'expires': 60 * 60 * 24 # 默认一天 70 | } 71 | 72 | return ret, 200 73 | 74 | 75 | rest_api.add_resource(ApiTokenResource, '/refresh_token', methods=['POST']) 76 | -------------------------------------------------------------------------------- /flask_scalarest/resources/example/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | jaryee.web 4 | ~~~~~~~~~~~~~~~~~~~ 5 | 6 | Jaryee system application 7 | 8 | :copyright: (c) Power by Daqing Chan. 9 | :license, see LICENSE for more details. 10 | """ 11 | 12 | from .models import Address 13 | from .views import UsersResource, UserResource -------------------------------------------------------------------------------- /flask_scalarest/resources/example/fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | jaryee.web 4 | ~~~~~~~~~~~~~~~~~~~ 5 | 6 | Jaryee system application 7 | 8 | :copyright: (c) Power by Daqing Chan. 9 | :license, see LICENSE for more details. 10 | """ 11 | 12 | 13 | from flask_restful import fields 14 | 15 | 16 | 17 | # 针对单个表述的LINK 18 | def link_field(endpoint, rel_type='self', absolute=True): 19 | 20 | _link_field = { 21 | 'rel': rel_type, 22 | 'href': fields.Url(endpoint, absolute=absolute) 23 | } 24 | return _link_field 25 | 26 | 27 | # 针对集合表述的LINK 28 | link_fields = { 29 | 30 | } 31 | 32 | user_detail_fields = { 33 | 'id': fields.Integer, 34 | 'real_name': fields.String, 35 | 'intro': fields.String, 36 | 'add_time': fields.DateTime 37 | } 38 | 39 | address_fields = { 40 | 'id': fields.Integer, 41 | 'addr': fields.String, 42 | 'post_code': fields.String, 43 | 'add_time': fields.DateTime 44 | } 45 | 46 | 47 | user_fields = { 48 | 'id': fields.Integer, 49 | 'email': fields.String, 50 | 'head_ico': fields.String, 51 | 'username': fields.String, 52 | 'role': fields.Integer, 53 | 'add_time': fields.DateTime, 54 | 'detail': fields.Nested(user_detail_fields), 55 | 'addresses': fields.Nested(address_fields), 56 | } 57 | -------------------------------------------------------------------------------- /flask_scalarest/resources/example/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | jaryee.web 4 | ~~~~~~~~~~~~~~~~~~~ 5 | 6 | Jaryee system application 7 | 8 | :copyright: (c) Power by Daqing Chan. 9 | :license, see LICENSE for more details. 10 | """ 11 | import datetime 12 | from ...extensions.database import database 13 | from ...core import DictSerializableMixed 14 | 15 | 16 | class Address(database.Model, DictSerializableMixed): 17 | 18 | __tablename__ = 'tb_address' 19 | 20 | id = database.Column(database.Integer, autoincrement=True, primary_key=True) 21 | addr = database.Column(database.String(100)) 22 | post_code = database.Column(database.String(10)) 23 | add_time = database.Column(database.DateTime, default=datetime.datetime.now) 24 | 25 | user_id = database.Column(database.Integer, database.ForeignKey('tb_user.id')) -------------------------------------------------------------------------------- /flask_scalarest/resources/example/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | 5 | from flask_restful import Resource, marshal_with, reqparse, abort, url_for, marshal 6 | from flask import request, _request_ctx_stack, redirect 7 | 8 | from ...extensions.rest import rest_api 9 | from ...extensions.database import database as db 10 | from ...extensions.jwt import jwt_required 11 | from ...core.metrics import metrics 12 | 13 | from ..base import (User, UserDetail) 14 | from .models import Address 15 | 16 | from .fields import user_detail_fields, user_fields, address_fields 17 | 18 | 19 | def fmt_date(val): 20 | return val.strftime('%Y-%m-%d %H:%M:%S') 21 | 22 | 23 | def add_self_atom_link(data, **kwargs): 24 | atom_link_tag = {'link': {'rel': 'self', 'href': request.url}} 25 | data.update(atom_link_tag) 26 | 27 | 28 | class UserResource(Resource): 29 | 30 | def __init__(self): 31 | self.reqparse = reqparse.RequestParser() 32 | super(UserResource, self).__init__() 33 | 34 | @staticmethod 35 | def use_exist(user_id): 36 | user = User.query.filter(User.id == user_id).first() 37 | if not user: 38 | abort(404, message=u'id is %s not exist here' % user_id, error_code=404) 39 | return user 40 | 41 | 42 | @jwt_required() 43 | @metrics.with_meter("user-get-tp") 44 | @metrics.with_histogram("user-get-latency") 45 | @metrics.with_meter("user-throughput") 46 | @marshal_with(user_fields) 47 | def get(self, user_id): 48 | if hasattr(_request_ctx_stack.top, "current_user"): 49 | current_user = _request_ctx_stack.top.current_user 50 | # user = self.use_exist(user_id) 51 | return current_user, 200 52 | else: 53 | return redirect('/api/auth_token') 54 | 55 | @metrics.with_meter("user-put-tp") 56 | @metrics.with_histogram("user-put-latency") 57 | @metrics.with_meter("user-put-throughput") 58 | def put(self, user_id): 59 | user = self.use_exist(user_id) 60 | return {}, 200 61 | 62 | @metrics.with_meter("user-delete-tp") 63 | @metrics.with_histogram("user-delete-latency") 64 | @metrics.with_meter("user-delete-throughput") 65 | def delete(self, user_id): 66 | user = self.use_exist(user_id) 67 | return {}, 200 68 | 69 | 70 | class UsersResource(Resource): 71 | 72 | def get_reqparse(self): 73 | self.reqparse = reqparse.RequestParser() 74 | self.reqparse.add_argument('page', type=int, location='args') 75 | self.reqparse.add_argument('size', type=int, location='args') 76 | 77 | def __init__(self): 78 | self.reqparse = reqparse.RequestParser() 79 | 80 | self.reqparse.add_argument('id', type=int, required=True, help='entry id must here', location='form') 81 | self.reqparse.add_argument('email', type=str, required=True, help='email is must here!', location='form') 82 | self.reqparse.add_argument('head_ico', type=str, location='form') 83 | self.reqparse.add_argument('username', type=str, required=True, location='form') 84 | self.reqparse.add_argument('role', type=int, required=True, location='form') 85 | self.reqparse.add_argument('password', type=str, required=True, location='form') 86 | 87 | self.reqparse.add_argument('detail.id', type=int, location='form') 88 | self.reqparse.add_argument('detail.real_name', type=str, location='form') 89 | self.reqparse.add_argument('detail.intro', type=str, location='form') 90 | 91 | self.reqparse.add_argument('address.id', type=int, location='form', action='append') 92 | self.reqparse.add_argument('address.post_code', type=str, location='form', action='append') 93 | self.reqparse.add_argument('address.addr', type=str, location='form', action='append') 94 | 95 | super(UsersResource, self).__init__() 96 | 97 | @staticmethod 98 | def parse_detail(**args): 99 | detail_args = {} 100 | for k, v in args.iteritems(): 101 | if k.find('detail') == -1: 102 | continue 103 | if k.find('.') == -1: 104 | continue 105 | detail_args.update({k.split('.')[1]: v}) 106 | return detail_args 107 | 108 | @metrics.with_meter("users-get-tp") 109 | @metrics.with_histogram("uses-get-latency") 110 | @metrics.with_meter("users-get-throughput") 111 | def get(self): 112 | self.get_reqparse() 113 | 114 | args = self.reqparse.parse_args() 115 | page = args['page'] 116 | size = args['size'] 117 | 118 | _users = User.query.paginate(page, per_page=size, error_out=False) 119 | if page > _users.pages: 120 | abort(400, message="page no can't max than pages, no users data!", error_date=fmt_date(datetime.datetime.now())) 121 | if not _users.items: 122 | abort(404, message="no users data!", error_date=fmt_date(datetime.datetime.now())) 123 | 124 | users = {'users': map(lambda t: marshal(t, user_fields), _users.items), 'total': _users.total, 125 | 'base_url': request.url_root[:-1], 126 | 'link': {'rel': 'self', 'href': request.url}, 127 | 'previous': url_for('users_ep', page=_users.prev_num, size=size), 128 | 'next': url_for('users_ep', page=_users.next_num, size=size)} 129 | 130 | if not _users.has_prev: 131 | users.pop('previous') 132 | if not _users.has_next: 133 | users.pop('next') 134 | 135 | return users, 200 136 | 137 | @marshal_with(user_fields) 138 | def post(self): 139 | args = self.reqparse.parse_args() 140 | 141 | email = args['email'] 142 | username = args['username'] 143 | user = User.query.filter(db.or_(User.email == email, User.username == username)).first() 144 | if user: 145 | return marshal({}, user_fields), 301 146 | user = User() 147 | user.id = args['id'] 148 | user.username = username 149 | user.email = email 150 | user.head_ico = args['head_ico'] 151 | user.role = args['role'] 152 | user.password = args['password'] 153 | 154 | detail_args = self.parse_detail(**args) 155 | if detail_args: 156 | user.detail = UserDetail() 157 | user.detail.id = detail_args['id'] 158 | user.detail.real_name = detail_args['real_name'] 159 | user.detail.intro = detail_args['intro'] 160 | db.session.add(user) 161 | db.session.commit() 162 | return {'user': marshal(user.to_dict(), user_fields)}, 201 163 | 164 | 165 | class AddressResource(Resource): 166 | 167 | def __init__(self): 168 | self.reqparse = reqparse.RequestParser() 169 | 170 | self.reqparse.add_argument('id', type=int, location='form', action='append') 171 | self.reqparse.add_argument('post_code', type=str, location='form', action='append') 172 | self.reqparse.add_argument('addr', type=str, location='form', action='append') 173 | 174 | super(AddressResource, self).__init__() 175 | 176 | def get(self, addr_id): 177 | return {}, 200 178 | 179 | def put(self, addr_id): 180 | return {}, 200 181 | 182 | def delete(self, addr_id): 183 | return {}, 200 184 | 185 | 186 | class AddressListResource(Resource): 187 | 188 | def get_reqparse(self): 189 | self.reqparse = reqparse.RequestParser() 190 | self.reqparse.add_argument('page', type=int, location='args') 191 | self.reqparse.add_argument('size', type=int, location='args') 192 | 193 | def __init__(self): 194 | self.reqparse = reqparse.RequestParser() 195 | 196 | self.reqparse.add_argument('id', type=int, location='form', action='append') 197 | self.reqparse.add_argument('post_code', type=str, location='form', action='append') 198 | self.reqparse.add_argument('addr', type=str, location='form', action='append') 199 | 200 | super(AddressListResource, self).__init__() 201 | 202 | def get(self): 203 | self.get_reqparse() 204 | return {}, 200 205 | 206 | def post(self): 207 | return {}, 201 208 | 209 | 210 | class QrcodeResource(Resource): 211 | """生产二维码的API""" 212 | 213 | @jwt_required() 214 | @metrics.with_meter("qrcode-tp") 215 | @metrics.with_histogram("qrcode-latency") 216 | @metrics.with_meter("qrcode-throughput") 217 | def get(self, qrcode_id): 218 | pass 219 | 220 | @jwt_required() 221 | @metrics.with_meter("qrcode-tp") 222 | @metrics.with_histogram("qrcode-latency") 223 | @metrics.with_meter("qrcode-throughput") 224 | def put(self, qrcode_id): 225 | pass 226 | 227 | @jwt_required() 228 | @metrics.with_meter("qrcode-tp") 229 | @metrics.with_histogram("qrcode-latency") 230 | @metrics.with_meter("qrcode-throughput") 231 | def delete(self, qrcode_id): 232 | pass 233 | 234 | 235 | class QrcodeListResource(Resource): 236 | 237 | @jwt_required() 238 | @metrics.with_meter("qrcode-tp") 239 | @metrics.with_histogram("qrcode-latency") 240 | @metrics.with_meter("qrcode-throughput") 241 | def get(self): 242 | pass 243 | 244 | @jwt_required() 245 | @metrics.with_meter("qrcode-tp") 246 | @metrics.with_histogram("qrcode-latency") 247 | @metrics.with_meter("qrcode-throughput") 248 | def post(self): 249 | pass 250 | 251 | 252 | rest_api.add_resource(UsersResource, 253 | '/users', endpoint='users_ep', methods=['GET', 'POST']) 254 | rest_api.add_resource(UserResource, 255 | '/user/', endpoint='user_ep', methods=['GET', 'DELETE', 'PUT']) 256 | 257 | rest_api.add_resource(AddressResource, 258 | '/address/', endpoint='addr_ep', methods=['GET', 'DELETE', 'PUT']) 259 | rest_api.add_resource(AddressListResource, 260 | '/addresses', endpoint='addrs_ep', methods=['GET', 'POST']) 261 | 262 | rest_api.add_resource(QrcodeResource, '/qrcode/') 263 | rest_api.add_resource(QrcodeListResource, '/qrcodes') -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # 基于Flask、Flask-Restufl上的快速构建Restful风格API的小项目 2 | 3 | 该项目能让你基于Flask与Flask-Restful之上构建一个良好Restful风格的API,让你快速构建一个能用于生产中的API,并提供良好的Metrics! 4 | 5 | ## 项目的特性 6 | 7 | * 基于Flask/Flask-Restful 8 | * ORM使用SQLAlchemy 9 | * 具有metrics功能,可以方便通过decorator的方式让你随心监控某些API的运行指标 10 | * 可部署在兼容uwsgi协议上的容器中(有uwsgi与gevent等的实现版本,gevent基于协程),当然也可以使用PyPy达到更高的性能 11 | * 基于JWT(JSON Web Tokens)授权访问的机制(更多方式可以自己添加)保护API 12 | 13 | ## 后续开发计划 14 | 15 | * 编写完善的API文档,利于新人上手 16 | * 增加一个建议的WebAPP客户端来进行API调用的案例 17 | * 为metrics增加一个可视化的浏览方式? 18 | * metrics的数据使用mysql(mongodb)保存? 19 | * 基于Swagger-UI美化API文档? 20 | 21 | ## 如何使用 22 | 23 | 每个JWT TOKEN值默认有效时间为 `JWT_EXPIRATION_DELTA` 7200s,如果同时设置了 `JWT_LEEWAY` 则是两个配置项加起来,就是有效时间 24 | 25 | 下载下来在对应的 `flask_scalarest/resources/your package name/` 创建python包(当然你也可以将整个项目改名) 26 | 27 | 更多内容敬请期待!!! 28 | 29 | 30 | ## 联系方式 31 | 32 | email: eoolife@163.com 33 | 34 | QQ: eoolife@163.com 35 | 36 | 37 | ## LICENCE 38 | 39 | MIT -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pymysql 2 | Flask 3 | Flask-RESTful 4 | flask-restful-swagger 5 | Flask-SQLAlchemy 6 | Flask-Login 7 | Flask-Migrate 8 | flask-script 9 | Flask-JSONRPC 10 | Flask-Testing 11 | Flask-QRcode 12 | Flask-JWT 13 | flask-cors 14 | flask-restplus 15 | flask-rq 16 | 17 | marshmallow-sqlalchemy 18 | sqlalchemy-utils 19 | sqlacodegen 20 | 21 | passlib 22 | requests 23 | redis 24 | APScheduler 25 | gevent 26 | appmetrics -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf8 -*- 3 | 4 | import os 5 | from appmetrics.wsgi import AppMetricsMiddleware 6 | from flask_scalarest import create_app 7 | from flask_scalarest.extensions.jwt import jwt 8 | 9 | 10 | here = os.path.dirname(os.path.realpath(__file__)) 11 | config_file = os.path.join(here, 'test_settings.py') 12 | application = create_app(config_file) 13 | 14 | 15 | if __name__ == '__main__': 16 | application.wsgi_app = AppMetricsMiddleware(application.wsgi_app) 17 | jwt.init_app(application) 18 | print(application.url_map) 19 | application.run( 20 | host=application.config.get('HOST', '0.0.0.0'), 21 | port=application.config.get('PORT'), 22 | debug=application.config['DEBUG'], 23 | ) 24 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | detailed-errors=1 3 | with-coverage=1 4 | cover-package=flask_scalarest 5 | nologcapture=1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | version = '0.0.1' 5 | 6 | setup(name='flask_scalarest', 7 | version=version, 8 | description='', 9 | long_description="""\ 10 | """, 11 | # Get more strings from http://www.python.org/pypi?%3Aaction=list_classifiers 12 | classifiers=[ 13 | "Framework :: Plone", 14 | "Framework :: Zope2", 15 | "Framework :: Zope3", 16 | "Programming Language :: Python", 17 | "Topic :: Software Development :: Libraries :: Python Modules", 18 | ], 19 | keywords='', 20 | author='eoolife@163.com', 21 | author_email='', 22 | url='', 23 | license='GPL', 24 | packages=find_packages(exclude=['ez_setup']), 25 | namespace_packages=['flask_scalarest'], 26 | include_package_data=True, 27 | test_suite='nose.collector', 28 | test_requires=['Nose'], 29 | zip_safe=False, 30 | install_requires=[ 31 | # -*- Extra requirements: -*- 32 | ], 33 | entry_points=""" 34 | # -*- Entry points: -*- 35 | """, 36 | ) 37 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | jaryee.web 4 | ~~~~~~~~~~~~~~~~~~~ 5 | 6 | Jaryee system application 7 | 8 | :copyright: (c) Power by Daqing Chan. 9 | :license, see LICENSE for more details. 10 | """ 11 | 12 | from datetime import timedelta 13 | 14 | DEBUG = True 15 | HOST = '0.0.0.0' 16 | SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@192.168.1.103:3316/restapi?charset=utf8mb4' 17 | SQLALCHEMY_ECHO = False 18 | SQLALCHEMY_POOL_RECYCLE = 20 19 | SECRET_KEY = 'super-secret fuck' 20 | JWT_SECRET_KEY = 'JSON-Web-Token-Projected-Every!!' 21 | JWT_AUTH_URL_RULE = '/api/auth_token' 22 | JWT_EXPIRATION_DELTA = timedelta(seconds=7200) 23 | JWT_LEEWAY = 60 24 | JWT_DEFAULT_REALM = 'Login Required' -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | jaryee.web 4 | ~~~~~~~~~~~~~~~~~~~ 5 | 6 | Jaryee system application 7 | 8 | :copyright: (c) Power by Daqing Chan. 9 | :license, see LICENSE for more details. 10 | """ 11 | -------------------------------------------------------------------------------- /tests/test_jwt.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | 5 | import jwt 6 | import json 7 | import requests 8 | 9 | 10 | if __name__ == '__main__': 11 | 12 | JWT_SECRET_KEY = 'JSON-Web-Token-Projected-Every!!' 13 | token = 'eyJhbGciOiJIUzI1NiIsImV4cCI6MTQ0MjQ3NTY3NCwiaWF0IjoxNDQyNDY4NDE0fQ.eyJ1c2VyX2lkIjoxfQ.VpTiXb887msGFl95BT70jwkRX4XDUifgmJpo9Zlfp_A' 14 | decode_data = jwt.decode(token, JWT_SECRET_KEY, algorithms=['HS256'], verify=False) 15 | print(decode_data) 16 | 17 | session = requests.Session() 18 | 19 | # payload = {'username': 'daqing', 'password': '1234561'} 20 | # headers = {'content-type': 'application/json'} 21 | # response = session.post('http://127.0.0.1:5000/api/auth_token', 22 | # data=json.dumps(payload), 23 | # headers={'content-type': 'application/json'}) 24 | # print(response.status_code) 25 | # print(response.json()) 26 | 27 | hello_resp = session.get('http://127.0.0.1:5000/user/1', headers={"Authorization": "Bearer %s" % token }) 28 | print(hello_resp.status_code) 29 | print(hello_resp.content) 30 | print(hello_resp.headers) 31 | -------------------------------------------------------------------------------- /tests/test_metrics.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | 5 | from flask import Flask, jsonify 6 | from appmetrics import metrics 7 | 8 | app = Flask(__name__) 9 | 10 | 11 | @app.route('/factorial/') 12 | @metrics.with_meter("factorial-tp") 13 | @metrics.with_histogram("factorial-latency") 14 | @metrics.with_meter("throughput") 15 | def factorial(n): 16 | f = 1 17 | for i in xrange(2, n+1): 18 | f *= i 19 | return jsonify(factorial=str(f)) 20 | 21 | 22 | @app.route('/is-prime/') 23 | @metrics.with_meter("primality-tp") 24 | @metrics.with_histogram("primality-latency") 25 | @metrics.with_meter("throughput") 26 | def is_prime(n): 27 | result = True 28 | 29 | if n % 2 == 0: 30 | result = False 31 | else: 32 | for i in xrange(3, int(n**0.5)+1, 2): 33 | if n % i == 0: 34 | result = False 35 | return jsonify(is_prime=result) 36 | 37 | 38 | if __name__ == '__main__': 39 | from appmetrics.wsgi import AppMetricsMiddleware 40 | app.wsgi_app = AppMetricsMiddleware(app.wsgi_app) 41 | app.run(threaded=True, debug=True) -------------------------------------------------------------------------------- /tests/test_passlib.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | 5 | 6 | from __future__ import print_function 7 | import random 8 | from passlib.hash import sha256_crypt 9 | 10 | password = '123456' 11 | salt = ''.join(random.sample('1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()', 8)) 12 | print(salt) 13 | encrypt_str = '%s%s' % (password, salt) 14 | password = sha256_crypt.encrypt(encrypt_str) 15 | hash = sha256_crypt.encrypt(encrypt_str) 16 | print(hash) 17 | 18 | verify_result = sha256_crypt.verify(encrypt_str, hash) 19 | print(verify_result) -------------------------------------------------------------------------------- /tests/test_redis_session.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 人生哭短, Python当歌 since 2015 4 | # https://github.com/miguelgrinberg/REST-auth/blob/master/api.py#L75 5 | 6 | import random 7 | from uuid import uuid4 8 | from flask import Flask, request, jsonify, session, redirect, url_for 9 | 10 | from flask_scalarest.core.session import FlaskRedisSession 11 | 12 | redisSession = FlaskRedisSession() 13 | app = Flask(__name__) 14 | app.config.update( 15 | DEBUG=True, 16 | SECRET_KEY='HSDHFSKDFSDFS#$#sdfsdf' 17 | ) 18 | redisSession.init_app(app) 19 | 20 | @app.route('/') 21 | def index(): 22 | user_id = session.get('user_id') 23 | if user_id is not None: 24 | return """ 25 | Hello. This is index page. Your login is %s.<\br> 26 | Logout Logout from all devices 27 | """ % user_id 28 | else: 29 | return 'Hello. This is index page. Please login.' 30 | 31 | 32 | @app.route('/login') 33 | def login(): 34 | session['user_id'] = random.randint(1, 10000) 35 | session['token'] = str(uuid4()).replace('-', '') 36 | print('login uid %s' % session['user_id']) 37 | return redirect(url_for('user_info')) 38 | 39 | 40 | @app.route('/logout') 41 | def logout(): 42 | del session['user_id'] 43 | del session['token'] 44 | return redirect(url_for('index')) 45 | 46 | 47 | @app.route('/user_info') 48 | def user_info(): 49 | print('====================== user_info =================') 50 | print('token: ' + session.get('token', '')) 51 | return jsonify({'name': 'daqing', 'age': 25, 'session_id': session.get('user_id'), 'token': session.get('token')}) 52 | 53 | 54 | if __name__ == '__main__': 55 | app.run(debug=True) -------------------------------------------------------------------------------- /tests/test_users.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import requests 4 | import random 5 | 6 | 7 | # curl http://localhost:5000/todos -d "task=something new" -X POST -v 8 | def test_post(): 9 | uri = 'http://localhost:8000/users' 10 | data = {'id': random.randint(1, 9999999), 11 | 'email': 'd%s%s@d.com' % (random.randint(1, 9999999), random.randint(1, 9999999)), 12 | 'username': '%sdq%s151' % (random.randint(1, 9999999), random.randint(1, 9999999)), 13 | 'password': '123456', 'role': 1, 14 | 'detail.id': random.randint(1, 9999999), 15 | 'detail.real_name': 'ddddd', 16 | 'detail.intro': 'intro', 17 | 18 | 'address.id': [random.randint(1, 9999999), random.randint(1, 9999999)], 19 | 'address.post_code': ['527227', '527200'], 20 | 'address.addr': ['sulong taozi', 'luoding'] 21 | } 22 | import pprint 23 | pprint.pprint(data) 24 | resp = requests.post(uri, data=data) 25 | print resp.status_code 26 | 27 | 28 | if __name__ == '__main__': 29 | test_post() -------------------------------------------------------------------------------- /uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | socket=127.0.0.1:9001 3 | profiler = true 4 | memory-report = true 5 | enable-threads = true 6 | http-keepalive = 1 7 | pythonpath = ../ 8 | ; module = bootstrap:application 9 | master = 1 10 | mountpoint = bootstrap 11 | callable = application 12 | ; mount = /app3=app3.py 13 | ; generally flask apps expose the 'app' callable instead of 'application' 14 | ; callable = app 15 | 16 | processes = 2 17 | daemonize = /tmp/uwsgi.log 18 | disable-logging = 1 19 | buffer-size = 32768 20 | harakiri = 5 21 | harakiri-verbose = 1 22 | pidfile = /tmp/uwsgi.pid 23 | stats = $(HOSTNAME):5033 24 | py-autoreload = 2 25 | 26 | ; tell uWSGI to rewrite PATH_INFO and SCRIPT_NAME according to mount-points 27 | manage-script-name = true 28 | 29 | ; bind to a socket 30 | ; socket = /var/run/uwsgi.sock --------------------------------------------------------------------------------