ravioli 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 10956034d9851d2c30cc00a73bbb95ece033cb8ca1d6bc66dee4ad106335b430
4
+ data.tar.gz: 340b538b33f19016fbc494633ef809a79b1df518016af43de1c84f5428ce42fd
5
+ SHA512:
6
+ metadata.gz: e639d69ce9ccd5e373805b8e06edc8d2e5a468af695ce40dc6630cafbfe2d8ae403fe63cda38ba61b6d6b8becdbdca846566d6f5aea374a4ae9905ae66527d74
7
+ data.tar.gz: f71dd8b25999a8972fc3ac9173c488ce337233f5f2496dcdb084c59067ea48056026ac65d9f1a8f176aac52c5eca9b5c71b6202998ebc40761bb6ff0064c491e
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2020 Flip Sasser
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
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,465 @@
1
+ # Ravioli.rb 🍝
2
+
3
+
4
+ **Grab a fork and twist your configuration spaghetti in a single, delicious dumpling!**
5
+
6
+ Ravioli combines all of your app's runtime configuration into a unified, simple interface. **It combines YAML or JSON configuration files, encrypted Rails credentials, and ENV vars into one easy-to-consume interface** so you can focus on writing code and not on where configuration comes from.
7
+
8
+ **Ravioli turns this...**
9
+
10
+ ```ruby
11
+ key = ENV.fetch("THING_API_KEY") { Rails.credentials.thing&["api_key"] || raise("I need an API key for thing to work") }
12
+ ```
13
+
14
+ **...into this:**
15
+
16
+ ```ruby
17
+ key = Rails.config.dig!(:thing, :api_key)
18
+ ```
19
+
20
+ **🚨 FYI:** Ravioli is two libraries: a Ruby gem (this doc), and a [JavaScript NPM package](src/README.md). The NPM docs contain specifics about how to [use Ravioli in the Rails asset pipeline](src/README.md#using-in-the-rails-asset-pipeline), in [a Node web server](src/README.md#using-in-a-server), or [bundled into a client using Webpack](src/README.md#using-with-webpack), [Rollup](src/README.md#using-with-rollup), or [whatever else](src/README.md#using-with-another-bundler).
21
+
22
+ ## Table of Contents
23
+
24
+ 1. [Installation](#installation)
25
+ 2. [Usage](#usage)
26
+ 3. [Automatic Configuration](#automatic-configuration)
27
+ 4. [Manual Configuration](#manual-configuration)
28
+ 5. [Deploying](#deploying)
29
+ 6. [License](#license)
30
+ <!-- 5. [JavaScript library](#javascript-library) -->
31
+
32
+ ## Installation
33
+
34
+ <!--Ravioli comes as a Ruby gem or an NPM package; they work marginally differently. Let's focus on Ruby/Rails for now.
35
+ -->
36
+ 1. Add `gem "ravioli"` to your `Gemfile`
37
+ 2. Run `bundle install`
38
+ 3. Add an initializer (totally optional): `rails generate ravioli:install` - Ravioli will do **everything** automatically for you if you skip this step, because I aim to *please*
39
+
40
+ ## Usage
41
+
42
+ Ravioli turns your app's configuration environment into a [PORO](http://blog.jayfields.com/2007/10/ruby-poro.html) with direct accessors and a few special methods. By *default*, it adds the method `Rails.config` that returns a Ravioli instance. You can access all of your app's configuration from there. _This is totally optional_ and you can also do everything manually, but for the sake of these initial examples, we'll use the `Rails.config` setup.
43
+
44
+ Either way, for the following examples, imagine we had the following configuration structure:*
45
+
46
+ ```yaml
47
+ host: "example.com"
48
+ url: "https://www.example.com"
49
+ sender: "reply-welcome@example.com"
50
+
51
+ database:
52
+ host: "localhost"
53
+ port: "5432"
54
+
55
+ sendgrid:
56
+ api_key: "12345"
57
+
58
+ sentry:
59
+ api_key: "12345"
60
+ environment: <%= Rails.env %>
61
+ dsn: "https://sentry.io/whatever?api_key=12345"
62
+ ```
63
+
64
+ <small>*this structure is the end result of Ravioli's loading process; it has nothing to do with filesystem organization or config file layout. We'll talk about that in a bit, so just slow your roll about loading up config files until then.</small>
65
+
66
+ **Got it? Good.** Let's access some configuration,
67
+
68
+ ### Accessing values directly
69
+
70
+ Ravioli objects support direct accessors:
71
+
72
+ ```ruby
73
+ Rails.config.host #=> "example.com"
74
+ Rails.config.database.port #=> "5432"
75
+ Rails.config.not.here #=> NoMethodError (undefined method `here' for nil:NilClass)
76
+ ```
77
+
78
+ ### Accessing configuration values safely by key path
79
+
80
+ #### Traversing the keypath with `dig`
81
+
82
+ You can traverse deeply nested config values safely with `dig`:
83
+
84
+ ```ruby
85
+ Rails.config.dig(:database, :port) #=> "5432"
86
+ Rails.config.dig(:not, :here) #=> nil
87
+ ```
88
+
89
+ This works the same in principle as the [`dig`](https://ruby-doc.org/core-2.7.2/Hash.html#method-i-dig) method on `Hash` objects, with the added benefit of not caring about key type (both symbols and strings are accepted).
90
+
91
+ #### Providing fallback values with `fetch`
92
+
93
+ You can provide a sane fallback value using `fetch`, which works like `dig` but accepts a block:
94
+
95
+ ```ruby
96
+ Rails.config.fetch(:database, :port) { "5678" } #=> "5432" is returned from the config
97
+ Rails.config.fetch(:not, :here) { "PRESENT!" } #=> "PRESENT!" is returned from the block
98
+ ```
99
+
100
+ **Note that `fetch` differs from the [`fetch`](https://ruby-doc.org/core-2.7.2/Hash.html#method-i-fetch) method on `Hash` objects.** Ravioli's `fetch` accepts keys as arguments, and does not accept a `default` argument - instead, the default _must_ appear inside of a block.
101
+
102
+ #### Requiring configuration values with `dig!`
103
+
104
+ If a part of your app cannot operate without a configuration value, e.g. an API key is required to make an API call, you can use `dig!`, which behaves identically to `dig` except it will raise a `KeyMissingError` if no value is specified:
105
+
106
+ ```ruby
107
+ uri = URI("https://api.example.com/things/1")
108
+ request = Net::HTTP::Get.new(uri)
109
+ request["X-Example-API-Key"] = Rails.config.dig!(:example, :api_key) #=> Ravioli::KeyMissingError (could not find configuration value at key path [:example, :api_key])
110
+ ```
111
+
112
+ #### Allowing for blank values with `safe` (or `dig(*keys, safe: true)`)
113
+
114
+ As a convenience for avoiding the billion dollar mistake, you can use `safe` to ensure you're operating on a configuration object, even if it has not been set for your environment:
115
+
116
+ ```ruby
117
+ Rails.config.dig(:google) #=> nil
118
+ Rails.config.safe(:google) #=> #<Ravioli::Configuration {}>
119
+ Rails.config.dig(:google, safe: true) #=> #<Ravioli::Configuration {}>
120
+ ```
121
+
122
+ Use `safe` when, for example, you don't want your code to explode because a root config key is not set. Here's an example:
123
+
124
+ ```ruby
125
+ class GoogleMapsClient
126
+ include HTTParty
127
+
128
+ config = Rails.config.safe(:google)
129
+ headers "Auth-Token" => config.token, "Other-Header" => config.other_thing
130
+ base_uri config.fetch(:base_uri) { "https://api.google.com/maps-do-stuff-cool-right" }
131
+ end
132
+ ```
133
+
134
+ ### Querying for presence
135
+
136
+ In addition to direct accessors, you can append a `?` to a method to see if a value exists. For example:
137
+
138
+ ```ruby
139
+ Rails.config.database.host? #=> true
140
+ Rails.config.database.password? #=> false
141
+ ```
142
+
143
+ ### `ENV` variables take precedence over loaded configuration
144
+
145
+ I guess the headline is the thing: `ENV` variables take precedence over loaded configuration files. When loading or querying your configuration, Ravioli checks for a capitalized `ENV` variable corresponding to the keypath you're searching.
146
+
147
+ For example:
148
+
149
+ ```env
150
+ Rails.config.dig(:database, :url)
151
+
152
+ # ...is equivalent to...
153
+
154
+ ENV.fetch("DATABASE_URL") { Rails.config.database&.url }
155
+ ```
156
+
157
+ This means that you can use Ravioli instead of querying `ENV` for its keys, and it'll get you the right value every time.
158
+
159
+ ## Automatic Configuration
160
+
161
+ **The fastest way to use Ravioli is via automatic configuration,** bootstrapping it into the `Rails.config` method. This is the default experience when you `require "ravioli"`, either explicitly through an initializer or implicitly through `gem "ravioli"` in your Gemfile.
162
+
163
+ **Automatic configuration takes the following steps for you:**
164
+
165
+ ### 1. Adds a `staging` flag
166
+
167
+ First, Ravioli adds a `staging` flag to `Rails.config`. It defaults to `true` if:
168
+
169
+ 1. `ENV["RAILS_ENV"]` is set to "production"
170
+ 2. `ENV["STAGING"]` is not blank
171
+
172
+ Using [query accessors](#querying-for-presence), you can access this value as `Rails.config.staging?`.
173
+
174
+ **BUT, as I am a generous and loving man,** Ravioli will also ensure `Rails.env.staging?` returns `true` if 1 and 2 are true above:
175
+
176
+ ```ruby
177
+ ENV["RAILS_ENV"] = "production"
178
+ Rails.env.staging? #=> false
179
+ Rails.env.production? #=> true
180
+
181
+ ENV["STAGING"] = "totes"
182
+ Rails.env.staging? #=> true
183
+ Rails.env.production? #=> true
184
+ ```
185
+
186
+ ### 2. Loads every plaintext configuration file it can find
187
+
188
+ Ravioli will traverse your `config/` directory looking for every YAML or JSON file it can find. It loads them in arbitrary order, and keys them by name. For example, with the following directory layout:
189
+
190
+ ```
191
+ config/
192
+ app.yml
193
+ cable.yml
194
+ database.yml
195
+ mailjet.json
196
+ ```
197
+
198
+ ...the automatically loaded configuration will look like
199
+
200
+ ```
201
+ # ...the contents of app.yml
202
+ cable:
203
+ # ...the contents of cable.yml
204
+ database:
205
+ # ...the contents of database.yml
206
+ mailjet:
207
+ # ...the contents of mailjet.json
208
+ ```
209
+
210
+ **NOTE THAT APP.YML GOT LOADED INTO THE ROOT OF THE CONFIGURATION!** This is because the automatic loading system assumes you want some configuration values that aren't nested. It effectively calls [`load_configuration_file(filename, key: File.basename(filename) != "app")`](#load_configuration_file), which ensures that, for example, the values in `config/mailjet.json` get loaded under `Rails.config.mailjet` while the valuaes in `config/app.yml` get loaded directly into `Rails.config`.
211
+
212
+ ### 3. Loads and combines encrypted credentials
213
+
214
+ Ravioli will then check for [encrypted credentials](https://guides.rubyonrails.org/security.html#custom-credentials). It loads credentials in the following order:
215
+
216
+ 1. First, it loads `config/credentials.yml.enc`
217
+ 2. Then, it loads and applies `config/credentials/RAILS_ENV.yml.enc` over top of what it has already loaded
218
+ 3. Finally, IF `Rails.config.staging?` IS TRUE, it loads and applies `config/credentials/staging.yml.enc`
219
+
220
+ This allows you to use your secure credentials stores without duplicating information; you can simply layer environment-specific values over top of
221
+
222
+ ### All put together, it does this:
223
+
224
+ ```ruby
225
+ def Rails.config
226
+ @config ||= Ravioli.build(strict: Rails.env.production?) do |config|
227
+ config.add_staging_flag!
228
+ config.auto_load_config_files!
229
+ config.auto_load_credentials!
230
+ end
231
+ end
232
+ ```
233
+
234
+ I documented that because, you know, you can do parts of that yourself when we get into the weeds with.........
235
+
236
+ ## Manual configuration
237
+
238
+ If any of the above doesn't suit you, by all means, Ravioli is flexible enough for you to build your own instance. There are a number of things you can change, so read through to see what you can do by going your own way.
239
+
240
+ ### Using `Ravioli.build`
241
+
242
+ The best way to build your own configuration is by calling `Ravioli.build`. It will yield an instance of a `Ravioli::Builder`, which has lots of convenient methods for loading configuration files, credentials, and the like. It works like so:
243
+
244
+ ```ruby
245
+ configuration = Ravioli.build do |config|
246
+ config.load_configuration_file("whatever.yml")
247
+ config.whatever = {things: true}
248
+ end
249
+ ```
250
+
251
+ This will yield a configured instance of `Ravioli::Configuration` with structure
252
+
253
+ ```yaml
254
+ rubocop:
255
+ # ...the contents of whatever.yml
256
+ whatever:
257
+ things: true
258
+ ```
259
+
260
+ `Ravioli.build` also does a few handy things:
261
+
262
+ - It freezes the configuration object so it is immutable,
263
+ - It caches the final configuration in `Ravioli.configurations`, and
264
+ - It sets `Ravioli.default` to the most-recently built configuration
265
+
266
+ ### Direct construction with `Ravioli::Configuration.new`
267
+
268
+ You can also directly construct a configuration object by passing a hash to `Ravioli::Configuration.new`. This is basically the same thing as an `OpenStruct` with the added [helper methods of a Ravioli object](#usage):
269
+
270
+ ```ruby
271
+ config = Ravioli::Configuration.new(whatever: true, test: {things: "stuff"})
272
+ config.dig(:test, :things) #=> "stuff
273
+ ```
274
+
275
+ ### Alternatives to using `Rails.config`
276
+
277
+ By default, Ravioli loads a default configuration in `Rails.config`. If you are already using `Rails.config` for something else, or you just hate the idea of all those letters, you can do it however else makes sense to you: in a constant (e.g. `Config` or `App`), or somewhere else entirely (you could, for example, define a `Config` module, mix it in to your classes where it's needed, and access it via a `config` instance method).
278
+
279
+ Here's an example using an `App` constant:
280
+
281
+ ```ruby
282
+ # config/initializers/_config.rb
283
+ App = Raviloli.build { |config| ... }
284
+ ```
285
+
286
+ You can also point it to `Rails.config` if you'd like to access configuration somewhere other than `Rails.config`, but you want to enjoy the benefits of [automatic configuration](#automatic-configuration):
287
+
288
+ ```ruby
289
+ # config/initializers/_config.rb
290
+ App = Rails.config
291
+ ```
292
+
293
+ You could also opt-in to configuration access with a module:
294
+
295
+ ```ruby
296
+ module Config
297
+ def config
298
+ Ravioli.default || Ravioli.build {|config| ... }
299
+ end
300
+ end
301
+ ```
302
+
303
+ ### `add_staging_flag!`
304
+
305
+
306
+ ### `load_config_file`
307
+
308
+ Let's imagine we have this config file:
309
+
310
+ `config/mailjet.yml`
311
+
312
+ ```yaml
313
+ development:
314
+ api_key: "NOT_USED"
315
+
316
+ test:
317
+ api_key: "VCR"
318
+
319
+ staging:
320
+ api_key: "12345678"
321
+
322
+ production:
323
+ api_key: "98765432"
324
+ ```
325
+
326
+ In an initializer, generate your Ravioli instance and load it up:
327
+
328
+
329
+ ```ruby
330
+ # config/initializers/_ravioli.rb`
331
+ Config = Ravioli.build do
332
+ load_config_file(:mailjet) # given a symbol, it automatically assumes you meant `config/mailjet.yml`
333
+ load_config_file("config/mailjet") # same as above
334
+ load_config_file("lib/mailjet/config") # looks for `Rails.root.join("lib", "mailjet", "config.yml")
335
+ end
336
+ ```
337
+
338
+ `config/initializers/_ravioli.rb`
339
+
340
+ ```ruby
341
+ Config = Ravioli.build do
342
+ %i[new_relic sentry google].each do |service|
343
+ load_config_file(service)
344
+ end
345
+
346
+ load_credentials # just load the base credentials file
347
+ load_credentials("credentials/production") if Rails.env.production? # add production overrides when appropriate
348
+
349
+ self.staging = File.exists?("./staging.txt") # technically you could do this ... I don't know why you would, but technically you could
350
+ end
351
+ ```
352
+
353
+ Configuration values take precedence in the order they are applied. For example, if you load two config files defining `host`, the latest one will overwrite the earlier one's value.
354
+
355
+
356
+ ### `load_credentials`
357
+
358
+ Imagine the following encrypted YAML files:
359
+
360
+ #### `config/credentials.yml.enc`
361
+
362
+ Accessing the credentials with `rails credentials:edit`, let's say you have the following encrypted file:
363
+
364
+ ```yaml
365
+ mailet:
366
+ api_key: "12345"
367
+ ```
368
+
369
+ #### `config/credentials/production.yml.enc`
370
+
371
+ Edit with `rails credentials:edit --environment production`
372
+
373
+ ```yaml
374
+ mailet:
375
+ api_key: "67891"
376
+ ```
377
+
378
+ You can then load credentials like so:
379
+
380
+ ``config/initializers/_ravioli.rb`
381
+
382
+ ```ruby
383
+ Config = Ravioli.build do
384
+ # Load the base credentials
385
+ load_credentials
386
+
387
+ # Load the env-specific credentials file. It will look for `config/credentials/#{Rails.env}.key`
388
+ # just like Rails does. But in this case, it falls back on e.g. `ENV["PRODUCTION_KEY"]` if that
389
+ # file is missing (as it should be when deployed to a remote server)
390
+ load_credentials("credentials/#{Rails.env}", env_key: "#{Rails.env}_KEY")
391
+
392
+ # Load the staging credentials. Because we did not provide an `env_key` argument, this will
393
+ # default to looking for `ENV["RAILS_STAGING_KEY"]` or `ENV["RAILS_MASTER_KEY"]`.
394
+ load_credentials("credentials/staging") if Rails.env.production? && srand.zero?
395
+ end
396
+ ```
397
+
398
+
399
+
400
+ You can manually define your configuration in an initializer if you don't want the automatic configuration assumptions to step on any toes.
401
+
402
+ For the following examples, imagine a file in `config/sentry.yml`:
403
+
404
+ ```yaml
405
+ development:
406
+ dsn: "https://dev_user:pass@sentry.io/dsn/12345"
407
+ environment: "development"
408
+
409
+ production:
410
+ dsn: "https://prod_user:pass@sentry.io/dsn/12345"
411
+ environment: "production"
412
+
413
+ staging:
414
+ environment: "staging"
415
+ ```
416
+
417
+ ## Deploying
418
+
419
+ ### Encryption keys in ENV
420
+
421
+ Because Ravioli merges environment-specific credentials over top of the root credentials file, you'll need to provide encryption keys for two (or, if you have a staging setup, three) different files in ENV vars. As such, Ravioli looks for decryption keys in a fallback-specific way. Here's where it looks for each file:
422
+
423
+ <table><thead><tr><th>File</th><th>First it tries...</th><th>Then it tries...</th></tr></thead><tbody><tr><td>
424
+
425
+ `config/credentials.yml.enc`
426
+
427
+ </td><td>
428
+
429
+ `ENV["RAILS_BASE_KEY"]`
430
+
431
+ </td><td>
432
+
433
+ `ENV["RAILS_MASTER_KEY"]`
434
+
435
+ </td></tr><tr><td>
436
+
437
+ `config/credentials/production.yml.enc`
438
+
439
+ </td><td>
440
+
441
+ `ENV["RAILS_PRODUCTION_KEY"]`
442
+
443
+ </td><td>
444
+
445
+ `ENV["RAILS_MASTER_KEY"]`
446
+
447
+ </td></tr><tr><td>
448
+
449
+ `config/credentials/staging.yml.enc` (only if running on staging)
450
+
451
+ </td><td>
452
+
453
+ `ENV["RAILS_STAGING_KEY"]`
454
+
455
+ </td><td>
456
+
457
+ `ENV["RAILS_MASTER_KEY"]`
458
+
459
+ </td></tr></tbody></table>
460
+
461
+ Credentials are loaded in that order, too, so that you can have a base setup on `config/credentials.yml.enc`, overlay that with production-specific stuff from `config/credentials/production.yml.enc`, and then short-circuit or redirect some stuff in `config/credentials/staging.yml.enc` for staging environments.
462
+
463
+ ## License
464
+
465
+ Ravioli is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "bundler/setup"
5
+ rescue LoadError
6
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
7
+ end
8
+
9
+ require "rdoc/task"
10
+
11
+ RDoc::Task.new(:rdoc) do |rdoc|
12
+ rdoc.rdoc_dir = "rdoc"
13
+ rdoc.title = "Ravioli"
14
+ rdoc.options << "--line-numbers"
15
+ rdoc.rdoc_files.include("README.md")
16
+ rdoc.rdoc_files.include("lib/**/*.rb")
17
+ end
18
+
19
+ APP_RAKEFILE = File.expand_path("spec/fixtures/dummy/Rakefile", __dir__)
20
+ load "rails/tasks/engine.rake"
21
+
22
+ load "rails/tasks/statistics.rake"
23
+
24
+ require "bundler/gem_tasks"
data/lib/ravioli.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/all"
4
+
5
+ # These are the basic building blocks of Ravioli
6
+ require_relative "ravioli/builder"
7
+ require_relative "ravioli/configuration"
8
+ require_relative "ravioli/version"
9
+
10
+ ##
11
+ # Ravioli contains helper methods for building Configuration instances and accessing them, as well
12
+ # as the Builder class for help loading configuration files and encrypted credentials
13
+ module Ravioli
14
+ NAME = "Ravioli"
15
+
16
+ class << self
17
+ def build(class_name: "Configuration", namespace: nil, strict: false, &block)
18
+ builder = Builder.new(class_name: class_name, namespace: namespace, strict: strict)
19
+ yield builder if block_given?
20
+ builder.build!.tap do |configuration|
21
+ configurations.push(configuration)
22
+ end
23
+ end
24
+
25
+ def default
26
+ configurations.last
27
+ end
28
+
29
+ def configurations
30
+ @configurations ||= []
31
+ end
32
+ end
33
+ end
34
+
35
+ require_relative "ravioli/engine" if defined?(Rails)
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/all"
4
+ require_relative "configuration"
5
+
6
+ module Ravioli
7
+ # The Builder clas provides a simple interface for building a Ravioli configuration. It has
8
+ # methods for loading configuration files and encrypted credentials, and forwards direct
9
+ # configuration on to the configuration instance. This allows us to keep a clean separation of
10
+ # concerns (builder: loads configuration details; configuration: provides access to information
11
+ # in memory).
12
+ class Builder
13
+ def initialize(class_name: "Configuration", namespace: nil, strict: false)
14
+ configuration_class = if namespace.present?
15
+ namespace.class_eval <<-EOC, __FILE__, __LINE__ + 1
16
+ class #{class_name.to_s.classify} < Ravioli::Configuration; end
17
+ EOC
18
+ namespace.const_get(class_name)
19
+ else
20
+ Ravioli::Configuration
21
+ end
22
+ @strict = !!strict
23
+ @configuration = configuration_class.new
24
+ end
25
+
26
+ # Automatically infer a `staging?` status
27
+ def add_staging_flag!(is_staging = Rails.env.production? && ENV["STAGING"].present?)
28
+ configuration.staging = is_staging
29
+ Rails.env.class_eval <<-EOC, __FILE__, __LINE__ + 1
30
+ def staging?
31
+ config = Rails.try(:config)
32
+ return false unless config&.is_a?(Ravioli::Configuration)
33
+
34
+ config.staging?
35
+ end
36
+ EOC
37
+ is_staging
38
+ end
39
+
40
+ # Load YAML or JSON files in config/**/* (except for locales)
41
+ def auto_load_config_files!
42
+ config_dir = Rails.root.join("config")
43
+ Dir[config_dir.join("{[!locales/]**/*,*}.{json,yaml,yml}")].each do |config_file|
44
+ load_config_file(config_file, key: !File.basename(config_file, File.extname(config_file)).casecmp("app").zero?)
45
+ end
46
+ end
47
+
48
+ # Load config/credentials**/*.yml.enc files (assuming we can find a key)
49
+ def auto_load_credentials!
50
+ # Load the base config
51
+ load_credentials(key_path: "config/master.key", env_name: "base")
52
+
53
+ # Load any environment-specific configuration on top of it
54
+ load_credentials("config/credentials/#{Rails.env}", key_path: "config/credentials/#{Rails.env}.key", env_name: "master")
55
+
56
+ # Apply staging configuration on top of THAT, if need be
57
+ load_credentials("config/credentials/staging", key_path: "config/credentials/staging.key") if configuration.staging?
58
+ end
59
+
60
+ # When the builder is done working, lock the configuration and return it
61
+ def build!
62
+ configuration.freeze
63
+ end
64
+
65
+ # Load a config file either with a given path or by name (e.g. `config/whatever.yml` or `:whatever`)
66
+ def load_config_file(path, options = {})
67
+ config = parse_config_file(path, options)
68
+ configuration.append(config) if config.present?
69
+ rescue => error
70
+ warn "Could not load config file #{path}", error
71
+ end
72
+
73
+ # Load secure credentials using a key either from a file or the ENV
74
+ def load_credentials(path = "credentials", key_path: path, env_name: path.split("/").last)
75
+ credentials = parse_credentials(path, env_name: env_name, key_path: key_path)
76
+ configuration.append(credentials) if credentials.present?
77
+ rescue => error
78
+ warn "Could not decrypt `#{path}.yml.enc' with key file `#{key_path}' or `ENV[\"#{env_name}\"]'", error
79
+ {}
80
+ end
81
+
82
+ private
83
+
84
+ ENV_KEYS = %w[default development production shared staging test].freeze
85
+ EXTNAMES = %w[yml yaml json].freeze
86
+
87
+ attr_reader :configuration
88
+
89
+ def extract_environmental_config(config)
90
+ # Check if the config hash is keyed by environment - if not, just return it as-is. It's
91
+ # considered "keyed by environment" if it contains ONLY env-specific keys.
92
+ return config unless (config.keys & ENV_KEYS).any? && (config.keys - ENV_KEYS).empty?
93
+
94
+ # Combine environmental config in the following order:
95
+ # 1. Shared config
96
+ # 2. Environment-specific
97
+ # 3. Staging-specific (if we're in a staging environment)
98
+ environments = ["shared", Rails.env.to_s]
99
+ environments.push("staging") if configuration.staging?
100
+ config.values_at(*environments).inject({}) { |final_config, environment_config|
101
+ final_config.deep_merge((environment_config || {}))
102
+ }
103
+ end
104
+
105
+ # rubocop:disable Style/MethodMissingSuper
106
+ # rubocop:disable Style/MissingRespondToMissing
107
+ def method_missing(*args, &block)
108
+ configuration.send(*args, &block)
109
+ end
110
+ # rubocop:enable Style/MissingRespondToMissing
111
+ # rubocop:enable Style/MethodMissingSuper
112
+
113
+ def parse_config_file(path, options = {})
114
+ path = path_to_config_file_path(path)
115
+
116
+ config = case path.extname.downcase
117
+ when ".json"
118
+ parse_json_config_file(path)
119
+ when ".yml", ".yaml"
120
+ parse_yaml_config_file(path)
121
+ else
122
+ raise ParseError.new("#{Ravioli::NAME} doesn't know how to parse #{path}")
123
+ end
124
+
125
+ # At least expect a hash to be returned from the loaded config file
126
+ return {} unless config.is_a?(Hash)
127
+
128
+ # Extract a merged config based on the Rails.env (if the file is keyed that way)
129
+ config = extract_environmental_config(config)
130
+
131
+ # Key the configuration according the passed-in options
132
+ key = options.delete(:key) { true }
133
+ return config if key == false # `key: false` means don't key the configuration at all
134
+
135
+ if key == true
136
+ # `key: true` means key it automatically based on the filename
137
+ name = File.basename(path, File.extname(path))
138
+ name = File.dirname(path).split(Pathname::SEPARATOR_PAT).last if name.casecmp("config").zero?
139
+ else
140
+ # `key: :anything_else` means use `:anything_else` as the key
141
+ name = key.to_s
142
+ end
143
+
144
+ {name => config}
145
+ end
146
+
147
+ def parse_credentials(path, key_path: path, env_name: path.split("/").last)
148
+ env_name = env_name.to_s
149
+ env_name = "RAILS_#{env_name.upcase}_KEY" unless env_name.upcase == env_name
150
+ key_path = path_to_config_file_path(key_path, extnames: "key", quiet: true)
151
+ options = {key_path: key_path}
152
+ options[:env_key] = ENV[env_name].present? ? env_name : SecureRandom.hex(6)
153
+
154
+ path = path_to_config_file_path(path, extnames: "yml.enc")
155
+ credentials = Rails.application.encrypted(path, options)&.config || {}
156
+ credentials
157
+ end
158
+
159
+ def parse_json_config_file(path)
160
+ contents = File.read(path)
161
+ JSON.parse(contents).deep_transform_keys { |key| key.to_s.underscore }
162
+ end
163
+
164
+ def parse_yaml_config_file(path)
165
+ require "erb"
166
+ contents = File.read(path)
167
+ erb = ERB.new(contents).tap { |renderer| renderer.filename = path.to_s }
168
+ YAML.safe_load(erb.result, aliases: true)
169
+ end
170
+
171
+ def path_to_config_file_path(path, extnames: EXTNAMES, quiet: false)
172
+ original_path = path.dup
173
+ unless path.is_a?(Pathname)
174
+ path = path.to_s
175
+ path = path.match?(Pathname::SEPARATOR_PAT) ? Pathname.new(path) : Pathname.new("config").join(path)
176
+ end
177
+ path = Rails.root.join(path) unless path.absolute?
178
+
179
+ # Try to guess an extname, if we weren't given one
180
+ if path.extname.blank?
181
+ Array(extnames).each do |extname|
182
+ other_path = path.sub_ext(".#{extname}")
183
+ if other_path.exist?
184
+ path = other_path
185
+ break
186
+ end
187
+ end
188
+ end
189
+
190
+ warn "Could not resolve a configuration file at #{original_path.inspect}" unless quiet || path.exist?
191
+
192
+ path
193
+ end
194
+
195
+ def warn(message, error = $!)
196
+ message = "[#{Ravioli::NAME}] #{message}"
197
+ message = "#{message}:\n\n#{error.cause.inspect}" if error&.cause.present?
198
+ if @strict
199
+ raise BuildError.new(message, error)
200
+ else
201
+ Rails.logger.warn(message) if defined? Rails
202
+ $stderr.write message # rubocop:disable Rails/Output
203
+ end
204
+ end
205
+ end
206
+
207
+ class BuildError < StandardError
208
+ def initialize(message, cause = nil)
209
+ super message
210
+ @cause = cause
211
+ end
212
+
213
+ def cause
214
+ @cause || super
215
+ end
216
+ end
217
+ class ParseError < StandardError; end
218
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/all"
4
+ require "ostruct"
5
+
6
+ module Ravioli
7
+ class Configuration < OpenStruct
8
+ attr_reader :key_path
9
+
10
+ def initialize(attributes = {})
11
+ super({})
12
+ @key_path = attributes.delete(:key_path)
13
+ append(attributes)
14
+ end
15
+
16
+ # def ==(other)
17
+ # other = other.table if other.respond_to?(:table)
18
+ # other == table
19
+ # end
20
+
21
+ def append(attributes = {})
22
+ attributes.each do |key, value|
23
+ self[key.to_sym] = cast(key.to_sym, value)
24
+ end
25
+ end
26
+
27
+ def dig(*keys, safe: false)
28
+ return safe(*keys) if safe
29
+
30
+ fetch_env_key_for(keys) do
31
+ keys.inject(self) do |value, key|
32
+ value = value.try(:[], key)
33
+ break if value.blank?
34
+ value
35
+ end
36
+ end
37
+ end
38
+
39
+ def dig!(*keys)
40
+ fetch(*keys) { raise KeyMissingError.new("Could not find value at key path #{keys.inspect}") }
41
+ end
42
+
43
+ def fetch(*keys)
44
+ dig(*keys) || yield
45
+ end
46
+
47
+ def pretty_print(printer = nil)
48
+ table.pretty_print(printer)
49
+ end
50
+
51
+ def safe(*keys)
52
+ fetch(*keys) { build(keys) }
53
+ end
54
+
55
+ private
56
+
57
+ def build(keys, attributes = {})
58
+ attributes[:key_path] = key_path_for(keys)
59
+ child = self.class.new(attributes)
60
+ child.freeze if frozen?
61
+ child
62
+ end
63
+
64
+ def cast(key, value)
65
+ if value.is_a?(Hash)
66
+ original_value = dig(*Array(key))
67
+ value = original_value.table.deep_merge(value.deep_symbolize_keys) if original_value.is_a?(self.class)
68
+ build(key, value)
69
+ else
70
+ fetch_env_key_for(key) {
71
+ if value.is_a?(Array)
72
+ value.each_with_index.map { |subvalue, index| cast(Array(key) + [index], subvalue) }
73
+ else
74
+ value
75
+ end
76
+ }
77
+ end
78
+ end
79
+
80
+ def fetch_env_key_for(keys, &block)
81
+ env_key = key_path_for(keys).join("_").upcase
82
+ ENV.fetch(env_key, &block)
83
+ end
84
+
85
+ def key_path_for(keys)
86
+ Array(key_path) + Array(keys)
87
+ end
88
+
89
+ # rubocop:disable Style/MethodMissingSuper
90
+ # rubocop:disable Style/MissingRespondToMissing
91
+ def method_missing(method, *args, &block)
92
+ # Return proper booleans from query methods
93
+ return send(method.to_s.chomp("?")).present? if args.empty? && method.to_s.ends_with?("?")
94
+ super
95
+ end
96
+ # rubocop:enable Style/MissingRespondToMissing
97
+ # rubocop:enable Style/MethodMissingSuper
98
+ end
99
+
100
+ class KeyMissingError < StandardError; end
101
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ravioli
4
+ class Engine < ::Rails::Engine
5
+ # Bootstrap Ravioli onto the Rails app
6
+ initializer "ravioli", before: "load_environment_config" do |app|
7
+ Rails.extend Ravioli::Config unless Rails.respond_to?(:config)
8
+ end
9
+ end
10
+
11
+ module Config
12
+ def config
13
+ Ravioli.default || Ravioli.build(namespace: Rails.application&.class&.module_parent, strict: Rails.env.production?) do |config|
14
+ config.add_staging_flag!
15
+ config.auto_load_config_files!
16
+ config.auto_load_credentials!
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ravioli
4
+ VERSION = "0.1.0"
5
+ end
metadata ADDED
@@ -0,0 +1,259 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ravioli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Flip Sasser
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-02-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 6.0.3.1
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '6.0'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 6.0.3.1
33
+ - !ruby/object:Gem::Dependency
34
+ name: pry
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rails
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '6'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '6'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rspec
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.9'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.9'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rspec-rails
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rubocop
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.8'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.8'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rubocop-ordered_methods
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.6'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.6'
117
+ - !ruby/object:Gem::Dependency
118
+ name: rubocop-performance
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: 1.5.2
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: 1.5.2
131
+ - !ruby/object:Gem::Dependency
132
+ name: rubocop-rails
133
+ requirement: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: 2.5.2
138
+ type: :development
139
+ prerelease: false
140
+ version_requirements: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: 2.5.2
145
+ - !ruby/object:Gem::Dependency
146
+ name: rubocop-rspec
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '1.39'
152
+ type: :development
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '1.39'
159
+ - !ruby/object:Gem::Dependency
160
+ name: simplecov
161
+ requirement: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: '0'
166
+ type: :development
167
+ prerelease: false
168
+ version_requirements: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - ">="
171
+ - !ruby/object:Gem::Version
172
+ version: '0'
173
+ - !ruby/object:Gem::Dependency
174
+ name: sqlite3
175
+ requirement: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: '0'
180
+ type: :development
181
+ prerelease: false
182
+ version_requirements: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - ">="
185
+ - !ruby/object:Gem::Version
186
+ version: '0'
187
+ - !ruby/object:Gem::Dependency
188
+ name: standard
189
+ requirement: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '0.4'
194
+ type: :development
195
+ prerelease: false
196
+ version_requirements: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - "~>"
199
+ - !ruby/object:Gem::Version
200
+ version: '0.4'
201
+ - !ruby/object:Gem::Dependency
202
+ name: yard
203
+ requirement: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - "~>"
206
+ - !ruby/object:Gem::Version
207
+ version: '0.9'
208
+ type: :development
209
+ prerelease: false
210
+ version_requirements: !ruby/object:Gem::Requirement
211
+ requirements:
212
+ - - "~>"
213
+ - !ruby/object:Gem::Version
214
+ version: '0.9'
215
+ description: Ravioli combines all of your app's runtime configuration into a unified,
216
+ simple interface. It automatically loads and combines YAML config files, encrypted
217
+ Rails credentials, and ENV vars so you can focus on writing code and not on where
218
+ configuration comes from
219
+ email:
220
+ - hello@flipsasser.com
221
+ executables: []
222
+ extensions: []
223
+ extra_rdoc_files: []
224
+ files:
225
+ - MIT-LICENSE
226
+ - README.md
227
+ - Rakefile
228
+ - lib/ravioli.rb
229
+ - lib/ravioli/builder.rb
230
+ - lib/ravioli/configuration.rb
231
+ - lib/ravioli/engine.rb
232
+ - lib/ravioli/version.rb
233
+ homepage: https://github.com/flipsasser/ravioli
234
+ licenses:
235
+ - MIT
236
+ metadata:
237
+ homepage_uri: https://github.com/flipsasser/ravioli
238
+ source_code_uri: https://github.com/flipsasser/ravioli
239
+ post_install_message:
240
+ rdoc_options: []
241
+ require_paths:
242
+ - lib
243
+ required_ruby_version: !ruby/object:Gem::Requirement
244
+ requirements:
245
+ - - ">="
246
+ - !ruby/object:Gem::Version
247
+ version: 2.3.0
248
+ required_rubygems_version: !ruby/object:Gem::Requirement
249
+ requirements:
250
+ - - ">="
251
+ - !ruby/object:Gem::Version
252
+ version: '0'
253
+ requirements: []
254
+ rubygems_version: 3.0.3
255
+ signing_key:
256
+ specification_version: 4
257
+ summary: Grab a fork and twist all your configuration spaghetti into a single, delicious
258
+ bundle
259
+ test_files: []