defmodule Trogon.Error do alias Trogon.Error.Metadata import Trogon.Error.Metadata, only: [is_empty_metadata: 1] @options_schema NimbleOptions.new!( domain: [ type: :string, required: true, doc: "The error domain identifying the service or component that generated the error" ], reason: [ type: :string, required: true, doc: "A unique identifier for the specific error within the domain" ], message: [ type: {:or, [:atom, :string]}, required: false, doc: "The error message, either an atom that maps to a standard message or a custom string" ], metadata: [ type: {:map, :string, {:or, [:string, {:tuple, [:string, {:in, [:internal, :private, :public]}]}]}}, default: %{}, doc: "Default metadata to be merged with runtime metadata. Values will be automatically converted to MetadataValue structs." ], code: [ type: {:in, [ :cancelled, :unknown, :invalid_argument, :deadline_exceeded, :not_found, :already_exists, :permission_denied, :unauthenticated, :resource_exhausted, :failed_precondition, :aborted, :out_of_range, :unimplemented, :internal, :unavailable, :data_loss ]}, default: :unknown, doc: "The standard error code" ], visibility: [ type: {:in, [:internal, :private, :public]}, default: :internal, doc: "Whether the error should be visible to end users or kept internal" ], help: [ type: :map, required: false, keys: [ links: [ type: {:list, {:map, [description: [type: :string, required: false], url: [type: :string, required: true]]}} ] ], doc: "Help information with links to documentation" ] ) @moduledoc """ Universal Error Specification implementation for Elixir. This module provides a `use` macro to define structured exceptions following the Universal Error Specification ADR. ## Usage defmodule MyApp.NotFoundError do use Trogon.Error, domain: "com.myapp.mydomain", reason: "not_found", message: "The {resource} was not found" end ## Creating Errors MyApp.NotFoundError.new!( metadata: Trogon.Error.Metadata.new(%{resource: "user"}) ) """ # Error codes as per the spec @type code :: :cancelled | :unknown | :invalid_argument | :deadline_exceeded | :not_found | :already_exists | :permission_denied | :unauthenticated | :resource_exhausted | :failed_precondition | :aborted | :out_of_range | :unimplemented | :internal | :unavailable | :data_loss @type visibility :: :internal | :private | :public @type metadata :: Metadata.t() @type subject :: String.t() @type source_id :: String.t() @type id :: String.t() @type time :: DateTime.t() @type help_link :: %{ description: String.t(), url: String.t() } @type help :: %{ links: list(help_link()) } @type debug_info :: %{ stack_entries: list(String.t()), detail: String.t() } @type localized_message :: %{ locale: String.t(), message: String.t() } @type retry_info_duration :: %{retry_offset: Duration.t()} # TODO: Enable retry info time aftr https://github.com/aip-dev/google.aip.dev/issues/1528 is resolved # To avoid breaking changes, and strong incompatibility with Google RPC spec, we're not adding retry info time. # @type retry_info_time :: %{retry_time: DateTime.t()} @type retry_info :: retry_info_duration() @type t(struct) :: %{ __struct__: struct, __exception__: true, specversion: non_neg_integer(), code: code(), message: String.t(), domain: String.t(), reason: String.t(), metadata: metadata(), causes: list(t(module())), visibility: visibility(), subject: subject() | nil, id: id() | nil, time: time() | nil, help: help() | nil, debug_info: debug_info() | nil, localized_message: localized_message() | nil, retry_info: retry_info() | nil, source_id: source_id() | nil } @type error_opts :: [ {:metadata, metadata()} | {:causes, list(t(module()))} | {:subject, subject() | nil} | {:debug_info, debug_info() | nil} | {:localized_message, localized_message() | nil} | {:retry_info, retry_info() | nil} | {:id, id() | nil} | {:time, time() | nil} | {:source_id, source_id() | nil} ] @spec_version 1 @doc """ Defines an error module with the given options. ## Options #{NimbleOptions.docs(@options_schema)} """ defmacro __using__(opts) do quote bind_quoted: [opts: opts] do alias Trogon.Error opts = Error.validate_options!(opts) compiled_opts = [code: :unknown, visibility: :internal, help: nil] |> Keyword.merge(opts) |> Keyword.update!(:help, &Macro.escape/1) |> Keyword.update!(:metadata, &Error._compile_metadata(&1)) |> then(&Keyword.put_new(&1, :message, &1[:code])) |> Keyword.update!(:message, &Error.to_msg/1) @derive {Inspect, except: [:__trogon_error__]} @enforce_keys [:specversion, :code, :message, :domain, :reason, :metadata] defexception [ :__trogon_error__, :specversion, :code, :message, :domain, :reason, :metadata, :causes, :visibility, :subject, :id, :time, :help, :debug_info, :localized_message, :retry_info, :source_id ] @doc """ Raises an error with the given options. > #### When to use `exception/1` {: .info} > Use `exception/1` when you want to raise an error. > > Example: > > ```elixir > raise MyApp.NotFoundError, metadata: Trogon.Error.Metadata.new(%{resource: "user"}) > ``` > > Otherwise, use `new!/1` to create an error instance. """ @impl Exception @spec exception(Trogon.Error.error_opts()) :: Trogon.Error.t(__MODULE__) def exception(opts \\ []) do Error._exception(__MODULE__, unquote(compiled_opts), opts) end @doc """ Creates a new error instance with the given options. """ @spec new!(Trogon.Error.error_opts()) :: Trogon.Error.t(__MODULE__) def new!(opts \\ []) when is_list(opts) do Error._exception(__MODULE__, unquote(compiled_opts), opts) end @impl Exception def message(%__MODULE__{} = error) do Error._message(error) end end end def _message(error) do help = (error.help || %{}) |> Map.get(:links, []) |> Enum.map_join("\n", fn link -> "- #{link.description}: #{link.url}" end) info_msg = "" |> put_key_msg_line(" visibility", error.visibility) |> put_key_msg_line(" domain", error.domain) |> put_key_msg_line(" reason", error.reason) |> put_key_msg_line(" code", error.code) |> put_key_msg_line(" metadata", error.metadata) |> put_key_msg_line(" id", error.id) |> put_key_msg_line(" time", error.time) |> put_key_msg_line(" subject", error.subject) |> put_key_msg_line(" source_id", error.source_id) |> put_key_msg_line(" retry_info", error.retry_info) |> put_key_msg_line(" debug_info", error.debug_info) |> put_help_msg_line(help) String.trim_trailing(error.message) <> info_msg end defp put_key_msg_line(msg, _key, nil), do: msg defp put_key_msg_line(msg, _key, ""), do: msg defp put_key_msg_line(msg, _key, map) when is_map(map) and map_size(map) == 0, do: msg defp put_key_msg_line(msg, _key, metadata) when is_empty_metadata(metadata), do: msg defp put_key_msg_line(msg, key, %Metadata{} = metadata), do: "#{msg}\n#{key}:\n#{pretty_print(metadata)}" defp put_key_msg_line(msg, key, value), do: "#{msg}\n#{key}: #{pretty_print(value)}" defp put_help_msg_line(msg, ""), do: msg defp put_help_msg_line(msg, help), do: "#{msg}\n#{help}" defp pretty_print(term) when is_binary(term), do: term defp pretty_print(%Metadata{entries: entries}) do Enum.map_join(entries, "\n", fn {key, value} -> " - #{key}: #{value.value} visibility=#{value.visibility}" end) end defp pretty_print(term), do: inspect(term, pretty: true) def _exception(struct_module, compile_opts, opts \\ []) do domain = Keyword.fetch!(compile_opts, :domain) reason = Keyword.fetch!(compile_opts, :reason) code = Keyword.fetch!(compile_opts, :code) message = Keyword.fetch!(compile_opts, :message) visibility = Keyword.fetch!(compile_opts, :visibility) help = Keyword.get(compile_opts, :help) compile_metadata = Keyword.fetch!(compile_opts, :metadata) runtime_metadata = opts |> Keyword.get(:metadata, Metadata.new()) |> to_metadata() metadata = Metadata.merge(compile_metadata, runtime_metadata) causes = Keyword.get(opts, :causes, []) subject = Keyword.get(opts, :subject) debug_info = Keyword.get(opts, :debug_info) localized_message = Keyword.get(opts, :localized_message) retry_info = Keyword.get(opts, :retry_info) id = Keyword.get(opts, :id) time = Keyword.get(opts, :time) source_id = Keyword.get(opts, :source_id) struct(struct_module, %{ __trogon_error__: true, specversion: @spec_version, code: code, message: message, domain: domain, reason: reason, metadata: metadata, causes: causes, visibility: visibility, subject: subject, id: id, time: time, help: help, debug_info: debug_info, localized_message: localized_message, retry_info: retry_info, source_id: source_id }) end defp to_metadata(%Metadata{} = metadata), do: metadata defp to_metadata(metadata) when is_map(metadata), do: Metadata.new(metadata) @doc """ Validates compile-time options using NimbleOptions. ## Examples iex> opts = [domain: "com.test", reason: "TEST"] iex> validated = Trogon.Error.validate_options!(opts) iex> validated[:domain] "com.test" iex> validated[:reason] "TEST" """ @spec validate_options!(keyword()) :: keyword() def validate_options!(opts) do NimbleOptions.validate!(opts, @options_schema) end defdelegate metadata, to: Metadata, as: :new @doc """ Converts an atom to an integer code. ## Examples iex> Trogon.Error.to_code_int(:cancelled) 1 iex> err = TestSupport.InvalidCurrencyError.new!() ...> Trogon.Error.to_code_int(err) 2 """ @spec to_code_int(atom() | t(module())) :: non_neg_integer() def to_code_int(%{code: code}), do: to_code_int(code) def to_code_int(:cancelled), do: 1 def to_code_int(:unknown), do: 2 def to_code_int(:invalid_argument), do: 3 def to_code_int(:deadline_exceeded), do: 4 def to_code_int(:not_found), do: 5 def to_code_int(:already_exists), do: 6 def to_code_int(:permission_denied), do: 7 def to_code_int(:resource_exhausted), do: 8 def to_code_int(:failed_precondition), do: 9 def to_code_int(:aborted), do: 10 def to_code_int(:out_of_range), do: 11 def to_code_int(:unimplemented), do: 12 def to_code_int(:internal), do: 13 def to_code_int(:unavailable), do: 14 def to_code_int(:data_loss), do: 15 def to_code_int(:unauthenticated), do: 16 @spec to_msg(atom() | String.t()) :: String.t() def to_msg(msg) when is_binary(msg), do: msg def to_msg(:cancelled), do: "the operation was cancelled" def to_msg(:unknown), do: "unknown error" def to_msg(:invalid_argument), do: "invalid argument provided" def to_msg(:deadline_exceeded), do: "deadline exceeded" def to_msg(:not_found), do: "resource not found" def to_msg(:already_exists), do: "resource already exists" def to_msg(:permission_denied), do: "permission denied" def to_msg(:unauthenticated), do: "unauthenticated" def to_msg(:resource_exhausted), do: "resource exhausted" def to_msg(:failed_precondition), do: "failed precondition" def to_msg(:aborted), do: "operation aborted" def to_msg(:out_of_range), do: "out of range" def to_msg(:unimplemented), do: "not implemented" def to_msg(:internal), do: "internal error" def to_msg(:unavailable), do: "service unavailable" def to_msg(:data_loss), do: "data loss or corruption" @doc """ Checks if a term is a Trogon error. ## Examples iex> error = TestSupport.TestError.new!() iex> require Trogon.Error iex> Trogon.Error.is_trogon_error?(error) true iex> require Trogon.Error iex> Trogon.Error.is_trogon_error?(%{}) false """ defguard is_trogon_error?(term) when is_map(term) and is_map_key(term, :__trogon_error__) and :erlang.map_get(:__trogon_error__, term) == true def _compile_metadata(metadata) when is_map(metadata) do metadata |> Metadata.new() |> Macro.escape() end end