request_migrations 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 +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
|
+
[](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
|
+
[](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: []
|