Environment configuration in Vapor
When I started this post, I was planning on talking about Vapor integration testing. But in the thick of that, I found myself on a substantial rabbit trail talking about environment configuration. That’s because I wanted to change configuration values based on if I was in the testing environment or not. As it turns out, there’s not an established mechanism in Vapor for doing that that I like. So I built one that I like slightly better.
To clarify, what I mean by environment configuration is static global settings like: database URL, email server configuration, api keys, etc. That is, things that will change based on environment (e.g. production, staging, testing).
I’d like to have a few things for my environment configuration. First, I’d like all the configuration to be in one file, so it’s easy to see in one place. In my experience, most web frameworks provide this. They have config files that are specific to production, testing, and development. Second, I’d like the configuration to be statically checked at compile time, so I have confidence that all the values have been provided. I want to avoid the burden of having to run it and see it crashes at start up because something was not registered as a service. Finally, I’d like my global configure()
method to not have conditionals in it. i.e. no if
s checking to see if the environment is testing and then doing something different than production; or changing the configuration code flow if the environment is release.
My plan for building this out is to first describe what I want the final environment configuration to look like in Swift. Then, I’ll figure out how to create and load that configuration based on the Vapor Environment. Finally, I modify the global configure()
method to make use of the new configuration data type.
Start with where I want to end
I’ll first start by defining the testing configuration I’d like to use for running integration tests:
import Foundation
import Vapor
import FluentPostgreSQL
struct TestingConfiguration: ConfigurationType {
let apiKey = "fake-api-key"
let databaseConfig = PostgreSQLDatabaseConfig(
hostname: "localhost",
port: 5433,
username: "myapp",
database: "myapp-test",
password: "password")
let mailProvider = TestMailProvider()
func configure(_ services: inout Services) throws {
try services.register(mailProvider)
}
}
This is as declarative as I can get it currently. The api key is fake since this is for tests, and the database is pointing to a test database on the localhost. I also want to point out I’m using native Swift types in my configuration like PostgreSQLDatabaseConfig
. Finally, a fake test provider for the mailer is created and registered as a service. This will allow me to test sending email without actually sending mail to a real 3rd party service.
Ideally, I wouldn’t need the configure(_:)
method here, and could do everything completely declaratively. But given the production mail provider that I’m using, I have to swap the provider out entirely. There’s not a configuration that can be given to the production provider that allows testing.
Another approach would be to register both testing and production mail providers and then use Vapor’s Config.prefer()
to choose the correct one at runtime. But that would still require some sort of configuration method on the testing and production configurations to call Config.prefer()
on the correct type. And, to me, that’s less clear than simply registering the provider that I want.
Now, here’s my production configuration:
import Foundation
import Vapor
import FluentPostgreSQL
struct ProductionConfiguration: ConfigurationType {
let apiKey: String
let databaseConfig: PostgreSQLDatabaseConfig
init() throws {
self.apiKey = try Environment.require("SECRET_API_KEY")
guard let config = try PostgreSQLDatabaseConfig(url: Environment.require("DATABASE_URL")) else {
throw Abort(.internalServerError, reason: "DATABASE_URL not configured correctly")
}
self.databaseConfig = config
}
func configure(_ services: inout Services) throws {
try services.register(MailgunProvider(mailgunConfig: mailgun))
}
}
The production configuration is a bit more complicated and less declarative because it needs to pull values out of the environment. It does use a small helper method I added to Environment
to make fetching a required variable a little less verbose.
import Foundation
import Vapor
extension Environment {
static func require(_ key: String) throws -> String {
guard let value = get(key) else {
throw Abort(.internalServerError, reason: "Missing value for \(key)")
}
return value
}
}
Environment.require()
works the same as Environment.get()
, except it returns a non-optional, and throws an internal error if the value is missing.
I also have a DevelopmentConfiguration
that I used for running the service locally. However, it’s not as interesting as the testing or production configurations, so I’m not showing it’s implementation here.
The final bit I need for my configurations is a protocol that they’re required to conform to. This is what allows the compiler to statically check that all the values are provided.
public protocol ConfigurationType: Service {
var apiKey: String { get }
var databaseConfig: PostgreSQLDatabaseConfig { get }
func configure(_ services: inout Services) throws
}
The protocol simply declares the two config values that are used, and the configure()
method. I’ll also note that it derives from the Service
protocol so that I can register it with the dependency injection framework.
How to load the correct configuration based on the environment
At this point, I have three environment configurations (production, testing, and development) but I need a way of loading the correct one at runtime. I know that I want to pass the loaded environment configuration to the global configure()
method for it to be used. So, for that reason, I chose to load it in the app(_:)
method in app.swift
.
import Vapor
/// Creates an instance of Application. This is called from main.swift in the run target.
public func app(_ env: Environment) throws -> Application {
var config = Config.default()
var env = env
var services = Services.default()
let configuration = try registerConfiguration(for: env, services: &services)
try configure(&config, &env, &services, configuration)
let app = try Application(config: config, environment: env, services: services)
try boot(app)
return app
}
All I did here was call a new method called registerConfiguration()
and pass the result of that into the global configure()
method. Here’s the definition of that:
private func registerConfiguration(for env: Environment, services: inout Services) throws -> ConfigurationType {
if env.isRelease {
return try register(ProductionConfiguration(env: env), services: &services)
} else if env.isTesting {
return register(TestingConfiguration(), services: &services)
} else {
return try register(DevelopmentConfiguration(env: env), services: &services)
}
}
private func register<T: ConfigurationType>(_ configuration: T, services: inout Services) -> ConfigurationType {
services.register(configuration, as: ConfigurationType.self)
return configuration
}
This is where I choose which configuration to load and return. It’s not a clean mechanism, in that it asks a series of somewhat ambiguous questions to determine which configuration it should load. First, if the environment claims that it is a release environment, I choose the production configuration. If it’s not a release environment, I ask if it is a testing environment (kind of, see below). If it is neither production or testing, then I assume it should use a development configuration.
There are some tradeoffs to this approach. One benefit is that there will always be a valid environment configuration loaded. One downside is that this only supports three configurations. If I wanted to add a staging configuration, this code would have to change, in addition to creating a StagingConfiguration
type. There’s also the matter of determining what kind of environment I have. isRelease
is a Bool
property on Environment
, and multiple environments could have set that to true
. That might be ok, but it does make the determination more ambiguous.
On the other hand the isTesting
property is something I made up entirely:
import Foundation
import Vapor
extension Environment {
var isTesting: Bool {
return name == Environment.testing.name
}
}
isTesting
checks to see if the name is the same name as the static testing
instance on Environment
. So unlike production, the testing configuration will only be loaded for one specific environment, named a specific name. I did it this way because Environment
only provides a name
property and a isRelease
flag. Other than the name, I didn’t have any way to differentiate between non-release environments.
An alternative way to do the loading would be by the name only. I’ve seen this with web frameworks for dynamic languages. If they have an environment named “spackle”, the framework will try to load a configuration file named something like “spackle.config” from a known location. This approach loses the static compile time checking (assuming the language had any), but it’s straight forward and flexible. In Swift, I could do the same, except use JSON as the config file format. That would mean I couldn’t use non-JSON (or non-Decodable
) types in my configuration (like PostgreSQLDatabaseConfig
), and dynamically selecting which providers to register would be trickier. A compromise for Swift could be to register the ConfigurationType
s in a global table by name. This would allow rich Swift datatypes to be used and static type checking to happen at compile time. However, the downside to all these approaches is if an environment name is given that doesn’t exactly match any of the configurations, the app will crash. That could be an acceptable tradeoff though, since one could argue that’s a progammer error.
What configure() looks like now
One of the goals I started out with was to remove all the conditionals in the global configure()
method. Setting up all the necessary configuration is already a complicated enough task, and adding conditionals increases the complexity. The conditionals implicitly encode the differences in environment configuration, which makes discerning the differences difficult. It also is difficult to see when the differences were intentional vs. accidental. Since the configuration is implicit and spread out across a large file, it becomes trivial to make a mistake when updating an environment configuration.
Here’s a redacted version of my resulting global configure()
method:
import FluentPostgreSQL
import Vapor
import Authentication
import Leaf
/// Called before your application initializes.
public func configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services, _ configuration: ConfigurationType) throws {
/// Register providers first
try configuration.configure(&services)
...
services.register(StaticApiKeyConfig(apiConfig: configuration), as: ApiKeyConfig.self)
...
// Configure database
var databases = DatabasesConfig()
let databaseConfig = configuration.databaseConfig
let database = PostgreSQLDatabase(config: databaseConfig)
databases.add(database: database, as: .psql)
services.register(databases)
...
}
First off, I let the environment configuration register any Service
s that it needs to. For the test of the method, it’s simply a matter of the configuration code pulling configuration values off the ConfigurationType
. There are no conditionals, only straight line code.
It would be nice if Vapor provided some infrastructure for this out of the box. However, until then, this is how I create and load the environment configuration in Vapor. I first create the needed configurations as a native Swift types. Then I load them in the global app()
method, before passing them to the global configure()
. The end result is a statically checked configuration, all in one file, and a global configure()
method with no conditionals.