synerma-apartment 3.1.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.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.pryrc +5 -0
- data/.rspec +4 -0
- data/.rubocop.yml +79 -0
- data/.ruby-version +1 -0
- data/Appraisals +182 -0
- data/CODE_OF_CONDUCT.md +71 -0
- data/Gemfile +20 -0
- data/Guardfile +11 -0
- data/README.md +671 -0
- data/Rakefile +157 -0
- data/legacy_CHANGELOG.md +965 -0
- data/lib/apartment/active_record/connection_handling.rb +31 -0
- data/lib/apartment/active_record/internal_metadata.rb +9 -0
- data/lib/apartment/active_record/postgres/schema_dumper.rb +20 -0
- data/lib/apartment/active_record/postgresql_adapter.rb +58 -0
- data/lib/apartment/active_record/schema_migration.rb +11 -0
- data/lib/apartment/adapters/abstract_adapter.rb +275 -0
- data/lib/apartment/adapters/abstract_jdbc_adapter.rb +20 -0
- data/lib/apartment/adapters/jdbc_mysql_adapter.rb +19 -0
- data/lib/apartment/adapters/jdbc_postgresql_adapter.rb +62 -0
- data/lib/apartment/adapters/mysql2_adapter.rb +77 -0
- data/lib/apartment/adapters/postgis_adapter.rb +13 -0
- data/lib/apartment/adapters/postgresql_adapter.rb +280 -0
- data/lib/apartment/adapters/sqlite3_adapter.rb +66 -0
- data/lib/apartment/adapters/trilogy_adapter.rb +29 -0
- data/lib/apartment/console.rb +24 -0
- data/lib/apartment/custom_console.rb +42 -0
- data/lib/apartment/deprecation.rb +8 -0
- data/lib/apartment/elevators/domain.rb +23 -0
- data/lib/apartment/elevators/first_subdomain.rb +18 -0
- data/lib/apartment/elevators/generic.rb +33 -0
- data/lib/apartment/elevators/host.rb +35 -0
- data/lib/apartment/elevators/host_hash.rb +26 -0
- data/lib/apartment/elevators/subdomain.rb +66 -0
- data/lib/apartment/log_subscriber.rb +45 -0
- data/lib/apartment/migrator.rb +46 -0
- data/lib/apartment/model.rb +29 -0
- data/lib/apartment/railtie.rb +68 -0
- data/lib/apartment/tasks/enhancements.rb +55 -0
- data/lib/apartment/tasks/task_helper.rb +54 -0
- data/lib/apartment/tenant.rb +63 -0
- data/lib/apartment/version.rb +5 -0
- data/lib/apartment.rb +155 -0
- data/lib/generators/apartment/install/USAGE +5 -0
- data/lib/generators/apartment/install/install_generator.rb +11 -0
- data/lib/generators/apartment/install/templates/apartment.rb +116 -0
- data/lib/tasks/apartment.rake +106 -0
- data/synerma-apartment.gemspec +40 -0
- metadata +198 -0
@@ -0,0 +1,280 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'apartment/adapters/abstract_adapter'
|
4
|
+
require 'apartment/active_record/postgresql_adapter'
|
5
|
+
|
6
|
+
module Apartment
|
7
|
+
module Tenant
|
8
|
+
def self.postgresql_adapter(config)
|
9
|
+
adapter = Adapters::PostgresqlAdapter
|
10
|
+
adapter = Adapters::PostgresqlSchemaAdapter if Apartment.use_schemas
|
11
|
+
adapter = Adapters::PostgresqlSchemaFromSqlAdapter if Apartment.use_sql && Apartment.use_schemas
|
12
|
+
adapter.new(config)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
module Adapters
|
17
|
+
# Default adapter when not using Postgresql Schemas
|
18
|
+
class PostgresqlAdapter < AbstractAdapter
|
19
|
+
private
|
20
|
+
|
21
|
+
def rescue_from
|
22
|
+
PG::Error
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Separate Adapter for Postgresql when using schemas
|
27
|
+
class PostgresqlSchemaAdapter < AbstractAdapter
|
28
|
+
def initialize(config)
|
29
|
+
super
|
30
|
+
|
31
|
+
reset
|
32
|
+
end
|
33
|
+
|
34
|
+
def default_tenant
|
35
|
+
@default_tenant = Apartment.default_tenant || 'public'
|
36
|
+
end
|
37
|
+
|
38
|
+
# Reset schema search path to the default schema_search_path
|
39
|
+
#
|
40
|
+
# @return {String} default schema search path
|
41
|
+
#
|
42
|
+
def reset
|
43
|
+
@current = default_tenant
|
44
|
+
Apartment.connection.schema_search_path = full_search_path
|
45
|
+
end
|
46
|
+
|
47
|
+
def init
|
48
|
+
super
|
49
|
+
Apartment.connection.schema_search_path = full_search_path
|
50
|
+
end
|
51
|
+
|
52
|
+
def current
|
53
|
+
@current || default_tenant
|
54
|
+
end
|
55
|
+
|
56
|
+
protected
|
57
|
+
|
58
|
+
def process_excluded_model(excluded_model)
|
59
|
+
excluded_model.constantize.tap do |klass|
|
60
|
+
# Ensure that if a schema *was* set, we override
|
61
|
+
table_name = klass.table_name.split('.', 2).last
|
62
|
+
|
63
|
+
klass.table_name = "#{default_tenant}.#{table_name}"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def drop_command(conn, tenant)
|
68
|
+
conn.execute(%(DROP SCHEMA "#{tenant}" CASCADE))
|
69
|
+
end
|
70
|
+
|
71
|
+
# Set schema search path to new schema
|
72
|
+
#
|
73
|
+
def connect_to_new(tenant = nil)
|
74
|
+
return reset if tenant.nil?
|
75
|
+
raise ActiveRecord::StatementInvalid, "Could not find schema #{tenant}" unless schema_exists?(tenant)
|
76
|
+
|
77
|
+
@current = tenant.is_a?(Array) ? tenant.map(&:to_s) : tenant.to_s
|
78
|
+
Apartment.connection.schema_search_path = full_search_path
|
79
|
+
rescue *rescuable_exceptions => e
|
80
|
+
raise_schema_connect_to_new(tenant, e)
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def tenant_exists?(tenant)
|
86
|
+
return true unless Apartment.tenant_presence_check
|
87
|
+
|
88
|
+
Apartment.connection.schema_exists?(tenant)
|
89
|
+
end
|
90
|
+
|
91
|
+
def create_tenant_command(conn, tenant)
|
92
|
+
# NOTE: This was causing some tests to fail because of the database strategy for rspec
|
93
|
+
if ActiveRecord::Base.connection.open_transactions.positive?
|
94
|
+
conn.execute(%(CREATE SCHEMA "#{tenant}"))
|
95
|
+
else
|
96
|
+
schema = %(BEGIN;
|
97
|
+
CREATE SCHEMA "#{tenant}";
|
98
|
+
COMMIT;)
|
99
|
+
|
100
|
+
conn.execute(schema)
|
101
|
+
end
|
102
|
+
rescue *rescuable_exceptions => e
|
103
|
+
rollback_transaction(conn)
|
104
|
+
raise e
|
105
|
+
end
|
106
|
+
|
107
|
+
def rollback_transaction(conn)
|
108
|
+
conn.execute('ROLLBACK;')
|
109
|
+
end
|
110
|
+
|
111
|
+
# Generate the final search path to set including persistent_schemas
|
112
|
+
#
|
113
|
+
def full_search_path
|
114
|
+
persistent_schemas.map(&:inspect).join(', ')
|
115
|
+
end
|
116
|
+
|
117
|
+
def persistent_schemas
|
118
|
+
[@current, Apartment.persistent_schemas].flatten
|
119
|
+
end
|
120
|
+
|
121
|
+
def postgresql_version
|
122
|
+
# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#postgresql_version is
|
123
|
+
# public from Rails 5.0.
|
124
|
+
Apartment.connection.send(:postgresql_version)
|
125
|
+
end
|
126
|
+
|
127
|
+
def schema_exists?(schemas)
|
128
|
+
return true unless Apartment.tenant_presence_check
|
129
|
+
|
130
|
+
Array(schemas).all? { |schema| Apartment.connection.schema_exists?(schema.to_s) }
|
131
|
+
end
|
132
|
+
|
133
|
+
def raise_schema_connect_to_new(tenant, exception)
|
134
|
+
raise TenantNotFound, <<~EXCEPTION_MESSAGE
|
135
|
+
Could not set search path to schemas, they may be invalid: "#{tenant}" #{full_search_path}.
|
136
|
+
Original error: #{exception.class}: #{exception}
|
137
|
+
EXCEPTION_MESSAGE
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Another Adapter for Postgresql when using schemas and SQL
|
142
|
+
class PostgresqlSchemaFromSqlAdapter < PostgresqlSchemaAdapter
|
143
|
+
PSQL_DUMP_BLACKLISTED_STATEMENTS = [
|
144
|
+
/SET search_path/i, # overridden later
|
145
|
+
/SET lock_timeout/i, # new in postgresql 9.3
|
146
|
+
/SET row_security/i, # new in postgresql 9.5
|
147
|
+
/SET idle_in_transaction_session_timeout/i, # new in postgresql 9.6
|
148
|
+
/SET default_table_access_method/i, # new in postgresql 12
|
149
|
+
/CREATE SCHEMA/i,
|
150
|
+
/COMMENT ON SCHEMA/i,
|
151
|
+
/SET transaction_timeout/i, # new in postgresql 17
|
152
|
+
|
153
|
+
].freeze
|
154
|
+
|
155
|
+
def import_database_schema
|
156
|
+
preserving_search_path do
|
157
|
+
clone_pg_schema
|
158
|
+
copy_schema_migrations
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
private
|
163
|
+
|
164
|
+
# Re-set search path after the schema is imported.
|
165
|
+
# Postgres now sets search path to empty before dumping the schema
|
166
|
+
# and it mut be reset
|
167
|
+
#
|
168
|
+
def preserving_search_path
|
169
|
+
search_path = Apartment.connection.execute('show search_path').first['search_path']
|
170
|
+
yield
|
171
|
+
Apartment.connection.execute("set search_path = #{search_path}")
|
172
|
+
end
|
173
|
+
|
174
|
+
# Clone default schema into new schema named after current tenant
|
175
|
+
#
|
176
|
+
def clone_pg_schema
|
177
|
+
pg_schema_sql = patch_search_path(pg_dump_schema)
|
178
|
+
Apartment.connection.execute(pg_schema_sql)
|
179
|
+
end
|
180
|
+
|
181
|
+
# Copy data from schema_migrations into new schema
|
182
|
+
#
|
183
|
+
def copy_schema_migrations
|
184
|
+
pg_migrations_data = patch_search_path(pg_dump_schema_migrations_data)
|
185
|
+
Apartment.connection.execute(pg_migrations_data)
|
186
|
+
end
|
187
|
+
|
188
|
+
# Dump postgres default schema
|
189
|
+
#
|
190
|
+
# @return {String} raw SQL contaning only postgres schema dump
|
191
|
+
#
|
192
|
+
def pg_dump_schema
|
193
|
+
exclude_table =
|
194
|
+
if Apartment.pg_exclude_clone_tables
|
195
|
+
excluded_tables.map! { |t| "-T #{t}" }.join(' ')
|
196
|
+
else
|
197
|
+
''
|
198
|
+
end
|
199
|
+
with_pg_env { `pg_dump -s -x -O -n #{default_tenant} #{dbname} #{exclude_table}` }
|
200
|
+
end
|
201
|
+
|
202
|
+
# Dump data from schema_migrations table
|
203
|
+
#
|
204
|
+
# @return {String} raw SQL contaning inserts with data from schema_migrations
|
205
|
+
#
|
206
|
+
# rubocop:disable Layout/LineLength
|
207
|
+
def pg_dump_schema_migrations_data
|
208
|
+
with_pg_env { `pg_dump -a --inserts -t #{default_tenant}.schema_migrations -t #{default_tenant}.ar_internal_metadata #{dbname}` }
|
209
|
+
end
|
210
|
+
# rubocop:enable Layout/LineLength
|
211
|
+
|
212
|
+
# Temporary set Postgresql related environment variables if there are in @config
|
213
|
+
#
|
214
|
+
def with_pg_env
|
215
|
+
pghost = ENV['PGHOST']
|
216
|
+
pgport = ENV['PGPORT']
|
217
|
+
pguser = ENV['PGUSER']
|
218
|
+
pgpassword = ENV['PGPASSWORD']
|
219
|
+
|
220
|
+
ENV['PGHOST'] = @config[:host] if @config[:host]
|
221
|
+
ENV['PGPORT'] = @config[:port].to_s if @config[:port]
|
222
|
+
ENV['PGUSER'] = @config[:username].to_s if @config[:username]
|
223
|
+
ENV['PGPASSWORD'] = @config[:password].to_s if @config[:password]
|
224
|
+
|
225
|
+
yield
|
226
|
+
ensure
|
227
|
+
ENV['PGHOST'] = pghost
|
228
|
+
ENV['PGPORT'] = pgport
|
229
|
+
ENV['PGUSER'] = pguser
|
230
|
+
ENV['PGPASSWORD'] = pgpassword
|
231
|
+
end
|
232
|
+
|
233
|
+
# Remove "SET search_path ..." line from SQL dump and prepend search_path set to current tenant
|
234
|
+
#
|
235
|
+
# @return {String} patched raw SQL dump
|
236
|
+
#
|
237
|
+
def patch_search_path(sql)
|
238
|
+
search_path = "SET search_path = \"#{current}\", #{default_tenant};"
|
239
|
+
|
240
|
+
swap_schema_qualifier(sql)
|
241
|
+
.split("\n")
|
242
|
+
.select { |line| check_input_against_regexps(line, PSQL_DUMP_BLACKLISTED_STATEMENTS).empty? }
|
243
|
+
.prepend(search_path)
|
244
|
+
.join("\n")
|
245
|
+
end
|
246
|
+
|
247
|
+
def swap_schema_qualifier(sql)
|
248
|
+
sql.gsub(/#{default_tenant}\.\w*/) do |match|
|
249
|
+
if Apartment.pg_excluded_names.any? { |name| match.include? name }
|
250
|
+
match
|
251
|
+
elsif Apartment.pg_exclude_clone_tables && excluded_tables.any?(match)
|
252
|
+
match
|
253
|
+
else
|
254
|
+
match.gsub("#{default_tenant}.", %("#{current}".))
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
# Checks if any of regexps matches against input
|
260
|
+
#
|
261
|
+
def check_input_against_regexps(input, regexps)
|
262
|
+
regexps.select { |c| input.match c }
|
263
|
+
end
|
264
|
+
|
265
|
+
# Convenience method for excluded table names
|
266
|
+
#
|
267
|
+
def excluded_tables
|
268
|
+
Apartment.excluded_models.map do |m|
|
269
|
+
m.constantize.table_name
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
# Convenience method for current database name
|
274
|
+
#
|
275
|
+
def dbname
|
276
|
+
Apartment.connection_config[:database]
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'apartment/adapters/abstract_adapter'
|
4
|
+
|
5
|
+
module Apartment
|
6
|
+
module Tenant
|
7
|
+
def self.sqlite3_adapter(config)
|
8
|
+
Adapters::Sqlite3Adapter.new(config)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
module Adapters
|
13
|
+
class Sqlite3Adapter < AbstractAdapter
|
14
|
+
def initialize(config)
|
15
|
+
@default_dir = File.expand_path(File.dirname(config[:database]))
|
16
|
+
|
17
|
+
super
|
18
|
+
end
|
19
|
+
|
20
|
+
def drop(tenant)
|
21
|
+
unless File.exist?(database_file(tenant))
|
22
|
+
raise TenantNotFound,
|
23
|
+
"The tenant #{environmentify(tenant)} cannot be found."
|
24
|
+
end
|
25
|
+
|
26
|
+
File.delete(database_file(tenant))
|
27
|
+
end
|
28
|
+
|
29
|
+
def current
|
30
|
+
File.basename(Apartment.connection.instance_variable_get(:@config)[:database], '.sqlite3')
|
31
|
+
end
|
32
|
+
|
33
|
+
protected
|
34
|
+
|
35
|
+
def connect_to_new(tenant)
|
36
|
+
return reset if tenant.nil?
|
37
|
+
|
38
|
+
unless File.exist?(database_file(tenant))
|
39
|
+
raise TenantNotFound,
|
40
|
+
"The tenant #{environmentify(tenant)} cannot be found."
|
41
|
+
end
|
42
|
+
|
43
|
+
super database_file(tenant)
|
44
|
+
end
|
45
|
+
|
46
|
+
def create_tenant(tenant)
|
47
|
+
if File.exist?(database_file(tenant))
|
48
|
+
raise TenantExists,
|
49
|
+
"The tenant #{environmentify(tenant)} already exists."
|
50
|
+
end
|
51
|
+
|
52
|
+
begin
|
53
|
+
f = File.new(database_file(tenant), File::CREAT)
|
54
|
+
ensure
|
55
|
+
f.close
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def database_file(tenant)
|
62
|
+
"#{@default_dir}/#{environmentify(tenant)}.sqlite3"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'apartment/adapters/mysql2_adapter'
|
4
|
+
|
5
|
+
module Apartment
|
6
|
+
# Helper module to decide wether to use trilogy adapter or trilogy adapter with schemas
|
7
|
+
module Tenant
|
8
|
+
def self.trilogy_adapter(config)
|
9
|
+
if Apartment.use_schemas
|
10
|
+
Adapters::TrilogySchemaAdapter.new(config)
|
11
|
+
else
|
12
|
+
Adapters::TrilogyAdapter.new(config)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
module Adapters
|
18
|
+
class TrilogyAdapter < Mysql2Adapter
|
19
|
+
protected
|
20
|
+
|
21
|
+
def rescue_from
|
22
|
+
Trilogy::Error
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class TrilogySchemaAdapter < Mysql2SchemaAdapter
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
def st(schema_name = nil)
|
4
|
+
if schema_name.nil?
|
5
|
+
tenant_list.each { |t| puts t }
|
6
|
+
|
7
|
+
elsif tenant_list.include? schema_name
|
8
|
+
Apartment::Tenant.switch!(schema_name)
|
9
|
+
else
|
10
|
+
puts "Tenant #{schema_name} is not part of the tenant list"
|
11
|
+
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def tenant_list
|
16
|
+
tenant_list = [Apartment.default_tenant]
|
17
|
+
tenant_list += Apartment.tenant_names
|
18
|
+
tenant_list.uniq
|
19
|
+
end
|
20
|
+
|
21
|
+
def tenant_info_msg
|
22
|
+
puts "Available Tenants: #{tenant_list}\n"
|
23
|
+
puts "Use `st 'tenant'` to switch tenants & `tenant_list` to see list\n"
|
24
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'console'
|
4
|
+
|
5
|
+
module Apartment
|
6
|
+
module CustomConsole
|
7
|
+
begin
|
8
|
+
require 'pry-rails'
|
9
|
+
rescue LoadError
|
10
|
+
# rubocop:disable Layout/LineLength
|
11
|
+
puts '[Failed to load pry-rails] If you want to use Apartment custom prompt you need to add pry-rails to your gemfile'
|
12
|
+
# rubocop:enable Layout/LineLength
|
13
|
+
end
|
14
|
+
|
15
|
+
desc = "Includes the current Rails environment and project folder name.\n" \
|
16
|
+
'[1] [project_name][Rails.env][Apartment::Tenant.current] pry(main)>'
|
17
|
+
|
18
|
+
prompt_procs = [
|
19
|
+
proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '>') },
|
20
|
+
proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '*') }
|
21
|
+
]
|
22
|
+
|
23
|
+
if Gem::Version.new(Pry::VERSION) >= Gem::Version.new('0.13')
|
24
|
+
Pry.config.prompt = Pry::Prompt.new 'ros', desc, prompt_procs
|
25
|
+
else
|
26
|
+
Pry::Prompt.add 'ros', desc, %w[> *] do |target_self, nest_level, pry, sep|
|
27
|
+
prompt_contents(pry, target_self, nest_level, sep)
|
28
|
+
end
|
29
|
+
Pry.config.prompt = Pry::Prompt[:ros][:value]
|
30
|
+
end
|
31
|
+
|
32
|
+
Pry.config.hooks.add_hook(:when_started, 'startup message') do
|
33
|
+
tenant_info_msg
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.prompt_contents(pry, target_self, nest_level, sep)
|
37
|
+
"[#{pry.input_ring.size}] [#{PryRails::Prompt.formatted_env}][#{Apartment::Tenant.current}] " \
|
38
|
+
"#{pry.config.prompt_name}(#{Pry.view_clip(target_self)})" \
|
39
|
+
"#{":#{nest_level}" unless nest_level.zero?}#{sep} "
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'apartment/elevators/generic'
|
4
|
+
|
5
|
+
module Apartment
|
6
|
+
module Elevators
|
7
|
+
# Provides a rack based tenant switching solution based on domain
|
8
|
+
# Assumes that tenant name should match domain
|
9
|
+
# Parses request host for second level domain, ignoring www
|
10
|
+
# eg. example.com => example
|
11
|
+
# www.example.bc.ca => example
|
12
|
+
# a.example.bc.ca => a
|
13
|
+
#
|
14
|
+
#
|
15
|
+
class Domain < Generic
|
16
|
+
def parse_tenant_name(request)
|
17
|
+
return nil if request.host.blank?
|
18
|
+
|
19
|
+
request.host.match(/(www\.)?(?<sld>[^.]*)/)['sld']
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'apartment/elevators/subdomain'
|
4
|
+
|
5
|
+
module Apartment
|
6
|
+
module Elevators
|
7
|
+
# Provides a rack based tenant switching solution based on the first subdomain
|
8
|
+
# of a given domain name.
|
9
|
+
# eg:
|
10
|
+
# - example1.domain.com => example1
|
11
|
+
# - example2.something.domain.com => example2
|
12
|
+
class FirstSubdomain < Subdomain
|
13
|
+
def parse_tenant_name(request)
|
14
|
+
super.split('.')[0] unless super.nil?
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rack/request'
|
4
|
+
require 'apartment/tenant'
|
5
|
+
|
6
|
+
module Apartment
|
7
|
+
module Elevators
|
8
|
+
# Provides a rack based tenant switching solution based on request
|
9
|
+
#
|
10
|
+
class Generic
|
11
|
+
def initialize(app, processor = nil)
|
12
|
+
@app = app
|
13
|
+
@processor = processor || method(:parse_tenant_name)
|
14
|
+
end
|
15
|
+
|
16
|
+
def call(env)
|
17
|
+
request = Rack::Request.new(env)
|
18
|
+
|
19
|
+
database = @processor.call(request)
|
20
|
+
|
21
|
+
if database
|
22
|
+
Apartment::Tenant.switch(database) { @app.call(env) }
|
23
|
+
else
|
24
|
+
@app.call(env)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def parse_tenant_name(_request)
|
29
|
+
raise 'Override'
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'apartment/elevators/generic'
|
4
|
+
|
5
|
+
module Apartment
|
6
|
+
module Elevators
|
7
|
+
# Provides a rack based tenant switching solution based on the host
|
8
|
+
# Assumes that tenant name should match host
|
9
|
+
# Strips/ignores first subdomains in ignored_first_subdomains
|
10
|
+
# eg. example.com => example.com
|
11
|
+
# www.example.bc.ca => www.example.bc.ca
|
12
|
+
# if ignored_first_subdomains = ['www']
|
13
|
+
# www.example.bc.ca => example.bc.ca
|
14
|
+
# www.a.b.c.d.com => a.b.c.d.com
|
15
|
+
#
|
16
|
+
class Host < Generic
|
17
|
+
def self.ignored_first_subdomains
|
18
|
+
@ignored_first_subdomains ||= []
|
19
|
+
end
|
20
|
+
|
21
|
+
# rubocop:disable Style/TrivialAccessors
|
22
|
+
def self.ignored_first_subdomains=(arg)
|
23
|
+
@ignored_first_subdomains = arg
|
24
|
+
end
|
25
|
+
# rubocop:enable Style/TrivialAccessors
|
26
|
+
|
27
|
+
def parse_tenant_name(request)
|
28
|
+
return nil if request.host.blank?
|
29
|
+
|
30
|
+
parts = request.host.split('.')
|
31
|
+
self.class.ignored_first_subdomains.include?(parts[0]) ? parts.drop(1).join('.') : request.host
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'apartment/elevators/generic'
|
4
|
+
|
5
|
+
module Apartment
|
6
|
+
module Elevators
|
7
|
+
# Provides a rack based tenant switching solution based on hosts
|
8
|
+
# Uses a hash to find the corresponding tenant name for the host
|
9
|
+
#
|
10
|
+
class HostHash < Generic
|
11
|
+
def initialize(app, hash = {}, processor = nil)
|
12
|
+
super app, processor
|
13
|
+
@hash = hash
|
14
|
+
end
|
15
|
+
|
16
|
+
def parse_tenant_name(request)
|
17
|
+
unless @hash.key?(request.host)
|
18
|
+
raise TenantNotFound,
|
19
|
+
"Cannot find tenant for host #{request.host}"
|
20
|
+
end
|
21
|
+
|
22
|
+
@hash[request.host]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'apartment/elevators/generic'
|
4
|
+
require 'public_suffix'
|
5
|
+
|
6
|
+
module Apartment
|
7
|
+
module Elevators
|
8
|
+
# Provides a rack based tenant switching solution based on subdomains
|
9
|
+
# Assumes that tenant name should match subdomain
|
10
|
+
#
|
11
|
+
class Subdomain < Generic
|
12
|
+
def self.excluded_subdomains
|
13
|
+
@excluded_subdomains ||= []
|
14
|
+
end
|
15
|
+
|
16
|
+
# rubocop:disable Style/TrivialAccessors
|
17
|
+
def self.excluded_subdomains=(arg)
|
18
|
+
@excluded_subdomains = arg
|
19
|
+
end
|
20
|
+
# rubocop:enable Style/TrivialAccessors
|
21
|
+
|
22
|
+
def parse_tenant_name(request)
|
23
|
+
request_subdomain = subdomain(request.host)
|
24
|
+
|
25
|
+
# If the domain acquired is set to be excluded, set the tenant to whatever is currently
|
26
|
+
# next in line in the schema search path.
|
27
|
+
tenant = if self.class.excluded_subdomains.include?(request_subdomain)
|
28
|
+
nil
|
29
|
+
else
|
30
|
+
request_subdomain
|
31
|
+
end
|
32
|
+
|
33
|
+
tenant.presence
|
34
|
+
end
|
35
|
+
|
36
|
+
protected
|
37
|
+
|
38
|
+
# *Almost* a direct ripoff of ActionDispatch::Request subdomain methods
|
39
|
+
|
40
|
+
# Only care about the first subdomain for the database name
|
41
|
+
def subdomain(host)
|
42
|
+
subdomains(host).first
|
43
|
+
end
|
44
|
+
|
45
|
+
def subdomains(host)
|
46
|
+
host_valid?(host) ? parse_host(host) : []
|
47
|
+
end
|
48
|
+
|
49
|
+
def host_valid?(host)
|
50
|
+
!ip_host?(host) && domain_valid?(host)
|
51
|
+
end
|
52
|
+
|
53
|
+
def ip_host?(host)
|
54
|
+
!/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.match(host).nil?
|
55
|
+
end
|
56
|
+
|
57
|
+
def domain_valid?(host)
|
58
|
+
PublicSuffix.valid?(host, ignore_private: true)
|
59
|
+
end
|
60
|
+
|
61
|
+
def parse_host(host)
|
62
|
+
(PublicSuffix.parse(host, ignore_private: true).trd || '').split('.')
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|