OpenAPI 打通前後端任督二脈
Tags: openapi, fastapi
Published: 2021-12-15 07:15:49

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

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

然而：

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

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

通常這類的工具採用的文件規範是 [OpenAPI](https://www.openapis.org/) 規範，在上手工具之前，先來快速了解一下 OpenAPI。

## OpenAPI

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

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

```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"
          }
        }
      }
    }
  }
}
```

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

<figure>

![說人話！](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t4n842fqlxm1mz238wuz.JPG)
<figcaption>來源：洪金寶</figcaption>
</figure>

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

- [Swagger UI](https://swagger.io/tools/swagger-ui/) 🆓 / [SwaggerHub](https://swagger.io/tools/swaggerhub/)
- [REDOC](https://redoc.ly/redoc) 🆓 / [Redocly](https://redoc.ly/reference-docs)
- [Stoplight Elements](https://stoplight.io/open-source/elements/) 🆓 / [Stoplight](https://stoplight.io/api-documentation/)
- [ReadMe Documentation](https://readme.com/documentation)

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

<figure>

![TestAPI](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5skulkej4jq4qvpu90ys.png)
<figcaption>來源：<a href="https://app.swaggerhub.com/apis-docs/heidi/TestAPI/1.0#/default">TestAPI</a></figcaption>
</figure>

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

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

## OpenAPI 與 FastAPI

<figure>

![FastAPI](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bvgo0muwkc9j8v17bgss.png)
<figcaption>來源：FastAPI</figcaption>
</figure>

[FastAPI](https://fastapi.tiangolo.com/zh/) 是 Python 的 web 框架，它有這些特性：

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

### FastAPI 極簡使用

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

```py
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}
```

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

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

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

### FastAPI + Pydantic

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

```py
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
```

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

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

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

### FastAPI + OpenAPI

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

```py
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
```

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

- 建構 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 文檔：

```json
{
  "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"
          }
        }
      }
    }
  }
}
```

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

![FastAPI - Swagger UI](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3fcng0wk8qfvkrwtkbbz.png)

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

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

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

![FastAPI - Swagger UI](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9wbaaxu61cop2f7mc48e.png)

可以看到：

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

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

---

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

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

## OpenAPI 在前端

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

### Swagger Client 用法

一個絕簡單的使用範例：

```js
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
}
```

和用 `fetch()` 寫起來有變簡單嗎？好像差不多，因為：

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

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

## 總結

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

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

- [OpenAPI.Tools](https://openapi.tools/)
- [OpenAPI Tools and Libraries](https://github.com/OAI/OpenAPI-Specification/blob/main/IMPLEMENTATIONS.md)