iron-settings 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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