request_migrations 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []