ravioli 0.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 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: []