multiplicity 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: dbb437285df420a10c5468ccbf46f342d5688356
4
+ data.tar.gz: f2de70fd4eedac87e4af6a0eef0f597a48c25212
5
+ SHA512:
6
+ metadata.gz: 63a4d8ec0b7e4d764364326081b743fa12e564229474b1469f0c33ba05f83548066b38aaed2bcb7a144a86ff3caa667abd897b94e587645afd3d48e6fc69d114
7
+ data.tar.gz: 564a6288b57e4ffb1a789725784a6e5c581bdde1206173df73ad075143ee2a44c6692a3e538e228ee22d60ab1e73a9cb11112ce01d439a156a34dbb45425d9ce
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /spec/examples.txt
10
+ /tmp/
11
+ .ruby-version
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in multiplicity.gemspec
4
+ gemspec
5
+
6
+ gem 'activerecord', '~> 5.0'
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Adam Lassek
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
13
+ all 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
21
+ THE SOFTWARE.
@@ -0,0 +1,174 @@
1
+ # Multiplicity
2
+
3
+ Multiplicity is a gem for building a multitenant application in a Rack application,
4
+ with a much less opinionated approach than e.g. [Apartment](https://github.com/influitive/apartment) might entail.
5
+
6
+ The goal of this gem is to provide the simplest tools required to isolate your data, and then get out of your way.
7
+
8
+ It uses an adapter system to plug into your ORM framework of choice. Currently ActiveRecord is the only adapter included, but it is not a dependency and never will be.
9
+
10
+ Adapter contributions for additional ORMs are welcome.
11
+
12
+ ## Installation
13
+
14
+ Add this line to your application's Gemfile:
15
+
16
+ ```ruby
17
+ gem 'multiplicity'
18
+ ```
19
+
20
+ And then execute:
21
+
22
+ $ bundle
23
+
24
+ Or install it yourself as:
25
+
26
+ $ gem install multiplicity
27
+
28
+ ## Usage
29
+
30
+ Multiplicity expects a table that looks like this:
31
+
32
+ | id | subdomain | name | deleted_at |
33
+ |----|-----------|--------------|------------|
34
+ | 1 | demo | Demo Account | NULL |
35
+
36
+ The table name defaults to `tenants` but can be set with `Multiplicity.table_name`.
37
+
38
+ First, choose your adapter.
39
+
40
+ ```ruby
41
+ require 'multiplicity/adapters/active_record'
42
+ ```
43
+
44
+ Second, set the default domain for your app.
45
+
46
+ ```ruby
47
+ Multiplicity.domain = 'example.com'
48
+ ```
49
+
50
+ The domain is purely a convenience setting, you can override this when calling `Multiplicity::Tenant#uri` by passing a domain as an argument.
51
+
52
+ Finally, load the middleware. Either `config.ru` for a Rack app, or perhaps `application.rb` for Rails.
53
+
54
+ ```ruby
55
+ require 'multiplicity/middleware'
56
+ use Multiplicity::Middleware
57
+ ```
58
+
59
+ This will automatically set `Multiplicity::Tenant.current` by subdomain for the duration of your request.
60
+
61
+ ## Multiplicity::Tenant
62
+
63
+ This is the object that gets initialized by your tenant record from the db. It's just a simple [Virtus](https://github.com/solnic/virtus) model with some helper functions. It's been namespaced under `Multiplicity` so that you can be free to define your own tenant model.
64
+
65
+ ### `.find_by(column_name, value)`
66
+
67
+ This performs a simple `SELECT` against a given column.
68
+
69
+ ```ruby
70
+ Multiplicity::Tenant.current
71
+ # => nil
72
+ Multiplicity::Tenant.find_by :subdomain, 'demo'
73
+ # => #<Tenant id=1 subdomain=demo name="Demo Account" deleted_at=nil>
74
+ Multiplicity::Tenant.current
75
+ # => #<Tenant id=1 subdomain=demo name="Demo Account" deleted_at=nil>
76
+ ```
77
+
78
+ ### `.find_by!(column_name, value)`
79
+
80
+ Raises `Multiplicity::Tenant::UnknownTenantError` if tenant is not found.
81
+
82
+ ### `.load(subdomain)`
83
+
84
+ Alias for `find_by :subdomain`
85
+
86
+ ### `.current_id`
87
+
88
+ Returns the numeric id from `Multiplicity::Tenant.current` without having to care about nil traversal.
89
+
90
+ ```ruby
91
+ Multiplicity::Tenant.current_id # => nil
92
+ Multiplicity::Tenant.load 'demo'
93
+ Multiplicity::Tenant.current_id # => 1
94
+ ```
95
+
96
+ ### `.use_tenant(subdomain, &block)`
97
+
98
+ Set a given tenant inside the block without changing the global context.
99
+
100
+ ```ruby
101
+ Multiplicity::Tenant.current
102
+ # => #<Tenant id=1 subdomain=foo name="Foo Account" deleted_at=nil>
103
+ Multiplicity::Tenant.use_tenant('bar') do
104
+ Multiplicity::Tenant.current
105
+ end
106
+ # => #<Tenant id=2 subdomain=bar name="Bar Account" deleted_at=nil>
107
+ Multiplicity::Tenant.current
108
+ # => #<Tenant id=1 subdomain=foo name="Foo Account" deleted_at=nil>
109
+ ```
110
+
111
+ ### `#archived?`
112
+
113
+ Simple convenience predicate to check if `deleted_at` is nil.
114
+
115
+ ### `#uri(domain = Multiplicity.domain)`
116
+
117
+ Returns a `URI` object for the tenant's subdomain.
118
+
119
+ ```ruby
120
+ Multiplicity::Tenant.current.uri
121
+ # => #<URI::HTTPS URL:https://demo.example.com>
122
+ Multiplicity::Tenant.current.uri('example.org')
123
+ # => #<URI::HTTPS URL:https://demo.example.org>
124
+ ```
125
+
126
+ ## Isolating data
127
+
128
+ Since Multiplicity doesn't impose opinions on how to do this, it doesn't include the logic for it. Here's an example using ActiveRecord's `default_scope` feature.
129
+
130
+ ```ruby
131
+ # All multitenant models should descend from this
132
+ # parent, as they already should in Rails 5.0+
133
+ class ApplicationRecord < ActiveRecord::Base
134
+ self.abstract_class = true
135
+
136
+ def self.inherited(subclass)
137
+ super
138
+
139
+ return unless subclass.superclass == self
140
+ return unless subclass.column_names.include? 'tenant_id'
141
+
142
+ subclass.class_eval do
143
+ default_scope ->{ where tenant_id: Multiplicity::Tenant.current_id }
144
+ end
145
+ end
146
+ end
147
+ ```
148
+
149
+ Side-effects of `default_scope` that are normally downsides are upsides in this case. Every new record created in a tenant scope will automatically save the correct id.
150
+
151
+ But this is merely a suggestion. Do what works best for your business logic.
152
+
153
+ ## Development
154
+
155
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
156
+
157
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
158
+
159
+ You are, at some point, going to need to point a local url to your development machine to actually test the middleware, however most of the time you can actually just set `Multiplicity::Tenant.current` with a fake record.
160
+
161
+ ```ruby
162
+ if Rails.env.development?
163
+ Multiplicity::Tenant.current = Multiplicity::Tenant.new(id: 1, subdomain: 'demo', name: 'Demo Account')
164
+ end
165
+ ```
166
+
167
+ ## Contributing
168
+
169
+ Bug reports and pull requests are welcome on GitHub at https://github.com/alassek/multiplicity.
170
+
171
+
172
+ ## License
173
+
174
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+ task :default => :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "multiplicity"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "pry"
14
+ Pry.start
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,28 @@
1
+ require 'multiplicity/version'
2
+ require 'multiplicity/tenant'
3
+ require 'multiplicity/middleware'
4
+
5
+ module Multiplicity
6
+ def self.adapter; @adapter; end
7
+
8
+ def self.adapter=(adapter)
9
+ @adapter = adapter
10
+ end
11
+
12
+ def self.table_name
13
+ @table ||= :tenants
14
+ end
15
+
16
+ def self.table_name=(value)
17
+ @table = value.to_sym
18
+ end
19
+
20
+ def self.domain; @domain; end
21
+
22
+ def self.domain=(uri)
23
+ @domain = uri.to_s
24
+ end
25
+ end
26
+
27
+ # Always load AR adapter for now, until there is more than one
28
+ require 'multiplicity/adapters/active_record'
@@ -0,0 +1,38 @@
1
+ module Multiplicity
2
+ module Adapters
3
+ class ActiveRecord
4
+ def self.connection_pool
5
+ Thread.current[:multiplicity_connection_pool] ||= ::ActiveRecord::Base.connection_pool
6
+ end
7
+
8
+ def self.connection_pool=(pool)
9
+ Thread.current[:multiplicity_connection_pool] = pool
10
+ end
11
+
12
+ def self.find_by(field, value)
13
+ table = Arel::Table.new(Multiplicity.table_name)
14
+ columns = Multiplicity::Tenant.column_names.map{|col| table[col] }
15
+ query = table.where(table[field].eq(value)).project(columns)
16
+
17
+ connection_pool.with_connection do |connection|
18
+ connection.select_one query
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ begin
26
+ gem 'activerecord', '>= 3.1'
27
+ rescue Gem::LoadError => e
28
+ raise Gem::LoadError, "You are using functionality requiring
29
+ the optional gem dependency `#{e.name}`, but the gem is not
30
+ loaded, or is not using an acceptable version. Add
31
+ `gem '#{e.name}'` to your Gemfile. Version #{Multiplicity::VERSION}
32
+ of multiplicity requires #{e.name} that matches #{e.requirement}".gsub(/\n/, '').gsub(/\s+/, ' ')
33
+ end
34
+
35
+ require 'active_support/lazy_load_hooks'
36
+ ActiveSupport.on_load(:active_record) do
37
+ Multiplicity.adapter = Multiplicity::Adapters::ActiveRecord
38
+ end
@@ -0,0 +1,49 @@
1
+ module Multiplicity
2
+ class Middleware
3
+ attr_reader :app, :header
4
+
5
+ def initialize(app, header = 'HTTP_HOST')
6
+ @app = app
7
+ @header = header
8
+
9
+ unless defined?(Multiplicity::Adapters)
10
+ raise RuntimeError, "You must require an adapter to use Multiplicity"
11
+ end
12
+ end
13
+
14
+ def call(env)
15
+ subdomain = env[header].to_s.sub(/^http(s)?:\/\//, '').sub(/:[0-9]+$/, '')
16
+ subdomain = subdomain.split('.')[0..-3].join('.').downcase if subdomain.split('.').length > 2
17
+ subdomain = env.fetch('TENANT', 'localhost') if development?(subdomain)
18
+
19
+ if subdomain.length > 0
20
+ ::Multiplicity::Tenant.load(subdomain) or return not_found
21
+ else
22
+ return not_found
23
+ end
24
+
25
+ return gone if ::Multiplicity::Tenant.current.archived?
26
+
27
+ @app.call(env)
28
+ ensure
29
+ ::Multiplicity::Tenant.current = nil
30
+ end
31
+
32
+ def not_found
33
+ [404, { 'Content-Type' => 'text/plain', 'Content-Length' => '9' }, ['Not Found']]
34
+ end
35
+
36
+ def gone
37
+ [410, { 'Content-Type' => 'text/plain', 'Content-Length' => '15' }, ['Tenant archived']]
38
+ end
39
+
40
+ private
41
+
42
+ def development?(server_name)
43
+ return true if server_name =~ /^localhost(?:\:[0-9]+)$/
44
+ return true if server_name =~ /\.local$/
45
+ return true if server_name =~ /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:\:[0-9]+)$/
46
+ false
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,66 @@
1
+ require 'virtus'
2
+ require 'uri'
3
+
4
+ module Multiplicity
5
+ class Tenant
6
+ UnknownTenantError = Class.new(StandardError)
7
+
8
+ include Virtus.model(nullify_blank: true)
9
+
10
+ attribute :id, Integer
11
+ attribute :subdomain, String
12
+ attribute :name, String
13
+ attribute :deleted_at, DateTime
14
+
15
+ def archived?
16
+ !!deleted_at
17
+ end
18
+
19
+ def uri(domain = Multiplicity.domain)
20
+ URI("https://#{ subdomain }.#{ domain }")
21
+ end
22
+
23
+ class << self
24
+ def column_names
25
+ attribute_set.map(&:name)
26
+ end
27
+
28
+ def current
29
+ Thread.current[:multiplicity_tenant]
30
+ end
31
+
32
+ def current=(value)
33
+ Thread.current[:multiplicity_tenant] = value
34
+ end
35
+
36
+ def current_id
37
+ current && current.id
38
+ end
39
+
40
+ def use_tenant(subdomain)
41
+ previous = Tenant.current
42
+ find_by :subdomain, subdomain
43
+ yield
44
+ ensure
45
+ self.current = previous
46
+ end
47
+
48
+ def load(subdomain)
49
+ find_by :subdomain, subdomain
50
+ end
51
+
52
+ def find_by(field, value)
53
+ return current if current && current.send(field) == value
54
+
55
+ record = Multiplicity.adapter.find_by(field, value)
56
+
57
+ self.current = nil
58
+ self.current = new(record) if record
59
+ end
60
+
61
+ def find_by!(field, value)
62
+ find_by(field, value) or raise UnknownTenantError, "Unknown Tenant #{field}: #{value}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,3 @@
1
+ module Multiplicity
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,29 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'multiplicity/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "multiplicity"
8
+ spec.version = Multiplicity::VERSION
9
+ spec.authors = ["Adam Lassek"]
10
+ spec.email = ["adam@doubleprime.net"]
11
+
12
+ spec.summary = %q{Simple multitenancy for rack-based web servers}
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
16
+ spec.bindir = "exe"
17
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_dependency 'virtus', '>= 1.0.5'
21
+ spec.add_dependency 'rack'
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.12"
24
+ spec.add_development_dependency "rake", "~> 10.0"
25
+ spec.add_development_dependency "pry"
26
+ spec.add_development_dependency "sqlite3"
27
+ spec.add_development_dependency "rspec"
28
+ spec.add_development_dependency "rspec-expectations"
29
+ end
metadata ADDED
@@ -0,0 +1,170 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: multiplicity
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Adam Lassek
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-08-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: virtus
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 1.0.5
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 1.0.5
27
+ - !ruby/object:Gem::Dependency
28
+ name: rack
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.12'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.12'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '10.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '10.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pry
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec-expectations
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description:
126
+ email:
127
+ - adam@doubleprime.net
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - ".gitignore"
133
+ - ".rspec"
134
+ - Gemfile
135
+ - LICENSE.txt
136
+ - README.md
137
+ - Rakefile
138
+ - bin/console
139
+ - bin/setup
140
+ - lib/multiplicity.rb
141
+ - lib/multiplicity/adapters/active_record.rb
142
+ - lib/multiplicity/middleware.rb
143
+ - lib/multiplicity/tenant.rb
144
+ - lib/multiplicity/version.rb
145
+ - multiplicity.gemspec
146
+ homepage:
147
+ licenses:
148
+ - MIT
149
+ metadata: {}
150
+ post_install_message:
151
+ rdoc_options: []
152
+ require_paths:
153
+ - lib
154
+ required_ruby_version: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ required_rubygems_version: !ruby/object:Gem::Requirement
160
+ requirements:
161
+ - - ">="
162
+ - !ruby/object:Gem::Version
163
+ version: '0'
164
+ requirements: []
165
+ rubyforge_project:
166
+ rubygems_version: 2.5.1
167
+ signing_key:
168
+ specification_version: 4
169
+ summary: Simple multitenancy for rack-based web servers
170
+ test_files: []