OpenAPI 打通前後端任督二脈

leon0824

Leon

Posted on December 15, 2021

OpenAPI 打通前後端任督二脈

過去曾經寫過一篇〈Apiary API 規格文件+假接口一次到位〉,簡單的說,Apiary 是幫我們產生 API 文件與假接口的工具,讓別人可以在真正的後端 API 完工前就著手開工前端的工作,而定義 Apiary 的文件是一種稱為 API Blueprint 的格式,它是以 Markdown 為基礎的擴充格式,也就是說,繞一圈回過頭來你各位終究還是要寫文件的

問題是工程師最討厭三件事:寫註解、寫測試、寫文件。

然而:

  • 註解可以不寫,因為好的程式碼是自我詮釋的
  • 測試可以不寫,因為可以丟給 QA 寫 🤨
  • 文件不可不寫,因為你的前端妹子同事會抓狂

於是有人發明工具,從程式碼自動產出文件,於是程式碼有了,文件也有了,接口也有了,而且比 Apiary 更棒的是,是真.接口

通常這類的工具採用的文件規範是 OpenAPI 規範,在上手工具之前,先來快速了解一下 OpenAPI。

OpenAPI

OpenAPI 是用於描述 API 資訊的文件,包括 API 的端點、參數、輸出入格式、說明、認證等,本質上它是一個 JSON 或 YAML 文件,而文件內的 schema 則是由 OpenAPI 定義。

下面是一份 OpenAPI JSON 文件的範例:

{
  "openapi": "3.0.0",
  "info": {
    "title": "TestAPI",
    "description": "Bare en liten test",
    "version": "1.0"
  },
  "servers": [
    {
      "url": "https://api.example.com/v1"
    }
  ],
  "paths": {
    "/users/{userId}": {
      "get": {
        "summary": "Returns a user by ID",
        "parameters": [
          {
            "name": "userId",
            "in": "path",
            "description": "The ID of the user to return",
            "required": true,
            "style": "simple",
            "explode": false,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/inline_response_200"
                }
              }
            }
          },
          "400": {
            "description": "The specified user ID is invalid (e.g. not a number)"
          },
          "404": {
            "description": "A user with the specified ID was not found"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "inline_response_200": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

裡面的 schema 有些可望文生義,剩下的不用完全懂,JSON 本來就不是人讀而是機讀的文件,有工具會幫我們產出人讀的文件。:)

說人話!

來源:洪金寶

有許多的工具都可以讀取 OpenAPI JSON / YAML 檔案並產出功能豐富的 web 文檔,包括:

這些工具中,最常見的應該是本家的 Swagger UI(OpenAPI 在成為開放規格以前,是 Swagger 產品線的一部分),以上面的那份範例為例,經過 Swagger UI 處理後會產出如下的 web 文檔:

TestAPI

來源:TestAPI

我們可以看到,web 文檔告訴我們 API 的端點位址、要打哪些參數、回應的格式、錯誤回應有哪些、還可以試玩,這些內容在上面提到的工具都有,形式上大同小異罷了。

然而這一切的根源 OpenAPI JSON / YAML 終究還是要用某種手段生出來,否則無法解決不寫文件卻又不能沒有文件的需求,於是乎有人開發出從程式碼自動產出 OpenAPI JSON / YAML 的工具,例如 Python 的 FastAPI。

OpenAPI 與 FastAPI

FastAPI

來源:FastAPI

FastAPI 是 Python 的 web 框架,它有這些特性:

  • 高效能而功能豐富。通常功能越多,體積越大,效能越差,而 FastAPI 在效能與功能間取了一個很好的平衡點。
  • 整合了 Pydantic 的資料驗證功能。
  • 無綁定 ODM / ORM,可以自由搭配任意的 ODM 操作 MongoDB 這類的文件型資料庫,或任意的 ORM 操作 SQLite 這類的關聯資料庫。
  • 整合了 OpenAPI、Swagger UI、REDOC。
  • 生態健康,目前 GitHub 星星數近四萬,僅次於老牌的 Django(六萬星)與 Flask(近六萬星)。

FastAPI 極簡使用

FastAPI 是 decorator 風格的框架,我們對函式加上 FastAPI 提供的裝飾器,即可讓該函式成為一個 API 端點:

from fastapi import FastAPI


app: FastAPI = FastAPI()


@app.get(path='/items/{item_id}')
def read_item(item_id: int) -> dict:
    return {'item_id': item_id}
Enter fullscreen mode Exit fullscreen mode

上面的陽春 API 範例中,我們做了幾件事:

  • @app.get() 裝飾器定義了此函式 read_item() 接受 HTTP 的 GET 請求
  • 對外暴露的 API 端點為 /items/{item_id}
  • item_id 則在函式簽名內定義型態應為整數

在談到 OpenAPI 前,先把 pydantic 的資料驗證的特性整合進來,讓範例更接近實用一點。

FastAPI + Pydantic

沿用上面的例子,我們把 item 的真身做出來:

from fastapi import FastAPI
from pydantic import BaseModel


class Item(BaseModel):
    id: int
    name: str
    quantity: int


app: FastAPI = FastAPI()


@app.get(path='/items/{item_id}')
def read_item(item_id: int) -> dict:
    item = get_single_item_by_id(item_id)
    return item
Enter fullscreen mode Exit fullscreen mode

與前一個範例比起來,這邊多了幾個特性:

  • Item class 定義了 item 物件內的欄位與型態
  • read_item() 直接回傳 item

如果要表現更多 FastAPI 對 OpenAPI 整合特性,還有更多錦上添花的部份可以摻進來。

FastAPI + OpenAPI

我們加入更多完善 OpenAPI 文檔的元素:

from fastapi import FastAPI
from pydantic import BaseModel


class Item(BaseModel):
    id: int
    name: str
    quantity: int


app: FastAPI = FastAPI(servers=[{'url': 'http://localhost:5000'}])


@app.get(
    path='/items/{item_id}',
    operation_id='read_item',
    response_model=Item,
)
def read_item(item_id: int) -> Item:
    """Read item by id."""
    item = get_single_item_by_id(item_id)
    return item


@app.delete(path='/items/{item_id}', operation_id='delete_item')
def delete_item(item_id: int) -> None:
    """Delete item by id."""
    delete_single_item_by_id(item_id)
    return None
Enter fullscreen mode Exit fullscreen mode

相較於前一個範例,這邊又多了幾個特性:

  • 建構 FastAPI 實例時加入了 servers 參數,用於在 OpenAPI 文檔內指示 API 站台位址。
  • 函式裝飾器加入了 operation_id='read_item' 參數。
  • 函式內加入了 docstring。
  • 加入了另一個用於刪除的端點。

operationId 是 OpenAPI 用於辨識 API 端點與操作的唯一值,由於在 RESTful API 的風格下,一個 API 端點 /items/{item_id} 可能會同時接受 GET、DELETE、PUT 等動作,每種動作分別對應到內部不同的處理函式,如同範例中所展示的那樣,但這些內部函式又不為外界所知,所以用 operationId 作為唯一的識別值有其必要性。

在一番折騰下,我們終於可以看看產出的 OpenAPI JSON 檔與 web 文檔:

{
  "openapi": "3.0.2",
  "info": {
    "title": "FastAPI",
    "version": "0.1.0"
  },
  "servers": [
    {
      "url": "http://localhost:5000"
    }
  ],
  "paths": {
    "/items/{item_id}": {
      "get": {
        "summary": "Read Item",
        "description": "Read item by id.",
        "operationId": "read_item",
        "parameters": [
          {
            "required": true,
            "schema": {
              "title": "Item Id",
              "type": "integer"
            },
            "name": "item_id",
            "in": "path"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Item"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      },
      "delete": {
        "summary": "Delete Item",
        "description": "Delete item by id.",
        "operationId": "delete_item",
        "parameters": [
          {
            "required": true,
            "schema": {
              "title": "Item Id",
              "type": "integer"
            },
            "name": "item_id",
            "in": "path"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "HTTPValidationError": {
        "title": "HTTPValidationError",
        "type": "object",
        "properties": {
          "detail": {
            "title": "Detail",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ValidationError"
            }
          }
        }
      },
      "Item": {
        "title": "Item",
        "required": [
          "id",
          "name",
          "quantity"
        ],
        "type": "object",
        "properties": {
          "id": {
            "title": "Id",
            "type": "integer"
          },
          "name": {
            "title": "Name",
            "type": "string"
          },
          "quantity": {
            "title": "Quantity",
            "type": "integer"
          }
        }
      },
      "ValidationError": {
        "title": "ValidationError",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "type": "object",
        "properties": {
          "loc": {
            "title": "Location",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "msg": {
            "title": "Message",
            "type": "string"
          },
          "type": {
            "title": "Error Type",
            "type": "string"
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

同樣的,我們不追求用肉身理解 OpenAPI JSON,我們用 FastAPI 自動幫我們產出的 Swagger UI 的 web 文檔來輔助我們人類理解這份 API 的用法:

FastAPI - Swagger UI

在上面的總覽檢視,可以看到與程式碼互相對應的部份:

  • Servers 區塊來自程式碼內的 server 參數
  • 「GET /items/{item_id}」與「DELETE /items/{item_id}」分別對應到程式碼內的裝飾器與函式

把「GET /items/{item_id}」端點展開:

FastAPI - Swagger UI

可以看到:

  • 函式的 docstring 變成端點的說明文
  • item_id 被列為參數,並且型態必須為整數,與程式內聲明的一致
  • 成功回應的 JSON 格式與我們所定義的 Item model 一致

在 FastAPI 框架中大量運用了 Python 的 type hints 特性與 Pydantic 的型態驗證特性,在程式內的型態聲明不僅用於 OpenAPI 的文檔內,也真實的運作在端點函式內,Pydantic 會自動幫我們檢查收到的參數型態是否相符,當一個聲明應為整數的參數,卻收到其他型別時,Pydantic 會直接返回錯誤,這個特性為我們節省了大量的型態驗證工作,相較於不檢查儲存才檢查,在更前方把不良的資料擋下是更好也更安全的策略。


回到 OpenAPI 的主題,有與 OpenAPI 做原生整合的框架當然不只 FastAPI 一個,由 Flask 維護者之一的李輝先生開發的 APIFlask 也與 OpenAPI 做了原生整合,即便未有原生整合的框架,也大都有第三方套件可以幫助產出 OpenAPI 文件。

在此之前都是談後端與 OpenAPI 如何如何,下面談談 OpenAPI 在前端的運用。

OpenAPI 在前端

除了變出美美的 web 文檔外,OpenAPI JSON / YAML 本身也是個機讀文件,裡面定義了後端 API 的 server、path、HTTP method、operation ID、parameter、schema 等前端會用到的資訊,那理所當然的也有套件可以幫助我們在前端專案用上 OpenAPI JSON / YAML,這套件就是 Swagger Client,Swagger Client 與 Swagger UI 一樣,也是祖傳正宗本家嫡系 Swagger 家族的一員。

Swagger Client 用法

一個絕簡單的使用範例:

import SwaggerClient from "swagger-client";


async function getOasClient() {
  return await SwaggerClient({
    url: "http://localhost:5000/openapi.json",
  });
}


async function getItem(id) {
  const oasClient = await getOasClient();
  const response = await oasClient.execute({ 
    operationId: "read_item",
    parameters: { item_id: id },
  });
  return response.body
}
Enter fullscreen mode Exit fullscreen mode

和用 fetch() 寫起來有變簡單嗎?好像差不多,因為:

  • 不用查 HTTP 方法與端點路徑
  • 但要手動從 OpenAPI JSON / YAML 查 Operation ID
  • 要傳遞的參數、型態、回傳格式還是要對照 web 文檔看

沒有用了可以少奮鬥三十年的感覺…,讓我們結束這一回合。

總結

至今我們已經看過兩種 API 資訊交換格式 API Blueprint 與 OpenAPI,當然老外一向是造輪子在行的,還有其他的流派像是 RAML、AsyncAPI、AML / AMF 等,目前看來 OpenAPI 已經是最常見的標準,其他流派也或多或少的往與 OpenAPI 相容的方向走,但唯一也是最大的例外是 GraphQL,相較於走 RESTful 的 resource 為基礎的 OpenAPI,有固定的 schema,GraphQL 則是一種 Query Language,不存在固定的 schema,而是依查詢動態的回覆,兩種標準間很難做到不打折的轉換。

本文僅提到了 OpenAPI 工具的一小部份,OpenAPI JSON / YAML 能做的還有很多,API 測試、監控、假 API、安全檢測等,在此附上兩個 OpenAPI 相關工具的連結:

💖 💪 🙅 🚩
leon0824
Leon

Posted on December 15, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related