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 更新
This commit is contained in:
@@ -51,4 +51,89 @@ defmodule BearCli.Api do
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user