Packages

Official Elixir SDK for Tripswitch — a circuit breaker management service

Current section

Files

Jump to
tripswitch_ex lib tripswitch admin.ex
Raw

lib/tripswitch/admin.ex

defmodule Tripswitch.Admin do
@moduledoc """
Stateless client for the Tripswitch management API.
Requires an admin API key (`eb_admin_...`). Admin keys are org-scoped and
grant access to all workspaces and projects within the org.
## Quick start
client = Tripswitch.Admin.new(api_key: "eb_admin_...")
{:ok, project} = Tripswitch.Admin.get_project(client, "proj_abc123")
{:ok, breakers} = Tripswitch.Admin.list_breakers(client, "proj_abc123")
## Error handling
All functions return `{:ok, result}` or `{:error, %Tripswitch.Admin.Error{}}`.
Transport failures return `{:error, exception}`.
case Tripswitch.Admin.get_breaker(client, project_id, breaker_id) do
{:ok, breaker} -> breaker
{:error, %Tripswitch.Admin.Error{} = err} ->
if Tripswitch.Admin.Error.not_found?(err), do: nil, else: raise "unexpected error"
{:error, reason} -> raise "transport error: \#{inspect(reason)}"
end
"""
alias Tripswitch.Admin.Error
@default_base_url "https://api.tripswitch.dev"
@enforce_keys [:api_key]
defstruct [:api_key, base_url: @default_base_url]
@type t :: %__MODULE__{
api_key: String.t(),
base_url: String.t()
}
@doc """
Creates a new admin client.
## Options
- `:api_key` — (required) admin API key (`eb_admin_...`)
- `:base_url` — override the default API base URL
"""
@spec new(keyword()) :: t()
def new(opts) do
%__MODULE__{
api_key: Keyword.fetch!(opts, :api_key),
base_url: Keyword.get(opts, :base_url, @default_base_url)
}
end
# ---------------------------------------------------------------------------
# Workspaces
# ---------------------------------------------------------------------------
@doc "Lists all workspaces for the authenticated org."
@spec list_workspaces(t()) :: {:ok, list(map())} | {:error, Error.t() | term()}
def list_workspaces(client) do
with {:ok, body} <- request(client, :get, "/v1/workspaces") do
{:ok, Map.get(body, "workspaces", [])}
end
end
@doc "Creates a new workspace."
@spec create_workspace(t(), map()) :: {:ok, map()} | {:error, Error.t() | term()}
def create_workspace(client, params) do
request(client, :post, "/v1/workspaces", body: params)
end
@doc "Retrieves a workspace by ID."
@spec get_workspace(t(), String.t()) :: {:ok, map()} | {:error, Error.t() | term()}
def get_workspace(client, workspace_id) do
request(client, :get, "/v1/workspaces/#{workspace_id}")
end
@doc "Updates a workspace's settings."
@spec update_workspace(t(), String.t(), map()) :: {:ok, map()} | {:error, Error.t() | term()}
def update_workspace(client, workspace_id, params) do
request(client, :patch, "/v1/workspaces/#{workspace_id}", body: params)
end
@doc "Deletes a workspace by ID."
@spec delete_workspace(t(), String.t()) :: :ok | {:error, Error.t() | term()}
def delete_workspace(client, workspace_id) do
with {:ok, _} <- request(client, :delete, "/v1/workspaces/#{workspace_id}"), do: :ok
end
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@doc """
Lists all projects for the authenticated org.
## Options
- `:workspace_id` — filter by workspace
"""
@spec list_projects(t(), keyword()) :: {:ok, list(map())} | {:error, Error.t() | term()}
def list_projects(client, opts \\ []) do
params = if id = opts[:workspace_id], do: %{workspace_id: id}, else: %{}
with {:ok, body} <- request(client, :get, "/v1/projects", params: params) do
{:ok, Map.get(body, "projects", [])}
end
end
@doc "Creates a new project."
@spec create_project(t(), map()) :: {:ok, map()} | {:error, Error.t() | term()}
def create_project(client, params) do
request(client, :post, "/v1/projects", body: params)
end
@doc "Retrieves a project by ID."
@spec get_project(t(), String.t()) :: {:ok, map()} | {:error, Error.t() | term()}
def get_project(client, project_id) do
request(client, :get, "/v1/projects/#{project_id}")
end
@doc "Updates a project's settings."
@spec update_project(t(), String.t(), map()) :: {:ok, map()} | {:error, Error.t() | term()}
def update_project(client, project_id, params) do
request(client, :patch, "/v1/projects/#{project_id}", body: params)
end
@doc """
Deletes a project by ID.
Requires `confirm_name:` to match the project's actual name. This safety
guard prevents accidental deletion by verifying the name before sending
the DELETE request.
Tripswitch.Admin.delete_project(client, "proj_123", confirm_name: "prod-payments")
"""
@spec delete_project(t(), String.t(), keyword()) :: :ok | {:error, Error.t() | term()}
def delete_project(client, project_id, opts \\ []) do
confirm =
opts[:confirm_name] || raise ArgumentError, ":confirm_name is required for delete_project"
with {:ok, project} <- get_project(client, project_id),
:ok <- verify_project_name(project, confirm),
{:ok, _} <- request(client, :delete, "/v1/projects/#{project_id}") do
:ok
end
end
@doc "Rotates the ingest secret for a project. Returns the new secret string."
@spec rotate_ingest_secret(t(), String.t()) ::
{:ok, String.t()} | {:error, Error.t() | term()}
def rotate_ingest_secret(client, project_id) do
with {:ok, body} <- request(client, :post, "/v1/projects/#{project_id}/ingest_secret/rotate") do
{:ok, body["ingest_secret"]}
end
end
# ---------------------------------------------------------------------------
# Breakers
# ---------------------------------------------------------------------------
@doc """
Lists all breakers for a project.
## Options
- `:cursor` — pagination cursor
- `:limit` — maximum number of results
"""
@spec list_breakers(t(), String.t(), keyword()) ::
{:ok, list(map())} | {:error, Error.t() | term()}
def list_breakers(client, project_id, opts \\ []) do
params = build_list_params(opts)
with {:ok, body} <-
request(client, :get, "/v1/projects/#{project_id}/breakers", params: params) do
{:ok, Map.get(body, "breakers", [])}
end
end
@doc "Creates a new breaker."
@spec create_breaker(t(), String.t(), map()) :: {:ok, map()} | {:error, Error.t() | term()}
def create_breaker(client, project_id, params) do
with {:ok, body} <-
request(client, :post, "/v1/projects/#{project_id}/breakers", body: params) do
{:ok, flatten_breaker(body)}
end
end
@doc "Retrieves a specific breaker."
@spec get_breaker(t(), String.t(), String.t()) ::
{:ok, map()} | {:error, Error.t() | term()}
def get_breaker(client, project_id, breaker_id) do
with {:ok, body} <-
request(client, :get, "/v1/projects/#{project_id}/breakers/#{breaker_id}") do
{:ok, flatten_breaker(body)}
end
end
@doc "Updates a breaker's configuration."
@spec update_breaker(t(), String.t(), String.t(), map()) ::
{:ok, map()} | {:error, Error.t() | term()}
def update_breaker(client, project_id, breaker_id, params) do
with {:ok, body} <-
request(client, :patch, "/v1/projects/#{project_id}/breakers/#{breaker_id}",
body: params
) do
{:ok, flatten_breaker(body)}
end
end
@doc "Deletes a breaker."
@spec delete_breaker(t(), String.t(), String.t()) :: :ok | {:error, Error.t() | term()}
def delete_breaker(client, project_id, breaker_id) do
with {:ok, _} <-
request(client, :delete, "/v1/projects/#{project_id}/breakers/#{breaker_id}"),
do: :ok
end
@doc "Bulk-replaces all breakers for a project."
@spec sync_breakers(t(), String.t(), list(map())) ::
{:ok, list(map())} | {:error, Error.t() | term()}
def sync_breakers(client, project_id, breakers) do
request(client, :put, "/v1/projects/#{project_id}/breakers", body: %{breakers: breakers})
end
@doc "Retrieves the current state of a single breaker."
@spec get_breaker_state(t(), String.t(), String.t()) ::
{:ok, map()} | {:error, Error.t() | term()}
def get_breaker_state(client, project_id, breaker_id) do
request(client, :get, "/v1/projects/#{project_id}/breakers/#{breaker_id}/state")
end
@doc """
Retrieves states for multiple breakers in a single request.
Pass either `breaker_ids: [...]` or `router_id: "..."` in params.
"""
@spec batch_get_breaker_states(t(), String.t(), map()) ::
{:ok, list(map())} | {:error, Error.t() | term()}
def batch_get_breaker_states(client, project_id, params) do
request(client, :post, "/v1/projects/#{project_id}/breakers/state:batch", body: params)
end
# ---------------------------------------------------------------------------
# Routers
# ---------------------------------------------------------------------------
@doc """
Lists all routers for a project.
## Options
- `:cursor` — pagination cursor
- `:limit` — maximum number of results
"""
@spec list_routers(t(), String.t(), keyword()) ::
{:ok, list(map())} | {:error, Error.t() | term()}
def list_routers(client, project_id, opts \\ []) do
params = build_list_params(opts)
with {:ok, body} <-
request(client, :get, "/v1/projects/#{project_id}/routers", params: params) do
{:ok, Map.get(body, "routers", [])}
end
end
@doc "Creates a new router."
@spec create_router(t(), String.t(), map()) :: {:ok, map()} | {:error, Error.t() | term()}
def create_router(client, project_id, params) do
request(client, :post, "/v1/projects/#{project_id}/routers", body: params)
end
@doc "Retrieves a specific router."
@spec get_router(t(), String.t(), String.t()) ::
{:ok, map()} | {:error, Error.t() | term()}
def get_router(client, project_id, router_id) do
request(client, :get, "/v1/projects/#{project_id}/routers/#{router_id}")
end
@doc "Updates a router's configuration."
@spec update_router(t(), String.t(), String.t(), map()) ::
{:ok, map()} | {:error, Error.t() | term()}
def update_router(client, project_id, router_id, params) do
request(client, :patch, "/v1/projects/#{project_id}/routers/#{router_id}", body: params)
end
@doc "Deletes a router. The router must have no linked breakers."
@spec delete_router(t(), String.t(), String.t()) :: :ok | {:error, Error.t() | term()}
def delete_router(client, project_id, router_id) do
with {:ok, _} <- request(client, :delete, "/v1/projects/#{project_id}/routers/#{router_id}"),
do: :ok
end
@doc "Links a breaker to a router."
@spec link_breaker(t(), String.t(), String.t(), String.t()) ::
:ok | {:error, Error.t() | term()}
def link_breaker(client, project_id, router_id, breaker_id) do
with {:ok, _} <-
request(client, :post, "/v1/projects/#{project_id}/routers/#{router_id}/breakers",
body: %{breaker_id: breaker_id}
),
do: :ok
end
@doc "Removes a breaker from a router."
@spec unlink_breaker(t(), String.t(), String.t(), String.t()) ::
:ok | {:error, Error.t() | term()}
def unlink_breaker(client, project_id, router_id, breaker_id) do
with {:ok, _} <-
request(
client,
:delete,
"/v1/projects/#{project_id}/routers/#{router_id}/breakers/#{breaker_id}"
),
do: :ok
end
# ---------------------------------------------------------------------------
# Notification channels
# ---------------------------------------------------------------------------
@doc """
Lists all notification channels for a project.
## Options
- `:cursor` — pagination cursor
- `:limit` — maximum number of results
"""
@spec list_notification_channels(t(), String.t(), keyword()) ::
{:ok, %{channels: list(map()), next_cursor: String.t() | nil}}
| {:error, Error.t() | term()}
def list_notification_channels(client, project_id, opts \\ []) do
params = build_list_params(opts)
with {:ok, body} <-
request(client, :get, "/v1/projects/#{project_id}/notification-channels",
params: params
) do
{:ok, %{channels: Map.get(body, "channels", []), next_cursor: body["next_cursor"]}}
end
end
@doc "Creates a new notification channel."
@spec create_notification_channel(t(), String.t(), map()) ::
{:ok, map()} | {:error, Error.t() | term()}
def create_notification_channel(client, project_id, params) do
request(client, :post, "/v1/projects/#{project_id}/notification-channels", body: params)
end
@doc "Retrieves a specific notification channel."
@spec get_notification_channel(t(), String.t(), String.t()) ::
{:ok, map()} | {:error, Error.t() | term()}
def get_notification_channel(client, project_id, channel_id) do
request(client, :get, "/v1/projects/#{project_id}/notification-channels/#{channel_id}")
end
@doc "Updates a notification channel's configuration."
@spec update_notification_channel(t(), String.t(), String.t(), map()) ::
{:ok, map()} | {:error, Error.t() | term()}
def update_notification_channel(client, project_id, channel_id, params) do
request(client, :patch, "/v1/projects/#{project_id}/notification-channels/#{channel_id}",
body: params
)
end
@doc "Deletes a notification channel."
@spec delete_notification_channel(t(), String.t(), String.t()) ::
:ok | {:error, Error.t() | term()}
def delete_notification_channel(client, project_id, channel_id) do
with {:ok, _} <-
request(
client,
:delete,
"/v1/projects/#{project_id}/notification-channels/#{channel_id}"
),
do: :ok
end
@doc "Sends a test notification to a channel."
@spec test_notification_channel(t(), String.t(), String.t()) ::
:ok | {:error, Error.t() | term()}
def test_notification_channel(client, project_id, channel_id) do
with {:ok, _} <-
request(
client,
:post,
"/v1/projects/#{project_id}/notification-channels/#{channel_id}/test"
),
do: :ok
end
# ---------------------------------------------------------------------------
# Events
# ---------------------------------------------------------------------------
@doc """
Lists state transition events for a project.
## Options
- `:breaker_id` — filter by breaker
- `:start_time` — filter events after this `DateTime`
- `:end_time` — filter events before this `DateTime`
- `:cursor` — pagination cursor
- `:limit` — maximum number of results
"""
@spec list_events(t(), String.t(), keyword()) ::
{:ok, %{events: list(map()), next_cursor: String.t() | nil}}
| {:error, Error.t() | term()}
def list_events(client, project_id, opts \\ []) do
params =
[]
|> maybe_put(:breaker_id, opts[:breaker_id])
|> maybe_put(:start_time, opts[:start_time] && DateTime.to_iso8601(opts[:start_time]))
|> maybe_put(:end_time, opts[:end_time] && DateTime.to_iso8601(opts[:end_time]))
|> maybe_put(:cursor, opts[:cursor])
|> maybe_put(:limit, opts[:limit])
|> Map.new()
with {:ok, body} <-
request(client, :get, "/v1/projects/#{project_id}/events", params: params) do
{:ok, %{events: Map.get(body, "events", []), next_cursor: body["next_cursor"]}}
end
end
# ---------------------------------------------------------------------------
# Project keys
# ---------------------------------------------------------------------------
@doc "Lists all project API keys."
@spec list_project_keys(t(), String.t()) ::
{:ok, list(map())} | {:error, Error.t() | term()}
def list_project_keys(client, project_id) do
with {:ok, body} <- request(client, :get, "/v1/projects/#{project_id}/keys") do
{:ok, Map.get(body, "keys", [])}
end
end
@doc """
Creates a new project API key.
The returned map includes a `"key"` field containing the full `eb_pk_...` value.
This is the only time the key is returned — store it securely.
"""
@spec create_project_key(t(), String.t(), map()) ::
{:ok, map()} | {:error, Error.t() | term()}
def create_project_key(client, project_id, params \\ %{}) do
request(client, :post, "/v1/projects/#{project_id}/keys", body: params)
end
@doc "Revokes a project API key. Once revoked, the key cannot be used for authentication."
@spec delete_project_key(t(), String.t(), String.t()) :: :ok | {:error, Error.t() | term()}
def delete_project_key(client, project_id, key_id) do
with {:ok, _} <- request(client, :delete, "/v1/projects/#{project_id}/keys/#{key_id}"),
do: :ok
end
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
defp request(client, method, path, opts \\ []) do
url = client.base_url <> path
headers = [
{"authorization", "Bearer #{client.api_key}"},
{"accept", "application/json"}
]
req_opts =
[headers: headers, retry: false]
|> maybe_put_req(:params, opts[:params])
|> maybe_put_req(:json, opts[:body])
case dispatch(method, url, req_opts) do
{:ok, %{status: s, body: body}} when s in 200..299 ->
{:ok, body || %{}}
{:ok, %{status: s} = resp} ->
{:error, parse_error(s, resp)}
{:error, reason} ->
{:error, reason}
end
end
defp dispatch(:get, url, opts), do: Req.get(url, opts)
defp dispatch(:post, url, opts), do: Req.post(url, opts)
defp dispatch(:put, url, opts), do: Req.put(url, opts)
defp dispatch(:patch, url, opts), do: Req.patch(url, opts)
defp dispatch(:delete, url, opts), do: Req.delete(url, opts)
defp parse_error(status, resp) do
body = resp.body || %{}
%Error{
status: status,
code: body["code"],
message: body["message"] || default_message(status),
request_id: get_header(resp, "x-request-id"),
retry_after: parse_retry_after(get_header(resp, "retry-after"))
}
end
defp get_header(resp, name) do
case Enum.find(resp.headers, fn {k, _} -> String.downcase(k) == name end) do
{_, v} -> v
nil -> nil
end
end
defp parse_retry_after(nil), do: nil
defp parse_retry_after(value) do
case Integer.parse(value) do
{n, _} -> n
:error -> nil
end
end
@http_status_messages %{
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
409 => "Conflict",
422 => "Unprocessable Entity",
429 => "Too Many Requests",
500 => "Internal Server Error"
}
defp default_message(status), do: Map.get(@http_status_messages, status, "HTTP #{status}")
defp verify_project_name(%{"name" => name}, name), do: :ok
defp verify_project_name(%{"name" => actual}, confirm) do
{:error,
%Error{
status: 0,
message:
"confirmation name #{inspect(confirm)} does not match project name #{inspect(actual)}"
}}
end
# Merges router_ids from wrapper envelope into the breaker map
defp flatten_breaker(%{"breaker" => breaker} = body) do
case body["router_ids"] do
nil -> breaker
router_ids -> Map.put(breaker, "router_ids", router_ids)
end
end
defp flatten_breaker(body), do: body
defp build_list_params(opts) do
[]
|> maybe_put(:cursor, opts[:cursor])
|> maybe_put(:limit, opts[:limit])
|> Map.new()
end
defp maybe_put(list, _key, nil), do: list
defp maybe_put(list, key, value), do: [{key, value} | list]
defp maybe_put_req(opts, _key, nil), do: opts
defp maybe_put_req(opts, key, value), do: Keyword.put(opts, key, value)
end