[Python] Create an API with Flask and test with pytest


Posted by jerryeml on 2021-07-11

前言

繼上一篇文章 https://jerryeml.coderbridge.io/2021/05/30/create-api-with-pyhon-and-test-with-auto-retry/

感謝同事 Robin 的建議 推薦 Flask 這個更輕量的東西讓我去嘗試實作 API 的確是更方便一些 因為真的幫忙包了好多東西XD

上一篇提到 透過 #BaseHTTPRequestHandler 可以 mock apiresponse code 但萬一今天想驗證的除了 response code 之外 還包含回傳的 json

有沒有更好的方法可以快速把這些東西 implement 出來
答案是有的!! 接著就可以用 pytest 針對他做 testing

Flask

Flask 是一個使用 Python 編寫的輕量級 Web 應用框架。基於 Werkzeug WSGI 工具箱和 Jinja2 模板引擎。 Flask 使用 BSD 授權。

Flask 被稱為「微框架」,因為它使用簡單的核心,用擴充增加其他功能。 Flask 沒有預設使用的資料庫、表單驗證工具。然而, Flask 保留了擴增的彈性,可以用 Flask-extension 加入這些功能: ORM 、表單驗證工具、檔案上傳、各種開放式身分驗證技術。

但我們今天不會介紹 Flask-extension
我們的目的只是介紹如何利用 flask 快速建置一個可被驗證的 API

關於 API

API(application programming interface)是應用程式的介面/接口,我們不需要知道背後怎麼執行,只要瞭解要跟它說什麼、以及我們可以拿回什麼。
就像我們去速食店櫃檯點餐,櫃檯店員就像API接口,替櫃檯前的顧客、和後台廚房建立起友誼的橋樑。
當我們和他說「請給我雞腿堡」,不需要管什麼原料、漢堡如何製作,稍待片刻廚房就會完成,並透過櫃檯店員轉手拿到我們面前的餐盤。
我們在接口這端(櫃台店員)說需求,接著他會進行處理,例如去資料庫(廚房)拿東西,處理後告訴我們結果;過程我們只需要等待即可。
REST API(Representational State Transfer API)是一種API的設計風格,關於我們怎麼和資料互動;這邊不深究細節,讀者可以想成是某個共識,例如請、謝謝、對不起,出現的語境會有對應的行為。

前置準備

30秒搞一個API

產生一個 py 檔案 >> api_with_flask.py

import flask
from flask import jsonify, request
from flask import render_template


app = flask.Flask(__name__)
app.config["DEBUG"] = True


@app.route('/', methods=['GET'])
def home():
    return "<h1>Hello, I am here !</h1>"


@app.route('/template', methods=['GET'])
def template_home():
    return render_template('upload_sample.txt')


app.run()

接著輸入 python api_with_flask.py
就可以發現你的local api已經建立好囉

依照畫面提示打開瀏覽器前往 http://127.0.0.1:5000/

接著來實作各種Action吧

在此之前 必須先定義好 API 裡面的 DATASET

我們就假設有一個復仇者聯盟裡面有以下成員吧XD
avergers:[aladdin, elpis, lapras]

其下也包含各自的屬性及內容,idLeadernationalsuperpowernicknamegender

# test data
aladdin = {
    "id":1,
    "Leader":"Tony",
    "nickname":"iron man",
    "nationality":"American",
    "gender":"M",
    "superpower":"n"
}

elpis = {
    "id":2,
    "Leader":"Peter",
    "nickname":"spider man",
    "nationality":"American",
    "gender":"M",
    "superpower":"y"
}

lapras = {
    "id":3,
    "Leader":"Natasha",
    "nickname":"Block Widow",
    "nationality":"Russia",
    "gender":"F",
    "superpower":"n"
}

avengers =[aladdin, elpis, lapras]

當我們把 DATASET 加入程式碼之後
就可以來進行實作囉

GET

GET 方法請求展示指定資源。使用 GET 的請求只應用於取得資料。

首先我們一樣

@app.route('/get/avengers/all', methods=['GET'])
def avengers_all():
    return jsonify(avengers)

jsonify 是甚麼呢

藉由 Flask 套件所提供的 jsonify() 函數輸出為 JSON 格式呈現在網頁上

那如果沒有用 jsonify 會怎麼樣?

TypeError
TypeError: The view function did not return a valid response. The return type must be a string, dict, tuple, Response instance, or WSGI callable, but it was a list.

沒錯…會壞掉。

一切正常之後 輸入 http://127.0.0.1:5000/get/avengers/all
就可以得到 API 的內容囉

我們還可以多寫一個 API 讓大家帶參數來 Query 想要的東西

舉個例子來說 我們開一個 http://127.0.0.1:5000/get/avengersAPI
可以指定參數拿特定的資料回來

@app.route('/get/avengers', methods=['GET'])
def avengers_properties():
    results = []
    nationality = ""
    if 'nationality' in request.args:
        nationality = request.args['nationality']
    else:
        print("no hero")    

    for avenger in avengers:
        if avenger['nationality'] == nationality:
            results.append(avenger)

    return jsonify(results)

假設我們輸入後 URL 帶入 ?nationality=American 會拿到 nationality=American 的資料囉

[
  {
    "Leader": "Tony", 
    "gender": "M", 
    "id": 1, 
    "nationality": "American", 
    "nickname": "iron man", 
    "superpower": "n"
  }, 
  {
    "Leader": "Peter", 
    "gender": "M", 
    "id": 2, 
    "nationality": "American", 
    "nickname": "spider man", 
    "superpower": "y"
  }
]

POST

POST 方法用於提交指定資源的實體,通常會改變伺服器的狀態或副作用(side effect)

  1. POST 方法新增一位 avengeravengers,並回傳新增 avengerJSON
  2. 將我們 POST 送出的 JSON 資料,轉換成Pythondictionary,後續建立 new_avengerdictionary ,並把它加入 avengers 清單。
  3. 回傳剛剛建立的 new_avengers json格式,讓使用者知道新增成功。
@app.route('/post/avengers', methods=['POST'])
def create_avengers():
    request_data = request.get_json()
    leader = request_data['Leader']
    gender = request_data['gender']
    new_avenger = {
        "Leader": leader, 
        "gender": gender, 
    }
    avengers.append(new_avenger)
    return jsonify(new_avenger)

利用 POSTMAN 嘗試 就可以發現 成功囉

小結

從上述兩個例子,可以看到 Python 處理資料後、會透過 jsonifydictionary 轉成 JSON 格式再回傳,顯示在瀏覽器、 Postman
Postman 透過 Post 方法傳給 PythonJSON ,會先被轉成 Pythondictionary 格式進行處理,而後透過 jsonifydictionary 轉成 JSON 再回傳。

接著我們來測試 API 吧!! with pytest

建立一個 test_api_with_flask.py

我們先利用 pytestfixture 來做 setup 的動作
目的是 init 我們剛寫好的 api
並且把我們自己建立的 api 利用另外一條 threadcreate 出來

import logging
import json
import requests
import pytest
from threading import Thread
from tmp import api_with_flask


@pytest.fixture(scope="module", autouse=True)
def setup():
    # Start running mock server in a separate thread.
    # Daemon threads automatically shut down when the main process exits.
    mock_server_thread = Thread(target=api_with_flask.init_api)
    mock_server_thread.setDaemon(True)
    mock_server_thread.start()

接著呢 我們開始寫第一個 testcase
主要會去驗證這個 apireturn code 是不是 200
還有內容是不是我們預期的

def test_avengers_all_with_get_method():
    rtn = requests.get(url='http://localhost:5000/get/avengers/all')
    print(f'status code: {rtn.status_code}')
    assert rtn.status_code == 200
    print(f'response: {json.loads(rtn.content)}')

    expect_content = [
        {
            "Leader": "Tony", 
            "gender": "M", 
            "id": 1, 
            "nationality": "American", 
            "nickname": "iron man", 
            "superpower": "n"
        }, 
        {
            "Leader": "Peter", 
            "gender": "M", 
            "id": 2, 
            "nationality": "American", 
            "nickname": "spider man", 
            "superpower": "y"
        }, 
        {
            "Leader": "Natasha", 
            "gender": "F", 
            "id": 3, 
            "nationality": "Russia", 
            "nickname": "Block Widow", 
            "superpower": "n"
        }
    ]
    assert json.loads(rtn.content) == expect_content

輸入 pytest -s .\tmp\test_api_with_flask.py
透過 -s 可以知道 印出來的log是甚麼

接著我們可以繼續下一個 testcsae
透過這個反例 我們打算測試 api 有沒有開放 post 的功能
預期是沒有 所以應該要回傳 405

def test_avengers_all_with_post_method():
    rtn = requests.post(url='http://localhost:5000/get/avengers/all')
    print(f'status code: {rtn.status_code}')
    assert rtn.status_code == 405

測試結果

成功的結果如下

================================== test session starts ===================================
platform win32 -- Python 3.8.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: E:\Coding\side_project\poc\Python_Practice
collected 2 items

tmp\test_api_with_flask.py  * Serving Flask app 'tmp.api_with_flask' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
status code: 200
response: [{'Leader': 'Tony', 'gender': 'M', 'id': 1, 'nationality': 'American', 'nickname': 'iron man', 'superpower': 'n'}, {'Leader': 'Peter', 'gender': 'M', 'id': 2, 'nationality': 'American', 'nickname': 'spider man', 'superpower': 'y'}, {'Leader': 'Natasha', 'gender': 'F', 'id': 3, 'nationality': 'Russia', 'nickname': 'Block Widow', 'superpower': 'n'}]     
.status code: 405
.

=================================== 2 passed in 4.54s ====================================

完整程式碼

api_with_flask.py

import flask
from flask import jsonify, request
from flask import render_template


app = flask.Flask(__name__)
app.config["DEBUG"] = True

# test data
aladdin = {
    "id":1,
    "Leader":"Tony",
    "nickname":"iron man",
    "nationality":"American",
    "gender":"M",
    "superpower":"n"
}

elpis = {
    "id":2,
    "Leader":"Peter",
    "nickname":"spider man",
    "nationality":"American",
    "gender":"M",
    "superpower":"y"
}

lapras = {
    "id":3,
    "Leader":"Natasha",
    "nickname":"Block Widow",
    "nationality":"Russia",
    "gender":"F",
    "superpower":"n"
}

avengers =[aladdin, elpis, lapras]


@app.route('/', methods=['GET'])
def home():
    return "<h1>Hello, I am here !</h1>"


@app.route('/template', methods=['GET'])
def template_home():
    return render_template('upload_sample.txt')


@app.route('/get/avengers/all', methods=['GET'])
def avengers_all():
    return jsonify(avengers)


@app.route('/get/avengers', methods=['GET'])
def avengers_properties():
    results = []
    nationality = ""
    if 'nationality' in request.args:
        nationality = request.args['nationality']
    else:
        print("no hero")    

    for avenger in avengers:
        if avenger['nationality'] == nationality:
            results.append(avenger)

    return jsonify(results)


@app.route('/post/avengers', methods=['POST'])
def create_avengers():
    request_data = request.get_json()
    leader = request_data['Leader']
    gender = request_data['gender']
    new_avenger = {
        "Leader": leader, 
        "gender": gender, 
    }
    avengers.append(new_avenger)
    return jsonify(new_avenger)


def init_api():
    app.run(debug=False, use_reloader=False)


if __name__ == '__main__':
    init_api()

api_with_flask.py

import logging
import json
import requests
import pytest
from threading import Thread
from tmp import api_with_flask


@pytest.fixture(scope="module", autouse=True)
def setup():
    # Start running mock server in a separate thread.
    # Daemon threads automatically shut down when the main process exits.
    mock_server_thread = Thread(target=api_with_flask.init_api)
    mock_server_thread.setDaemon(True)
    mock_server_thread.start()


def test_avengers_all_with_get_method():
    rtn = requests.get(url='http://localhost:5000/get/avengers/all')
    print(f'status code: {rtn.status_code}')
    assert rtn.status_code == 200
    print(f'response: {json.loads(rtn.content)}')

    expect_content = [
        {
            "Leader": "Tony", 
            "gender": "M", 
            "id": 1, 
            "nationality": "American", 
            "nickname": "iron man", 
            "superpower": "n"
        }, 
        {
            "Leader": "Peter", 
            "gender": "M", 
            "id": 2, 
            "nationality": "American", 
            "nickname": "spider man", 
            "superpower": "y"
        }, 
        {
            "Leader": "Natasha", 
            "gender": "F", 
            "id": 3, 
            "nationality": "Russia", 
            "nickname": "Block Widow", 
            "superpower": "n"
        }
    ]
    assert json.loads(rtn.content) == expect_content


def test_avengers_all_with_post_method():
    rtn = requests.post(url='http://localhost:5000/get/avengers/all')
    print(f'status code: {rtn.status_code}')
    assert rtn.status_code == 405

https://github.com/jerryeml/programming.py

Reference

https://www.tpisoftware.com/tpu/articleDetails/1719

https://medium.com/%E4%B8%80%E5%80%8B%E4%BA%BA%E7%9A%84%E6%96%87%E8%97%9D%E5%BE%A9%E8%88%88/python-flask-rest-api%E7%AD%86%E8%A8%98-869c3d2fee3

https://medium.com/datainpoint/flask-web-api-quickstart-3b13d96cccc2

https://iter01.com/578851.html


#API #Python #Flask #Testing #pytest #testcase







Related Posts

[Py 百日馬 Day 0] 來場 Python 百日馬吧!

[Py 百日馬 Day 0] 來場 Python 百日馬吧!

ASP.NET Core Web API 入門教學 - 使用AutoMapper更新資料

ASP.NET Core Web API 入門教學 - 使用AutoMapper更新資料

關於 React 小書:React props

關於 React 小書:React props


Comments