1.4.1

Session Adapters

Session adapters provide the storage backend for user sessions. Amber 2.0 includes a memory adapter by default, and you can implement custom adapters for any storage backend.

Session Adapter Interface

All session adapters must implement the abstract SessionAdapter class:

abstract class Amber::Adapters::SessionAdapter
  abstract def get(session_id : String) : String?
  abstract def set(session_id : String, value : String) : Nil
  abstract def delete(session_id : String) : Nil
  abstract def destroy(session_id : String) : Nil
  abstract def exists?(session_id : String) : Bool
end

Built-in Memory Adapter

The MemorySessionAdapter is the default:

# Automatically used when adapter: "memory"
class Amber::Adapters::MemorySessionAdapter < SessionAdapter
  # Thread-safe in-memory storage
  # Automatic expiration cleanup
end

Creating a Custom Adapter

Database Session Adapter Example

# src/adapters/database_session_adapter.cr
require "amber"

class DatabaseSessionAdapter < Amber::Adapters::SessionAdapter
  def initialize(@connection : DB::Database)
  end

  def get(session_id : String) : String?
    result = @connection.query_one?(
      "SELECT data FROM sessions WHERE id = $1 AND expires_at > NOW()",
      session_id,
      as: String
    )
    result
  end

  def set(session_id : String, value : String) : Nil
    expires_at = Time.utc + session_ttl
    @connection.exec(
      "INSERT INTO sessions (id, data, expires_at) VALUES ($1, $2, $3)
       ON CONFLICT (id) DO UPDATE SET data = $2, expires_at = $3",
      session_id, value, expires_at
    )
  end

  def delete(session_id : String) : Nil
    @connection.exec("DELETE FROM sessions WHERE id = $1", session_id)
  end

  def destroy(session_id : String) : Nil
    delete(session_id)
  end

  def exists?(session_id : String) : Bool
    @connection.query_one?(
      "SELECT 1 FROM sessions WHERE id = $1 AND expires_at > NOW()",
      session_id,
      as: Int32
    ).present?
  end

  private def session_ttl
    Amber.settings.session["expires"].as_i.seconds
  end
end

Redis Session Adapter Example

# src/adapters/redis_session_adapter.cr
require "redis"

class RedisSessionAdapter < Amber::Adapters::SessionAdapter
  def initialize(@redis : Redis::PooledClient)
  end

  def get(session_id : String) : String?
    @redis.get(key(session_id))
  end

  def set(session_id : String, value : String) : Nil
    @redis.setex(key(session_id), session_ttl, value)
  end

  def delete(session_id : String) : Nil
    @redis.del(key(session_id))
  end

  def destroy(session_id : String) : Nil
    delete(session_id)
  end

  def exists?(session_id : String) : Bool
    @redis.exists(key(session_id)) > 0
  end

  private def key(session_id : String)
    "session:#{session_id}"
  end

  private def session_ttl
    Amber.settings.session["expires"].as_i
  end
end

Registering Custom Adapters

Register your adapter with the AdapterFactory:

# config/initializers/adapters.cr
require "../src/adapters/database_session_adapter"
require "../src/adapters/redis_session_adapter"

# Register database adapter
Amber::Adapters::AdapterFactory.register_session_adapter("database") do
  DatabaseSessionAdapter.new(AppDatabase.connection)
end

# Register Redis adapter
Amber::Adapters::AdapterFactory.register_session_adapter("redis") do
  redis = Redis::PooledClient.new(url: ENV["REDIS_URL"])
  RedisSessionAdapter.new(redis)
end

Configuration

Update your environment configuration:

# config/environments/production.yml
session:
  key: "myapp.session"
  adapter: "database"  # Use your registered adapter
  expires: 86400

Database Schema

For database-backed sessions, create a migrations:

-- db/migrations/create_sessions.sql
CREATE TABLE sessions (
  id VARCHAR(64) PRIMARY KEY,
  data TEXT NOT NULL,
  expires_at TIMESTAMP NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_sessions_expires ON sessions(expires_at);

Session Cleanup

For database adapters, implement periodic cleanup:

# lib/tasks/cleanup_sessions.cr
desc "Clean expired sessions"
task :cleanup_sessions do
  AppDatabase.connection.exec(
    "DELETE FROM sessions WHERE expires_at < NOW()"
  )
  puts "Expired sessions cleaned up"
end

Schedule this with cron:

# Clean sessions every hour
0 * * * * cd /app && crystal lib/tasks/cleanup_sessions.cr

Testing with Adapters

Use memory adapter for fast tests:

# spec/spec_helper.cr
Amber.settings.session["adapter"] = "memory"

Or mock the adapter:

# spec/support/mock_session_adapter.cr
class MockSessionAdapter < Amber::Adapters::SessionAdapter
  property sessions = {} of String => String

  def get(session_id : String) : String?
    sessions[session_id]?
  end

  def set(session_id : String, value : String) : Nil
    sessions[session_id] = value
  end

  def delete(session_id : String) : Nil
    sessions.delete(session_id)
  end

  def destroy(session_id : String) : Nil
    delete(session_id)
  end

  def exists?(session_id : String) : Bool
    sessions.has_key?(session_id)
  end
end