diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..58e63a0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @matteoredz diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..046880f --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,20 @@ +name: Lint + +on: + pull_request: + types: + - opened + - reopened + - synchronize + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: 2.5.0 + - run: bundle install + - run: bundle exec rubocop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..29c9e15 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + branches: + - main + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: google-github-actions/release-please-action@v3 + id: release + with: + package-name: rack-idempotency_key + release-type: ruby + token: ${{secrets.GITHUB_TOKEN}} + version-file: "lib/rack/idempotency_key/version.rb" + - uses: actions/checkout@v3 + if: ${{steps.release.outputs.release_created}} + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: 2.7.0 + if: ${{steps.release.outputs.release_created}} + - run: bundle install + if: ${{steps.release.outputs.release_created}} + - run: ./bin/release + env: + RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}} + if: ${{steps.release.outputs.release_created}} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..db5ab2e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Test + +on: + pull_request: + types: + - opened + - reopened + - synchronize + +jobs: + test: + runs-on: ubuntu-latest + continue-on-error: ${{ endsWith(matrix.ruby, 'head') || matrix.ruby == 'debug' }} + strategy: + fail-fast: false + matrix: + ruby: [2.5, 2.6, 2.7, '3.0', 3.1, head] + steps: + - uses: actions/checkout@v2 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: ${{ matrix.ruby }} + - run: bundle install + - run: bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6342223 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.rbc +*.swp +/_yardoc/ +/.bundle/ +/.byebug_history +/.yardoc +/.ruby-version +/coverage/ +/doc/ +/dump.rdb +/Gemfile.lock +/pkg/ +/rack-idempotency_key-*.gem +/spec/reports/ +/tmp/ diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..b362f3b --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,51 @@ +--- +require: + - rubocop-performance + - rubocop-rake + - rubocop-rspec + +AllCops: + TargetRubyVersion: 2.5 + +Bundler/OrderedGems: + Enabled: false + +Layout/EmptyLineBetweenDefs: + Enabled: true + AllowAdjacentOneLineDefs: true + +Layout/IndentationConsistency: + Enabled: true + EnforcedStyle: indented_internal_methods + +Layout/LineLength: + Enabled: true + Max: 100 + +Metrics/AbcSize: + Enabled: false + +Metrics/BlockLength: + Enabled: true + Exclude: + - 'spec/**/*' + +Metrics/ClassLength: + Enabled: true + Exclude: + - 'spec/**/*' + +Metrics/MethodLength: + Enabled: true + CountAsOne: ['array', 'hash'] + +RSpec/MultipleMemoizedHelpers: + Max: 10 + +Style/Documentation: + Enabled: false + +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes +... diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..910bf38 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +--- +language: ruby +cache: bundler +rvm: + - 2.7.0 +before_install: gem install bundler -v 2.1.4 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..83e003f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at mttrss5@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [https://contributor-covenant.org/version/1/4][version] + +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/4/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..233d618 --- /dev/null +++ b/Gemfile @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +gem "byebug" +gem "mock_redis" +gem "rack-test" +gem "rake" +gem "rspec" +gem "rubocop" +gem "rubocop-performance" +gem "rubocop-rake" +gem "rubocop-rspec" +gem "simplecov" +gem "timecop" diff --git a/README.md b/README.md new file mode 100644 index 0000000..071efa1 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# `Rack::IdempotencyKey` + +A Rack Middleware implementing the idempotency design principle using the `Idempotency-Key` HTTP header. A cached response, generated by an idempotent request, can be recognized by checking for the presence of the `Idempotent-Replayed` response header. + +## What is idempotency? + +Idempotency is a design principle that allows a client to safely retry API requests that might have failed due to connection issues, without causing duplication or conflicts. In other words, no matter how many times you perform an idempotent operation, the end result will always be the same. + +To be idempotent, only the state of the server is considered. The response returned by each request may differ: for example, the first call of a `DELETE` will likely return a `200`, while successive ones will likely return a `404`. + +`POST`, `PATCH` and `CONNECT` are the non-idempotent methods, and this gem exists to make them so. + +## Under the hood + +- A valid idempotent request is cached on the server, using the `store` of choice +- A cached response expires out of the system after `24 hours` +- A response with a `400` (BadRequest) HTTP status code isn't cached + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem "rack-idempotency_key" +``` + +And then execute: + + $ bundle install + +Or install it yourself as: + + $ gem install rack-idempotency_key + +## General usage + +You may use this Rack Middleware in any application that conforms to the [Rack Specification](https://github.com/rack/rack/blob/main/SPEC.rdoc). Please refer to the specific application's guidelines. + +## Usage with Rails + +```ruby +# config/application.rb + +module MyApp + class Application < Rails::Application + # ... + + config.middleware.use( + Rack::IdempotencyKey, + store: Rack::IdempotencyKey::MemoryStore.new, + routes: [ + { path: "/posts", method: "POST" }, + { path: "/posts/*", method: "PATCH" } + ] + ) + end +end +``` + +## Available Stores + +The Store is responsible for getting and setting the response from a cache of a given idempotent request. + +### MemoryStore + +This one is the default store. It caches the response in memory. + +```ruby +Rack::IdempotencyKey::MemoryStore.new + +# Explicitly set the key's expiration, in seconds. The default is 86_400 (24 hours) +Rack::IdempotencyKey::MemoryStore.new(expires_in: 43_200) +``` + +### RedisStore + +This one is the suggested store to use in production. It relies on the [redis gem](https://github.com/redis/redis-rb). + +```ruby +Rack::IdempotencyKey::RedisStore.new(Redis.current) + +# Explicitly set the key's expiration, in seconds. The default is 86_400 (24 hours) +Rack::IdempotencyKey::RedisStore.new(Redis.current, expires_in: 43_200) +``` + +Every key written to Redis will get prefixed with `idempotency_key` to avoid conflicts on shared instances. + +### Custom Store + +Any object that conforms to the following interface can be used as a custom Store: + +```ruby +# @param [String] key +# +# @return [Array] +def get(key) + +# @param [String] key +# @param [Array] value +# +# @return [Array] +def set(key, value) +``` + +The Array returned must conform to the [Rack Specification](https://github.com/rack/rack/blob/main/SPEC.rdoc), as follows: + +```ruby +[ + 200, # Response code + {}, # Response headers + [] # Response body +] +``` + +## Idempotent Routes + +To declare the routes where you want to enable idempotency, you only need to pass a `route` keyword parameter when the Middleware gets mounted. + +Each route entry must be compliant with what follows: + +```ruby +routes: [ + { path: "/posts", method: "POST" }, + { path: "/posts/*", method: "PATCH" } +] +``` + +The `*` char is a placeholder representing a named parameter that will get converted to an any-chars regex. + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. +Then, run `rake test` to run the tests. +You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. +To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, +which will create a git tag for the version, push git commits and tags, +and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/matteoredz/rack-idempotency_key. +This project is intended to be a safe, welcoming space for collaboration, and contributors are expected +to adhere to the [code of conduct](https://github.com/matteoredz/rack-idempotency_key/blob/master/CODE_OF_CONDUCT.md). + +## Code of Conduct + +Everyone interacting in the `Rack::IdempotencyKey` project's codebases, issue trackers, +chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/matteoredz/rack-idempotency_key/blob/master/CODE_OF_CONDUCT.md). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..8ca97e1 --- /dev/null +++ b/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/*_test.rb"] +end + +task default: :test diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..0403148 --- /dev/null +++ b/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "rack/idempotency_key" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/release b/bin/release new file mode 100755 index 0000000..c9dee37 --- /dev/null +++ b/bin/release @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Setup gem credentials +mkdir -p ~/.gem +touch ~/.gem/credentials +chmod 0600 ~/.gem/credentials + +cat << EOF > ~/.gem/credentials +--- +:rubygems_api_key: ${RUBYGEMS_API_KEY} +EOF + +# Build and Push +gem build *.gemspec +gem push *.gem diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/idempotency_key.gemspec b/idempotency_key.gemspec new file mode 100644 index 0000000..1366b26 --- /dev/null +++ b/idempotency_key.gemspec @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "lib/rack/idempotency_key/version" + +Gem::Specification.new do |spec| + spec.name = "rack-idempotency_key" + spec.version = Rack::IdempotencyKey::VERSION + spec.licenses = ["MIT"] + spec.authors = ["Matteo Rossi"] + spec.email = ["mttrss5@gmail.com"] + spec.summary = "A Rack Middleware implementing the idempotency principle" + spec.homepage = "https://github.com/matteoredz/rack-idempotency_key" + spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0") + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/matteoredz/rack-idempotency_key" + spec.metadata["changelog_uri"] = "https://github.com/matteoredz/rack-idempotency_key/CHANGELOG.md" + + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{^(test|spec|features)/}) + end + end + + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] +end diff --git a/lib/rack/idempotency_key.rb b/lib/rack/idempotency_key.rb new file mode 100644 index 0000000..c09f640 --- /dev/null +++ b/lib/rack/idempotency_key.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rack/idempotency_key/version" + +# Stores +require "rack/idempotency_key/memory_store" +require "rack/idempotency_key/redis_store" + +# Collaborators +require "rack/idempotency_key/idempotent_request" + +module Rack + class IdempotencyKey + Error = Class.new(StandardError) + + def initialize(app, routes: [], store: MemoryStore.new) + @app = app + @routes = routes + @store = store + end + + def call(env) + request = IdempotentRequest.new(Rack::Request.new(env), routes) + return app.call(env) unless request.allowed? + + cached_response = store.get(request.idempotency_key) + + if cached_response + cached_response[1]["Idempotent-Replayed"] = true + return cached_response + end + + app.call(env).tap do |response| + store.set(request.idempotency_key, response) if response[0] != 400 + end + end + + private + + attr_reader :app, :store, :routes + end +end diff --git a/lib/rack/idempotency_key/idempotent_request.rb b/lib/rack/idempotency_key/idempotent_request.rb new file mode 100644 index 0000000..5705087 --- /dev/null +++ b/lib/rack/idempotency_key/idempotent_request.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Rack + class IdempotencyKey + class IdempotentRequest + # @param [Rack::Request] request + # @param [Array] routes + def initialize(request, routes = []) + @request = request + @routes = routes + end + + # Check if the `Idempotency-Key` header is present, if the HTTP request method is + # allowed and if there is any matching route whitelisted in the `routes` array. + # + # @return [Boolean] + def allowed? + idempotency_key? && allowed_method? && any_matching_route? + end + + # Check if the HTTP request method is non-idempotent by design. + # + # @return [Boolean] + def allowed_method? + %w[POST PATCH CONNECT].include? request.request_method + end + + # Check if there is any matching route from the `routes` input array against + # the currently requested path. + # + # @return [Boolean] + def any_matching_route? + routes.any? { |route| matching_route?(route[:path]) && matching_method?(route[:method]) } + end + + # Check if the given request has the Idempotency-Key header + # + # @return [Boolean] + def idempotency_key? + request.has_header? "HTTP_IDEMPOTENCY_KEY" + end + + # Fetches the Idempotency-Key header value from the request headers + # + # @return [String, nil] + def idempotency_key + request.get_header "HTTP_IDEMPOTENCY_KEY" + end + + private + + attr_reader :request, :routes + + def matching_route?(route_path) + same_segments? segments(route_path) + end + + def matching_method?(route_method) + request.request_method.casecmp(route_method).zero? + end + + def path_segments + @path_segments ||= segments(request.path_info) + end + + def segments(path) + path.split("/").reject(&:empty?) + end + + def same_segments?(route_segments) + path_segments.each_with_index do |path_segment, index| + route_segment = Regexp.new route_segments[index].gsub("*", '\w+'), Regexp::IGNORECASE + return false unless path_segment.match?(route_segment) + end + + true + end + end + end +end diff --git a/lib/rack/idempotency_key/memory_store.rb b/lib/rack/idempotency_key/memory_store.rb new file mode 100644 index 0000000..01e9d25 --- /dev/null +++ b/lib/rack/idempotency_key/memory_store.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Rack + class IdempotencyKey + class MemoryStore + def initialize(expires_in: 86_400) + @store = {} + @expires_in = expires_in + end + + def get(key) + value = store[key] + return if value.nil? + + if expired?(value[:added_at]) + store.delete(key) + return + end + + value[:value] + end + + def set(key, value) + store[key] ||= { value: value, added_at: Time.now.utc } + get(key) + end + + private + + attr_reader :store, :expires_in + + def expired?(added_at) + Time.now.utc - added_at > expires_in + end + end + end +end diff --git a/lib/rack/idempotency_key/redis_store.rb b/lib/rack/idempotency_key/redis_store.rb new file mode 100644 index 0000000..e81328e --- /dev/null +++ b/lib/rack/idempotency_key/redis_store.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Rack + class IdempotencyKey + class RedisStore + KEY_NAMESPACE = "idempotency_key" + + def initialize(store, expires_in: 86_400) + @store = store + @expires_in = expires_in + end + + def get(key) + value = store.get(namespaced_key(key)) + JSON.parse(value) unless value.nil? + end + + def set(key, value) + store.set(namespaced_key(key), value, nx: true, ex: expires_in) + get(key) + end + + private + + attr_reader :store, :expires_in + + def namespaced_key(key) + "#{KEY_NAMESPACE}:#{key.split.join}" + end + end + end +end diff --git a/lib/rack/idempotency_key/version.rb b/lib/rack/idempotency_key/version.rb new file mode 100644 index 0000000..f0606f5 --- /dev/null +++ b/lib/rack/idempotency_key/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Rack + class IdempotencyKey + VERSION = "0.0.0" + end +end diff --git a/spec/rack/idempotency_key/idempotent_request_spec.rb b/spec/rack/idempotency_key/idempotent_request_spec.rb new file mode 100644 index 0000000..63b66cf --- /dev/null +++ b/spec/rack/idempotency_key/idempotent_request_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "spec_helper" +require "rack/test" + +RSpec.describe Rack::IdempotencyKey::IdempotentRequest do + include Rack::Test::Methods + + subject(:idempotent_request) { described_class.new(rack_request, routes) } + + let(:rack_request) { Rack::Request.new(env) } + let(:env) { Rack::MockRequest.env_for(env_uri, env_opts) } + let(:env_uri) { "/" } + let(:env_opts) { {} } + let(:routes) { [] } + let(:idempotency_key) { "123456789" } + + describe "#allowed?" do + context "with idempotency key over an allowed method and a matching route" do + let(:env_opts) { { method: "POST" } } + let(:env_uri) { "/posts" } + let(:routes) { [{ path: "/posts", method: "POST" }] } + + before { env["HTTP_IDEMPOTENCY_KEY"] = idempotency_key } + + it { is_expected.to be_allowed } + end + + context "without the idempotency key" do + let(:env_opts) { { method: "POST" } } + let(:env_uri) { "/posts" } + let(:routes) { [{ path: "/posts", method: "POST" }] } + + it { is_expected.not_to be_allowed } + end + + context "with a not allowed request method" do + let(:env_uri) { "/posts" } + let(:routes) { [{ path: "/posts", method: "GET" }] } + + it { is_expected.not_to be_allowed } + end + + context "without a matching route" do + let(:env_opts) { { method: "POST" } } + let(:env_uri) { "/posts/1/authors" } + let(:routes) { [{ path: "/posts", method: "POST" }] } + + it { is_expected.not_to be_allowed } + end + end + + describe "#allowed_method?" do + %w[POST PATCH CONNECT].each do |request_method| + context "when #{request_method}" do + let(:env_opts) { { method: request_method } } + + it { is_expected.to be_allowed_method } + end + end + + %w[GET PUT OPTIONS].each do |request_method| + context "when #{request_method}" do + let(:env_opts) { { method: request_method } } + + it { is_expected.not_to be_allowed_method } + end + end + end + + describe "#any_matching_route?" do + context "without any declared route" do + it { is_expected.not_to be_any_matching_route } + end + + context "when the route ends with a placeholder" do + let(:env_opts) { { method: "PATCH" } } + let(:env_uri) { "/posts/1" } + let(:routes) { [{ path: "/posts/*", method: "PATCH" }] } + + it { is_expected.to be_any_matching_route } + end + + context "when the route ends with a defined character" do + let(:env_opts) { { method: "POST" } } + let(:env_uri) { "/posts" } + let(:routes) { [{ path: "/posts", method: "POST" }] } + + it { is_expected.to be_any_matching_route } + end + + context "with declared but no matching routes" do + let(:env_opts) { { method: "POST" } } + let(:env_uri) { "/authors" } + let(:routes) { [{ path: "/posts/*/authors", method: "POST" }] } + + it { is_expected.not_to be_any_matching_route } + end + end + + describe "#idempotency_key?" do + context "with the idempotency key" do + before { env["HTTP_IDEMPOTENCY_KEY"] = idempotency_key } + + it { is_expected.to be_idempotency_key } + end + + context "without the idempotency key" do + it { is_expected.not_to be_idempotency_key } + end + end + + describe "#idempotency_key" do + context "with the idempotency key" do + before { env["HTTP_IDEMPOTENCY_KEY"] = idempotency_key } + + it { expect(idempotent_request.idempotency_key).to eq(idempotency_key) } + end + + context "without the idempotency key" do + it { expect(idempotent_request.idempotency_key).to be_nil } + end + end +end diff --git a/spec/rack/idempotency_key/memory_store_spec.rb b/spec/rack/idempotency_key/memory_store_spec.rb new file mode 100644 index 0000000..0253060 --- /dev/null +++ b/spec/rack/idempotency_key/memory_store_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "securerandom" +require "spec_helper" + +RSpec.describe Rack::IdempotencyKey::MemoryStore do + subject(:store) { described_class.new } + + include_examples "describe store get and set methods" +end diff --git a/spec/rack/idempotency_key/redis_store_spec.rb b/spec/rack/idempotency_key/redis_store_spec.rb new file mode 100644 index 0000000..a06d265 --- /dev/null +++ b/spec/rack/idempotency_key/redis_store_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "mock_redis" +require "securerandom" +require "spec_helper" + +RSpec.describe Rack::IdempotencyKey::RedisStore do + subject(:store) { described_class.new(MockRedis.new) } + + include_examples "describe store get and set methods" +end diff --git a/spec/rack/idempotency_key_spec.rb b/spec/rack/idempotency_key_spec.rb new file mode 100644 index 0000000..13e79be --- /dev/null +++ b/spec/rack/idempotency_key_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "spec_helper" +require "rack/test" + +RSpec.describe Rack::IdempotencyKey do + include Rack::Test::Methods + + let(:app) { described_class.new(next_app, store: store, routes: idempotent_routes) } + let(:app_with_default_store) { described_class.new(next_app) } + let(:next_app) { ->(_env = {}) { [200, { "Content-Type" => "text/plain" }, ["OK"]] } } + let(:store) { described_class::MemoryStore.new } + let(:idempotent_routes) { [{ path: "/", method: "POST" }] } + + it "has a VERSION" do + expect(Rack::IdempotencyKey::VERSION).to be_a(String) + end + + it "has MemoryStore as default store" do + expect(app_with_default_store.send(:store)).to be_a(Rack::IdempotencyKey::MemoryStore) + end + + it "has a default empty routes array" do + expect(app_with_default_store.send(:routes)).to be_empty + end + + context "without Idempotency-Key header" do + context "with an idempotent method" do + before { get "/", {}, {} } + + it "responds with 200 HTTP status code" do + expect(last_response.status).to eq(200) + end + + it "responds with 'OK' plain text body" do + expect(last_response.body).to eq("OK") + end + end + + context "with a non-idempotent method" do + before { post "/", {}, {} } + + it "responds with 200 HTTP status code" do + expect(last_response.status).to eq(200) + end + + it "responds with 'OK' plain text body" do + expect(last_response.body).to eq("OK") + end + end + end + + context "with Idempotency-Key header and an idempotent method" do + it "passes the env to the next Middleware" do + allow(next_app).to receive(:call).with(any_args).and_return(next_app.call) + get "/", {}, { "HTTP_IDEMPOTENCY_KEY" => "123456789" } + expect(next_app).to have_received(:call) + end + + it "returns a Rack response" do + get "/", {}, { "HTTP_IDEMPOTENCY_KEY" => "123456789" } + expect(last_response).to be_a(Rack::MockResponse) + end + end + + context "with Idempotency-Key header and a non-idempotent method" do + context "with a previously cached response" do + before do + allow(store).to receive(:get).with("123456789").and_return(next_app.call) + post "/", {}, { "HTTP_IDEMPOTENCY_KEY" => "123456789" } + end + + it "returns the Idempotent-Replayed header" do + expect(last_response.headers["Idempotent-Replayed"]).to be_truthy + end + end + + context "without a previously cached response" do + it "does not return the Idempotent-Replayed header" do + post "/", {}, { "HTTP_IDEMPOTENCY_KEY" => "123456789" } + expect(last_response.headers).not_to have_key("Idempotent-Replayed") + end + + it "passes the env to the next Middleware" do + allow(next_app).to receive(:call).with(any_args).and_return(next_app.call) + post "/", {}, { "HTTP_IDEMPOTENCY_KEY" => "123456789" } + expect(next_app).to have_received(:call) + end + end + + context "when the response code is 400" do + let(:next_app) { ->(_env) { [400, { "Content-Type" => "text/plain" }, ["BadRequest"]] } } + + it "doesn't cache the response" do + allow(store).to receive(:set).with(any_args) + post "/", {}, { "HTTP_IDEMPOTENCY_KEY" => "123456789" } + expect(store).not_to have_received(:set) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..b1a9517 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "bundler/setup" + +require "byebug" +require "timecop" + +require "simplecov" +SimpleCov.start + +require "rack/idempotency_key" + +Dir["./spec/support/**/*.rb"].sort.each { |f| require f } + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + mocks.verify_doubled_constant_names = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups +end diff --git a/spec/support/shared_examples/describe_store_get_and_set_methods.rb b/spec/support/shared_examples/describe_store_get_and_set_methods.rb new file mode 100644 index 0000000..ad48f9e --- /dev/null +++ b/spec/support/shared_examples/describe_store_get_and_set_methods.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.shared_examples "describe store get and set methods" do + let(:key) { SecureRandom.uuid } + let(:value) { [204, {}, []] } + + describe "#get" do + context "with an existing key" do + before { store.set(key, value) } + + it { expect(store.get(key)).to eq(value) } + end + + context "with a non-existing key" do + it { expect(store.get(key)).to be_nil } + end + + context "with an expired key" do + let(:twenty_four_hours_from_now) { Time.now + 86_400 } + + before { store.set(key, value) } + + it "returns nil" do + Timecop.freeze(twenty_four_hours_from_now) do + expect(store.get(key)).to be_nil + end + end + end + end + + describe "#set" do + context "with a new key-value pair" do + it { expect(store.set(key, value)).to eq(value) } + end + + context "with an already existing key" do + let(:new_value) { [200, {}, ["OK"]] } + + before { store.set(key, value) } + + it { expect(store.set(key, new_value)).to eq(value) } + end + end +end