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