在 為什麼要學習開發CLI 有提到,越來越多原本僅提供 REST API 的服務(如 GitHub、Stripe 與 Grafana),為了讓 AI Agent 與開發者更方便呼叫與執行自動化,也相繼提供了 CLI 工具。相較於要 AI 解析 OpenAPI 文件、自行拼接 HTTP Header 或處理 Token 刷新,透過 CLI 操作不僅能重用本地認證狀態,還能提供結構化 JSON 輸出與標準 Exit Code,更符合 AI 工具呼叫的情境。
這篇文章說明如何將既有的 REST API 重新設計為符合使用者與 AI 任務特性的 CLI。我們選用 Frankfurter API 作為範例,將它重新封裝成一支 fx CLI。Frankfurter 提供每日匯率、歷史匯率與幣別資料,而且不需要 API key,執行範例前不必設定憑證。
GET /v2/rates?base={base}"es={quote1},{quote2}base(如 USD)與目標幣別 quotes(以逗號分隔,如 TWD,JPY)。GET /v2/rates?base={base}"es={quotes}&date={date}date 參數(格式 YYYY-MM-DD)。GET /v2/currenciesGET /v2/currency/{code}{code}(如 TWD),取得該幣別的詳細名稱。以查指定日期匯率為例,用 curl 查詢 2026 年 7 月 1 日 1 美元可換多少台幣與日圓:
curl --get 'https://api.frankfurter.dev/v2/rates' \
--data-urlencode 'base=USD' \
--data-urlencode 'quotes=TWD,JPY' \
--data-urlencode 'date=2026-07-01'
API 會回傳 JSON 陣列,包含日期、基準幣別、目標幣別與匯率數值:
[
{
"date": "2026-07-01",
"base": "USD",
"quote": "JPY",
"rate": 162.59
},
{
"date": "2026-07-01",
"base": "USD",
"quote": "TWD",
"rate": 31.855
}
]
REST API 的 HTTP method、版本號、路徑和 query parameter 是給程式呼叫的介面。CLI 面對的是終端使用者,命令名稱與參數要重新設計。
如果把 endpoint 直接翻成 Command,會得到這類命令:
fx get-v2-rates --base USD --quotes TWD,JPY
fx get-v2-currencies
這種設計要求使用者記住 HTTP method、API 版本與 query parameter。終端使用者要完成的是「查匯率」、「換算金額」或「查看幣別」,命令應該直接表達這些任務。
重新設計後,fx CLI 使用 rate、convert 和 currency 組成命令樹:
fx
├── rate <base> <quote> [quote...] [--date <date>] # 查詢匯率(支援一次查詢多個目標幣別與歷史日期)
├── convert <amount> <from> <to> [--date <date>] # 換算金額(計算指定金額換算結果)
└── currency # 幣別資源命令組
├── list # 列出所有支援的幣別清單
└── show <code> # 查看單一幣別的詳細資料
每個命令都直接對應一項工作:
fx rate USD TWD # 查 USD 對 TWD 的最新匯率
fx rate USD TWD JPY EUR # 一次查三個目標幣別
fx rate USD TWD --date 2026-07-01 # 查指定日期的匯率
fx convert 100 USD TWD # 把 100 USD 換算成 TWD
fx currency list # 列出支援的幣別
fx currency show TWD # 查看 TWD 的詳細資料
currency list 與 currency show 都在操作幣別資源,因此歸類在 currency 命令組底下。
rate 與 convert 需要的金額與幣別是任務核心,直接用位置參數(positional argument)傳入,免去輸入 --base 或 --from 等 Flag:
rate <base> <quote...>:第一個參數是基準幣別,後續可以帶入一個或多個目標幣別(如 fx rate USD TWD JPY)。convert <amount> <from> <to>:依序傳入金額、來源幣別與目標幣別(如 fx convert 100 USD TWD)。選填條件則使用 Flag:歷史日期非每次必要,因此設計為選填的 --date Flag(格式為 YYYY-MM-DD)。
使用者意圖與背後 REST API 的對應關係:
fx rate USD TWDGET /v2/rates?base=USD"es=TWD
fx rate USD TWD JPY EURGET /v2/rates?base=USD"es=TWD,JPY,EUR
fx rate USD TWD --date 2026-07-01GET /v2/rates?base=USD"es=TWD&date=2026-07-01
fx convert 100 USD TWDGET /v2/rates?base=USD"es=TWD 取得匯率後,由 CLI 計算結果fx currency listGET /v2/currencies
fx currency show TWDGET /v2/currency/TWD
fx 沿用 CLI 架構設計 的責任分工:Command 接收命令列輸入,Service 處理操作規則,Renderer 產生終端機輸出。資料來源改成 REST API 後,需要加入 HTTP Client,負責 request 與 response 的共同處理。
以 fx rate USD TWD JPY --date 2026-07-01 為例,輸入會依序轉換:
Command
base = USD
quotes = [TWD, JPY]
date = 2026-07-01
↓
Service
GET /v2/rates?base=USD"es=TWD,JPY&date=2026-07-01
↓
HTTP Client
發送 request,檢查 status code,decode JSON
↓
[]Rate
HTTP Client 統一組裝 URL、發送 request、檢查 status code 與 decode JSON。Service 只需要提供 path、query parameter 和接收 response 的型別:
// internal/frankfurter/client.go
type Request struct {
Path string
Params url.Values
}
type Client struct {
BaseURL string
HTTPClient *http.Client
}
func (c *Client) Do(ctx context.Context, req Request, out any) error {
endpoint, err := url.Parse(c.BaseURL + req.Path)
if err != nil {
return err
}
endpoint.RawQuery = req.Params.Encode()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return err
}
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return decodeAPIError(resp)
}
return json.NewDecoder(resp.Body).Decode(out)
}
網路連線失敗會由 HTTPClient.Do() 回傳;非 2xx response 交給 decodeAPIError() 解析;成功的 response 則 decode 到 out。這三種結果都沿著 Service 與 Command 往外回傳,最外層再決定錯誤訊息與 Exit Code。
Service 使用與 API JSON 欄位相同的 struct 接收 response。Conversion 則是 CLI 自己產生的結果,不對應 API response:
// internal/frankfurter/service.go
type Rate struct {
Date string `json:"date"`
Base string `json:"base"`
Quote string `json:"quote"`
Rate float64 `json:"rate"`
}
type Conversion struct {
Date string `json:"date"`
From string `json:"from"`
To string `json:"to"`
Amount float64 `json:"amount"`
Rate float64 `json:"rate"`
Result float64 `json:"result"`
}
Rates() 將 base 與 quotes 組成 HTTP query parameter:
func (s *Service) Rates(
ctx context.Context,
base string,
quotes []string,
date string,
) ([]Rate, error) {
params := url.Values{
"base": []string{base},
"quotes": []string{strings.Join(quotes, ",")},
}
if date != "" {
params.Set("date", date)
}
var rates []Rate
err := s.client.Do(ctx, Request{Path: "/v2/rates", Params: params}, &rates)
return rates, err
}
像 convert 這類金額換算功能,Frankfurter API 本身並沒有提供對應的端點。Service 會先呼叫 Rates() 取得兩幣別之間的匯率,再由 CLI 在本地將金額乘上匯率計算出結果。
完成後,進入 cli-sample/rest-api-cli執行命令:
$ go run . rate USD TWD --date 2026-07-01
1 USD = 31.855000 TWD(2026-07-01)
$ go run . convert 100 USD TWD --date 2026-07-01
100.00 USD = 3185.50 TWD(匯率 31.855000,2026-07-01)
$ go run . rate USD TWD --date 2026-07-01 --output json
[{"date":"2026-07-01","base":"USD","quote":"TWD","rate":31.855}]
把 REST API 封裝為 CLI,重點不是逐條將 endpoint 翻成 Command,而是先依使用者任務設計命令介面。Command 解析終端機輸入的 args 與 flags 後,由 Service 依業務邏輯組裝為對應的 API 請求參數(如 path 與 query parameters)。最後再由 HTTP Client 統一處理網路請求、錯誤與 JSON 解析。
如果對本篇範例中出現的 Go 語法不熟悉,以下為相關特性的補充說明:
在 Go 1.18 之後,any 是空介面 interface{} 的別名,可以代表任意型別。當函式需要將 JSON 解碼到呼叫端傳入的 struct 時,通常接收 any 型別的指標:
// out 接收任意 struct 的指標(如 *[]Rate 或 *Conversion)
func (c *Client) Do(ctx context.Context, req Request, out any) error {
// 將 HTTP response 直接解碼填入 out 記憶體位置
return json.NewDecoder(resp.Body).Decode(out)
}
Go 允許在 struct 欄位定義後方加上字串標籤(Struct Tag)。在序列化或反序列化時,encoding/json 套件會透過 Reflection(反射)讀取標籤,將 JSON 鍵名映射至 struct 欄位:
type Rate struct {
// 指定 JSON 中的 "date" 鍵名對應到 Date 欄位
Date string `json:"date"`
Base string `json:"base"`
Rate float64 `json:"rate"`
}
當需要將字串切片 []string 的多個元素以指定分隔符(如逗號 ,)連接成單字串時,標準庫的 strings.Join 是最直覺且效能良好的做法:
quotes := []string{"TWD", "JPY", "EUR"}
// 使用逗號分割連接為 "TWD,JPY,EUR"
joinedQuotes := strings.Join(quotes, ",")