diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce82b1f..56110ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,31 +9,58 @@ on: branches: - main +env: + ruby_version: 3.3 + jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - ruby: [2.5, 2.6, 2.7, '3.0', 3.1, 3.2, 3.3, jruby-9.2, jruby-9.3, jruby-9.4] - env: [RACK2, RACK3] + ruby: [2.7, '3.0', 3.1, 3.2, 3.3, jruby-9.4] + services: + dynamodb: + image: amazon/dynamodb-local:latest + ports: + - 8000:8000 + env: + AWS_REGION: us-east-1 + AWS_ACCESS_KEY_ID: akid + AWS_SECRET_ACCESS_KEY: secret + AWS_ENDPOINT_URL_DYNAMODB: http://localhost:8000 steps: + - uses: actions/checkout@v4 + - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} + - name: Install gems + run: | + bundle config set --local with 'test' + bundle install + + - name: Tests + run: bundle exec rake test + + rubocop: + runs-on: ubuntu-latest + + steps: - uses: actions/checkout@v4 - - name: Setup environment - run: | - echo "${{ matrix.env }}=1" >> $GITHUB_ENV + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.ruby_version }} - name: Install gems run: | - bundle config set --local without 'docs' + bundle config set --local with 'development' bundle install - - name: Tests - run: bundle exec rake spec \ No newline at end of file + - name: Rubocop + run: bundle exec rake rubocop diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..af72ef6 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,38 @@ +# inherit_from: .rubocop_todo.yml + +AllCops: + Exclude: + - 'tasks/release/**/*' + - 'test/dummy/db/migrate' + NewCops: enable + SuggestExtensions: false + TargetRubyVersion: 2.7 + +Gemspec/RequireMFA: + Enabled: false + +Metrics/BlockLength: + Exclude: + - 'spec/**/*' + +Metrics/ClassLength: + Max: 150 + +Naming/AccessorMethodName: + Enabled: false + +Naming/FileName: + Exclude: + - 'lib/aws-sessionstore-dynamodb.rb' + +Security/MarshalLoad: + Exclude: + - 'lib/aws/session_store/dynamo_db/locking/base.rb' + +Style/ClassAndModuleChildren: + Enabled: false + +Style/GlobalVars: + AllowedVariables: + - $VERSION + - $REPO_ROOT diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b808ae..311a0a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ Unreleased Changes ------------------ +* Feature - Uses `rack` version 3 as the minimum. + +* Feature - Drop support for Ruby 2.5 and 2.6. + +* Feature - Support additional configuration options through ENV. + +* Feature - Moves Error classes into the Errors module. + 2.2.0 (2024-01-25) ------------------ diff --git a/Gemfile b/Gemfile index f84937e..333b206 100644 --- a/Gemfile +++ b/Gemfile @@ -1,24 +1,25 @@ -source 'https://rubygems.org' +# frozen_string_literal: true -if ENV['RACK2'] - gem 'rack', '~> 2' -end +source 'https://rubygems.org' gemspec gem 'rake', require: false +group :development do + gem 'byebug', platforms: :ruby + gem 'rubocop' +end + group :docs do gem 'yard' gem 'yard-sitemap', '~> 1.0' end group :test do - gem 'rspec' gem 'rack-test' + gem 'rails' + gem 'rexml' + gem 'rspec' gem 'simplecov' - - if RUBY_VERSION >= '3.0' - gem 'rexml' - end end diff --git a/LICENSE b/LICENSE deleted file mode 100644 index fa1f6a5..0000000 --- a/LICENSE +++ /dev/null @@ -1,12 +0,0 @@ -Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. - -Licensed under the Apache License, Version 2.0 (the "License"). You -may not use this file except in compliance with the License. A copy of -the License is located at - - http://aws.amazon.com/apache2.0/ - -or in the "license" file accompanying this file. This file is -distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -ANY KIND, either express or implied. See the License for the specific -language governing permissions and limitations under the License. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index b09703d..776f6fc 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,130 @@ # Amazon DynamoDB Session Store -The **Amazon DynamoDB Session Store** handles sessions for Ruby web applications -using a DynamoDB backend. The session store is compatible with all Rack based -frameworks. For Rails applications, use the [`aws-sdk-rails`][1] gem. +This gem handles sessions for Ruby web applications using a DynamoDB backend. +It is compatible with all Rack based frameworks including Rails. + +A DynamoDB backend provides scaling and centralized data benefits for session +storage with more ease than other containers, like local servers or cookies. +Once an application scales beyond a single web server, session data will need to +be shared across the servers. Cookie storage places all session data on the +client side, discouraging sensitive data storage. It also forces strict data size +limitations. DynamoDB takes care of these concerns by allowing for a safe and +scalable storage container with a much larger data size limit for session data. -## Installation +For more developer information, see the +[Full API documentation](https://docs.aws.amazon.com/sdk-for-ruby/aws-sessionstore-dynamodb/api/). -For Rack applications, you can create the Amazon DynamoDB table in a -Ruby file using the following method: +## Installation - require 'aws-sessionstore-dynamodb' +Add this gem to your Rack application's Gemfile: - Aws::SessionStore::DynamoDB::Table.create_table +```ruby +gem 'aws-sessionstore-dynamodb', '~> 3' +``` -Run the session store as a Rack middleware in the following way: +If you are using Rails, please include the +[aws-sdk-rails](https://github.com/aws/aws-sdk-rails) +gem for +[extra functionality](https://github.com/aws/aws-sdk-rails?tab=readme-ov-file#dynamodb-session-store), +including generators for the session table, ActionDispatch Session integration, +a garbage collection Rake task, and more: - require 'aws-sessionstore-dynamodb' - require 'some_rack_app' +```ruby +gem 'aws-sdk-rails', '~> 4' +``` - options = { :secret_key => 'SECRET_KEY' } +## Configuration - use Aws::SessionStore::DynamoDB::RackMiddleware.new(options) - run SomeRackApp +A number of options are available to be set in +`Aws::SessionStore::DynamoDB::Configuration`, which is used throughout the +application. These options can be set directly by Ruby code, through +a YAML configuration file, or Environment variables, in order of precedence. -Note that `:secret_key` is a mandatory configuration option that must be set. +The full set of options along with defaults can be found in the +[Configuration](https://docs.aws.amazon.com/sdk-for-ruby/aws-sessionstore-dynamodb/api/Aws/SessionStore/DynamoDB/Configuration.html) +documentation. -## Detailed Usage +### YAML Configuration -The session store is a Rack Middleware, meaning that it will implement the Rack -interface for dealing with HTTP request/responses. +You can create a YAML configuration file to set the options. The file must be +passed into Configuration as the `:config_file` option. -This session store uses a DynamoDB backend in order to provide scaling and -centralized data benefits for session storage with more ease than other -containers, like local servers or cookies. Once an application scales beyond -a single web server, session data will need to be shared across the servers. -DynamoDB takes care of this burden for you by scaling with your application. -Cookie storage places all session data on the client side, -discouraging sensitive data storage. It also forces strict data size -limitations. DynamoDB takes care of these concerns by allowing for a safe and -scalable storage container with a much larger data size limit for session data. +### Environment Options -For more developer information, see the [Full API documentation][2]. +All Configuration options can be loaded from the environment except for +`:dynamo_db_client` and `:error_handler`, which must be set in Ruby code +directly if needed. The environment options must be prefixed with +`DYNAMO_DB_SESSION_` and then the name of the option: -### Configuration Options + DYNAMO_DB_SESSION_ -A number of options are available to be set in -`Aws::SessionStore::DynamoDB::Configuration`, which is used by the -`RackMiddleware` class. These options can be set directly by Ruby code or -through environment variables. +The example below would be a valid way to set the session table name: -The full set of options along with defaults can be found in the -[Configuration class documentation][3]. + export DYNAMO_DB_SESSION_TABLE_NAME='your-table-name' -#### Environment Options +## Creating the session table -Certain configuration options can be loaded from the environment. These -options must be specified in the following format: +After installation and configuration, you must create the session table using +the following Ruby methods: - DYNAMO_DB_SESSION_NAME-OF-CONFIGURATION-OPTION +```ruby +options = { table_name: 'your-table-name' } # overrides from YAML or ENV +Aws::SessionStore::DynamoDB::Table.create_table(options) +Aws::SessionStore::DynamoDB::Table.delete_table(options) +``` -The example below would be a valid way to set the session table name: +## Usage - export DYNAMO_DB_SESSION_TABLE_NAME='sessions' +Run the session store as a Rack middleware in the following way: -### Garbage Collection +```ruby +require 'aws-sessionstore-dynamodb' +require 'some_rack_app' -You may want to delete old sessions from your session table. You can use the -DynamoDB [Time to Live (TTL) feature][4] on the `expire_at` attribute to -automatically delete expired items. +options = { :secret_key => 'secret' } # overrides from YAML or ENV -If you want to take other attributes into consideration for deletion, you could -instead use the `GarbageCollection` class. You can create your own Rake task for -garbage collection similar to below: +use Aws::SessionStore::DynamoDB::RackMiddleware.new(options) +run SomeRackApp +``` - require "aws-sessionstore-dynamodb" +Note that `:secret_key` is a mandatory configuration option that must be set. - desc 'Perform Garbage Collection' - task :garbage_collect do |t| - options = {:max_age => 3600*24, max_stale => 5*3600 } - Aws::SessionStore::DynamoDB::GarbageCollection.collect_garbage(options) - end +`RackMiddleware` inherits from the `Rack::Session::Abstract::Persisted` class, +which also includes additional options (such as `:key`) that can be set. -The above example will clear sessions older than one day or that have been -stale for longer than an hour. +The `RackMiddleware` inherits from the +[`Rack::Session::Abstract::Persisted`](https://rubydoc.info/github/rack/rack-session/main/Rack/Session/Abstract/Persisted) +class, which also includes additional options (such as `:key`) that can be +passed into the class. -### Locking Strategy +### Garbage Collection -You may want the Session Store to implement the provided pessimistic locking -strategy if you are concerned about concurrency issues with session accesses. -By default, locking is not implemented for the session store. You must trigger -the locking strategy through the configuration of the session store. Pessimistic -locking, in this case, means that only one read can be made on a session at -once. While the session is being read by the process with the lock, other -processes may try to obtain a lock on the same session but will be blocked. +By default sessions do not expire. You can use `:max_age` and `:max_stale` to +configure the max age or stale period of a session. -Locking is expensive and will drive up costs depending on how it is used. -Without locking, one read and one write are performed per request for session -data manipulation. If a locking strategy is implemented, as many as the total -maximum wait time divided by the lock retry delay writes to the database. -Keep these considerations in mind if you plan to enable locking. +You can use the DynamoDB +[Time to Live (TTL) feature](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html) +on the `expire_at` attribute to automatically delete expired items, saving you +the trouble of manually deleting them and reducing costs. -#### Configuration for Locking +If you wish to delete old sessions based on creation age (invalidating valid +sessions) or if you want control over the garbage collection process, you can +create your own Rake task: -The following configuration options will allow you to configure the pessimistic -locking strategy according to your needs: +```ruby +desc 'Perform Garbage Collection' +task :clean_session_table do + options = { max_age: 3600*24, max_stale: 5*3600 } # overrides from YAML or ENV + Aws::SessionStore::DynamoDB::GarbageCollection.collect_garbage(options) +end +``` - options = { - :enable_locking => true, - :lock_expiry_time => 500, - :lock_retry_delay => 500, - :lock_max_wait_time => 1 - } +The above example will clear sessions older than one day or that have been +stale for longer than an hour. ### Error Handling You can pass in your own error handler for raised exceptions or you can allow -the default error handler to them for you. See the API documentation -on the {Aws::SessionStore::DynamoDB::Errors::BaseHandler} class for more -details. - -[1]: https://github.com/aws/aws-sdk-rails/ -[2]: https://docs.aws.amazon.com/sdk-for-ruby/aws-sessionstore-dynamodb/api/ -[3]: https://docs.aws.amazon.com/sdk-for-ruby/aws-sessionstore-dynamodb/api/Aws/SessionStore/DynamoDB/Configuration.html -[4]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html +the default error handler to them for you. See the +[BaseHandler](https://docs.aws.amazon.com/sdk-for-ruby/aws-sessionstore-dynamodb/api/Aws/SessionStore/DynamoDB/Errors/BaseHandler.html) +documentation for more details on how to create your own error handler. diff --git a/Rakefile b/Rakefile index 5194827..74bdb01 100644 --- a/Rakefile +++ b/Rakefile @@ -1,35 +1,31 @@ +# frozen_string_literal: true + require 'rspec/core/rake_task' +require 'rake/testtask' +require 'rubocop/rake_task' $REPO_ROOT = File.dirname(__FILE__) $LOAD_PATH.unshift(File.join($REPO_ROOT, 'lib')) $VERSION = ENV['VERSION'] || File.read(File.join($REPO_ROOT, 'VERSION')).strip - Dir.glob('**/*.rake').each do |task_file| load task_file end -task 'test:coverage:clear' do - sh("rm -rf #{File.join($REPO_ROOT, 'coverage')}") -end - desc 'Runs unit tests' RSpec::Core::RakeTask.new do |t| - t.rspec_opts = "-I #{$REPO_ROOT}/lib -I #{$REPO_ROOT}/spec --tag ~integration" - t.pattern = "#{$REPO_ROOT}/spec" + t.rspec_opts = '--tag ~integration --format documentation' end -task :spec => 'test:coverage:clear' desc 'Runs integration tests' RSpec::Core::RakeTask.new('spec:integration') do |t| - t.rspec_opts = "-I #{$REPO_ROOT}/lib -I #{$REPO_ROOT}/spec --tag integration" - t.pattern = "#{$REPO_ROOT}/spec" + t.rspec_opts = '--tag integration --format documentation' end desc 'Runs unit and integration tests' task 'test' => [:spec, 'spec:integration'] -task :default => :spec -task 'release:test' => [:spec, 'spec:integration'] - +RuboCop::RakeTask.new +task default: :spec +task 'release:test' => [:spec, 'spec:integration'] diff --git a/VERSION b/VERSION index ccbccc3..4a36342 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.0 +3.0.0 diff --git a/aws-sessionstore-dynamodb.gemspec b/aws-sessionstore-dynamodb.gemspec index 77a2971..a8e1793 100644 --- a/aws-sessionstore-dynamodb.gemspec +++ b/aws-sessionstore-dynamodb.gemspec @@ -1,24 +1,22 @@ -version = File.read(File.expand_path('../VERSION', __FILE__)).strip +# frozen_string_literal: true + +version = File.read(File.expand_path('VERSION', __dir__)).strip Gem::Specification.new do |spec| - spec.name = "aws-sessionstore-dynamodb" + spec.name = 'aws-sessionstore-dynamodb' spec.version = version - spec.authors = ["Amazon Web Services"] - spec.email = ["aws-dr-rubygems@amazon.com"] - - spec.summary = "The Amazon DynamoDB Session Store handles sessions " + - "for Ruby web applications using a DynamoDB backend." - spec.homepage = "http://github.com/aws/aws-sessionstore-dynamodb-ruby" - spec.license = "Apache License 2.0" - - spec.files = `git ls-files`.split($/) - spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) - spec.require_paths = ["lib"] + spec.author = 'Amazon Web Services' + spec.email = ['aws-dr-rubygems@amazon.com'] + spec.summary = 'The Amazon DynamoDB Session Store handles sessions ' \ + 'for Rack web applications using a DynamoDB backend.' + spec.homepage = 'https://github.com/aws/aws-sessionstore-dynamodb-ruby' + spec.license = 'Apache-2.0' + spec.files = Dir['LICENSE.txt', 'CHANGELOG.md', 'VERSION', 'lib/**/*'] # Require 1.85.0 for user_agent_frameworks config spec.add_dependency 'aws-sdk-dynamodb', '~> 1', '>= 1.85.0' - spec.add_dependency 'rack', '>= 2', '< 4' - spec.add_dependency 'rack-session', '>= 1', '< 3' + spec.add_dependency 'rack', '~> 3' + spec.add_dependency 'rack-session', '~> 2' - spec.required_ruby_version = '>= 2.5' + spec.required_ruby_version = '>= 2.7' end diff --git a/lib/aws-sessionstore-dynamodb.rb b/lib/aws-sessionstore-dynamodb.rb index 4fa603b..ed78c83 100644 --- a/lib/aws-sessionstore-dynamodb.rb +++ b/lib/aws-sessionstore-dynamodb.rb @@ -1,19 +1,19 @@ +# frozen_string_literal: true + module Aws module SessionStore - module DynamoDB; end + # Namespace for DynamoDB rack session storage. + module DynamoDB + VERSION = File.read(File.expand_path('../VERSION', __dir__)).strip + end end end -require 'aws/session_store/dynamo_db/configuration' -require 'aws/session_store/dynamo_db/invalid_id_error' -require 'aws/session_store/dynamo_db/missing_secret_key_error' -require 'aws/session_store/dynamo_db/lock_wait_timeout_error' -require 'aws/session_store/dynamo_db/errors/base_handler' -require 'aws/session_store/dynamo_db/errors/default_handler' -require 'aws/session_store/dynamo_db/garbage_collection' -require 'aws/session_store/dynamo_db/locking/base' -require 'aws/session_store/dynamo_db/locking/null' -require 'aws/session_store/dynamo_db/locking/pessimistic' -require 'aws/session_store/dynamo_db/rack_middleware' -require 'aws/session_store/dynamo_db/table' -require 'aws/session_store/dynamo_db/version' +require_relative 'aws/session_store/dynamo_db/configuration' +require_relative 'aws/session_store/dynamo_db/errors' +require_relative 'aws/session_store/dynamo_db/garbage_collection' +require_relative 'aws/session_store/dynamo_db/locking/base' +require_relative 'aws/session_store/dynamo_db/locking/null' +require_relative 'aws/session_store/dynamo_db/locking/pessimistic' +require_relative 'aws/session_store/dynamo_db/rack_middleware' +require_relative 'aws/session_store/dynamo_db/table' diff --git a/lib/aws/session_store/dynamo_db/configuration.rb b/lib/aws/session_store/dynamo_db/configuration.rb index 99acf14..b18c39c 100644 --- a/lib/aws/session_store/dynamo_db/configuration.rb +++ b/lib/aws/session_store/dynamo_db/configuration.rb @@ -1,264 +1,179 @@ -require 'yaml' +# frozen_string_literal: true + require 'aws-sdk-dynamodb' module Aws::SessionStore::DynamoDB - # This class provides a Configuration object for all DynamoDB transactions - # by pulling configuration options from Runtime, a YAML file, the ENV and - # default settings. + # This class provides a Configuration object for all DynamoDB session store operations + # by pulling configuration options from Runtime, a YAML file, the ENV, and default + # settings, in that order. # # == Environment Variables - # The Configuration object can load default values from your environment. An example - # of setting and environment variable is below: + # The Configuration object can load default values from your environment. All configuration + # keys are supported except for `:dynamo_db_client` and `:error_handler`. The keys take the form + # of DYNAMO_DB_SESSION_. Example: # # export DYNAMO_DB_SESSION_TABLE_NAME='Sessions' + # export DYNAMO_DB_SESSION_TABLE_KEY='id' + # + # == Locking Strategy + # By default, locking is disabled for session store access. To enable locking, set the + # `:enable_locking` option to true. The locking strategy is pessimistic, meaning that only one + # read can be made on a session at once. While the session is being read by the process with the + # lock, other processes may try to obtain a lock on the same session but will be blocked. + # See the initializer for how to configure the pessimistic locking strategy to your needs. # # == Handling Errors - # There are two configurable options for error handling: :raise_errors and :error_handler. + # There are two configurable options for error handling: `:raise_errors` and `:error_handler`. # - # If you would like to use the Default Error Handler, you can decide to set :raise_errors - # to true or false depending on whether you want all errors, regadless of class, to be raised + # If you would like to use the Default Error Handler, you can decide to set `:raise_errors` + # to true or false depending on whether you want all errors, regardless of class, to be raised # up the stack and essentially throw a 500. # - # If you decide to use your own Error Handler. You may pass it in for the value of the key - # :error_handler as a cofniguration object. You must implement the BaseErrorHandler class. + # If you decide to use your own Error Handler, you must implement the `BaseErrorHandler` + # class and pass it into the `:error_handler` option. # @see BaseHandler Interface for Error Handling for DynamoDB Session Store. # - # == Locking Strategy - # By default, locking is not implemented for the session store. You must trigger the - # locking strategy through the configuration of the session store. Pessimistic locking, - # in this case, means that only one read can be made on a session at once. While the session - # is being read by the process with the lock, other processes may try to obtain a lock on - # the same session but will be blocked. See the accessors with lock in their name for - # how to configure the pessimistic locking strategy to your needs. - # # == DynamoDB Specific Options # You may configure the table name and table hash key value of your session table with - # the :table_name and :table_key options. You may also configure performance options for - # your table with the :consistent_read, :read_capacity, write_capacity. For more information - # about these configurations see CreateTable method for Amazon DynamoDB. + # the `:table_name` and `:table_key` options. You may also configure performance options for + # your table with the `:consistent_read`, `:read_capacity`, `:write_capacity`. For more information + # about these configurations see + # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#create_table-instance_method CreateTable } + # method for Amazon DynamoDB. # class Configuration - - # Default configuration options - DEFAULTS = { - :table_name => "sessions", - :table_key => "session_id", - :consistent_read => true, - :read_capacity => 10, - :write_capacity => 5, - :raise_errors => false, - # :max_age => 7*3600*24, - # :max_stale => 3600*5, - :enable_locking => false, - :lock_expiry_time => 500, - :lock_retry_delay => 500, - :lock_max_wait_time => 1, - :secret_key => nil - } - - ### Feature options - - # @return [String] Session table name. - attr_reader :table_name - - # @return [String] Session table hash key name. - attr_reader :table_key - - # @return [true] If a strongly consistent read is used - # @return [false] If an eventually consistent read is used. - # See AWS DynamoDB documentation for table consistent_read for more - # information on this setting. - attr_reader :consistent_read - - # @return [Integer] Maximum number of reads consumed per second before - # DynamoDB returns a ThrottlingException. See AWS DynamoDB documentation - # for table read_capacity for more information on this setting. - attr_reader :read_capacity - - # @return [Integer] Maximum number of writes consumed per second before - # DynamoDB returns a ThrottlingException. See AWS DynamoDB documentation - # for table write_capacity for more information on this setting. - attr_reader :write_capacity - - # @return [true] All errors are raised up the stack when default ErrorHandler - # is used. - # @return [false] Only specified errors are raised up the stack when default - # ErrorHandler is used. - attr_reader :raise_errors - - # @return [Integer] Maximum number of seconds earlier - # from the current time that a session was created. - attr_reader :max_age - - # @return [Integer] Maximum number of seconds - # before the current time that the session was last accessed. - attr_reader :max_stale - - # @return [true] Pessimistic locking strategy will be implemented for - # all session accesses. - # @return [false] No locking strategy will be implemented for - # all session accesses. - attr_reader :enable_locking - - # @return [Integer] Time in milleseconds after which lock will expire. - attr_reader :lock_expiry_time - - # @return [Integer] Time in milleseconds to wait before retrying to obtain - # lock once an attempt to obtain lock has been made and has failed. - attr_reader :lock_retry_delay - - # @return [Integer] Maximum time in seconds to wait to acquire lock - # before giving up. - attr_reader :lock_max_wait_time - - # @return [String] The secret key for HMAC encryption. - attr_reader :secret_key - - # @return [String,Pathname] - attr_reader :config_file - - ### Client and Error Handling options - - # @return [DynamoDB Client] DynamoDB client. - attr_reader :dynamo_db_client - - # @return [Error Handler] An error handling object that handles all exceptions - # thrown during execution of the AWS DynamoDB Session Store Rack Middleware. - # For more information see the Handling Errors Section. - attr_reader :error_handler + # @api private + MEMBERS = { + table_name: 'sessions', + table_key: 'session_id', + secret_key: nil, + consistent_read: true, + read_capacity: 10, + write_capacity: 5, + raise_errors: false, + error_handler: nil, + max_age: nil, + max_stale: nil, + enable_locking: false, + lock_expiry_time: 500, + lock_retry_delay: 500, + lock_max_wait_time: 1, + config_file: nil, + dynamo_db_client: nil + }.freeze # Provides configuration object that allows access to options defined - # during Runtime, in a YAML file, in the ENV and by default. + # during Runtime, in a YAML file, in the ENV, and by default. # - # @option options [String] :table_name ("Sessions") Name of the session - # table. - # @option options [String] :table_key ("id") The hash key of the sesison - # table. - # @option options [Boolean] :consistent_read (true) If true, a strongly - # consistent read is used. If false, an eventually consistent read is - # used. - # @option options [Integer] :read_capacity (10) The maximum number of - # strongly consistent reads consumed per second before - # DynamoDB raises a ThrottlingException. See AWS DynamoDB documentation - # for table read_capacity for more information on this setting. + # @option options [String] :table_name ("sessions") Name of the session table. + # @option options [String] :table_key ("session_id") The hash key of the session table. + # @option options [String] :secret_key Secret key for HMAC encryption. + # @option options [Boolean] :consistent_read (true) If true, a strongly consistent read is used. + # If false, an eventually consistent read is used. + # @see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html + # @option options [Integer] :read_capacity (10) The maximum number of strongly consistent reads + # consumed per second before DynamoDB raises a ThrottlingException. + # @see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/read-write-operations.html # @option options [Integer] :write_capacity (5) The maximum number of writes # consumed per second before DynamoDB returns a ThrottlingException. - # See AWS DynamoDB documentation for table write_capacity for more - # information on this setting. - # @option options [DynamoDB Client] :dynamo_db_client - # (Aws::DynamoDB::Client) DynamoDB client used to perform database - # operations inside of middleware application. - # @option options [Boolean] :raise_errors (false) If true, all errors are - # raised up the stack when default ErrorHandler. If false, Only specified - # errors are raised up the stack when default ErrorHandler is used. - # @option options [Error Handler] :error_handler (DefaultErrorHandler) - # An error handling object that handles all exceptions thrown during - # execution of the AWS DynamoDB Session Store Rack Middleware. - # For more information see the Handling Errors Section. + # @see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/read-write-operations.html + # @option options [Boolean] :raise_errors (false) If true, all errors are raised up the stack + # when default ErrorHandler. If false, Only specified errors are raised up the stack when + # the default ErrorHandler is used. + # @option options [#handle_error] :error_handler (Errors::DefaultHandler) An error handling object + # that handles all exceptions thrown during execution of the rack application. # @option options [Integer] :max_age (nil) Maximum number of seconds earlier # from the current time that a session was created. # @option options [Integer] :max_stale (nil) Maximum number of seconds # before current time that session was last accessed. - # @option options [String] :secret_key (nil) Secret key for HMAC encription. - # @option options [Integer] :enable_locking (false) If true, a pessimistic - # locking strategy will be implemented for all session accesses. - # If false, no locking strategy will be implemented for all session - # accesses. - # @option options [Integer] :lock_expiry_time (500) Time in milliseconds - # after which lock expires on session. - # @option options [Integer] :lock_retry_delay (500) Time in milleseconds to - # wait before retrying to obtain lock once an attempt to obtain lock - # has been made and has failed. - # @option options [Integer] :lock_max_wait_time (500) Maximum time - # in seconds to wait to acquire lock before giving up. - # @option options [String] :secret_key (SecureRandom.hex(64)) - # Secret key for HMAC encription. + # @option options [Integer] :enable_locking (false) If true, a pessimistic locking strategy will be + # used for all session accesses. + # @option options [Integer] :lock_expiry_time (500) Time in milliseconds after which the lock + # expires on session. + # @option options [Integer] :lock_retry_delay (500) Time in milliseconds to wait before retrying + # to obtain lock once an attempt to obtain the lock has been made and has failed. + # @option options [Integer] :lock_max_wait_time (500) Maximum time in seconds to wait to acquire the + # lock before giving up. + # @option options [String, Pathname] :config_file + # Path to a YAML file that contains configuration options. + # @option options [Aws::DynamoDB::Client] :dynamo_db_client (Aws::DynamoDB::Client.new) + # DynamoDB client used to perform database operations inside of the rack application. def initialize(options = {}) - @options = default_options.merge( - env_options.merge( - file_options(options).merge(symbolize_keys(options)) - ) - ) - @options = client_error.merge(@options) - set_attributes(@options) + opts = file_options(options).merge(options) + opts = env_options.merge(opts) + MEMBERS.each_pair do |opt_name, default_value| + opts[opt_name] = default_value unless opts.key?(opt_name) + end + opts = opts.merge(dynamo_db_client: default_dynamo_db_client(opts)) + opts = opts.merge(error_handler: default_error_handler(opts)) unless opts[:error_handler] + + set_attributes(opts) + end + + MEMBERS.each_key do |attr_name| + attr_reader(attr_name) end # @return [Hash] The merged configuration hash. def to_hash - @options.dup + MEMBERS.each_with_object({}) do |(key, _), hash| + hash[key] = send(key) + end end private - # @return [Hash] DDB client. - def gen_dynamo_db_client - dynamo_db_client = @options[:dynamo_db_client] || Aws::DynamoDB::Client.new - # this used to be aws-sessionstore/version on user_agent_suffix - dynamo_db_client.config.user_agent_frameworks << "aws-sessionstore-dynamodb" - {:dynamo_db_client => dynamo_db_client} + def default_dynamo_db_client(options) + dynamo_db_client = options[:dynamo_db_client] || Aws::DynamoDB::Client.new + dynamo_db_client.config.user_agent_frameworks << 'aws-sessionstore-dynamodb' + dynamo_db_client end - # @return [Hash] Default Error Handler - def gen_error_handler - default_handler = Aws::SessionStore::DynamoDB::Errors::DefaultHandler - error_handler = @options[:error_handler] || - default_handler.new(@options[:raise_errors]) - {:error_handler => error_handler} + def default_error_handler(options) + Aws::SessionStore::DynamoDB::Errors::DefaultHandler.new(options[:raise_errors]) end - # @return [Hash] Client and error objects in hash. - def client_error - gen_error_handler.merge(gen_dynamo_db_client) - end + # @return [Hash] Environment options. + def env_options + unsupported_keys = %i[dynamo_db_client error_handler] + (MEMBERS.keys - unsupported_keys).each_with_object({}) do |opt_name, opts| + key = "DYNAMO_DB_SESSION_#{opt_name.to_s.upcase}" + next unless ENV.key?(key) - # @return [Hash] Default Session table options. - def default_options - DEFAULTS + opts[opt_name] = parse_env_value(key) + end end - # @return [Hash] Environment options that are useful for Session Handler. - def env_options - default_options.keys.inject({}) do |opts, opt_name| - env_var = "DYNAMO_DB_SESSION_#{opt_name.to_s.upcase}" - opts[opt_name] = ENV[env_var] if ENV.key?(env_var) - opts + def parse_env_value(key) + Integer(ENV.fetch(key, nil)) + rescue ArgumentError + if ENV[key] == 'true' || ENV[key] == 'false' + ENV[key] == 'true' + else + ENV.fetch(key, nil) end end # @return [Hash] File options. def file_options(options = {}) - file_path = config_file_path(options) - if file_path - load_from_file(file_path) + if options[:config_file] + load_from_file(options[:config_file]) else {} end end - # Load options from YAML file + # Load options from the YAML file. def load_from_file(file_path) - require "erb" - opts = YAML.load(ERB.new(File.read(file_path)).result) || {} - symbolize_keys(opts) + require 'erb' + require 'yaml' + opts = YAML.safe_load(ERB.new(File.read(file_path)).result) || {} + opts.transform_keys(&:to_sym) end - # @return [String] Configuration path found in environment or YAML file. - def config_file_path(options) - options[:config_file] || ENV["DYNAMO_DB_SESSION_CONFIG_FILE"] - end - - # Set accessible attributes after merged options. def set_attributes(options) - @options.keys.each do |opt_name| - instance_variable_set("@#{opt_name}", options[opt_name]) - end - end - - # @return [Hash] Hash with all symbolized keys. - def symbolize_keys(options) - options.inject({}) do |opts, (opt_name, opt_value)| - opts[opt_name.to_sym] = opt_value - opts + MEMBERS.each_key do |attr_name| + instance_variable_set("@#{attr_name}", options[attr_name]) end end end diff --git a/lib/aws/session_store/dynamo_db/errors.rb b/lib/aws/session_store/dynamo_db/errors.rb new file mode 100644 index 0000000..563cf93 --- /dev/null +++ b/lib/aws/session_store/dynamo_db/errors.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Aws::SessionStore::DynamoDB::Errors + # This error is raised when no secret key is provided. + class MissingSecretKeyError < RuntimeError + def initialize(msg = 'No secret key provided!') + super + end + end + + # This error is raised when an invalid session ID is provided. + class InvalidIDError < RuntimeError + def initialize(msg = 'Corrupt Session ID!') + super + end + end + + # This error is raised when the maximum time spent to acquire lock has been exceeded. + class LockWaitTimeoutError < RuntimeError + def initialize(msg = 'Maximum time spent to acquire lock has been exceeded!') + super + end + end +end + +require_relative 'errors/base_handler' +require_relative 'errors/default_handler' diff --git a/lib/aws/session_store/dynamo_db/errors/base_handler.rb b/lib/aws/session_store/dynamo_db/errors/base_handler.rb index 9291213..7a18ef3 100644 --- a/lib/aws/session_store/dynamo_db/errors/base_handler.rb +++ b/lib/aws/session_store/dynamo_db/errors/base_handler.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Aws::SessionStore::DynamoDB::Errors # BaseErrorHandler provides an interface for error handlers # that can be passed in to {Aws::SessionStore::DynamoDB::RackMiddleware}. diff --git a/lib/aws/session_store/dynamo_db/errors/default_handler.rb b/lib/aws/session_store/dynamo_db/errors/default_handler.rb index 53e70a2..22e3d87 100644 --- a/lib/aws/session_store/dynamo_db/errors/default_handler.rb +++ b/lib/aws/session_store/dynamo_db/errors/default_handler.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Aws::SessionStore::DynamoDB::Errors # This class handles errors raised from DynamoDB. class DefaultHandler < Aws::SessionStore::DynamoDB::Errors::BaseHandler @@ -5,38 +7,37 @@ class DefaultHandler < Aws::SessionStore::DynamoDB::Errors::BaseHandler HARD_ERRORS = [ Aws::DynamoDB::Errors::ResourceNotFoundException, Aws::DynamoDB::Errors::ConditionalCheckFailedException, - Aws::SessionStore::DynamoDB::MissingSecretKeyError, - Aws::SessionStore::DynamoDB::LockWaitTimeoutError - ] + Aws::SessionStore::DynamoDB::Errors::MissingSecretKeyError, + Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError + ].freeze # Determines behavior of DefaultErrorHandler # @param [true] raise_errors Pass all errors up the Rack stack. def initialize(raise_errors) + super() @raise_errors = raise_errors end # Raises {HARD_ERRORS} up the Rack stack. # Places all other errors in Racks error stream. def handle_error(error, env = {}) - if HARD_ERRORS.include?(error.class) || @raise_errors - raise error - else - store_error(error, env) - false - end + raise error if HARD_ERRORS.include?(error.class) || @raise_errors + + store_error(error, env) + false end # Sends error to error stream def store_error(error, env = {}) - env["rack.errors"].puts(errors_string(error)) if env + env['rack.errors'].puts(errors_string(error)) if env end # Returns string to be placed in error stream def errors_string(error) str = [] str << "Exception occurred: #{error.message}" - str << "Stack trace:" - str += error.backtrace.map {|l| " " + l } + str << 'Stack trace:' + str += error.backtrace.map { |l| " #{l}" } str.join("\n") end end diff --git a/lib/aws/session_store/dynamo_db/garbage_collection.rb b/lib/aws/session_store/dynamo_db/garbage_collection.rb index e1f8845..ee5e358 100644 --- a/lib/aws/session_store/dynamo_db/garbage_collection.rb +++ b/lib/aws/session_store/dynamo_db/garbage_collection.rb @@ -1,115 +1,106 @@ +# frozen_string_literal: true + require 'aws-sdk-dynamodb' module Aws::SessionStore::DynamoDB # Collects and deletes unwanted sessions based on # their creation and update dates. module GarbageCollection - module_function - - # Scans DynamoDB session table to find - # sessions that match the max age and max stale period - # requirements. it then deletes all of the found sessions. - def collect_garbage(options = {}) - config = load_config(options) - last_key = eliminate_unwanted_sessions(config) - while !last_key.empty? - last_key = eliminate_unwanted_sessions(config, last_key) + class << self + # Scans DynamoDB session table to find sessions that match the max age and + # max stale period requirements. it then deletes all of the found sessions. + def collect_garbage(options = {}) + config = load_config(options) + last_key = eliminate_unwanted_sessions(config) + last_key = eliminate_unwanted_sessions(config, last_key) until last_key.empty? end - end - # Loads configuration options. - # @option (see Configuration#initialize) - # @api private - def load_config(options = {}) - Aws::SessionStore::DynamoDB::Configuration.new(options) - end + private - # Sets scan filter attributes based on attributes specified. - # @api private - def scan_filter(config) - hash = {} - hash['created_at'] = oldest_date(config.max_age) if config.max_age - hash['updated_at'] = oldest_date(config.max_stale) if config.max_stale - { :scan_filter => hash } - end + # Loads configuration options. + # @option (see Configuration#initialize) + def load_config(options = {}) + Aws::SessionStore::DynamoDB::Configuration.new(options) + end - # Scans and deletes batch. - # @api private - def eliminate_unwanted_sessions(config, last_key = nil) - scan_result = scan(config, last_key) - batch_delete(config, scan_result[:items]) - scan_result[:last_evaluated_key] || {} - end + # Sets scan filter attributes based on attributes specified. + def scan_filter(config) + hash = {} + hash['created_at'] = oldest_date(config.max_age) if config.max_age + hash['updated_at'] = oldest_date(config.max_stale) if config.max_stale + { scan_filter: hash } + end - # Scans the table for sessions matching the max age and - # max stale time specified. - # @api private - def scan(config, last_item = nil) - options = scan_opts(config) - options = options.merge(start_key(last_item)) if last_item - config.dynamo_db_client.scan(options) - end + # Scans and deletes batch. + def eliminate_unwanted_sessions(config, last_key = nil) + scan_result = scan(config, last_key) + batch_delete(config, scan_result[:items]) + scan_result[:last_evaluated_key] || {} + end - # Deletes the batch gotten from the scan result. - # @api private - def batch_delete(config, items) - begin - subset = items.shift(25) - sub_batch = write(subset) - process!(config, sub_batch) - end until subset.empty? - end + # Scans the table for sessions matching the max age and + # max stale time specified. + def scan(config, last_item = nil) + options = scan_opts(config) + options = options.merge(start_key(last_item)) if last_item + config.dynamo_db_client.scan(options) + end - # Turns array into correct format to be passed in to - # a delete request. - # @api private - def write(sub_batch) - sub_batch.inject([]) do |rqst_array, item| - rqst_array << {:delete_request => {:key => item}} - rqst_array + # Deletes the batch gotten from the scan result. + def batch_delete(config, items) + loop do + subset = items.shift(25) + sub_batch = write(subset) + process!(config, sub_batch) + break if subset.empty? + end end - end - # Proccesses pending request items. - # @api private - def process!(config, sub_batch) - return if sub_batch.empty? - opts = {} - opts[:request_items] = {config.table_name => sub_batch} - begin - response = config.dynamo_db_client.batch_write_item(opts) - opts[:request_items] = response[:unprocessed_items] - end until opts[:request_items].empty? - end + # Turns array into correct format to be passed in to + # a delete request. + def write(sub_batch) + sub_batch.each_with_object([]) do |item, rqst_array| + rqst_array << { delete_request: { key: item } } + end + end - # Provides scan options. - # @api private - def scan_opts(config) - table_opts(config).merge(scan_filter(config)) - end + # Processes pending request items. + def process!(config, sub_batch) + return if sub_batch.empty? - # Provides table options - # @api private - def table_opts(config) - { - :table_name => config.table_name, - :attributes_to_get => [config.table_key] - } - end + opts = { request_items: { config.table_name => sub_batch } } + loop do + response = config.dynamo_db_client.batch_write_item(opts) + opts[:request_items] = response[:unprocessed_items] + break if opts[:request_items].empty? + end + end - # @return [Hash] Hash with specified date attributes. - # @api private - def oldest_date(sec) - hash = {} - hash[:attribute_value_list] = [:n => "#{((Time.now - sec).to_f)}"] - hash[:comparison_operator] = 'LT' - hash - end + # Provides scan options. + def scan_opts(config) + table_opts(config).merge(scan_filter(config)) + end + + # Provides table options + def table_opts(config) + { + table_name: config.table_name, + attributes_to_get: [config.table_key] + } + end - # Provides start key. - # @api private - def start_key(last_item) - { :exclusive_start_key => last_item } + # Provides specified date attributes. + def oldest_date(sec) + { + attribute_value_list: [n: (Time.now - sec).to_f.to_s], + comparison_operator: 'LT' + } + end + + # Provides start key. + def start_key(last_item) + { exclusive_start_key: last_item } + end end end end diff --git a/lib/aws/session_store/dynamo_db/invalid_id_error.rb b/lib/aws/session_store/dynamo_db/invalid_id_error.rb deleted file mode 100644 index b7de751..0000000 --- a/lib/aws/session_store/dynamo_db/invalid_id_error.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Aws::SessionStore::DynamoDB - class InvalidIDError < RuntimeError - def initialize(msg = "Corrupt Session ID!") - super - end - end -end diff --git a/lib/aws/session_store/dynamo_db/lock_wait_timeout_error.rb b/lib/aws/session_store/dynamo_db/lock_wait_timeout_error.rb deleted file mode 100644 index 15ca039..0000000 --- a/lib/aws/session_store/dynamo_db/lock_wait_timeout_error.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Aws::SessionStore::DynamoDB - class LockWaitTimeoutError < RuntimeError - def initialize(msg = 'Maximum time spent to acquire lock has been exceeded!') - super - end - end -end diff --git a/lib/aws/session_store/dynamo_db/locking/base.rb b/lib/aws/session_store/dynamo_db/locking/base.rb index 6eedbac..a8c23b6 100644 --- a/lib/aws/session_store/dynamo_db/locking/base.rb +++ b/lib/aws/session_store/dynamo_db/locking/base.rb @@ -1,9 +1,9 @@ +# frozen_string_literal: true + module Aws::SessionStore::DynamoDB::Locking - # This class provides a framework for implementing - # locking strategies. + # Handles session management. class Base - - # Creates configuration object. + # @param [Aws::SessionStore::DynamoDB::Configuration] cfg def initialize(cfg) @config = cfg end @@ -11,6 +11,7 @@ def initialize(cfg) # Updates session in database def set_session_data(env, sid, session, options = {}) return false if session.empty? + packed_session = pack_data(session) handle_error(env) do save_opts = update_opts(env, sid, packed_session, options) @@ -19,12 +20,7 @@ def set_session_data(env, sid, session, options = {}) end end - # Packs session data. - def pack_data(data) - [Marshal.dump(data)].pack("m*") - end - - # Gets session data. + # Retrieves session data based on id def get_session_data(env, sid) raise NotImplementedError end @@ -36,19 +32,17 @@ def delete_session(env, sid) end end + private + # Each database operation is placed in this rescue wrapper. # This wrapper will call the method, rescue any exceptions and then pass # exceptions to the configured error handler. - def handle_error(env = nil, &block) - begin - yield - rescue Aws::DynamoDB::Errors::ServiceError => e - @config.error_handler.handle_error(e, env) - end + def handle_error(env = nil) + yield + rescue Aws::DynamoDB::Errors::ServiceError => e + @config.error_handler.handle_error(e, env) end - private - # @return [Hash] Options for deleting session. def delete_opts(sid) table_opts(sid) @@ -57,11 +51,10 @@ def delete_opts(sid) # @return [Hash] Options for updating item in Session table. def update_opts(env, sid, session, options = {}) if env['dynamo_db.new_session'] - updt_options = save_new_opts(env, sid, session) + save_new_opts(env, sid, session) else - updt_options = save_exists_opts(env, sid, session, options) + save_exists_opts(env, sid, session, options) end - updt_options end # @return [Hash] Options for saving a new session in database. @@ -78,16 +71,21 @@ def save_exists_opts(env, sid, session, options = {}) merge_all(table_opts(sid), attribute_opts) end + # Marshal the data. + def pack_data(data) + [Marshal.dump(data)].pack('m*') + end + # Unmarshal the data. def unpack_data(packed_data) - Marshal.load(packed_data.unpack("m*").first) + Marshal.load(packed_data.unpack1('m*')) end # Table options for client. def table_opts(sid) { - :table_name => @config.table_name, - :key => { @config.table_key => sid } + table_name: @config.table_name, + key: { @config.table_key => sid } } end @@ -102,12 +100,12 @@ def attr_updts(env, session, add_attrs = {}) # Update client with current time attribute. def updated_at - { :value => "#{(Time.now).to_f}", :action => "PUT" } + { value: Time.now.to_f.to_s, action: 'PUT' } end # Attribute for creation of session. def created_attr - { "created_at" => updated_at } + { 'created_at' => updated_at } end # Update client with current time + max_stale. @@ -124,35 +122,36 @@ def expire_attr # Attribute for updating session. def updated_attr { - "updated_at" => updated_at + 'updated_at' => updated_at } end def data_attr(session) - { "data" => {:value => session, :action => "PUT"} } + { 'data' => { value: session, action: 'PUT' } } end # Determine if data has been manipulated def data_unchanged?(env, session) return false unless env['rack.initial_data'] + env['rack.initial_data'] == session end # Expected attributes def expected_attributes(sid) - { :expected => { @config.table_key => {:value => sid, :exists => true} } } + { expected: { @config.table_key => { value: sid, exists: true } } } end # Attributes to be retrieved via client def attr_opts - {:attributes_to_get => ["data"], - :consistent_read => @config.consistent_read} + { attributes_to_get: ['data'], + consistent_read: @config.consistent_read } end # @return [Hash] merged hash of all hashes passed in. def merge_all(*hashes) new_hash = {} - hashes.each{|hash| new_hash.merge!(hash)} + hashes.each { |hash| new_hash.merge!(hash) } new_hash end end diff --git a/lib/aws/session_store/dynamo_db/locking/null.rb b/lib/aws/session_store/dynamo_db/locking/null.rb index 5bb04c1..5fc0aea 100644 --- a/lib/aws/session_store/dynamo_db/locking/null.rb +++ b/lib/aws/session_store/dynamo_db/locking/null.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Aws::SessionStore::DynamoDB::Locking # This class gets and sets sessions # without a locking strategy. @@ -18,9 +20,8 @@ def get_session_opts(sid) # @return [String] Session data. def extract_data(env, result = nil) - env['rack.initial_data'] = result[:item]["data"] if result[:item] - unpack_data(result[:item]["data"]) if result[:item] + env['rack.initial_data'] = result[:item]['data'] if result[:item] + unpack_data(result[:item]['data']) if result[:item] end - end end diff --git a/lib/aws/session_store/dynamo_db/locking/pessimistic.rb b/lib/aws/session_store/dynamo_db/locking/pessimistic.rb index c61e5c8..7f5c3d7 100644 --- a/lib/aws/session_store/dynamo_db/locking/pessimistic.rb +++ b/lib/aws/session_store/dynamo_db/locking/pessimistic.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Aws::SessionStore::DynamoDB::Locking # This class implements a pessimistic locking strategy for the # DynamoDB session handler. Sessions obtain an exclusive lock @@ -19,6 +21,7 @@ def get_session_data(env, sid) private # Get session with implemented locking strategy. + # rubocop:disable Metrics/MethodLength def get_session_with_lock(env, sid) expires_at = nil result = nil @@ -30,19 +33,21 @@ def get_session_with_lock(env, sid) rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException expires_at ||= get_expire_date(sid) next if expires_at.nil? + result = bust_lock(sid, expires_at) wait_to_retry(result) end end get_data(env, result) end + # rubocop:enable Metrics/MethodLength # Determine if session has waited too long to obtain lock. # # @raise [Error] When time for attempting to get lock has # been exceeded. def exceeded_wait_time?(max_attempt_date) - lock_error = Aws::SessionStore::DynamoDB::LockWaitTimeoutError + lock_error = Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError raise lock_error if Time.now.to_f > max_attempt_date end @@ -54,22 +59,22 @@ def get_lock_time_opts(sid) # @return [Time] Time stamp for which the session was locked. def lock_time(sid) result = @config.dynamo_db_client.get_item(get_lock_time_opts(sid)) - (result[:item]["locked_at"]).to_f if result[:item]["locked_at"] + (result[:item]['locked_at']).to_f if result[:item]['locked_at'] end # @return [String] Session data. def get_data(env, result) - lock_time = result[:attributes]["locked_at"] - env["locked_at"] = (lock_time).to_f - env['rack.initial_data'] = result[:item]["data"] if result.members.include? :item - unpack_data(result[:attributes]["data"]) + lock_time = result[:attributes]['locked_at'] + env['locked_at'] = lock_time.to_f + env['rack.initial_data'] = result[:item]['data'] if result.members.include? :item + unpack_data(result[:attributes]['data']) end # Attempt to bust the lock if the expiration date has expired. def bust_lock(sid, expires_at) - if expires_at < Time.now.to_f - @config.dynamo_db_client.update_item(obtain_lock_opts(sid)) - end + return unless expires_at < Time.now.to_f + + @config.dynamo_db_client.update_item(obtain_lock_opts(sid)) end # @return [Hash] Options hash for obtaining the lock. @@ -96,20 +101,20 @@ def attempt_set_lock(sid) # Lock attribute - time stamp of when session was locked. def lock_attr { - :attribute_updates => {"locked_at" => updated_at}, - :return_values => "ALL_NEW" + attribute_updates: { 'locked_at' => updated_at }, + return_values: 'ALL_NEW' } end # Time in which session was updated. def updated_at - { :value => "#{(Time.now).to_f}", :action => "PUT" } + { value: Time.now.to_f.to_s, action: 'PUT' } end # Attributes for locking. def add_lock_attrs(env) { - :add_attrs => add_attr, :expect_attr => expect_lock_time(env) + add_attrs: add_attr, expect_attr: expect_lock_time(env) } end @@ -120,25 +125,32 @@ def set_lock_options(env, options = {}) # Lock expectation. def lock_expect - { :expected => { "locked_at" => { :exists => false } } } + { expected: { 'locked_at' => { exists: false } } } end # Option to delete lock. def add_attr - { "locked_at" => {:action => "DELETE"} } + { 'locked_at' => { action: 'DELETE' } } end # Expectation of when lock was set. def expect_lock_time(env) - { :expected => {"locked_at" => { - :value => "#{env["locked_at"]}", :exists => true}} } + { + expected: { + 'locked_at' => { + value: (env['locked_at']).to_s, + exists: true + } + } + } end # Attributes to be retrieved via client def lock_opts - {:attributes_to_get => ["locked_at"], - :consistent_read => @config.consistent_read} + { + attributes_to_get: ['locked_at'], + consistent_read: @config.consistent_read + } end - end end diff --git a/lib/aws/session_store/dynamo_db/missing_secret_key_error.rb b/lib/aws/session_store/dynamo_db/missing_secret_key_error.rb deleted file mode 100644 index ed13be0..0000000 --- a/lib/aws/session_store/dynamo_db/missing_secret_key_error.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Aws::SessionStore::DynamoDB - class MissingSecretKeyError < RuntimeError - def initialize(msg = "No secret key provided!") - super - end - end -end diff --git a/lib/aws/session_store/dynamo_db/rack_middleware.rb b/lib/aws/session_store/dynamo_db/rack_middleware.rb index bad6a40..5c508d9 100644 --- a/lib/aws/session_store/dynamo_db/rack_middleware.rb +++ b/lib/aws/session_store/dynamo_db/rack_middleware.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rack/session/abstract/id' require 'openssl' require 'aws-sdk-dynamodb' @@ -6,67 +8,66 @@ module Aws::SessionStore::DynamoDB # This class is an ID based Session Store Rack Middleware # that uses a DynamoDB backend for session storage. class RackMiddleware < Rack::Session::Abstract::Persisted - # @return [Configuration] An instance of Configuration that is used for - # this middleware. - attr_reader :config - # Initializes SessionStore middleware. # # @param app Rack application. # @option (see Configuration#initialize) - # @raise [Aws::DynamoDB::Errors::ResourceNotFoundException] If valid table - # name is not provided. - # @raise [Aws::SessionStore::DynamoDB::MissingSecretKey] If secret key is - # not provided. + # @raise [Aws::DynamoDB::Errors::ResourceNotFoundException] If a valid table name is not provided. + # @raise [Aws::SessionStore::DynamoDB::MissingSecretKey] If a secret key is not provided. def initialize(app, options = {}) super @config = Configuration.new(options) set_locking_strategy end + # @return [Configuration] An instance of Configuration that is used for + # this middleware. + attr_reader :config + private - # Sets locking strategy for session handler - # - # @return [Locking::Null] If locking is not enabled. - # @return [Locking::Pessimistic] If locking is enabled. def set_locking_strategy - if @config.enable_locking - @lock = Aws::SessionStore::DynamoDB::Locking::Pessimistic.new(@config) - else - @lock = Aws::SessionStore::DynamoDB::Locking::Null.new(@config) - end + @lock = + if @config.enable_locking + Aws::SessionStore::DynamoDB::Locking::Pessimistic.new(@config) + else + Aws::SessionStore::DynamoDB::Locking::Null.new(@config) + end end - # Determines if the correct session table name is being used for - # this application. Also tests existence of secret key. - # - # @raise [Aws::DynamoDB::Errors::ResourceNotFoundException] If wrong table - # name. def validate_config - raise MissingSecretKeyError unless @config.secret_key + raise Errors::MissingSecretKeyError unless @config.secret_key end - # Gets session data. + # Get session from the database or create a new session. def find_session(req, sid) validate_config case verify_hmac(sid) when nil set_new_session_properties(req.env) when false - handle_error { raise InvalidIDError } + handle_error { raise Errors::InvalidIDError } set_new_session_properties(req.env) else - data = @lock.get_session_data(req.env, sid) - [sid, data || {}] + get_session(req, sid) end end + # Sets new session properties. def set_new_session_properties(env) env['dynamo_db.new_session'] = 'true' [generate_sid, {}] end + # Retrieves session from the database after unpacking data. + # + # @raise [Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError] If the session + # has waited too long to obtain lock. + def get_session(req, sid) + data = @lock.get_session_data(req.env, sid) + [sid, data || {}] + end + # Sets the session in the database after packing data. # # @return [Hash] If session has been saved. @@ -86,24 +87,22 @@ def delete_session(req, sid, options) # Each database operation is placed in this rescue wrapper. # This wrapper will call the method, rescue any exceptions and then pass # exceptions to the configured session handler. - def handle_error(env = nil, &block) - begin - yield - rescue Aws::DynamoDB::Errors::Base, - Aws::SessionStore::DynamoDB::InvalidIDError => e - @config.error_handler.handle_error(e, env) - end + def handle_error(env = nil) + yield + rescue Aws::DynamoDB::Errors::Base, + Aws::SessionStore::DynamoDB::Errors::InvalidIDError => e + @config.error_handler.handle_error(e, env) end # Generate HMAC hash based on MD5 def generate_hmac(sid, secret) - OpenSSL::HMAC.hexdigest(OpenSSL::Digest::MD5.new, secret, sid).strip() + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('MD5'), secret, sid).strip end # Generate sid with HMAC hash def generate_sid(secure = @sid_secure) - sid = super(secure) - sid = "#{generate_hmac(sid, @config.secret_key)}--" + sid + sid = super + "#{generate_hmac(sid, @config.secret_key)}--" + sid end # Verify digest of HMACed hash @@ -112,8 +111,10 @@ def generate_sid(secure = @sid_secure) # @return [false] If the HMAC id has been corrupted. def verify_hmac(sid) return unless sid - digest, ver_sid = sid.split("--") + + digest, ver_sid = sid.split('--') return false unless ver_sid + digest == generate_hmac(ver_sid, @config.secret_key) end end diff --git a/lib/aws/session_store/dynamo_db/table.rb b/lib/aws/session_store/dynamo_db/table.rb index 1754227..3ee013f 100644 --- a/lib/aws/session_store/dynamo_db/table.rb +++ b/lib/aws/session_store/dynamo_db/table.rb @@ -1,85 +1,82 @@ +# frozen_string_literal: true + require 'aws-sdk-dynamodb' require 'logger' module Aws::SessionStore::DynamoDB - # This class provides a way to create and delete a session table. + # This module provides a way to create and delete a session table. module Table - module_function - - # Creates a session table. - # @option (see Configuration#initialize) - def create_table(options = {}) - config = load_config(options) - ddb_options = properties(config.table_name, config.table_key).merge( - throughput(config.read_capacity, config.write_capacity) - ) - config.dynamo_db_client.create_table(ddb_options) - logger << "Table #{config.table_name} created, waiting for activation...\n" - block_until_created(config) - logger << "Table #{config.table_name} is now ready to use.\n" - rescue Aws::DynamoDB::Errors::ResourceInUseException - logger << "Table #{config.table_name} already exists, skipping creation.\n" - end + class << self + # Creates a session table. + # @option (see Configuration#initialize) + def create_table(options = {}) + config = load_config(options) + config.dynamo_db_client.create_table(create_opts(config)) + logger.info "Table #{config.table_name} created, waiting for activation..." + config.dynamo_db_client.wait_until(:table_exists, table_name: config.table_name) + logger.info "Table #{config.table_name} is now ready to use." + rescue Aws::DynamoDB::Errors::ResourceInUseException + logger.warn "Table #{config.table_name} already exists, skipping creation." + end - # Deletes a session table. - # @option (see Configuration#initialize) - def delete_table(options = {}) - config = load_config(options) - config.dynamo_db_client.delete_table(:table_name => config.table_name) - end + # Deletes a session table. + # @option (see Configuration#initialize) + def delete_table(options = {}) + config = load_config(options) + config.dynamo_db_client.delete_table(table_name: config.table_name) + config.dynamo_db_client.wait_until(:table_not_exists, table_name: config.table_name) + logger.info "Table #{config.table_name} deleted." + end - # @api private - def logger - @logger ||= Logger.new($STDOUT) - end + private - # Loads configuration options. - # @option (see Configuration#initialize) - # @api private - def load_config(options = {}) - Aws::SessionStore::DynamoDB::Configuration.new(options) - end + def logger + @logger ||= Logger.new($stdout) + end - # @return [Hash] Attribute settings for creating a session table. - # @api private - def attributes(hash_key) - attributes = [{:attribute_name => hash_key, :attribute_type => 'S'}] - { :attribute_definitions => attributes } - end + # Loads configuration options. + # @option (see Configuration#initialize) + def load_config(options = {}) + Aws::SessionStore::DynamoDB::Configuration.new(options) + end - # @return Shema values for session table - # @api private - def schema(table_name, hash_key) - { - :table_name => table_name, - :key_schema => [ {:attribute_name => hash_key, :key_type => 'HASH'} ] - } - end + def create_opts(config) + properties(config.table_name, config.table_key).merge( + throughput(config.read_capacity, config.write_capacity) + ) + end - # @return Throughput for Session table - # @api private - def throughput(read, write) - units = {:read_capacity_units=> read, :write_capacity_units => write} - { :provisioned_throughput => units } - end + # @return Properties for the session table. + def properties(table_name, hash_key) + attributes(hash_key).merge(schema(table_name, hash_key)) + end - # @return Properties for Session table - # @api private - def properties(table_name, hash_key) - attributes(hash_key).merge(schema(table_name, hash_key)) - end + # @return [Hash] Attribute settings for creating the session table. + def attributes(hash_key) + { + attribute_definitions: [ + { attribute_name: hash_key, attribute_type: 'S' } + ] + } + end - # @api private - def block_until_created(config) - created = false - until created - params = { :table_name => config.table_name } - response = config.dynamo_db_client.describe_table(params) - created = response[:table][:table_status] == 'ACTIVE' + # @return Schema values for the session table. + def schema(table_name, hash_key) + { + table_name: table_name, + key_schema: [{ attribute_name: hash_key, key_type: 'HASH' }] + } + end - sleep 10 + # @return Throughput for the session table. + def throughput(read, write) + { + provisioned_throughput: { + read_capacity_units: read, + write_capacity_units: write + } + } end end - end end diff --git a/lib/aws/session_store/dynamo_db/version.rb b/lib/aws/session_store/dynamo_db/version.rb deleted file mode 100644 index d905596..0000000 --- a/lib/aws/session_store/dynamo_db/version.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Aws - module SessionStore - module DynamoDB - VERSION = "2.0.0" - end - end -end diff --git a/spec/aws/session_store/dynamo_db/app_config.yml b/spec/aws/session_store/dynamo_db/app_config.yml deleted file mode 100644 index b1c7bbc..0000000 --- a/spec/aws/session_store/dynamo_db/app_config.yml +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). You -# may not use this file except in compliance with the License. A copy of -# the License is located at -# -# http://aws.amazon.com/apache2.0/ -# -# or in the "license" file accompanying this file. This file is -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific -# language governing permissions and limitations under the License. - -table_name: NewTable -table_key: Somekey -consistent_read: true diff --git a/spec/aws/session_store/dynamo_db/configuration_spec.rb b/spec/aws/session_store/dynamo_db/configuration_spec.rb index 770244b..b6fc155 100644 --- a/spec/aws/session_store/dynamo_db/configuration_spec.rb +++ b/spec/aws/session_store/dynamo_db/configuration_spec.rb @@ -1,81 +1,90 @@ # frozen_string_literal: true -# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). You -# may not use this file except in compliance with the License. A copy of -# the License is located at -# -# http://aws.amazon.com/apache2.0/ -# -# or in the "license" file accompanying this file. This file is -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific -# language governing permissions and limitations under the License. - require 'spec_helper' +require 'yaml' describe Aws::SessionStore::DynamoDB::Configuration do let(:defaults) do - { - table_name: 'sessions', - table_key: 'session_id', - consistent_read: true, - read_capacity: 10, - write_capacity: 5, - raise_errors: false - } + Aws::SessionStore::DynamoDB::Configuration::MEMBERS.merge( + dynamo_db_client: kind_of(Aws::DynamoDB::Client), + error_handler: kind_of(Aws::SessionStore::DynamoDB::Errors::DefaultHandler) + ) end - let(:expected_file_opts) do + let(:options) do { - consistent_read: true, - table_name: 'NewTable', - table_key: 'Somekey', + table_name: 'SessionTable', + table_key: 'SessionKey', + consistent_read: false, + read_capacity: 20, + write_capacity: 10, + raise_errors: true, + max_age: 7 * 60, + max_stale: 7, + secret_key: 'SecretKey' } end - let(:client) { Aws::DynamoDB::Client.new(stub_responses: true) } - - let(:runtime_options) do - { - table_name: 'SessionTable', - table_key: 'session_id_stuff' - } + def setup_env + options.each do |k, v| + ENV["DYNAMO_DB_SESSION_#{k.to_s.upcase}"] = v.to_s + end end - def expected_options(opts) - cfg = Aws::SessionStore::DynamoDB::Configuration.new(opts) - expected_opts = defaults.merge(expected_file_opts).merge(opts) - expect(cfg.to_hash).to include(expected_opts) + def teardown_env + options.each_key { |k| ENV.delete("DYNAMO_DB_SESSION_#{k.to_s.upcase}") } end + let(:client) { Aws::DynamoDB::Client.new(stub_responses: true) } + before do allow(Aws::DynamoDB::Client).to receive(:new).and_return(client) end - context 'Configuration Tests' do - it 'configures option with out runtime,YAML or ENV options' do - cfg = Aws::SessionStore::DynamoDB::Configuration.new - expect(cfg.to_hash).to include(defaults) - end + it 'configures defaults without runtime, YAML or ENV options' do + cfg = Aws::SessionStore::DynamoDB::Configuration.new + expect(cfg.to_hash).to include(defaults) + end - it 'configures accurate option hash with runtime options, no YAML or ENV' do - cfg = Aws::SessionStore::DynamoDB::Configuration.new(runtime_options) - expected_opts = defaults.merge(runtime_options) - expect(cfg.to_hash).to include(expected_opts) - end + it 'configures with ENV with precedence over defaults' do + setup_env + cfg = Aws::SessionStore::DynamoDB::Configuration.new + expect(cfg.to_hash).to include(options) + teardown_env + end - it 'merge YAML and runtime options giving runtime precendence' do - config_path = File.dirname(__FILE__) + '/app_config.yml' - runtime_opts = { config_file: config_path }.merge(runtime_options) - expected_options(runtime_opts) + it 'configs with YAML with precedence over ENV' do + setup_env + Tempfile.create('dynamo_db_session_store.yml') do |f| + f << options.transform_keys(&:to_s).to_yaml + f.rewind + ENV['DYNAMO_DB_SESSION_CONFIG_FILE'] = f.path + cfg = Aws::SessionStore::DynamoDB::Configuration.new + ENV.delete('DYNAMO_DB_SESSION_CONFIG_FILE') + expect(cfg.to_hash).to include(options) end + teardown_env + end - it 'throws an exception when wrong path for file' do - config_path = 'Wrong path!' - runtime_opts = { config_file: config_path }.merge(runtime_options) - expect { cfg = Aws::SessionStore::DynamoDB::Configuration.new(runtime_opts) }.to raise_error(Errno::ENOENT) + it 'configures with runtime options with full precedence' do + setup_env + Tempfile.create('dynamo_db_session_store.yml') do |f| + f << { table_name: 'OldTable', table_key: 'OldKey' }.transform_keys(&:to_s).to_yaml + f.rewind + cfg = Aws::SessionStore::DynamoDB::Configuration.new( + options.merge( + config_file: f.path + ) + ) + expect(cfg.to_hash).to include(options) end + teardown_env + end + + it 'raises an exception when wrong path for file' do + config_path = 'Wrong path!' + runtime_opts = { config_file: config_path }.merge(options) + expect { Aws::SessionStore::DynamoDB::Configuration.new(runtime_opts) } + .to raise_error(Errno::ENOENT) end end diff --git a/spec/aws/session_store/dynamo_db/error/default_error_handler_spec.rb b/spec/aws/session_store/dynamo_db/error/default_error_handler_spec.rb deleted file mode 100644 index 25ea645..0000000 --- a/spec/aws/session_store/dynamo_db/error/default_error_handler_spec.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). You -# may not use this file except in compliance with the License. A copy of -# the License is located at -# -# http://aws.amazon.com/apache2.0/ -# -# or in the "license" file accompanying this file. This file is -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific -# language governing permissions and limitations under the License. - -require 'spec_helper' - -describe Aws::SessionStore::DynamoDB do - include Rack::Test::Methods - - instance_exec(&ConstantHelpers) - - before do - @options = { dynamo_db_client: client, secret_key: 'meltingbutter' } - end - - let(:base_app) { MultiplierApplication.new } - let(:app) { Aws::SessionStore::DynamoDB::RackMiddleware.new(base_app, @options) } - let(:client) { double('Aws::DynamoDB::Client', config: double(user_agent_frameworks: [])) } - - context 'Error handling for Rack Middleware with default error handler' do - it 'raises error for missing secret key' do - allow(client).to receive(:update_item).and_raise(missing_key_error) - expect { get '/' }.to raise_error(missing_key_error) - end - - it 'catches exception for inaccurate table name and raises error ' do - allow(client).to receive(:update_item).and_raise(resource_error) - expect { get '/' }.to raise_error(resource_error) - end - - it 'catches exception for inaccurate table key' do - allow(client).to receive(:update_item).and_raise(key_error) - allow(client).to receive(:get_item).and_raise(key_error) - - get '/' - expect(last_request.env['rack.errors'].string).to include(key_error_msg) - end - end - - context 'Test ExceptionHandler with true as return value for handle_error' do - it 'raises all errors' do - @options[:raise_errors] = true - allow(client).to receive(:update_item).and_raise(client_error) - expect { get '/' }.to raise_error(client_error) - end - - it 'catches exception for inaccurate table key and raises error' do - @options[:raise_errors] = true - allow(client).to receive(:update_item).and_raise(key_error) - expect { get '/' }.to raise_error(key_error) - end - end -end diff --git a/spec/aws/session_store/dynamo_db/error/default_handler_spec.rb b/spec/aws/session_store/dynamo_db/error/default_handler_spec.rb new file mode 100644 index 0000000..f33fbf5 --- /dev/null +++ b/spec/aws/session_store/dynamo_db/error/default_handler_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Aws::SessionStore::DynamoDB do + include Rack::Test::Methods + + let(:missing_key_error) { Aws::SessionStore::DynamoDB::Errors::MissingSecretKeyError } + let(:resource_error_msg) { 'The Resource is not found' } + let(:resource_error) do + Aws::DynamoDB::Errors::ResourceNotFoundException.new(double('Seahorse::Client::RequestContext'), resource_error_msg) + end + let(:key_error_msg) { 'The provided key element does not match the schema' } + let(:key_error) do + Aws::DynamoDB::Errors::ValidationException.new(double('Seahorse::Client::RequestContext'), key_error_msg) + end + let(:client_error_msg) { 'Unrecognized Client' } + let(:client_error) do + Aws::DynamoDB::Errors::UnrecognizedClientException.new(double('Seahorse::Client::RequestContext'), client_error_msg) + end + + let(:options) do + { dynamo_db_client: client, secret_key: 'meltingbutter' } + end + + let(:base_app) { MultiplierApplication.new } + let(:app) { Aws::SessionStore::DynamoDB::RackMiddleware.new(base_app, options) } + let(:client) { Aws::DynamoDB::Client.new(stub_responses: true) } + + it 'raises error for missing secret key' do + allow(client).to receive(:update_item).and_raise(missing_key_error) + expect { get '/' }.to raise_error(missing_key_error) + end + + it 'catches exception for inaccurate table name and raises error ' do + allow(client).to receive(:update_item).and_raise(resource_error) + expect { get '/' }.to raise_error(resource_error) + end + + it 'catches exception for inaccurate table key' do + allow(client).to receive(:update_item).and_raise(key_error) + allow(client).to receive(:get_item).and_raise(key_error) + + get '/' + expect(last_request.env['rack.errors'].string).to include(key_error_msg) + end + + context 'raise_error is true' do + before do + options[:raise_errors] = true + end + + it 'raises all errors' do + allow(client).to receive(:update_item).and_raise(client_error) + expect { get '/' }.to raise_error(client_error) + end + + it 'catches exceptions for inaccurate table key and raises error' do + allow(client).to receive(:update_item).and_raise(key_error) + expect { get '/' }.to raise_error(key_error) + end + end +end diff --git a/spec/aws/session_store/dynamo_db/garbage_collection_spec.rb b/spec/aws/session_store/dynamo_db/garbage_collection_spec.rb index 32efdaf..3f147a9 100644 --- a/spec/aws/session_store/dynamo_db/garbage_collection_spec.rb +++ b/spec/aws/session_store/dynamo_db/garbage_collection_spec.rb @@ -1,18 +1,5 @@ # frozen_string_literal: true -# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). You -# may not use this file except in compliance with the License. A copy of -# the License is located at -# -# http://aws.amazon.com/apache2.0/ -# -# or in the "license" file accompanying this file. This file is -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific -# language governing permissions and limitations under the License. - require 'spec_helper' describe Aws::SessionStore::DynamoDB::GarbageCollection do @@ -41,7 +28,7 @@ def collect_garbage end let(:scan_resp1) do - resp = { + { items: items(0, 49), count: 50, scanned_count: 1000, @@ -57,102 +44,67 @@ def collect_garbage end let(:scan_resp3) do - { - items: items(31, 49), - last_evaluated_key: {} - } + { items: items(31, 49), last_evaluated_key: {} } end let(:write_resp1) do - { - unprocessed_items: {} - } + { unprocessed_items: {} } end let(:write_resp2) do { unprocessed_items: { 'sessions' => [ - { - delete_request: { - key: { - 'session_id' => - { - s: '1' - } - } - } - }, - { - delete_request: { - key: { - 'session_id' => - { - s: '17' - } - } - } - } + { delete_request: { key: { 'session_id' => { s: '1' } } } }, + { delete_request: { key: { 'session_id' => { s: '17' } } } } ] } } end - let(:dynamo_db_client) {Aws::DynamoDB::Client.new(stub_responses: true)} + let(:dynamo_db_client) { Aws::DynamoDB::Client.new(stub_responses: true) } - context 'Mock DynamoDB client with garbage collection' do - it 'processes scan result greater than 25 and deletes in batches of 25' do - expect(dynamo_db_client).to receive(:scan) - .exactly(1).times.and_return(scan_resp1) - expect(dynamo_db_client).to receive(:batch_write_item). - exactly(2).times.and_return(write_resp1) - collect_garbage - end + it 'processes scan results greater than 25 and deletes in batches of 25' do + expect(dynamo_db_client).to receive(:scan) + .exactly(1).times.and_return(scan_resp1) + expect(dynamo_db_client).to receive(:batch_write_item) + .exactly(2).times.and_return(write_resp1) + collect_garbage + end - it 'gets scan results then returns last evaluated key and resumes scanning' do - expect(dynamo_db_client).to receive(:scan). - exactly(1).times.and_return(scan_resp2) - expect(dynamo_db_client).to receive(:scan). - exactly(1).times.with(hash_including(exclusive_start_key: scan_resp2[:last_evaluated_key])). - and_return(scan_resp3) - expect(dynamo_db_client).to receive(:batch_write_item). - exactly(3).times.and_return(write_resp1) - collect_garbage - end + it 'gets scan results then returns last evaluated key and resumes scanning' do + expect(dynamo_db_client).to receive(:scan) + .exactly(1).times.and_return(scan_resp2) + expect(dynamo_db_client).to receive(:scan) + .exactly(1).times.with( + hash_including(exclusive_start_key: scan_resp2[:last_evaluated_key]) + ) + .and_return(scan_resp3) + expect(dynamo_db_client).to receive(:batch_write_item) + .exactly(3).times.and_return(write_resp1) + collect_garbage + end - it 'it formats unprocessed_items and then batch deletes them' do - expect(dynamo_db_client).to receive(:scan). - exactly(1).times.and_return(scan_resp3) - expect(dynamo_db_client).to receive(:batch_write_item).ordered. - with({request_items: { 'sessions' => format_scan_result }}). - and_return(write_resp2) - expect(dynamo_db_client).to receive(:batch_write_item).ordered.with({ - request_items: { - 'sessions' => [ - { - delete_request: { - key: { - 'session_id' => - { - s: '1' - } - } - } - }, - { - delete_request: { - key: { - 'session_id' => - { - s: '17' - } - } - } - } - ] + it 'it formats unprocessed_items and then batch deletes them' do + expect(dynamo_db_client).to receive(:scan) + .exactly(1).times.and_return(scan_resp3) + expect(dynamo_db_client).to receive(:batch_write_item) + .ordered + .with({ request_items: { 'sessions' => format_scan_result } }) + .and_return(write_resp2) + expect(dynamo_db_client).to receive(:batch_write_item) + .ordered + .with( + { + request_items: { + 'sessions' => [ + { delete_request: { key: { 'session_id' => { s: '1' } } } }, + { delete_request: { key: { 'session_id' => { s: '17' } } } } + ] + } } - }).and_return(write_resp1) - collect_garbage - end + ) + .and_return(write_resp1) + collect_garbage end end diff --git a/spec/aws/session_store/dynamo_db/locking/threaded_sessions_spec.rb b/spec/aws/session_store/dynamo_db/locking/threaded_sessions_spec.rb index bdd7c5e..657d46e 100644 --- a/spec/aws/session_store/dynamo_db/locking/threaded_sessions_spec.rb +++ b/spec/aws/session_store/dynamo_db/locking/threaded_sessions_spec.rb @@ -1,21 +1,8 @@ # frozen_string_literal: true -# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). You -# may not use this file except in compliance with the License. A copy of -# the License is located at -# -# http://aws.amazon.com/apache2.0/ -# -# or in the "license" file accompanying this file. This file is -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific -# language governing permissions and limitations under the License. - require 'spec_helper' -describe Aws::SessionStore::DynamoDB::RackMiddleware do +describe Aws::SessionStore::DynamoDB::RackMiddleware, integration: true do include Rack::Test::Methods def thread(mul_val, time, check) @@ -31,66 +18,61 @@ def thread_exception(error) end def update_item_mock(options, update_method) - if options[:return_values] == 'UPDATED_NEW' && options.key?(:expected) - sleep(0.50) - update_method.call(options) - else - update_method.call(options) - end + sleep(0.50) if options[:return_values] == 'UPDATED_NEW' && options.key?(:expected) + update_method.call(options) end let(:base_app) { MultiplierApplication.new } let(:app) { Aws::SessionStore::DynamoDB::RackMiddleware.new(base_app, @options) } - context 'Mock Multiple Threaded Sessions', integration: true do - before do - @options = Aws::SessionStore::DynamoDB::Configuration.new.to_hash - @options[:enable_locking] = true - @options[:secret_key] = 'watermelon_smiles' + before do + @options = Aws::SessionStore::DynamoDB::Configuration.new.to_hash + @options[:table_name] = 'sessionstore-integration-test' + @options[:enable_locking] = true + @options[:secret_key] = 'watermelon_smiles' - update_method = @options[:dynamo_db_client].method(:update_item) - expect(@options[:dynamo_db_client]).to receive(:update_item).at_least(:once) do |options| - update_item_mock(options, update_method) - end + update_method = @options[:dynamo_db_client].method(:update_item) + expect(@options[:dynamo_db_client]).to receive(:update_item).at_least(:once) do |options| + update_item_mock(options, update_method) end + end - it 'should wait for lock' do - @options[:lock_expiry_time] = 2000 + it 'should wait for lock' do + @options[:lock_expiry_time] = 2000 - get '/' - expect(last_request.session[:multiplier]).to eq(1) + get '/' + expect(last_request.session[:multiplier]).to eq(1) - t1 = thread(2, 0, false) - t2 = thread(4, 0.25, true) - t1.join - t2.join - end + t1 = thread(2, 0, false) + t2 = thread(4, 0.25, true) + t1.join + t2.join + end - it 'should bust lock' do - @options[:lock_expiry_time] = 100 + it 'should bust lock' do + @options[:lock_expiry_time] = 100 - get '/' - expect(last_request.session[:multiplier]).to eq(1) + get '/' + expect(last_request.session[:multiplier]).to eq(1) - t1 = thread_exception(Aws::DynamoDB::Errors::ConditionalCheckFailedException) - t2 = thread(2, 0.25, true) - t1.join - t2.join - end + t1 = thread_exception(Aws::DynamoDB::Errors::ConditionalCheckFailedException) + t2 = thread(2, 0.25, true) + t1.join + t2.join + end - it 'should throw exceeded time spent aquiring lock error' do - @options[:lock_expiry_time] = 1000 - @options[:lock_retry_delay] = 100 - @options[:lock_max_wait_time] = 0.25 + it 'should throw exceeded time spent aquiring lock error' do + @options[:lock_expiry_time] = 1000 + @options[:lock_retry_delay] = 100 + @options[:lock_max_wait_time] = 0.25 - get '/' - expect(last_request.session[:multiplier]).to eq(1) + get '/' + expect(last_request.session[:multiplier]).to eq(1) - t1 = thread(2, 0, false) - sleep(0.25) - t2 = thread_exception(Aws::SessionStore::DynamoDB::LockWaitTimeoutError) - t1.join - t2.join - end + t1 = thread(2, 0, false) + sleep(0.25) + t2 = thread_exception(Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError) + t1.join + t2.join end end diff --git a/spec/aws/session_store/dynamo_db/rack_middleware_database_spec.rb b/spec/aws/session_store/dynamo_db/rack_middleware_database_spec.rb deleted file mode 100644 index 6737c60..0000000 --- a/spec/aws/session_store/dynamo_db/rack_middleware_database_spec.rb +++ /dev/null @@ -1,130 +0,0 @@ -# frozen_string_literal: true - -# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). You -# may not use this file except in compliance with the License. A copy of -# the License is located at -# -# http://aws.amazon.com/apache2.0/ -# -# or in the "license" file accompanying this file. This file is -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific -# language governing permissions and limitations under the License. - -require 'spec_helper' - -module Aws - module SessionStore - module DynamoDB - describe RackMiddleware do - include Rack::Test::Methods - - instance_exec(&ConstantHelpers) - - before do - @options = { secret_key: 'watermelon_cherries' } - end - - # Table options for client - def table_opts(sid) - { - table_name: Configuration::DEFAULTS[:table_name], - key: { Configuration::DEFAULTS[:table_key] => sid } - } - end - - # Attributes to be retrieved via client - def attr_opts - { - attributes_to_get: %w[data created_at locked_at], - consistent_read: true - } - end - - def extract_time(sid) - options = table_opts(sid).merge(attr_opts) - Time.at((client.get_item(options)[:item]['created_at']).to_f) - end - - let(:base_app) { MultiplierApplication.new } - let(:app) { RackMiddleware.new(base_app, @options) } - let(:config) { Configuration.new } - let(:client) { config.dynamo_db_client } - - context 'Testing best case session storage', integration: true do - it 'stores session data in session object' do - get '/' - expect(last_request.session[:multiplier]).to eq(1) - end - - it 'creates a new HTTP cookie when Cookie not supplied' do - get '/' - expect(last_response.body).to eq('All good!') - expect(last_response['Set-Cookie']).to be_truthy - end - - it 'does not rewrite Cookie if cookie previously/accuarately set' do - get '/' - expect(last_response['Set-Cookie']).not_to be_nil - - get '/' - expect(last_response['Set-Cookie']).to be_nil - end - - it 'does not set cookie when defer option is specified' do - @options[:defer] = true - get '/' - expect(last_response['Set-Cookie']).to be_nil - end - - it 'creates new session with false/nonexistant http-cookie id' do - get '/', {}, invalid_cookie.merge(invalid_session_data) - expect(last_response['Set-Cookie']).not_to eq('rack.session=ApplePieBlueberries') - expect(last_response['Set-Cookie']).not_to be_nil - end - - it 'expires after specified time and sets date for cookie to expire' do - @options[:expire_after] = 1 - get '/' - session_cookie = last_response['Set-Cookie'] - sleep(1.2) - - get '/' - expect(last_response['Set-Cookie']).not_to be_nil - expect(last_response['Set-Cookie']).not_to eq(session_cookie) - end - - it 'will not set a session cookie when defer is true' do - @options[:defer] = true - get '/' - expect(last_response['Set-Cookie']).to be_nil - end - - it 'adds the created at attribute for a new session' do - get '/' - expect(last_request.env['dynamo_db.new_session']).to eq('true') - sid = last_response['Set-Cookie'].split(/[;\=]/)[1] - time = extract_time(sid) - expect(time).to be_within(2).of(Time.now) - - get '/' - expect(last_request.env['dynamo_db.new_session']).to be_nil - end - - it 'releases pessimistic lock at finish of transaction' do - @options[:enable_locking] = true - get '/' - expect(last_request.env['dynamo_db.new_session']).to eq('true') - sid = last_response['Set-Cookie'].split(/[;\=]/)[1] - - get '/' - options = table_opts(sid).merge(attr_opts) - expect(client.get_item(options)[:item]['locked_at']).to be_nil - end - end - end - end - end -end diff --git a/spec/aws/session_store/dynamo_db/rack_middleware_integration_spec.rb b/spec/aws/session_store/dynamo_db/rack_middleware_integration_spec.rb new file mode 100644 index 0000000..6fbbced --- /dev/null +++ b/spec/aws/session_store/dynamo_db/rack_middleware_integration_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Aws + module SessionStore + module DynamoDB + describe RackMiddleware, integration: true do + include Rack::Test::Methods + + def table_opts(sid) + { + table_name: config.table_name, + key: { config.table_key => sid } + } + end + + def attr_opts + { + attributes_to_get: %w[data created_at], + consistent_read: true + } + end + + def extract_time(sid) + options = table_opts(sid).merge(attr_opts) + Time.at((config.dynamo_db_client.get_item(options)[:item]['created_at']).to_f) + end + + let(:options) do + { table_name: 'sessionstore-integration-test', secret_key: 'watermelon_cherries' } + end + + let(:base_app) { MultiplierApplication.new } + let(:app) { RackMiddleware.new(base_app, options) } + let(:config) { app.config } + + it 'stores session data in session object' do + get '/' + expect(last_request.session[:multiplier]).to eq(1) + end + + it 'creates a new HTTP cookie when Cookie not supplied' do + get '/' + expect(last_response.body).to eq('All good!') + expect(last_response['Set-Cookie']).to be_truthy + end + + it 'does not rewrite Cookie if cookie previously/accuarately set' do + get '/' + expect(last_response['Set-Cookie']).not_to be_nil + + get '/' + expect(last_response['Set-Cookie']).to be_nil + end + + it 'does not set cookie when defer option is specified' do + options[:defer] = true + get '/' + expect(last_response['Set-Cookie']).to be_nil + end + + it 'creates new session with false/nonexistant http-cookie id' do + env = { + 'HTTP_COOKIE' => 'rack.session=ApplePieBlueberries', + 'rack.session' => { 'multiplier' => 1 } + } + get '/', {}, env + expect(last_response['Set-Cookie']).not_to eq('rack.session=ApplePieBlueberries') + expect(last_response['Set-Cookie']).not_to be_nil + end + + it 'expires after specified time and sets date for cookie to expire' do + options[:expire_after] = 1 + get '/' + session_cookie = last_response['Set-Cookie'] + sleep(1.2) + + get '/' + expect(last_response['Set-Cookie']).not_to be_nil + expect(last_response['Set-Cookie']).not_to eq(session_cookie) + end + + it 'will not set a session cookie when defer is true' do + options[:defer] = true + get '/' + expect(last_response['Set-Cookie']).to be_nil + end + + it 'adds the created at attribute for a new session' do + get '/' + expect(last_request.env['dynamo_db.new_session']).to eq('true') + sid = last_response['Set-Cookie'].split(/[;=]/)[1] + time = extract_time(sid) + expect(time).to be_within(2).of(Time.now) + + get '/' + expect(last_request.env['dynamo_db.new_session']).to be_nil + end + end + end + end +end diff --git a/spec/aws/session_store/dynamo_db/rack_middleware_spec.rb b/spec/aws/session_store/dynamo_db/rack_middleware_spec.rb index 1eb2c40..6f765d0 100644 --- a/spec/aws/session_store/dynamo_db/rack_middleware_spec.rb +++ b/spec/aws/session_store/dynamo_db/rack_middleware_spec.rb @@ -1,18 +1,5 @@ # frozen_string_literal: true -# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). You -# may not use this file except in compliance with the License. A copy of -# the License is located at -# -# http://aws.amazon.com/apache2.0/ -# -# or in the "license" file accompanying this file. This file is -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific -# language governing permissions and limitations under the License. - require 'spec_helper' module Aws @@ -21,8 +8,6 @@ module DynamoDB describe RackMiddleware do include Rack::Test::Methods - before { @options = {} } - def ensure_data_updated(mutated_data) expect(dynamo_db_client).to receive(:update_item) do |options| if mutated_data @@ -33,114 +18,111 @@ def ensure_data_updated(mutated_data) end end - before do - @options = { + let(:options) do + { dynamo_db_client: dynamo_db_client, secret_key: 'watermelon_cherries' } end let(:base_app) { MultiplierApplication.new } - let(:app) { RackMiddleware.new(base_app, @options) } + let(:app) { RackMiddleware.new(base_app, options) } let(:sample_packed_data) do [Marshal.dump('multiplier' => 1)].pack('m*') end let(:dynamo_db_client) do - double( - 'Aws::DynamoDB::Client', - delete_item: 'Deleted', - list_tables: { table_names: ['Sessions'] }, - get_item: { item: { 'data' => sample_packed_data } }, - update_item: { attributes: { created_at: 'now' } }, - config: double(user_agent_frameworks: []) + Aws::DynamoDB::Client.new( + stub_responses: { + list_tables: { table_names: ['Sessions'] }, + get_item: { item: { 'data' => sample_packed_data } }, + update_item: { attributes: { 'created_at' => 'now' } } + } ) end - context 'Testing best case session storage with mock client' do - it 'stores session data in session object' do - get '/' - expect(last_request.session.to_hash).to eq('multiplier' => 1) - end + it 'stores session data in session object' do + get '/' + expect(last_request.session.to_hash).to eq('multiplier' => 1) + end - it 'creates a new HTTP cookie when Cookie not supplied' do - get '/' - expect(last_response.body).to eq('All good!') - expect(last_response['Set-Cookie']).to be_truthy - end + it 'creates a new HTTP cookie when Cookie not supplied' do + get '/' + expect(last_response.body).to eq('All good!') + expect(last_response['Set-Cookie']).to be_truthy + end - it 'loads/manipulates a session based on id from HTTP-Cookie' do - get '/' - expect(last_request.session.to_hash).to eq('multiplier' => 1) + it 'loads/manipulates a session based on id from HTTP-Cookie' do + get '/' + expect(last_request.session.to_hash).to eq('multiplier' => 1) - get '/' - expect(last_request.session.to_hash).to eq('multiplier' => 2) - end + get '/' + expect(last_request.session.to_hash).to eq('multiplier' => 2) + end - it 'does not rewrite Cookie if cookie previously/accuarately set' do - get '/' - expect(last_response['Set-Cookie']).not_to be_nil + it 'does not rewrite Cookie if cookie previously/accuarately set' do + get '/' + expect(last_response['Set-Cookie']).not_to be_nil - get '/' - expect(last_response['Set-Cookie']).to be_nil - end + get '/' + expect(last_response['Set-Cookie']).to be_nil + end - it 'does not set cookie when defer option is specifed' do - @options[:defer] = true - get '/' - expect(last_response['Set-Cookie']).to be_nil - end + it 'does not set cookie when defer option is specifed' do + options[:defer] = true + get '/' + expect(last_response['Set-Cookie']).to be_nil + end - it 'creates new session with false/nonexistant http-cookie id' do - get '/' - expect(last_response['Set-Cookie']).not_to eq('1234') - expect(last_response['Set-Cookie']).not_to be_nil - end + it 'creates new session with false/nonexistant http-cookie id' do + get '/' + expect(last_response['Set-Cookie']).not_to eq('1234') + expect(last_response['Set-Cookie']).not_to be_nil + end - it 'expires after specified time and sets date for cookie to expire' do - @options[:expire_after] = 0 - get '/' - session_cookie = last_response['Set-Cookie'] + it 'expires after specified time and sets date for cookie to expire' do + options[:expire_after] = 0 + get '/' + session_cookie = last_response['Set-Cookie'] - get '/' - expect(last_response['Set-Cookie']).not_to be_nil - expect(last_response['Set-Cookie']).not_to eq(session_cookie) - end + get '/' + expect(last_response['Set-Cookie']).not_to be_nil + expect(last_response['Set-Cookie']).not_to eq(session_cookie) + end - it "doesn't reset Cookie if not outside expire date" do - @options[:expire_after] = 3600 - get '/' - session_cookie = last_response['Set-Cookie'] - get '/' - expect(last_response['Set-Cookie']).to eq(session_cookie) - end + it "doesn't reset Cookie if not outside expire date" do + options[:expire_after] = 3600 + get '/' + session_cookie = last_response['Set-Cookie'] + get '/' + expect(last_response['Set-Cookie']).to eq(session_cookie) + end - it 'will not set a session cookie when defer is true' do - @options[:defer] = true - get '/' - expect(last_response['Set-Cookie']).to be_nil - end + it 'will not set a session cookie when defer is true' do + options[:defer] = true + get '/' + expect(last_response['Set-Cookie']).to be_nil + end - it 'generates sid and migrates data to new sid when renew is selected' do - @options[:renew] = true - get '/' - expect(last_request.session.to_hash).to eq('multiplier' => 1) - session_cookie = last_response['Set-Cookie'] + it 'generates sid and migrates data to new sid when renew is selected' do + options[:renew] = true + get '/' + expect(last_request.session.to_hash).to eq('multiplier' => 1) + session_cookie = last_response['Set-Cookie'] - get '/', 'HTTP_Cookie' => session_cookie - expect(last_response['Set-Cookie']).not_to eq(session_cookie) - expect(last_request.session.to_hash).to eq('multiplier' => 2) - end + get '/', 'HTTP_Cookie' => session_cookie + expect(last_response['Set-Cookie']).not_to eq(session_cookie) + expect(last_request.session.to_hash).to eq('multiplier' => 2) + end - it "doesn't resend unmutated data" do - ensure_data_updated(true) - @options[:renew] = true - get '/' + it "doesn't resend unmutated data" do + ensure_data_updated(true) + options[:renew] = true + get '/' - ensure_data_updated(false) - get '/', {}, { 'rack.session' => { 'multiplier' => nil } } - end + ensure_data_updated(false) + get '/', {}, { 'rack.session' => { 'multiplier' => nil } } end end end diff --git a/spec/aws/session_store/dynamo_db/table_spec.rb b/spec/aws/session_store/dynamo_db/table_spec.rb index 429ad63..1eb454b 100644 --- a/spec/aws/session_store/dynamo_db/table_spec.rb +++ b/spec/aws/session_store/dynamo_db/table_spec.rb @@ -1,48 +1,30 @@ # frozen_string_literal: true -# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). You -# may not use this file except in compliance with the License. A copy of -# the License is located at -# -# http://aws.amazon.com/apache2.0/ -# -# or in the "license" file accompanying this file. This file is -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific -# language governing permissions and limitations under the License. - require 'spec_helper' -require 'stringio' -require 'logger' - -module Aws - module SessionStore - module DynamoDB - describe Table do - context 'Mock Table Methods Tests', integration: true do - let(:table_name) { "sessionstore-integration-test-#{Time.now.to_i}" } - let(:options) { { table_name: table_name } } - let(:io) { StringIO.new } - - before { allow(Table).to receive(:logger) { Logger.new(io) } } - - it 'Creates and deletes a new table' do - Table.create_table(options) - - # second attempt should warn - Table.create_table(options) - - expect(io.string).to include("Table #{table_name} created, waiting for activation...\n") - expect(io.string).to include("Table #{table_name} is now ready to use.\n") - expect(io.string).to include("Table #{table_name} already exists, skipping creation.\n") - # now delete table - Table.delete_table(options) - end - end - end +module Aws::SessionStore::DynamoDB + describe Table, integration: true do + let(:table_name) { "sessionstore-integration-test-#{Time.now.to_i}" } + let(:options) { { table_name: table_name } } + let(:logger) { Logger.new(IO::NULL) } + + before { allow(Table).to receive(:logger).and_return(logger) } + + it 'Creates and deletes a new table' do + expect(logger).to receive(:info) + .with("Table #{table_name} created, waiting for activation...") + expect(logger).to receive(:info) + .with("Table #{table_name} is now ready to use.") + Table.create_table(options) + + # second attempt should warn + expect(logger).to receive(:warn) + .with("Table #{table_name} already exists, skipping creation.") + Table.create_table(options) + + expect(logger).to receive(:info) + .with("Table #{table_name} deleted.") + Table.delete_table(options) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 18e3e6e..b466afd 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,26 +1,13 @@ # frozen_string_literal: true -# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). You -# may not use this file except in compliance with the License. A copy of -# the License is located at -# -# http://aws.amazon.com/apache2.0/ -# -# or in the "license" file accompanying this file. This file is -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific -# language governing permissions and limitations under the License. - require 'simplecov' SimpleCov.start { add_filter 'spec' } $LOAD_PATH << File.join(File.dirname(File.dirname(__FILE__)), 'lib') +require 'rack/test' require 'rspec' require 'aws-sessionstore-dynamodb' -require 'rack/test' # Default Rack application class MultiplierApplication @@ -34,32 +21,9 @@ def call(env) end end -ConstantHelpers = lambda do - let(:token_error_msg) { 'The security token included in the request is invalid' } - let(:resource_error) do - Aws::DynamoDB::Errors::ResourceNotFoundException.new(double('Seahorse::Client::RequestContext'), resource_error_msg) - end - let(:resource_error_msg) { 'The Resource is not found.' } - let(:key_error) { Aws::DynamoDB::Errors::ValidationException.new(double('Seahorse::Client::RequestContext'), key_error_msg) } - let(:key_error_msg) { 'The provided key element does not match the schema' } - let(:client_error) do - Aws::DynamoDB::Errors::UnrecognizedClientException.new(double('Seahorse::Client::RequestContext'), client_error_msg) - end - let(:client_error_msg) { 'Unrecognized Client.'} - let(:invalid_cookie) { { 'HTTP_COOKIE' => 'rack.session=ApplePieBlueberries' } } - let(:invalid_session_data) { { 'rack.session' => { 'multiplier' => 1 } } } - let(:rack_default_error_msg) { "Warning! Aws::SessionStore::DynamoDB failed to save session. Content dropped.\n" } - let(:missing_key_error) { Aws::SessionStore::DynamoDB::MissingSecretKeyError } -end - RSpec.configure do |c| - c.raise_errors_for_deprecations! - c.before(:each, integration: true) do + c.before(:all, integration: true) do opts = { table_name: 'sessionstore-integration-test' } - - defaults = Aws::SessionStore::DynamoDB::Configuration::DEFAULTS - defaults = defaults.merge(opts) - stub_const('Aws::SessionStore::DynamoDB::Configuration::DEFAULTS', defaults) Aws::SessionStore::DynamoDB::Table.create_table(opts) end end diff --git a/tasks/release b/tasks/release index 2f3b049..15cde91 160000 --- a/tasks/release +++ b/tasks/release @@ -1 +1 @@ -Subproject commit 2f3b0495701fca571d99896bd072a00257b710e7 +Subproject commit 15cde91f275e893955756336e6fda0bc90d413e7