1. 程式人生 > >14 使用Flask提供REST Web服務

14 使用Flask提供REST Web服務

一. 建立API藍本

  REST API相關的路由是一個自成一體的程式子集, 所以為了更好的組織程式碼, 我們最好把這些路由放到獨立的藍本中。

1)API藍本的結構

|-flasky

  |-app/

    |-api_1_0

      |-__init__.py

      |-users.py

      |-posts.py

      |-comments.py

      |-authentication.py

      |-errors.py

      |-decorators.py

這個API藍本中, 各資源分別在不同的模組中實現。 藍本還包含處理認證, 錯誤以及提供自定義修飾器的模組。

2)藍本的構造檔案__init__.py

from flask import Blueprint

api = Blueprint('api', __name__)

from . import users, posts, comments, authentication, errors

3)註冊API藍本app/__init__.py

def create_app(config_name):

    #...

    from .api_1_0 import api as api_1_0_blueprint

    app.register_blueprint(api_1_0_blueprint, url_prefix='/api/v1.0')

    #...

二. 錯誤處理

1)客戶端能從web服務得到的常見狀態碼如下表所示:

HTTP狀態碼名稱說明
200OK(成功)請求成功完成
201Created(建立)請求成功完成並建立了一個新資源
400Bad Request(壞請求)請求不可用或不一致
401Unauthorized(未授權)請求未包含認證資訊
403Forbidden(禁止)請求中傳送的認證密令無權訪問目標
404Notfound(未找到)URL對應的資源不存在
405Method not allowed(不允許使用的方法)指定資源不支援請求使用的方法
500Interval server error(內部伺服器錯誤)處理請求的過程中發生意外錯誤

  為所有客戶端生成相應的一種方法是, 在錯誤處理程式中根據客戶端請求的格式改寫相應, 這種技術稱為內容協商。

2)改進後的404錯誤處理程式

@main.app_errorhandler(404)

def page_not_found(e):

    if request.accept_mimetypes.accept_json and \

            not request.accept_mimetypes.accept_html: #如果客戶端只接受json相應

        response = jsonify({'error': 'not found'})

        response.code_status = 404

        return response

    return render_template('404.html'), 404
app_error_handler作用在全域性, 所有路由中發生404錯誤就會由該錯誤處理程式處理。

3)API藍本中的錯誤處理程式app/api_1_0/errors.py

def forbidden(message):
    response = jsonify({'error': 'forbidden', 'message': message})
    response.status_code = 403
    return response


def unauthorized(message):
    response = jsonify({'error': 'unauthorized', 'message': message})
    response.status_code = 401
    return response


def bad_request(message):
    response = jsonify({'error': 'bad request', 'message': message})
    response.status_code = 400
    return response

@api.errorhandler(ValidationError)#如果在api藍本註冊的路由中遇到ValidaitonError, 由改程式處理錯誤
def validation_error(e):
    return bad_request(e.args[0])

這樣檢視函式就可以呼叫這些輔助函式生成錯誤響應了

4)ValidationError自定義錯誤app/exceptions.py

class ValidationError(ValueError):

    pass

三. 使用Flask-HTTPAuth認證使用者

  我們的認證支援匿名使用者, 郵箱密碼認證, token令牌認證;

1)安裝flask-httpauth

&pip install flask-httpauth

2)初始化Flask-HTTPAuth app/api_1_0/authentication.py

from flask_httpauth import HTTPBasicAuth


auth = HTTPBasicAuth()


@auth.verify_password

def verify_password(email_or_token, password):

    if email_or_token == '':

        g.current_user = AnonymousUser()

        return True

    if password == '':

        g.current_user = User.verify_auth_token(email_or_token)

        g.token_used = True

        return g.current_user is not None

    user = User.query.filter_by(email=email_or_token).first()

    if not user:

        return False

    g.token_used = False

    g.current_user = user

    return user.verify_password(password)


@auth.error_handler

def auth_error():

    return unauthorized('Invalid credentials')


#使用修飾器保護路由
@api.route('/posts/')
@auth.login_required
def get_posts():
    pass

"""
上面的程式碼是否有些不好理解? 沒關係, 我來介紹一下它的作用。
部署了上面的程式碼以後, 我們就可以使用修飾器@auth.login_reqired保護路由了,
只要客戶端向該修飾器修飾的路由發出請求, 瀏覽器就會彈出一個對話方塊要求填寫郵箱和密碼, 即需要認證
如果填寫的資訊通過認證, 則可以訪問路由, 未通過認證不可訪問路由。
"""

#...email-password認證是預設的, token認證是我們自己新增的, 因為不想每次都發送敏感資訊
#為了實現token認證我們還需要實現生成token, 驗證token, 獲取token的函式

#在那之前, 我們先處理一個問題, 在每個路由上都新增@auth.login_required修飾器稍顯麻煩, 
#我們可以在before_request函式上新增該修飾器, 那樣該修飾器就會應用到所有api路由上了

@api.before_request
@auth.login_required
def before_request():
    if not g.current_user.is_anonymous and not g.current_user.confirm:
        return forbidden('Unconfirmed account')

#我們訪問任意api藍本註冊的路由, 就會先訪問@api.before_request修飾器註冊的before_request函式
#因為before_request函式被@auth.login_required修飾器修飾, 所以我們就需要通過認證
#認證通過後才可以進入before_request函式, 執行完before_request函式後自動執行我們想訪問的路由
#不過使用者要是匿名使用者或者是confirm屬性為True的使用者才可以

3)生成, 驗證token app/models.py

class User(UserMixin, db.Model):

    #...

    def generate_auth_token(self, expiration):

        s = Serializer(current_app.config['FLASKY_SECRET_KEY'], expires_in=expiration)

        return s.dumps({'id': self.id})

   

    @staticmethod

    def verify_auth_token(token):  #驗證失敗返回None, 驗證成功返回對應使用者

        s = Serializer(current_app.config['FLASKY_SECRET_KEY'])

        try:

            data = s.loads(token)

        except:

            return None

        return User.query.get(data['id'])

4)傳送token app/api_1_0/authentications.py

@api.route('/token')

def get_token(): #能通過認證才能訪問該路由, 有三種方式通過認證: 匿名使用者, token認證, email-password認證

    if g.current_user.is_anonymous or g.token_used == True:  #匿名使用者無法獲取token, 無法用舊token獲取新token

        return unauthorized('Invalid credentials')

    return jsonify({'token': g.current_user.generate_auth_token(expiration=3600), 'expiration': 3600}) 

四. 資源和json的轉換

1)post&json_post app/models.py

class Post(db.Model):

    #...

    def to_json(self):

        json_post = {

            'url': url_for('api.get_post', id=self.id, _external=True),

            'body': self.body,

            'body_html': self.body_html,

            'timestamp': self.timestamp,

            'author': url_for('api.get_user', id=self.author_id, _external=True),

            'comments': url_for('api.get_post_comments', id=self.id, _external=True),

            'comment_count': self.comments.count()

        }

        return json_post


    @staticmethod

    def from_json(json_post):

        body = json_post.get('body')

        if body == None or body == '':

            raise ValidationError('post does not have a body')

        return Post(body=body)

"""
我們建立Post例項的時候只需要使用body欄位,post的author屬性在檢視函式中定義。
"""

#http客戶端和web服務之間傳遞資訊使用json編碼, 所以web服務要實現json和資源之間的相互轉換
#把post請求發來的json轉換成資源存入資料庫
#把get請求請求的資源轉換成json傳送給客戶端

2)user&json_user app/models.py

class User(UserMixin, db.Model):

    def to_json(self):

        json_user = {
                'url': url_for('api.get_user', id=self.id, _external=True),
                'username': self.username,
                'member_since': self.member_since,
                'last_seen': self.last_seen,
                'posts': url_for('api.get_user_posts', id=self.id, _external=True),
                'followed_posts': url_for('api.get_user_followed_posts', id=self.id, _external=True),
                'post_count': self.posts.count()
                }
        return json_user
#為了保護使用者隱私, 我們沒有把email, password, role等屬性放入json字典
#這說明我們把資源轉成json時沒必要把所有屬性都放進去
#user並沒有post_count屬性, 這說明我們可以在json裡面增加虛擬屬性

3)comment&json_comment app/models.py

class Comment(db.Model):

    #...

    def to_json(self):
        json_comment = {
                'url': url_for('api.get_comment', id=self.id, _external=True),
                'body': self.body,
                'body_html': self.body_html,
                'timestamp': self.timestamp,
                'author_url': url_for('api.get_user', id=self.author.id, _external=True),
                'post_url': url_for('api.get_post', id=self.post.id, _external=True)
                }
        return json_comment

    @staticmethod
    def from_json(json_comment):
        body = json_comment.get('body')
        if body == None or body == '':
            raise ValidationError('comment does not have a body')
        return Comment(body)

五. 實現資源端點

1)app/api_1_0/posts.py

from ..models import Post, Permission
from flask import request, g, jsonify, url_for, current_app
from . import api
from .. import db
from .errors import forbidden
from .decorators import permission_required


@api.route('/posts/')
def get_posts():
    page = request.args.get('page', 1, type=int)
    pagination = Post.query.paginate(page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], error_out=False)
    posts = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for('api.get_posts', page=page-1, _external=True)
    next = None
    if pagination.has_next:
        next = url_for('api.get_posts', page=page+1, _external=True)
    return jsonify({
        'posts': [post.to_json() for post in posts],
        'prev': prev,
        'next': next,
        'count': pagination.total
        })


@api.route('/posts/<int:id>')
def get_post(id):
    post = Post.query.get_or_404(id)
    return jsonify(post.to_json())


@api.route('/posts/', methods=['POST'])
@permission_required(Permission.WRITE_ARTICLES) #建立文章的使用者需要寫許可權, 該修飾函式定義在文章後面
def new_post():
    post = Post.from_json(request.json) #利用請求傳送的json資料建立post例項, 如果遇到ValidationError, 由errorhandler處理,檢視函式變得簡潔
    post.author = g.current_user  
    db.session.add(post)
    db.session.commit()
    return jsonify(post.to_json()), 201, \
            {'location': url_for('api.get_post', id=post.id, _external=True)}


@api.route('/posts/<id>', methods=['PUT'])
@permission_required(Permission.WRITE_ARTICLES)
def edit_post(id):
    post = Post.query.get_or_404(id)
    if g.current_user != post.author and \ #通過認證的使用者不是文章的作者, 並且不是管理員
            not g.current_user.can(Permission.ADMINISTER):
                return forbidden('Insufficient permissions')
    post.body = request.json.get('body', post.body)
    db.session.add(post)
    return jsonify(post.to_json())

2)app/api_1_0/users.py

from . import api
from ..models import User
from flask import jsonify, request, current_app, url_for


@api.route('/users/<int:id>')
def get_user(id):
    user = User.query.get_or_404(id)
    return jsonify(user.to_json())


@api.route('/users/<int:id>/posts/')
def get_user_posts(id):
    user = User.query.get_or_404(id)
    page = request.args.get('page', 1, type=int)
    pagination = user.posts.paginate(page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], error_out=False)
    posts = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for('api.get_user.posts', id=id, page=page-1)
    next = None
    if pagination.has_next:
        next = url_for('api.get_user_posts', id=id, page=page+1)
    return jsonify({
        'posts': [post.to_json() for post in posts],
        'prev': prev,
        'next': next,
        'count': user.posts.count()
        })

@api.route('/users/<int:id>/timeline/')
def get_user_followed_posts(id):
    user = User.query.get_or_404(id)
    page = request.args.get('page', 1, type=int)
    pagination = user.followed_posts.paginate(page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], error_out=False)
    posts = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for('api.get_user_followed_posts', id=id, page=page-1)
    next = None
    if pagination.has_next:
        next = url_for('api.get_user_followed_posts', id=id, page=page+1)
    return jsonify({
        'posts': [post.to_json() for post in posts],
        'prev': prev,
        'next': next,
        'count': user.followed_posts.count()
        })

3)app/api_1_0/comments.py

from . import api
from flask import request, current_app, jsonify, url_for, g
from ..models import Comment, Permission, Post
from .decorators import permission_required
from .. import db


@api.route('/comments/')
def get_comments():
    page = request.args.get('page', 1, type=int)
    pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate(page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], error_out=False)
    comments = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for('api.get_comments', page=page-1)
    next = None
    if pagination.has_next:
        next = url_for('api.get_comments', page=page+1)
    return jsonify({
        'comments': [comment.to_json() for comment in comments],
        'prev': prev,
        'next': next,
        'count': pagination.total()
        })


@api.route('/comments/<int:id>')
def get_comment(id):
    comment = Comment.query.get_or_404(id)
    return jsonify(comment.to_json())


@api.route('/posts/<int:id>/comments/')
def get_post_comments(id):
    post = Post.query.get_or_404(id)
    page = request.args.get('page', 1, type=int)
    pagination = post.comments.order_by(Comment.timstamp.desc()).paginate(page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], error_out=False)
    comments = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for('api.get_post.comments', id=id, page=page-1)
    next = None
    if pagination.has_next:
        next = url_for('api.get_post_comments', id=id, page=page+1)
    return jsonify({
        'comments': [comment.to_josn() for comment in comments],
        'prev': prev,
        'next': next,
        'count': pagination.total
        })


@api.route('/posts/<int:id>/comments/', methods=['POST'])
@permission_required(Permission.COMMIT)
def new_post_comment(id):
    post = Post.query.get_or_404(id)
    comment = Comment.from_json(request.json)
    comment.author = g.current_user
    comment.post = post
    db.session.add(comment)
    db.session.commit()
    return jsonify(comment.to_json), 201, \
            {'location': url_for('api.get_comment', id=comment.id, _external=True)}

六. permission_required修飾器 app/api_1_0/decorations.py

還記得我們是如何利用該修飾器修飾路由, 以達到限制許可權使用者才能訪問路由的目的的嗎?

@api.route('/posts/', methods=['POST'])
@permission_required(Permission.WRITE_ARTICLES) #只有具有寫許可權的使用者才能訪問該路由
def new_post():
    pass

我們來推理一下permission_required函式的構造, 首先修飾器語句等價於:

new_post = permission_required(Permission.WRITE_ARTICLES)(new_post)

即:

new_post() = permission_required(Permission.WRITE_ARTICLES)(new_post)()

當用戶訪問該路由時首先需要通過認證, 通過認證以後g.current_user中儲存的就是通過認證的使用者, 然後如果使用者是匿名使用者或者confirm屬性為True, 通過before_request函式, 即可訪問路由函式new_post()

就相當於訪問:

permission_required(Permission.WRITE_ARTICLES)(new_post)()

這樣的話看來是兩層閉包, 通過兩層呼叫把Permission.WRITE_ARTICLES和new_post引數傳到最裡面的函式, 最裡面的函式進行使用者許可權認證, 通過的話執行new_post函式即可:

def permission_required(permission):
    
    def decorator(f):
        
      @wraps(f)
 def decorator_function(): if not g.current_user.can(permission): return forbidden('Insufficient permissions') return f() return decorator_function return decorator

如果被修飾的函式有引數的話, 版本就是下面這樣:(有無引數都適用)

def permission_required(permission):
    
    def decorator(f):
        @wraps(f)
        def decorator_function(*nkwargs, **kwargs):

            if not g.current_user.can(permission):

                return forbidden('Insufficient permissions')

            return f(*nkwargs, **kwargs)

        return decorator_function

    return decorator

@wraps修飾器的作用是, 保持被修飾函式的__doc__和__name__屬性。

七. 使用HTTPie測試Web服務

    測試Web服務必須使用HTTP客戶端, 最常使用的在命令列中測試Web服務的客戶端是curl, httpie, 後者命令更簡潔, 可讀性也更高, 我們使用後者。

1)安裝httpie

&pip install httpie

2)執行web服務

3)測試web服務

GET請求可按如下方式發起:

...


因為這是第一頁, 所以prev引數為None, 但是返回了獲取下一頁的url和總頁數。

匿名使用者可傳送空郵件地址和密碼來發起相同的請求:

下面這個命令傳送POST請求以新增一篇新部落格文章:

如此推測, request.json就是字典:

{'body': "I'm adding a post2 from the *command line*."}

而且檢視函式返回語句:

    return jsonify(post.to_json()), 201, \
            {'location': url_for('api.get_post', id=post.id, _external=True)}

201是status_code,

{'location': url_for('api.get_post', id=post.id, _external=True)}是location首部。

要想使用認證令牌, 可向/api/v1.0/token 傳送請求:

在接下來的1小時內, 這個令牌可用於訪問API, 請求時要和空密碼一起傳送:

至此, Flasky的功能開發階段就完全結束啦~~~

很顯然, 下一步我們要部署Flasky。 在部署過程中, 我們會遇到新的挑戰~