├── .formatter.exs ├── .gitignore ├── .tool-versions ├── .travis.yml ├── LICENSE ├── README.md ├── brand ├── logo-text.png ├── logo-text.svg ├── logo-text_25percent.png ├── logo-text_50percent.png ├── logo-thicklines-25percent.png ├── logo-thicklines-50percent.png ├── logo-thicklines.png ├── logo.png ├── logo.svg ├── logo_25percent.png ├── logo_50percent.png └── logo_thicklines.svg ├── config └── config.exs ├── lib ├── specify.ex └── specify │ ├── options.ex │ ├── parsers.ex │ ├── provider.ex │ └── provider │ ├── mix_env.ex │ ├── process.ex │ └── system_env.ex ├── mix.exs ├── mix.lock └── test ├── provider ├── mix_env_test.exs ├── process_test.exs └── system_env_test.exs ├── specify └── parsers_test.exs ├── specify_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | import_deps: [:stream_data] 5 | ] 6 | -------------------------------------------------------------------------------- /.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 3rd-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 | confy-*.tar 24 | 25 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.10.3 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.7.4 4 | - 1.8.0 5 | after_script: 6 | - MIX_ENV=docs mix deps.get 7 | - MIX_ENV=docs mix inch.report 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Qqwy / Wiebe-Marten 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 | ![](https://raw.githubusercontent.com/Qqwy/elixir_specify/master/brand/logo-text.png) 2 | 3 | `Specify` is a library to create Comfortable, Explicit, Multi-Layered and Well-Documented Specifications for all your configurations, settings and options in Elixir. 4 | 5 | [![hex.pm version](https://img.shields.io/hexpm/v/specify.svg)](https://hex.pm/packages/specify) 6 | [![Build Status](https://travis-ci.org/Qqwy/elixir_confy.svg?branch=master)](https://travis-ci.org/Qqwy/elixir_confy) 7 | [![Documentation](https://img.shields.io/badge/hexdocs-latest-blue.svg)](https://hexdocs.pm/specify/index.html) 8 | [![Inline docs](http://inch-ci.org/github/qqwy/elixir_specify.svg)](http://inch-ci.org/github/qqwy/elixir_specify) 9 | 10 | --- 11 | 12 | Basic features: 13 | 14 | - Configuration is converted to a struct, with fields being parsed to their appropriate types. 15 | - Specify a stack of sources to fetch the configuration from. 16 | - Always possible to override local configuration using plain arguments to a function call. 17 | - Fail-fast on missing or malformed values. 18 | - Auto-generated documentation based on your config specification. 19 | 20 | Specify can be used both to create normalized configuration structs during runtime and compile-time using both implicit external configuration sources and explicit arguments to a function call. 21 | 22 | ## Installation 23 | 24 | You can install Specify by adding `specify` to your list of dependencies in `mix.exs`: 25 | 26 | ```elixir 27 | def deps do 28 | [ 29 | {:specify, "~> 0.7.0"} 30 | ] 31 | end 32 | ``` 33 | 34 | Documentation can be found at [https://hexdocs.pm/specify](https://hexdocs.pm/specify). 35 | 36 | ## Examples 37 | 38 | 39 | Basic usage is as follows, using `Specify.defconfig/1`: 40 | 41 | 42 | ```elixir 43 | 44 | defmodule Cosette.CastleOnACloud do 45 | require Specify 46 | Specify.defconfig do 47 | @doc "there are no floors for me to sweep" 48 | field :floors_to_sweep, :integer, default: 0 49 | 50 | @doc "there are a hundred boys and girls" 51 | field :amount_boys_and_girls, :integer, default: 100 52 | 53 | @doc "The lady all in white holds me and sings a lullaby" 54 | field :lullaby, :string 55 | 56 | @doc "Crying is usually not allowed" 57 | field :crying_allowed, :boolean, default: false 58 | end 59 | end 60 | ``` 61 | 62 | and later `Specify.load/2`, `Specify.load_explicit/3` (or `YourModule.load/1`, `YourModule.load_explicit/2` which are automatically defined). 63 | ``` 64 | iex> Cosette.CastleOnACloud.load(explicit_values: [lullaby: "I love you very much", crying_allowed: true]) 65 | %Cosette.CastleOnACloud{ 66 | crying_allowed: true, 67 | floors_to_sweep: 0, 68 | lullaby: "I love you very much", 69 | amount_boys_and_girls: 100 70 | } 71 | 72 | ``` 73 | 74 | ### Mandatory Fields 75 | 76 | Notice that since the `:lullaby`-field is mandatory, if it is not defined in any of the configuration sources, an error will be thrown: 77 | 78 | ```elixir 79 | Cosette.CastleOnACloud.load 80 | ** (Specify.MissingRequiredFieldsError) Missing required fields for `Elixir.Cosette.CastleOnACloud`: `:lullaby`. 81 | (specify) lib/specify.ex:179: Specify.prevent_missing_required_fields!/3 82 | (specify) lib/specify.ex:147: Specify.load/2 83 | ``` 84 | 85 | ### Multiple parsers 86 | 87 | It is possible to specify several parsers for a unique field, using a list of parsers. They 88 | will be tried consecutively in list order. For instance: 89 | 90 | ```elixir 91 | field :some_field, [:string, :boolean], default: true 92 | ``` 93 | 94 | ### Loading from Sources 95 | 96 | Loading from another source is easy: 97 | 98 | ```elixir 99 | iex> Application.put_env(Cosette.CastleOnACloud, :lullaby, "sleep little darling") 100 | # or: in a Mix config.ex file 101 | config Cosette.CastleOnACloud, lullaby: "sleep little darling" 102 | ``` 103 | ```elixir 104 | iex> Cosette.CastleOnACloud.load(sources: [Specify.Provider.MixEnv]) 105 | %Cosette.CastleOnACloud{ 106 | crying_allowed: false, 107 | floors_to_sweep: 0, 108 | lullaby: "sleep little darling", 109 | no_boys_and_girls: 100 110 | } 111 | ``` 112 | 113 | Rather than passing in the sources when loading the configuration, it often makes more sense to specify them when defining the configuration: 114 | 115 | ```elixir 116 | defmodule Cosette.CastleOnACloud do 117 | require Specify 118 | Specify.defconfig sources: [Specify.Provider.MixEnv] do 119 | # ... 120 | end 121 | end 122 | ``` 123 | 124 | ## Providers 125 | 126 | Providers can be specified by passing them to the `sources:` option (while loading the configuration structure or while defining it). 127 | They can also be set globally by altering the `sources:` key of the `Specify` application environment, or per-process using the `:sources` subkey of the `Specify` key in the current process' dictionary (`Process.put_env`). 128 | 129 | Be aware that for bootstrapping reasons, it is impossible to override the `:sources` field globally in an external source (because Specify would not know where to find it). 130 | 131 | `Specify` comes with the following built-in providers: 132 | 133 | - `Specify.Provider.MixEnv`, which uses `Mix.env` / `Application.get_env` to read from the application environment. 134 | - `Specify.Provider.SystemEnv`, which uses `System.get_env` to read from system environment variables. 135 | - `Specify.Provider.Process`, which uses `Process.get` to read from the current process' dictionary. 136 | 137 | Often, Providers have sensible default values on how they work, making their usage simpler: 138 | - `Specify.Provider.Process` will look at the configured `key`, but will default to the configuration specification module name. 139 | - `Specify.Provider.MixEnv` will look at the configured `application_name` and `key`, but will default to the whole environment of an application (`Application.get_all_env`) if no key was set, with `application_name` defaulting to the configuration specification module name. 140 | - `Specify.Provider.SystemEnv` will look at the configured `prefix` but will default to the module name (in all caps), followed by the field name (in all caps, separated by underscores). What names should be used for a field is also configurable. 141 | 142 | ## Writing Providers 143 | 144 | Providers implement the `Specify.Provider` protocol, which consists of only one function: `load/2`. 145 | Its first argument is the implementation's own struct, the second argument being the configuration specification's module name. 146 | If extra information is required about the configuration specification to write a good implementation, the Reflection function `module_name.__specify__` can be used to look these up. 147 | 148 | 149 | ## Roadmap 150 | 151 | - [x] Compound parsers for collections using `{collection_parser, element_parser}`-syntax, with provided `:list` parser. 152 | - [x] Main functionality documentation. 153 | - [x] Parsers documentation. 154 | - [x] Writing basic Tests 155 | - [x] Specify.Parsers 156 | - [x] Main Specify module and functionality. 157 | - [x] Thinking on how to handle environment variable names (capitalization, prefixes). 158 | - [x] Environment Variables (System.get_env) provider 159 | - [x] Specify Provider Tests. 160 | - [ ] Better/more examples 161 | - [ ] Stable release 162 | 163 | ## Possibilities for the future 164 | 165 | - (D)ETS provider 166 | - CLI arguments provider, which could be helpful for defining e.g. Mix tasks. 167 | - .env files provider. 168 | - JSON and YML files provider. 169 | - Nested configs? 170 | - Possibility to load without raising on parsing falure (instead returning a success/failure tuple?) 171 | - Watching for updates and call a configurable handler function when configuration has changed. 172 | 173 | ## Changelog 174 | 175 | - 0.10.0 - Adds an `option({atom, term})` parser that can be used to parse for instance keyword lists. Thank you, @tanguilp! 176 | - 0.9.0 - Allows multi-value parsers by specifying a list of parsers. Thank you, @tanguilp! 177 | - 0.8.0 - Makes string-parsers work on more of Elixir's builtin terms including lists and maps of other types (including lists and maps themselves). Thank you, @tanguilp! 178 | - 0.7.2 - Makes functions clickable in the generated documentation. Thank you, @tanguilp! 179 | - 0.7.1 - Pretty-prints long default values in a multi-line code block in the documentation (#2). 180 | - 0.7 - Adds an `optional` key to the built-in providers. They will only return `{error, :not_found}` if they are not set to optional. Also adds two new ways to indicate sources, which are helpful in environments where you do not have access to the structs directly (such as `Mix.Config` or the newer `Elixir.Config` files.) 181 | - 0.6 - Adds the `mfa` and `function` builtin parsers. 182 | - 0.5 - Adds the `nonnegative_integer`, `positive_integer`, `nonnegative_float`, `positive_float` and `timeout` builtin parsers. 183 | - 0.4.5 - Fixes built-in `integer` and `float` parsers to not crash on input like `"10a"` (but instead return `{:error, _}`). 184 | - 0.4.4 - Fixes references to validation/parsing functions in documentation. 185 | - 0.4.2 - Finishes provider tests; bugfix for the MixEnv provider. 186 | - 0.4.1 - Improves documentation. 187 | - 0.4.0 - Name change: from 'Confy' to 'Specify'. This name has been chosen to be more clear about the intent of the library. 188 | - 0.3.0 - Changed `overrides:` to `explicit_values:` and added `Specify.load_explicit/3` function. (Also added tests and fixed parser bugs). 189 | - 0.2.0 - Initially released version 190 | 191 | 192 | ## Attribution 193 | 194 | I want to thank Chris Keathley for his interesting library [Vapor](https://github.com/keathley/vapor) which helped inspire Specify. 195 | 196 | I also want to thank José Valim for the great conversations we've had about the advantages and disadvantages of various approaches to configuring Elixir applications. 197 | -------------------------------------------------------------------------------- /brand/logo-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-text.png -------------------------------------------------------------------------------- /brand/logo-text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 40 | 44 | 48 | 49 | 52 | 56 | 60 | 61 | 69 | 75 | 76 | 84 | 90 | 91 | 99 | 105 | 106 | 114 | 120 | 121 | 130 | 139 | 148 | 157 | 166 | 175 | 184 | 193 | 202 | 211 | 220 | 229 | 238 | 247 | 256 | 265 | 274 | 283 | 292 | 301 | 310 | 319 | 328 | 337 | 346 | 355 | 364 | 374 | 384 | 394 | 403 | 413 | 414 | 438 | 440 | 441 | 443 | image/svg+xml 444 | 446 | 447 | 448 | 449 | 450 | 455 | 459 | 463 | 466 | 471 | 476 | 477 | 483 | 484 | 488 | 491 | 496 | 501 | 506 | 507 | 513 | 514 | 518 | 521 | 526 | 531 | 532 | 538 | 539 | 540 | 544 | Specify 555 | 556 | 560 | 564 | 567 | 572 | 577 | 578 | 596 | 597 | 601 | 604 | 609 | 614 | 615 | 633 | 634 | 635 | 636 | 637 | -------------------------------------------------------------------------------- /brand/logo-text_25percent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-text_25percent.png -------------------------------------------------------------------------------- /brand/logo-text_50percent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-text_50percent.png -------------------------------------------------------------------------------- /brand/logo-thicklines-25percent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-thicklines-25percent.png -------------------------------------------------------------------------------- /brand/logo-thicklines-50percent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-thicklines-50percent.png -------------------------------------------------------------------------------- /brand/logo-thicklines.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo-thicklines.png -------------------------------------------------------------------------------- /brand/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo.png -------------------------------------------------------------------------------- /brand/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 40 | 44 | 48 | 49 | 52 | 56 | 60 | 61 | 69 | 75 | 76 | 84 | 90 | 91 | 99 | 105 | 106 | 114 | 120 | 121 | 130 | 139 | 148 | 157 | 166 | 175 | 184 | 193 | 202 | 211 | 220 | 229 | 238 | 247 | 256 | 265 | 274 | 283 | 292 | 301 | 310 | 319 | 328 | 337 | 346 | 355 | 364 | 374 | 384 | 394 | 403 | 404 | 428 | 430 | 431 | 433 | image/svg+xml 434 | 436 | 437 | 438 | 439 | 440 | 445 | 449 | 453 | 456 | 461 | 466 | 467 | 473 | 474 | 478 | 481 | 486 | 491 | 496 | 497 | 503 | 504 | 508 | 511 | 516 | 521 | 522 | 528 | 529 | 530 | 534 | 538 | 542 | 545 | 550 | 555 | 556 | 574 | 575 | 579 | 582 | 587 | 592 | 593 | 611 | 612 | 613 | 614 | 615 | -------------------------------------------------------------------------------- /brand/logo_25percent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo_25percent.png -------------------------------------------------------------------------------- /brand/logo_50percent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qqwy/elixir-specify/e0cfc89a8d043de73da6729e19ad5d0ca6ebd173/brand/logo_50percent.png -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :specify, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:specify, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env()}.exs" 31 | -------------------------------------------------------------------------------- /lib/specify.ex: -------------------------------------------------------------------------------- 1 | defmodule Specify do 2 | @moduledoc """ 3 | Specify allows you to make your configuration explicit: 4 | 5 | - Specify exactly what fields are expected. 6 | - Specify exactly what values these fields might take, by giving them parser-functions. 7 | - Load their values from a slew of different locations, with 'explicitly passed in to the function' as final option. 8 | """ 9 | 10 | defmodule MissingRequiredFieldsError do 11 | @moduledoc """ 12 | Default exception to be raised when a required field is not existent in any configuration source. 13 | """ 14 | defexception [:message] 15 | end 16 | 17 | defmodule ParsingError do 18 | @moduledoc """ 19 | Default exception to be raised when it is impossible to parse one of the configuration values. 20 | 21 | (See also `Specify.Parsers`) 22 | """ 23 | defexception [:message] 24 | end 25 | 26 | defmodule Schema do 27 | @moduledoc """ 28 | Functions that can be used inside `Specify.defconfig/2`. 29 | """ 30 | 31 | @doc """ 32 | Specifies a field that is part of the configuration struct. 33 | 34 | Can/should only be called inside a call to `Specify.defconfig`. 35 | 36 | - `name` should be an atom representing the field. It will also become the field name for the struct that is created. 37 | - `parser` should either be: 38 | - an arity-one function capture like `&YourModule.some_type_parser/1`. 39 | - An atom representing one of the common parser function names in `Specify.Parsers` like `:integer`, `:string`, `:boolean` or `:term`. 40 | - A two-element tuple like `{:list, :atom}`. The first element represents the 'collection parser' which is an arity-2 function that takes the 'element parser' as second argument. The second element is the 'element parser'. Both of the elements listed in the tuple can also be either an atom, or a function capture with the correct arity. (like `{&YourAwesomeModule.fancy_collection/2, :integer}`). 41 | 42 | `parser` defaults to `:string`. 43 | 44 | Supported field options are: 45 | 46 | - `default:`, supplies a default value to this field. If not set, the configuration field is set to be _required_. 47 | 48 | You are highly encouraged to add a `@doc`umentation text above each and every field; 49 | these will be added to the configuration's module documentation. 50 | """ 51 | defmacro field(name, parser \\ :string, options \\ []) do 52 | quote do 53 | field_documentation = Module.delete_attribute(__MODULE__, :doc) 54 | 55 | field_documentation = 56 | case field_documentation do 57 | {_line, val} -> 58 | val 59 | 60 | nil -> 61 | IO.warn( 62 | "Missing documentation for configuration field `#{unquote(name)}`. Please add it by adding `@doc \"field documentation here\"` above the line where you define it." 63 | ) 64 | 65 | "" 66 | end 67 | 68 | Specify.__field__( 69 | __MODULE__, 70 | unquote(name), 71 | unquote(parser), 72 | field_documentation, 73 | unquote(options) 74 | ) 75 | end 76 | end 77 | end 78 | 79 | @doc """ 80 | Defines a configuration structure in the current module. 81 | 82 | Fields are added to this configuration structure by calling `Specify.Schema.field/3` 83 | (which can be called just as `field` because `Specify.Schema` is autoamatically imported into the 84 | inner context of the call to `defconfig`.) 85 | 86 | The `options` that can be passed to this module are used as defaults for the options passed to a call to `Specify.load/2` or `YourModule.load/1`. 87 | 88 | See also `Specify.Schema.field/3` and `Specify.Options` 89 | 90 | ## Reflection 91 | 92 | The special function `__specify__/1` will be defined on the module as well. It is not intended to be used 93 | by people that want to consume your configuration, 94 | but it is there to e.g. allow `Specify.Provider` implementations to be smarter 95 | in how they fetch the configuration for the module. For instance, configuration 96 | might be lazily fetched, when knowing what field names exist beforehand. 97 | 98 | `YourModule.__specify__/1` supports the following publicly usable parameters: 99 | 100 | - `__specify__(:field_names)` returns a MapSet of atoms, one per field in the configuration structure. 101 | - `__specify__(:defaults)` returns a Map containing only the `field_name => value`s of field names having default values. 102 | - `__specify__(:requireds)` returns a MapSet of atoms, one per required field in the configuration structure. 103 | - `__specify__(:parsers)` returns a Map of the format `field_name => parser`. 104 | - `__specify__(:field_options)` returns a Map of the format `field_name => options`, where `options` is the keyword-list that was passed to the `field` macro. 105 | """ 106 | defmacro defconfig(options \\ [], do: block) do 107 | quote do 108 | import Specify.Schema 109 | Module.register_attribute(__MODULE__, :config_fields, accumulate: true) 110 | 111 | try do 112 | unquote(block) 113 | after 114 | config_fields = 115 | Module.get_attribute(__MODULE__, :config_fields) 116 | |> Enum.reverse() 117 | 118 | {line_number, existing_moduledoc} = 119 | Module.delete_attribute(__MODULE__, :moduledoc) || {0, ""} 120 | 121 | Module.put_attribute( 122 | __MODULE__, 123 | :moduledoc, 124 | {line_number, existing_moduledoc <> Specify.__config_doc__(config_fields)} 125 | ) 126 | 127 | defstruct(Specify.__struct_fields__(config_fields)) 128 | 129 | # Reflection; part of 'public API' for Config Providers, 130 | # but not of public API for consumers of '__MODULE__'. 131 | @field_names Specify.__field_names__(config_fields) 132 | @field_options Specify.__field_options__(config_fields) 133 | @defaults Specify.__defaults__(config_fields) 134 | @required_fields Specify.__required_fields__(config_fields) 135 | @parsers Specify.__parsers__(config_fields) 136 | 137 | # Super secret private reflection; doing this at compile-time speeds up `load`. 138 | @la_defaults for {name, val} <- @defaults, into: %{}, do: {name, [val]} 139 | @la_requireds for name <- @required_fields, into: %{}, do: {name, []} 140 | @loading_accumulator Map.merge(@la_defaults, @la_requireds) 141 | 142 | @doc false 143 | def __specify__(:field_names), do: @field_names 144 | def __specify__(:field_options), do: @field_options 145 | def __specify__(:defaults), do: @defaults 146 | def __specify__(:required_fields), do: @required_fields 147 | def __specify__(:parsers), do: @parsers 148 | def __specify__(:__loading_begin_accumulator__), do: @loading_accumulator 149 | 150 | @doc """ 151 | Loads, parses, and normalizes the configuration of `#{inspect(__MODULE__)}`, based on the current source settings, returning the result as a struct. 152 | 153 | For more information about the options this function supports, see 154 | `Specify.load/2` and `Specify.Options` 155 | """ 156 | def load(options \\ []), do: Specify.load(__MODULE__, options ++ unquote(options)) 157 | 158 | @doc """ 159 | Loads, parses and normalizes the configuration of `#{inspect(__MODULE__)}`, using the provided `explicit_values` (and falling back to values configured elsewhere) 160 | 161 | For more information about the options this function supports, see 162 | `Specify.load_explicit/3` and `Specify.Options` 163 | """ 164 | def load_explicit(explicit_values, options \\ []), 165 | do: Specify.load_explicit(__MODULE__, explicit_values, options ++ unquote(options)) 166 | 167 | :ok 168 | end 169 | end 170 | end 171 | 172 | @doc """ 173 | Loads, parses, and normalizes the configuration of `config_module`, based on the current source settings, returning the result as a struct. 174 | 175 | (This is the more general way of calling `config_module.load/1`). 176 | 177 | See `Specify.Options` for more information of the options that can be supplied to this function, 178 | and how it can be configured further. 179 | """ 180 | def load(config_module, options \\ []) do 181 | explicit_values = 182 | (options[:explicit_values] || []) 183 | |> Enum.to_list() 184 | 185 | prevent_improper_explicit_values!(config_module, explicit_values) 186 | 187 | options = parse_options(config_module, options) 188 | 189 | # Values explicitly passed in are always the last, highest priority source. 190 | sources = options.sources ++ [explicit_values] 191 | sources_configs = load_sources_configs(config_module, sources) 192 | 193 | if options.explain do 194 | sources_configs 195 | else 196 | prevent_missing_required_fields!(config_module, sources_configs, options) 197 | 198 | parsers = config_module.__specify__(:parsers) 199 | 200 | sources_configs 201 | |> Enum.map(&try_load_and_parse!(&1, parsers, config_module, options)) 202 | |> (fn config -> struct(config_module, config) end).() 203 | end 204 | end 205 | 206 | @doc """ 207 | Loads, parses and normalizes the configuration of `config_module`, using the provided `explicit_values` (and falling back to values configured elsewhere) 208 | 209 | This call is conceptually the same as `Specify.load(config_module, [explicit_values: [] | options])`, but makes it more explicit that values 210 | are meant to be passed in as arguments. 211 | 212 | Prefer this function if you do not intend to use Specify's 'cascading configuration' functionality, such as when e.g. just parsing options passed to a function, 213 | `use`-statement or other macro. 214 | """ 215 | def load_explicit(config_module, explicit_values, options \\ []) do 216 | full_options = put_in(options, [:explicit_values], explicit_values) 217 | load(config_module, full_options) 218 | end 219 | 220 | # Raises if `explicit_values` contains keys that are not part of the configuration structure of `config_module`. 221 | defp prevent_improper_explicit_values!(config_module, explicit_values) do 222 | improper_explicit_values = 223 | explicit_values 224 | |> Keyword.keys() 225 | |> MapSet.new() 226 | |> MapSet.difference(config_module.__specify__(:field_names)) 227 | 228 | if(Enum.any?(improper_explicit_values)) do 229 | raise ArgumentError, 230 | "The following fields passed as `:explicit_values` are not part of `#{ 231 | inspect(config_module) 232 | }`'s fields: `#{improper_explicit_values |> Enum.map(&inspect/1) |> Enum.join(", ")}`." 233 | end 234 | end 235 | 236 | # Raises appropriate error if required fields of `config_module` are missing in `sources_configs`. 237 | defp prevent_missing_required_fields!(config_module, sources_configs, options) do 238 | missing_required_fields = 239 | sources_configs 240 | |> Enum.filter(fn {_key, value} -> value == [] end) 241 | |> Enum.into(%{}) 242 | 243 | if Enum.any?(missing_required_fields) do 244 | field_names = Map.keys(missing_required_fields) 245 | 246 | raise options.missing_fields_error, 247 | "Missing required fields for `#{config_module}`: `#{ 248 | field_names |> Enum.map(&inspect/1) |> Enum.join(", ") 249 | }`." 250 | end 251 | end 252 | 253 | # Loads the listed `sources` in turn, warning for missing ones. 254 | defp load_sources_configs(config_module, sources) do 255 | sources 256 | |> Enum.map(&load_source(&1, config_module)) 257 | |> reject_and_warn_unloadable_sources(config_module) 258 | |> list_of_configs2config_of_lists(config_module) 259 | end 260 | 261 | # Attempts to parse the highest-priority value of a given `name`. 262 | # Upon failure, raises an appropriate error. 263 | defp try_load_and_parse!({name, values}, parsers, config_module, options) do 264 | parser = construct_parser(parsers[name]) 265 | 266 | case parser.(hd(values)) do 267 | {:ok, value} -> 268 | {name, value} 269 | 270 | {:error, reason} -> 271 | raise options.parsing_error, 272 | reason <> 273 | " (required for loading the field `#{inspect(name)}` of `#{inspect(config_module)}`)" 274 | 275 | other -> 276 | raise ArgumentError, 277 | "Improper Specify configuration parser result. Parser `#{inspect(parsers[name])}` is supposed to return either {:ok, val} or {:error, reason} but instead, `#{ 278 | inspect(other) 279 | }` was returned." 280 | end 281 | end 282 | 283 | defp construct_parser(parser_list) when is_list(parser_list) do 284 | parser_funs = Enum.map(parser_list, &construct_parser(&1)) 285 | 286 | fn thing -> Enum.find_value( 287 | parser_funs, 288 | {:error, "No validating parser found"}, 289 | fn parser_fun -> 290 | case parser_fun.(thing) do 291 | {:ok, val} -> 292 | {:ok, val} 293 | 294 | {:error, _} -> 295 | nil 296 | end 297 | end 298 | ) 299 | end 300 | end 301 | 302 | defp construct_parser({collection_parser, elem_parser}) do 303 | fn thing -> collection_parser.(thing, elem_parser) end 304 | end 305 | 306 | defp construct_parser(elem_parser) do 307 | elem_parser 308 | end 309 | 310 | # Parses `options` into a normalized `Specify.Options` struct. 311 | defp parse_options(config_module, options) 312 | # Catch bootstrapping-case 313 | defp parse_options(Specify.Options, options) do 314 | %{ 315 | __struct__: Specify.Options, 316 | sources: 317 | options[:sources] || 318 | Process.get(:specify, [])[:sources] || 319 | Application.get_env(Specify, :sources) || 320 | [], 321 | missing_fields_error: 322 | options[:missing_fields_error] || 323 | Process.get(Specify, [])[:missing_fields_error] || 324 | Application.get_env(Specify, :missing_fields_error) || 325 | Specify.MissingRequiredFieldsError, 326 | parsing_error: 327 | options[:parsing_error] || 328 | Process.get(Specify, [])[:parsing_error] || 329 | Application.get_env(Specify, :parsing_error) || 330 | Specify.ParsingError, 331 | explain: 332 | options[:explain] || 333 | false 334 | } 335 | end 336 | 337 | defp parse_options(_config_module, options), do: Specify.Options.load(explicit_values: options) 338 | 339 | # Turns a list of Access-implementations into a map of lists. 340 | # In the end, empty values will look like `key: []`. 341 | # And filled ones like `key: [something | ...]` 342 | defp list_of_configs2config_of_lists(list_of_configs, config_module) do 343 | begin_accumulator = config_module.__specify__(:__loading_begin_accumulator__) 344 | 345 | list_of_configs 346 | |> Enum.reduce(begin_accumulator, fn config, acc -> 347 | :maps.map( 348 | fn key, values_list -> 349 | case Access.fetch(config, key) do 350 | {:ok, val} -> [val | values_list] 351 | :error -> values_list 352 | end 353 | end, 354 | acc 355 | ) 356 | end) 357 | end 358 | 359 | defp load_source(source, config_module) do 360 | {source, Specify.Provider.load(source, config_module)} 361 | end 362 | 363 | # Logs errors on sources that cannot be found, 364 | # and transforms `{source, {:ok, config}} -> config` for all successful configurations. 365 | defp reject_and_warn_unloadable_sources(sources_configs, config_module) do 366 | require Logger 367 | 368 | sources_configs 369 | |> Enum.flat_map(fn 370 | {_source, {:ok, config}} -> 371 | [config] 372 | 373 | {source, {:error, error}} -> 374 | case error do 375 | :not_found -> 376 | Logger.warn(""" 377 | While loading the configuration `#{inspect(config_module)}`, the source `#{ 378 | inspect(source) 379 | }` could not be found. 380 | Please make sure it exists. 381 | In the case you do not need this source, consider removing this source from the `sources:` list. 382 | """) 383 | 384 | :malformed -> 385 | Logger.warn(""" 386 | While loading the configuration `#{inspect(config_module)}`, found out that 387 | it was not possible to parse the configuration inside #{inspect(source)}. 388 | This usually indicates a grave problem! 389 | """) 390 | end 391 | 392 | [] 393 | end) 394 | end 395 | 396 | @doc false 397 | # Handles the actual work of the `field` macro. 398 | def __field__(module, name, parser, field_documentation, options) do 399 | normalized_parser = normalize_parser(parser) 400 | 401 | Module.put_attribute(module, :config_fields, %{ 402 | name: name, 403 | parser: normalized_parser, 404 | original_parser: parser, 405 | documentation: field_documentation, 406 | options: options 407 | }) 408 | end 409 | 410 | # Extracts the struct definition keyword list 411 | # from the outputs of the list of `field` calls. 412 | @doc false 413 | def __struct_fields__(config_fields) do 414 | config_fields 415 | |> Enum.map(fn %{name: name, options: options} -> 416 | {name, options[:default]} 417 | end) 418 | end 419 | 420 | @doc false 421 | # Builds the module documentation 422 | # for the configuration. 423 | # This includes information on each of the fields, 424 | # with the user-supplied documentation description, 425 | # as well as the used parser and potential default value. 426 | def __config_doc__(config_fields) do 427 | acc = 428 | config_fields 429 | |> Enum.reduce("", fn %{ 430 | name: name, 431 | parser: parser, 432 | original_parser: original_parser, 433 | documentation: documentation, 434 | options: options 435 | }, 436 | acc -> 437 | doc = """ 438 | 439 | ### #{name} 440 | 441 | #{documentation} 442 | 443 | #{parser_doc(parser, original_parser)} 444 | """ 445 | 446 | doc = 447 | case Access.fetch(options, :default) do 448 | {:ok, val} -> 449 | """ 450 | #{doc} 451 | Defaults to#{clever_prettyprint(val)} 452 | """ 453 | 454 | :error -> 455 | """ 456 | #{doc} 457 | Required field. 458 | """ 459 | end 460 | 461 | acc <> doc 462 | end) 463 | 464 | """ 465 | ## Configuration structure documentation: 466 | 467 | This configuration was made using the `Specify` library. 468 | It contains the following fields: 469 | 470 | #{acc} 471 | """ 472 | end 473 | 474 | # Render a multiline Markdown code block if `value` is large enough 475 | # to be pretty-printed across multiple lines. 476 | # otherwise, render an inline Markdown code block. 477 | # Functions are an exception: there are always on their own line to 478 | # make then clickable. 479 | defp clever_prettyprint(f) when is_function(f) do 480 | "&" <> f_str = inspect(f) 481 | 482 | "`#{f_str}`." 483 | end 484 | 485 | defp clever_prettyprint(value) do 486 | inspected = Kernel.inspect(value, printable_limit: :infinity, limit: :infinity, width: 80, pretty: true) 487 | if String.contains?(inspected, "\n") do 488 | """ 489 | : 490 | ``` 491 | #{inspected} 492 | ``` 493 | """ 494 | else 495 | " `#{inspected}`." 496 | end 497 | end 498 | 499 | defp parser_doc(parser, original_parser) do 500 | case original_parser do 501 | atom when is_atom(atom) -> 502 | """ 503 | Validated/parsed by calling `#{Macro.to_string(parser) |> String.trim_leading("&")}`. 504 | 505 | (Specified as `#{inspect(atom)}`) 506 | """ 507 | 508 | {collection_parser, parser} -> 509 | """ 510 | Validated/parsed by calling `fn thing -> (#{ 511 | Macro.to_string(normalize_parser(collection_parser, 2)) |> String.trim_leading("&") 512 | }).(thing, #{Macro.to_string(normalize_parser(parser))}) end`. 513 | 514 | (Specified as `{#{Macro.to_string(collection_parser)}, #{Macro.to_string(parser)}}`) 515 | """ 516 | 517 | _other -> 518 | """ 519 | Validated/parsed by calling `#{Macro.to_string(parser) |> String.trim_leading("&")}`. 520 | """ 521 | end 522 | end 523 | 524 | @doc false 525 | # Builds a map of fields with default values. 526 | def __defaults__(config_fields) do 527 | config_fields 528 | |> Enum.filter(fn %{options: options} -> 529 | case Access.fetch(options, :default) do 530 | {:ok, _} -> true 531 | :error -> false 532 | end 533 | end) 534 | |> Enum.map(fn %{name: name, options: options} -> 535 | {name, options[:default]} 536 | end) 537 | |> Enum.into(%{}) 538 | end 539 | 540 | @doc false 541 | # Builds a MapSet of all the required fields 542 | def __required_fields__(config_fields) do 543 | config_fields 544 | |> Enum.filter(fn %{options: options} -> 545 | case Access.fetch(options, :default) do 546 | :error -> true 547 | _ -> false 548 | end 549 | end) 550 | |> Enum.map(fn %{name: name} -> 551 | name 552 | end) 553 | |> MapSet.new() 554 | end 555 | 556 | @doc false 557 | # Builds a MapSet of all the fields 558 | def __field_names__(config_fields) do 559 | config_fields 560 | |> Enum.map(fn %{name: name} -> name end) 561 | |> MapSet.new() 562 | end 563 | 564 | @doc false 565 | # Builds a MapSet of all the fields 566 | def __field_options__(config_fields) do 567 | config_fields 568 | |> Enum.map(fn %{name: name, options: options} -> {name, options} end) 569 | |> Enum.into(%{}) 570 | end 571 | 572 | @doc false 573 | # Builds a map of parsers for the fields. 574 | def __parsers__(config_fields) do 575 | config_fields 576 | |> Enum.map(fn %{name: name, parser: parser} -> 577 | {name, parser} 578 | end) 579 | |> Enum.into(%{}) 580 | end 581 | 582 | # Replaces simplified atom parsers with 583 | # an actual reference to the parser function in `Specify.Parsers`. 584 | defp normalize_parser(parser, arity \\ 1) 585 | 586 | defp normalize_parser(parsers, _arity) when is_list(parsers) do 587 | Enum.map(parsers, &normalize_parser(&1)) 588 | end 589 | 590 | defp normalize_parser(parser, arity) when is_atom(parser) do 591 | case Specify.Parsers.__info__(:functions)[parser] do 592 | nil -> 593 | raise ArgumentError, 594 | "Parser shorthand `#{inspect(parser)}` was not recognized. Only atoms representing names of functions that live in `Specify.Parsers` are." 595 | 596 | ^arity -> 597 | Function.capture(Specify.Parsers, parser, arity) 598 | end 599 | end 600 | 601 | defp normalize_parser({collection_parser, elem_parser}, _arity) do 602 | {normalize_parser(collection_parser, 2), normalize_parser(elem_parser, 1)} 603 | end 604 | 605 | defp normalize_parser(other, _arity), do: other 606 | end 607 | -------------------------------------------------------------------------------- /lib/specify/options.ex: -------------------------------------------------------------------------------- 1 | defmodule Specify.Options do 2 | require Specify 3 | 4 | @moduledoc """ 5 | This struct represents the options you can pass 6 | to a call of `Specify.load/2` (or `YourModule.load/1`). 7 | 8 | ### Metaconfiguration 9 | 10 | Besides making it nice and explicit to have the options listed here, 11 | `Specify.Options` has itself been defined using `Specify.defconfig/2`, 12 | which means that it (and thus what default options are passed on to to other Specify configurations) 13 | can be configured in the same way. 14 | 15 | """ 16 | 17 | @doc false 18 | def list_of_sources(sources) do 19 | res = 20 | Enum.reduce_while(sources, [], fn 21 | source, acc -> 22 | case source do 23 | source = %struct_module{} -> 24 | Protocol.assert_impl!(Specify.Provider, struct_module) 25 | {:cont, [source | acc]} 26 | source when is_atom(source) -> 27 | parse_source_module(source, acc) 28 | {module, args} when is_atom(module) and is_map(args) -> 29 | source = struct(module, args) 30 | Protocol.assert_impl!(Specify.Provider, module) 31 | {:cont, [source | acc]} 32 | {module, fun, args} when is_atom(module) and is_atom(fun) and is_map(args) -> 33 | source = %struct_module{} = Kernel.apply(module, fun, args) 34 | Protocol.assert_impl!(Specify.Provider, struct_module) 35 | {:cont, [source | acc]} 36 | end 37 | end) 38 | 39 | case res do 40 | {:error, error} -> {:error, error} 41 | sources_list -> {:ok, Enum.reverse(sources_list)} 42 | end 43 | end 44 | 45 | defp parse_source_module(module, acc) do 46 | case module.__info__(:functions)[:new] do 47 | 0 -> 48 | source = %struct_module{} = module.new() 49 | Protocol.assert_impl!(Specify.Provider, struct_module) 50 | {:cont, [source | acc]} 51 | 52 | _ -> 53 | {:halt, 54 | {:error, 55 | "`#{inspect(module)}` does not seem to have an appropriate default `new/0` function. Instead, pass a full-fledged struct (like `%#{inspect(module)}{}`), or one use one of the other ways to specify a source. \n\n(See the documentation of `Specify.Options.sources` for more information)"}} 56 | end 57 | end 58 | 59 | Specify.defconfig do 60 | @doc """ 61 | A list of structures that implement the `Specify.Provider` protocol, which will be used to fetch configuration from. 62 | Later entries in the list take precedence over earlier entries. 63 | Defaults always have the lowest precedence, and `:explicit_values` always have the highest precedence. 64 | 65 | A source can be: 66 | - A struct. Example: `%Specify.Provider.SystemEnv{}`; 67 | - A module that has a `new/0`-method which returns a struct. Example: `Specify.Provider.SystemEnv`; 68 | - A tuple, whose first argument is a module and second argument is a map of arguments. This will be turned into a full-blown struct at startup using `Kernel.struct/2`. Example: `{Specify.Provider.SystemEnv, %{prefix: "CY", optional: true}}`; 69 | - A {module, function, arguments}-tuple, which will be called on startup. It should return a struct. Example: `{Specify.Provider.SystemEnv, :new, ["CY", [optional: true]]}`. 70 | 71 | In all cases, the struct should implement the `Specify.Provider` protocol (and this is enforced at startup). 72 | """ 73 | field(:sources, &Specify.Options.list_of_sources/1, default: []) 74 | 75 | @doc """ 76 | A list or map (or other enumerable) representing explicit values 77 | that are to be used instead of what can be found in the implicit sources stack. 78 | """ 79 | field(:explicit_values, :term, default: []) 80 | 81 | @doc """ 82 | The error to be raised if a missing field which is required has been encountered. 83 | """ 84 | field(:missing_fields_error, :term, default: Specify.MissingRequiredFieldsError) 85 | 86 | @doc """ 87 | The error to be raised if a field value could not properly be parsed. 88 | """ 89 | field(:parsing_error, :term, default: Specify.ParsingError) 90 | 91 | @doc """ 92 | When set to `true`, rather than returning the config struct, 93 | a map is returned with every field-key containing a list of consecutive found values. 94 | 95 | This is useful for debugging. 96 | """ 97 | field(:explain, :boolean, default: false) 98 | end 99 | 100 | {line_number, existing_moduledoc} = Module.delete_attribute(__MODULE__, :moduledoc) || {0, ""} 101 | 102 | Module.put_attribute( 103 | __MODULE__, 104 | :moduledoc, 105 | {line_number, 106 | existing_moduledoc <> 107 | """ 108 | ## Metaconfiguration Gotcha's 109 | 110 | Specify will only be able to find a source after it knows it exists. 111 | This means that it is impossible to define a different set of sources inside an external source. 112 | 113 | For this special case, Specify will look at the current process' Process dictionary, 114 | falling back to the Application environment (also known as the Mix environment), 115 | and finally falling back to an empty list of sources (its default). 116 | 117 | So, from lowest to highest precedence, option values are loaded in this order: 118 | 119 | 1. Specify.Options default 120 | 2. Application Environment `:specify` 121 | 3. Process Dictionary `:specify` field 122 | 4. Options passed to `Specify.defconfig` 123 | 5. Options passed to `YourModule.load` 124 | 125 | Requiring Specify to be configured in such an even more general way seems highly unlikely. 126 | If the current approach does turn out to not be good enough for your use-case, 127 | please open an issue on Specify's issue tracker. 128 | """} 129 | ) 130 | end 131 | -------------------------------------------------------------------------------- /lib/specify/parsers.ex: -------------------------------------------------------------------------------- 1 | defmodule Specify.Parsers do 2 | @moduledoc """ 3 | Simple functions to parse strings to datatypes commonly used during configuration. 4 | 5 | These functions can be used as parser/validator function in a call to `Specify.Schema.field`, 6 | by using their shorthand name (`:integer` as shorthand for `&Specify.Parsers.integer/1`). 7 | 8 | (Of course, using their longhand name works as well.) 9 | 10 | 11 | ## Defining your own parser function 12 | 13 | A parser function receives the to-be-parsed/validated value as input, 14 | and should return `{:ok, parsed_val}` on success, 15 | or `{:error, reason}` on failure. 16 | 17 | Be aware that depending on where the configuration is loaded from, 18 | the to-be-parsed value might be a binary string, 19 | or already the Elixir type you want to convert it to. 20 | 21 | """ 22 | 23 | @doc """ 24 | Parses an integer and turns binary string representing an integer into an integer. 25 | """ 26 | def integer(int) when is_integer(int), do: {:ok, int} 27 | 28 | def integer(binary) when is_binary(binary) do 29 | case Integer.parse(binary) do 30 | {int, ""} -> {:ok, int} 31 | {_int, _rest} -> {:error, "the binary `#{binary}` cannot be parsed to an integer."} 32 | :error -> {:error, "the binary `#{binary}` cannot be parsed to an integer."} 33 | end 34 | end 35 | 36 | def integer(other), do: {:error, "#{inspect(other)} is not an integer."} 37 | 38 | @doc """ 39 | Similar to integer/1, but only accepts integers larger than 0. 40 | """ 41 | def positive_integer(val) do 42 | with {:ok, int} <- integer(val) do 43 | if int > 0 do 44 | {:ok, int} 45 | else 46 | {:error, "integer #{int} is not a positive integer."} 47 | end 48 | end 49 | end 50 | 51 | @doc """ 52 | Similar to integer/1, but only accepts integers larger than or equal to 0. 53 | """ 54 | def nonnegative_integer(val) do 55 | with {:ok, int} <- integer(val) do 56 | if int >= 0 do 57 | {:ok, int} 58 | else 59 | {:error, "integer #{int} is not a nonnegative integer."} 60 | end 61 | end 62 | end 63 | 64 | @doc """ 65 | Parses a float and turns a binary string representing a float into an float. 66 | 67 | Will also accept integers, which are turned into their float equivalent. 68 | """ 69 | def float(float) when is_float(float), do: {:ok, float} 70 | def float(int) when is_integer(int), do: {:ok, 1.0 * int} 71 | 72 | def float(binary) when is_binary(binary) do 73 | case Float.parse(binary) do 74 | {float, ""} -> {:ok, float} 75 | {_float, _rest} -> {:error, "the binary `#{binary}` cannot be parserd to a float."} 76 | :error -> {:error, "the binary `#{binary}` cannot be parserd to a float."} 77 | end 78 | end 79 | 80 | def float(other), do: {:error, "`#{inspect(other)}` is not a float"} 81 | 82 | @doc """ 83 | Similar to float/1, but only accepts floats larger than 0. 84 | """ 85 | def positive_float(val) do 86 | with {:ok, float} <- float(val) do 87 | if float > 0 do 88 | {:ok, float} 89 | else 90 | {:error, "float #{float} is not a positive float."} 91 | end 92 | end 93 | end 94 | 95 | @doc """ 96 | Similar to float/1, but only accepts floats larger than or equal to 0. 97 | """ 98 | def nonnegative_float(val) do 99 | with {:ok, float} <- float(val) do 100 | if float >= 0 do 101 | {:ok, float} 102 | else 103 | {:error, "float #{float} is not a nonnegative float."} 104 | end 105 | end 106 | end 107 | 108 | @doc """ 109 | Parses a binary string and turns anything that implements `String.Chars` into its binary string representation by calling `to_string/1` on it. 110 | """ 111 | def string(binary) when is_binary(binary), do: {:ok, binary} 112 | 113 | def string(thing) do 114 | try do 115 | {:ok, to_string(thing)} 116 | rescue 117 | ArgumentError -> 118 | {:error, 119 | "`#{inspect(thing)}` cannot be converted to string because it does not implement the String.Chars protocol."} 120 | end 121 | end 122 | 123 | @doc """ 124 | Accepts any Elixir term as-is. Will not do any parsing. 125 | 126 | Only use this as a last resort. It is usually better to create your own dedicated parsing function instead. 127 | """ 128 | def term(anything), do: {:ok, anything} 129 | 130 | @doc """ 131 | Parses a boolean or a binary string representing a boolean value, turning it into a boolean. 132 | """ 133 | def boolean(boolean) when is_boolean(boolean), do: {:ok, boolean} 134 | 135 | def boolean(binary) when is_binary(binary) do 136 | case binary |> Macro.underscore() do 137 | "true" -> {:ok, true} 138 | "false" -> {:ok, false} 139 | _ -> {:error, "`#{binary}` cannot be parsed to a boolean."} 140 | end 141 | end 142 | 143 | def boolean(other), do: {:error, "`#{inspect(other)}` is not a boolean."} 144 | 145 | @doc """ 146 | Parses an atom or a binary string representing an (existing) atom. 147 | 148 | Will not create new atoms (See `String.to_existing_atom/1` for more info). 149 | """ 150 | def atom(atom) when is_atom(atom), do: {:ok, atom} 151 | 152 | def atom(binary) when is_binary(binary) do 153 | try do 154 | {:ok, String.to_existing_atom(binary)} 155 | rescue 156 | ArgumentError -> 157 | {:error, "`#{binary}` is not an existing atom."} 158 | end 159 | end 160 | 161 | def atom(other), do: {:error, "`#{inspect(other)}` is not an (existing) atom."} 162 | 163 | @doc """ 164 | Parses an atom or a binary string representing an (potentially not yet existing!) atom. 165 | 166 | Will create new atoms. Whenever possible, consider using `atom/1` instead. 167 | (See `String.to_atom/1` for more info on why creating new atoms is usually a bad idea). 168 | """ 169 | def unsafe_atom(atom) when is_atom(atom), do: {:ok, atom} 170 | 171 | def unsafe_atom(binary) when is_binary(binary) do 172 | {:ok, String.to_atom(binary)} 173 | end 174 | 175 | def unsafe_atom(other), do: {:error, "`#{inspect(other)}` is not convertible to an atom."} 176 | 177 | @doc """ 178 | Parses a list of elements. 179 | 180 | In the case a binary string was passed, this parser uses `Code.string_to_quoted` under the hood to check for Elixir syntax, and will only accepts binaries representing lists. 181 | 182 | If a list was passed in (or after turning a binary into a list), it will try to parse each of the elements in turn. 183 | """ 184 | def list(list, elem_parser) when is_list(list) do 185 | res_list = 186 | Enum.reduce_while(list, [], fn 187 | elem, acc -> 188 | case elem_parser.(elem) do 189 | {:ok, res} -> 190 | {:cont, [res | acc]} 191 | 192 | {:error, reason} -> 193 | {:halt, 194 | {:error, 195 | "One of the elements of input list `#{inspect(list)}` failed to parse: \n#{reason}."}} 196 | end 197 | end) 198 | 199 | case res_list do 200 | {:error, reason} -> 201 | {:error, reason} 202 | 203 | parsed_list when is_list(parsed_list) -> 204 | {:ok, Enum.reverse(parsed_list)} 205 | end 206 | end 207 | 208 | def list(binary, elem_parser) when is_binary(binary) do 209 | case string_to_term(binary) do 210 | {:ok, list_ast} when is_list(list_ast) -> 211 | list_ast 212 | |> Enum.map(&Macro.expand(&1, __ENV__)) 213 | |> list(elem_parser) 214 | 215 | {:ok, _not_a_list} -> 216 | {:error, 217 | "`#{inspect(binary)}`, while parseable as Elixir code, does not represent an Elixir list."} 218 | 219 | {:error, reason} -> 220 | {:error, reason} 221 | end 222 | end 223 | 224 | def list(term, _) do 225 | {:error, "`#{inspect(term)}` does not represent an Elixir list."} 226 | end 227 | 228 | @doc """ 229 | Allows to pass in a 'timeout' which is a common setting for OTP-related features, 230 | accepting either a positive integer, or the atom `:infinity`. 231 | """ 232 | def timeout(raw) do 233 | case positive_integer(raw) do 234 | {:ok, int} -> 235 | {:ok, int} 236 | {:error, _} -> 237 | case atom(raw) do 238 | {:ok, :infinity} -> 239 | {:ok, :infinity} 240 | 241 | {:ok, _} -> 242 | {:error, 243 | "#{inspect(raw)} is neither a positive integer nor the special atom value `:infinity`"} 244 | 245 | {:error, _} -> 246 | {:error, 247 | "`#{inspect(raw)}` is neither a positive integer nor the special atom value `:infinity`"} 248 | end 249 | end 250 | end 251 | 252 | @doc """ 253 | Parses a Module-Function-Arity tuple. 254 | 255 | Accepts it both as Elixir three-element tuple (where the first two elements are atoms, and the third is a nonnegative integer), or as string representation of the same. 256 | 257 | Will also check and ensure that this function is actually defined. 258 | """ 259 | def mfa(raw) when is_binary(raw) do 260 | case string_to_term(raw) do 261 | {:ok, {module, function, arity}} 262 | when is_atom(module) and is_atom(function) and is_integer(arity) -> 263 | mfa({module, function, arity}) 264 | {:ok, _other} -> 265 | {:error, "`#{inspect(raw)}`, while parseable as Elixir code, does not represent a Module-Function-Arity tuple."} 266 | {:error, reason} -> 267 | {:error, reason} 268 | end 269 | end 270 | 271 | def mfa(mfa = {module, function, arity}) when is_atom(module) and is_atom(function) and is_integer(arity) and arity >= 0 do 272 | if function_exported?(module, function, arity) do 273 | {:ok, mfa} 274 | else 275 | {:error, "function #{module}.#{function}/#{arity} does not exist."} 276 | end 277 | end 278 | 279 | def mfa(other_val) do 280 | {:error, "`#{inspect(other_val)}` is not a Module-Function-Arity tuple"} 281 | end 282 | 283 | def unquote_atom(atom) when is_atom(atom) do 284 | {:ok, atom} 285 | end 286 | 287 | def unquote_atom(aliased_atom = {:__aliases__, _, [atom]}) when is_atom(atom) do 288 | case Code.eval_quoted(aliased_atom) do 289 | {result, []} -> 290 | {:ok, result} 291 | other -> 292 | {:error, "`#{inspect(other)}` cannot be unquoted as an atom."} 293 | end 294 | end 295 | 296 | def unquote_atom(other) do 297 | {:error, "`#{inspect(other)}` cannot be unquoted as an atom."} 298 | end 299 | 300 | @doc """ 301 | Parses a function. 302 | 303 | This can be a function capture, or a MFA (Module-Function-Arity) tuple, which will 304 | be transformed into the `&Module.function/arity` capture. 305 | 306 | (So in either case, you end up with a function value 307 | that you can call using the dot operator, i.e. `.()` or `.(maybe, some, args)`). 308 | 309 | ## String Contexts 310 | 311 | For contexts in which values are specified as strings, the parser only supports the MFA format. 312 | This is for security (and ease of parsing) reasons. 313 | """ 314 | def function(raw) when is_binary(raw) or is_tuple(raw) do 315 | with {:ok, {module, function, arity}} <- mfa(raw), 316 | {fun, []} <- Code.eval_quoted(quote do &unquote(module).unquote(function)/unquote(arity) end) do 317 | {:ok, fun} 318 | end 319 | end 320 | 321 | def function(fun) when is_function(fun) do 322 | {:ok, fun} 323 | end 324 | 325 | def function(other) do 326 | {:error, "`#{other}` cannot be parsed as a function."} 327 | end 328 | 329 | @doc """ 330 | Parses an option. 331 | An option is a 2-tuple whose first element is an atom, and the second an arbitrary term. 332 | The following terms are options: 333 | - `{:a, :b}` 334 | - `{MyApp.Module, "Hellow, world!"}` 335 | - `{:some_atom, %{[] => {1, 2, 3, 4, 5}}}` 336 | In the case a binary string was passed, this parser uses `Code.string_to_quoted` under the 337 | hood to parse the terms. 338 | It can be convenently used alongside the list parser to check for keyword list: 339 | `{:list, :option}`. 340 | """ 341 | def option(raw) when is_binary(raw) do 342 | case string_to_term(raw, existing_atoms_only: true) do 343 | {:ok, term} when not is_binary(term) -> 344 | option(term) 345 | 346 | {:ok, term} -> 347 | {:error, "the term `#{inspect(term)}` cannot be parsed to an option."} 348 | 349 | {:error, _} = error -> 350 | error 351 | end 352 | end 353 | 354 | def option({key, value}) when is_atom(key) do 355 | {:ok, {key, value}} 356 | end 357 | 358 | def option(term) do 359 | {:error, "the term `#{inspect(term)}` cannot be parsed to an option."} 360 | end 361 | 362 | defp string_to_term(binary, opts \\ [existing_atoms_only: true]) when is_binary(binary) do 363 | case Code.string_to_quoted(binary, opts) do 364 | {:ok, ast} -> 365 | {:ok, ast_to_term(ast)} 366 | 367 | {:error, _} = error -> 368 | error 369 | end 370 | rescue 371 | e -> 372 | {:error, e} 373 | end 374 | 375 | defp ast_to_term(term) when is_atom(term), do: term 376 | defp ast_to_term(term) when is_integer(term), do: term 377 | defp ast_to_term(term) when is_float(term), do: term 378 | defp ast_to_term(term) when is_binary(term), do: term 379 | defp ast_to_term([]), do: [] 380 | defp ast_to_term([h | t]), do: [ast_to_term(h) | ast_to_term(t)] 381 | defp ast_to_term({a, b}), do: {ast_to_term(a), ast_to_term(b)} 382 | defp ast_to_term({:{}, _place, terms}), 383 | do: terms |> Enum.map(&ast_to_term/1) |> List.to_tuple() 384 | defp ast_to_term({:%{}, _place, terms}), 385 | do: for {k, v} <- terms, into: %{}, do: {ast_to_term(k), ast_to_term(v)} 386 | defp ast_to_term(aliased = {:__aliases__, _, _}), do: Macro.expand(aliased, __ENV__) 387 | defp ast_to_term({:+, _, [number]}), do: number 388 | defp ast_to_term({:-, _, [number]}), do: -number 389 | defp ast_to_term(ast), do: raise ArgumentError, message: "invalid term `#{inspect(ast)}`" 390 | end 391 | -------------------------------------------------------------------------------- /lib/specify/provider.ex: -------------------------------------------------------------------------------- 1 | defprotocol Specify.Provider do 2 | @moduledoc """ 3 | Protocol to load configuration from a signle source. 4 | 5 | Configuration Providers implement this protocol, which consists of only one function: `load/2`. 6 | """ 7 | 8 | @doc """ 9 | Loads the configuration of specification `module` from the source indicated by `struct`. 10 | 11 | Its first argument is the implementation's own struct, the second argument being the configuration specification's module name. 12 | If extra information is required about the configuration specification to write a good implementation, the Reflection function `module_name.__specify__` can be used to look these up. 13 | 14 | See also `Specify.defconfig/2` and `Specify.Options`. 15 | """ 16 | def load(struct, module) 17 | end 18 | 19 | defimpl Specify.Provider, for: List do 20 | def load(list, _module) do 21 | {:ok, Enum.into(list, %{})} 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/specify/provider/mix_env.ex: -------------------------------------------------------------------------------- 1 | defmodule Specify.Provider.MixEnv do 2 | @moduledoc """ 3 | A Configuration Provider source based on `Mix.env()` / `Application.get_env/2`. 4 | 5 | ### Examples 6 | 7 | The following examples use the following specification for reference: 8 | 9 | defmodule Elixir.Pet do 10 | require Specify 11 | Specify.defconfig do 12 | @doc "The name of the pet" 13 | field :name, :string 14 | @doc "is it a dog or a cat?" 15 | field :kind, :atom 16 | end 17 | end 18 | 19 | 20 | """ 21 | defstruct [:application, :key, optional: false] 22 | 23 | @doc """ 24 | By default, will try to use `Application.get_all_env(YourConfigModule)` to fetch the source's configuration. 25 | A different application name can be used by supplying a different `application` argument. 26 | 27 | If the actual configuration is only inside one of the keys in this application, the second field `key` 28 | can also be provided. 29 | 30 | 31 | iex> Application.put_env(Elixir.Pet, :name, "Timmy") 32 | iex> Application.put_env(Elixir.Pet, :kind, "cat") 33 | iex> Pet.load(sources: [Specify.Provider.MixEnv.new()]) 34 | %Pet{name: "Timmy", kind: :cat} 35 | iex> Pet.load(sources: [Specify.Provider.MixEnv.new(Elixir.Pet)]) 36 | %Pet{name: "Timmy", kind: :cat} 37 | 38 | iex> Application.put_env(:second_pet, :name, "John") 39 | iex> Application.put_env(:second_pet, :kind, :dog) 40 | iex> Pet.load(sources: [Specify.Provider.MixEnv.new(:second_pet)]) 41 | %Pet{name: "John", kind: :dog} 42 | 43 | """ 44 | def new(application \\ nil, key \\ nil, options \\ []) do 45 | optional = options[:optional] || false 46 | %__MODULE__{application: application, key: key, optional: optional} 47 | end 48 | 49 | defimpl Specify.Provider do 50 | def load(%Specify.Provider.MixEnv{application: nil, key: nil, optional: optional}, module) do 51 | res = Enum.into(Application.get_all_env(module), %{}) 52 | 53 | case {Enum.empty?(res), optional} do 54 | {true, false} -> 55 | {:error, :not_found} 56 | _ -> 57 | {:ok, res} 58 | end 59 | end 60 | 61 | def load(%Specify.Provider.MixEnv{application: application, key: nil, optional: optional}, _module) do 62 | res = Enum.into(Application.get_all_env(application), %{}) 63 | 64 | case {Enum.empty?(res), optional} do 65 | {true, false} -> 66 | {:error, :not_found} 67 | _ -> 68 | {:ok, res} 69 | end 70 | end 71 | 72 | def load(%Specify.Provider.MixEnv{application: application, key: key, optional: optional}, _module) do 73 | case Application.get_env( 74 | application, 75 | key, 76 | :there_is_no_specify_configuration_in_this_application_environment! 77 | ) do 78 | map when is_map(map) -> 79 | {:ok, map} 80 | 81 | list when is_list(list) -> 82 | {:ok, Enum.into(list, %{})} 83 | 84 | :there_is_no_specify_configuration_in_this_application_environment! -> 85 | if optional do 86 | {:ok, %{}} 87 | else 88 | {:error, :not_found} 89 | end 90 | 91 | _other -> 92 | {:error, :malformed} 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/specify/provider/process.ex: -------------------------------------------------------------------------------- 1 | defmodule Specify.Provider.Process do 2 | @moduledoc """ 3 | A Configuration Provider source based on the current process' Process Dictionary. 4 | 5 | ### Examples 6 | 7 | The following examples use the following specification for reference: 8 | 9 | defmodule Elixir.Pet do 10 | require Specify 11 | Specify.defconfig do 12 | @doc "The name of the pet" 13 | field :name, :string 14 | @doc "is it a dog or a cat?" 15 | field :kind, :atom 16 | end 17 | end 18 | """ 19 | 20 | defstruct [:key, optional: false] 21 | 22 | @doc """ 23 | By default, will try to use `Process.get(YourModule)` to fetch the source's configuration. 24 | A different key can be used by supplying a different `key` argument. 25 | 26 | iex> Process.put(Pet, %{name: "Timmy", kind: :cat}) 27 | iex> Pet.load(sources: [Specify.Provider.Process.new(Pet)]) 28 | %Pet{name: "Timmy", kind: :cat} 29 | 30 | iex> Process.put(:another_pet, %{name: "John", kind: :dog}) 31 | iex> Pet.load(sources: [Specify.Provider.Process.new(:another_pet)]) 32 | %Pet{name: "John", kind: :dog} 33 | """ 34 | 35 | def new(key \\ nil, options \\ []) do 36 | optional = options[:optional] || false 37 | %__MODULE__{key: key, optional: optional} 38 | end 39 | 40 | defimpl Specify.Provider do 41 | def load(%Specify.Provider.Process{key: nil}, module) do 42 | load(%Specify.Provider.Process{key: module}, module) 43 | end 44 | 45 | def load(%Specify.Provider.Process{key: key}, _module) do 46 | case Process.get(key, :there_is_no_specify_configuration_in_this_process_dictionary!) do 47 | map when is_map(map) -> 48 | {:ok, map} 49 | 50 | list when is_list(list) -> 51 | {:ok, Enum.into(list, %{})} 52 | 53 | :there_is_no_specify_configuration_in_this_process_dictionary! -> 54 | {:error, :not_found} 55 | 56 | _other -> 57 | {:error, :malformed} 58 | end 59 | end 60 | end 61 | end 62 | 63 | # TODO: Should we even allow this? 64 | # Looking into another process' dictionary is probably bad style, isn't it? 65 | defimpl Specify.Provider, for: PID do 66 | def load(process, module) do 67 | {:dictionary, res} = Process.info(process, :dictionary) 68 | 69 | case Access.fetch(res, module) do 70 | {:ok, map} when is_map(map) -> 71 | {:ok, map} 72 | 73 | {:ok, list} when is_list(list) -> 74 | {:ok, Enum.into(list, %{})} 75 | 76 | :error -> 77 | {:error, :not_found} 78 | 79 | _other -> 80 | {:error, :malformed} 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/specify/provider/system_env.ex: -------------------------------------------------------------------------------- 1 | defmodule Specify.Provider.SystemEnv do 2 | @moduledoc """ 3 | A Configuration Provider source based on `System.get_env/2` 4 | 5 | Values will be loaded based on `\#{prefix}_\#{capitalized_field_name}`. 6 | `prefix` defaults to the capitalized name of the configuration specification module. 7 | `capitalized_field_name` is in `CONSTANT_CASE` (all-caps, with underscores as word separators). 8 | 9 | ### Examples 10 | 11 | The following examples use the following specification for reference: 12 | 13 | defmodule Elixir.Pet do 14 | require Specify 15 | Specify.defconfig do 16 | @doc "The name of the pet" 17 | field :name, :string 18 | @doc "is it a dog or a cat?" 19 | field :kind, :atom, system_env_name: "TYPE" 20 | end 21 | end 22 | 23 | Note that if a field has a different name than the environment variable you want to read from, 24 | you can add the `system_env_name:` option when specifying the field, as has been done for the `:kind` field 25 | in the example module above. 26 | 27 | iex> System.put_env("PET_NAME", "Timmy") 28 | iex> System.put_env("PET_TYPE", "cat") 29 | iex> Pet.load(sources: [Specify.Provider.SystemEnv.new()]) 30 | %Pet{name: "Timmy", kind: :cat} 31 | iex> Pet.load(sources: [Specify.Provider.SystemEnv.new("PET")]) 32 | %Pet{name: "Timmy", kind: :cat} 33 | 34 | iex> System.put_env("SECOND_PET_NAME", "John") 35 | iex> System.put_env("SECOND_PET_TYPE", "dog") 36 | iex> Pet.load(sources: [Specify.Provider.SystemEnv.new("SECOND_PET")]) 37 | %Pet{name: "John", kind: :dog} 38 | 39 | """ 40 | defstruct [:prefix, optional: false] 41 | 42 | @doc """ 43 | 44 | """ 45 | def new(prefix \\ nil, options \\ []) do 46 | optional = options[:optional] || false 47 | %__MODULE__{prefix: prefix, optional: optional} 48 | end 49 | 50 | defimpl Specify.Provider do 51 | def load(provider = %Specify.Provider.SystemEnv{prefix: nil}, module) do 52 | capitalized_prefix = 53 | module 54 | |> Macro.to_string() 55 | |> String.upcase() 56 | 57 | load(%Specify.Provider.SystemEnv{provider | prefix: capitalized_prefix}, module) 58 | end 59 | 60 | def load(%Specify.Provider.SystemEnv{prefix: prefix, optional: optional}, module) do 61 | full_env = System.get_env() 62 | 63 | res = 64 | Enum.reduce(module.__specify__(:field_options), %{}, fn {name, options}, acc -> 65 | capitalized_field_name = options[:system_env_name] || String.upcase(to_string(name)) 66 | full_field_name = "#{prefix}_#{capitalized_field_name}" 67 | 68 | if Map.has_key?(full_env, full_field_name) do 69 | Map.put(acc, name, full_env[full_field_name]) 70 | else 71 | acc 72 | end 73 | end) 74 | 75 | if res == %{} do 76 | if optional do 77 | {:ok, %{}} 78 | else 79 | {:error, :not_found} 80 | end 81 | else 82 | {:ok, res} 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Specify.MixProject do 2 | @source_url "https://github.com/Qqwy/elixir_specify" 3 | use Mix.Project 4 | 5 | def project do 6 | [ 7 | app: :specify, 8 | version: "0.10.0", 9 | elixir: "~> 1.7", 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | description: description(), 14 | package: package(), 15 | source_url: @source_url, 16 | docs: docs() 17 | ] 18 | end 19 | 20 | # Run "mix help compile.app" to learn about applications. 21 | def application do 22 | [ 23 | extra_applications: [:logger] 24 | ] 25 | end 26 | 27 | # Run "mix help deps" to learn about dependencies. 28 | defp deps do 29 | [ 30 | {:ex_doc, "~> 0.19", only: [:docs], runtime: false}, 31 | # Inch CI documentation quality test. 32 | {:inch_ex, ">= 0.0.0", only: [:docs]}, 33 | {:stream_data, "~> 0.1", only: :test} 34 | ] 35 | end 36 | 37 | defp elixirc_paths(_), do: ["lib"] 38 | 39 | defp description do 40 | """ 41 | Comfortable, Explicit, Multi-Layered and Well-Documented Specifications for all your configurations, settings and options 42 | """ 43 | end 44 | 45 | defp package do 46 | # These are the default files included in the package 47 | [ 48 | name: :specify, 49 | files: ["lib", "mix.exs", "README*", "LICENSE"], 50 | maintainers: ["Wiebe-Marten Wijnja/Qqwy"], 51 | licenses: ["MIT"], 52 | links: %{"GitHub" => @source_url} 53 | ] 54 | end 55 | 56 | defp docs do 57 | [ 58 | main: "readme", 59 | logo: "brand/logo-thicklines-25percent.png", 60 | extras: ["README.md"] 61 | ] 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 3 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm"}, 4 | "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 7 | "makeup": {:hex, :makeup, "1.0.1", "82f332e461dc6c79dbd82fbe2a9c10d48ed07146f0a478286e590c83c52010b5", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm"}, 10 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 11 | "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm"}, 12 | } 13 | -------------------------------------------------------------------------------- /test/provider/mix_env_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Specify.Provider.MixEnvTest do 2 | use ExUnit.Case 3 | use ExUnitProperties 4 | 5 | doctest Specify.Provider.MixEnv 6 | end 7 | -------------------------------------------------------------------------------- /test/provider/process_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Specify.Provider.ProcessTest do 2 | use ExUnit.Case 3 | use ExUnitProperties 4 | 5 | doctest Specify.Provider.Process 6 | end 7 | -------------------------------------------------------------------------------- /test/provider/system_env_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Specify.Provider.SystemEnvTest do 2 | use ExUnit.Case 3 | use ExUnitProperties 4 | 5 | doctest Specify.Provider.SystemEnv 6 | end 7 | -------------------------------------------------------------------------------- /test/specify/parsers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Specify.ParsersTest do 2 | use ExUnit.Case 3 | use ExUnitProperties 4 | 5 | doctest Specify.Parsers 6 | alias Specify.Parsers 7 | 8 | describe "integer/1" do 9 | property "works on integers" do 10 | check all int <- integer() do 11 | assert Parsers.integer(int) === {:ok, int} 12 | end 13 | end 14 | 15 | property "works on binaries representing integers" do 16 | check all int <- integer() do 17 | str = to_string(int) 18 | assert Parsers.integer(str) === {:ok, int} 19 | end 20 | end 21 | 22 | property "fails on binaries representing integers with trailing garbage" do 23 | check all int <- integer(), postfix <- string(:printable), Integer.parse(postfix) == :error, postfix != "" do 24 | str = to_string(int) 25 | assert {:error, _} = Parsers.integer(str <> postfix) 26 | end 27 | end 28 | 29 | property "fails on non-integer terms" do 30 | check all thing <- term() do 31 | if is_integer(thing) do 32 | assert {:ok, thing} = Parsers.integer(thing) 33 | else 34 | if !is_binary(thing) || Integer.parse(thing) == :error do 35 | assert {:error, _} = Parsers.integer(thing) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | 42 | describe "nonnegative_integer/1" do 43 | property "works on non-negative integers, fails on negative integers" do 44 | check all int <- integer() do 45 | if int >= 0 do 46 | assert Parsers.nonnegative_integer(int) === {:ok, int} 47 | else 48 | assert {:error, _} = Parsers.nonnegative_integer(int) 49 | end 50 | end 51 | end 52 | 53 | property "works on binaries representing non-negative integers, fails on binaries representing negative integers" do 54 | check all int <- integer() do 55 | str = to_string(int) 56 | if int >= 0 do 57 | assert Parsers.nonnegative_integer(str) === {:ok, int} 58 | else 59 | assert {:error, _} = Parsers.nonnegative_integer(str) 60 | end 61 | end 62 | end 63 | 64 | property "fails on binaries representing non-negative integers with trailing garbage" do 65 | check all int <- integer(), postfix <- string(:printable), Integer.parse(postfix) == :error, postfix != "" do 66 | str = to_string(int) 67 | assert {:error, _} = Parsers.nonnegative_integer(str <> postfix) 68 | end 69 | end 70 | 71 | property "fails on non-integer terms" do 72 | check all thing <- term() do 73 | if is_integer(thing) && thing >= 0 do 74 | assert {:ok, thing} = Parsers.nonnegative_integer(thing) 75 | else 76 | if !is_binary(thing) || Integer.parse(thing) == :error do 77 | assert {:error, _} = Parsers.nonnegative_integer(thing) 78 | end 79 | end 80 | end 81 | end 82 | end 83 | 84 | describe "positive_integer/1" do 85 | property "works on positive integers, fails on other integers" do 86 | check all int <- integer() do 87 | if int > 0 do 88 | assert Parsers.positive_integer(int) === {:ok, int} 89 | else 90 | assert {:error, _} = Parsers.positive_integer(int) 91 | end 92 | end 93 | end 94 | 95 | property "works on binaries representing positive integers, fails on binaries representing other integers" do 96 | check all int <- integer() do 97 | str = to_string(int) 98 | if int > 0 do 99 | assert Parsers.positive_integer(str) === {:ok, int} 100 | else 101 | assert {:error, _} = Parsers.positive_integer(str) 102 | end 103 | end 104 | end 105 | 106 | property "fails on binaries representing non-negative integers with trailing garbage" do 107 | check all int <- integer(), postfix <- string(:printable), Integer.parse(postfix) == :error, postfix != "" do 108 | str = to_string(int) 109 | assert {:error, _} = Parsers.positive_integer(str <> postfix) 110 | end 111 | end 112 | 113 | property "fails on non-integer terms" do 114 | check all thing <- term() do 115 | if is_integer(thing) && thing > 0 do 116 | assert {:ok, thing} = Parsers.positive_integer(thing) 117 | else 118 | if !is_binary(thing) || Integer.parse(thing) == :error do 119 | assert {:error, _} = Parsers.positive_integer(thing) 120 | end 121 | end 122 | end 123 | end 124 | end 125 | 126 | describe "float/1" do 127 | property "works on floats" do 128 | check all float <- float() do 129 | assert Parsers.float(float) === {:ok, float} 130 | end 131 | end 132 | 133 | property "works on integers" do 134 | check all int <- integer() do 135 | assert Parsers.float(int) === {:ok, int * 1.0} 136 | end 137 | end 138 | 139 | property "works on binaries representing floats" do 140 | check all float <- float() do 141 | str = to_string(float) 142 | assert Parsers.float(str) === {:ok, float} 143 | end 144 | end 145 | 146 | property "fails on binaries representing floats with trailing garbage" do 147 | check all float <- float(), postfix <- string(:printable), Float.parse(postfix) == :error, postfix != "" do 148 | str = to_string(float) 149 | assert {:error, _} = Parsers.float(str <> postfix) 150 | end 151 | end 152 | 153 | property "works on binaries representing integers" do 154 | check all int <- integer() do 155 | str = to_string(int) 156 | assert Parsers.float(str) === {:ok, 1.0 * int} 157 | end 158 | end 159 | 160 | property "fails on non-integer, non-float terms" do 161 | check all thing <- term() do 162 | if(is_float(thing) or is_integer(thing)) do 163 | assert {:ok, 1.0 * thing} == Parsers.float(thing) 164 | else 165 | if !is_binary(thing) || Float.parse(thing) == :error do 166 | assert {:error, _} = Parsers.float(thing) 167 | end 168 | end 169 | end 170 | end 171 | end 172 | 173 | describe "nonnegative_float/1" do 174 | property "works on non-negative floats, fails on negative floats" do 175 | check all float <- float() do 176 | if float >= 0 do 177 | assert Parsers.nonnegative_float(float) === {:ok, float} 178 | else 179 | assert {:error, _} = Parsers.nonnegative_float(float) 180 | end 181 | end 182 | end 183 | 184 | property "works on non-negative integers, fails on negative integers" do 185 | check all int <- integer() do 186 | if int >= 0 do 187 | assert Parsers.nonnegative_float(int) === {:ok, int * 1.0} 188 | else 189 | assert {:error, _} = Parsers.nonnegative_float(int) 190 | end 191 | end 192 | end 193 | 194 | property "works on binaries representing non-negative floats, fails on binaries representing negative floats" do 195 | check all float <- float() do 196 | str = to_string(float) 197 | if float >= 0 do 198 | assert Parsers.nonnegative_float(str) === {:ok, float} 199 | else 200 | assert {:error, _} = Parsers.nonnegative_float(str) 201 | end 202 | end 203 | end 204 | 205 | property "fails on binaries representing floats with trailing garbage" do 206 | check all float <- float(), postfix <- string(:printable), Float.parse(postfix) == :error, postfix != "" do 207 | str = to_string(float) 208 | assert {:error, _} = Parsers.nonnegative_float(str <> postfix) 209 | end 210 | end 211 | 212 | property "works on binaries representing non-negative integers, fails on binaries representing negative integers" do 213 | check all int <- integer() do 214 | str = to_string(int) 215 | if int >= 0 do 216 | assert Parsers.nonnegative_float(str) === {:ok, 1.0 * int} 217 | else 218 | assert {:error, _} = Parsers.nonnegative_float(str) 219 | end 220 | end 221 | end 222 | 223 | property "fails on non-float, non-integer terms" do 224 | check all thing <- term() do 225 | if (is_float(thing) or is_integer(thing)) and thing >= 0 do 226 | assert {:ok, 1.0 * thing} == Parsers.nonnegative_float(thing) 227 | else 228 | if !is_binary(thing) || Float.parse(thing) == :error do 229 | assert {:error, _} = Parsers.nonnegative_float(thing) 230 | end 231 | end 232 | end 233 | end 234 | end 235 | 236 | describe "positive_float/1" do 237 | property "works on positive floats, fails on negative floats" do 238 | check all float <- float() do 239 | if float > 0 do 240 | assert Parsers.positive_float(float) === {:ok, float} 241 | else 242 | assert {:error, _} = Parsers.positive_float(float) 243 | end 244 | end 245 | end 246 | 247 | property "works on positive integers, fails on negative integers" do 248 | check all int <- integer() do 249 | if int > 0 do 250 | assert Parsers.positive_float(int) === {:ok, int * 1.0} 251 | else 252 | assert {:error, _} = Parsers.positive_float(int) 253 | end 254 | end 255 | end 256 | 257 | property "works on binaries representing positive floats, fails on binaries representing negative floats" do 258 | check all float <- float() do 259 | str = to_string(float) 260 | if float > 0 do 261 | assert Parsers.positive_float(str) === {:ok, float} 262 | else 263 | assert {:error, _} = Parsers.positive_float(str) 264 | end 265 | end 266 | end 267 | 268 | property "fails on binaries representing floats with trailing garbage" do 269 | check all float <- float(), postfix <- string(:printable), Float.parse(postfix) == :error, postfix != "" do 270 | str = to_string(float) 271 | assert {:error, _} = Parsers.positive_float(str <> postfix) 272 | end 273 | end 274 | 275 | property "works on binaries representing positive integers, fails on binaries representing negative integers" do 276 | check all int <- integer() do 277 | str = to_string(int) 278 | if int > 0 do 279 | assert Parsers.positive_float(str) === {:ok, 1.0 * int} 280 | else 281 | assert {:error, _} = Parsers.positive_float(str) 282 | end 283 | end 284 | end 285 | 286 | property "fails on non-float, non-integer terms" do 287 | check all thing <- term() do 288 | if (is_float(thing) or is_integer(thing)) and thing >= 0 do 289 | assert {:ok, 1.0 * thing} == Parsers.nonnegative_float(thing) 290 | else 291 | if !is_binary(thing) || Float.parse(thing) == :error do 292 | assert {:error, _} = Parsers.nonnegative_float(thing) 293 | end 294 | end 295 | end 296 | end 297 | end 298 | 299 | describe "string/1" do 300 | property "works on binaries" do 301 | check all bin <- string(:printable) do 302 | assert {:ok, bin} = Parsers.string(bin) 303 | end 304 | end 305 | 306 | property "works on charlists" do 307 | check all bin <- string(:printable) do 308 | chars = to_charlist(bin) 309 | assert {:ok, bin} = Parsers.string(chars) 310 | end 311 | end 312 | 313 | property "works on terms that implement String.Chars" do 314 | check all thing <- 315 | one_of([ 316 | integer(), 317 | string(:printable), 318 | binary(), 319 | float(), 320 | boolean(), 321 | atom(:alphanumeric) 322 | ]) do 323 | assert {:ok, "#{thing}"} == Parsers.string(thing) 324 | end 325 | end 326 | end 327 | 328 | describe "boolean/1" do 329 | property "works on booleans" do 330 | check all bool <- boolean() do 331 | assert {:ok, bool} == Parsers.boolean(bool) 332 | end 333 | end 334 | 335 | property "works on strings representing booleans" do 336 | check all bool <- boolean() do 337 | str = to_string(bool) 338 | assert {:ok, bool} == Parsers.boolean(str) 339 | end 340 | end 341 | 342 | property "does not work on non-boolean terms" do 343 | check all thing <- term() do 344 | if is_boolean(thing) do 345 | assert {:ok, thing} == Parsers.boolean(thing) 346 | else 347 | assert {:error, _} = Parsers.boolean(thing) 348 | end 349 | end 350 | end 351 | end 352 | 353 | describe "atom/1 and unsafe_atom/1" do 354 | property "works on atoms" do 355 | check all atom <- one_of([atom(:alphanumeric), atom(:alias)]) do 356 | assert {:ok, atom} == Parsers.atom(atom) 357 | assert {:ok, atom} == Parsers.unsafe_atom(atom) 358 | end 359 | end 360 | 361 | property "Works on strings" do 362 | check all atom <- one_of([atom(:alphanumeric), atom(:alias)]) do 363 | str = to_string(atom) 364 | assert {:ok, atom} == Parsers.atom(str) 365 | assert {:ok, atom} == Parsers.unsafe_atom(str) 366 | end 367 | end 368 | 369 | test "atom/1 raises on non-existent atom" do 370 | assert {:error, _} = Parsers.atom("this_does_not_exist_as_atom") 371 | assert {:error, _} = Parsers.atom("This.Module.Does.Not.Exist.Either") 372 | end 373 | 374 | test "unsafe_atom/1 does noton non-existent atom" do 375 | assert {:ok, :this_does_not_exist_as_atom2} = 376 | Parsers.unsafe_atom("this_does_not_exist_as_atom2") 377 | 378 | assert {:ok, This.Module.Does.Not.Exist.Either2} = 379 | Parsers.unsafe_atom("Elixir.This.Module.Does.Not.Exist.Either2") 380 | end 381 | end 382 | 383 | describe "list/2" do 384 | property "works on lists of atoms" do 385 | check all list <- list_of(atom(:alphanumeric)) do 386 | assert {:ok, list} == Parsers.list(list, &Parsers.atom/1) 387 | end 388 | end 389 | 390 | property "works on strings representing lists of atoms" do 391 | check all list <- list_of(atom(:alphanumeric)) do 392 | str = inspect(list, limit: :infinity) 393 | assert {:ok, list} == Parsers.list(str, &Parsers.atom/1) 394 | end 395 | end 396 | 397 | property "works on lists of integers" do 398 | check all list <- list_of(integer()) do 399 | assert {:ok, list} == Parsers.list(list, &Parsers.integer/1) 400 | end 401 | end 402 | 403 | property "works on strings representing lists of integers" do 404 | check all list <- list_of(integer()) do 405 | str = inspect(list, limit: :infinity) 406 | assert {:ok, list} == Parsers.list(str, &Parsers.integer/1) 407 | end 408 | end 409 | 410 | property "works on keyword lists (list of options)" do 411 | check all list <- list_of(tuple({atom(:alphanumeric), supported_terms_generator()})) do 412 | str = inspect(list, limit: :infinity) 413 | assert {:ok, list} == Parsers.list(str, &Parsers.option/1) 414 | end 415 | end 416 | 417 | property "works on strings representing lists of arbitrary terms" do 418 | check all list <- list_of(supported_terms_generator()) do 419 | str = inspect(list, limit: :infinity) 420 | assert {:ok, list} == Parsers.list(str, &Parsers.term/1) 421 | end 422 | end 423 | end 424 | 425 | describe "timeout/1" do 426 | test "works on `:infinity` (both as atom and as binary)" do 427 | assert Parsers.timeout(:infinity) === {:ok, :infinity} 428 | assert Parsers.timeout("infinity") === {:ok, :infinity} 429 | end 430 | 431 | property "works on positive integers, fails on other integers" do 432 | check all int <- integer() do 433 | if int > 0 do 434 | assert Parsers.timeout(int) === {:ok, int} 435 | else 436 | assert {:error, _} = Parsers.timeout(int) 437 | end 438 | end 439 | end 440 | 441 | property "works on binaries representing positive integers, fails on binaries representing other integers" do 442 | check all int <- integer() do 443 | str = to_string(int) 444 | if int > 0 do 445 | assert Parsers.timeout(str) === {:ok, int} 446 | else 447 | assert {:error, _} = Parsers.timeout(str) 448 | end 449 | end 450 | end 451 | 452 | property "fails on binaries representing non-negative integers with trailing garbage" do 453 | check all int <- integer(), postfix <- string(:printable), Integer.parse(postfix) == :error, postfix != "" do 454 | str = to_string(int) 455 | assert {:error, _} = Parsers.integer(str <> postfix) 456 | end 457 | end 458 | 459 | property "fails on non-integer, non-`:infinity` terms" do 460 | check all thing <- term() do 461 | if thing == :infinity || (is_integer(thing) && thing > 0) do 462 | assert {:ok, thing} = Parsers.timeout(thing) 463 | else 464 | if !is_binary(thing) || Integer.parse(thing) == :error do 465 | assert {:error, _} = Parsers.timeout(thing) 466 | end 467 | end 468 | end 469 | end 470 | end 471 | 472 | describe "mfa/1" do 473 | property "works on MFA tuples of existing functions (and binaries of the same)" do 474 | existing_mfas = 475 | Enum.__info__(:functions) 476 | |> Enum.map(fn {fun, arity} -> constant({Enum, fun, arity}) end) 477 | check all mfa <- one_of(existing_mfas) do 478 | assert {:ok, fun} = Parsers.mfa(mfa) 479 | assert {:ok, fun} = Parsers.mfa(inspect(mfa)) 480 | end 481 | end 482 | 483 | property "Fails on MFA tuples of non-existing functions" do 484 | check all module <- atom(:alphanumeric), fun <- atom(:alphanumeric), arity <- integer(), !function_exported?(module, fun, arity) do 485 | assert {:error, res} = Parsers.mfa({module, fun, abs(arity)}) 486 | end 487 | end 488 | end 489 | 490 | describe "function/1" do 491 | property "works on MFA tuples of existing functions (and binaries of the same)" do 492 | existing_mfas = 493 | Enum.__info__(:functions) 494 | |> Enum.map(fn {fun, arity} -> constant({Enum, fun, arity}) end) 495 | check all mfa <- one_of(existing_mfas) do 496 | assert {:ok, fun} = Parsers.mfa(mfa) 497 | assert {:ok, fun} = Parsers.mfa(inspect(mfa)) 498 | end 499 | end 500 | 501 | property "Fails on MFA tuples of non-existing functions" do 502 | check all module <- atom(:alphanumeric), fun <- atom(:alphanumeric), arity <- integer(), !function_exported?(module, fun, arity) do 503 | assert {:error, res} = Parsers.mfa({module, fun, abs(arity)}) 504 | end 505 | end 506 | end 507 | 508 | describe "option/1" do 509 | property "works on option of arbitrary term" do 510 | check all option <- tuple({atom(:alphanumeric), supported_terms_generator()}) do 511 | assert {:ok, option} == Parsers.option(option) 512 | end 513 | end 514 | 515 | property "works on strings representing options of arbitrary terms" do 516 | check all option <- tuple({atom(:alphanumeric), supported_terms_generator()}) do 517 | str = inspect(option, limit: :infinity) 518 | assert {:ok, option} == Parsers.option(str) 519 | end 520 | end 521 | end 522 | 523 | defp supported_terms_generator(max_depth \\ 2) do 524 | generators = [ 525 | boolean(), 526 | float(), 527 | integer(), 528 | string(:ascii), 529 | string(:alphanumeric), 530 | string(:printable), 531 | ] 532 | 533 | generators 534 | |> maybe_add_tuple_generator(max_depth) 535 | |> maybe_add_map_generator(max_depth) 536 | |> one_of() 537 | end 538 | 539 | defp maybe_add_tuple_generator(generators, 0) do 540 | generators 541 | end 542 | 543 | defp maybe_add_tuple_generator(generators, max_depth) do 544 | [ 545 | (for _ <- 1..Enum.random(1..5), do: supported_terms_generator(max_depth - 1)) 546 | |> List.to_tuple() 547 | |> tuple 548 | | generators] 549 | end 550 | 551 | defp maybe_add_map_generator(generators, 0) do 552 | generators 553 | end 554 | 555 | defp maybe_add_map_generator(generators, max_depth) do 556 | [ 557 | map_of( 558 | supported_terms_generator(max_depth - 1), 559 | supported_terms_generator(max_depth - 1), 560 | max_length: 5 561 | ) 562 | | generators 563 | ] 564 | end 565 | end 566 | -------------------------------------------------------------------------------- /test/specify_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpecifyTest do 2 | use ExUnit.Case 3 | use ExUnitProperties 4 | import ExUnit.CaptureIO 5 | import ExUnit.CaptureLog 6 | 7 | doctest Specify 8 | 9 | describe "Simple examples of Specify.defconfig/2" do 10 | defmodule BasicSpecifyExample do 11 | require Specify 12 | 13 | Specify.defconfig do 14 | @doc "a field with a default" 15 | field(:name, :string, default: "Jabberwocky") 16 | @doc "A required field" 17 | field(:age, :integer) 18 | @doc "A compound parsers test." 19 | field(:colors, {:list, :atom}, default: []) 20 | end 21 | end 22 | 23 | test "Basic configuration works without glaring problems" do 24 | assert Specify.load(BasicSpecifyExample, explicit_values: [age: 42]) == %BasicSpecifyExample{ 25 | name: "Jabberwocky", 26 | age: 42, 27 | colors: [] 28 | } 29 | 30 | assert BasicSpecifyExample.load(explicit_values: [age: 43]) == %BasicSpecifyExample{ 31 | name: "Jabberwocky", 32 | age: 43, 33 | colors: [] 34 | } 35 | 36 | assert Specify.load_explicit(BasicSpecifyExample, age: 42) == %BasicSpecifyExample{ 37 | name: "Jabberwocky", 38 | age: 42, 39 | colors: [] 40 | } 41 | 42 | assert BasicSpecifyExample.load_explicit(age: 44, colors: [:red, :green]) == 43 | %BasicSpecifyExample{name: "Jabberwocky", age: 44, colors: [:red, :green]} 44 | 45 | assert_raise(Specify.MissingRequiredFieldsError, fn -> 46 | Specify.load(BasicSpecifyExample) 47 | end) 48 | 49 | assert_raise(Specify.MissingRequiredFieldsError, fn -> 50 | Specify.load_explicit(BasicSpecifyExample, []) 51 | end) 52 | end 53 | 54 | test "compound parsers are used correctly" do 55 | assert %BasicSpecifyExample{colors: [:red, :green, :blue]} = 56 | BasicSpecifyExample.load_explicit(age: 22, colors: "[:red, :green, :blue]") 57 | 58 | assert %BasicSpecifyExample{colors: [:red, :cyan, :yellow]} = 59 | BasicSpecifyExample.load_explicit(age: 22, colors: [:red, "cyan", :yellow]) 60 | end 61 | 62 | test "Warnings are shown when defining a configuration with missing doc strings" do 63 | assert capture_io(:stderr, fn -> 64 | defmodule MissingDocs do 65 | require Specify 66 | 67 | Specify.defconfig do 68 | field(:name, :string, default: "Slatibartfast") 69 | end 70 | end 71 | end) =~ 72 | "Missing documentation for configuration field `name`. Please add it by adding `@doc \"field documentation here\"` above the line where you define it." 73 | end 74 | 75 | test "Reflection is in place" do 76 | assert MapSet.new([:name, :age, :colors]) == BasicSpecifyExample.__specify__(:field_names) 77 | assert %{name: "Jabberwocky", colors: []} == BasicSpecifyExample.__specify__(:defaults) 78 | assert MapSet.new([:age]) == BasicSpecifyExample.__specify__(:required_fields) 79 | 80 | assert %{ 81 | name: &Specify.Parsers.string/1, 82 | age: &Specify.Parsers.integer/1, 83 | colors: {&Specify.Parsers.list/2, &Specify.Parsers.atom/1} 84 | } == BasicSpecifyExample.__specify__(:parsers) 85 | end 86 | 87 | for provider <- [Specify.Provider.SystemEnv, Specify.Provider.MixEnv, Specify.Provider.Process] do 88 | @provider provider 89 | test "Specify shows warnings on missing (required) sources for #{@provider}" do 90 | res = capture_log(fn -> 91 | defmodule ConfigWithRequiredSource do 92 | require Specify 93 | 94 | Specify.defconfig do 95 | @doc "A name" 96 | field(:name, :string) 97 | end 98 | end 99 | 100 | conf = ConfigWithRequiredSource.load_explicit(%{name: "Floof"}, sources: [@provider]) 101 | assert %{name: "Floof"} = conf 102 | end) 103 | 104 | assert res =~ "While loading the configuration `SpecifyTest.ConfigWithRequiredSource`, the source `%#{inspect(@provider)}" 105 | assert res =~ "could not be found." 106 | end 107 | 108 | test "Specify does not show warnings on missing (optional) sources for #{@provider}" do 109 | res = capture_log(fn -> 110 | defmodule ConfigWithOptionalSource do 111 | require Specify 112 | 113 | Specify.defconfig [sources: [%Specify.Provider.SystemEnv{optional: true}]] do 114 | @doc "A name" 115 | field(:name, :string) 116 | end 117 | end 118 | 119 | conf = ConfigWithOptionalSource.load_explicit(%{name: "Floof"}, sources: [@provider]) 120 | assert %{name: "Floof"} = conf 121 | end) 122 | 123 | assert res == "" 124 | end 125 | end 126 | 127 | end 128 | 129 | describe "parsers are properly called" do 130 | defmodule ParsingExample do 131 | require Specify 132 | 133 | Specify.defconfig do 134 | @doc false 135 | field(:size, :integer, default: "42") 136 | end 137 | end 138 | 139 | test "Parser is called with default" do 140 | assert ParsingExample.load() == %ParsingExample{size: 42} 141 | end 142 | 143 | property "parser is called with value" do 144 | check all thing <- term() do 145 | if is_integer(thing) do 146 | assert %ParsingExample{size: thing} = ParsingExample.load_explicit(size: thing) 147 | else 148 | assert_raise(Specify.ParsingError, fn -> 149 | ParsingExample.load_explicit(size: thing) 150 | end) 151 | end 152 | end 153 | end 154 | 155 | property "parser failure results in (custom) error" do 156 | defmodule MyCustomError do 157 | defexception [:message] 158 | end 159 | 160 | check all thing <- term(), !is_integer(thing) do 161 | assert_raise(MyCustomError, fn -> 162 | ParsingExample.load_explicit([size: thing], parsing_error: MyCustomError) 163 | end) 164 | end 165 | end 166 | end 167 | 168 | describe "multi-parser is properly called" do 169 | defmodule MultiParserExample do 170 | require Specify 171 | 172 | Specify.defconfig do 173 | @doc false 174 | field(:value, [:integer, :float, :boolean], default: "13.37") 175 | end 176 | end 177 | 178 | test "Parser is called with default" do 179 | assert MultiParserExample.load() == %MultiParserExample{value: 13.37} 180 | end 181 | 182 | property "parser is called with value" do 183 | check all thing <- term() do 184 | if is_integer(thing) or is_float(thing) or is_boolean(thing) do 185 | assert %MultiParserExample{value: thing} = MultiParserExample.load_explicit(value: thing) 186 | else 187 | assert_raise(Specify.ParsingError, fn -> 188 | MultiParserExample.load_explicit(value: thing) 189 | end) 190 | end 191 | end 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Used in multiple modules' doctests 2 | defmodule Pet do 3 | require Specify 4 | Specify.defconfig do 5 | @doc "The name of the pet" 6 | field :name, :string 7 | @doc "is it a dog or a cat?" 8 | field :kind, :atom, system_env_name: "TYPE" 9 | end 10 | end 11 | 12 | ExUnit.start() 13 | 14 | # We require some atoms to be defined for the doctests 15 | _some_animal_kinds = [:cat, :dog, :bird, :fish] 16 | --------------------------------------------------------------------------------