Skip to content

Commit

Permalink
Release into the wild 🐯
Browse files Browse the repository at this point in the history
  • Loading branch information
ElMassimo committed Dec 29, 2017
0 parents commit a5d7484
Show file tree
Hide file tree
Showing 18 changed files with 544 additions and 0 deletions.
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
*.gem
*.rbc
.bundle
.config
.yardoc
Gemfile.lock
InstalledFiles
_yardoc
coverage
doc/
lib/bundler/man
pkg
rdoc
spec/reports
test/tmp
test/version_tmp
tmp
*.bundle
*.so
*.o
*.a
mkmf.log
2 changes: 2 additions & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
--color
--format=progress
1 change: 1 addition & 0 deletions .ruby-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ruby-2.3.0
6 changes: 6 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
language: ruby
rvm:
- 2.2.2
- 2.3.0
gemfile:
- Gemfile
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## BetterSettings 1.0.0 (2017-12-29) ##

* Initial Release.
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
source 'https://rubygems.org'

gemspec
20 changes: 20 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Copyright (c) 2017 Máximo Mussini

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
137 changes: 137 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
BetterSettings [![Gem Version](https://img.shields.io/gem/v/better_settings.svg?colorB=e9573f)](https://rubygems.org/gems/better_settings) [![Build Status](https://travis-ci.org/ElMassimo/better_settings.svg)](https://travis-ci.org/ElMassimo/better_settings) [![Coverage Status](https://coveralls.io/repos/github/ElMassimo/better_settings/badge.svg?branch=master)](https://coveralls.io/github/ElMassimo/better_settings?branch=master) [![Inline docs](http://inch-ci.org/github/ElMassimo/better_settings.svg)](http://inch-ci.org/github/ElMassimo/better_settings) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/ElMassimo/better_settings/blob/master/LICENSE.txt)
=======================================

A robust settings library that can read YML files and provide an immutable object allowing to access settings through method calls. Can be used in __any Ruby app__, __not just Rails__.

### Installation

Add this line to your application's Gemfile:

```ruby
gem 'better_settings'
```

And then execute:

$ bundle

Or install it yourself as:

$ gem install better_settings

### Usage

#### 1. Define a class

Instead of defining a Settings constant for you, that task is left to you. Simply create a class in your application
that looks like:

```ruby
# app/models/settings.rb
class Settings < BetterSettings
source Rails.root.join('config', 'application.yml'), namespace: Rails.env
end
```

#### 2. Create your settings

Notice above we specified an absolute path to our settings file called `application.yml`. This is just a typical YAML file.
Also notice above that we specified a namespace for our environment. A namespace is just an optional string that corresponds to a key in the YAML file.

Using a namespace allows us to change our configuration depending on our environment:

```yaml
# config/application.yml
defaults: &defaults
port: 80
mailer:
root: www.example.com
dynamic: <%= "Did you know you can use ERB inside the YML file? Env is #{ Rails.env }." %>

development:
<<: *defaults
port: 3000

test:
<<: *defaults

production:
<<: *defaults
```

#### 3. Access your settings

>> Rails.env
=> "development"
>> Settings.mailer
=> "#<Settings ... >"
>> Settings.mailer.root
=> "www.example.com
>> Settings.port
=> 3000
>> Settings.dynamic
=> "Did you know you can use ERB inside the YML file? Env is development."
You can use these settings anywhere, for example in a model:

class Post < ActiveRecord::Base
self.per_page = Settings.pagination.posts_per_page
end

### Advanced Setup ⚙
Name it `Settings`, name it `Config`, name it whatever you want. Add as many or as few as you like, read from as many files as necessary (nested keys will be merged).

We usually read a few optional files for the `development` and `test` environment, which allows each developer to override some settings in their own local environment (we git ignore `development.yml` and `test.yml`).

```ruby
# app/models/settings.rb
class Settings < BetterSettings
source Rails.root.join('config', 'application.yml'), namespace: Rails.env
source Rails.root.join('config', 'development.yml'), namespace: Rails.env, optional: true if Rails.env.development?
source Rails.root.join('config', 'test.yml'), namespace: Rails.env, optional: true if Rails.env.test?
end
```
Our `application.yml` looks like this:
```yaml
# application.yml
defaults: &defaults
auto_logout: false
secret_key_base: 'fake_secret_key_base'

server_defaults: &server_defaults
<<: *defaults
auto_logout: true
secret_key: <%= ENV['SECRET_KEY'] %>

development:
<<: *defaults
host: 'localhost'

test:
<<: *defaults
host: '127.0.0.1'

staging:
<<: *server_defaults
host: 'staging.example.com'

production:
<<: *server_defaults
host: 'example.com'
```
A developer might want to override some settings by defining a `development.yml` such as:
```yaml
development:
auto_logout: true
````
The main advantage is that those changes won't be tracked by source control :smiley:

## Opinionated Design
After using [settingslogic](https://github.com/settingslogic/settingslogic) for a long time, we learned some lessons, which are distilled in the following decisions:
- __Immutability:__ Once created settings can't be modified.
- __No Optional Setings:__ Any optional setting can be modeled in a safer way, this library doesn't allow them.
- __Not Tied to a Source File:__ Useful to create multiple environment-specific files.
6 changes: 6 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'

RSpec::Core::RakeTask.new

task default: :spec
23 changes: 23 additions & 0 deletions better_settings.gemspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
require File.expand_path('../lib/better_settings/version', __FILE__)

Gem::Specification.new do |s|
s.name = 'better_settings'
s.version = BetterSettings::VERSION
s.authors = ['Máximo Mussini']
s.email = ['maximomussini@gmail.com']
s.summary = 'Settings for Rails applications: simple, immutable, better.'
s.description = 'Settings solution for Rails applications that can read YAML files (ERB-enabled) and allows to access using method calls.'
s.homepage = 'https://github.com/ElMassimo/better_settings'
s.license = 'MIT'
s.extra_rdoc_files = ['README.md']
s.files = Dir.glob('{lib}/**/*.rb') + %w(README.md)
s.test_files = Dir.glob('{spec}/**/*.rb')
s.require_path = 'lib'

s.required_ruby_version = '~> 2.2'

s.add_development_dependency 'coveralls'
s.add_development_dependency 'pry-byebug'
s.add_development_dependency 'rake'
s.add_development_dependency 'rspec-given', '~> 3.0'
end
110 changes: 110 additions & 0 deletions lib/better_settings.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# frozen_string_literal: true

require 'yaml'
require 'erb'
require 'open-uri'
require 'forwardable'

# Public: Rewrite of BetterSettings to enforce fail-fast and immutability, and
# avoid extending a core class like Hash which can be problematic.
class BetterSettings
extend Forwardable

VALID_SETTING_NAME = /^\w+$/
RESERVED_METHODS = %w[
settings
root_settings
]

attr_reader :settings
def_delegators :settings, :to_h, :to_hash

# Public: Initializes a new settings object from a Hash or compatible object.
def initialize(hash, parent:)
@settings = hash.to_h.freeze
@parent = parent

# Create a getter method for each setting.
@settings.each { |key, value| create_accessor(key, value) }
end

# Internal: Returns a new Better Settings instance that combines the settings.
def merge(other_settings)
self.class.new(deep_merge(@settings, other_settings.to_h), parent: @parent)
end

# Internal: Display explicit errors for typos and missing settings.
# rubocop:disable Style/MethodMissing
def method_missing(name, *)
raise MissingSetting, "Missing setting '#{ name }' in #{ @parent }"
end

private

# Internal: Wrap nested hashes as settings to allow accessing keys as methods.
def auto_wrap(key, value)
case value
when Hash then self.class.new(value, parent: "'#{ key }' section in #{ @parent }")
when Array then value.map { |item| auto_wrap(key, item) }.freeze
else value.freeze
end
end

# Internal: Defines a getter for the specified setting.
def create_accessor(key, value)
raise InvalidSettingKey if !key.is_a?(String) || key !~ VALID_SETTING_NAME || RESERVED_METHODS.include?(key)
instance_variable_set("@#{ key }", auto_wrap(key, value))
singleton_class.send(:attr_reader, key)
end

# Internal: Recursively merges two hashes (in case ActiveSupport is not available).
def deep_merge(this_hash, other_hash)
this_hash.merge(other_hash) do |key, this_val, other_val|
if this_val.is_a?(Hash) && other_val.is_a?(Hash)
deep_merge(this_val, other_val)
else
other_val
end
end
end

class MissingSetting < StandardError; end
class InvalidSettingKey < StandardError; end

class << self
extend Forwardable
def_delegators :root_settings, :to_h, :to_hash, :method_missing

# Public: Loads a file as settings (merges it with any previously loaded settings).
def source(file_name, namespace: false, optional: false)
return if !File.exist?(file_name) && optional

# Load the specified yaml file and instantiate a Settings object.
settings = new(yaml_to_hash(file_name), parent: file_name)

# Take one of the settings keys if one is specified.
settings = settings.public_send(namespace) if namespace

# Merge settings if a source had previously been specified.
@root_settings = @root_settings ? @root_settings.merge(settings) : settings

# Allow to call any settings methods directly on the class.
singleton_class.extend(Forwardable)
singleton_class.def_delegators :root_settings, *@root_settings.settings.keys
end

private

# Internal: Methods called at the class level are delegated to this instance.
def root_settings
raise ArgumentError, '`source` must be specified for the settings' unless defined?(@root_settings)
@root_settings
end

# Internal: Parses a yml file that can optionally use ERB templating.
def yaml_to_hash(file_name)
return {} if (content = open(file_name).read).empty?
YAML.load(ERB.new(content).result).to_hash
end
end
end
5 changes: 5 additions & 0 deletions lib/better_settings/version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class BetterSettings
VERSION = '1.0.0'
end
Loading

0 comments on commit a5d7484

Please sign in to comment.