idempo 1.2.1 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79351972a8246031e7301fcadb2d3721fa99300edffc00a3cb1a2e5fa828c68b
4
- data.tar.gz: 27bb14ea7cfe6a10de6367cd725a549a0e26ccec289ca6bdc192752df39c60db
3
+ metadata.gz: 3db7fbb3c612bae5675890f118986c3d026e49c73f3c74c24f15fea51e6a67c7
4
+ data.tar.gz: 65a193261af6e424fe8f7e3437d28e02c20ddb4c9ae199c9556f39676474b8ac
5
5
  SHA512:
6
- metadata.gz: e7cab37f569d2688a83b0d2c25c8f73b720c3fbc294ab6f2cd70686167f8c8dfb1864c738a33ffa28fec152f346e409cc0088be2d9a907974ac8c5370a9469fb
7
- data.tar.gz: f2ad3d16e636499ce989c9093ad0c7144fb9d22fe89d63939045fbe9df593595ce349d4e9ae25cb70898020bc1e66ea0307889cd7b1eb3060e758675219e5add
6
+ metadata.gz: bd32562481a479070ed2806ef3201d370417fd9acfbbb8499d1aefb2f1000f278ee3dff47ccb0ac640f33001be8c41cccba7c498980d07c893dee519e53f539b
7
+ data.tar.gz: df3b05f901ac7e49bb82bfde9a522e2a179b96953db10751b7814858597a5a0af0aa82ff0fd6824e3a11cae6bdf4ebc84004a35b09cfae7a39958114a307eef8
@@ -15,7 +15,7 @@ jobs:
15
15
  strategy:
16
16
  matrix:
17
17
  ruby:
18
- - '2.7'
18
+ - "2.7"
19
19
  steps:
20
20
  - name: Checkout
21
21
  uses: actions/checkout@v4
@@ -34,14 +34,13 @@ jobs:
34
34
  - name: Standard (Lint)
35
35
  run: bundle exec rake standard
36
36
  test:
37
- name: Specs
37
+ name: "Specs (Rack 2 and 3)"
38
38
  runs-on: ubuntu-22.04
39
39
  if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
40
40
  strategy:
41
41
  matrix:
42
42
  ruby:
43
- - '2.7'
44
- - '3.2'
43
+ - "2.7"
45
44
  services:
46
45
  mysql:
47
46
  image: mysql:5.7
@@ -65,13 +64,14 @@ jobs:
65
64
  steps:
66
65
  - name: Checkout
67
66
  uses: actions/checkout@v4
67
+
68
68
  - name: Setup Ruby
69
69
  uses: ruby/setup-ruby@v1
70
70
  with:
71
71
  ruby-version: ${{ matrix.ruby }}
72
72
  bundler-cache: true
73
- - name: RSpec
74
- run: bundle exec rspec
73
+ - name: RSpec via Appraisal
74
+ run: "bundle exec appraisal install && bundle exec appraisal rspec"
75
75
  env:
76
76
  MYSQL_HOST: 127.0.0.1
77
77
  MYSQL_PORT: 3306
data/Appraisals ADDED
@@ -0,0 +1,9 @@
1
+ appraise "rack-3" do
2
+ gem "rack", ">= 3.0"
3
+ gem "activerecord", "~> 7.0", "< 9.0"
4
+ end
5
+
6
+ appraise "rack-2" do
7
+ gem "rack", ">= 2.0", "< 3.0"
8
+ gem "activerecord", "~> 6", "< 7.0"
9
+ end
data/CHANGELOG.md CHANGED
@@ -1,13 +1,21 @@
1
- ## [1.2.1] - 2024-02-22
1
+ ## 1.3.0
2
+
3
+ - Streamline integration with both Rack 2 and 3, add tests for request fingerprinting.
4
+
5
+ ## 1.2.2
6
+
7
+ - Support `#to_ary` on Rack response bodies on newer Rails/Rack versions
8
+
9
+ ## 1.2.1
2
10
 
3
11
  - Use autoloading for internal modules. A user using Redis does not have to load the ActiveRecord storage backend, for example
4
12
  - Ensure that the original Rack response body receives a `close` when reading out for caching
5
13
 
6
- ## [1.2.0] - 2024-02-22
14
+ ## 1.2.0
7
15
 
8
16
  - Use memory locking in addition to DB locking in `ActiveRecordBackend`
9
17
 
10
- ## [1.1.0] - 2024-02-22
18
+ ## 1.1.0
11
19
 
12
20
  - Use modern ActiveRecord migration options for better Rails 7.x compatibility
13
21
  - Ensure Github actions CI can run and uses Postgres appropriately
@@ -15,16 +23,16 @@
15
23
  - Implement `#prune!` on storage backends
16
24
  - Reformat all code using [standard](https://github.com/standardrb/standard) instead of wetransfer_style as it is both more relaxed and more modern
17
25
 
18
- ## [1.0.0] - 2023-10-27
26
+ ## 1.0.0
19
27
 
20
28
  - Release 1.0 as the API can be considered stable and the gem has been in production for years
21
29
 
22
- ## [0.2.0] - 2022-04-08
30
+ ## 0.2.0
23
31
 
24
32
  - Allow setting the global default TTL for the cached responses
25
33
  - Allow customisation of the request key computation (so that the client can decide whether to include/exclude `Authorization` and the like)
26
34
  - Extract the error response generating apps into separate modules, to make them easier to override
27
35
 
28
- ## [0.1.0] - 2021-10-14
36
+ ## 0.1.0
29
37
 
30
38
  - Initial release
data/README.md CHANGED
@@ -10,7 +10,8 @@ instead of calling your application.
10
10
  Idempo supports a number of backends, we recommend using Redis if you have multiple application servers / dynos and MemoryBackend if you are only using one single Puma worker. To initialize with Redis as backend pass the `backend:` parameter when adding the middleware:
11
11
 
12
12
  ```ruby
13
- use Idempo, backend: Idempo::RedisBackend.new(Rails.application.config.redis_connection_pool)
13
+ be = Idempo::RedisBackend.new(Rails.application.config.redis_connection_pool)
14
+ use Idempo, backend: be
14
15
  ```
15
16
 
16
17
  and to initialize with a memory store as backend:
@@ -65,7 +66,8 @@ end
65
66
  Then configure Idempo to use the backend (in your `application.rb`):
66
67
 
67
68
  ```ruby
68
- config.middleware.insert Idempo, backend: Idempo::ActiveRecordBackend.new
69
+ be = Idempo::ActiveRecordBackend.new
70
+ config.middleware.insert Idempo, backend: be
69
71
  ```
70
72
 
71
73
  In your regular tasks (cron or Rake) you will want to add a call to delete old Idempo responses (there is an index on `expire_at`):
@@ -87,7 +89,8 @@ use Idempo, backend: Idempo::RedisBackend.new
87
89
  If you have a configured Redis connection pool (and you should) - pass it to the initializer:
88
90
 
89
91
  ```ruby
90
- config.middleware.insert Idempo, backend: Idempo::RedisBackend.new(config.redis_connection_pool)
92
+ be = Idempo::RedisBackend.new(config.redis_connection_pool)
93
+ config.middleware.insert Idempo, backend: be
91
94
  ```
92
95
 
93
96
  All data stored in Redis will have TTLs and will expire automatically. Redis scripts ensure that updates to the stored idempotent responses and locking happen atomically.
data/Rakefile CHANGED
@@ -3,7 +3,11 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
5
  require "standard/rake"
6
+ require "appraisal"
6
7
 
7
8
  RSpec::Core::RakeTask.new(:spec)
8
-
9
9
  task default: :spec
10
+
11
+ if !ENV["APPRAISAL_INITIALIZED"]
12
+ task default: :appraisal
13
+ end
data/idempo.gemspec CHANGED
@@ -29,16 +29,15 @@ Gem::Specification.new do |spec|
29
29
  # Specify which files should be added to the gem when it is released.
30
30
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
31
31
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
32
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
32
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features|gemfiles)/}) }
33
33
  end
34
34
  spec.bindir = "exe"
35
35
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
36
36
  spec.require_paths = ["lib"]
37
37
 
38
- # Uncomment to register a new dependency of your gem
39
- spec.add_dependency "rack"
40
38
  spec.add_dependency "msgpack"
41
39
  spec.add_dependency "measurometer", "~> 1.3"
40
+ spec.add_dependency "rack", "< 4"
42
41
 
43
42
  spec.add_development_dependency "rake", "~> 13.0"
44
43
  spec.add_development_dependency "rspec", "~> 3.0"
@@ -48,6 +47,7 @@ Gem::Specification.new do |spec|
48
47
  spec.add_development_dependency "mysql2"
49
48
  spec.add_development_dependency "pg"
50
49
  spec.add_development_dependency "standard"
50
+ spec.add_development_dependency "appraisal"
51
51
 
52
52
  # For more information and examples about making a new gem, checkout our
53
53
  # guide at: https://bundler.io/guides/creating_gem.html
@@ -8,6 +8,6 @@ class Idempo::ConcurrentRequestErrorApp
8
8
  message: "Another request with this idempotency key is still in progress, please try again later"
9
9
  }
10
10
  }
11
- [429, {"Retry-After" => RETRY_AFTER_SECONDS, "Content-Type" => "application/json"}, [JSON.pretty_generate(res)]]
11
+ [429, {"retry-after" => RETRY_AFTER_SECONDS, "content-type" => "application/json"}, [JSON.pretty_generate(res)]]
12
12
  end
13
13
  end
@@ -6,6 +6,6 @@ class Idempo::MalformedKeyErrorApp
6
6
  message: "The Idempotency-Key header provided was empty or malformed"
7
7
  }
8
8
  }
9
- [400, {"Content-Type" => "application/json"}, [JSON.pretty_generate(res)]]
9
+ [400, {"content-type" => "application/json"}, [JSON.pretty_generate(res)]]
10
10
  end
11
11
  end
@@ -5,11 +5,24 @@ module Idempo::RequestFingerprint
5
5
  d << rack_request.url << "\n"
6
6
  d << rack_request.request_method << "\n"
7
7
  d << rack_request.get_header("HTTP_AUTHORIZATION").to_s << "\n"
8
- while (chunk = rack_request.env["rack.input"].read(1024 * 65))
9
- d << chunk
8
+
9
+ # Under Rack 3.0 the rack.input may or may not be rewindable (this is done to support
10
+ # streaming HTTP request bodies). If we know a request body is rewindable we can read it
11
+ # out in full and add it to the request fingerprint. If the request body cannot be
12
+ # rewound, we can't really rely on it as it can be fairly large (and we want the
13
+ # downstream app to read the request body, not us).
14
+ if rack_request.env["rack.input"].respond_to?(:rewind)
15
+ read_and_rewind(rack_request.env["rack.input"], d)
10
16
  end
17
+
11
18
  Base64.strict_encode64(d.digest)
19
+ end
20
+
21
+ def self.read_and_rewind(source_io, to_destination_io)
22
+ while (chunk = source_io.read(1024 * 65))
23
+ to_destination_io << chunk
24
+ end
12
25
  ensure
13
- rack_request.env["rack.input"].rewind
26
+ source_io.rewind
14
27
  end
15
28
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Idempo
4
- VERSION = "1.2.1"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/idempo.rb CHANGED
@@ -7,6 +7,7 @@ require "measurometer"
7
7
  require "msgpack"
8
8
  require "zlib"
9
9
  require "set"
10
+ require "rack"
10
11
 
11
12
  require "idempo/version"
12
13
 
@@ -54,9 +55,17 @@ class Idempo
54
55
  return from_persisted_response(stored_response)
55
56
  end
56
57
 
57
- status, headers, body = @app.call(env)
58
+ status, raw_headers, body = @app.call(env)
59
+ headers = downcase_keys(raw_headers)
60
+
61
+ expires_in_seconds = (headers.delete("x-idempo-persist-for-seconds") || @persist_for_seconds).to_i
62
+
63
+ # In some cases `body` could respond to to_ary. In this case, we don't need to
64
+ # call .close on the body afterwards, as it is supposed to self-close as per Rack 3.0 SPEC
65
+ #
66
+ # @see https://github.com/rack/rack/blob/main/SPEC.rdoc#the-body-
67
+ body = body.to_ary if body.respond_to?(:to_ary)
58
68
 
59
- expires_in_seconds = (headers.delete("X-Idempo-Persist-For-Seconds") || @persist_for_seconds).to_i
60
69
  if response_may_be_persisted?(status, headers, body)
61
70
  # Body is replaced with a cached version since a Rack response body is not rewindable
62
71
  marshaled_response, body = serialize_response(status, headers, body)
@@ -76,6 +85,12 @@ class Idempo
76
85
 
77
86
  private
78
87
 
88
+ def downcase_keys(raw_headers)
89
+ raw_headers.each_with_object({}) do |(name, value), hh|
90
+ hh[name.to_s.downcase] = value
91
+ end
92
+ end
93
+
79
94
  def from_persisted_response(marshaled_response)
80
95
  if marshaled_response[-2..] != ":1"
81
96
  raise Error, "Unknown serialization of the marshaled response"
@@ -104,21 +119,23 @@ class Idempo
104
119
  # does not
105
120
  [deflated_message_packed_str, body_chunks]
106
121
  ensure
122
+ # This will not be applied to response bodies of Array type.
107
123
  rack_response_body.close if rack_response_body.respond_to?(:close)
108
124
  end
109
125
 
110
126
  def response_may_be_persisted?(status, headers, body)
111
- return false if headers.delete("X-Idempo-Policy") == "no-store"
127
+ return false if headers.delete("x-idempo-policy") == "no-store"
112
128
  return false unless status_may_be_persisted?(status)
113
129
  return false unless body_size_within_limit?(headers, body)
114
130
  true
115
131
  end
116
132
 
117
133
  def body_size_within_limit?(response_headers, body)
118
- return response_headers["Content-Length"].to_i <= SAVED_RESPONSE_BODY_SIZE_LIMIT if response_headers["Content-Length"]
134
+ if response_headers["content-length"]
135
+ return response_headers["content-length"].to_i <= SAVED_RESPONSE_BODY_SIZE_LIMIT
136
+ end
119
137
 
120
138
  return false unless body.is_a?(Array) # Arbitrary iterable of unknown size
121
-
122
139
  sum_of_string_bytesizes(body) <= SAVED_RESPONSE_BODY_SIZE_LIMIT
123
140
  end
124
141
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idempo
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
@@ -9,10 +9,10 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2024-02-27 00:00:00.000000000 Z
12
+ date: 2024-11-29 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
- name: rack
15
+ name: msgpack
16
16
  requirement: !ruby/object:Gem::Requirement
17
17
  requirements:
18
18
  - - ">="
@@ -26,33 +26,33 @@ dependencies:
26
26
  - !ruby/object:Gem::Version
27
27
  version: '0'
28
28
  - !ruby/object:Gem::Dependency
29
- name: msgpack
29
+ name: measurometer
30
30
  requirement: !ruby/object:Gem::Requirement
31
31
  requirements:
32
- - - ">="
32
+ - - "~>"
33
33
  - !ruby/object:Gem::Version
34
- version: '0'
34
+ version: '1.3'
35
35
  type: :runtime
36
36
  prerelease: false
37
37
  version_requirements: !ruby/object:Gem::Requirement
38
38
  requirements:
39
- - - ">="
39
+ - - "~>"
40
40
  - !ruby/object:Gem::Version
41
- version: '0'
41
+ version: '1.3'
42
42
  - !ruby/object:Gem::Dependency
43
- name: measurometer
43
+ name: rack
44
44
  requirement: !ruby/object:Gem::Requirement
45
45
  requirements:
46
- - - "~>"
46
+ - - "<"
47
47
  - !ruby/object:Gem::Version
48
- version: '1.3'
48
+ version: '4'
49
49
  type: :runtime
50
50
  prerelease: false
51
51
  version_requirements: !ruby/object:Gem::Requirement
52
52
  requirements:
53
- - - "~>"
53
+ - - "<"
54
54
  - !ruby/object:Gem::Version
55
- version: '1.3'
55
+ version: '4'
56
56
  - !ruby/object:Gem::Dependency
57
57
  name: rake
58
58
  requirement: !ruby/object:Gem::Requirement
@@ -165,6 +165,20 @@ dependencies:
165
165
  - - ">="
166
166
  - !ruby/object:Gem::Version
167
167
  version: '0'
168
+ - !ruby/object:Gem::Dependency
169
+ name: appraisal
170
+ requirement: !ruby/object:Gem::Requirement
171
+ requirements:
172
+ - - ">="
173
+ - !ruby/object:Gem::Version
174
+ version: '0'
175
+ type: :development
176
+ prerelease: false
177
+ version_requirements: !ruby/object:Gem::Requirement
178
+ requirements:
179
+ - - ">="
180
+ - !ruby/object:Gem::Version
181
+ version: '0'
168
182
  description: Provides idempotency keys for Rack applications.
169
183
  email:
170
184
  - me@julik.nl
@@ -177,6 +191,7 @@ files:
177
191
  - ".gitignore"
178
192
  - ".rubocop.yml"
179
193
  - ".standard.yml"
194
+ - Appraisals
180
195
  - CHANGELOG.md
181
196
  - Gemfile
182
197
  - LICENSE.txt