iron-settings 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require <%= File.join(File.expand_path(File.dirname(__FILE__)), 'spec', 'spec_helper.rb') %>
@@ -0,0 +1,9 @@
1
+ == 1.0.0 / 2013-09-XX
2
+
3
+ * Initial revision
4
+ * Working class and instance level settings definition
5
+ * Working static and db-backed value stores
6
+ * Working user-specifiable settings data types
7
+ * Reload support for file timestamp, timeout, and custom proc-based reload logic
8
+ * Security checking for file ownership and world-writability on settings files
9
+ * Working Rails integration
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2013 Irongaze Consulting LLC
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ 'Software'), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,133 @@
1
+ = GEM: iron-settings
2
+
3
+ Written by Rob Morris @ Irongaze Consulting LLC (http://irongaze.com)
4
+
5
+ == DESCRIPTION
6
+
7
+ A set of classes to support elegant class and instance level settings, both
8
+ static (in files/code) and dynamic (database-backed).
9
+
10
+ == SYNOPSIS
11
+
12
+ Managing settings in applications is a pain. This gem makes it less so. You define your setting
13
+ structure using a simple DSL, then override it as needed in your code or via user interaction. Great for
14
+ gem-based tools, frameworks, etc needing elegant customization, and great for any project
15
+ wanting to have flexible, powerful user-editable settings stored in a database.
16
+
17
+ As an example, consider the User model in any given Rails-based site. Here's how we could add
18
+ some flexible settings:
19
+
20
+ class User < ActiveRecord::Base
21
+
22
+ # Declare our settings schema for this model with types, names and defaults.
23
+ # Each model instance will have its own set of values for these settings.
24
+ instance_settings do
25
+ # homepage is a string, with a default of '/dashboard'
26
+ string('homepage', '/dashboard')
27
+
28
+ # Groups let you bundle settings together, as well as namespace
29
+ # items if so desired
30
+ group('notification') do
31
+ # This setting has a dynamic default - basically a block that gets evaluated to generate
32
+ # a default value. It has access to the model that owns it - in this case, we have
33
+ # an email notification address that defaults to the user's primary email.
34
+ string('email') {|user| user.email}
35
+
36
+ # Lists (aka arrays) are also supported, of any of the supported types
37
+ int_list('subscriptions', [ListServe::NEWS_GROUP, ListServe::ALERTS])
38
+ end
39
+ end
40
+
41
+ end
42
+
43
+ To work with these settings:
44
+
45
+ # Create a new user
46
+ >> @user = User.new(:email => 'info@irongaze.com')
47
+
48
+ # Default values are available immediately
49
+ >> puts @user.settings.homepage
50
+ => '/dashboard'
51
+
52
+ # Override the default value for this user
53
+ >> @user.settings.homepage = '/dashboard-v2'
54
+
55
+ # Update his notification frequency, drilling down into the 'notification' group
56
+ >> @user.settings.notification.frequency = 10
57
+
58
+ # Settings are saved when the model that owns them is saved
59
+ >> @user.save!
60
+
61
+ # Once saved, the settings are reloaded as needed
62
+ >> puts User.find_by_email('info@irongaze.com').settings.homepage
63
+ => '/dashboard-v2'
64
+
65
+ Static settings work differently. Imagine you are building a command-line tool that needs
66
+ configuration info.
67
+
68
+ class MyTool
69
+
70
+ # You'd first define your schema at class level, passing the file
71
+ # you want to use as an option on creation
72
+ class_settings(:file => '~/.mytool') do
73
+ string('api_key')
74
+ string('base_path', '~')
75
+ end
76
+
77
+ def initialize
78
+ # The bound file will be loaded automatically if present on first access.
79
+ # Verify we have what we need - interrogative version of keys tests for the
80
+ # presence of a non-default value.
81
+ unless MyTool.settings.api_key?
82
+ raise "You must define your API key in your ~/.mytool settings file!"
83
+ end
84
+ end
85
+
86
+ end
87
+
88
+ Now, your users could create a .mytool file like so:
89
+
90
+ api_key '1234ASDF'
91
+ base_path '~/code'
92
+
93
+ On calling MyTool.settings, this file would be loaded and override the settings defaults.
94
+
95
+ You could set up a similar arrangement for managing settings for Gems and other reusable libraries.
96
+
97
+ == LIMITATIONS
98
+
99
+ Database-backed settings are not a panacea. They duplicate functionality that could be built more
100
+ directly using standard model attributes. In particular, care must be taken to avoid changing existing
101
+ entry paths and data types, as doing so will invalidate saved values and potentially cause errors
102
+ on loading prior values.
103
+
104
+ In addition, they are not intended for storing hundreds of thousands of values! Like any key/value
105
+ store, they are a tool suited for certain tasks.
106
+
107
+ == REQUIREMENTS
108
+
109
+ Depends on the iron-extensions gem, and optionally requires ActiveRecord to support db-backed
110
+ dynamic settings.
111
+
112
+ Requires RSpec, ActiveRecord and Sqlite3 gems to build/test.
113
+
114
+ == INSTALLATION
115
+
116
+ To install, simply run:
117
+
118
+ sudo gem install iron-settings
119
+
120
+ RVM users can skip the sudo:
121
+
122
+ gem install iron-settings
123
+
124
+ Then use
125
+
126
+ require 'iron/settings'
127
+
128
+ to require the library code.
129
+
130
+ If you want to use db-backed settings (for example, for per-model settings), you will need to run
131
+ the settings-creation migration. In a Rails project, simply run the provided rake settings:install task,
132
+ which will copy the required migration into your app. If you're using this in a non-Rails environment,
133
+ you can manually run the migration in <gem_path>/db/settings_migration.rb.
@@ -0,0 +1 @@
1
+ 1.0.0
@@ -0,0 +1,23 @@
1
+ class SettingsMigration < ActiveRecord::Migration
2
+
3
+ def change
4
+
5
+ # To support db-backed settings, we need a table containing their values
6
+ create_table :settings_values do |t|
7
+ # Polymorphic ownership as "context"
8
+ t.string 'context_type', :null => false
9
+ t.integer 'context_id'
10
+
11
+ # Full key, ie App.settings.foo.bar.some_value => 'foo.bar.some_value'
12
+ t.string 'full_key', :null => false
13
+
14
+ # Serialized value
15
+ t.text 'value'
16
+ end
17
+
18
+ # In cases where we have a lot of db-backed settings, an index is a must!
19
+ add_index :settings_values, ['context_type', 'context_id']
20
+
21
+ end
22
+
23
+ end
@@ -0,0 +1,6 @@
1
+ # Defines a Railtie to inject our rake tasks into the Rails rake environment
2
+ class SettingsRailtie < Rails::Railtie
3
+ rake_tasks do
4
+ Dir[File.join(File.dirname(__FILE__),'../tasks/*.rake')].each { |f| load f }
5
+ end
6
+ end
@@ -0,0 +1,161 @@
1
+ # Dependencies
2
+ require 'tmpdir'
3
+ require 'fileutils'
4
+ require 'iron/extensions'
5
+
6
+ # Top-level class with numerous helper methods for defining and handling
7
+ # our supported settings data types.
8
+ class Settings
9
+
10
+ # Registers a new data type for use in settings entries. Pass a symbol
11
+ # for the type, and a lambda that accepts an arbitrary value and either
12
+ # parses it into a value of the required type or raises.
13
+ #
14
+ # For example, let's say we had a project that commonly had to assign
15
+ # admin users (represented here by ActiveRecord models) on projects, tasks, etc.
16
+ # We could create a :user data type that would seamlessly allow setting
17
+ # and getting admin users as a native settings type:
18
+ #
19
+ # Settings.register_type :user,
20
+ # :parse => lambda {|val| val.is_a?(AdminUser) ? val.id : raise },
21
+ # :restore => lambda {|val| AdminUser.find_by_id(val) }
22
+ #
23
+ # Now we can use the user type in our settings definitions:
24
+ #
25
+ # class Project < ActiveRecord::Base
26
+ # instance_settings do
27
+ # user('lead')
28
+ # end
29
+ # end
30
+ #
31
+ # With our settings defined, we can get and set admin users to that setting entry:
32
+ #
33
+ # @project = Project.new(:name => 'Lazarus')
34
+ # @project.settings.lead = AdminUser.find_by_email('jim@example.com')
35
+ # @project.save
36
+ #
37
+ # @project = Project.find_by_name('Lazarus')
38
+ # # Will use our :restore lambda to restore the id as a full AdminUser model
39
+ # @lead = @project.settings.lead
40
+ # # Will print 'jim@example.com'
41
+ # puts @lead.email
42
+ #
43
+ # If you do not define either the parse or restore lambdas, they will act as
44
+ # pass-throughs. Also note that you do not need to handle nil values, which
45
+ # will always parse to nil and restore to nil.
46
+ def self.register_type(type, options = {})
47
+ data_type_map[type] = {parse: options[:parse], restore: options[:restore]}
48
+ end
49
+
50
+ # Registers initial set of built-in data types
51
+ def self.register_built_ins
52
+ register_type(:int, parse: lambda {|val| val.is_a?(Fixnum) || (val.is_a?(String) && val.integer?) ? val.to_i : raise })
53
+ register_type(:string, parse: lambda {|val| val.is_a?(String) ? val : raise })
54
+ register_type(:symbol, parse: lambda {|val| val.is_a?(Symbol) ? val : raise })
55
+ register_type(:bool, parse: lambda {|val| (val === true || val === false) ? val : raise })
56
+ register_type(:var)
57
+ end
58
+
59
+ def self.data_type_map
60
+ @data_type_map ||= {}
61
+ @data_type_map
62
+ end
63
+
64
+ # Returns array of symbols for the supported data types for
65
+ # settings entries. You can add custom types using the #register_type
66
+ # method
67
+ def self.data_types
68
+ data_type_map.keys
69
+ end
70
+
71
+ # Returns the proper parser for a given type and mode (either :parse or :restore)
72
+ def self.converter_for(type, mode)
73
+ if type.to_s.ends_with?('_list')
74
+ type = type.to_s.gsub('_list','').to_sym
75
+ end
76
+
77
+ hash = data_type_map[type]
78
+ raise ArgumentError.new("Unknown settings data type [#{type.inspect}]") if hash.nil?
79
+ hash[mode]
80
+ end
81
+
82
+ def self.parse(val, type)
83
+ # Nil is always ok
84
+ return nil if val.nil?
85
+
86
+ # Check for lists
87
+ parser = converter_for(type, :parse)
88
+ if type.to_s.ends_with?('_list')
89
+ # Gotta be an array, thanks
90
+ raise ArgumentError.new("Must set #{type} settings to an array of values") unless val.is_a?(Array)
91
+
92
+ # Parse 'em all
93
+ return val if parser.nil?
94
+ val.collect {|v| parser.call(v) } rescue raise ArgumentError.new("Values #{val.inspect} is not a valid #{type}")
95
+ else
96
+ # Single value
97
+ return val if parser.nil?
98
+ parser.call(val) rescue raise ArgumentError.new("Value [#{val.inspect}] is not a valid #{type}")
99
+ end
100
+ end
101
+
102
+ def self.restore(val, type)
103
+ # Nil restores to nil... always
104
+ return nil if val.nil?
105
+
106
+ # Check for lists
107
+ restorer = converter_for(type, :restore)
108
+ if type.to_s.ends_with?('_list')
109
+ # Gotta be an array, thanks
110
+ raise ArgumentError.new("Must set #{type} settings to an array of values") unless val.is_a?(Array)
111
+
112
+ # Parse 'em all
113
+ return val if restorer.nil?
114
+ val.collect {|v| parser.call(v) } rescue raise ArgumentError.new("Unable to restore values #{val.inspect} to type #{type}")
115
+ else
116
+ # Single value
117
+ return val if restorer.nil?
118
+ restorer.call(val) rescue raise ArgumentError.new("Unable to restore value [#{val.inspect}] to type #{type}")
119
+ end
120
+ end
121
+
122
+ def self.classes
123
+ @classes ||= []
124
+ end
125
+
126
+ def self.default_timestamp_file(class_name)
127
+ filename = class_name.gsub(/([a-z])([A-Z])/, '\1-\2').to_dashcase + '-settings.txt'
128
+ defined?(Rails) ?
129
+ File.join(RAILS_ROOT, 'tmp', filename) :
130
+ File.join(Dir.tmpdir, filename)
131
+ end
132
+
133
+ end
134
+
135
+ # Register our built-in types
136
+ Settings.register_built_ins
137
+
138
+ # Include required classes
139
+ require_relative 'settings/node'
140
+ require_relative 'settings/group'
141
+ require_relative 'settings/root'
142
+ require_relative 'settings/entry'
143
+ require_relative 'settings/builder'
144
+ require_relative 'settings/cursor'
145
+ require_relative 'settings/class_level'
146
+ require_relative 'settings/instance_level'
147
+ require_relative 'settings/value_store'
148
+ require_relative 'settings/static_store'
149
+ if defined?(ActiveRecord)
150
+ require_relative 'settings/db_value'
151
+ require_relative 'settings/db_store'
152
+ end
153
+
154
+ # Install our support at the correct scopes
155
+ Module.send(:include, Settings::ClassLevel)
156
+ Object.send(:include, Settings::InstanceLevel)
157
+
158
+ # Rails support here
159
+ if defined?(Rails)
160
+ require_relative 'rake_loader'
161
+ end
@@ -0,0 +1,72 @@
1
+ class Settings
2
+
3
+ # Mirror to the Cursor class, this class helps extend and expand a settings
4
+ # hierarchy.
5
+ class Builder
6
+
7
+ def self.define(group, &block)
8
+ builder = self.new(group)
9
+ builder.define(&block)
10
+ end
11
+
12
+ # Bind to a group/root
13
+ def initialize(group)
14
+ @group = group
15
+ end
16
+
17
+ # Define in block mode
18
+ def define(&block)
19
+ DslProxy.exec(self, &block)
20
+ end
21
+
22
+ # Create a new sub-group, yield for definition if block passed
23
+ def group(name, &block)
24
+ verify_key?(name)
25
+ group = @group.find_group(name)
26
+ unless group
27
+ verify_available?(name, :group)
28
+ group = @group.add_group(name)
29
+ end
30
+
31
+ # Chain it
32
+ builder = self.class.new(group)
33
+ builder.define(&block) if block
34
+ builder
35
+ end
36
+
37
+ def method_missing(method, *args, &block)
38
+ type = method.to_s
39
+
40
+ if Settings.data_types.include?(type.gsub('_list','').to_sym)
41
+ type = type.to_sym
42
+ name = args[0]
43
+ default = args[1]
44
+ verify_key?(name)
45
+ verify_available?(name, :entry)
46
+ @group.add_entry(name, type, default, &block)
47
+ else
48
+ super
49
+ end
50
+ end
51
+
52
+ def respond_to_missing?(method, include_private = false)
53
+ Settings.data_types.include?(method.to_s.gsub('_list','').to_sym)
54
+ end
55
+
56
+ protected
57
+
58
+ # Raise's if name already in use for the group
59
+ def verify_available?(name, type)
60
+ unless @group.find_item(name).nil?
61
+ raise RuntimeError.new("#{type.capitalize}'s name '#{name}' already defined for settings group: #{@group.key}")
62
+ end
63
+ end
64
+
65
+ def verify_key?(key)
66
+ unless key.is_a?(String) && key.match(/^[a-z][a-z0-9_]*$/)
67
+ raise RuntimeError.new("Key '#{key}' is not a valid group/entry key while defining settings group #{@group.key} - must be a string with a-z, 0-9, or _ chars")
68
+ end
69
+ end
70
+ end
71
+
72
+ end
@@ -0,0 +1,122 @@
1
+ class Settings #:nodoc:
2
+
3
+ # Class-level settings can be either static (file/code-based) or dynamic (db-based) depending
4
+ # on your needs. Static settings will work well for gem configuration, command-line tools,
5
+ # etc. while dynamic settings might be useful for a CMS or other web-based tool that needs
6
+ # to support user editing of settings values on-the-fly.
7
+ module ClassLevel
8
+
9
+ module ClassMethods
10
+
11
+ # Access the class-level settings values for this class, returns
12
+ # a Settings::Cursor to read/write, pointed at the root of the
13
+ # settings definition for this class.
14
+ #
15
+ # Optionally accepts a block
16
+ # for mass assignment using DSL setters, eg:
17
+ #
18
+ # Foo.settings do
19
+ # some_group.some_entry 'some value'
20
+ # some_other_entry 250
21
+ # end
22
+ def settings(&block)
23
+ @settings_values.reload_if_needed
24
+ cursor = Settings::Cursor.new(@settings_class_root, @settings_values)
25
+ DslProxy::exec(cursor, &block) if block
26
+ cursor
27
+ end
28
+
29
+ # Reset state to default values only - useful in testing
30
+ def class_settings_reset!
31
+ @settings_values = @settings_class_options[:store] == :static ?
32
+ Settings::StaticStore.new(@settings_class_root, @settings_class_options) :
33
+ Settings::DbStore.new(@settings_class_root, @settings_class_options)
34
+ end
35
+
36
+ # Force a settings reload (from db or file(s) depending on settings) regarless
37
+ # of need to reload automatically. Useful for testing, but not generally needed in production use
38
+ def reload_settings
39
+ @settings_values.load
40
+ end
41
+
42
+ end
43
+
44
+ # Define the class-level settings for a given class. Supported options include:
45
+ #
46
+ # :store => :static | :dynamic - how to load and (potentially) save settings values, defaults to :static
47
+ #
48
+ # Static mode options:
49
+ #
50
+ # :file => '/path/to/file' - provides a single file to load when using the static settings store
51
+ # :files => ['/path1', '/path2'] - same as :file, but allows multiple files to be loaded in order
52
+ #
53
+ # Reload timing (primarily intended for use with :db mode):
54
+ #
55
+ # :reload => <when> - when and if to reload from file/db, with <when> as one of:
56
+ # true - on every access to #settings
57
+ # false - only do initial load, never reload
58
+ # '/path/to/file' - file to test for modified timestamp changes, reload if file timestamp is after latest load
59
+ # <num seconds> - after N seconds since last load
60
+ # lambda { <true to reload> } - custom lambda to execute to check for reload, reloads on returned true value
61
+ #
62
+ # Any options passed on subsequent calls to #class_settings will be ignored.
63
+ #
64
+ # A passed block will be evaluated in the context of a Settings::Builder instance
65
+ # that can be used to define groups and entries.
66
+ #
67
+ # Example:
68
+ #
69
+ # class Site
70
+ # class_settings(:file => File.join(RAILS_ROOT, 'config/site-settings.rb')) do
71
+ # string('name')
72
+ # group('security') do
73
+ # bool('force-ssl', false)
74
+ # string('secret-key')
75
+ # end
76
+ # end
77
+ # end
78
+ #
79
+ # The above would set up Site.settings.name, Site.settings.security.force_ssl, etc, with an optional settings
80
+ # file located at $RAILS_ROOT/config/site-settings.rb
81
+ def class_settings(options = {}, &block)
82
+ unless @settings_class_root
83
+ # Set up our root group and options
84
+ @settings_class_root = Settings::Root.new()
85
+ options = {
86
+ :store => :static
87
+ }.merge(options)
88
+
89
+ # Set our default reload timing
90
+ if options[:reload].nil?
91
+ if options[:store] == :static
92
+ # Static settings generally don't need reloading
93
+ options[:reload] = false
94
+ else
95
+ # For dynamic, db-backed settings at the class level, we use
96
+ # file modified reload timing by default
97
+ options[:reload] = Settings.default_timestamp_file(self.name)
98
+ end
99
+ end
100
+
101
+ # Save off our options
102
+ @settings_class_options = options
103
+
104
+ # Add this class to the settings registry
105
+ Settings.classes << self
106
+
107
+ # Add in support for settings for this class
108
+ extend ClassMethods
109
+
110
+ # Create our value store
111
+ class_settings_reset!
112
+ end
113
+
114
+ # Create a builder and do the right thing based on passed args
115
+ builder = Settings::Builder.new(@settings_class_root)
116
+ builder.define(&block) if block
117
+ builder
118
+ end
119
+
120
+ end
121
+
122
+ end