Ravioli.rb 🍝

Grab a fork and twist your configuration spaghetti in a single, delicious dumpling!

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.

Ravioli turns this...

key = ENV.fetch("THING_API_KEY") { Rails.credentials.thing&["api_key"] || raise("I need an API key for thing to work") }

...into this:

key = Rails.config.dig!(:thing, :api_key)

🚨 FYI: Ravioli is two libraries: a Ruby gem (this doc), and a JavaScript NPM package. The NPM docs contain specifics about how to use Ravioli in the Rails asset pipeline, in a Node web server, or bundled into a client using Webpack, Rollup, or whatever else.

Table of Contents

  1. Installation
  2. Usage
  3. Automatic Configuration
  4. Manual Configuration
  5. Deploying
  6. License <!-- 5. JavaScript library -->

Installation

  1. Add gem "ravioli" to your Gemfile
  2. Run bundle install
  3. Add an initializer (totally optional): rails generate ravioli:install - Ravioli will do everything automatically for you if you skip this step, because I'm here to put a little meat on your bones.

Usage

Ravioli turns your app's configuration environment into a PORO 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.

Either way, for the following examples, imagine we had the following configuration structure:*

host: "example.com"
url: "https://www.example.com"
sender: "[email protected]"

database:
  host: "localhost"
  port: "5432"

sendgrid:
  api_key: "12345"

sentry:
  api_key: "12345"
  environment: <%= Rails.env %>
  dsn: "https://sentry.io/whatever?api_key=12345"

*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.

Got it? Good. Let's access some configuration,

Accessing values directly

Ravioli objects support direct accessors:

Rails.config.host #=> "example.com"
Rails.config.database.port #=> "5432"
Rails.config.not.here #=> NoMethodError (undefined method `here' for nil:NilClass)

Accessing configuration values safely by key path

Traversing the keypath with dig

You can traverse deeply nested config values safely with dig:

Rails.config.dig(:database, :port) #=> "5432"
Rails.config.dig(:not, :here) #=> nil

This works the same in principle as the dig method on Hash objects, with the added benefit of not caring about key type (both symbols and strings are accepted).

Providing fallback values with fetch

You can provide a sane fallback value using fetch, which works like dig but accepts a block:

Rails.config.fetch(:database, :port) { "5678" } #=> "5432" is returned from the config
Rails.config.fetch(:not, :here) { "PRESENT!" } #=> "PRESENT!" is returned from the block

Note that fetch differs from the 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.

Requiring configuration values with dig!

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:

uri = URI("https://api.example.com/things/1")
request = Net::HTTP::Get.new(uri)
request["X-Example-API-Key"] = Rails.config.dig!(:example, :api_key) #=> Ravioli::KeyMissingError (could not find configuration value at key path [:example, :api_key])

Allowing for blank values with safe (or dig(*keys, safe: true))

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:

Rails.config.dig(:google) #=> nil
Rails.config.safe(:google) #=> #<Ravioli::Configuration {}>
Rails.config.dig(:google, safe: true) #=> #<Ravioli::Configuration {}>

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:

class GoogleMapsClient
  include HTTParty

  config = Rails.config.safe(:google)
  headers "Auth-Token" => config.token, "Other-Header" => config.other_thing
  base_uri config.fetch(:base_uri) { "https://api.google.com/maps-do-stuff-cool-right" }
end

Querying for presence

In addition to direct accessors, you can append a ? to a method to see if a value exists. For example:

Rails.config.database.host? #=> true
Rails.config.database.password? #=> false

ENV variables take precedence over loaded configuration

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.

For example:

Rails.config.dig(:database, :url)

# ...is equivalent to...

ENV.fetch("DATABASE_URL") { Rails.config.database&.url }

This means that you can use Ravioli instead of querying ENV for its keys, and it'll get you the right value every time.

Automatic Configuration

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.

Automatic configuration takes the following steps for you:

1. Adds a staging flag

First, Ravioli adds a staging flag to Rails.config. It defaults to true if:

  1. ENV["RAILS_ENV"] is set to "production"
  2. ENV["STAGING"] is not blank

Using query accessors, you can access this value as Rails.config.staging?.

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:

ENV["RAILS_ENV"] = "production"
Rails.env.staging? #=> false
Rails.env.production? #=> true

ENV["STAGING"] = "totes"
Rails.env.staging? #=> true
Rails.env.production? #=> true

2. Loads every plaintext configuration file it can find

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:

config/
  app.yml
  cable.yml
  database.yml
  mailjet.json

...the automatically loaded configuration will look like

# ...the contents of app.yml
cable:
  # ...the contents of cable.yml
database:
  # ...the contents of database.yml
mailjet:
  # ...the contents of mailjet.json

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_file(filename, key: File.basename(filename) != "app"), 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.

3. Loads and combines encrypted credentials

Ravioli will then check for encrypted credentials. It loads credentials in the following order:

  1. First, it loads config/credentials.yml.enc
  2. Then, it loads and applies config/credentials/RAILS_ENV.yml.enc over top of what it has already loaded
  3. Finally, IF Rails.config.staging? IS TRUE, it loads and applies config/credentials/staging.yml.enc

This allows you to use your secure credentials stores without duplicating information; you can simply layer environment-specific values over top of a "root" config/credentials.yml.enc file.

All put together, it does this:

def Rails.config
  @config ||= Ravioli.build(strict: Rails.env.production?) do |config|
    config.add_staging_flag!
    config.auto_load_files!
    config.auto_load_credentials!
  end
end

I documented that because, you know, you can do parts of that yourself when we get into the weeds with.........

Manual configuration

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.

Using Ravioli.build

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:

configuration = Ravioli.build do |config|
  config.load_file("things.yml")
  config.whatever = {things: true}
end

This will return a configured instance of Ravioli::Configuration with structure

things:
  # ...the contents of things.yml
whatever:
  things: true

Ravioli.build also does a few handy things:

  • It freezes the configuration object so it is immutable,
  • It caches the final configuration in Ravioli.configurations, and
  • It sets Ravioli.default to the most-recently built configuration

Direct construction with Ravioli::Configuration.new

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:

config = Ravioli::Configuration.new(whatever: true, test: {things: "stuff"})
config.dig(:test, :things) #=> "stuff

Alternatives to using Rails.config

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).

Here's an example using an App constant:

# config/initializers/_config.rb
App = Raviloli.build { |config| ... }

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:

# config/initializers/_config.rb
App = Rails.config

You could also opt-in to configuration access with a module:

module Config
  def config
    Ravioli.default || Ravioli.build {|config| ... }
  end
end

add_staging_flag!

load_file

Let's imagine we have this config file:

config/mailjet.yml

development:
  api_key: "NOT_USED"

test:
  api_key: "VCR"

staging:
  api_key: "12345678"

production:
  api_key: "98765432"

In an initializer, generate your Ravioli instance and load it up:

# config/initializers/_ravioli.rb`
Config = Ravioli.build do
  load_file(:mailjet) # given a symbol, it automatically assumes you meant `config/mailjet.yml`
  load_file("config/mailjet") # same as above
  load_file("lib/mailjet/config") # looks for `Rails.root.join("lib", "mailjet", "config.yml")
end

config/initializers/_ravioli.rb

Config = Ravioli.build do |config|
  %i[new_relic sentry google].each do |service|
    config.load_file(service)
  end

  config.load_credentials # just load the base credentials file
  config.load_credentials("credentials/production") if Rails.env.production? # add production overrides when appropriate

  config.staging = File.exists?("./staging.txt") # technically you could do this ... I don't know why you would, but technically you could
end

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.

load_credentials

Imagine the following encrypted YAML files:

config/credentials.yml.enc

Accessing the credentials with rails credentials:edit, let's say you have the following encrypted file:

mailet:
  api_key: "12345"

config/credentials/production.yml.enc

Edit with rails credentials:edit --environment production

mailet:
  api_key: "67891"

You can then load credentials like so:

`config/initializers/_ravioli.rb

Config = Ravioli.build do
  # Load the base credentials
  load_credentials

  # Load the env-specific credentials file. It will look for `config/credentials/#{Rails.env}.key`
  # just like Rails does. But in this case, it falls back on e.g. `ENV["PRODUCTION_KEY"]` if that
  # file is missing (as it should be when deployed to a remote server)
  load_credentials("credentials/#{Rails.env}", env_key: "#{Rails.env}_KEY")

  # Load the staging credentials. Because we did not provide an `env_key` argument, this will
  # default to looking for `ENV["RAILS_STAGING_KEY"]` or `ENV["RAILS_MASTER_KEY"]`.
  load_credentials("credentials/staging") if Rails.env.production? && srand.zero?
end

You can manually define your configuration in an initializer if you don't want the automatic configuration assumptions to step on any toes.

For the following examples, imagine a file in config/sentry.yml:

development:
  dsn: "https://dev_user:[email protected]/dsn/12345"
  environment: "development"

production:
  dsn: "https://prod_user:[email protected]/dsn/12345"
  environment: "production"

staging:
  environment: "staging"

Deploying

Encryption keys in ENV

Here are a few facts about credentials in Rails and how they're deployed:

  1. Rails assumes you want to use the file that matches your environment, if it exists (e.g. RAILS_ENV=production will look for config/credentials/production.yml.enc)
  2. Rails does not support environment-specfic keys, but it does now aggressively loads credentials at boot time.

This means RAILS_MASTER_KEY MUST be the decryption key for your environment-specific credential file, if one exists.

But, 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 way that mirrors Rails' assumptions, but allows progressive layering of credentials.

Here are a few examples

FileFirst it tries...Then it tries...
config/credentials.yml.enc ENV["RAILS_MASTER_KEY"] ENV["RAILS_ROOT_KEY"]
config/credentials/#{RAILS_ENV}.yml.enc ENV["RAILS_MASTER_KEY"] ENV["RAILS_#{RAILS_ENV}_KEY"]
config/credentials/staging.yml.enc ENV["RAILS_MASTER_KEY"] ENV["RAILS_STAGING_KEY"]

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.

TLDR:

  1. Set RAILS_MASTER_KEY to the key for your specific environment
  2. Set RAILS_STAGING_KEY to the key for your staging credentials (if deploying to staging AND you have staging-specific credentials)
  3. Set RAILS_ROOT_KEY to the key for your root credentials (if you have anything in config/credentials.yml.enc)

License

Ravioli is available as open source under the terms of the MIT License.