request_migrations 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CONTRIBUTING.md +33 -0
- data/LICENSE +20 -0
- data/README.md +540 -0
- data/SECURITY.md +7 -0
- data/lib/request_migrations/configuration.rb +13 -0
- data/lib/request_migrations/controller.rb +67 -0
- data/lib/request_migrations/gem.rb +5 -0
- data/lib/request_migrations/migration.rb +79 -0
- data/lib/request_migrations/migrator.rb +49 -0
- data/lib/request_migrations/railtie.rb +5 -0
- data/lib/request_migrations/router.rb +29 -0
- data/lib/request_migrations/version.rb +65 -0
- data/lib/request_migrations.rb +33 -0
- metadata +101 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a07f06f1ea7b2b51851ff1490e1233363bc4d13bc6ac8918e7c1d886c9497ffd
|
4
|
+
data.tar.gz: 79f9bb8c317e906b4d4d6992811156103265d902f82ca0fcc1ac65a6e85327de
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 595d53c2b5d25dd34d4212e9db9208b96dccf684ec03e4cd75fa485d5421c400cc0b465e4914d7613a3ef15c08757b93ce163cebf1cb886dccf14e7986ca16bb
|
7
|
+
data.tar.gz: 95cd329eee002333f976886c2394a538dc1f3e6231f093a4d7155b9330eaf0725dc9ed38e31c6ccc0d9e7fdeb6a32b7f54970dc6114064b6286b96888b0be4d2
|
data/CONTRIBUTING.md
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
## Security issues
|
2
|
+
|
3
|
+
If you have found a security related issue, please do not file an issue on
|
4
|
+
GitHub or send a PR addressing the issue. Contact [Keygen](mailto:security@keygen.sh)
|
5
|
+
directly. You will be given public credit for your disclosure.
|
6
|
+
|
7
|
+
## Reporting issues
|
8
|
+
|
9
|
+
Please try to answer the following questions in your bug report:
|
10
|
+
|
11
|
+
- What did you do?
|
12
|
+
- What did you expect to happen?
|
13
|
+
- What happened instead?
|
14
|
+
|
15
|
+
Make sure to include as much relevant information as possible. Ruby version,
|
16
|
+
Rails version, `request_migrations` version, OS version and any stack traces
|
17
|
+
you have are very valuable.
|
18
|
+
|
19
|
+
## Pull Requests
|
20
|
+
|
21
|
+
- **Add tests!** Your patch won't be accepted if it doesn't have tests.
|
22
|
+
|
23
|
+
- **Document any change in behaviour**. Make sure the README and any other
|
24
|
+
relevant documentation are kept up-to-date.
|
25
|
+
|
26
|
+
- **Create topic branches**. Please don't ask us to pull from your master branch.
|
27
|
+
|
28
|
+
- **One pull request per feature**. If you want to do more than one thing, send
|
29
|
+
multiple pull requests.
|
30
|
+
|
31
|
+
- **Send coherent history**. Make sure each individual commit in your pull
|
32
|
+
request is meaningful. If you had to make multiple intermediate commits while
|
33
|
+
developing, please squash them before sending them to us.
|
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2022 Keygen LLC
|
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,540 @@
|
|
1
|
+
# request_migrations
|
2
|
+
|
3
|
+
[![Gem Version](https://badge.fury.io/rb/request_migrations.svg)](https://badge.fury.io/rb/request_migrations)
|
4
|
+
|
5
|
+
**Make breaking API changes without breaking things!** Use `request_migrations` to craft
|
6
|
+
backwards-compatible migrations for API requests, responses, and more. This is being
|
7
|
+
used in production by [Keygen](https://keygen.sh) to serve millions of API requests per day.
|
8
|
+
|
9
|
+
Sponsored by:
|
10
|
+
|
11
|
+
[![Keygen logo](https://camo.githubusercontent.com/d50a6bd1f31fd4da523b8aa555a54356cc2d3e81eb8bc9123303787e44c5bb07/68747470733a2f2f6b657967656e2e73682f696d616765732f62616467652e706e67)](https://keygen.sh)
|
12
|
+
|
13
|
+
_A software licensing and distribution API built for developers._
|
14
|
+
|
15
|
+
## Installation
|
16
|
+
|
17
|
+
Add this line to your application's `Gemfile`:
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
gem 'request_migrations'
|
21
|
+
```
|
22
|
+
|
23
|
+
And then execute:
|
24
|
+
|
25
|
+
```bash
|
26
|
+
$ bundle
|
27
|
+
```
|
28
|
+
|
29
|
+
Or install it yourself as:
|
30
|
+
|
31
|
+
```bash
|
32
|
+
$ gem install request_migrations
|
33
|
+
```
|
34
|
+
|
35
|
+
## Supported Rubies
|
36
|
+
|
37
|
+
`request_migrations` supports Ruby 3. We encourage you to upgrade if you're on an older
|
38
|
+
version. Ruby 3 provides a lot of great features, like better pattern matching.
|
39
|
+
|
40
|
+
## Documentation
|
41
|
+
|
42
|
+
You can find the documentation on [RubyDoc](https://rubydoc.info/github/keygen-sh/request_migrations).
|
43
|
+
|
44
|
+
_We're working on improving the docs._
|
45
|
+
|
46
|
+
## Features
|
47
|
+
|
48
|
+
- Define migrations for migrating a response between versions.
|
49
|
+
- Define migrations for migrating a request between versions.
|
50
|
+
- Define migrations for applying one-off migrations.
|
51
|
+
- Define version-based routing constraints.
|
52
|
+
|
53
|
+
## Usage
|
54
|
+
|
55
|
+
Use `request_migrations` to make _backwards-incompatible_ changes in your code, while
|
56
|
+
providing a _backwards-compatible_ interface for clients on older API versions. What
|
57
|
+
exactly does that mean? Well, let's demonstrate!
|
58
|
+
|
59
|
+
Let's assume that we provide an API service, which has `/users` CRUD resources.
|
60
|
+
|
61
|
+
Let's also assume we start with the following `User` model:
|
62
|
+
|
63
|
+
```ruby
|
64
|
+
class User
|
65
|
+
include ActiveModel::Model
|
66
|
+
include ActiveModel::Attributes
|
67
|
+
|
68
|
+
attribute :name, :string
|
69
|
+
end
|
70
|
+
```
|
71
|
+
|
72
|
+
After awhile, we realize our `User` model's combined `name` attribute is not working too
|
73
|
+
well, and we want to change it to `first_name` and `last_name`.
|
74
|
+
|
75
|
+
So we write a database migration that changes our `User` model:
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
class User
|
79
|
+
include ActiveModel::Model
|
80
|
+
include ActiveModel::Attributes
|
81
|
+
|
82
|
+
attribute :first_name, :string
|
83
|
+
attribute :last_name, :string
|
84
|
+
end
|
85
|
+
```
|
86
|
+
|
87
|
+
But what about the API consumers who were relying on `name`? We just broke our API contract
|
88
|
+
with them! To resolve this, let's create our first request migration.
|
89
|
+
|
90
|
+
We recommend that migrations be stored under `app/migrations/`.
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
class CombineNamesForUserMigration < RequestMigrations::Migration
|
94
|
+
# Provide a useful description of the change
|
95
|
+
description %(transforms a user's first and last name to a combined name attribute)
|
96
|
+
|
97
|
+
# Migrate inputs that contain a user. The migration should mutate
|
98
|
+
# the input, whatever that may be.
|
99
|
+
migrate if: -> data { data in type: 'user' } do |data|
|
100
|
+
first_name = data.delete(:first_name)
|
101
|
+
last_name = data.delete(:last_name)
|
102
|
+
|
103
|
+
data[:name] = "#{first_name} #{last_name}"
|
104
|
+
end
|
105
|
+
|
106
|
+
# Migrate the response. This is where you provide the migration input.
|
107
|
+
response if: -> res { res.successful? && res.request.params in controller: 'api/v1/users',
|
108
|
+
action: 'show' } do |res|
|
109
|
+
data = JSON.parse(res.body, symbolize_names: true)
|
110
|
+
|
111
|
+
# Call our migrate definition above
|
112
|
+
migrate!(data)
|
113
|
+
|
114
|
+
res.body = JSON.generate(data)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
```
|
118
|
+
|
119
|
+
As you can see, with pattern matching, it makes creating migrations for certain
|
120
|
+
resources simple. Here, we've defined a migration that only runs for the `users#show`
|
121
|
+
and `me#show` resources, and only when the response is successfuly. In addition,
|
122
|
+
the data is only migrated when the response body contains a user.
|
123
|
+
|
124
|
+
Next, we'll need to configure `request_migrations` via an initializer under
|
125
|
+
`initializers/request_migrations.rb`:
|
126
|
+
|
127
|
+
```ruby
|
128
|
+
RequestMigrations.configure do |config|
|
129
|
+
# Define a resolver to determine the current version. Here, you can perform
|
130
|
+
# a lookup on the current user using request parameters, or simply use
|
131
|
+
# a header like we are here, defaulting to the latest version.
|
132
|
+
config.request_version_resolver = -> request {
|
133
|
+
request.headers.fetch('Foo-Version') { config.current_version }
|
134
|
+
}
|
135
|
+
|
136
|
+
# Define the latest version of our application.
|
137
|
+
config.current_version = '1.1'
|
138
|
+
|
139
|
+
# Define previous versions and their migrations, in descending order.
|
140
|
+
config.versions = {
|
141
|
+
'1.0' => %i[combine_user_names_migration],
|
142
|
+
}
|
143
|
+
end
|
144
|
+
```
|
145
|
+
|
146
|
+
Lastly, you'll want to update your application controller so that migrations
|
147
|
+
are applied:
|
148
|
+
|
149
|
+
```ruby
|
150
|
+
class ApplicationController < ActionController::API
|
151
|
+
include RequestMigrations::Controller::Migrations
|
152
|
+
end
|
153
|
+
```
|
154
|
+
|
155
|
+
Now, when an API client provides a `Foo-Version: 1.0` header, they'll receive a
|
156
|
+
response containing the combined `name` attribute.
|
157
|
+
|
158
|
+
### Response migrations
|
159
|
+
|
160
|
+
We covered this above, but response migrations define a change to a response.
|
161
|
+
You define a response migration by using the `response` class method.
|
162
|
+
|
163
|
+
```ruby
|
164
|
+
class RemoveVowelsMigration < RequestMigrations::Migration
|
165
|
+
description %(in the past, we had a bug that removed all vowels, and some clients rely on that behavior)
|
166
|
+
|
167
|
+
response if: -> res { res.request.params in action: 'index' | 'show' | 'create' | 'update' } do |res|
|
168
|
+
body = JSON.parse(res.body, symbolize_names: true)
|
169
|
+
|
170
|
+
# Mutate the response body by removing all vowels
|
171
|
+
body.deep_transform_values! { _1.gsub(/[aeiou]/, '') }
|
172
|
+
|
173
|
+
res.body = JSON.generate(body)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
```
|
177
|
+
|
178
|
+
The `response` method accepts an `:if` keyword, which should be a lambda
|
179
|
+
that evaluates to a boolean, which determines whether or not the migration
|
180
|
+
should be applied.
|
181
|
+
|
182
|
+
### Request migrations
|
183
|
+
|
184
|
+
Request migrations define a change on a request. For example, modifying a request's
|
185
|
+
headers. You define a response migration by using the `request` class method.
|
186
|
+
|
187
|
+
```ruby
|
188
|
+
class AssumeContentTypeMigration < RequestMigrations::Migration
|
189
|
+
description %(in the past, we assumed all requests were JSON, but that has since changed)
|
190
|
+
|
191
|
+
# Migrate the request, adding an assumed content type to all requests.
|
192
|
+
request do |req|
|
193
|
+
req.headers['Content-Type'] = 'application/json'
|
194
|
+
end
|
195
|
+
end
|
196
|
+
```
|
197
|
+
|
198
|
+
The `request` method accepts an `:if` keyword, which should be a lambda
|
199
|
+
that evaluates to a boolean, which determines whether or not the migration
|
200
|
+
should be applied.
|
201
|
+
|
202
|
+
### One-off migrations
|
203
|
+
|
204
|
+
In our first scenario, where we combined our user's name attributes, we defined
|
205
|
+
our migration using the `migrate` class method. At this point, you may be wondering
|
206
|
+
why we did that, since we didn't use that method for the 2 previous request and
|
207
|
+
response migrations above.
|
208
|
+
|
209
|
+
Well, it comes down to support for one-off migrations (as well as offering
|
210
|
+
a nice interface for pattern matching inputs).
|
211
|
+
|
212
|
+
Let's go back to our first example, `CombineNamesForUserMigration`.
|
213
|
+
|
214
|
+
```ruby
|
215
|
+
class CombineNamesForUserMigration < RequestMigrations::Migration
|
216
|
+
# Provide a useful description of the change
|
217
|
+
description %(transforms a user's first and last name to a combined name attribute)
|
218
|
+
|
219
|
+
# Migrate inputs that contain a user. The migration should mutate
|
220
|
+
# the input, whatever that may be.
|
221
|
+
migrate if: -> data { data in type: 'user' } do |data|
|
222
|
+
first_name = data.delete(:first_name)
|
223
|
+
last_name = data.delete(:last_name)
|
224
|
+
|
225
|
+
data[:name] = "#{first_name} #{last_name}"
|
226
|
+
end
|
227
|
+
|
228
|
+
# Migrate the response. This is where you provide the migration input.
|
229
|
+
response if: -> res { res.successful? && res.request.params in controller: 'api/v1/users' | 'api/v1/me',
|
230
|
+
action: 'show' } do |res|
|
231
|
+
data = JSON.parse(res.body, symbolize_names: true)
|
232
|
+
|
233
|
+
# Call our migrate definition above
|
234
|
+
migrate!(data)
|
235
|
+
|
236
|
+
res.body = JSON.generate(data)
|
237
|
+
end
|
238
|
+
end
|
239
|
+
```
|
240
|
+
|
241
|
+
What if we had [a webhook system](https://keygen.sh/blog/how-to-build-a-webhook-system-in-rails-using-sidekiq/)
|
242
|
+
that we also needed to apply these migrations to? Well, we can use a one-off migration
|
243
|
+
here, via the `Migrator` class:
|
244
|
+
|
245
|
+
```ruby
|
246
|
+
class WebhookWorker
|
247
|
+
def perform(event, endpoint, data)
|
248
|
+
# ...
|
249
|
+
|
250
|
+
# Migrate event data from latest version to endpoint's configured version
|
251
|
+
current_version = RequestMigrations.config.current_version
|
252
|
+
target_version = endpoint.api_version
|
253
|
+
migrator = RequestMigrations::Migrator.new(
|
254
|
+
from: current_version,
|
255
|
+
to: target_version,
|
256
|
+
)
|
257
|
+
|
258
|
+
# Migrate the event data (tries to apply all matching migrations)
|
259
|
+
migrator.migrate!(data:)
|
260
|
+
|
261
|
+
# ...
|
262
|
+
|
263
|
+
event.send!(data)
|
264
|
+
end
|
265
|
+
end
|
266
|
+
```
|
267
|
+
|
268
|
+
Now, we've successfully applied a migration to both our API responses, as well
|
269
|
+
as to the webhook events we send. In this case, if our `event` matches the
|
270
|
+
our user shape, e.g. `type: 'user'`, then the migration will be applied.
|
271
|
+
|
272
|
+
### Routing constraints
|
273
|
+
|
274
|
+
When you want to encourage API clients to upgrade, you can utilize a routing `version_constraint`
|
275
|
+
to define routes only available for certain versions. You can also utilize routing constraints
|
276
|
+
to remove an API endpoint entirely.
|
277
|
+
|
278
|
+
```ruby
|
279
|
+
Rails.application.routes.draw do
|
280
|
+
# This endpoint is only available for version 1.1 and above
|
281
|
+
version_constraint '>= 1.1' do
|
282
|
+
resources :some_shiny_new_resource
|
283
|
+
end
|
284
|
+
|
285
|
+
# Remove this endpoint for any version below 1.1
|
286
|
+
version_constraint '< 1.1' do
|
287
|
+
scope module: :v1x0 do
|
288
|
+
resources :a_deprecated_resource
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
292
|
+
```
|
293
|
+
|
294
|
+
Currently, routing constraints only work for the `:semver` version format.
|
295
|
+
|
296
|
+
### Configuration
|
297
|
+
|
298
|
+
```ruby
|
299
|
+
RequestMigrations.configure do |config|
|
300
|
+
# Define a resolver to determine the current version. Here, you can perform
|
301
|
+
# a lookup on the current user using request parameters, or simply use
|
302
|
+
# a header like we are here, defaulting to the latest version.
|
303
|
+
config.request_version_resolver = -> request {
|
304
|
+
request.headers.fetch('Foo-Version') { config.current_version }
|
305
|
+
}
|
306
|
+
|
307
|
+
# Define the accepted version format. Default is :semver.
|
308
|
+
config.version_format = :semver
|
309
|
+
|
310
|
+
# Define the latest version of our application.
|
311
|
+
config.current_version = '1.2'
|
312
|
+
|
313
|
+
# Define previous versions and their migrations, in descending order.
|
314
|
+
# Should be a hash, where the key is the version and the value is an
|
315
|
+
# array of migration symbols or classes.
|
316
|
+
config.versions = {
|
317
|
+
'1.1' => %i[
|
318
|
+
has_one_author_to_has_many_for_posts_migration
|
319
|
+
has_one_author_to_has_many_for_post_migration
|
320
|
+
],
|
321
|
+
'1.0' => %i[
|
322
|
+
combine_names_for_users_migration
|
323
|
+
combine_names_for_user_migration
|
324
|
+
],
|
325
|
+
}
|
326
|
+
|
327
|
+
# Use a custom logger. Must be a tagged logger. Defaults to
|
328
|
+
# using Rails.logger.
|
329
|
+
config.logger = ActiveSupport::TaggedLogging.new(...)
|
330
|
+
end
|
331
|
+
```
|
332
|
+
|
333
|
+
### Version formats
|
334
|
+
|
335
|
+
By default, `request_migrations` uses a `:semver` version format, but it can be configured
|
336
|
+
to instead use one of the following, set via `config.version_format=`.
|
337
|
+
|
338
|
+
| Format | |
|
339
|
+
|:-----------|:----------------------------------------------------|
|
340
|
+
| `:semver` | Use semantic versions, e.g. `1.0`, `1.1, and `2.0`. |
|
341
|
+
| `:date` | Use date versions, e.g. `2020-09-02`, `2021-01-01`. |
|
342
|
+
| `:integer` | Use integer versions, e.g. `1`, `2`, and `3`. |
|
343
|
+
| `:float` | Use float versions, e.g. `1.0`, `1.1`, and `2.0`. |
|
344
|
+
| `:string` | Use string versions, e.g. `a`, `b`, and `z`. |
|
345
|
+
|
346
|
+
### Tips and tricks
|
347
|
+
|
348
|
+
Over the years, we're learned a thing or two about writing request migrations. We'll share tips here.
|
349
|
+
|
350
|
+
#### Use pattern matching
|
351
|
+
|
352
|
+
Pattern matching really cleans up the `:if` conditions, and overall makes migrations more readable.
|
353
|
+
|
354
|
+
```ruby
|
355
|
+
class AddUsernameAttributeToUsersMigration < RequestMigrations::Migration
|
356
|
+
description %(adds username attributes to a collection of users)
|
357
|
+
|
358
|
+
migrate if: -> body { body in data: [*] } do |body|
|
359
|
+
case body
|
360
|
+
in data: [*, { type: 'users', attributes: { ** } }, *]
|
361
|
+
body[:data].each do |user|
|
362
|
+
case user
|
363
|
+
in type: 'users', attributes: { email: }
|
364
|
+
user[:attributes][:username] = email
|
365
|
+
else
|
366
|
+
end
|
367
|
+
end
|
368
|
+
else
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
372
|
+
response if: -> res { res.successful? && res.request.params in controller: 'api/v1/users',
|
373
|
+
action: 'index' } do |res|
|
374
|
+
body = JSON.parse(res.body, symbolize_names: true)
|
375
|
+
|
376
|
+
migrate!(body)
|
377
|
+
|
378
|
+
res.body = JSON.generate(body)
|
379
|
+
end
|
380
|
+
end
|
381
|
+
```
|
382
|
+
|
383
|
+
Just be sure to remember your `else` block when `case` pattern matching. :)
|
384
|
+
|
385
|
+
#### Route helpers
|
386
|
+
|
387
|
+
If you need to use route helpers in a migration, include them in your migration:
|
388
|
+
|
389
|
+
```ruby
|
390
|
+
class SomeMigration < RequestMigrations::Migration
|
391
|
+
include Rails.application.routes.url_helpers
|
392
|
+
end
|
393
|
+
```
|
394
|
+
|
395
|
+
#### Separate by shape
|
396
|
+
|
397
|
+
Define separate migrations for different input shapes, e.g. define a migration for an `#index`
|
398
|
+
to migrate an array of objects, and define another migration that handles the singular object
|
399
|
+
from `#show`, `#create` and `#update`. This will help keep your migrations readable.
|
400
|
+
|
401
|
+
For example, for a singular user response:
|
402
|
+
|
403
|
+
```ruby
|
404
|
+
class CombineNamesForUserMigration < RequestMigrations::Migration
|
405
|
+
description %(transforms a user's first and last name to a combined name attribute)
|
406
|
+
|
407
|
+
migrate if: -> data { data in type: 'user' } do |data|
|
408
|
+
first_name = data.delete(:first_name)
|
409
|
+
last_name = data.delete(:last_name)
|
410
|
+
|
411
|
+
data[:name] = "#{first_name} #{last_name}"
|
412
|
+
end
|
413
|
+
|
414
|
+
response if: -> res { res.successful? && res.request.params in controller: 'api/v1/users',
|
415
|
+
action: 'show' } do |res|
|
416
|
+
data = JSON.parse(res.body, symbolize_names: true)
|
417
|
+
|
418
|
+
migrate!(data)
|
419
|
+
|
420
|
+
res.body = JSON.generate(data)
|
421
|
+
end
|
422
|
+
end
|
423
|
+
```
|
424
|
+
|
425
|
+
And for a response containing a collection of users:
|
426
|
+
|
427
|
+
```ruby
|
428
|
+
class CombineNamesForUserMigration < RequestMigrations::Migration
|
429
|
+
description %(transforms a collection of users' first and last names to a combined name attribute)
|
430
|
+
|
431
|
+
migrate if: -> data { data in [*, { type: 'user' }, *] do |data|
|
432
|
+
data.each do |record|
|
433
|
+
case record
|
434
|
+
in type: 'user', first_name:, last_name:
|
435
|
+
record[:name] = "#{first_name} #{last_name}"
|
436
|
+
|
437
|
+
record.delete(:first_name)
|
438
|
+
record.delete(:last_name)
|
439
|
+
else
|
440
|
+
end
|
441
|
+
end
|
442
|
+
end
|
443
|
+
|
444
|
+
response if: -> res { res.successful? && res.request.params in controller: 'api/v1/users',
|
445
|
+
action: 'index' } do |res|
|
446
|
+
data = JSON.parse(res.body, symbolize_names: true)
|
447
|
+
|
448
|
+
migrate!(data)
|
449
|
+
|
450
|
+
res.body = JSON.generate(data)
|
451
|
+
end
|
452
|
+
end
|
453
|
+
```
|
454
|
+
|
455
|
+
Note that the `migrate` method now migrates an array input, and matches on the `#index` route.
|
456
|
+
|
457
|
+
#### Always check response status
|
458
|
+
|
459
|
+
Always check a response's status. You don't want to unintentionally apply migrations to error
|
460
|
+
responses.
|
461
|
+
|
462
|
+
```ruby
|
463
|
+
class SomeMigration < RequestMigrations::Migration
|
464
|
+
response if: -> res { res.successful? } do |res|
|
465
|
+
# ...
|
466
|
+
end
|
467
|
+
end
|
468
|
+
```
|
469
|
+
|
470
|
+
#### Don't match on URL pattern
|
471
|
+
|
472
|
+
Don't match on URL pattern. Instead, use `response.request.params` to access the request params
|
473
|
+
in a `response` migration, and use the `:controller` and `:action` params to determine route.
|
474
|
+
|
475
|
+
```ruby
|
476
|
+
class SomeMigration < RequestMigrations::Migration
|
477
|
+
# Bad
|
478
|
+
response if: -> res { res.request.path.matches?(/^\/v1\/posts$/) }
|
479
|
+
|
480
|
+
# Good
|
481
|
+
response if: -> res { res.request.params in controller: 'api/v1/posts', action: 'index' }
|
482
|
+
end
|
483
|
+
```
|
484
|
+
|
485
|
+
#### Namespace deprecated controllers
|
486
|
+
|
487
|
+
When you need to entirely change a controller or service class, use a `V1x0::UsersController`-style
|
488
|
+
namespace to keep the old deprecated classes tidy.
|
489
|
+
|
490
|
+
```ruby
|
491
|
+
class V1x0::UsersController
|
492
|
+
def foo
|
493
|
+
# Some old foo action
|
494
|
+
end
|
495
|
+
end
|
496
|
+
```
|
497
|
+
|
498
|
+
|
499
|
+
#### Avoid routing contraints
|
500
|
+
|
501
|
+
Avoid using routing version constraints that remove functionality. They can be a headache
|
502
|
+
during upgrades. Consider only making _additive_ changes. You should remove docs for old
|
503
|
+
or deprecated endpoints to limit any new usage.
|
504
|
+
|
505
|
+
```ruby
|
506
|
+
Rails.application.routes.draw do
|
507
|
+
resources :users do
|
508
|
+
# Iffy
|
509
|
+
version_constraint '< 1.1' do
|
510
|
+
resources :posts
|
511
|
+
end
|
512
|
+
|
513
|
+
# Good
|
514
|
+
scope module: :v1x0 do
|
515
|
+
resources :posts
|
516
|
+
end
|
517
|
+
end
|
518
|
+
end
|
519
|
+
```
|
520
|
+
|
521
|
+
---
|
522
|
+
|
523
|
+
Have a tip of your own? Open a pull request!
|
524
|
+
|
525
|
+
## Is it any good?
|
526
|
+
|
527
|
+
Yes.
|
528
|
+
|
529
|
+
## Credits
|
530
|
+
|
531
|
+
Credit goes to Stripe for inspiring the [high-level migration strategy](https://stripe.com/blog/api-versioning).
|
532
|
+
Intercom has [another good post on the topic](https://www.intercom.com/blog/api-versioning/).
|
533
|
+
|
534
|
+
## Contributing
|
535
|
+
|
536
|
+
If you have an idea, or have discovered a bug, please open an issue or create a pull request.
|
537
|
+
|
538
|
+
## License
|
539
|
+
|
540
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/SECURITY.md
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RequestMigrations
|
4
|
+
class Configuration
|
5
|
+
include ActiveSupport::Configurable
|
6
|
+
|
7
|
+
config_accessor(:logger) { Rails.logger }
|
8
|
+
config_accessor(:request_version_resolver) { -> req { self.current_version } }
|
9
|
+
config_accessor(:version_format) { :semver }
|
10
|
+
config_accessor(:current_version) { nil }
|
11
|
+
config_accessor(:versions) { [] }
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RequestMigrations
|
4
|
+
module Controller
|
5
|
+
class Migrator < Migrator
|
6
|
+
def initialize(request:, response:, **kwargs)
|
7
|
+
super(**kwargs)
|
8
|
+
|
9
|
+
@request = request
|
10
|
+
@response = response
|
11
|
+
end
|
12
|
+
|
13
|
+
def migrate!
|
14
|
+
logger.debug { "Migrating from #{current_version} to #{target_version} (#{migrations.size} potential migrations)" }
|
15
|
+
|
16
|
+
migrations.each_with_index { |migration, i|
|
17
|
+
logger.debug { "Applying migration #{migration} (#{i + 1}/#{migrations.size})" }
|
18
|
+
|
19
|
+
migration.new.migrate_request!(request)
|
20
|
+
}
|
21
|
+
|
22
|
+
yield
|
23
|
+
|
24
|
+
migrations.each_with_index { |migration, i|
|
25
|
+
logger.debug { "Applying migration #{migration} (#{i + 1}/#{migrations.size})" }
|
26
|
+
|
27
|
+
migration.new.migrate_response!(response)
|
28
|
+
}
|
29
|
+
|
30
|
+
logger.debug { "Migrated from #{current_version} to #{target_version}" }
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
attr_accessor :request,
|
36
|
+
:response
|
37
|
+
|
38
|
+
def logger = RequestMigrations.logger.tagged(request&.request_id)
|
39
|
+
end
|
40
|
+
|
41
|
+
module Migrations
|
42
|
+
extend ActiveSupport::Concern
|
43
|
+
|
44
|
+
included do
|
45
|
+
around_action :apply_migrations!
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def apply_migrations!
|
50
|
+
current_version = RequestMigrations.config.current_version
|
51
|
+
target_version = RequestMigrations.config.request_version_resolver.call(request)
|
52
|
+
|
53
|
+
migrator = Migrator.new(from: current_version, to: target_version, request:, response:)
|
54
|
+
migrator.migrate! { yield }
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
module Constraints
|
60
|
+
extend ActiveSupport::Concern
|
61
|
+
|
62
|
+
included do
|
63
|
+
# TODO(ezekg) Implement controller-level version constraints
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RequestMigrations
|
4
|
+
class Migration
|
5
|
+
class ConditionalBlock
|
6
|
+
def initialize(if: nil, &block)
|
7
|
+
@if = binding.local_variable_get(:if)
|
8
|
+
@block = block
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(ctx, *args)
|
12
|
+
return if
|
13
|
+
@if.respond_to?(:call) && !@if.call(*args)
|
14
|
+
|
15
|
+
ctx.instance_exec(*args, &@block)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
module DSL
|
20
|
+
def self.extended(klass)
|
21
|
+
class << klass
|
22
|
+
attr_accessor :description_value,
|
23
|
+
:changeset_value,
|
24
|
+
:request_blocks,
|
25
|
+
:migration_blocks,
|
26
|
+
:response_blocks
|
27
|
+
end
|
28
|
+
|
29
|
+
klass.description_value = nil
|
30
|
+
klass.request_blocks = []
|
31
|
+
klass.migration_blocks = []
|
32
|
+
klass.response_blocks = []
|
33
|
+
end
|
34
|
+
|
35
|
+
def inherited(klass)
|
36
|
+
klass.description_value = description_value.dup
|
37
|
+
klass.request_blocks = request_blocks.dup
|
38
|
+
klass.migration_blocks = migration_blocks.dup
|
39
|
+
klass.response_blocks = response_blocks.dup
|
40
|
+
end
|
41
|
+
|
42
|
+
def description(desc)
|
43
|
+
self.description_value = desc
|
44
|
+
end
|
45
|
+
|
46
|
+
def request(if: nil, &block)
|
47
|
+
self.request_blocks << ConditionalBlock.new(if:, &block)
|
48
|
+
end
|
49
|
+
|
50
|
+
def migrate(if: nil, &block)
|
51
|
+
self.migration_blocks << ConditionalBlock.new(if:, &block)
|
52
|
+
end
|
53
|
+
|
54
|
+
def response(if: nil, &block)
|
55
|
+
self.response_blocks << ConditionalBlock.new(if:, &block)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
extend DSL
|
60
|
+
|
61
|
+
def migrate_request!(request)
|
62
|
+
self.class.request_blocks.each { |block|
|
63
|
+
instance_exec(request) { block.call(self, _1) }
|
64
|
+
}
|
65
|
+
end
|
66
|
+
|
67
|
+
def migrate!(data)
|
68
|
+
self.class.migration_blocks.each { |block|
|
69
|
+
instance_exec(data) { block.call(self, _1) }
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
def migrate_response!(response)
|
74
|
+
self.class.response_blocks.each { |block|
|
75
|
+
instance_exec(response) { block.call(self, _1) }
|
76
|
+
}
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RequestMigrations
|
4
|
+
class Migrator
|
5
|
+
def initialize(from:, to:)
|
6
|
+
@current_version = Version.new(from)
|
7
|
+
@target_version = Version.new(to)
|
8
|
+
end
|
9
|
+
|
10
|
+
def migrate!(data:)
|
11
|
+
logger.debug { "Migrating from #{current_version} to #{target_version} (#{migrations.size} potential migrations)" }
|
12
|
+
|
13
|
+
migrations.each_with_index { |migration, i|
|
14
|
+
logger.debug { "Applying migration #{migration} (#{i + 1}/#{migrations.size})" }
|
15
|
+
|
16
|
+
migration.new.migrate!(data)
|
17
|
+
}
|
18
|
+
|
19
|
+
logger.debug { "Migrated from #{current_version} to #{target_version}" }
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
attr_accessor :current_version,
|
25
|
+
:target_version
|
26
|
+
|
27
|
+
def logger = RequestMigrations.logger
|
28
|
+
|
29
|
+
# TODO(ezekg) These should be sorted
|
30
|
+
def migrations
|
31
|
+
@migrations ||=
|
32
|
+
RequestMigrations.config.versions
|
33
|
+
.filter { |(version, _)| Version.new(version).between?(target_version, current_version) }
|
34
|
+
.flat_map { |(_, migrations)| migrations }
|
35
|
+
.map { |migration|
|
36
|
+
case migration
|
37
|
+
when Symbol
|
38
|
+
migration.to_s.classify.constantize
|
39
|
+
when String
|
40
|
+
migration.classify.constantize
|
41
|
+
when Class
|
42
|
+
migration
|
43
|
+
else
|
44
|
+
raise UnsupportedMigrationError, "migration type is unsupported: #{migration}"
|
45
|
+
end
|
46
|
+
}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RequestMigrations
|
4
|
+
module Router
|
5
|
+
module Constraints
|
6
|
+
class VersionConstraint
|
7
|
+
def initialize(constraint:)
|
8
|
+
@constraint = Version::Constraint.new(constraint)
|
9
|
+
end
|
10
|
+
|
11
|
+
def matches?(request)
|
12
|
+
version = Version.coerce(resolver.call(request))
|
13
|
+
|
14
|
+
@constraint.satisfies?(version)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def resolver = RequestMigrations.config.request_version_resolver
|
20
|
+
end
|
21
|
+
|
22
|
+
def version_constraint(constraint, &)
|
23
|
+
constraints VersionConstraint.new(constraint:) do
|
24
|
+
instance_eval(&)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RequestMigrations
|
4
|
+
class Version
|
5
|
+
include Comparable
|
6
|
+
|
7
|
+
attr_reader :format,
|
8
|
+
:value
|
9
|
+
|
10
|
+
def initialize(version)
|
11
|
+
raise UnsupportedVersionError, "version is unsupported: #{version}" unless
|
12
|
+
version.in?(RequestMigrations.supported_versions)
|
13
|
+
|
14
|
+
@format = RequestMigrations.config.version_format.to_sym
|
15
|
+
@value = case @format
|
16
|
+
when :semver
|
17
|
+
Semverse::Version.coerce(version)
|
18
|
+
when :date
|
19
|
+
Date.parse(version)
|
20
|
+
when :integer
|
21
|
+
version.to_i
|
22
|
+
when :float
|
23
|
+
version.to_f
|
24
|
+
when :string
|
25
|
+
version.to_s
|
26
|
+
else
|
27
|
+
raise InvalidVersionFormatError, "invalid version format: #{@format} (must be one of: #{SUPPORTED_VERSION_FORMATS.join(',')}"
|
28
|
+
end
|
29
|
+
rescue Semverse::InvalidVersionFormat,
|
30
|
+
Date::Error
|
31
|
+
raise InvalidVersionError, "invalid #{@format} version given: #{version}"
|
32
|
+
end
|
33
|
+
|
34
|
+
def <=>(other) = @value <=> Version.coerce(other).value
|
35
|
+
def to_s = @value.to_s
|
36
|
+
|
37
|
+
class << self
|
38
|
+
def coerce(version)
|
39
|
+
version.is_a?(self) ? version : new(version)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class Constraint
|
44
|
+
attr_reader :format,
|
45
|
+
:value
|
46
|
+
|
47
|
+
def initialize(constraint)
|
48
|
+
@format = RequestMigrations.config.version_format.to_sym
|
49
|
+
@constraint = case @format
|
50
|
+
when :semver
|
51
|
+
Semverse::Constraint.coerce(constraint)
|
52
|
+
when :date,
|
53
|
+
:integer,
|
54
|
+
:float,
|
55
|
+
:string
|
56
|
+
raise NotImplementedError, "#{@format} constraints are not supported"
|
57
|
+
else
|
58
|
+
raise InvalidVersionFormatError, "invalid version constraint format: #{@format} (must be one of: #{SUPPORTED_VERSION_FORMATS.join(',')}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def satisfies?(other) = @constraint.satisfies?(other)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require "semverse"
|
5
|
+
require "request_migrations/gem"
|
6
|
+
require "request_migrations/configuration"
|
7
|
+
require "request_migrations/version"
|
8
|
+
require "request_migrations/migration"
|
9
|
+
require "request_migrations/migrator"
|
10
|
+
require "request_migrations/controller"
|
11
|
+
require "request_migrations/router"
|
12
|
+
require "request_migrations/railtie"
|
13
|
+
|
14
|
+
module RequestMigrations
|
15
|
+
class UnsupportedMigrationError < StandardError; end
|
16
|
+
class InvalidVersionFormatError < StandardError; end
|
17
|
+
class UnsupportedVersionError < StandardError; end
|
18
|
+
class InvalidVersionError < StandardError; end
|
19
|
+
|
20
|
+
SUPPORTED_VERSION_FORMATS = %i[semver date float integer string].freeze
|
21
|
+
|
22
|
+
def self.logger = @logger ||= RequestMigrations.config.logger.tagged(:request_migrations)
|
23
|
+
def self.config = @config ||= Configuration.new
|
24
|
+
|
25
|
+
def self.configure
|
26
|
+
yield config
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.supported_versions
|
30
|
+
[RequestMigrations.config.current_version, *RequestMigrations.config.versions.keys].uniq.freeze
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
metadata
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: request_migrations
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Zeke Gabrielse
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-06-23 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '6.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '6.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: semverse
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec-rails
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
description: Make breaking API changes without breaking things by using request_migrations
|
56
|
+
to craft backwards-compatible migrations for API requests, responses, and more.
|
57
|
+
Inspired by Stripe's API versioning strategy.
|
58
|
+
email:
|
59
|
+
- oss@keygen.sh
|
60
|
+
executables: []
|
61
|
+
extensions: []
|
62
|
+
extra_rdoc_files: []
|
63
|
+
files:
|
64
|
+
- CHANGELOG.md
|
65
|
+
- CONTRIBUTING.md
|
66
|
+
- LICENSE
|
67
|
+
- README.md
|
68
|
+
- SECURITY.md
|
69
|
+
- lib/request_migrations.rb
|
70
|
+
- lib/request_migrations/configuration.rb
|
71
|
+
- lib/request_migrations/controller.rb
|
72
|
+
- lib/request_migrations/gem.rb
|
73
|
+
- lib/request_migrations/migration.rb
|
74
|
+
- lib/request_migrations/migrator.rb
|
75
|
+
- lib/request_migrations/railtie.rb
|
76
|
+
- lib/request_migrations/router.rb
|
77
|
+
- lib/request_migrations/version.rb
|
78
|
+
homepage: https://github.com/keygen-sh/request_migrations
|
79
|
+
licenses:
|
80
|
+
- MIT
|
81
|
+
metadata: {}
|
82
|
+
post_install_message:
|
83
|
+
rdoc_options: []
|
84
|
+
require_paths:
|
85
|
+
- lib
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - ">="
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '3.0'
|
91
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
92
|
+
requirements:
|
93
|
+
- - ">="
|
94
|
+
- !ruby/object:Gem::Version
|
95
|
+
version: '0'
|
96
|
+
requirements: []
|
97
|
+
rubygems_version: 3.3.7
|
98
|
+
signing_key:
|
99
|
+
specification_version: 4
|
100
|
+
summary: Write request and response migrations for your Ruby on Rails API.
|
101
|
+
test_files: []
|