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 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/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Test release.
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,7 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ If you find a vulnerability in `request_migrations`, please contact Keygen via
6
+ [email](mailto:security@keygen.sh). You will be given public credit for your
7
+ disclosure.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RequestMigrations
4
+ VERSION = "0.1.0"
5
+ 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,5 @@
1
+ module RequestMigrations
2
+ class Railtie < ::Rails::Railtie
3
+ ActionDispatch::Routing::Mapper.send(:include, Router::Constraints)
4
+ end
5
+ 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: []