class EnvParser

The EnvParser class simplifies parsing of environment variables as different data types.

Constants

AUTOREGISTER_FILE

The default filename to use for {.autoregister} requests.

VERSION

Public Class Methods

add_env_bindings() click to toggle source

Creates ENV bindings for {.parse} and {.register} proxy methods.

The sole difference between these proxy methods and their EnvParser counterparts is that ENV.parse will interpret any value given as an ENV key (as a String), not the given value itself. i.e. ENV.parse('XYZ', …) is equivalent to EnvParser.parse(ENV['XYZ'], …)

@return [ENV]

This generates no usable value.
# File lib/env_parser.rb, line 219
def add_env_bindings
  ENV.instance_eval do
    def parse(name, options = {}, &validation_block)
      EnvParser.parse(self[name.to_s], options, &validation_block)
    end

    def register(*args)
      EnvParser.register(*args)
    end
  end

  ENV
end
autoregister(filename = nil) click to toggle source

Reads an “autoregister” file and registers the ENV constants defined therein.

The “autoregister” file is read, parsed as YAML, sanitized for use as a parameter to {.register_all}, and then passed along for processing. The return value from that {.register_all} call is passed through.

@param filename [String]

A path for the autoregister file to parse and process. Defaults to
{EnvParser::AUTOREGISTER_FILE} if unset.

@return [Hash]

The return value from the {.register_all} call that handles the actual registration.

@raise [EnvParser::AutoregisterFileNotFound, EnvParser::UnparseableAutoregisterSpec]

# File lib/env_parser.rb, line 248
def autoregister(filename = nil)
  filename ||= AUTOREGISTER_FILE
  autoregister_spec = Psych.load_file(filename)

  autoregister_spec.deep_symbolize_keys!
  autoregister_spec.transform_values! do |spec|
    sanitized = spec.slice(:as, :within, :if_unset, :from_set)
    sanitized[:as] = sanitized[:as].to_sym if sanitized.key? :as
    sanitized[:within] = sanitized[:within].constantize if sanitized.key? :within

    sanitized
  end

  register_all autoregister_spec

## Psych raises an Errno::ENOENT on file-not-found.
##
rescue Errno::ENOENT
  raise EnvParser::AutoregisterFileNotFound, %(file not found: "#{filename}")

## Psych raises a Psych::SyntaxError on unparseable YAML.
##
rescue Psych::SyntaxError => e
  raise EnvParser::UnparseableAutoregisterSpec, "malformed YAML in spec file: #{e.message}"
end
define_type(name, options = {}, &parser) click to toggle source

Defines a new type for use as the “as” option on a subsequent {.parse} or {.register} call.

@param name [Symbol]

The name to assign to the type.

@option options [Array<Symbol>] aliases

An array of additional names you'd like to see refer to this same type.

@option options if_unset (nil)

Specifies a "sensible default" to return for this type if the value being parsed (via
{.parse} or {.register}) is either unset (`nil`) or blank (`''`). Note this may be
overridden by the user via the {.parse}/{.register} "if_unset" option.

@yield [value]

A block to act as the parser for the this type. If no block is given, an ArgumentError is
raised.

When the type defined is used via a {.parse}/{.register} call, this block is invoked with
the value to be parsed. Said value is guaranteed to be a non-empty String (the "if_unset"
check will have already run), but no other assurances as to content are given. The block
should return the final output of parsing the given String value as the type being defined.

If the value given cannot be sensibly parsed into the type defined, the block should raise
an {EnvParser::ValueNotConvertibleError}.

@return [nil]

This generates no usable value.

@raise [ArgumentError, EnvParser::TypeAlreadyDefinedError]

# File lib/env_parser.rb, line 44
def define_type(name, options = {}, &parser)
  raise(ArgumentError, 'no parsing block given') unless block_given?

  given_types = (Array(name) + Array(options[:aliases])).map(&:to_s).map(&:to_sym)
  given_types.each do |type|
    raise(TypeAlreadyDefinedError, "cannot redefine #{type.inspect}") if known_types.key?(type)

    known_types[type] = {
      parser: parser,
      if_unset: options[:if_unset]
    }
  end

  nil
end
parse(value, options = {}, &validation_block) click to toggle source

Interprets the given value as the specified type.

@param value [String, Symbol]

The value to parse/interpret. If a String is given, the value will be used as-is. If a
Symbol is given, the ENV value for the matching string key will be used.

@option options [Symbol] as

The expected return type. A best-effort attempt is made to convert the source String to the
requested type.

If no "as" option is given, an ArgumentError is raised. If the "as" option given is unknown
(the given type has not been previously defined via {.define_type}), an
{EnvParser::UnknownTypeError} is raised.

@option options if_unset

Specifies the default value to return if the given "value" is either unset (`nil`) or blank
(`''`). Any "if_unset" value given will be returned as-is, with no type conversion or other
change having been made.  If unspecified, the "default" value for `nil`/`''` input will
depend on the "as" type.

@option options [Array, Range] from_set

Gives a limited set of allowed values (after type conversion). If, after parsing, the final
value is not included in the "from_set" list/range, an {EnvParser::ValueNotAllowedError} is
raised.

Note that if the "if_unset" option is given and the value to parse is `nil`/`''`, the
"if_unset" value will be returned, even if it is not part of the "from_set" list/range.

Also note that, due to the nature of the lookup, the "from_set" option is only available
for scalar values (i.e. not arrays, hashes, or other enumerables). An attempt to use the
"from_set" option with a non-scalar value will raise an ArgumentError.

@option options [Proc] validated_by

If given, the "validated_by" Proc is called with the parsed value (after type conversion)
as its sole argument. This allows for user-defined validation of the parsed value beyond
what can be enforced by use of the "from_set" option alone. If the Proc's return value is
`#blank?`, an {EnvParser::ValueNotAllowedError} is raised. To accomodate your syntax of
choice, this validation Proc may be given as a block instead.

Note that this option is intended to provide an inspection mechanism only -- no mutation
of the parsed value should occur within the given Proc. To that end, the argument passed is
a *frozen* duplicate of the parsed value.

@yield [value]

A block (if given) is treated exactly as the "validated_by" Proc would.

Although there is no compelling reason to provide both a "validated_by" Proc *and* a
validation block, there is no technical limitation preventing this. **If both are given,
both validation checks must pass.**

@raise [ArgumentError, EnvParser::UnknownTypeError, EnvParser::ValueNotAllowedError]

# File lib/env_parser.rb, line 112
def parse(value, options = {}, &validation_block)
  value = ENV[value.to_s] if value.is_a? Symbol
  value = value.to_s

  type = known_types[options[:as]]
  raise(ArgumentError, 'missing `as` parameter') unless options.key?(:as)
  raise(UnknownTypeError, "invalid `as` parameter: #{options[:as].inspect}") unless type

  return (options.key?(:if_unset) ? options[:if_unset] : type[:if_unset]) if value.blank?

  value = type[:parser].call(value)
  check_for_set_inclusion(value, set: options[:from_set]) if options.key?(:from_set)
  check_user_defined_validations(value, proc: options[:validated_by], block: validation_block)

  value
end
register(name, options = {}, &validation_block) click to toggle source

Parses the referenced value and creates a matching constant in the requested context.

Multiple calls to {.register} may be shortcutted by passing in a Hash whose keys are the variable names and whose values are the options set for each variable's {.register} call.

<pre>

## Example shortcut usage:

EnvParser.register :A, from: one_hash, as: :integer
EnvParser.register :B, from: another_hash, as: :string, if_unset: 'none'

## ... is equivalent to ...

EnvParser.register(
  A: { from: one_hash, as: :integer }
  B: { from: another_hash, as: :string, if_unset: 'none' }
)

</pre>

@param name

The name of the value to parse/interpret from the "from" Hash. If the "from" value is
`ENV`, you may give a Symbol and the corresponding String key will be used instead.

@option options [Hash] from (ENV)

The source Hash from which to pull the value referenced by the "name" key.

@option options [Module, Class] within (Kernel)

The module or class in which the constant should be created. Creates global constants by
default.

@option options [Symbol] as

See {.parse}.

@option options if_unset

See {.parse}.

@option options [Array, Range] from_set

See {.parse}.

@option options [Proc] validated_by

See {.parse}.

@yield [value]

A block (if given) is treated exactly as in {.parse}. Note, however, that a single block
cannot be used to register multiple constants simultaneously -- each value needing
validation must give its own "validated_by" Proc.

@raise [ArgumentError]

# File lib/env_parser.rb, line 178
def register(name, options = {}, &validation_block)
  ## Allow for registering multiple variables simultaneously via a single call.
  if name.is_a? Hash
    raise(ArgumentError, 'cannot register multiple values with one block') if block_given?
    return register_all(name)
  end

  from = options.fetch(:from, ENV)
  within = options.fetch(:within, Kernel)

  ## ENV *seems* like a Hash and it does *some* Hash-y things, but it is NOT a Hash and that can
  ## bite you in some cases. Making sure we're working with a straight-up Hash saves a lot of
  ## sanity checks later on. This is also a good place to make sure we're working with a String
  ## key.
  if from == ENV
    from = from.to_h
    name = name.to_s
  end

  raise ArgumentError, "invalid `from` parameter: #{from.class}" unless from.is_a? Hash

  unless within.is_a?(Module) || within.is_a?(Class)
    raise ArgumentError, "invalid `within` parameter: #{within.inspect}"
  end

  value = from[name]
  value = parse(value, options, &validation_block)
  within.const_set(name.upcase.to_sym, value.dup.freeze)

  value
end

Private Class Methods

check_for_set_inclusion(value, set: nil) click to toggle source

Verifies that the given “value” is included in the “set”.

@param value @param set [Array, Range]

@return [nil]

This generates no usable value.

@raise [ArgumentError, EnvParser::ValueNotAllowedError]

# File lib/env_parser.rb, line 292
def check_for_set_inclusion(value, set: nil)
  if value.respond_to?(:each)
    raise ArgumentError, "`from_set` option is not compatible with #{value.class} values"
  end

  unless set.is_a?(Array) || set.is_a?(Range)
    raise ArgumentError, "invalid `from_set` parameter type: #{set.class}"
  end

  raise(ValueNotAllowedError, 'parsed value not in allowed set') unless set.include?(value)
end
check_user_defined_validations(value, proc: nil, block: nil) click to toggle source

Verifies that the given “value” passes both the “proc” and “block” validations.

@param value @param proc [Proc, nil] @param block [Proc, nil]

@return [nil]

This generates no usable value.

@raise [EnvParser::ValueNotAllowedError]

# File lib/env_parser.rb, line 315
def check_user_defined_validations(value, proc: nil, block: nil)
  immutable_value = value.dup.freeze
  all_tests_passed = [proc, block].compact.all? { |i| i.call(immutable_value) }

  raise(ValueNotAllowedError, 'parsed value failed user validation') unless all_tests_passed
end
known_types() click to toggle source

Class instance variable for storing known type data.

# File lib/env_parser.rb, line 278
def known_types
  @known_types ||= {}
end
register_all(list) click to toggle source

Receives a list of {.register} calls to make, as a Hash keyed with variable names and the values being each {.register} call's option set.

@param list [Hash]

@return [Hash]

@raise [ArgumentError]

# File lib/env_parser.rb, line 331
def register_all(list)
  raise(ArgumentError, "invalid 'list' parameter type: #{list.class}") unless list.is_a? Hash

  list.to_a.each_with_object({}) do |tuple, output|
    output[tuple.first] = register(tuple.first, tuple.second)
  end
end