Skip to content

Commit

Permalink
Implement Pragma::Operation hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
aldesantis committed Jul 17, 2018
1 parent 1257eb1 commit a8dcb30
Show file tree
Hide file tree
Showing 14 changed files with 304 additions and 60 deletions.
3 changes: 3 additions & 0 deletions .rubocop.yml
Expand Up @@ -26,6 +26,9 @@ Style/BlockDelimiters:
Exclude:
- 'spec/**/*'

Style/Documentation:
Enabled: false

Layout/AlignParameters:
EnforcedStyle: with_fixed_indentation

Expand Down
66 changes: 37 additions & 29 deletions README.md
Expand Up @@ -24,7 +24,7 @@ Or install it yourself as:

$ gem install pragma-migration

Next, you're going to create a migration repository for our API:
Next, you're going to create a migration repository for your API:

```ruby
module API
Expand All @@ -38,20 +38,26 @@ module API
end
```

Finally, you need to mount the migration Rack middleware. In a Rails environment, this means adding
And configure the gem:

```ruby
# config/initializers/pragma_migration.rb or equivalent in your framework
Pragma::Migration.repository = API::V1::MigrationRepository
Pragma::Migration.user_version_proc = lambda do |request|
# `request` here is a `Rack::Request` object.
request.get_header 'X-Api-Version'
end
```

Finally, you need to mount the migration Rack middleware. In a Rails environment, this means adding
the following to `config/application.rb`:

```ruby
module YourApp
class Application < Rails::Application
# ...

config.middleware.use Pragma::Migration::Middleware,
repository: API::V1::MigrationRepository,
user_version_proc: (lambda do |request|
# `request` here is a `Rack::Request` object.
request.get_header 'X-Api-Version'
end)
config.middleware.use Pragma::Migration::Middleware
end
end
```
Expand All @@ -65,9 +71,9 @@ module API
module V1
class MigrationRepository < Pragma::Migration::Repository
version '2017-12-17'

# We will give this a date very far into the future for now, since we don't know the release
# date yet.
# date yet.
version '2100-01-01', [
# Add migrations here...
]
Expand All @@ -76,7 +82,7 @@ module API
end
```

Suppose you are working on a new API version and you decide to remove the `_id` suffix from
Suppose you are working on a new API version and you decide to remove the `_id` suffix from
association properties. In order to support users who are on an older version of the API, you will
need to do the following:

Expand Down Expand Up @@ -139,18 +145,18 @@ As you can see, the migration allows API requests generated by outdated clients
version. You don't have to implement ugly conditionals everywhere in your API: all the changes are
neatly contained in the API migrations.

There is no limit to how many migrations or versions you can have. There's also no limit on how old
your clients can be: even if they are 10 versions behind, the migrations for all versions will be
applied in order, so that the clients are able to interact with the very latest version without even
There is no limit to how many migrations or versions you can have. There's also no limit on how old
your clients can be: even if they are 10 versions behind, the migrations for all versions will be
applied in order, so that the clients are able to interact with the very latest version without even
knowing it!

### Using migrations to contain side effects

In some cases, migrations are more complex than a simple update of the request and response.
In some cases, migrations are more complex than a simple update of the request and response.

Let's take this example scenario: you are building a blog API and you are working on a new version
that automatically sends an email to subscribers when a new article is sent, whereas the current
version requires a separate API call to accomplish this. Since you don't want to surprise existing
Let's take this example scenario: you are building a blog API and you are working on a new version
that automatically sends an email to subscribers when a new article is sent, whereas the current
version requires a separate API call to accomplish this. Since you don't want to surprise existing
users with the new behavior, you only want to do this when the new API version is being used.

You can use a no-op migration like the following for this:
Expand All @@ -171,6 +177,8 @@ Then, in your operation, you will only execute the new code if the migration has
the user's version is greater than the migration's version):

```ruby
require 'pragma/migration/hooks/operation'

module API
module V1
module Article
Expand All @@ -192,15 +200,15 @@ end

### Implementing complex version tracking

It is possible to implement more complex tracking strategies for determining your user's API
It is possible to implement more complex tracking strategies for determining your user's API
version. For instance, you might want to store the API version on the user profile instead:

```ruby
module YourApp
class Application < Rails::Application
# ...

config.middleware.use Pragma::Migration::Middleware,
config.middleware.use Pragma::Migration::Middleware,
repository: API::V1::MigrationRepository,
user_version_proc: (lambda do |request|
current_user = UserFinder.(request)
Expand All @@ -210,7 +218,7 @@ module YourApp
end
```

The possibilities here are endless. Stripe adopts a hybrid strategy: they freeze a user's API
The possibilities here are endless. Stripe adopts a hybrid strategy: they freeze a user's API
version when the user performs the first request. They allow the user to upgrade to newer versions
either permanently (you are not allowed to go back after a grace period) or on a per-request basis,
which is useful when doing partial upgrades.
Expand All @@ -222,7 +230,7 @@ module YourApp
class Application < Rails::Application
# ...

config.middleware.use Pragma::Migration::Middleware,
config.middleware.use Pragma::Migration::Middleware,
repository: API::V1::MigrationRepository,
user_version_proc: (lambda do |request|
request.get_header('X-Api-Version') || UserFinder.(request)&.api_version
Expand All @@ -235,10 +243,10 @@ end

### Why are the migrations so low-level?

Admittedly, the code for migrations is very low-level: you are interacting with requests and
responses directly, rather than using contracts and decorators. Unfortunately, so far we have been
unable to come up with an abstraction that will not blow up at the first edge case. We are still
experimenting here - ideas are welcome!
Admittedly, the code for migrations is very low-level: you are interacting with requests and
responses directly, rather than using contracts and decorators. Unfortunately, so far we have been
unable to come up with an abstraction that will not blow up at the first edge case. We are still
experimenting here - ideas are welcome!

### What are the drawbacks of API migrations?

Expand All @@ -252,13 +260,13 @@ evolving part of your codebase that you will have to maintain over time.

The main reason for keeping the `/v1` prefix and the `API::V1` namespace in your API is that you
might want to introduce a change so disruptive that it warrants a separate major version, like
migrating from REST to GraphQL or introducing one alongside the other. In this case, you won't be
able to use migrations to contain the change, so you will need to create a completely separate
migrating from REST to GraphQL or introducing one alongside the other. In this case, you won't be
able to use migrations to contain the change, so you will need to create a completely separate
codebase and URL scheme.

### What is the impact on performance?

We have a simple benchmark that runs 2,000 migrations in both directions. You can check out
We have a simple benchmark that runs 2,000 migrations in both directions. You can check out
`benchmark.rb` for the details. Improvements are welcome!

Here are the results on my machine, a MacBook Pro 2017 i7 @ 3.1 GHz:
Expand Down
2 changes: 1 addition & 1 deletion benchmark.rb
Expand Up @@ -70,7 +70,7 @@ class Repository < Pragma::Migration::Repository
runner = Pragma::Migration::Runner.new(Pragma::Migration::Bond.new(
repository: Repository,
request: request,
user_version: '2017-12-26'
user_version_proc: proc { '2017-12-26' }
))

runner.run_upwards
Expand Down
20 changes: 20 additions & 0 deletions lib/pragma/migration.rb
Expand Up @@ -9,10 +9,30 @@
require 'pragma/migration/runner'
require 'pragma/migration/bond'
require 'pragma/migration/middleware'
require 'pragma/migration/hooks/operation'
require 'pragma/migration/gem_version'

module Pragma
# Provides API payload migrations to support clients on older versions of your API.
module Migration
# The default +user_version_proc+.
DEFAULT_USER_VERSION_PROC = lambda do |request|
request.get_header('X-Api-Version')
end

class << self
# @!attribute [rw] repository
# @return [Pragma::Migration::Repository] your migrations repository
#
# @!attribute [rw] user_version_proc
# @return [Object] a callable taking a +Rack::Request+ as argument and returning an API
# version identifier
attr_accessor :repository
attr_writer :user_version_proc

def user_version_proc
@user_version_proc ||= DEFAULT_USER_VERSION_PROC
end
end
end
end
27 changes: 20 additions & 7 deletions lib/pragma/migration/bond.rb
Expand Up @@ -14,20 +14,18 @@ class Bond
#
# @!attribute [r] request
# @return [Rack::Request] the request this bond will work with
#
# @!attribute [r] user_version
# @return [String] the user's API version, usually determined with +user_version_proc+
attr_reader :repository, :request, :user_version
attr_reader :repository, :request, :user_version_proc

# Initializes the bond.
#
# @param repository [Repository] the repository to use
# @param request [Rack::Request] the request to work with
# @param user_version [String] the user's API version
def initialize(repository:, request:, user_version:)
# @param user_version_proc [Proc] a Proc that takes a Rake request and returns the request's
# API version
def initialize(repository:, request:, user_version_proc:)
@repository = repository
@request = request
@user_version = user_version
@user_version_proc = user_version_proc
end

# Returns the migrations that must be applied on the user's API version.
Expand Down Expand Up @@ -106,6 +104,21 @@ def migration_applies?(migration)
applying_migrations.include?(migration)
end

# Returns the user's API version for this request.
#
# @return [String] the API version identifier
def user_version
return @user_version if @user_version

version = user_version_proc.call(request)

@user_version ||= if version && repository.sorted_versions.include?(version)
version
else
repository.sorted_versions.last.number
end
end

private

def allocate_migrations
Expand Down
91 changes: 91 additions & 0 deletions lib/pragma/migration/hooks/operation.rb
@@ -0,0 +1,91 @@
# frozen_string_literal: true

module Pragma
module Migration
module Hooks
# Provides hooks for Pragma::Operation to use and query migrations.
module Operation
# Returns the migrations that have already been rolled/applied on the user's API version.
#
# @param options [Trailblazer::Context] the +options+ hash passed to the operation's steps
#
# @return [Array<Pragma::Migration::Base>]
#
# @see Pragma::Migration::Bond#rolled_migrations
def rolled_migrations(options)
build_migration_bond(options).rolled_migrations
end

# Returns the migrations that must be applied to the request.
#
# @param options [Trailblazer::Context] the +options+ hash passed to the operation's steps
#
# @return [Array<Pragma::Migration::Base>]
#
# @see Pragma::Migration::Bond#applying_migrations
def applying_migrations(options)
build_migration_bond(options).applying_migrations
end

# Returns the migrations that must be applied on the user's API version.
#
# @param options [Trailblazer::Context] the +options+ hash passed to the operation's steps
#
# @return [Array<Pragma::Migration::Base>]
#
# @see Pragma::Migration::Bond#pending_migrations
def pending_migrations(options)
build_migration_bond(options).pending_migrations
end

# Returns whether a migration has been rolled.
#
# @param options [Trailblazer::Context] the +options+ hash passed to the operation's steps
# @param migration [Base] the migration to check
#
# @return [Boolean] whether the migration is pending
#
# @see Pragma::Migration::Bond#migration_rolled?
def migration_rolled?(options, migration)
build_migration_bond(options).migration_rolled?(migration)
end

# Returns whether a migration applies to the request.
#
# @param options [Trailblazer::Context] the +options+ hash passed to the operation's steps
# @param migration [Base] the migration to check
#
# @return [Boolean] whether the migration is pending
#
# @see Pragma::Migration::Bond#migration_applies?
def migration_applies?(options, migration)
build_migration_bond(options).migration_applies?(migration)
end

# Returns whether a migration is pending.
#
# @param options [Trailblazer::Context] the +options+ hash passed to the operation's steps
# @param migration [Base] the migration to check
#
# @return [Boolean] whether the migration is pending
#
# @see Pragma::Migration::Bond#migration_pending?
def migration_pending?(options, migration)
build_migration_bond(options).migration_pending?(migration)
end

private

def build_migration_bond(options)
Bond.new(
repository: Pragma::Migration.repository,
request: options['rack.request'],
user_version_proc: Pragma::Migration.user_version_proc,
)
end

::Pragma::Operation::Base.prepend(self) if defined?(::Pragma::Operation::Base)
end
end
end
end

0 comments on commit a8dcb30

Please sign in to comment.