idempo 0.1.3 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 057a7d3947de8a08ae62e9f7de2f2b05fae6607c1662aac78c7b12544050f41d
4
- data.tar.gz: d0d8a84c2b208fd27b2301f975a3cf582d0c7b9e1b572d61574febf1f9f31cf4
3
+ metadata.gz: 96b24842e2d4d301bcf7a1470790a392fa1443a82ab2634e3aa81adbd173828c
4
+ data.tar.gz: 02243d85940b4e9f110fc6460eb925533d39ca62e331237b8e5ad4934f250703
5
5
  SHA512:
6
- metadata.gz: bb98caf618c0c0c8987de2bd3e9e3a277c0d23d72d7b24ae90c22a678e44675bfb5a23468d23d0b73e9ccc7ec8e90704df553852b72a346b4f28e00d7a441fcc
7
- data.tar.gz: 4c66050b6e219abd0922bd9a3e72740344c78c65bf0d35af08e2f423f4f2b835da377662b785802a37db4806f996e565027f1e3bdceff9d5344d59fa03344930
6
+ metadata.gz: b931753fa35dc613bf54416010c0c12a29855dedf1d9076958e7ef60e7594b1d90d53deae18dc5e5c9de6468916175ca49761413a54054c0c0523af552c29604
7
+ data.tar.gz: d32efc792665bd2695b39f3b98280196d9046e2e5b8affee072023f5e438477f064a1010927bf99fd755cf657d85796f19c28c51ba0086b10645ba95e5e2b66b
@@ -0,0 +1,102 @@
1
+ name: CI
2
+
3
+ on:
4
+ - push
5
+ - pull_request
6
+
7
+ env:
8
+ BUNDLE_PATH: vendor/bundle
9
+
10
+ services:
11
+ mysql:
12
+ image: mysql:5.7
13
+ env:
14
+ MYSQL_ALLOW_EMPTY_PASSWORD: yes
15
+ ports:
16
+ - 3306
17
+ options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
18
+ redis:
19
+ image: redis
20
+ options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
21
+
22
+ jobs:
23
+ lint:
24
+ name: Code Style
25
+ runs-on: ubuntu-18.04
26
+ if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
27
+ strategy:
28
+ matrix:
29
+ ruby:
30
+ - '2.7'
31
+ steps:
32
+ - name: Checkout
33
+ uses: actions/checkout@v2
34
+ - name: Setup Ruby
35
+ uses: ruby/setup-ruby@v1
36
+ with:
37
+ ruby-version: ${{ matrix.ruby }}
38
+ - name: Gemfile Cache
39
+ uses: actions/cache@v2
40
+ with:
41
+ path: Gemfile.lock
42
+ key: ${{ runner.os }}-gemlock-${{ matrix.ruby }}-${{ hashFiles('Gemfile', 'idempo.gemspec') }}
43
+ restore-keys: |
44
+ ${{ runner.os }}-gemlock-${{ matrix.ruby }}-
45
+ - name: Bundle Cache
46
+ id: cache-gems
47
+ uses: actions/cache@v2
48
+ with:
49
+ path: vendor/bundle
50
+ key: ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ hashFiles('Gemfile', 'Gemfile.lock', 'idempo.gemspec') }}
51
+ restore-keys: |
52
+ ${{ runner.os }}-gems-${{ matrix.ruby }}-
53
+ ${{ runner.os }}-gems-
54
+ - name: Bundle Install
55
+ if: steps.cache-gems.outputs.cache-hit != 'true'
56
+ run: bundle install --jobs 4 --retry 3
57
+ - name: Rubocop Cache
58
+ uses: actions/cache@v2
59
+ with:
60
+ path: ~/.cache/rubocop_cache
61
+ key: ${{ runner.os }}-rubocop-${{ hashFiles('.rubocop.yml') }}
62
+ restore-keys: |
63
+ ${{ runner.os }}-rubocop-
64
+ - name: Rubocop
65
+ run: bundle exec rubocop
66
+ test:
67
+ name: Specs
68
+ runs-on: ubuntu-18.04
69
+ if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
70
+ strategy:
71
+ matrix:
72
+ ruby:
73
+ - '2.6'
74
+ - '3.0'
75
+ steps:
76
+ - name: Checkout
77
+ uses: actions/checkout@v2
78
+ - name: Setup Ruby
79
+ uses: ruby/setup-ruby@v1
80
+ with:
81
+ ruby-version: ${{ matrix.ruby }}
82
+ - name: Gemfile Cache
83
+ uses: actions/cache@v2
84
+ with:
85
+ path: Gemfile.lock
86
+ key: ${{ runner.os }}-gemlock-${{ matrix.ruby }}-${{ hashFiles('Gemfile', 'idempo.gemspec') }}
87
+ restore-keys: |
88
+ ${{ runner.os }}-gemlock-${{ matrix.ruby }}-
89
+ - name: Bundle Cache
90
+ id: cache-gems
91
+ uses: actions/cache@v2
92
+ with:
93
+ path: vendor/bundle
94
+ key: ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ hashFiles('Gemfile', 'Gemfile.lock', 'idempo.gemspec') }}
95
+ restore-keys: |
96
+ ${{ runner.os }}-gems-${{ matrix.ruby }}-
97
+ ${{ runner.os }}-gems-
98
+ - name: Bundle Install
99
+ if: steps.cache-gems.outputs.cache-hit != 'true'
100
+ run: bundle install --jobs 4 --retry 3
101
+ - name: RSpec
102
+ run: bundle exec rspec
data/CHANGELOG.md CHANGED
@@ -1,4 +1,8 @@
1
- ## [Unreleased]
1
+ ## [0.2.0] - 2022-04-08
2
+
3
+ - Allow setting the global default TTL for the cached responses
4
+ - Allow customisation of the request key computation (so that the client can decide whether to include/exclude `Authorization` and the like)
5
+ - Extract the error response generating apps into separate modules, to make them easier to override
2
6
 
3
7
  ## [0.1.0] - 2021-10-14
4
8
 
data/README.md CHANGED
@@ -5,22 +5,6 @@ application, and the response can be cached, Idempo will provide both a concurre
5
5
  the idempotent response is already saved for this idempotency key and request fingerprint, the cached response is going to be served
6
6
  instead of calling your application.
7
7
 
8
- ## Installation
9
-
10
- Add this line to your application's Gemfile:
11
-
12
- ```ruby
13
- gem 'idempo'
14
- ```
15
-
16
- And then execute:
17
-
18
- $ bundle install
19
-
20
- Or install it yourself as:
21
-
22
- $ gem install idempo
23
-
24
8
  ## Usage
25
9
 
26
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:
@@ -32,7 +16,7 @@ use Idempo, backend: Idempo::RedisBackend.new(Rails.application.config.redis_con
32
16
  and to initialize with a memory store as backend:
33
17
 
34
18
  ```ruby
35
- use Idempo, backend: Idempo::MemoryBackend.new
19
+ use Idempo
36
20
  ```
37
21
 
38
22
  In principle, the following requests qualify to be cached used the idempotency key:
@@ -42,12 +26,86 @@ In principle, the following requests qualify to be cached used the idempotency k
42
26
 
43
27
  The default time for storing the cache is 30 seconds from the moment the request has finished generating. The response is going to be buffered, then serialized using msgpack, then deflated. Idempo will not cache the response if its size cannot be known in advance, and if the size of the response body exceeds a reasonable size (4 MB is our limit for the time being) - this is to prevent your storage from filling up with very large responses.
44
28
 
45
- ## Controlling the behavior of Idempo
29
+ ## Controlling the behavior of Idempo from your application
46
30
 
47
- You can control the behavior of Idempo using special headers:
31
+ You can control the behavior of Idempo using special response headers:
48
32
 
49
33
  * Set `X-Idempo-Policy` to `no-store` to disable retention of the response even though it otherwise could be cached
50
- * Set `X-Idempo-Persist-For-Seconds` to decimal number of seconds to store your response fo
34
+ * Set `X-Idempo-Persist-For-Seconds` to a decimal number of seconds to store your response for. If your response contains time-sensitive data you might need to tweak the storage time.
35
+
36
+ Idempo supports a number of data stores (here they are called "backends") - `MemoryBackend`, `ActiveRecordBackend`, `RedisBackend`.
37
+
38
+ ## Using memory for idempotency keys
39
+
40
+ If you run only one Puma on one server (so multiple threads but one process) the `MemoryBackend` will work fine for you.
41
+
42
+ * It uses a `Set` with a `Mutex` around it to store requests in progress
43
+ * It uses a sorted array for expiration and cached responses.
44
+
45
+ Needless to say, if your server terminates or restarts all the data goes dead with it. However
46
+
47
+ ## Using your database for idempotency keys (via ActiveRecord)
48
+
49
+ The relational database you already have is a perfectly fine place to store idempotency key locks and responses. A requirement for that is that your database supports some form of advisory locking - both PostgreSQL and MySQL do. First you will need to create a table for the records. The table is going to be called `idempo_responses`, and you need to add a migration in your Rails project for it:
50
+
51
+ ```bash
52
+ $ rails g migration add_idempo_responses
53
+ ```
54
+
55
+ and then add a migration like this:
56
+
57
+ ```ruby
58
+ class AddIdempoResponses < ActiveRecord::Migration[7.0]
59
+ def change
60
+ Idempo::ActiveRecordBackend.create_table(self)
61
+ end
62
+ end
63
+ ```
64
+
65
+ Then configure Idempo to use the backend (in your `application.rb`):
66
+
67
+ ```ruby
68
+ config.middleware.insert Idempo, backend: Idempo::ActiveRecordBackend.new
69
+ ```
70
+
71
+ 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`):
72
+
73
+ ```ruby
74
+ Idempo::ActiveRecordBackend.new.model.where('expire_at_ < ?', Time.now).delete_all
75
+ ```
76
+
77
+ ## Using Redis for idempotency keys
78
+
79
+ Redis is a near-perfect data store for idempotency keys, but it can have race conditions with locks if your application runs for too long or crashes very often. If you have Redis, initialize Idempo using the `RedisBackend`:
80
+
81
+ ```ruby
82
+ use Idempo, backend: Idempo::RedisBackend.new
83
+ ```
84
+
85
+ If you have a configured Redis connection pool (and you should) - pass it to the initializer:
86
+
87
+ ```ruby
88
+ config.middleware.insert Idempo, backend: Idempo::RedisBackend.new(config.redis_connection_pool)
89
+ ```
90
+
91
+ All data stored in Redis will have TTLs and will expire automatically.
92
+
93
+
94
+ ## Installation
95
+
96
+ Add this line to your application's Gemfile:
97
+
98
+ ```ruby
99
+ gem 'idempo'
100
+ ```
101
+
102
+ And then execute:
103
+
104
+ $ bundle install
105
+
106
+ Or install it yourself as:
107
+
108
+ $ gem install idempo
51
109
 
52
110
  ## Development
53
111
 
data/idempo.gemspec CHANGED
@@ -46,6 +46,7 @@ Gem::Specification.new do |spec|
46
46
  spec.add_development_dependency "rack-test"
47
47
  spec.add_development_dependency "activerecord"
48
48
  spec.add_development_dependency "mysql2"
49
+ spec.add_development_dependency "pg"
49
50
  spec.add_development_dependency "wetransfer_style"
50
51
 
51
52
  # For more information and examples about making a new gem, checkout our
@@ -3,7 +3,7 @@ class Idempo::ActiveRecordBackend
3
3
  def self.create_table(via_migration)
4
4
  via_migration.create_table 'idempo_responses', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci' do |t|
5
5
  t.string :idempotent_request_key, index: true, unique: true, null: false
6
- t.datetime :expire_at, index: true, null: false
6
+ t.datetime :expire_at, index: true, null: false # Needs an index for cleanup
7
7
  t.binary :idempotent_response_payload, size: :medium
8
8
  t.timestamps
9
9
  end
@@ -25,6 +25,39 @@ class Idempo::ActiveRecordBackend
25
25
  end
26
26
  end
27
27
 
28
+ class PostgresLock
29
+ def acquire(conn, based_on_str)
30
+ acquisition_result = conn.select_value('SELECT pg_try_advisory_lock(%d)' % derive_lock_key(based_on_str))
31
+ [true, 't'].include?(acquisition_result)
32
+ end
33
+
34
+ def release(conn, based_on_str)
35
+ conn.select_value('SELECT pg_advisory_unlock(%d)' % derive_lock_key(based_on_str))
36
+ end
37
+
38
+ def derive_lock_key(from_str)
39
+ # The key must be a single bigint (signed long)
40
+ hash_bytes = Digest::SHA1.digest(from_str)
41
+ hash_bytes[0...8].unpack('l_').first
42
+ end
43
+ end
44
+
45
+ class MysqlLock
46
+ def acquire(connection, based_on_str)
47
+ did_acquire = connection.select_value("SELECT GET_LOCK(%s, %d)" % [connection.quote(derive_lock_name(based_on_str)), 0])
48
+ did_acquire == 1
49
+ end
50
+
51
+ def release(connection, based_on_str)
52
+ connection.select_value("SELECT RELEASE_LOCK(%s)" % connection.quote(derive_lock_name(based_on_str)))
53
+ end
54
+
55
+ def derive_lock_name(from_str)
56
+ db_safe_key = Base64.strict_encode64(from_str)
57
+ "idempo_%s" % db_safe_key[0...57] # Note there is a limit of 64 bytes on the lock name
58
+ end
59
+ end
60
+
28
61
  def initialize
29
62
  require 'active_record'
30
63
  end
@@ -37,18 +70,27 @@ class Idempo::ActiveRecordBackend
37
70
  end
38
71
 
39
72
  def with_idempotency_key(request_key)
40
- db_safe_key = Base64.strict_encode64(request_key)
41
-
42
- lock_name = "idempo_%s" % db_safe_key[0..48]
43
- quoted_lock_name = model.connection.quote(lock_name) # Note there is a limit of 64 bytes on the lock name
44
- did_acquire = model.connection.select_value("SELECT GET_LOCK(%s, %d)" % [quoted_lock_name, 0])
73
+ db_safe_key = Digest::SHA1.base64digest(request_key)
74
+ lock = lock_implementation_for_connection(model.connection)
45
75
 
46
- raise Idempo::ConcurrentRequest unless did_acquire == 1
76
+ raise Idempo::ConcurrentRequest unless lock.acquire(model.connection, request_key)
47
77
 
48
78
  begin
49
79
  yield(Store.new(db_safe_key, model))
50
80
  ensure
51
- model.connection.select_value("SELECT RELEASE_LOCK(%s)" % quoted_lock_name)
81
+ lock.release(model.connection, request_key)
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def lock_implementation_for_connection(connection)
88
+ if connection.adapter_name =~ /^mysql2/i
89
+ MysqlLock.new
90
+ elsif connection.adapter_name =~ /^postgres/i
91
+ PostgresLock.new
92
+ else
93
+ raise "Unsupported database driver #{model.connection.adapter_name.downcase} - we don't know whether it supports advisory locks"
52
94
  end
53
95
  end
54
96
  end
@@ -0,0 +1,15 @@
1
+ require 'json'
2
+
3
+ class Idempo::ConcurrentRequestErrorApp
4
+ RETRY_AFTER_SECONDS = 2.to_s
5
+
6
+ def self.call(env)
7
+ res = {
8
+ ok: false,
9
+ error: {
10
+ message: "Another request with this idempotency key is still in progress, please try again later"
11
+ }
12
+ }
13
+ [429, {'Retry-After' => RETRY_AFTER_SECONDS, 'Content-Type' => 'application/json'}, [JSON.pretty_generate(res)]]
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ require 'json'
2
+
3
+ class Idempo::MalformedKeyErrorApp
4
+ def self.call(env)
5
+ res = {
6
+ ok: false,
7
+ error: {
8
+ message: "The Idempotency-Key header provided was empty or malformed"
9
+ }
10
+ }
11
+ [400, {'Content-Type' => 'application/json'}, [JSON.pretty_generate(res)]]
12
+ end
13
+ end
@@ -64,7 +64,7 @@ class Idempo::RedisBackend
64
64
  end
65
65
  end
66
66
 
67
- def initialize(redis_or_connection_pool)
67
+ def initialize(redis_or_connection_pool = Redis.new)
68
68
  require 'redis'
69
69
  require 'securerandom'
70
70
  @redis_pool = redis_or_connection_pool.respond_to?(:with) ? redis_or_connection_pool : NullPool.new(redis_or_connection_pool)
@@ -0,0 +1,15 @@
1
+ module Idempo::RequestFingerprint
2
+ def self.call(idempotency_key, rack_request)
3
+ d = Digest::SHA256.new
4
+ d << idempotency_key << "\n"
5
+ d << rack_request.url << "\n"
6
+ d << rack_request.request_method << "\n"
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
10
+ end
11
+ Base64.strict_encode64(d.digest)
12
+ ensure
13
+ rack_request.env['rack.input'].rewind
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Idempo
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/idempo.rb CHANGED
@@ -8,12 +8,15 @@ require 'json'
8
8
  require 'measurometer'
9
9
 
10
10
  require_relative "idempo/version"
11
+ require_relative "idempo/request_fingerprint"
11
12
  require_relative "idempo/memory_backend"
12
13
  require_relative "idempo/redis_backend"
13
14
  require_relative "idempo/active_record_backend"
15
+ require_relative "idempo/malformed_key_error_app"
16
+ require_relative "idempo/concurrent_request_error_app"
14
17
 
15
18
  class Idempo
16
- DEFAULT_TTL = 30
19
+ DEFAULT_TTL_SECONDS = 30
17
20
  SAVED_RESPONSE_BODY_SIZE_LIMIT = 4 * 1024 * 1024
18
21
 
19
22
  class Error < StandardError; end
@@ -22,9 +25,13 @@ class Idempo
22
25
 
23
26
  class MalformedIdempotencyKey < Error; end
24
27
 
25
- def initialize(app, backend: MemoryBackend.new)
28
+ def initialize(app, backend: MemoryBackend.new, malformed_key_error_app: MalformedKeyErrorApp, compute_fingerprint_via: RequestFingerprint, concurrent_request_error_app: ConcurrentRequestErrorApp, persist_for_seconds: DEFAULT_TTL_SECONDS)
26
29
  @backend = backend
27
30
  @app = app
31
+ @concurrent_request_error_app = concurrent_request_error_app
32
+ @malformed_key_error_app = malformed_key_error_app
33
+ @fingerprint_calculator = compute_fingerprint_via
34
+ @persist_for_seconds = persist_for_seconds.to_i
28
35
  end
29
36
 
30
37
  def call(env)
@@ -33,11 +40,10 @@ class Idempo
33
40
  return @app.call(env) unless idempotency_key_header = extract_idempotency_key_from(env)
34
41
 
35
42
  # The RFC requires that the Idempotency-Key header value is enclosed in quotes
36
- idempotency_key_header = unquote(idempotency_key_header)
37
- raise MalformedIdempotencyKey if idempotency_key_header == ''
43
+ idempotency_key_header_value = unquote(idempotency_key_header)
44
+ raise MalformedIdempotencyKey if idempotency_key_header_value == ''
38
45
 
39
- fingerprint = compute_request_fingerprint(req)
40
- request_key = "#{idempotency_key_header}_#{fingerprint}"
46
+ request_key = @fingerprint_calculator.call(idempotency_key_header_value, req)
41
47
 
42
48
  @backend.with_idempotency_key(request_key) do |store|
43
49
  if stored_response = store.lookup
@@ -47,8 +53,8 @@ class Idempo
47
53
 
48
54
  status, headers, body = @app.call(env)
49
55
 
56
+ expires_in_seconds = (headers.delete('X-Idempo-Persist-For-Seconds') || @persist_for_seconds).to_i
50
57
  if response_may_be_persisted?(status, headers, body)
51
- expires_in_seconds = (headers.delete('X-Idempo-Persist-For-Seconds') || DEFAULT_TTL).to_i
52
58
  # Body is replaced with a cached version since a Rack response body is not rewindable
53
59
  marshaled_response, body = serialize_response(status, headers, body)
54
60
  store.store(data: marshaled_response, ttl: expires_in_seconds)
@@ -58,22 +64,11 @@ class Idempo
58
64
  [status, headers, body]
59
65
  end
60
66
  rescue MalformedIdempotencyKey
61
- res = {
62
- ok: false,
63
- error: {
64
- message: "The Idempotency-Key header provided was empty"
65
- }
66
- }
67
- [400, {'Content-Type' => 'application/json'}, [JSON.pretty_generate(res)]]
67
+ Measurometer.increment_counter('idempo.responses_served_from', 1, from: 'malformed-idempotency-key')
68
+ @malformed_key_error_app.call(env)
68
69
  rescue ConcurrentRequest
69
70
  Measurometer.increment_counter('idempo.responses_served_from', 1, from: 'conflict-concurrent-request')
70
- res = {
71
- ok: false,
72
- error: {
73
- message: "Another request with this idempotency key is still in progress, please try again later"
74
- }
75
- }
76
- [429, {'Retry-After' => '2', 'Content-Type' => 'application/json'}, [JSON.pretty_generate(res)]]
71
+ @concurrent_request_error_app.call(env)
77
72
  end
78
73
 
79
74
  private
@@ -94,7 +89,7 @@ class Idempo
94
89
 
95
90
  # Only keep headers which are strings
96
91
  stringified_headers = headers.each_with_object({}) do |(header, value), filtered|
97
- filtered[header] = value if value.is_a?(String)
92
+ filtered[header] = value if !header.start_with?('rack.') && value.is_a?(String)
98
93
  end
99
94
 
100
95
  message_packed_str = MessagePack.pack([status, stringified_headers, body_chunks])
@@ -120,8 +115,7 @@ class Idempo
120
115
 
121
116
  return false unless body.is_a?(Array) # Arbitrary iterable of unknown size
122
117
 
123
- precomputed_body_size = body.inject(0) { |sum, chunk| sum + chunk.bytesize }
124
- precomputed_body_size <= SAVED_RESPONSE_BODY_SIZE_LIMIT
118
+ sum_of_string_bytesizes(body) <= SAVED_RESPONSE_BODY_SIZE_LIMIT
125
119
  end
126
120
 
127
121
  def status_may_be_persisted?(status)
@@ -137,19 +131,6 @@ class Idempo
137
131
  end
138
132
  end
139
133
 
140
- def compute_request_fingerprint(req)
141
- d = Digest::SHA256.new
142
- d << req.url << "\n"
143
- d << req.request_method << "\n"
144
- d << req.get_header('HTTP_AUTHORIZATION').to_s << "\n"
145
- while chunk = req.env['rack.input'].read(1024 * 65)
146
- d << chunk
147
- end
148
- Base64.strict_encode64(d.digest)
149
- ensure
150
- req.env['rack.input'].rewind
151
- end
152
-
153
134
  def extract_idempotency_key_from(env)
154
135
  env['HTTP_IDEMPOTENCY_KEY'] || env['HTTP_X_IDEMPOTENCY_KEY']
155
136
  end
@@ -158,8 +139,12 @@ class Idempo
158
139
  request.get? || request.head? || request.options?
159
140
  end
160
141
 
142
+ def sum_of_string_bytesizes(in_array)
143
+ in_array.inject(0) { |sum, chunk| sum + chunk.bytesize }
144
+ end
145
+
161
146
  def unquote(str)
162
- # Do not use regular expressions so that we don't have to thing about a catastrophic lookahead
147
+ # Do not use regular expressions so that we don't have to think about a catastrophic lookahead
163
148
  double_quote = '"'
164
149
  if str.start_with?(double_quote) && str.end_with?(double_quote)
165
150
  str[1..-2]
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: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2021-10-20 00:00:00.000000000 Z
12
+ date: 2022-04-08 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack
@@ -137,6 +137,20 @@ dependencies:
137
137
  - - ">="
138
138
  - !ruby/object:Gem::Version
139
139
  version: '0'
140
+ - !ruby/object:Gem::Dependency
141
+ name: pg
142
+ requirement: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ type: :development
148
+ prerelease: false
149
+ version_requirements: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: '0'
140
154
  - !ruby/object:Gem::Dependency
141
155
  name: wetransfer_style
142
156
  requirement: !ruby/object:Gem::Requirement
@@ -159,6 +173,7 @@ executables: []
159
173
  extensions: []
160
174
  extra_rdoc_files: []
161
175
  files:
176
+ - ".github/workflows/ci.yml"
162
177
  - ".gitignore"
163
178
  - ".rubocop.yml"
164
179
  - CHANGELOG.md
@@ -171,8 +186,11 @@ files:
171
186
  - idempo.gemspec
172
187
  - lib/idempo.rb
173
188
  - lib/idempo/active_record_backend.rb
189
+ - lib/idempo/concurrent_request_error_app.rb
190
+ - lib/idempo/malformed_key_error_app.rb
174
191
  - lib/idempo/memory_backend.rb
175
192
  - lib/idempo/redis_backend.rb
193
+ - lib/idempo/request_fingerprint.rb
176
194
  - lib/idempo/response_store.rb
177
195
  - lib/idempo/version.rb
178
196
  homepage: https://github.com/julik/idempo