├── .check.exs ├── .credo.exs ├── .doctor.exs ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── documentation └── tutorials │ └── get-started-with-splode.md ├── lib ├── splode.ex └── splode │ ├── error.ex │ ├── error_class.ex │ ├── stacktrace.ex │ └── unknown.ex ├── mix.exs ├── mix.lock └── test ├── splode └── error_test.exs ├── splode_test.exs └── test_helper.exs /.check.exs: -------------------------------------------------------------------------------- 1 | [ 2 | ## all available options with default values (see `mix check` docs for description) 3 | # parallel: true, 4 | # skipped: true, 5 | 6 | ## list of tools (see `mix check` docs for defaults) 7 | tools: [ 8 | ## curated tools may be disabled (e.g. the check for compilation warnings) 9 | # {:compiler, false}, 10 | 11 | ## ...or adjusted (e.g. use one-line formatter for more compact credo output) 12 | # {:credo, "mix credo --format oneline"}, 13 | {:doctor, false} 14 | ] 15 | ] 16 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: [ 68 | # 69 | ## Consistency Checks 70 | # 71 | {Credo.Check.Consistency.ExceptionNames, []}, 72 | {Credo.Check.Consistency.LineEndings, []}, 73 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 74 | # This check was erroring on sigils so I had to disable it 75 | {Credo.Check.Consistency.SpaceAroundOperators, false}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | 80 | # 81 | ## Design Checks 82 | # 83 | # You can customize the priority of any check 84 | # Priority values are: `low, normal, high, higher` 85 | # 86 | {Credo.Check.Design.AliasUsage, false}, 87 | # You can also customize the exit_status of each check. 88 | # If you don't want TODO comments to cause `mix credo` to fail, just 89 | # set this value to 0 (zero). 90 | # 91 | {Credo.Check.Design.TagTODO, false}, 92 | {Credo.Check.Design.TagFIXME, []}, 93 | 94 | # 95 | ## Readability Checks 96 | # 97 | {Credo.Check.Readability.AliasOrder, []}, 98 | {Credo.Check.Readability.FunctionNames, []}, 99 | {Credo.Check.Readability.LargeNumbers, []}, 100 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 101 | {Credo.Check.Readability.ModuleAttributeNames, []}, 102 | {Credo.Check.Readability.ModuleDoc, []}, 103 | {Credo.Check.Readability.ModuleNames, []}, 104 | {Credo.Check.Readability.ParenthesesInCondition, false}, 105 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 106 | {Credo.Check.Readability.PredicateFunctionNames, false}, 107 | {Credo.Check.Readability.PreferImplicitTry, []}, 108 | {Credo.Check.Readability.RedundantBlankLines, []}, 109 | {Credo.Check.Readability.Semicolons, []}, 110 | {Credo.Check.Readability.SpaceAfterCommas, []}, 111 | {Credo.Check.Readability.StringSigils, []}, 112 | {Credo.Check.Readability.TrailingBlankLine, []}, 113 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 114 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 115 | {Credo.Check.Readability.VariableNames, []}, 116 | 117 | # 118 | ## Refactoring Opportunities 119 | # 120 | {Credo.Check.Refactor.CondStatements, []}, 121 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 122 | {Credo.Check.Refactor.FunctionArity, false}, 123 | {Credo.Check.Refactor.LongQuoteBlocks, false}, 124 | {Credo.Check.Refactor.MapInto, false}, 125 | {Credo.Check.Refactor.MatchInCondition, false}, 126 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 127 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 128 | {Credo.Check.Refactor.Nesting, [max_nesting: 10]}, 129 | {Credo.Check.Refactor.UnlessWithElse, []}, 130 | {Credo.Check.Refactor.WithClauses, []}, 131 | 132 | # 133 | ## Warnings 134 | # 135 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 136 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 137 | {Credo.Check.Warning.IExPry, []}, 138 | {Credo.Check.Warning.IoInspect, []}, 139 | {Credo.Check.Warning.LazyLogging, false}, 140 | {Credo.Check.Warning.MixEnv, false}, 141 | {Credo.Check.Warning.OperationOnSameValues, []}, 142 | {Credo.Check.Warning.OperationWithConstantResult, []}, 143 | {Credo.Check.Warning.RaiseInsideRescue, []}, 144 | {Credo.Check.Warning.UnusedEnumOperation, []}, 145 | {Credo.Check.Warning.UnusedFileOperation, []}, 146 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 147 | {Credo.Check.Warning.UnusedListOperation, []}, 148 | {Credo.Check.Warning.UnusedPathOperation, []}, 149 | {Credo.Check.Warning.UnusedRegexOperation, []}, 150 | {Credo.Check.Warning.UnusedStringOperation, []}, 151 | {Credo.Check.Warning.UnusedTupleOperation, []}, 152 | {Credo.Check.Warning.UnsafeExec, []}, 153 | 154 | # 155 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 156 | 157 | # 158 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 159 | # 160 | # {Credo.Check.Readability.StrictModuleLayout, 161 | # order: [:shortdoc, :moduledoc, :behaviour, :use, :defstruct, :type, :import, :alias, :require], 162 | # ignore: [:module_attribute, :type]}, 163 | {Credo.Check.Readability.StrictModuleLayout, false}, 164 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 165 | {Credo.Check.Consistency.UnusedVariableNames, false}, 166 | {Credo.Check.Design.DuplicatedCode, false}, 167 | {Credo.Check.Readability.AliasAs, false}, 168 | {Credo.Check.Readability.MultiAlias, false}, 169 | {Credo.Check.Readability.Specs, false}, 170 | {Credo.Check.Readability.SinglePipe, false}, 171 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 172 | {Credo.Check.Refactor.ABCSize, false}, 173 | {Credo.Check.Refactor.Apply, false}, 174 | {Credo.Check.Refactor.AppendSingleItem, false}, 175 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 176 | {Credo.Check.Refactor.ModuleDependencies, false}, 177 | {Credo.Check.Refactor.NegatedIsNil, false}, 178 | {Credo.Check.Refactor.PipeChainStart, false}, 179 | {Credo.Check.Refactor.VariableRebinding, false}, 180 | {Credo.Check.Warning.LeakyEnvironment, false}, 181 | {Credo.Check.Warning.MapGetUnsafePass, false}, 182 | {Credo.Check.Warning.UnsafeToAtom, false} 183 | 184 | # 185 | # Custom checks can be created using `mix credo.gen.check`. 186 | # 187 | ] 188 | } 189 | ] 190 | } 191 | -------------------------------------------------------------------------------- /.doctor.exs: -------------------------------------------------------------------------------- 1 | %Doctor.Config{ 2 | ignore_modules: [ ], 3 | ignore_paths: [], 4 | min_module_doc_coverage: 40, 5 | min_module_spec_coverage: 0, 6 | min_overall_doc_coverage: 48, 7 | min_overall_moduledoc_coverage: 100, 8 | min_overall_spec_coverage: 0, 9 | exception_moduledoc_required: true, 10 | raise: false, 11 | reporter: Doctor.Reporters.Full, 12 | struct_type_spec_required: true, 13 | umbrella: false, 14 | failed: false 15 | } 16 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | versioning-strategy: lockfile-only 6 | schedule: 7 | interval: "daily" 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Ash CI 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | jobs: 10 | ash-ci: 11 | uses: ash-project/ash/.github/workflows/ash-ci.yml@main 12 | with: 13 | spark-formatter: false 14 | spark-cheat-sheets: false 15 | secrets: 16 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | splode-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 26.0.2 2 | elixir 1.18.4 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](Https://conventionalcommits.org) for commit guidelines. 5 | 6 | 7 | 8 | ## [v0.2.9](https://github.com/ash-project/splode/compare/v0.2.8...v0.2.9) (2025-02-28) 9 | 10 | 11 | 12 | 13 | ### Improvements: 14 | 15 | * show docs for exception function & keys in doc string 16 | 17 | ## [v0.2.8](https://github.com/ash-project/splode/compare/v0.2.7...v0.2.8) (2025-01-27) 18 | 19 | 20 | 21 | 22 | ### Improvements: 23 | 24 | * accept raw stacktraces in exception 25 | 26 | ## [v0.2.7](https://github.com/ash-project/splode/compare/v0.2.6...v0.2.7) (2024-10-30) 27 | 28 | 29 | 30 | 31 | ### Bug Fixes: 32 | 33 | * remove IO.inspect 34 | 35 | ## [v0.2.6](https://github.com/ash-project/splode/compare/v0.2.5...v0.2.6) (2024-10-30) 36 | 37 | 38 | 39 | 40 | ### Bug Fixes: 41 | 42 | * don't miss adding bread crumbs in specific cases 43 | 44 | ### Improvements: 45 | 46 | * better formatting of bread crumbs 47 | 48 | ## [v0.2.5](https://github.com/ash-project/splode/compare/v0.2.4...v0.2.5) (2024-10-29) 49 | 50 | 51 | 52 | 53 | ### Improvements: 54 | 55 | * support `.unwrap!/2` on generated splode modules 56 | 57 | * Don't show `Process.info` in stacktraces. (#10) 58 | 59 | ## [v0.2.4](https://github.com/ash-project/splode/compare/v0.2.3...v0.2.4) (2024-05-02) 60 | 61 | 62 | 63 | 64 | ### Bug Fixes: 65 | 66 | * properly return unwrapped list of errors when `:special` class is used 67 | 68 | * Splode.Error.message/1 (#7) 69 | 70 | ## [v0.2.3](https://github.com/ash-project/splode/compare/v0.2.2...v0.2.3) (2024-04-23) 71 | 72 | 73 | 74 | 75 | ### Improvements: 76 | 77 | * make `set_path/2` overridable 78 | 79 | ## [v0.2.2](https://github.com/ash-project/splode/compare/v0.2.1...v0.2.2) (2024-04-05) 80 | 81 | 82 | 83 | 84 | ### Bug Fixes: 85 | 86 | * loosen elixir language version requirement 87 | 88 | ## [v0.2.1](https://github.com/ash-project/splode/compare/v0.2.0...v0.2.1) (2024-03-28) 89 | 90 | 91 | 92 | 93 | ### Bug Fixes: 94 | 95 | * properly produce unknown error on empty list provided 96 | 97 | ## [v0.2.0](https://github.com/ash-project/splode/compare/v0.1.1...v0.2.0) (2024-03-18) 98 | ### Breaking Changes: 99 | 100 | * message/1 instead of splode_message/1 101 | 102 | 103 | 104 | ### Improvements: 105 | 106 | * add `use Splode.ErrorClass` 107 | 108 | * store the module that created an error in the `splode` key 109 | 110 | ## [v0.1.1](https://github.com/ash-project/splode/compare/v0.1.0...v0.1.1) (2024-03-15) 111 | 112 | - Creating a new version so I can change the hex package description 😂 113 | 114 | ## [v0.1.0](https://github.com/ash-project/splode/compare/v0.1.0...v0.1.0) (2024-03-14) 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Zachary Scott Daniel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Splode 2 | 3 | Splode helps you deal with errors and exceptions in your application that are aggregatable and consistent. The general pattern is that you use the `Splode` module as a top level aggregator of error classes, and whenever you return errors, you return one of your `Splode.Error` structs, or a string, or a keyword list. Then, if you want to group errors together, you can use your `Splode` module to do so. You can also use that module to turn any arbitrary value into a splode error. 4 | 5 | See the [documentation on hex](https://hexdocs.pm/splode) for more information 6 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Mix.env() == :dev do 4 | config :git_ops, 5 | mix_project: Splode.MixProject, 6 | changelog_file: "CHANGELOG.md", 7 | repository_url: "https://github.com/ash-project/splode", 8 | # Instructs the tool to manage your mix version in your `mix.exs` file 9 | # See below for more information 10 | manage_mix_version?: true, 11 | # Instructs the tool to manage the version in your README.md 12 | # Pass in `true` to use `"README.md"` or a string to customize 13 | manage_readme_version: ["README.md"], 14 | version_tag_prefix: "v" 15 | end 16 | -------------------------------------------------------------------------------- /documentation/tutorials/get-started-with-splode.md: -------------------------------------------------------------------------------- 1 | # Get Started with Splode 2 | 3 | Splode helps you deal with errors and exceptions in your application that are aggregatable and consistent. The general pattern is that you use the `Splode` module as a top level aggregator of error classes, and whenever you return errors, you return one of your `Splode.Error` structs, or a string, or a keyword list. Then, if you want to group errors together, you can use your `Splode` module to do so. You can also use that module to turn any arbitrary value into a splode error. 4 | 5 | More documentation for `Splode` will come in the future. This was extracted from Ash Framework so that it could be standardized across multiple packages. If you use Ash, you can use `Ash.Errors` to get the benefits of `Splode`. 6 | 7 | For now, here is an example: 8 | 9 | ```elixir 10 | defmodule MyApp.Errors do 11 | use Splode, error_classes: [ 12 | invalid: MyApp.Errors.Invalid, 13 | unknown: MyApp.Errors.Unknown 14 | ], 15 | unknown_error: MyApp.Errors.Unknown.Unknown 16 | end 17 | 18 | # Error classes are splode errors with an `errors` key. 19 | defmodule MyApp.Errors.Invalid do 20 | use Splode.ErrorClass, class: :invalid 21 | end 22 | 23 | # You will want to define an unknown error class, 24 | # otherwise splode will use its own 25 | defmodule MyApp.Errors.Unknown do 26 | use Splode.ErrorClass, class: :unknown 27 | end 28 | 29 | # This fallback exception will be used for unknown errors 30 | defmodule MyApp.Errors.Unknown.Unknown do 31 | use Splode.Error, class: :unknown 32 | 33 | # your unknown message should have an `error` key 34 | def message(%{error: error}) do 35 | if is_binary(error) do 36 | to_string(error) 37 | else 38 | inspect(error) 39 | end 40 | end 41 | end 42 | 43 | # Finally, you can create your own error classes 44 | 45 | defmodule MyApp.Errors.InvalidArgument do 46 | use Splode.Error, fields: [:name, :message], class: :invalid 47 | 48 | def message(%{name: name, message: message}) do 49 | "Invalid argument #{name}: #{message}" 50 | end 51 | end 52 | ``` 53 | 54 | To use these exceptions in your application, the general pattern is to return errors in `:ok | :error` tuples, like so: 55 | 56 | ```elixir 57 | def do_something(argument) do 58 | if is_valid?(argument) do 59 | {:ok, do_stuff()} 60 | else 61 | {:error, 62 | MyApp.Errors.InvalidArgument.exception( 63 | name: :argument, 64 | message: "is invalid" 65 | )} 66 | end 67 | end 68 | ``` 69 | 70 | Then, you can use `to_class`, and `to_error` tools to ensure that you have consistent error structures. 71 | 72 | ```elixir 73 | def do_multiple_things(argument) do 74 | results = [do(), multiple(), things()] 75 | {results, errors} = 76 | Enum.reduce(results, {[], []}, fn 77 | {:ok, result}, {results, errors} -> 78 | {[result | results], errors} 79 | {:error, error} -> 80 | # ensure each error is a splode error 81 | # technically, `to_class` does this for you, 82 | # this is just an example 83 | {results, [MyApp.Errors.to_error(error) | errors]} 84 | end) 85 | 86 | case {results, errors} do 87 | {results, []} -> 88 | {:ok, results} 89 | {_results, errors} -> 90 | {:error, MyApp.Errors.to_class(errors)} 91 | end 92 | end 93 | ``` 94 | 95 | ## Error classes 96 | 97 | When we combine errors into error classes, we choose the first error class for which there are any errors as the "class" of the combined error. For example, in Ash Framework, we have: 98 | 99 | ```elixir 100 | use Splode, 101 | error_classes: [ 102 | forbidden: Ash.Error.Forbidden, 103 | invalid: Ash.Error.Invalid, 104 | framework: Ash.Error.Framework, 105 | unknown: Ash.Error.Unknown 106 | ], 107 | unknown_error: Ash.Error.Unknown.UnknownError 108 | ``` 109 | 110 | What this means is that if there are any `Forbidden` errors, then the class is `Forbidden`. A `Forbidden` error _can_ contain any of the lower classed errors. This allows people to match on and/or rescue on "the general type of failure" that occurred. Given that you have many varied kinds of errors, you can use this to your advantage to have both detailed errors, but simple to match on errors. Here is an example: 111 | 112 | ```elixir 113 | def get(conn, %{"user_id" => user_id}) do 114 | user = MyApp.Accounts.get_user!() 115 | render_user(conn, user) 116 | rescue 117 | e in Ash.Error.Forbidden -> 118 | render_error(conn, %{error: "You can't do this"}) 119 | 120 | e in Ash.Error.Invalid -> 121 | render_error(conn, %{error: "You did something wrong"}) 122 | 123 | e in [Ash.Error.Framework, Ash.Error.Unknown] -> 124 | render_error(conn, %{error: "Something went wrong"}) 125 | end 126 | ``` 127 | 128 | Or, alternatively, you can pattern match on them given a non-raised error class 129 | 130 | ```elixir 131 | def get(conn, %{"user_id" => user_id}) do 132 | case MyApp.Accounts.get_user() do 133 | {:ok, user} -> 134 | render_user(conn, user) 135 | 136 | {:error, %Ash.Error.Forbidden{}} -> 137 | render_error(conn, %{error: "You can't do this"}) 138 | 139 | {:error, %Ash.Error.Invalid{}} -> 140 | render_error(conn, %{error: "You did something wrong"}) 141 | 142 | {:error, %error{}} when error in [Ash.Error.Framework, Ash.Error.Unknown] -> 143 | render_error(conn, %{error: "Something went wrong"}) 144 | end 145 | end 146 | ``` 147 | 148 | # Raising Exceptions 149 | 150 | To make a `!` version of a function, use `.unwrap!/2` on your splode module. 151 | 152 | ```elixir 153 | def get_user!(user_id) do 154 | user_id 155 | |> get_user() 156 | |> MyApp.Errors.unwrap!() 157 | end 158 | 159 | def get_user(user_id) do 160 | case Repo.get(user_id) do 161 | nil -> 162 | {:error, MyApp.Error.NotFound.exception(resource: User, key: user_id)} 163 | user -> 164 | {:ok, user} 165 | end 166 | end 167 | ``` 168 | 169 | ## Installation 170 | 171 | ```elixir 172 | def deps do 173 | [ 174 | {:splode, "~> 0.1.0"} 175 | ] 176 | end 177 | ``` 178 | -------------------------------------------------------------------------------- /lib/splode.ex: -------------------------------------------------------------------------------- 1 | defmodule Splode do 2 | @moduledoc """ 3 | Use this module to create your error aggregator and handler. 4 | 5 | For example: 6 | 7 | ```elixir 8 | defmodule MyApp.Errors do 9 | use Splode, error_classes: [ 10 | invalid: MyApp.Errors.Invalid, 11 | unknown: MyApp.Errors.Unknown 12 | ], 13 | unknown_error: MyApp.Errors.Unknown.Unknown 14 | end 15 | ``` 16 | """ 17 | 18 | @doc """ 19 | Returns true if the given value is a splode error. 20 | """ 21 | @callback splode_error?(term) :: boolean() 22 | 23 | @doc """ 24 | Sets the path on the error or errors 25 | """ 26 | @callback set_path(Splode.Error.t() | [Splode.Error.t()], term | list(term)) :: 27 | Splode.Error.t() | [Splode.Error.t()] 28 | 29 | @doc """ 30 | Combine errors into an error class 31 | """ 32 | @callback to_class(any()) :: Splode.Error.t() 33 | 34 | @doc """ 35 | Turns any value into a splode error 36 | """ 37 | @callback to_error(any()) :: Splode.Error.t() 38 | @doc """ 39 | Converts a combination of a module and json input into an Splode exception. 40 | 41 | This allows for errors to be serialized and deserialized 42 | """ 43 | @callback from_json(module, map) :: Splode.Error.t() 44 | 45 | defmacro __using__(opts) do 46 | quote bind_quoted: [opts: opts], generated: true, location: :keep do 47 | @behaviour Splode 48 | @error_classes Keyword.put_new( 49 | List.wrap(opts[:error_classes]), 50 | :unknown, 51 | Splode.Error.Unknown 52 | ) 53 | 54 | @unknown_error opts[:unknown_error] || 55 | raise( 56 | ArgumentError, 57 | "must supply the `unknown_error` option, pointing at a splode error to use in situations where we cannot convert an error." 58 | ) 59 | 60 | @merge_with List.wrap(opts[:merge_with]) 61 | 62 | if Enum.empty?(opts[:error_classes]) do 63 | raise ArgumentError, 64 | "must supply at least one error class to `use Splode`, via `use Splode, error_classes: [class: ModuleForClass]`" 65 | end 66 | 67 | @type error_class() :: 68 | unquote(@error_classes |> Keyword.keys() |> Enum.reduce(&{:|, [], [&1, &2]})) 69 | 70 | @type class_module() :: 71 | unquote(@error_classes |> Keyword.values() |> Enum.reduce(&{:|, [], [&1, &2]})) 72 | 73 | @type t :: %{ 74 | required(:__struct__) => module(), 75 | required(:__exception__) => true, 76 | required(:class) => error_class(), 77 | required(:bread_crumbs) => list(String.t()), 78 | required(:vars) => Keyword.t(), 79 | required(:stacktrace) => Splode.Stacktrace.t() | nil, 80 | required(:context) => map(), 81 | optional(atom) => any 82 | } 83 | 84 | @type class :: %{ 85 | required(:__struct__) => class_module(), 86 | required(:__exception__) => true, 87 | required(:errors) => list(t()), 88 | required(:class) => error_class(), 89 | required(:bread_crumbs) => list(String.t()), 90 | required(:vars) => Keyword.t(), 91 | required(:stacktrace) => Splode.Stacktrace.t() | nil, 92 | required(:context) => map(), 93 | optional(atom) => any 94 | } 95 | 96 | @class_modules Keyword.values(@error_classes) |> Enum.reject(&is_nil/1) 97 | 98 | @error_class_indices @error_classes |> Keyword.keys() |> Enum.with_index() |> Enum.into(%{}) 99 | 100 | @doc """ 101 | Raises an error if the result is an error, otherwise returns the result 102 | 103 | Alternatively, you can use the `defsplode` macro, which does this automatically. 104 | 105 | ### Options 106 | 107 | - `:error_opts` - Options to pass to `to_error/2` when converting the returned error 108 | - `:unknown_error_opts` - Options to pass to the unknown error if the function returns only `:error`. 109 | not necessary if your function always returns `{:error, error}`. 110 | 111 | ### Examples 112 | 113 | def function(arg) do 114 | case do_something(arg) do 115 | :success -> :ok 116 | {:success, result} -> {:ok, result} 117 | {:error, error} -> {:error, error} 118 | end 119 | end 120 | 121 | def function!(arg) do 122 | YourErrors.unwrap!(function(arg)) 123 | end 124 | """ 125 | def unwrap!(result, opts \\ nil) 126 | def unwrap!({:ok, result}, _opts), do: result 127 | def unwrap!(:ok, _), do: :ok 128 | 129 | def unwrap!({:error, error}, opts), do: raise(to_error(error, opts[:error_opts] || [])) 130 | 131 | def unwrap!(:error, opts), 132 | do: raise(@error_classes[:unknown].exception(opts[:unknown_error_opts] || [])) 133 | 134 | def unwrap!(other, opts), 135 | do: 136 | raise( 137 | ArgumentError, 138 | "Invalid value provided to `splode!/2`:\n\n#{inspect(other)}" 139 | ) 140 | 141 | @impl true 142 | def set_path(errors, path) when is_list(errors) do 143 | Enum.map(errors, &set_path(&1, path)) 144 | end 145 | 146 | def set_path(error, path) when is_map(error) do 147 | path = List.wrap(path) 148 | 149 | error = 150 | if Map.has_key?(error, :path) && is_list(error.path) do 151 | %{error | path: path ++ error.path} 152 | else 153 | error 154 | end 155 | 156 | error = 157 | if Map.has_key?(error, :changeset) && error.changeset do 158 | %{ 159 | error 160 | | changeset: %{error.changeset | errors: set_path(error.changeset.errors, path)} 161 | } 162 | else 163 | error 164 | end 165 | 166 | if Map.has_key?(error, :errors) && is_list(error.errors) do 167 | %{error | errors: Enum.map(error.errors, &set_path(&1, path))} 168 | else 169 | error 170 | end 171 | end 172 | 173 | def set_path(error, _), do: error 174 | 175 | @impl true 176 | def splode_error?(%struct{}) do 177 | struct.splode_error?() 178 | rescue 179 | _ -> 180 | false 181 | end 182 | 183 | def splode_error?(_), do: false 184 | 185 | def splode_error?(%struct{splode: splode}, splode) do 186 | struct.splode_error?() 187 | rescue 188 | _ -> 189 | false 190 | end 191 | 192 | def splode_error?(%struct{splode: nil}, _splode) do 193 | struct.splode_error?() 194 | rescue 195 | _ -> 196 | false 197 | end 198 | 199 | def splode_error?(_, _), do: false 200 | 201 | @impl true 202 | def to_class(value, opts \\ []) 203 | 204 | def to_class(%struct{errors: [error]} = class, opts) 205 | when struct in @class_modules do 206 | if error.class == :special do 207 | error 208 | else 209 | class 210 | |> accumulate_bread_crumbs(opts[:bread_crumbs]) 211 | end 212 | end 213 | 214 | def to_class(value, opts) when not is_list(value) do 215 | if splode_error?(value) && value.class == :special do 216 | Map.put(value, :splode, __MODULE__) 217 | else 218 | to_class([value], opts) 219 | end 220 | end 221 | 222 | def to_class(values, opts) when is_list(values) do 223 | errors = 224 | if Keyword.keyword?(values) && values != [] do 225 | [to_error(values, Keyword.delete(opts, :bread_crumbs))] 226 | else 227 | values 228 | |> flatten_preserving_keywords() 229 | |> Enum.map(fn error -> 230 | if Enum.any?([__MODULE__ | @merge_with], &splode_error?(error, &1)) do 231 | error 232 | else 233 | to_error(error, Keyword.delete(opts, :bread_crumbs)) 234 | end 235 | end) 236 | end 237 | 238 | if Enum.count_until(errors, 2) == 1 && 239 | (Enum.at(errors, 0).class == :special || Enum.at(errors, 0).__struct__.error_class?()) do 240 | List.first(errors) 241 | |> accumulate_bread_crumbs(opts[:bread_crumbs]) 242 | else 243 | errors 244 | |> flatten_errors() 245 | |> Enum.uniq_by(&clear_stacktraces/1) 246 | |> Enum.map(fn value -> 247 | if Enum.any?([__MODULE__ | @merge_with], &splode_error?(value, &1)) do 248 | Map.put(value, :splode, value.splode || __MODULE__) 249 | else 250 | exception_opts = 251 | if opts[:stacktrace] do 252 | [ 253 | error: value, 254 | stacktrace: %Splode.Stacktrace{stacktrace: opts[:stacktrace]}, 255 | splode: __MODULE__ 256 | ] 257 | else 258 | [error: value, splode: __MODULE__] 259 | end 260 | 261 | @unknown_error.exception(exception_opts) 262 | end 263 | end) 264 | |> choose_error() 265 | |> accumulate_bread_crumbs(opts[:bread_crumbs]) 266 | |> Map.put(:splode, __MODULE__) 267 | end 268 | end 269 | 270 | defp choose_error([]) do 271 | @error_classes[:unknown].exception(splode: __MODULE__) 272 | end 273 | 274 | defp choose_error(errors) do 275 | [error | other_errors] = 276 | Enum.sort_by(errors, fn error -> 277 | # the second element here sorts errors that are already parent errors 278 | {Map.get(@error_class_indices, error.class) || 279 | Map.get(@error_class_indices, :unknown), 280 | @error_classes[error.class] != error.__struct__} 281 | end) 282 | 283 | parent_error_module = 284 | @error_classes[error.class] || Keyword.get(@error_classes, :unknown) || 285 | Splode.Error.Unknown 286 | 287 | if parent_error_module == error.__struct__ do 288 | %{error | errors: (error.errors || []) ++ other_errors} 289 | else 290 | parent_error_module.exception(errors: errors, splode: __MODULE__) 291 | end 292 | end 293 | 294 | @impl true 295 | def to_error(value, opts \\ []) 296 | 297 | def to_error(list, opts) when is_list(list) do 298 | if Keyword.keyword?(list) do 299 | list 300 | |> Keyword.take([:error, :vars]) 301 | |> Keyword.put_new(:error, list[:message]) 302 | |> Keyword.put_new(:value, list) 303 | |> Keyword.put(:splode, __MODULE__) 304 | |> @unknown_error.exception() 305 | |> add_stacktrace(opts[:stacktrace]) 306 | |> accumulate_bread_crumbs(opts[:bread_crumbs]) 307 | else 308 | case list do 309 | [item] -> 310 | to_error(item, opts) 311 | 312 | list -> 313 | to_class(list, opts) 314 | end 315 | end 316 | end 317 | 318 | def to_error(error, opts) when is_binary(error) do 319 | [error: error, splode: __MODULE__] 320 | |> @unknown_error.exception() 321 | |> Map.put(:stacktrace, nil) 322 | |> add_stacktrace(opts[:stacktrace]) 323 | |> accumulate_bread_crumbs(opts[:bread_crumbs]) 324 | end 325 | 326 | def to_error(other, opts) do 327 | cond do 328 | Enum.any?([__MODULE__ | @merge_with], &splode_error?(other, &1)) -> 329 | other 330 | |> Map.put(:splode, other.splode || __MODULE__) 331 | |> add_stacktrace(opts[:stacktrace]) 332 | |> accumulate_bread_crumbs(opts[:bread_crumbs]) 333 | 334 | is_exception(other) -> 335 | [error: Exception.format(:error, other), splode: __MODULE__] 336 | |> @unknown_error.exception() 337 | |> add_stacktrace(opts[:stacktrace]) 338 | |> accumulate_bread_crumbs(opts[:bread_crumbs]) 339 | 340 | true -> 341 | [error: "unknown error: #{inspect(other)}", splode: __MODULE__] 342 | |> @unknown_error.exception() 343 | |> Map.put(:stacktrace, nil) 344 | |> add_stacktrace(opts[:stacktrace]) 345 | |> accumulate_bread_crumbs(opts[:bread_crumbs]) 346 | end 347 | end 348 | 349 | defp flatten_errors(errors) do 350 | errors 351 | |> Enum.flat_map(&List.wrap/1) 352 | |> Enum.flat_map(fn error -> 353 | if Enum.any?([__MODULE__ | @merge_with], &splode_error?(error, &1)) do 354 | if error.__struct__.error_class?() do 355 | flatten_errors(error.errors) 356 | else 357 | [error] 358 | end 359 | else 360 | [error] 361 | end 362 | end) 363 | end 364 | 365 | defp flatten_preserving_keywords(list) do 366 | if Keyword.keyword?(list) do 367 | [list] 368 | else 369 | Enum.flat_map(list, fn item -> 370 | cond do 371 | Keyword.keyword?(item) -> 372 | [item] 373 | 374 | is_list(item) -> 375 | flatten_preserving_keywords(item) 376 | 377 | true -> 378 | [item] 379 | end 380 | end) 381 | end 382 | end 383 | 384 | defp add_stacktrace(%{stacktrace: _} = error, stacktrace) do 385 | stacktrace = 386 | case stacktrace do 387 | %Splode.Stacktrace{stacktrace: nil} -> 388 | nil 389 | 390 | nil -> 391 | nil 392 | 393 | stacktrace -> 394 | %Splode.Stacktrace{stacktrace: stacktrace} 395 | end 396 | 397 | %{error | stacktrace: stacktrace || error.stacktrace || fake_stacktrace()} 398 | end 399 | 400 | defp add_stacktrace(e, _), do: e 401 | 402 | defp fake_stacktrace do 403 | {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace) 404 | %Splode.Stacktrace{stacktrace: Enum.drop(stacktrace, 3)} 405 | end 406 | 407 | defp accumulate_bread_crumbs(error, bread_crumbs) when is_list(bread_crumbs) do 408 | bread_crumbs 409 | |> Enum.reverse() 410 | |> Enum.reduce(error, &accumulate_bread_crumbs(&2, &1)) 411 | end 412 | 413 | defp accumulate_bread_crumbs(%module{errors: errors} = error, bread_crumbs) 414 | when is_binary(bread_crumbs) and module in @class_modules do 415 | updated_errors = accumulate_bread_crumbs(errors, bread_crumbs) 416 | 417 | add_bread_crumbs(%{error | errors: updated_errors}, bread_crumbs) 418 | end 419 | 420 | defp accumulate_bread_crumbs(errors, bread_crumbs) 421 | when is_list(errors) and is_binary(bread_crumbs) do 422 | Enum.map(errors, &add_bread_crumbs(&1, bread_crumbs)) 423 | end 424 | 425 | defp accumulate_bread_crumbs(error, bread_crumbs) do 426 | add_bread_crumbs(error, bread_crumbs) 427 | end 428 | 429 | defp add_bread_crumbs(error, bread_crumbs) when is_list(bread_crumbs) do 430 | bread_crumbs 431 | |> Enum.reverse() 432 | |> Enum.reduce(error, &add_bread_crumbs(&2, &1)) 433 | end 434 | 435 | defp add_bread_crumbs(error, bread_crumb) when is_binary(bread_crumb) do 436 | %{error | bread_crumbs: [bread_crumb | error.bread_crumbs]} 437 | end 438 | 439 | defp add_bread_crumbs(error, _) do 440 | error 441 | end 442 | 443 | @impl true 444 | def from_json(module, json) do 445 | {handled, unhandled} = process_known_json_keys(json) 446 | 447 | unhandled = 448 | Map.update(unhandled, "vars", [], fn vars -> 449 | Map.to_list(vars) 450 | end) 451 | 452 | json = Map.merge(unhandled, handled) 453 | 454 | module.from_json(json) 455 | end 456 | 457 | defp process_known_json_keys(json) do 458 | {handled, unhandled} = Map.split(json, ~w(field fields message path)) 459 | 460 | handled = 461 | handled 462 | |> update_if_present("field", &String.to_existing_atom/1) 463 | |> update_if_present("fields", fn fields -> 464 | fields 465 | |> List.wrap() 466 | |> Enum.map(&Splode.Error.atomize_safely/1) 467 | end) 468 | |> update_if_present("path", fn item -> 469 | item 470 | |> List.wrap() 471 | |> Enum.map(fn 472 | item when is_integer(item) -> 473 | item 474 | 475 | item when is_binary(item) -> 476 | case Integer.parse(item) do 477 | {integer, ""} -> integer 478 | _ -> item 479 | end 480 | end) 481 | end) 482 | 483 | {handled, unhandled} 484 | end 485 | 486 | defp clear_stacktraces(%{stacktrace: stacktrace} = error) when not is_nil(stacktrace) do 487 | clear_stacktraces(%{error | stacktrace: nil}) 488 | end 489 | 490 | defp clear_stacktraces(%{errors: errors} = exception) when is_list(errors) do 491 | %{exception | errors: Enum.map(errors, &clear_stacktraces/1)} 492 | end 493 | 494 | defp clear_stacktraces(error), do: error 495 | 496 | defp update_if_present(handled, key, fun) do 497 | if Map.has_key?(handled, key) do 498 | Map.update!(handled, key, fun) 499 | else 500 | handled 501 | end 502 | end 503 | 504 | defoverridable set_path: 2 505 | end 506 | end 507 | end 508 | -------------------------------------------------------------------------------- /lib/splode/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Splode.Error do 2 | @moduledoc """ 3 | Use this module to create an aggregatable error. 4 | 5 | For example: 6 | 7 | ```elixir 8 | defmodule MyApp.Errors.InvalidArgument do 9 | use Splode.Error, fields: [:name, :message], class: :invalid 10 | 11 | def message(%{name: name, message: message}) do 12 | "Invalid argument \#{name}: \#{message}" 13 | end 14 | end 15 | ``` 16 | """ 17 | @callback splode_error?() :: boolean() 18 | @callback from_json(map) :: struct() 19 | @callback error_class?() :: boolean() 20 | @type t :: Exception.t() 21 | 22 | @doc false 23 | def atomize_safely(value) do 24 | String.to_existing_atom(value) 25 | rescue 26 | _ -> 27 | :unknown 28 | end 29 | 30 | defmacro __using__(opts) do 31 | quote generated: true, bind_quoted: [opts: opts, mod: __MODULE__] do 32 | @behaviour Splode.Error 33 | @error_class !!opts[:error_class?] 34 | 35 | if !opts[:class] do 36 | raise "Must provide an error class for a splode error, i.e `use Splode.Error, class: :invalid`" 37 | end 38 | 39 | defexception List.wrap(opts[:fields]) ++ 40 | [ 41 | splode: nil, 42 | bread_crumbs: [], 43 | vars: [], 44 | path: [], 45 | stacktrace: nil, 46 | class: opts[:class] 47 | ] 48 | 49 | @before_compile mod 50 | 51 | @impl Splode.Error 52 | def splode_error?, do: true 53 | 54 | @impl Splode.Error 55 | def error_class?, do: @error_class 56 | 57 | field_names = 58 | Enum.map(List.wrap(opts[:fields]), fn 59 | {k, _v} -> 60 | k 61 | 62 | k -> 63 | k 64 | end) 65 | 66 | @impl Exception 67 | @doc """ 68 | Create an `#{__MODULE__}` without raising it. 69 | 70 | 71 | ## Keys 72 | 73 | #{Enum.map_join(field_names, "\n", &"- #{inspect(&1)}")} 74 | """ 75 | @spec exception(opts :: Keyword.t()) :: %__MODULE__{} 76 | def exception(opts \\ []) do 77 | if @error_class && match?([%error{class: :special} = special], opts[:errors]) do 78 | special_error = Enum.at(opts[:errors], 0) 79 | 80 | if special_error.__struct__.splode_error?() do 81 | special_error 82 | else 83 | opts = 84 | if is_nil(opts[:stacktrace]) do 85 | {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace) 86 | 87 | Keyword.put(opts, :stacktrace, %Splode.Stacktrace{ 88 | stacktrace: Enum.drop(stacktrace, 1) 89 | }) 90 | else 91 | if is_list(opts[:stacktrace]) do 92 | Keyword.update!(opts, :stacktrace, &%Splode.Stacktrace{stacktrace: &1}) 93 | else 94 | opts 95 | end 96 | end 97 | 98 | super(opts) |> Map.update(:vars, [], &Splode.Error.clean_vars/1) 99 | end 100 | else 101 | opts = 102 | if is_nil(opts[:stacktrace]) do 103 | {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace) 104 | 105 | Keyword.put(opts, :stacktrace, %Splode.Stacktrace{ 106 | stacktrace: Enum.drop(stacktrace, 1) 107 | }) 108 | else 109 | if is_list(opts[:stacktrace]) do 110 | Keyword.update!(opts, :stacktrace, &%Splode.Stacktrace{stacktrace: &1}) 111 | else 112 | opts 113 | end 114 | end 115 | 116 | super(opts) |> Map.update(:vars, [], &Splode.Error.clean_vars/1) 117 | end 118 | end 119 | 120 | @impl Splode.Error 121 | def from_json(json) do 122 | keyword = 123 | json 124 | |> Map.to_list() 125 | |> Enum.map(fn {key, value} -> {Splode.Error.atomize_safely(key), value} end) 126 | 127 | exception(keyword) 128 | end 129 | 130 | defoverridable exception: 1, from_json: 1 131 | end 132 | end 133 | 134 | defmacro __before_compile__(env) do 135 | if Module.defines?(env.module, {:message, 1}, :def) && 136 | !Module.get_attribute(env.module, :error_class) do 137 | quote generated: true do 138 | defoverridable message: 1 139 | 140 | @impl true 141 | def message(%{vars: vars} = exception) do 142 | string = super(exception) 143 | 144 | string = 145 | case Splode.ErrorClass.bread_crumb(exception.bread_crumbs) do 146 | "" -> 147 | string 148 | 149 | context -> 150 | context <> "\n" <> string 151 | end 152 | 153 | Enum.reduce(List.wrap(vars), string, fn {key, value}, acc -> 154 | if String.contains?(acc, "%{#{key}}") do 155 | String.replace(acc, "%{#{key}}", inspect(value)) 156 | else 157 | acc 158 | end 159 | end) 160 | end 161 | end 162 | end 163 | end 164 | 165 | @doc false 166 | def clean_vars(vars) when is_map(vars) do 167 | clean_vars(Map.to_list(vars)) 168 | end 169 | 170 | def clean_vars(vars) do 171 | vars |> Kernel.||([]) |> Keyword.drop([:field, :message, :path]) 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /lib/splode/error_class.ex: -------------------------------------------------------------------------------- 1 | defmodule Splode.ErrorClass do 2 | @moduledoc "Tools for working with error classes" 3 | 4 | defmacro __using__(opts) do 5 | quote bind_quoted: [opts: opts] do 6 | opts = 7 | Keyword.update(opts, :fields, [errors: []], fn fields -> 8 | has_error_fields? = 9 | Enum.any?(fields, fn 10 | :errors -> 11 | true 12 | 13 | {:errors, _} -> 14 | true 15 | 16 | _ -> 17 | false 18 | end) 19 | 20 | if has_error_fields? do 21 | fields 22 | else 23 | fields ++ [errors: []] 24 | end 25 | end) 26 | |> Keyword.put(:error_class?, true) 27 | 28 | use Splode.Error, opts 29 | 30 | def message(%{errors: errors}) do 31 | Splode.ErrorClass.error_messages(errors) 32 | end 33 | end 34 | end 35 | 36 | @doc "Creates a long form composite error message for a list of errors" 37 | def error_messages(errors, opts \\ []) do 38 | custom_message = opts[:custom_message] 39 | errors = List.wrap(errors) 40 | 41 | {bread_crumbs, errors} = 42 | extract_shared_bread_crumbs(errors) 43 | 44 | generic_message = 45 | errors 46 | |> Enum.group_by(& &1.class) 47 | |> Enum.map_join("\n\n", fn {class, class_errors} -> 48 | header = String.capitalize(to_string(class)) <> " Error\n\n" 49 | 50 | header <> 51 | Enum.map_join(class_errors, "\n", fn 52 | error when is_binary(error) -> 53 | "* #{error}" 54 | 55 | %{stacktrace: %Splode.Stacktrace{stacktrace: stacktrace}} = class_error -> 56 | bread_crumb(class_error.bread_crumbs) <> 57 | "* #{Exception.message(class_error)}\n" <> 58 | path(class_error) <> 59 | Enum.map_join(stacktrace, "\n", fn stack_item -> 60 | " " <> Exception.format_stacktrace_entry(stack_item) 61 | end) 62 | 63 | class_error -> 64 | "* #{Exception.message(class_error)}\n" <> 65 | path(class_error) 66 | end) 67 | end) 68 | 69 | if custom_message do 70 | custom = 71 | custom_message 72 | |> List.wrap() 73 | |> Enum.map_join("\n", &"* #{&1}") 74 | 75 | "\n#{bread_crumb(bread_crumbs)}\n" <> custom <> generic_message 76 | else 77 | "\n#{bread_crumb(bread_crumbs)}" <> 78 | generic_message 79 | end 80 | end 81 | 82 | defp extract_shared_bread_crumbs(errors, shared \\ []) 83 | 84 | defp extract_shared_bread_crumbs( 85 | [%{bread_crumbs: [first | _rest_crumbs]} | rest] = errors, 86 | shared 87 | ) do 88 | if Enum.any?(rest, fn other_error -> 89 | !is_map(other_error) || !Map.has_key?(other_error, :bread_crumbs) || 90 | Enum.at(other_error.bread_crumbs, 0) != first 91 | end) do 92 | {Enum.reverse(shared), errors} 93 | else 94 | extract_shared_bread_crumbs( 95 | Enum.map(errors, &%{&1 | bread_crumbs: Enum.drop(&1.bread_crumbs, 1)}), 96 | [first | shared] 97 | ) 98 | end 99 | end 100 | 101 | defp extract_shared_bread_crumbs(errors, bread_crumbs), do: {Enum.reverse(bread_crumbs), errors} 102 | 103 | defp path(%{path: path}) when path not in [[], nil] do 104 | " at " <> to_path(path) <> "\n" 105 | end 106 | 107 | defp path(_), do: "" 108 | 109 | defp to_path(path) do 110 | Enum.map_join(path, ", ", fn item -> 111 | if is_list(item) do 112 | "[#{to_path(item)}]" 113 | else 114 | if is_binary(item) || is_atom(item) || is_number(item) do 115 | item 116 | else 117 | inspect(item) 118 | end 119 | end 120 | end) 121 | end 122 | 123 | @doc false 124 | def bread_crumb(nil), do: "" 125 | def bread_crumb([]), do: "" 126 | 127 | def bread_crumb(bread_crumbs) do 128 | case Enum.filter(bread_crumbs, & &1) do 129 | [] -> 130 | "" 131 | 132 | bread_crumbs -> 133 | "Bread Crumbs:\n" <> join_bread_crumbs(bread_crumbs) <> "\n" 134 | end 135 | end 136 | 137 | defp join_bread_crumbs(bread_crumbs) do 138 | Enum.reduce(bread_crumbs, {"", []}, fn bread_crumb, {line, lines} -> 139 | case String.length(line) + String.length(bread_crumb) do 140 | length when length < 80 -> 141 | {line <> "> " <> bread_crumb, lines} 142 | 143 | _ -> 144 | {"> " <> bread_crumb, [" " <> line | lines]} 145 | end 146 | end) 147 | |> then(fn {line, lines} -> 148 | lines = 149 | case line do 150 | "" -> 151 | lines 152 | 153 | line -> 154 | [" " <> line | lines] 155 | end 156 | 157 | lines 158 | |> Enum.reject(&(&1 == "")) 159 | |> Enum.join("\n") 160 | |> Kernel.<>("\n") 161 | end) 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /lib/splode/stacktrace.ex: -------------------------------------------------------------------------------- 1 | defmodule Splode.Stacktrace do 2 | @moduledoc "A placeholder for a stacktrace so that we can avoid printing it everywhere" 3 | defstruct [:stacktrace] 4 | 5 | @type t :: %__MODULE__{stacktrace: list} 6 | 7 | defimpl Inspect do 8 | def inspect(_, _) do 9 | "#Splode.Stacktrace<>" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/splode/unknown.ex: -------------------------------------------------------------------------------- 1 | defmodule Splode.Error.Unknown do 2 | @moduledoc "The default top level unknown error container" 3 | use Splode.ErrorClass, class: :unknown 4 | 5 | @impl true 6 | def exception(opts) do 7 | if opts[:error] do 8 | super(Keyword.update(opts, :errors, [opts[:error]], &[opts[:error] | &1])) 9 | else 10 | super(opts) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Splode.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.2.9" 5 | 6 | @description """ 7 | Splode helps you deal with errors and exceptions in your application that are aggregatable and consistent. 8 | """ 9 | 10 | def project do 11 | [ 12 | app: :splode, 13 | version: @version, 14 | elixir: "~> 1.13", 15 | start_permanent: Mix.env() == :prod, 16 | elixirc_paths: elixirc_paths(Mix.env()), 17 | deps: deps(), 18 | docs: docs(), 19 | package: package(), 20 | description: @description, 21 | source_url: "https://github.com/ash-project/splode", 22 | homepage_url: "https://github.com/ash-project/splode" 23 | ] 24 | end 25 | 26 | # Run "mix help compile.app" to learn about applications. 27 | def application do 28 | [ 29 | extra_applications: [:logger] 30 | ] 31 | end 32 | 33 | defp package do 34 | [ 35 | name: :splode, 36 | licenses: ["MIT"], 37 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* 38 | CHANGELOG* documentation), 39 | links: %{ 40 | GitHub: "https://github.com/ash-project/splode", 41 | Discord: "https://discord.gg/HTHRaaVPUc", 42 | Website: "https://ash-hq.org", 43 | Forum: "https://elixirforum.com/c/elixir-framework-forums/ash-framework-forum" 44 | } 45 | ] 46 | end 47 | 48 | defp docs do 49 | [ 50 | extras: [ 51 | "documentation/tutorials/get-started-with-splode.md" 52 | ], 53 | source_ref: "v#{@version}", 54 | extra_section: "GUIDES", 55 | before_closing_head_tag: fn type -> 56 | if type == :html do 57 | """ 58 | 67 | """ 68 | end 69 | end 70 | ] 71 | end 72 | 73 | defp elixirc_paths(:test) do 74 | ["lib", "test/support"] 75 | end 76 | 77 | defp elixirc_paths(_), do: ["lib"] 78 | 79 | # Run "mix help deps" to learn about dependencies. 80 | defp deps do 81 | [ 82 | # Dev/Test dependencies 83 | {:igniter, "~> 0.5", only: [:dev, :test]}, 84 | {:ex_doc, "~> 0.36", only: [:dev, :test]}, 85 | {:ex_check, "~> 0.12", only: [:dev, :test]}, 86 | {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, 87 | {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, 88 | {:mimic, "~> 1.7", only: [:test]}, 89 | {:sobelow, ">= 0.0.0", only: [:dev, :test], runtime: false}, 90 | {:git_ops, "~> 2.5", only: [:dev, :test]}, 91 | {:mix_audit, ">= 0.0.0", only: [:dev, :test], runtime: false}, 92 | {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, 93 | {:benchee, "~> 1.1", only: [:dev, :test]}, 94 | {:doctor, "~> 0.21", only: [:dev, :test]} 95 | ] 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.4.0", "9f1f96a30ac80bab94faad644b39a9031d5632e517416a8ab0a6b0ac4df124ce", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "299cd10dd8ce51c9ea3ddb74bb150f93d25e968f93e4c1fa31698a8e4fa5d715"}, 3 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 5 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 6 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 7 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 8 | "doctor": {:hex, :doctor, "0.22.0", "223e1cace1f16a38eda4113a5c435fa9b10d804aa72d3d9f9a71c471cc958fe7", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "96e22cf8c0df2e9777dc55ebaa5798329b9028889c4023fed3305688d902cd5b"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 10 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 11 | "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, 12 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 13 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 14 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 15 | "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, 16 | "git_ops": {:hex, :git_ops, "2.8.0", "29ac9ab68bf9645973cb2752047b987e75cbd3d9761489c615e3ba80018fa885", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.27 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "b535e4ad6b5d13e14c455e76f65825659081b5530b0827eb0232d18719530eec"}, 17 | "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, 18 | "ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"}, 19 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 20 | "igniter": {:hex, :igniter, "0.6.5", "0b16a37e1aaaefc39777c6250980a314df8ba02a8ae81063d786a7bddb40dbf0", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "21dec3066f372f49f391d00a2067769eb20f7a2213513e022593e4b51bad93e2"}, 21 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 22 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 23 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 24 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 25 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 26 | "mimic": {:hex, :mimic, "1.12.0", "34c9d1fb8e756df09ca5f96861d273f2bb01063df1a6a51a4c101f9ad7f07a9c", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "eaa43d495d6f3bc8099b28886e05a1b09a2a6be083f6385c3abc17599e5e2c43"}, 27 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 28 | "mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"}, 29 | "mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"}, 30 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 31 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 32 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 33 | "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, 34 | "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, 35 | "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, 36 | "sobelow": {:hex, :sobelow, "0.14.0", "dd82aae8f72503f924fe9dd97ffe4ca694d2f17ec463dcfd365987c9752af6ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7ecf91e298acfd9b24f5d761f19e8f6e6ac585b9387fb6301023f1f2cd5eed5f"}, 37 | "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, 38 | "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, 39 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 40 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 41 | "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, 42 | "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, 43 | "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"}, 44 | } 45 | -------------------------------------------------------------------------------- /test/splode/error_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Splode.ErrorTest do 2 | alias Splode.Stacktrace 3 | use ExUnit.Case 4 | 5 | defmodule InvalidAttribute do 6 | use Splode.Error, fields: [:message], class: :invalid 7 | end 8 | 9 | test "message" do 10 | invalid = %InvalidAttribute{message: "must be in %{list}", vars: [list: [:foo, :bar]]} 11 | assert "must be in [:foo, :bar]" == invalid |> Exception.message() 12 | end 13 | 14 | test "stacktrace" do 15 | {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace) 16 | 17 | assert %Stacktrace{} = InvalidAttribute.exception(stacktrace: stacktrace).stacktrace 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/splode_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SplodeTest do 2 | use ExUnit.Case 3 | 4 | # Error classes 5 | 6 | defmodule HwError do 7 | @moduledoc false 8 | use Splode.ErrorClass, class: :hw 9 | end 10 | 11 | defmodule SwError do 12 | @moduledoc false 13 | use Splode.ErrorClass, class: :sw 14 | end 15 | 16 | defmodule ContainerErrorClass do 17 | @moduledoc false 18 | use Splode.ErrorClass, class: :ui 19 | end 20 | 21 | # Errors 22 | 23 | defmodule CpuError do 24 | @moduledoc false 25 | use Splode.Error, class: :hw 26 | def message(err), do: err |> inspect() 27 | end 28 | 29 | defmodule RamError do 30 | @moduledoc false 31 | use Splode.Error, class: :hw 32 | def message(err), do: err |> inspect() 33 | end 34 | 35 | defmodule DivByZeroException do 36 | @moduledoc false 37 | use Splode.Error, fields: [:num, :denom], class: :sw 38 | def message(err), do: err |> inspect() 39 | end 40 | 41 | defmodule NullReferenceException do 42 | @moduledoc false 43 | use Splode.Error, class: :sw 44 | def message(err), do: err |> inspect() 45 | end 46 | 47 | defmodule UnknownError do 48 | @moduledoc false 49 | use Splode.Error, fields: [:error], class: :unknown 50 | def message(err), do: err |> inspect() 51 | end 52 | 53 | defmodule ExampleContainerError do 54 | @moduledoc false 55 | use Splode.Error, fields: [:description], class: :ui 56 | def message(err), do: err |> inspect() 57 | end 58 | 59 | defmodule ContainerUnknownError do 60 | @moduledoc false 61 | use Splode.Error, fields: [:error], class: :unknown 62 | def message(err), do: err |> inspect() 63 | end 64 | 65 | defmodule SystemError do 66 | @moduledoc false 67 | use Splode, 68 | error_classes: [ 69 | hw: HwError, 70 | sw: SwError 71 | ], 72 | unknown_error: UnknownError 73 | end 74 | 75 | defmodule ContainerError do 76 | @moduledoc false 77 | use Splode, 78 | error_classes: [ 79 | interaction: ContainerErrorClass, 80 | hw: HwError, 81 | sw: SwError 82 | ], 83 | unknown_error: ContainerUnknownError, 84 | merge_with: [SystemError] 85 | end 86 | 87 | defmodule ContainerWithoutMergeWith do 88 | @moduledoc false 89 | use Splode, 90 | error_classes: [ 91 | interaction: ContainerErrorClass 92 | ], 93 | unknown_error: ContainerUnknownError, 94 | merge_with: [] 95 | end 96 | 97 | defmodule Example do 98 | def function do 99 | {:error, "Error"} 100 | end 101 | 102 | def function! do 103 | SystemError.unwrap!(function()) 104 | end 105 | end 106 | 107 | test "splode functions work" do 108 | assert_raise SplodeTest.UnknownError, ~r/error: "Error"/, fn -> 109 | Example.function!() 110 | end 111 | end 112 | 113 | test "splode_error?" do 114 | refute SystemError.splode_error?(:error) 115 | refute SystemError.splode_error?(%{}) 116 | refute SystemError.splode_error?([]) 117 | 118 | assert SystemError.splode_error?(HwError.exception()) 119 | assert SystemError.splode_error?(SwError.exception()) 120 | 121 | assert SystemError.splode_error?(CpuError.exception()) 122 | assert SystemError.splode_error?(RamError.exception()) 123 | assert SystemError.splode_error?(DivByZeroException.exception()) 124 | assert SystemError.splode_error?(NullReferenceException.exception()) 125 | assert SystemError.splode_error?(UnknownError.exception()) 126 | end 127 | 128 | test "set_path" do 129 | null = NullReferenceException.exception(path: [:a]) 130 | null = SystemError.set_path(null, :b) 131 | 132 | assert null.path == [:b, :a] 133 | end 134 | 135 | describe "to_class" do 136 | setup do 137 | cpu = CpuError.exception() |> SystemError.to_error() 138 | ram = RamError.exception() |> SystemError.to_error() 139 | div = DivByZeroException.exception() |> SystemError.to_error() 140 | null = NullReferenceException.exception() |> SystemError.to_error() 141 | example_container_error = ExampleContainerError.exception() |> ContainerError.to_error() 142 | 143 | %{ 144 | cpu: cpu, 145 | ram: ram, 146 | div: div, 147 | null: null, 148 | example_container_error: example_container_error 149 | } 150 | end 151 | 152 | test "wraps errors in error class with same class", %{ 153 | cpu: cpu, 154 | ram: ram, 155 | div: div, 156 | null: null 157 | } do 158 | # H/W errors 159 | hw_error = [cpu, ram] |> SystemError.to_class() 160 | assert %HwError{errors: [^cpu, ^ram]} = hw_error 161 | 162 | # S/W errors 163 | sw_error = [div, null] |> SystemError.to_class() 164 | assert %SwError{errors: [^div, ^null]} = sw_error 165 | end 166 | 167 | test "error class with smaller index is selected for mixed class case", %{ 168 | cpu: cpu, 169 | ram: ram, 170 | div: div, 171 | null: null 172 | } do 173 | errors = [cpu, ram, div, null] |> Enum.shuffle() 174 | assert %HwError{errors: ^errors} = errors |> SystemError.to_class() 175 | end 176 | 177 | test "idempotent", %{ 178 | cpu: cpu, 179 | ram: ram, 180 | div: div, 181 | null: null 182 | } do 183 | error = 184 | [cpu, ram, div, null] |> Enum.shuffle() |> Enum.take(2) |> SystemError.to_class() 185 | 186 | assert error == error |> SystemError.to_class() 187 | end 188 | 189 | test "to_error flattens nested errors when included in merge_with", %{ 190 | cpu: cpu, 191 | ram: ram, 192 | example_container_error: example_container_error 193 | } do 194 | hw_error = [cpu, ram] |> SystemError.to_class() 195 | 196 | interaction_error = ContainerError.to_class([hw_error, example_container_error]) 197 | 198 | assert %{errors: [^cpu, ^ram, ^example_container_error]} = interaction_error 199 | end 200 | 201 | test "to_error doesn't flatten nested errors when not included in merge_with", %{ 202 | cpu: cpu, 203 | ram: ram, 204 | example_container_error: example_container_error 205 | } do 206 | hw_error = [cpu, ram] |> SystemError.to_class() 207 | 208 | interaction_error = ContainerWithoutMergeWith.to_class([hw_error, example_container_error]) 209 | 210 | assert %{errors: [%SplodeTest.ContainerUnknownError{}, %SplodeTest.ContainerUnknownError{}]} = 211 | interaction_error 212 | end 213 | end 214 | 215 | test "to_error" do 216 | error_tuple = {:error, :div_by_zero} 217 | assert %UnknownError{class: :unknown} = SystemError.to_error(error_tuple) 218 | 219 | runtime_error = %RuntimeError{} 220 | assert %UnknownError{class: :unknown} = SystemError.to_error(runtime_error) 221 | 222 | div_by_zero = DivByZeroException.exception() 223 | assert %DivByZeroException{} = SystemError.to_error(div_by_zero) 224 | end 225 | 226 | test "from_json" do 227 | div_by_zero = 228 | SystemError.from_json(DivByZeroException, %{"num" => 10, "denom" => 0}) 229 | 230 | assert %DivByZeroException{num: 10, denom: 0} = div_by_zero 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------