├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── shard.yml ├── spec ├── kemal-session-redis_spec.cr └── spec_helper.cr └── src ├── kemal-session-redis.cr └── kemal-session-redis └── version.cr /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: master 5 | pull_request: 6 | branches: [master] 7 | schedule: 8 | - cron: "0 0 * * 0" 9 | jobs: 10 | container-job: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ubuntu-latest, macos-latest] 15 | stable: [true] 16 | crystal: 17 | - 1.1.1 18 | - 1.2.2 19 | - 1.3.2 20 | - 1.4.1 21 | - 1.5.0 22 | include: 23 | - os: ubuntu-latest 24 | crystal: nightly 25 | stable: false 26 | - os: macos-latest 27 | crystal: nightly 28 | stable: false 29 | continue-on-error: ${{ !matrix.stable }} 30 | runs-on: ${{ matrix.os }} 31 | name: 'crystal: ${{ matrix.crystal }}, os: ${{ matrix.os }}' 32 | steps: 33 | - name: Download source 34 | uses: actions/checkout@v2 35 | - name: Install Crystal 36 | uses: crystal-lang/install-crystal@v1 37 | with: 38 | crystal: ${{ matrix.crystal }} 39 | - name: Cache shards 40 | uses: actions/cache@v2 41 | with: 42 | path: ~/.cache/shards 43 | key: ${{ runner.os }}-${{matrix.crystal}}-shards-${{ hashFiles('shard.yml') }} 44 | restore-keys: ${{ runner.os }}-shards- 45 | - name: Install shards 46 | run: shards update 47 | - name: Redis on ${{ runner.os }} 48 | uses: shogo82148/actions-setup-redis@v1 49 | id: main_redis 50 | with: 51 | redis-version: 6.2 52 | - run: redis-cli info | grep version 53 | - name: Run tests 54 | run: crystal spec 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /libs/ 3 | /lib/ 4 | /.crystal/ 5 | /.shards/ 6 | /.crystal-version 7 | /dump.rdb 8 | /shard.lock 9 | ./redis 10 | 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Rimas Silkaitis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kemal-session-redis 2 | 3 | [![CI](https://github.com/neovintage/kemal-session-redis/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/neovintage/kemal-session-redis/actions/workflows/ci.yml) 4 | 5 | Redis session store for [kemal-session](https://github.com/kemalcr/kemal-session). 6 | 7 | ## Installation 8 | 9 | Add this to your application's `shard.yml`: 10 | 11 | ```yaml 12 | dependencies: 13 | kemal-session-redis: 14 | github: neovintage/kemal-session-redis 15 | version: 1.0.1 16 | ``` 17 | 18 | ## Usage 19 | 20 | ```crystal 21 | require "kemal" 22 | require "kemal-session" 23 | require "kemal-session-redis" 24 | 25 | Kemal::Session.config do |config| 26 | config.cookie_name = "redis_test" 27 | config.secret = "a_secret" 28 | config.engine = Kemal::Session::RedisEngine.new(host: "localhost", port: 1234) 29 | config.timeout = Time::Span.new(1, 0, 0) 30 | end 31 | 32 | get "/" do 33 | puts "Hello World" 34 | end 35 | 36 | post "/sign_in" do |context| 37 | context.session.int("see-it-works", 1) 38 | end 39 | 40 | Kemal.run 41 | ``` 42 | 43 | The engine comes with a number of configuration options: 44 | 45 | | Option | Description | 46 | | ------ | ----------- | 47 | | host | where your redis instance lives | 48 | | port | assigned port for redis instance | 49 | | unixsocket | Use a socket instead of host/port. This will override host / port settings | 50 | | database | which database to use when after connecting to redis. defaults to 0 | 51 | | capacity | how many connections the connection pool should create. defaults to 20 | 52 | | timeout | how long until a connection is considered long-running. defaults to 2.0 (seconds) | 53 | | pool | an instance of `ConnectionPool(Redis)`. This overrides any setting in host or unixsocket | 54 | | key_prefix | when saving sessions to redis, how should the keys be namespaced. defaults to `kemal:session:` | 55 | 56 | When the Redis engine is instantiated and a connection pool isn't passed, 57 | RedisEngine will create a connection pool for you. The pool will have 20 connections 58 | and a timeout of 2 seconds. It's recommended that a connection pool be created 59 | to serve the wider application and then that passed to the RedisEngine initializer. 60 | 61 | If no options are passed the `RedisEngine` will try to connect to a Redis using 62 | default settings. 63 | 64 | ## Best Practices 65 | 66 | ### Creating a Client 67 | 68 | It's very easy for client code to leak Redis connections and you should 69 | pass a pool of connections that's used throughout Kemal and the 70 | session engine. 71 | 72 | ### Session Administration Performance 73 | 74 | `Kemal::Session.all` and `Kemal::Session.each` perform a bit differently under the hood. If 75 | `Kemal::Session.all` is used, the `RedisEngine` will use the `SCAN` command in Redis 76 | and page through all of the sessions, hydrating the Session object and returing 77 | an array of all sessions. If session storage has a large number of sessions this 78 | could have performance implications. `Kemal::Session.each` also uses the `SCAN` command 79 | in Redis but instead of creating one large array and enumerating through it, 80 | `Kemal::Session.each` will only hydrate and yield the keys returned from the current 81 | cursor. Once that block of sessions has been yielded, RedisEngine will retrieve 82 | the next block of sessions. 83 | 84 | ## Development 85 | 86 | Redis must be running on localhost and bound to the default port to run 87 | specs. 88 | 89 | ## Contributing 90 | 91 | 1. Fork it ( https://github.com/neovintage/kemal-session-redis/fork ) 92 | 2. Create your feature branch (git checkout -b my-new-feature) 93 | 3. Commit your changes (git commit -am 'Add some feature') 94 | 4. Push to the branch (git push origin my-new-feature) 95 | 5. Create a new Pull Request 96 | 97 | ## Contributors 98 | 99 | - [[neovintage](https://github.com/neovintage)] Rimas Silkaitis - creator, maintainer 100 | - [[crisward](https://github.com/crisward)] Cris Ward 101 | - [[fdocr](https://github.com/fdocr)] Fernando Valverde 102 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: kemal-session-redis 2 | version: 1.0.1 3 | 4 | dependencies: 5 | kemal-session: 6 | github: kemalcr/kemal-session 7 | version: ~> 1.0 8 | pool: 9 | github: ysbaddaden/pool 10 | version: 0.2.3 11 | redis: 12 | github: stefanwille/crystal-redis 13 | version: ~> 2.8.3 14 | 15 | authors: 16 | - Rimas Silkaitis 17 | 18 | license: MIT 19 | -------------------------------------------------------------------------------- /spec/kemal-session-redis_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe "Kemal::Session::RedisEngine" do 4 | describe ".new" do 5 | it "can be set up with no params" do 6 | redis = Kemal::Session::RedisEngine.new 7 | redis.should_not be_nil 8 | end 9 | 10 | it "can be set up with a connection pool" do 11 | pool = ConnectionPool.new(capacity: 1, timeout: 2.0) do 12 | Redis.new 13 | end 14 | redis = Kemal::Session::RedisEngine.new(pool: pool) 15 | redis.should_not be_nil 16 | end 17 | end 18 | 19 | describe ".int" do 20 | it "can save a value" do 21 | session = Kemal::Session.new(create_context(SESSION_ID)) 22 | session.int("int", 12) 23 | end 24 | 25 | it "can retrieve a saved value" do 26 | session = Kemal::Session.new(create_context(SESSION_ID)) 27 | session.int("int", 12) 28 | session.int("int").should eq 12 29 | end 30 | end 31 | 32 | describe ".bool" do 33 | it "can save a value" do 34 | session = Kemal::Session.new(create_context(SESSION_ID)) 35 | session.bool("bool", true) 36 | end 37 | 38 | it "can retrieve a saved value" do 39 | session = Kemal::Session.new(create_context(SESSION_ID)) 40 | session.bool("bool", true) 41 | session.bool("bool").should eq true 42 | end 43 | end 44 | 45 | describe ".float" do 46 | it "can save a value" do 47 | session = Kemal::Session.new(create_context(SESSION_ID)) 48 | session.float("float", 3.00) 49 | end 50 | 51 | it "can retrieve a saved value" do 52 | session = Kemal::Session.new(create_context(SESSION_ID)) 53 | session.float("float", 3.00) 54 | session.float("float").should eq 3.00 55 | end 56 | end 57 | 58 | describe ".string" do 59 | it "can save a value" do 60 | session = Kemal::Session.new(create_context(SESSION_ID)) 61 | session.string("string", "kemal") 62 | end 63 | 64 | it "can retrieve a saved value" do 65 | session = Kemal::Session.new(create_context(SESSION_ID)) 66 | session.string("string", "kemal") 67 | session.string("string").should eq "kemal" 68 | end 69 | end 70 | 71 | describe ".object" do 72 | it "can be saved and retrieved" do 73 | session = Kemal::Session.new(create_context(SESSION_ID)) 74 | u = UserJsonSerializer.new(123, "charlie") 75 | session.object("user", u) 76 | new_u = session.object("user").as(UserJsonSerializer) 77 | new_u.id.should eq(123) 78 | new_u.name.should eq("charlie") 79 | end 80 | end 81 | 82 | describe ".destroy" do 83 | it "should remove session from redis" do 84 | session = Kemal::Session.new(create_context(SESSION_ID)) 85 | value = REDIS.get("kemal:session:#{SESSION_ID}") 86 | value.should_not be_nil 87 | session.destroy 88 | value = REDIS.get("kemal:session:#{SESSION_ID}") 89 | value.should be_nil 90 | end 91 | end 92 | 93 | describe "#destroy" do 94 | it "should remove session from redis" do 95 | session = Kemal::Session.new(create_context(SESSION_ID)) 96 | value = REDIS.get("kemal:session:#{SESSION_ID}") 97 | value.should_not be_nil 98 | Kemal::Session.destroy(SESSION_ID) 99 | value = REDIS.get("kemal:session:#{SESSION_ID}") 100 | value.should be_nil 101 | end 102 | 103 | it "should succeed if session doesnt exist in redis" do 104 | session = Kemal::Session.new(create_context(SESSION_ID)) 105 | value = REDIS.get("kemal:session:#{SESSION_ID}") 106 | value.should_not be_nil 107 | if value 108 | Kemal::Session.destroy(SESSION_ID) 109 | Kemal::Session.get(SESSION_ID).should be_nil 110 | end 111 | end 112 | end 113 | 114 | describe "#destroy_all" do 115 | it "should remove all sessions in redis" do 116 | 5.times { Kemal::Session.new(create_context(Random::Secure.hex)) } 117 | arr = Kemal::Session.all 118 | arr.size.should eq(5) 119 | Kemal::Session.destroy_all 120 | Kemal::Session.all.size.should eq(0) 121 | end 122 | end 123 | 124 | describe "#get" do 125 | it "should return a valid Kemal::Session" do 126 | session = Kemal::Session.new(create_context(SESSION_ID)) 127 | get_session = Kemal::Session.get(SESSION_ID) 128 | get_session.should_not be_nil 129 | if get_session 130 | session.id.should eq(get_session.id) 131 | get_session.is_a?(Kemal::Session).should be_true 132 | end 133 | end 134 | 135 | it "should return nil if the Kemal::Session does not exist" do 136 | session = Kemal::Session.get(SESSION_ID) 137 | session.should be_nil 138 | end 139 | end 140 | 141 | describe "#create" do 142 | it "should build an empty session" do 143 | Kemal::Session.config.engine.create_session(SESSION_ID) 144 | value = REDIS.get("kemal:session:#{SESSION_ID}") 145 | value.should_not be_nil 146 | end 147 | end 148 | 149 | describe "#all" do 150 | it "should return an empty array if none exist" do 151 | arr = Kemal::Session.all 152 | arr.is_a?(Array).should be_true 153 | arr.size.should eq(0) 154 | end 155 | 156 | it "should return an array of Kemal::Sessions" do 157 | 3.times { Kemal::Session.new(create_context(Random::Secure.hex)) } 158 | arr = Kemal::Session.all 159 | arr.is_a?(Array).should be_true 160 | arr.size.should eq(3) 161 | end 162 | end 163 | 164 | describe "#each" do 165 | it "should iterate over all sessions" do 166 | 5.times { Kemal::Session.new(create_context(Random::Secure.hex)) } 167 | count = 0 168 | Kemal::Session.each do |session| 169 | count = count + 1 170 | end 171 | count.should eq(5) 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "io" 3 | require "../src/kemal-session-redis" 4 | 5 | Kemal::Session.config.secret = "super-awesome-secret" 6 | Kemal::Session.config.engine = Kemal::Session::RedisEngine.new 7 | 8 | REDIS = Redis.new 9 | SESSION_ID = Random::Secure.hex 10 | 11 | Spec.before_each do 12 | REDIS.flushall 13 | end 14 | 15 | def create_context(session_id : String) 16 | response = HTTP::Server::Response.new(IO::Memory.new) 17 | headers = HTTP::Headers.new 18 | 19 | # I would rather pass nil if no cookie should be created 20 | # but that throws an error 21 | unless session_id == "" 22 | Kemal::Session.config.engine.create_session(session_id) 23 | cookies = HTTP::Cookies.new 24 | cookies << HTTP::Cookie.new(Kemal::Session.config.cookie_name, Kemal::Session.encode(session_id)) 25 | cookies.add_request_headers(headers) 26 | end 27 | 28 | request = HTTP::Request.new("GET", "/", headers) 29 | return HTTP::Server::Context.new(request, response) 30 | end 31 | 32 | class UserJsonSerializer 33 | include JSON::Serializable 34 | include Kemal::Session::StorableObject 35 | 36 | property id : Int32 37 | property name : String 38 | 39 | def initialize(@id : Int32, @name : String); end 40 | end 41 | -------------------------------------------------------------------------------- /src/kemal-session-redis.cr: -------------------------------------------------------------------------------- 1 | require "uri" 2 | require "json" 3 | require "redis" 4 | require "pool/connection" 5 | require "kemal-session" 6 | 7 | module Kemal 8 | class Session 9 | class RedisEngine < Engine 10 | class StorageInstance 11 | include JSON::Serializable 12 | 13 | macro define_storage(vars) 14 | {% for name, type in vars %} 15 | @[JSON::Field(key: {{name.id}})] 16 | getter {{name.id}}s : Hash(String, {{type}}) 17 | 18 | def {{name.id}}(k : String) : {{type}} 19 | return @{{name.id}}s[k] 20 | end 21 | 22 | def {{name.id}}?(k : String) : {{type}}? 23 | return @{{name.id}}s[k]? 24 | end 25 | 26 | def {{name.id}}(k : String, v : {{type}}) 27 | @{{name.id}}s[k] = v 28 | end 29 | {% end %} 30 | 31 | def initialize 32 | {% for name, type in vars %} 33 | @{{name.id}}s = Hash(String, {{type}}).new 34 | {% end %} 35 | end 36 | end 37 | 38 | define_storage({ 39 | int: Int32, 40 | bigint: Int64, 41 | string: String, 42 | float: Float64, 43 | bool: Bool, 44 | object: Kemal::Session::StorableObject::StorableObjectContainer 45 | }) 46 | end 47 | 48 | @redis : ConnectionPool(Redis) 49 | @cache : StorageInstance 50 | @cached_session_id : String 51 | 52 | def initialize(host = "localhost", port = 6379, password = nil, database = 0, capacity = 20, timeout = 2.0, unixsocket = nil, pool = nil, key_prefix = "kemal:session:") 53 | @redis = uninitialized ConnectionPool(Redis) 54 | 55 | if pool.nil? 56 | @redis = ConnectionPool.new(capacity: capacity, timeout: timeout) do 57 | Redis.new( 58 | host: host, 59 | port: port, 60 | database: database, 61 | unixsocket: unixsocket, 62 | password: password 63 | ) 64 | end 65 | else 66 | @redis = pool.as(ConnectionPool(Redis)) 67 | end 68 | 69 | @cache = Kemal::Session::RedisEngine::StorageInstance.new 70 | @key_prefix = key_prefix 71 | @cached_session_id = "" 72 | end 73 | 74 | def run_gc 75 | # Do Nothing. All the sessions should be set with the 76 | # expiration option on the keys. So long as the redis instance 77 | # hasn't been set up with maxmemory policy of noeviction 78 | # then this should be fine. `noeviction` will cause the redis 79 | # instance to fill up and keys will not expire from the instance 80 | end 81 | 82 | def prefix_session(session_id : String) 83 | "#{@key_prefix}#{session_id}" 84 | end 85 | 86 | def parse_session_id(key : String) 87 | key.sub(@key_prefix, "") 88 | end 89 | 90 | def load_into_cache(session_id) 91 | @cached_session_id = session_id 92 | conn = @redis.checkout 93 | value = conn.get(prefix_session(session_id)) 94 | if !value.nil? 95 | @cache = Kemal::Session::RedisEngine::StorageInstance.from_json(value) 96 | else 97 | @cache = StorageInstance.new 98 | conn.set( 99 | prefix_session(session_id), 100 | @cache.to_json, 101 | ex: Kemal::Session.config.timeout.total_seconds.to_i 102 | ) 103 | end 104 | @redis.checkin(conn) 105 | return @cache 106 | end 107 | 108 | def save_cache 109 | conn = @redis.checkout 110 | conn.set( 111 | prefix_session(@cached_session_id), 112 | @cache.to_json, 113 | ex: Kemal::Session.config.timeout.total_seconds.to_i 114 | ) 115 | @redis.checkin(conn) 116 | end 117 | 118 | def is_in_cache?(session_id) 119 | return session_id == @cached_session_id 120 | end 121 | 122 | def create_session(session_id : String) 123 | load_into_cache(session_id) 124 | end 125 | 126 | def get_session(session_id : String) : (Kemal::Session | Nil) 127 | conn = @redis.checkout 128 | value = conn.get(prefix_session(session_id)) 129 | @redis.checkin(conn) 130 | 131 | return Kemal::Session.new(session_id) if value 132 | nil 133 | end 134 | 135 | def destroy_session(session_id : String) 136 | conn = @redis.checkout 137 | conn.del(prefix_session(session_id)) 138 | @redis.checkin(conn) 139 | end 140 | 141 | def destroy_all_sessions 142 | conn = @redis.checkout 143 | 144 | cursor = 0 145 | loop do 146 | cursor, keys = conn.scan(cursor, "#{@key_prefix}*") 147 | keys = keys.as(Array(Redis::RedisValue)).map(&.to_s) 148 | keys.each do |key| 149 | conn.del(key) 150 | end 151 | break if cursor == "0" 152 | end 153 | 154 | @redis.checkin(conn) 155 | end 156 | 157 | def all_sessions : Array(Kemal::Session) 158 | arr = [] of Kemal::Session 159 | 160 | each_session do |session| 161 | arr << session 162 | end 163 | 164 | return arr 165 | end 166 | 167 | def each_session 168 | conn = @redis.checkout 169 | 170 | cursor = 0 171 | loop do 172 | cursor, keys = conn.scan(cursor, "#{@key_prefix}*") 173 | keys = keys.as(Array(Redis::RedisValue)).map(&.to_s) 174 | keys.each do |key| 175 | yield Kemal::Session.new(parse_session_id(key.as(String))) 176 | end 177 | break if cursor == "0" 178 | end 179 | 180 | @redis.checkin(conn) 181 | end 182 | 183 | macro define_delegators(vars) 184 | {% for name, type in vars %} 185 | def {{name.id}}(session_id : String, k : String) : {{type}} 186 | load_into_cache(session_id) unless is_in_cache?(session_id) 187 | return @cache.{{name.id}}(k) 188 | end 189 | 190 | def {{name.id}}?(session_id : String, k : String) : {{type}}? 191 | load_into_cache(session_id) unless is_in_cache?(session_id) 192 | return @cache.{{name.id}}?(k) 193 | end 194 | 195 | def {{name.id}}(session_id : String, k : String, v : {{type}}) 196 | load_into_cache(session_id) unless is_in_cache?(session_id) 197 | @cache.{{name.id}}(k, v) 198 | save_cache 199 | end 200 | 201 | def {{name.id}}s(session_id : String) : Hash(String, {{type}}) 202 | load_into_cache(session_id) unless is_in_cache?(session_id) 203 | return @cache.{{name.id}}s 204 | end 205 | {% end %} 206 | end 207 | 208 | define_delegators({ 209 | int: Int32, 210 | bigint: Int64, 211 | string: String, 212 | float: Float64, 213 | bool: Bool, 214 | object: Kemal::Session::StorableObject::StorableObjectContainer, 215 | }) 216 | end 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /src/kemal-session-redis/version.cr: -------------------------------------------------------------------------------- 1 | module Kemal::Session::Redis 2 | VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify }} 3 | end 4 | --------------------------------------------------------------------------------