Files
bear-cli/lib/bear_cli/api.ex
alex 2192d067ce feat: 實作 bear apps 指令群 list/show/create/update/rotate-secret/toggle(issue #10)
- Api:新增 /api/v1/apps 端點(apps_list/get/create/update/rotate_secret/toggle,
  401/403/404/422/5xx/網路 分流)
- Apps:指令群實作——client_id → UUID 以 list 解析;一次性 client_secret
  僅於 create/rotate-secret 成功當下輸出;--json 輸出符合 docs/commands.md §3.6;
  visibility/status 過濾由 CLI 本地套用
- CLI:apps 子指令解析(重複選項收集為清單)、本地用法驗證(缺參數/列舉值/
  PKCE 未綁 --jwk-id → 退出碼 2)、help 更新
- 測試:注入 fake API 的單元測試 29 例(解析、輸出、退出碼、UUID 解析、
  401/403/404/422 分流、PKCE rotate)
- 文件:README.md 實作狀態、docs/commands.md §3.6/§9 更新
2026-09-08 20:58:54 +08:00

140 lines
4.7 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
defmodule BearCli.Api do
@moduledoc """
Bear 伺服器 HTTP 介面(一律使用 `Req`)。
目前僅實作 PAT 模式所需的 `GET /userinfo`。
"""
@finch BearCli.Finch
@doc """
呼叫 `GET {issuer}/userinfo`(Bearer token)。
回傳:
- `{:ok, claims}` — 200,claims 為 JSON map
- `{:error, :unauthorized, description}` — 401
- `{:error, :server_error, status}` — 其他非 2xx
- `{:error, :network, reason}` — 傳輸層錯誤
"""
def userinfo(issuer, token) do
url = String.trim_trailing(issuer, "/") <> "/userinfo"
case Req.get(url,
headers: [authorization: "Bearer " <> token, accept: "application/json"],
retry: false,
finch: [name: @finch]
) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, decode_body(body)}
{:ok, %Req.Response{status: 401, body: body}} ->
{:error, :unauthorized, error_description(body)}
{:ok, %Req.Response{status: status}} ->
{:error, :server_error, status}
{:error, exception} ->
{:error, :network, Exception.message(exception)}
end
end
defp decode_body(%{} = body), do: body
defp decode_body(body) when is_binary(body), do: Jason.decode!(body)
defp decode_body(_), do: %{}
defp error_description(body) when is_binary(body) do
case Jason.decode(body) do
{:ok, %{"error_description" => desc}} when is_binary(desc) -> desc
{:ok, %{"error" => err}} when is_binary(err) -> err
_ -> "invalid_token"
end
end
defp error_description(_), do: "invalid_token"
# -- App 管理 API(alterminal/bear#28;PAT Bearer、admin 限定)--
@doc """
`GET /api/v1/apps`:列出 App(分頁)。
`params` 為 keyword(`page:`/`per_page:`,可加 `visibility:`/`status:` 過濾)。
成功回 `{:ok, %{"data" => [apps], "page" => n, "per_page" => n, "total" => n}}`。
"""
def apps_list(issuer, token, params \\ []) do
apps_request(issuer, token, :get, "/api/v1/apps", params: params)
end
@doc "`GET /api/v1/apps/{id}`:單一 App(`client_secret` 永不回傳)。"
def apps_get(issuer, token, id) do
apps_request(issuer, token, :get, "/api/v1/apps/" <> URI.encode(id))
end
@doc """
`POST /api/v1/apps`:建立 App。`method=client_secret` 且未給
`client_secret` 時由伺服器產生,成功回應內含一次性明文
(`%{"data" => app, "client_secret" => secret}`)。
"""
def apps_create(issuer, token, attrs) do
apps_request(issuer, token, :post, "/api/v1/apps", json: attrs)
end
@doc "`PUT /api/v1/apps/{id}`:部分更新(只送有給的欄位)。"
def apps_update(issuer, token, id, attrs) do
apps_request(issuer, token, :put, "/api/v1/apps/" <> URI.encode(id), json: attrs)
end
@doc """
`POST /api/v1/apps/{id}/rotate-secret`:輪轉 client secret,回應內含
一次性明文。PKCE App 回 422 `%{"error" => "pkce_app"}`。
"""
def apps_rotate_secret(issuer, token, id) do
apps_request(issuer, token, :post, "/api/v1/apps/" <> URI.encode(id) <> "/rotate-secret",
json: %{}
)
end
@doc "`POST /api/v1/apps/{id}/toggle`:切換 active ↔ inactive。"
def apps_toggle(issuer, token, id) do
apps_request(issuer, token, :post, "/api/v1/apps/" <> URI.encode(id) <> "/toggle", json: %{})
end
# App 管理 API 共用請求。回傳:
# {:ok, body} | {:error, :unauthorized, desc} | {:error, :forbidden, desc}
# | {:error, :not_found, desc} | {:error, :unprocessable_entity, body}
# | {:error, :server_error, status} | {:error, :network, reason}
defp apps_request(issuer, token, method, path, extra \\ []) do
url = String.trim_trailing(issuer, "/") <> path
opts =
[
headers: [authorization: "Bearer " <> token, accept: "application/json"],
retry: false,
finch: [name: @finch]
]
|> Keyword.merge(extra)
case apply(Req, method, [url, opts]) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, decode_body(body)}
{:ok, %Req.Response{status: 401, body: body}} ->
{:error, :unauthorized, error_description(decode_body(body))}
{:ok, %Req.Response{status: 403, body: body}} ->
{:error, :forbidden, error_description(decode_body(body))}
{:ok, %Req.Response{status: 404, body: body}} ->
{:error, :not_found, error_description(decode_body(body))}
{:ok, %Req.Response{status: 422, body: body}} ->
{:error, :unprocessable_entity, decode_body(body)}
{:ok, %Req.Response{status: status}} ->
{:error, :server_error, status}
{:error, exception} ->
{:error, :network, Exception.message(exception)}
end
end
end